20 Commits
Author SHA1 Message Date
arnef 1f383878ab refactor(web): simplify address to single free-form value
Docker Image bauen und veröffentlichen / docker (push) Canceled after 26s
2026-09-01 07:02:40 +02:00
arnef c945e785c8 feat(web): split contact name into first and last name 2026-08-31 22:08:53 +02:00
arnef ea917b4148 feat(web): add file sorting to files browser 2026-08-31 05:57:35 +02:00
arnef 995441e917 docs: update versioning scheme in README 2026-08-30 20:55:21 +02:00
arnef 36e3fecc11 feat: allow dots in resource/folder names 2026-08-30 20:16:05 +02:00
arnef 6a95e27e31 chore: remove example config file 2026-08-30 19:59:04 +02:00
arnef e8c4dadfea chore: fmt code 2026-08-30 19:58:06 +02:00
arnef 2e43e32a9f refactor: clean up nidusctl CLI 2026-08-30 19:54:34 +02:00
arnef 9b9cbe2f6f refactor: update migration logic 2026-08-30 19:54:34 +02:00
arnef 0ee0874458 refactor: streamline session management 2026-08-30 19:54:34 +02:00
arnef b364bee265 refactor: simplify config validation 2026-08-30 19:54:34 +02:00
arnef dd77a61b18 refactor: unify config and data structures 2026-08-30 19:54:34 +02:00
arnef d3780e9e32 refactor: align import paths with module name 2026-08-30 18:51:45 +02:00
arnef e96a0b8c92 Merge pull request 'feat: implement unified directory structure with automatic migration for CalDAV and CardDAV' (#4) from bug/3 into main
Reviewed-on: #4

closes #3
2026-08-30 12:04:12 +00:00
arnef 0567fea729 docs: remove CHANGES_SUMMARY.md (superseded by AGENTS.md) 2026-08-30 13:57:16 +02:00
arnef 5af3a8508b docs: update AGENTS.md with current commands and features
- Add web-deps, nidusctl, and migrate commands to build section
- Document ICS/webcal subscriptions and Birthdays calendar
- Note Migrate() is idempotent
- Add additional database tables
- Include Docker/CI and database migration details
2026-08-30 13:54:55 +02:00
arnef 8d2d8d3972 fix: update web files root to use unified path structure 2026-08-30 13:04:40 +02:00
arnef 2fd39c8180 refactor: unify data directory structure
Unify user data directory from fragmented layout to consistent nested format:

- Move WebDAV from: data/files/<username> → data/<username>/files/
- Move CalDAV from: data/<username>/cal-<name> → data/<username>/calendars/<name>
- Move CardDAV from: data/<username>/card-<name> → data/<username>/addressbooks/<name>

Changes:
- internal/store/store.go: Update collectionPath() to map collection names
- internal/store/migrate.go: Add idempotent Migrate() method
- internal/store/migrate_test.go: Comprehensive migration tests
- internal/webdav/handler.go: Use new unified path structure
- cmd/server/main.go: Auto-run migration on startup
- tools/nidusctl/main.go: Add migrate subcommand
- Update tests to verify new structure

URL endpoints unchanged - only on-disk structure modified. All tests pass.
2026-08-30 12:15:31 +02:00
arnefandCopilot f463c01f0f Add a standalone docker-compose.yml example using the pre-built image
Complements the existing "build: ." example (for a full checkout) with
a minimal compose file that just pulls git.arnef.de/arnef/nidus:latest,
for users who only want to run the container without cloning the repo.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-21 22:00:20 +02:00
arnefandCopilot cd0761c9d1 Remove TLS/Reverse proxy and Dependencies sections from README
Both were low-value docs (TLS config is already in the Configuration
reference table; Go/JS dependencies are visible in go.mod and
web/package.json) that just added noise.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-21 21:59:05 +02:00
48 changed files with 1766 additions and 916 deletions
+239
View File
@@ -0,0 +1,239 @@
# Copilot Instructions for nidus
A self-hosted CalDAV, CardDAV, and WebDAV server written in Go, backed by a
filesystem store, with calendar/address book sharing grants tracked in a
small SQLite database. HTTP Basic Auth (bcrypt) with per-user isolated
collections. A small server-rendered web UI (templ + Tailwind + htmx) at
`/web/` lets users log in and manage their shares.
## Build, test, lint
```bash
make build # go build -o bin/davserver ./cmd/server + ./bin/nidusctl
make run # build + ./bin/davserver -config config.yaml
make test # go test ./... -v -race
make lint # golangci-lint run ./... (no config file; uses defaults)
make tidy # go mod tidy
make web-deps # install Tailwind CLI + TypeScript (needed once, or after web/package.json changes)
make templ-generate # regenerate *_templ.go after editing internal/web/templates/*.templ
make web-css # templ-generate + rebuild web/static/app.css
make web-ts # compile web/ts/*.ts to web/static/*.js
make web-assets # web-css + web-ts (everything under web/static/)
make nidusctl # build + run nidusctl with ARGS (e.g. `make nidusctl ARGS="user list"`)
go run ./tools/migrate -config config.yaml # restructure data directory
```
Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`,
`internal/db/shares_test.go`, `internal/caldav/backend_test.go`,
`internal/carddav/backend_test.go`, `internal/web/server_test.go`,
`tools/nidusctl/main_test.go`.
## Architecture
- `cmd/server/main.go` — entrypoint. Loads config, builds the `slog.Logger`,
constructs the `store.Store` and `db.DB`, lists all users/calendars/
address-books from the DB (`dbase.ListUsers`/`ListCalendars`/
`ListAddressBooks`) to pre-create their collections on disk, warns if
zero users exist (`nidusctl user create ...`), wires up `auth.Middleware`,
and builds the `http.ServeMux` (`buildMux`). Routes: `/cal/`, `/card/`,
`/files/`, `/.well-known/{caldav,carddav}`, `/healthz` (unauthenticated),
and `/` (welcome page on GET/HEAD only; any other method — e.g. a WebDAV
client pointed at the wrong URL — gets `405` instead of a misleading
`200`).
- `internal/config` — YAML config loading (`config.Load`), defaults
(`applyDefaults`), and validation (`validate`). Holds only server/auth/
storage/logging/TLS settings — **no user, calendar, or address-book
data**; all of that lives in `internal/db` now (see below).
- `internal/auth` — HTTP Basic Auth middleware (`auth.Middleware.Wrap`).
Takes a `*db.DB` and validates credentials via `dbase.GetUser` +
`dbase.VerifyPassword` (bcrypt), then stores a
`*Principal{Username, DisplayName, Email}` in the request context.
Downstream code retrieves it with `auth.FromContext(ctx)` — every backend
method needs this and returns `webdav.NewHTTPError(http.StatusUnauthorized, ...)`
if it's nil. `auth.NewContext(ctx, p)` is the test-only inverse, used to
build authenticated contexts without a real Basic Auth handshake.
- `internal/store` — the single source of truth for all persisted data.
A thin filesystem KV abstraction: `<data_dir>/<user>/[calendars|addressbooks|files]/<name>`.
All collection/object names pass through `sanitize()` (via
`filepath.Base` + strip `..`) to prevent path traversal — preserve this
when adding new store methods. Writes use temp-file + rename for atomicity
(`PutObject`). Locking is sharded per-user (`lockFor(user)`, a
`map[string]*sync.RWMutex` guarded by its own mutex) rather than one
global lock, so different users' requests don't serialize against each
other. `Migrate()` restructures data from the old format (`cal-<name>`,
`card-<name>`) to the new unified layout and is idempotent.
- `internal/caldav` and `internal/carddav` — implement the
`caldav.Backend`/`carddav.Backend` interfaces from `github.com/emersion/go-webdav`
on top of `store.Store`. Calendars are stored as collections prefixed
`cal-<name>` and address books as `card-<name>` (see `ListCalendars`,
`parseCalPath`). **The URL scheme has no username segment**, but DOES
have a fixed literal `home` segment: `/cal/` (principal), `/cal/home/`
(calendar-home-set), `/cal/home/<calname>/` (calendar),
`/cal/home/<calname>/<objid>` (object) — and equivalently
`/card/`, `/card/home/`, `/card/home/<bookname>/`,
`/card/home/<bookname>/<objid>` for carddav. These are identical for
every user; the acting user always comes from `auth.FromContext(ctx)`,
never from the path. **The `home` segment is load-bearing, not
cosmetic**: go-webdav's `caldav`/`carddav` server (in the
`github.com/emersion/go-webdav` dependency, not our code) classifies
each request purely by counting URL path segments relative to the
handler's `Prefix` (which we leave `""`) — 1 segment = user principal, 2
= home-set, 3 = calendar/address book, 4 = object. If the segment counts
don't line up (e.g. removing `home` would make `/cal/` and
`/cal/<calname>/` collapse to 1 and 2 segments, misclassifying the
calendar collection itself as the home-set), PROPFIND requests silently
return an empty `<multistatus>` (200/207, zero `<response>` elements) —
no error, just nothing found, which breaks client auto-discovery (e.g.
DAVx5 reporting "no resources found"). Keep this in mind when touching
`parseCalPath`/`parseObjPath`/`parseBookPath`,
`calHomePath`/`cardHomePath`, or `CurrentUserPrincipal` — always
preserve the exact segment depth at each level. Query methods
(`QueryCalendarObjects`) currently list all objects and filter in-memory
via `caldav.Filter` — fine for small collections, not optimized for
scale.
**Sharing**: both backends take an `*db.DB` (required — used for both
the base `ListCalendars`/`ListAddressBooks`/`Create*`/`Delete*`
operations and sharing). A calendar/address book shared with a user is exposed under
the synthetic local name `<owner>~<name>` (see `sharedNameSep`,
`sharedCalendarName`/`sharedBookName`) in that user's own home-set —
`resolveCalendar`/`resolveBook` split the local name back into
owner+real name and check the grant's permission (`db.PermRead`/
`db.PermWrite`) via `dbase.CalendarShareFor`/`AddressBookShareFor`
before allowing reads (any share) or writes (write share only). The
shared data is never copied — it's read/written directly under the
owner's own `store.Store` namespace, just addressed via the synthetic
name from the grantee's requests.
**ICSSubscriptions**: read-only calendars backed by remote ICS/webcal
URLs (see `db.CreateICSSubscription`, `internal/db/ics.go`) are exposed
alongside real calendars under `/cal/home/<name>/` (shared namespace;
see `internal/caldav/ics.go`). `Birthdays` is a computed calendar
synthesized from contacts' BDAY fields (see `internal/caldav/birthdays.go`,
`internal/birthdays/birthdays.go`).
- `internal/db` — a small `database/sql` wrapper around
`modernc.org/sqlite` (pure Go, no CGO) at `<data_dir>/nidus.db`. Only
one open connection is used (`SetMaxOpenConns(1)`) since SQLite allows a
single writer; this is intentionally simple and not meant to scale to
heavy concurrent write load. Schema lives in `migrate()`; there's no
migration framework, just idempotent `CREATE TABLE IF NOT EXISTS`.
Foreign keys are enabled per-connection (`?_pragma=foreign_keys(1)` in
the DSN). **This is now the single source of truth for users,
calendars, and address books** (`internal/db/users.go`):
`CreateUser`/`SetPassword`/`DeleteUser`/`GetUser`/`VerifyPassword`/
`ListUsers`, and `CreateCalendar`/`DeleteCalendar`/`ListCalendars`,
`CreateAddressBook`/`DeleteAddressBook`/`ListAddressBooks`. `users` is
the parent table; `calendars`/`addressbooks` cascade-delete via FK on
`DeleteUser`; `calendar_shares`/`addressbook_shares`/`web_sessions`
reference usernames as plain strings (no FK) so `DeleteUser` explicitly
cleans those up in a transaction. `modernc.org/sqlite` has no typed
unique-constraint error, so `isUniqueConstraintErr()` string-matches the
driver's error message.
**Additional tables**: `birthday_calendars` (per-user display color for
the computed Birthdays calendar), `ics_subscriptions` (remote ICS/webcal
calendar subscriptions), `web_sessions` (server-side session tokens).
- `internal/webdav` — plain-file WebDAV via `golang.org/x/net/webdav`,
mounted at the single fixed URL `/files/` for all users (no username in
the path either). `NewHandler` caches one `*xwebdav.Handler` per
authenticated username (keyed off `auth.FromContext`), each rooted at
`<data_dir>/files/<username>/` on disk with its own persistent
`LockSystem` — the handler (and its lock table) must be created once and
reused, not per-request, or LOCK/UNLOCK state resets on every call.
- `tools/hashpwd` — standalone CLI (`go run ./tools/hashpwd <password>`) to
generate bcrypt hashes for ad-hoc testing (no longer needed for normal
user setup — see `nidusctl user create` below).
- `tools/migrate` — restructures data directory from old format (`cal-<name>`,
`card-<name>`) to unified layout (`calendars/`, `addressbooks/`,
`files/`). Run automatically on server startup; invoke manually with
`go run ./tools/migrate -config config.yaml`.
- `tools/nidusctl` — standalone admin CLI (`go run ./tools/nidusctl
-config config.yaml <user|calendar|addressbook> ...`) — the only way to
create/delete users, calendars, and address books, plus manage sharing
grants (`calendar|addressbook share|unshare|shares`). It's a thin
argv-parsing wrapper around `db.DB`'s methods (`tools/nidusctl/main.go`
routes subcommands, `tools/nidusctl/users.go` implements `user
create/delete/list/passwd` with interactive masked password prompting
via `golang.org/x/term`, falling back to a plain stdin read when not a
TTY) — no server interaction, no daemon, no RPC; it just opens the same
SQLite file the running server uses. Changes take effect immediately
without restarting the server since nothing is cached.
- `internal/web` — the web UI, mounted at `/web/` in `cmd/server/main.go`
(`mux.Handle("/web/", http.StripPrefix("/web", web.NewServer(cfg, st,
dbase, logger).Handler(webstatic.FS())))`, so `Server.Handler`'s own
routes are all unprefixed — `/login`, `/`, `/shares/...` — and only the
outer mux adds the `/web` prefix), entirely separate from `internal/auth`'s
Basic Auth: logins go through
`/web/login` (username/password checked against the DB the same way
Basic Auth does, via `dbase.VerifyPassword`) and issue an opaque random session token
stored in the `web_sessions` SQLite table (`db.CreateSession`/
`SessionUser`/`DeleteSession`, see `internal/db/sessions.go`), set as an
`HttpOnly` cookie (`sessionCookieName` in `internal/web/session.go`).
`requireLogin` is the auth-guard middleware for authenticated routes,
storing the username in the request context (`userFromContext`).
`internal/web/dashboard.go` renders the logged-in user's own
calendars/address books plus who they're shared with
(`SharesOfCalendar`/`SharesOfAddressBook`) and what's shared with them
(`CalendarsSharedWith`/`AddressBooksSharedWith`); `resourceCards(username)`
is the shared helper (also used by `internal/web/resources.go`, see
below) that builds the list of `templates.ResourceCard`s from the DB.
`internal/web/resources.go` handles POST (create) and DELETE (delete) at
`/web/resources/{calendar,addressbook}`, validating names against
`resourceNameRe` (`^[a-zA-Z0-9_-]{1,64}$`) and re-rendering the whole
`#resources` list (`templates.ResourceList`) since the *set* of cards
changes (unlike a share update, which only touches one card).
`internal/web/shares.go`
handles POST (create/update share) and DELETE (revoke) at
`/web/shares/{calendar,addressbook}`, re-rendering just the affected
resource card for htmx's `hx-swap="outerHTML"`; it always checks
`ownsResource` first so a user can only share resources actually
configured for their own account (never someone else's, even via a
forged form post). **htmx v2 quirk**: `hx-delete` requests send
`hx-vals`/form params as URL **query string** parameters, not a request
body (unlike POST/PUT/PATCH) — `handleShare` special-cases
`r.Method == http.MethodDelete` to read from `r.URL.Query()` instead of
calling `r.ParseForm()`. Templates live in `internal/web/templates/*.templ`
(compiled to `*_templ.go` via `templ generate`/`make templ-generate` —
regenerate after editing any `.templ` file, the generated files are
committed). Styling is Tailwind v4, scanned directly over the generated
`_templ.go` files (`web/input.css`'s `@source` directives) and compiled
to `web/static/app.css` via `make web-css` (needs Node/npm — see
`web/package.json`); htmx itself is vendored as a static file
(`web/static/htmx.min.js`, not npm-installed) to avoid a CDN dependency.
Client-side-only logic (currently just the login page's password-visibility
toggle) is written in TypeScript under `web/ts/*.ts`, compiled to plain
JS via `tsc` (`web/tsconfig.json`, `make web-ts`) into `web/static/*.js`
as ES modules (`<script type="module">`). All static assets (CSS, JS,
vendored htmx) are embedded into the Go binary at build time via
`web/staticassets.go` (`//go:embed static`), so the compiled server has
no runtime dependency on Node.js or the `web/` directory being present —
Node/npm are only needed when actually changing templates/styles/TS
(`make web-assets` rebuilds everything under `web/static/`).
**Web UI features**: dashboard (calendars/address books/ICS subscriptions
management), login (cookie-based sessions), share management (create/update/
revoke share grants via htmx), files browser (inline preview, upload, download),
contacts manager (vCard import/export, edit fields), calendar view (month/week,
per-calendar colors), account settings (name/email/password). The
synthetic `Birthdays` calendar (computed from contacts' BDAY fields) and
ICS/webcal subscriptions are managed via the web UI just like real
calendars.
## Conventions
- **No username in any DAV URL** (`/cal/`, `/card/`, `/files/` are the same
for every account) — the acting user is always resolved from the Basic
Auth identity (`auth.FromContext`), never parsed out of the request path.
Don't reintroduce a `<user>` path segment when adding routes/paths.
- Path parsing in the CalDAV/CardDAV backends assumes fixed URL segment
positions (e.g. `cal/home/<calname>/<objid>`) split on `/` — see
`parseCalPath`/`parseObjPath`/`parseBookPath`. The `home` segment must
stay exactly one fixed literal segment (see note above on go-webdav's
segment-count-based resource classification) — don't remove it or add/
remove segments elsewhere without re-checking all four resource-type
depths still line up. New path-based operations should follow the same
segment-index approach for consistency.
- Store errors are sentinel values (`store.ErrNotFound`, `store.ErrConflict`)
checked with `errors.Is`/direct comparison; backends translate them into
`webdav.NewHTTPError` with the appropriate HTTP status.
- Logging uses `log/slog` structured fields (e.g. `logger.Warn("...", "user", u, "error", err)`), passed down explicitly to every constructor (`NewBackend`, `NewHandler`, `NewMiddleware`) rather than a global logger.
- Config module path is `github.com/yourusername/caldav-server` (go.mod name
predates the `nidus` repo rename) — import paths still use this, not `nidus`.
- **Docker/CI**: Docker image pushed to `git.arnef.de/arnef/nidus` on tags (`v*`) or releases (`.github/workflows/docker-release.yml`), multi-arch (`linux/amd64`, `linux/arm64`). Use `docker-compose` for local dev with healthcheck on `/healthz`.
- **Database schema** lives in `internal/db/db.go` `migrate()` — all tables are created with `CREATE TABLE IF NOT EXISTS` and schema changes are applied via `ALTER TABLE` in `migrateAddColumns()`. No external migration framework.
+9 -6
View File
@@ -18,13 +18,16 @@ WORKDIR /app
COPY --from=builder /bin/davserver /usr/local/bin/davserver COPY --from=builder /bin/davserver /usr/local/bin/davserver
COPY --from=builder /bin/nidusctl /usr/local/bin/nidusctl COPY --from=builder /bin/nidusctl /usr/local/bin/nidusctl
# Default data and config locations # Default data location
VOLUME ["/app/data"] VOLUME ["/app/data"]
# config.yaml itself is git-ignored (it holds real secrets), so the image
# ships the example config as a working default; mount your own # environment variables (override as needed)
# config.yaml over /app/config.yaml (see docker-compose.yaml) to override it. ENV NIDUS_DATA_DIR=/app/data
COPY config.example.yaml /app/config.yaml ENV NIDUS_HOST=0.0.0.0
ENV NIDUS_PORT=8080
ENV NIDUS_LOG_LEVEL=warn
ENV NIDUS_LOG_FORMAT=text
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["davserver", "-config", "/app/config.yaml"] ENTRYPOINT ["davserver"]
+2 -2
View File
@@ -7,7 +7,7 @@ build:
## run: run the server locally ## run: run the server locally
run: build run: build
./bin/davserver -config config.yaml ./bin/davserver
## test: run all tests ## test: run all tests
test: test:
@@ -37,7 +37,7 @@ hash-password:
## nidusctl: build and run the sharing-grant admin CLI ## nidusctl: build and run the sharing-grant admin CLI
## Usage: make nidusctl ARGS="calendar share alice work bob write" ## Usage: make nidusctl ARGS="calendar share alice work bob write"
nidusctl: build nidusctl: build
./bin/nidusctl -config config.yaml $(ARGS) ./bin/nidusctl $(ARGS)
## templ-generate: regenerate *_templ.go files from internal/web/templates/*.templ ## templ-generate: regenerate *_templ.go files from internal/web/templates/*.templ
templ-generate: templ-generate:
+109 -129
View File
@@ -1,4 +1,4 @@
# DAV Server # nidus
A self-hosted **CalDAV**, **CardDAV**, and **WebDAV** server written in Go. A self-hosted **CalDAV**, **CardDAV**, and **WebDAV** server written in Go.
@@ -23,56 +23,61 @@ and files, all kept under your own roof instead of a third-party cloud.
- **Web UI** — a mobile-friendly app at `/web/` for managing calendars, - **Web UI** — a mobile-friendly app at `/web/` for managing calendars,
contacts, files, and account settings (see [Web UI](#web-ui) below), contacts, files, and account settings (see [Web UI](#web-ui) below),
built with templ + Tailwind + htmx built with templ + Tailwind + htmx
- **ICSSubscriptions** — add remote ICS/webcal calendars
- **Birthdays calendar** — auto-computed from contacts' BDAY fields
- Auto-discovery via `/.well-known/caldav` and `/.well-known/carddav` - Auto-discovery via `/.well-known/caldav` and `/.well-known/carddav`
- Optional **TLS** (or use a reverse proxy)
- Structured logging (text or JSON) - Structured logging (text or JSON)
- Graceful shutdown - Graceful shutdown
- Docker & Docker Compose support - Docker & Docker Compose support
## Quick start ## Quick start
### 1. Install dependencies ### 1. Run the server
```bash
go mod tidy
```
### 2. Create your `config.yaml`
Copy the example config and edit it — `config.yaml` is git-ignored so your
real settings never get committed:
```bash
cp config.example.yaml config.yaml
```
Users, calendars, and address books are **no longer configured in
`config.yaml`** — they live in the SQLite database and are managed with
`nidusctl` (see below).
### 3. Run the server
```bash ```bash
make run make run
# or # or
go run ./cmd/server -config config.yaml go run ./cmd/server
``` ```
The server starts at **http://localhost:8080**. The server starts at **http://localhost:8080**.
### 4. Create a user and their resources ### 2. Configure the server via environment variables
The server is configured via environment variables:
```bash ```bash
go run ./tools/nidusctl -config config.yaml user create alice \ # Required: Set the data directory
export NIDUS_DATA_DIR="./data"
# Optional: Set port, host, and base URL
export NIDUS_PORT="8080"
export NIDUS_HOST="0.0.0.0"
export NIDUS_BASE_URL="https://dav.example.com"
# Optional: Set auth realm
export NIDUS_AUTH_REALM="My DAV Server"
# Optional: Set logging
export NIDUS_LOG_LEVEL="info"
export NIDUS_LOG_FORMAT="text"
```
This server does **not** handle TLS — use a reverse proxy (e.g. Nginx, Caddy,
Traefik) to terminate TLS and forward requests to the server.
### 3. Create a user and their resources
```bash
go run ./tools/nidusctl user create alice \
--display-name "Alice Smith" --email alice@example.com --display-name "Alice Smith" --email alice@example.com
# (prompts for a password; use --password to skip the prompt, e.g. in scripts) # (prompts for a password; use --password to skip the prompt, e.g. in scripts)
go run ./tools/nidusctl -config config.yaml calendar create alice personal go run ./tools/nidusctl calendar create alice personal
go run ./tools/nidusctl -config config.yaml addressbook create alice contacts go run ./tools/nidusctl addressbook create alice contacts
``` ```
Users can also be created/removed via the web UI (`/web/`) once logged in Or use the web UI (`/web/`) once logged in — see **Web UI** below.
as an existing user — see **Web UI** below.
--- ---
@@ -83,44 +88,66 @@ as an existing user — see **Web UI** below.
docker compose up --build docker compose up --build
# Or build manually # Or build manually
docker build -t davserver . docker build -t nidus .
docker run -p 8080:8080 \ docker run -p 8080:8080 \
-v ./config.yaml:/app/config.yaml:ro \ -v nidus-data:/app/data \
-v dav-data:/app/data \ -e NIDUS_DATA_DIR=/app/data \
davserver nidus
``` ```
The image also ships `nidusctl`, so once the container is running you can The image also ships `nidusctl`, so once the container is running you can
create your first user (and their calendars/address books) with create your first user (and their calendars/address books) with
`docker compose exec` — no need to install Go locally: `docker compose exec`:
```bash ```bash
docker compose exec davserver nidusctl -config /app/config.yaml user create alice \ docker compose exec nidus nidusctl user create alice \
--display-name "Alice Smith" --email alice@example.com --display-name "Alice Smith" --email alice@example.com
# (prompts for a password; use --password to skip the prompt, e.g. in scripts) # (prompts for a password; use --password to skip the prompt)
docker compose exec davserver nidusctl -config /app/config.yaml calendar create alice personal docker compose exec nidus nidusctl calendar create alice personal
docker compose exec davserver nidusctl -config /app/config.yaml addressbook create alice contacts docker compose exec nidus nidusctl addressbook create alice contacts
``` ```
### Pre-built images ### Using pre-built images
Pushing a version tag (e.g. `v1.2.3`) or publishing a release triggers This project uses year.month.hotfix versioning (e.g. `2026.8.0`, `2026.8.1`)
rather than semantic versioning. Pushing a version tag (e.g. `2026.8.0`) or
publishing a release triggers
[`.github/workflows/docker-release.yml`](.github/workflows/docker-release.yml), [`.github/workflows/docker-release.yml`](.github/workflows/docker-release.yml),
which builds and publishes a multi-arch (`linux/amd64` + `linux/arm64`) which builds and publishes a multi-arch (`linux/amd64` + `linux/arm64`)
image to `git.arnef.de/arnef/nidus`, tagged with the version, `<major>.<minor>`, image to `git.arnef.de/arnef/nidus`, tagged with the version, `<year>.<month>`,
`latest`, and the short commit SHA. It authenticates via the `latest`, and the short commit SHA. It authenticates via the
`REGISTRY_USERNAME`/`REGISTRY_PASSWORD` repository secrets. Point `REGISTRY_USERNAME`/`REGISTRY_PASSWORD` repository secrets.
`docker-compose.yaml`'s `image:` at it instead of `build: .` to use it
directly, e.g.: A minimal standalone `docker-compose.yml` that pulls this image instead of
building from a checkout — just fetch `config.example.yaml`, copy it to
`config.yaml`, and adjust it to your needs:
```yaml ```yaml
services: services:
davserver: nidus:
image: git.arnef.de/arnef/nidus:latest image: git.arnef.de/arnef/nidus:latest
ports:
- "8080:8080"
volumes:
- nidus-data:/app/data
environment:
- NIDUS_DATA_DIR=/app/data
# Optional: other environment variables
# - NIDUS_PORT=8080
# - NIDUS_HOST=0.0.0.0
# - NIDUS_BASE_URL=https://dav.example.com
# - NIDUS_AUTH_REALM="My DAV Server"
# - NIDUS_LOG_LEVEL=info
# - NIDUS_LOG_FORMAT=text
restart: unless-stopped
volumes:
nidus-data:
``` ```
--- Note: This server does **not** handle TLS. Use a reverse proxy (e.g. Nginx,
Caddy, Traefik) to terminate TLS and forward requests to the server.
## API endpoints ## API endpoints
@@ -192,10 +219,10 @@ mobile browsers:
`nidus.db` (`web_sessions` table), independent of DAV Basic Auth. Includes `nidus.db` (`web_sessions` table), independent of DAV Basic Auth. Includes
a "Show/Hide" password toggle to rule out typos before submitting. a "Show/Hide" password toggle to rule out typos before submitting.
- **Dashboard** (`/web/`) — create/delete your own calendars, address - **Dashboard** (`/web/`) — create/delete your own calendars, address
books, and ICS/webcal subscriptions; see who your resources are shared books, ICS/webcal subscriptions, and the Birthdays calendar; see who your
with and what others have shared with you; manage sharing grants resources are shared with and what others have shared with you; manage
directly (same effect as `nidusctl`) — updates happen in place via sharing grants directly (same effect as `nidusctl`) — updates happen in
[htmx](https://htmx.org/) without a full page reload. place via [htmx](https://htmx.org/) without a full page reload.
- **Files** (`/web/files/`) — a browser for the same storage the WebDAV - **Files** (`/web/files/`) — a browser for the same storage the WebDAV
endpoint (`/files/`) serves: navigate folders, create new folders, endpoint (`/files/`) serves: navigate folders, create new folders,
upload files/folders (including via drag & drop), download, and delete upload files/folders (including via drag & drop), download, and delete
@@ -207,8 +234,9 @@ mobile browsers:
delete contacts (name, organization, birthday, phone numbers, emails, delete contacts (name, organization, birthday, phone numbers, emails,
addresses, photo), and import/export vCards (`.vcf`). addresses, photo), and import/export vCards (`.vcf`).
- **Calendar** (`/web/calendar`) — month and week views across all your - **Calendar** (`/web/calendar`) — month and week views across all your
own and shared calendars, create/edit/delete events, per-calendar own and shared calendars (including ICS/webcal subscriptions and the
colors, and import/export `.ics` files. Birthdays calendar), create/edit/delete events, per-calendar colors,
and import/export `.ics` files.
- **Account** (`/web/account`) — update your display name/email and - **Account** (`/web/account`) — update your display name/email and
change your password. change your password.
- **Logout** (`/web/logout`). - **Logout** (`/web/logout`).
@@ -230,64 +258,36 @@ make web-assets # regenerate templ code + rebuild web/static/app.css and web/st
--- ---
## TLS / Reverse proxy
### Self-signed certificate (development)
```bash
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
```
Update `config.yaml`:
```yaml
tls:
enabled: true
cert_file: cert.pem
key_file: key.pem
```
### Caddy reverse proxy (recommended for production)
```
dav.example.com {
reverse_proxy localhost:8080
}
```
---
## Configuration reference ## Configuration reference
```yaml Configuration is done via environment variables:
server:
host: "0.0.0.0"
port: 8080
base_url: "https://dav.example.com" # used in DAV responses
auth: | Variable | Default | Description |
realm: "My DAV Server" |----------|---------|-------------|
| `NIDUS_HOST` | `0.0.0.0` | Server listen host |
| `NIDUS_PORT` | `8080` | Server listen port |
| `NIDUS_BASE_URL` | (auto) | Public URL for DAV responses (e.g. https://dav.example.com) |
| `NIDUS_AUTH_REALM` | `DAV Server` | HTTP Basic Auth realm |
| `NIDUS_DATA_DIR` | `./data` | Data directory for all user data |
| `NIDUS_LOG_LEVEL` | `info` | Log level: debug, info, warn, error |
| `NIDUS_LOG_FORMAT` | `text` | Log format: text, json |
storage: Example:
data_dir: "./data" # all user data lives here
logging: ```bash
level: "info" # debug | info | warn | error export NIDUS_DATA_DIR="./data"
format: "text" # text | json export NIDUS_PORT="8080"
export NIDUS_BASE_URL="https://dav.example.com"
tls: export NIDUS_LOG_LEVEL="info"
enabled: false
cert_file: ""
key_file: ""
``` ```
Users, calendars, and address books are managed via `nidusctl`, not This server does **not** handle TLS — use a reverse proxy (e.g. Nginx, Caddy,
`config.yaml` — see **Managing users** below. Traefik) to terminate TLS and forward requests to the server.
## Managing users ## Managing users
All user/calendar/address-book management is done with `nidusctl` (or the All user/calendar/address-book management is done with `nidusctl` or the
web UI). Nothing is stored in `config.yaml` anymore. web UI (`/web/`). Nothing is stored in `config.yaml` anymore.
```bash ```bash
# Users # Users
@@ -314,9 +314,10 @@ nidusctl addressbook unshare <owner> <book> <user>
nidusctl addressbook shares <owner> <book> nidusctl addressbook shares <owner> <book>
``` ```
Passwords are prompted for interactively (masked, double-entry) when Password are prompted for interactively (masked, double-entry) when
`--password` is omitted. The web UI (`/web/`) also lets a logged-in user `--password` is omitted. The web UI (`/web/`) also lets a logged-in user
create/delete their own calendars and address books from the dashboard. create/delete their own calendars, address books, and ICS/webcal subscriptions
from the dashboard.
> **Upgrading from an older version?** The `users:` section in > **Upgrading from an older version?** The `users:` section in
> `config.yaml` is no longer read. Recreate your users with > `config.yaml` is no longer read. Recreate your users with
@@ -328,22 +329,22 @@ create/delete their own calendars and address books from the dashboard.
## Project layout ## Project layout
``` ```
caldav-server/ nidus/
├── cmd/server/ # main entrypoint ├── cmd/server/ # main entrypoint
├── internal/ ├── internal/
│ ├── auth/ # HTTP Basic Auth middleware (DAV endpoints) │ ├── auth/ # HTTP Basic Auth middleware (DAV endpoints)
│ ├── caldav/ # CalDAV backend │ ├── caldav/ # CalDAV backend
│ ├── carddav/ # CardDAV backend │ ├── carddav/ # CardDAV backend
│ ├── config/ # YAML config loader │ ├── config/ # YAML config loader
│ ├── db/ # SQLite store (shares, web UI sessions) │ ├── db/ # SQLite store (users, calendars, shares, sessions)
│ ├── store/ # filesystem storage layer │ ├── store/ # filesystem storage layer
│ ├── web/ # web UI (cookie sessions, dashboard, share mgmt)
│ │ └── templates/ # templ templates (+ generated *_templ.go)
│ └── webdav/ # WebDAV file handler │ └── webdav/ # WebDAV file handler
├── tools/hashpwd/ # bcrypt password hasher CLI ├── internal/web/ # web UI (templ, dashboard, share mgmt, sessions)
├── tools/nidusctl/ # sharing-grant admin CLI │ └── templates/ # templ templates (+ generated *_templ.go)
├── cmd/nidusctl/ # admin CLI (users, calendars, address books, sharing)
├── web/ # front-end assets: Tailwind input/config, static/ ├── web/ # front-end assets: Tailwind input/config, static/
│ └── static/ # compiled app.css + htmx.min.js (embedded into the binary) │ └── static/ # compiled app.css + htmx.min.js (embedded into the binary)
├── tools/migrate/ # data directory migration tool
├── config.example.yaml # sample configuration (copy to config.yaml) ├── config.example.yaml # sample configuration (copy to config.yaml)
├── Dockerfile ├── Dockerfile
├── docker-compose.yaml ├── docker-compose.yaml
@@ -370,24 +371,3 @@ reviewed and tested where practical, but not every part of the codebase
has been fully reviewed yet — use accordingly, especially before relying has been fully reviewed yet — use accordingly, especially before relying
on this in security-sensitive environments. on this in security-sensitive environments.
---
## Dependencies
| Package | Purpose |
|---------|---------|
| `github.com/emersion/go-webdav` | WebDAV/CalDAV/CardDAV protocol layer |
| `github.com/emersion/go-ical` | iCalendar parsing/serialisation |
| `github.com/emersion/go-vcard` | vCard parsing/serialisation |
| `golang.org/x/crypto` | bcrypt |
| `golang.org/x/net` | `golang.org/x/net/webdav` |
| `gopkg.in/yaml.v3` | YAML config parsing |
| `modernc.org/sqlite` | Pure-Go SQLite driver (shares, web UI sessions) |
| `github.com/a-h/templ` | Type-safe Go HTML templates (web UI) |
Front-end (dev-only, not required at runtime — see [Web UI](#web-ui)):
| Tool | Purpose |
|------|---------|
| Tailwind CSS v4 (`web/package.json`) | Utility-first CSS, compiled to `web/static/app.css` |
| [htmx](https://htmx.org/) (`web/static/htmx.min.js`, vendored) | Partial page updates without a JS framework |
+18 -21
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"flag"
"fmt" "fmt"
"log/slog" "log/slog"
"net" "net"
@@ -13,24 +12,20 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/yourusername/caldav-server/internal/auth" "git.arnef.de/arnef/nidus/internal/auth"
"github.com/yourusername/caldav-server/internal/caldav" "git.arnef.de/arnef/nidus/internal/caldav"
"github.com/yourusername/caldav-server/internal/carddav" "git.arnef.de/arnef/nidus/internal/carddav"
"github.com/yourusername/caldav-server/internal/config" "git.arnef.de/arnef/nidus/internal/config"
"github.com/yourusername/caldav-server/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"github.com/yourusername/caldav-server/internal/store" "git.arnef.de/arnef/nidus/internal/store"
"github.com/yourusername/caldav-server/internal/web" "git.arnef.de/arnef/nidus/internal/web"
filewebdav "github.com/yourusername/caldav-server/internal/webdav" filewebdav "git.arnef.de/arnef/nidus/internal/webdav"
webstatic "github.com/yourusername/caldav-server/web" webstatic "git.arnef.de/arnef/nidus/web"
) )
func main() { func main() {
var cfgPath string
flag.StringVar(&cfgPath, "config", "config.yaml", "path to configuration file")
flag.Parse()
// ---- Configuration ---- // ---- Configuration ----
cfg, err := config.Load(cfgPath) cfg, err := config.Load()
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "error loading config: %v\n", err) fmt.Fprintf(os.Stderr, "error loading config: %v\n", err)
os.Exit(1) os.Exit(1)
@@ -41,8 +36,7 @@ func main() {
logger.Info("starting DAV server", logger.Info("starting DAV server",
"host", cfg.Server.Host, "host", cfg.Server.Host,
"port", cfg.Server.Port, "port", cfg.Server.Port,
"base_url", cfg.Server.BaseURL, "base_url", cfg.Server.BaseURL)
"tls", cfg.TLS.Enabled)
// ---- Storage ---- // ---- Storage ----
st, err := store.NewStore(cfg.Storage.DataDir) st, err := store.NewStore(cfg.Storage.DataDir)
@@ -51,6 +45,13 @@ func main() {
os.Exit(1) os.Exit(1)
} }
// ---- Run migration if data directory needs restructuring ----
if err := st.Migrate(); err != nil {
logger.Error("migration failed", "error", err)
os.Exit(1)
}
logger.Info("data directory migration completed")
// ---- Database (users, calendars, address books, sharing) ---- // ---- Database (users, calendars, address books, sharing) ----
dbPath := filepath.Join(cfg.Storage.DataDir, "nidus.db") dbPath := filepath.Join(cfg.Storage.DataDir, "nidus.db")
dbase, err := db.Open(dbPath) dbase, err := db.Open(dbPath)
@@ -136,11 +137,7 @@ func main() {
}() }()
logger.Info("server ready", "addr", addr) logger.Info("server ready", "addr", addr)
if cfg.TLS.Enabled {
err = srv.ListenAndServeTLS(cfg.TLS.CertFile, cfg.TLS.KeyFile)
} else {
err = srv.ListenAndServe() err = srv.ListenAndServe()
}
if err != nil && err != http.ErrServerClosed { if err != nil && err != http.ErrServerClosed {
logger.Error("server error", "error", err) logger.Error("server error", "error", err)
os.Exit(1) os.Exit(1)
-31
View File
@@ -1,31 +0,0 @@
server:
host: "0.0.0.0"
port: 8080
# Set this to your public-facing URL so discovery responses are correct.
# base_url: "https://dav.example.com"
auth:
realm: "My DAV Server"
storage:
data_dir: "./data"
logging:
level: "debug" # debug | info | warn | error
format: "text" # text | json
tls:
enabled: false
# cert_file: "/etc/ssl/certs/dav.crt"
# key_file: "/etc/ssl/private/dav.key"
# Users, calendars, and address books are no longer configured here — they
# are stored in the database (<data_dir>/nidus.db) and managed with
# nidusctl or the web UI (/web/):
#
# nidusctl user create alice --password mysecretpassword --display-name "Alice Smith" --email alice@example.com
# nidusctl calendar create alice personal
# nidusctl calendar create alice work
# nidusctl addressbook create alice contacts
#
# Run `nidusctl help` for the full command list.
+12 -4
View File
@@ -1,11 +1,19 @@
services: services:
davserver: nidus:
build: . build: .
ports: ports:
- "8080:8080" - "8080:8080"
environment:
# Optional: other environment variables
# - NIDUS_DATA_DIR=/app/data
# - NIDUS_HOST=0.0.0.0
# - NIDUS_PORT=8080
# - NIDUS_BASE_URL=https://dav.example.com
# - NIDUS_AUTH_REALM="My DAV Server"
# - NIDUS_LOG_LEVEL=info
# - NIDUS_LOG_FORMAT=text
volumes: volumes:
- ./config.yaml:/app/config.yaml:ro - nidus-data:/app/data
- dav-data:/app/data
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"] test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
@@ -14,4 +22,4 @@ services:
retries: 3 retries: 3
volumes: volumes:
dav-data: nidus-data:
+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/yourusername/caldav-server module git.arnef.de/arnef/nidus
go 1.25.0 go 1.25.0
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"github.com/yourusername/caldav-server/internal/config" "git.arnef.de/arnef/nidus/internal/config"
"github.com/yourusername/caldav-server/internal/db" "git.arnef.de/arnef/nidus/internal/db"
) )
type contextKey string type contextKey string
+2 -2
View File
@@ -14,8 +14,8 @@ import (
"github.com/emersion/go-vcard" "github.com/emersion/go-vcard"
"github.com/yourusername/caldav-server/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"github.com/yourusername/caldav-server/internal/store" "git.arnef.de/arnef/nidus/internal/store"
) )
// Contact holds one contact's parsed birthday. // Contact holds one contact's parsed birthday.
+6 -6
View File
@@ -14,15 +14,15 @@ import (
"strings" "strings"
"time" "time"
"git.arnef.de/arnef/nidus/internal/auth"
"git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/icalfix"
"git.arnef.de/arnef/nidus/internal/icssub"
"git.arnef.de/arnef/nidus/internal/store"
ical "github.com/emersion/go-ical" ical "github.com/emersion/go-ical"
"github.com/emersion/go-webdav" "github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/caldav" "github.com/emersion/go-webdav/caldav"
"github.com/yourusername/caldav-server/internal/auth"
"github.com/yourusername/caldav-server/internal/config"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/icalfix"
"github.com/yourusername/caldav-server/internal/icssub"
"github.com/yourusername/caldav-server/internal/store"
) )
// sharedNameSep separates the owner from the calendar name in the // sharedNameSep separates the owner from the calendar name in the
+4 -4
View File
@@ -11,11 +11,11 @@ import (
"strings" "strings"
"testing" "testing"
"git.arnef.de/arnef/nidus/internal/auth"
"git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/store"
ical "github.com/emersion/go-ical" ical "github.com/emersion/go-ical"
"github.com/yourusername/caldav-server/internal/auth"
"github.com/yourusername/caldav-server/internal/config"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/store"
) )
func newTestBackend(t *testing.T) (*Backend, *db.DB) { func newTestBackend(t *testing.T) (*Backend, *db.DB) {
+1 -1
View File
@@ -13,7 +13,7 @@ import (
"github.com/emersion/go-webdav" "github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/caldav" "github.com/emersion/go-webdav/caldav"
"github.com/yourusername/caldav-server/internal/birthdays" "git.arnef.de/arnef/nidus/internal/birthdays"
) )
// birthdaysCalendarName is the fixed, reserved local calendar name the // birthdaysCalendarName is the fixed, reserved local calendar name the
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"github.com/emersion/go-webdav" "github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/caldav" "github.com/emersion/go-webdav/caldav"
"github.com/yourusername/caldav-server/internal/db" "git.arnef.de/arnef/nidus/internal/db"
) )
// defaultICSColor is the display color for an ICS/webcal subscription // defaultICSColor is the display color for an ICS/webcal subscription
+4 -4
View File
@@ -9,13 +9,13 @@ import (
"strings" "strings"
"time" "time"
"git.arnef.de/arnef/nidus/internal/auth"
"git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/store"
vcard "github.com/emersion/go-vcard" vcard "github.com/emersion/go-vcard"
"github.com/emersion/go-webdav" "github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/carddav" "github.com/emersion/go-webdav/carddav"
"github.com/yourusername/caldav-server/internal/auth"
"github.com/yourusername/caldav-server/internal/config"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/store"
) )
// sharedNameSep separates the owner from the address book name in the // sharedNameSep separates the owner from the address book name in the
+4 -4
View File
@@ -8,11 +8,11 @@ import (
"strings" "strings"
"testing" "testing"
"git.arnef.de/arnef/nidus/internal/auth"
"git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/store"
vcard "github.com/emersion/go-vcard" vcard "github.com/emersion/go-vcard"
"github.com/yourusername/caldav-server/internal/auth"
"github.com/yourusername/caldav-server/internal/config"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/store"
) )
func newTestBackend(t *testing.T) (*Backend, *db.DB) { func newTestBackend(t *testing.T) (*Backend, *db.DB) {
+37 -64
View File
@@ -3,97 +3,70 @@ package config
import ( import (
"fmt" "fmt"
"os" "os"
"strconv"
"gopkg.in/yaml.v3"
) )
// Config is the top-level server configuration. // Config is the top-level server configuration.
type Config struct { type Config struct {
Server ServerConfig `yaml:"server"` Server ServerConfig
Auth AuthConfig `yaml:"auth"` Auth AuthConfig
Storage StorageConfig `yaml:"storage"` Storage StorageConfig
TLS TLSConfig `yaml:"tls"` Logging LoggingConfig
Logging LoggingConfig `yaml:"logging"`
} }
type ServerConfig struct { type ServerConfig struct {
Host string `yaml:"host"` Host string
Port int `yaml:"port"` Port int
// Base URL used in DAV responses (e.g. https://dav.example.com) BaseURL string
BaseURL string `yaml:"base_url"`
} }
type AuthConfig struct { type AuthConfig struct {
// Realm shown in WWW-Authenticate header Realm string
Realm string `yaml:"realm"`
} }
type StorageConfig struct { type StorageConfig struct {
// Root directory for all data DataDir string
DataDir string `yaml:"data_dir"`
}
type TLSConfig struct {
Enabled bool `yaml:"enabled"`
CertFile string `yaml:"cert_file"`
KeyFile string `yaml:"key_file"`
} }
type LoggingConfig struct { type LoggingConfig struct {
Level string `yaml:"level"` // debug | info | warn | error Level string
Format string `yaml:"format"` // text | json Format string
} }
// Load reads and parses a YAML config file. // Load reads and parses environment variables to create the configuration.
func Load(path string) (*Config, error) { func Load() (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config %q: %w", path, err)
}
cfg := &Config{} cfg := &Config{}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parsing config %q: %w", path, err)
}
cfg.applyDefaults() cfg.applyDefaults()
return cfg, nil
return cfg, cfg.validate()
} }
func (c *Config) applyDefaults() { func (c *Config) applyDefaults() {
if c.Server.Host == "" { c.Server.Host = getEnv("NIDUS_HOST", "0.0.0.0")
c.Server.Host = "0.0.0.0" c.Server.Port = getEnvInt("NIDUS_PORT", 8080)
} c.Server.BaseURL = getEnv("NIDUS_BASE_URL", "")
if c.Server.Port == 0 {
c.Server.Port = 8080
}
if c.Server.BaseURL == "" { if c.Server.BaseURL == "" {
scheme := "http" c.Server.BaseURL = fmt.Sprintf("http://%s:%d", c.Server.Host, c.Server.Port)
if c.TLS.Enabled {
scheme = "https"
}
c.Server.BaseURL = fmt.Sprintf("%s://%s:%d", scheme, c.Server.Host, c.Server.Port)
}
if c.Auth.Realm == "" {
c.Auth.Realm = "DAV Server"
}
if c.Storage.DataDir == "" {
c.Storage.DataDir = "./data"
}
if c.Logging.Level == "" {
c.Logging.Level = "info"
}
if c.Logging.Format == "" {
c.Logging.Format = "text"
} }
c.Auth.Realm = getEnv("NIDUS_AUTH_REALM", "DAV Server")
c.Storage.DataDir = getEnv("NIDUS_DATA_DIR", "./data")
c.Logging.Level = getEnv("NIDUS_LOG_LEVEL", "info")
c.Logging.Format = getEnv("NIDUS_LOG_FORMAT", "text")
} }
func (c *Config) validate() error { func getEnv(key string, defaultValue string) string {
if c.TLS.Enabled { if val := os.Getenv(key); val != "" {
if c.TLS.CertFile == "" || c.TLS.KeyFile == "" { return val
return fmt.Errorf("tls.cert_file and tls.key_file are required when tls is enabled")
} }
return defaultValue
}
func getEnvInt(key string, defaultValue int) int {
if val := os.Getenv(key); val != "" {
if intVal, err := strconv.Atoi(val); err == nil {
return intVal
} }
return nil }
return defaultValue
} }
-2
View File
@@ -102,8 +102,6 @@ func (d *DB) DisplayName(username string) string {
return u.DisplayName return u.DisplayName
} }
// DeleteUser removes username along with all of its calendars, address // DeleteUser removes username along with all of its calendars, address
// books, and sharing grants (calendars/addressbooks cascade via foreign // books, and sharing grants (calendars/addressbooks cascade via foreign
// key; shares are cleaned up explicitly since they reference usernames as // key; shares are cleaned up explicitly since they reference usernames as
+1 -1
View File
@@ -16,7 +16,7 @@ import (
ical "github.com/emersion/go-ical" ical "github.com/emersion/go-ical"
"github.com/yourusername/caldav-server/internal/icalfix" "git.arnef.de/arnef/nidus/internal/icalfix"
) )
// DefaultTTL is how long a fetched calendar is cached before being // DefaultTTL is how long a fetched calendar is cached before being
+200
View File
@@ -0,0 +1,200 @@
package store
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// Migrate restructures the data directory from the old format to the new unified format.
// It is idempotent and can be run multiple times safely.
//
// Old structure:
//
// data/files/<username>/
// data/<username>/cal-<name>/
// data/<username>/card-<name>/
//
// New structure:
//
// data/<username>/files/
// data/<username>/calendars/<name>/
// data/<username>/addressbooks/<name>/
func (s *Store) Migrate() error {
users, err := s.listUserDirectories()
if err != nil {
return fmt.Errorf("listing user directories: %w", err)
}
for _, user := range users {
if err := s.migrateUser(user); err != nil {
return fmt.Errorf("migrating user %q: %w", user, err)
}
}
return nil
}
// listUserDirectories returns all user directories in the data directory.
// It looks for directories that are NOT the old "files" directory,
// and also checks inside the old "files" directory for users who need migrating.
func (s *Store) listUserDirectories() ([]string, error) {
entries, err := os.ReadDir(s.rootDir)
if err != nil {
return nil, err
}
usersMap := make(map[string]bool)
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
// Skip the old files directory (will handle users inside it separately)
if name == "files" {
continue
}
usersMap[name] = true
}
// Also check the old files directory for users
filesDir := filepath.Join(s.rootDir, "files")
if filesEntries, err := os.ReadDir(filesDir); err == nil {
for _, e := range filesEntries {
if e.IsDir() {
usersMap[e.Name()] = true
}
}
}
var users []string
for user := range usersMap {
users = append(users, user)
}
return users, nil
}
// migrateUser migrates a single user's data from old to new structure.
func (s *Store) migrateUser(user string) error {
userDir := filepath.Join(s.rootDir, user)
// Create user directory if it doesn't exist (needed for WebDAV migration)
if err := os.MkdirAll(userDir, 0o755); err != nil {
return fmt.Errorf("creating user directory %q: %w", userDir, err)
}
// Migrate WebDAV files: files/<username> -> <username>/files
if err := s.migrateWebDAV(user); err != nil {
return err
}
// Migrate CalDAV calendars: cal-<name> -> calendars/<name>
if err := s.migrateCalendars(user, userDir); err != nil {
return err
}
// Migrate CardDAV address books: card-<name> -> addressbooks/<name>
if err := s.migrateAddressBooks(user, userDir); err != nil {
return err
}
return nil
}
// migrateWebDAV moves the old files/<username> directory to <username>/files.
func (s *Store) migrateWebDAV(user string) error {
oldPath := filepath.Join(s.rootDir, "files", user)
newPath := filepath.Join(s.rootDir, user, "files")
// Check if old directory exists and new doesn't
if _, err := os.Stat(oldPath); os.IsNotExist(err) {
return nil
}
if _, err := os.Stat(newPath); err == nil {
return nil // Already migrated
}
// Create parent directory if needed
if err := os.MkdirAll(filepath.Dir(newPath), 0o755); err != nil {
return fmt.Errorf("creating directory %q: %w", filepath.Dir(newPath), err)
}
// Move the directory
if err := os.Rename(oldPath, newPath); err != nil {
return fmt.Errorf("moving %q to %q: %w", oldPath, newPath, err)
}
return nil
}
// migrateCalendars moves old cal-<name> directories to calendars/<name>.
func (s *Store) migrateCalendars(user string, userDir string) error {
entries, err := os.ReadDir(userDir)
if err != nil {
return fmt.Errorf("reading user directory %q: %w", userDir, err)
}
var calendars []string
for _, e := range entries {
if e.IsDir() && strings.HasPrefix(e.Name(), "cal-") {
calendars = append(calendars, e.Name())
}
}
if len(calendars) == 0 {
return nil
}
calendarsDir := filepath.Join(userDir, "calendars")
if err := os.MkdirAll(calendarsDir, 0o755); err != nil {
return fmt.Errorf("creating calendars directory %q: %w", calendarsDir, err)
}
for _, calName := range calendars {
oldPath := filepath.Join(userDir, calName)
newPath := filepath.Join(calendarsDir, strings.TrimPrefix(calName, "cal-"))
if err := os.Rename(oldPath, newPath); err != nil {
return fmt.Errorf("moving calendar %q: %w", calName, err)
}
}
return nil
}
// migrateAddressBooks moves old card-<name> directories to addressbooks/<name>.
func (s *Store) migrateAddressBooks(user string, userDir string) error {
entries, err := os.ReadDir(userDir)
if err != nil {
return fmt.Errorf("reading user directory %q: %w", userDir, err)
}
var addressBooks []string
for _, e := range entries {
if e.IsDir() && strings.HasPrefix(e.Name(), "card-") {
addressBooks = append(addressBooks, e.Name())
}
}
if len(addressBooks) == 0 {
return nil
}
addressBooksDir := filepath.Join(userDir, "addressbooks")
if err := os.MkdirAll(addressBooksDir, 0o755); err != nil {
return fmt.Errorf("creating addressbooks directory %q: %w", addressBooksDir, err)
}
for _, bookName := range addressBooks {
oldPath := filepath.Join(userDir, bookName)
newPath := filepath.Join(addressBooksDir, strings.TrimPrefix(bookName, "card-"))
if err := os.Rename(oldPath, newPath); err != nil {
return fmt.Errorf("moving address book %q: %w", bookName, err)
}
}
return nil
}
+116
View File
@@ -0,0 +1,116 @@
package store
import (
"os"
"path/filepath"
"testing"
)
func TestMigrate(t *testing.T) {
tmpDir := t.TempDir()
// Create old structure
// WebDAV: files/<username>/
if err := os.MkdirAll(filepath.Join(tmpDir, "files", "alice"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "files", "alice", "test.txt"), []byte("test"), 0o644); err != nil {
t.Fatal(err)
}
// CalDAV: <username>/cal-<name>/
if err := os.MkdirAll(filepath.Join(tmpDir, "bob", "cal-work"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "bob", "cal-work", "evt.ics"), []byte("calendar"), 0o644); err != nil {
t.Fatal(err)
}
// CardDAV: <username>/card-<name>/
if err := os.MkdirAll(filepath.Join(tmpDir, "bob", "card-contacts"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "bob", "card-contacts", "vcard.vcf"), []byte("card"), 0o644); err != nil {
t.Fatal(err)
}
// Create store and migrate
st, err := NewStore(tmpDir)
if err != nil {
t.Fatal(err)
}
if err := st.Migrate(); err != nil {
t.Fatal(err)
}
// Verify WebDAV: <username>/files/
if _, err := os.Stat(filepath.Join(tmpDir, "alice", "files", "test.txt")); err != nil {
t.Errorf("alice files not migrated: %v", err)
}
// Verify CalDAV: <username>/calendars/<name>
if _, err := os.Stat(filepath.Join(tmpDir, "bob", "calendars", "work", "evt.ics")); err != nil {
t.Errorf("bob calendars not migrated: %v", err)
}
// Verify CardDAV: <username>/addressbooks/<name>
if _, err := os.Stat(filepath.Join(tmpDir, "bob", "addressbooks", "contacts", "vcard.vcf")); err != nil {
t.Errorf("bob addressbooks not migrated: %v", err)
}
// Verify old structure is gone
if _, err := os.Stat(filepath.Join(tmpDir, "files", "alice")); err == nil {
t.Error("old files directory not removed")
}
if _, err := os.Stat(filepath.Join(tmpDir, "bob", "cal-work")); err == nil {
t.Error("old cal- directory not removed")
}
if _, err := os.Stat(filepath.Join(tmpDir, "bob", "card-contacts")); err == nil {
t.Error("old card- directory not removed")
}
}
func TestMigrateIdempotent(t *testing.T) {
tmpDir := t.TempDir()
// Create new structure
if err := os.MkdirAll(filepath.Join(tmpDir, "alice", "files"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "alice", "files", "test.txt"), []byte("test"), 0o644); err != nil {
t.Fatal(err)
}
st, err := NewStore(tmpDir)
if err != nil {
t.Fatal(err)
}
// Run migration twice
if err := st.Migrate(); err != nil {
t.Fatal(err)
}
if err := st.Migrate(); err != nil {
t.Fatal(err)
}
// Verify data still there
if _, err := os.Stat(filepath.Join(tmpDir, "alice", "files", "test.txt")); err != nil {
t.Errorf("data not preserved: %v", err)
}
}
func TestMigrateMissingDirectories(t *testing.T) {
tmpDir := t.TempDir()
st, err := NewStore(tmpDir)
if err != nil {
t.Fatal(err)
}
// Should not error on empty directory
if err := st.Migrate(); err != nil {
t.Errorf("unexpected error: %v", err)
}
}
+22 -3
View File
@@ -48,9 +48,26 @@ func (s *Store) lockFor(user string) *sync.RWMutex {
return l return l
} }
// collectionPath returns the filesystem path for a collection. // collectionPath returns the filesystem path for a collection in the new unified format.
// Calendar collections: <username>/calendars/<name>
// Address book collections: <username>/addressbooks/<name>
// WebDAV collections: <username>/files (all files in one directory)
func (s *Store) collectionPath(user, collection string) string { func (s *Store) collectionPath(user, collection string) string {
return filepath.Join(s.rootDir, sanitize(user), sanitize(collection)) userDir := filepath.Join(s.rootDir, sanitize(user))
if strings.HasPrefix(collection, "cal-") {
return filepath.Join(userDir, "calendars", strings.TrimPrefix(collection, "cal-"))
}
if strings.HasPrefix(collection, "card-") {
return filepath.Join(userDir, "addressbooks", strings.TrimPrefix(collection, "card-"))
}
if collection == "files" {
return filepath.Join(userDir, "files")
}
return filepath.Join(userDir, sanitize(collection))
} }
// objectPath returns the filesystem path for an object within a collection. // objectPath returns the filesystem path for an object within a collection.
@@ -68,6 +85,7 @@ func (s *Store) EnsureCollection(user, collection string) error {
} }
// ListCollections returns all collection names for a user. // ListCollections returns all collection names for a user.
// Returns both old-style (cal-*, card-*) and new-style (calendars/*, addressbooks/*) collections.
func (s *Store) ListCollections(user string) ([]string, error) { func (s *Store) ListCollections(user string) ([]string, error) {
l := s.lockFor(user) l := s.lockFor(user)
l.RLock() l.RLock()
@@ -85,7 +103,8 @@ func (s *Store) ListCollections(user string) ([]string, error) {
var names []string var names []string
for _, e := range entries { for _, e := range entries {
if e.IsDir() { if e.IsDir() {
names = append(names, e.Name()) name := e.Name()
names = append(names, name)
} }
} }
return names, nil return names, nil
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"sync" "sync"
"testing" "testing"
"github.com/yourusername/caldav-server/internal/store" "git.arnef.de/arnef/nidus/internal/store"
) )
func TestStoreRoundTrip(t *testing.T) { func TestStoreRoundTrip(t *testing.T) {
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/yourusername/caldav-server/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"github.com/yourusername/caldav-server/internal/web/templates" "git.arnef.de/arnef/nidus/internal/web/templates"
) )
// handleAccount serves GET /account: the current user's own profile and // handleAccount serves GET /account: the current user's own profile and
+4 -5
View File
@@ -15,11 +15,11 @@ import (
"strings" "strings"
"time" "time"
"git.arnef.de/arnef/nidus/internal/birthdays"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/icalfix"
"git.arnef.de/arnef/nidus/internal/web/templates"
ical "github.com/emersion/go-ical" ical "github.com/emersion/go-ical"
"github.com/yourusername/caldav-server/internal/birthdays"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/icalfix"
"github.com/yourusername/caldav-server/internal/web/templates"
) )
// eventIDRe validates an event's object ID as it appears in a URL path // eventIDRe validates an event's object ID as it appears in a URL path
@@ -527,7 +527,6 @@ func mondayOf(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, -offset) return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, -offset)
} }
// eventDayRange returns the inclusive [start, end] calendar-day span an // eventDayRange returns the inclusive [start, end] calendar-day span an
// event occupies, in loc, for placing it on the month grid. // event occupies, in loc, for placing it on the month grid.
func eventDayRange(form templates.EventFormData, loc *time.Location) (start, end time.Time, err error) { func eventDayRange(form templates.EventFormData, loc *time.Location) (start, end time.Time, err error) {
+34 -46
View File
@@ -15,9 +15,9 @@ import (
"strconv" "strconv"
"strings" "strings"
"git.arnef.de/arnef/nidus/internal/store"
"git.arnef.de/arnef/nidus/internal/web/templates"
vcard "github.com/emersion/go-vcard" 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 // contactIDRe validates a contact's object ID as it appears in a URL path
@@ -239,7 +239,8 @@ func (s *Server) handleContactEdit(w http.ResponseWriter, r *http.Request) {
// contactFormInput holds the parsed, validated-ish values submitted by the // contactFormInput holds the parsed, validated-ish values submitted by the
// contact form, before they're translated into vCard fields. // contact form, before they're translated into vCard fields.
type contactFormInput struct { type contactFormInput struct {
FullName string Forename string
Surname string
Organization string Organization string
Note string Note string
Birthday string Birthday string
@@ -284,7 +285,8 @@ func parseContactForm(r *http.Request) (contactFormInput, error) {
get := func(key string) []string { return form[key] } get := func(key string) []string { return form[key] }
in := contactFormInput{ in := contactFormInput{
FullName: strings.TrimSpace(formValue(get("full_name"), 0)), Forename: strings.TrimSpace(formValue(get("forename"), 0)),
Surname: strings.TrimSpace(formValue(get("surname"), 0)),
Organization: strings.TrimSpace(formValue(get("organization"), 0)), Organization: strings.TrimSpace(formValue(get("organization"), 0)),
Note: strings.TrimSpace(formValue(get("note"), 0)), Note: strings.TrimSpace(formValue(get("note"), 0)),
Birthday: strings.TrimSpace(formValue(get("birthday"), 0)), Birthday: strings.TrimSpace(formValue(get("birthday"), 0)),
@@ -309,25 +311,13 @@ func parseContactForm(r *http.Request) (contactFormInput, error) {
in.Emails = append(in.Emails, templates.LabeledValue{Type: formValue(emailTypes, i), Value: v}) in.Emails = append(in.Emails, templates.LabeledValue{Type: formValue(emailTypes, i), Value: v})
} }
addrTypes := get("address_type") addrTypes, addrValues := get("address_type"), get("address_value")
streets := get("address_street") for i, v := range addrValues {
cities := get("address_city") v = strings.TrimSpace(v)
postalCodes := get("address_postal_code") if v == "" {
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 continue
} }
in.Addresses = append(in.Addresses, templates.AddressValue{ in.Addresses = append(in.Addresses, templates.AddressValue{Type: formValue(addrTypes, i), Value: v})
Type: formValue(addrTypes, i), Street: street, City: city,
Region: region, PostalCode: postalCode, Country: country,
})
} }
if r.MultipartForm != nil { if r.MultipartForm != nil {
@@ -358,11 +348,12 @@ func (s *Server) saveContactFromForm(w http.ResponseWriter, r *http.Request, use
return return
} }
if in.FullName == "" { if in.Forename == "" && in.Surname == "" {
form := templates.ContactFormData{ form := templates.ContactFormData{
Book: book, Book: book,
ID: id, ID: id,
FullName: in.FullName, Forename: in.Forename,
Surname: in.Surname,
Organization: in.Organization, Organization: in.Organization,
Note: in.Note, Note: in.Note,
Birthday: in.Birthday, Birthday: in.Birthday,
@@ -371,7 +362,7 @@ func (s *Server) saveContactFromForm(w http.ResponseWriter, r *http.Request, use
Addresses: ensureAtLeastOne(in.Addresses), Addresses: ensureAtLeastOne(in.Addresses),
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.ContactForm(username, form, "Full name is required").Render(context.Background(), w) _ = templates.ContactForm(username, form, "Name is required").Render(context.Background(), w)
return return
} }
@@ -422,16 +413,10 @@ func buildCard(uid string, in contactFormInput, existingPhoto string) vcard.Card
if uid != "" { if uid != "" {
card.SetValue(vcard.FieldUID, uid) card.SetValue(vcard.FieldUID, uid)
} }
card.SetValue(vcard.FieldFormattedName, in.FullName) if fn := strings.TrimSpace(in.Forename + " " + in.Surname); fn != "" {
card.SetValue(vcard.FieldFormattedName, fn)
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:], " ")
} }
name := &vcard.Name{GivenName: in.Forename, FamilyName: in.Surname}
card.SetName(name) card.SetName(name)
if in.Organization != "" { if in.Organization != "" {
@@ -459,14 +444,7 @@ func buildCard(uid string, in contactFormInput, existingPhoto string) vcard.Card
card.Add(vcard.FieldEmail, f) card.Add(vcard.FieldEmail, f)
} }
for _, a := range in.Addresses { for _, a := range in.Addresses {
addr := &vcard.Address{ addr := &vcard.Address{Field: &vcard.Field{}, StreetAddress: a.Value}
Field: &vcard.Field{},
StreetAddress: a.Street,
Locality: a.City,
Region: a.Region,
PostalCode: a.PostalCode,
Country: a.Country,
}
if a.Type != "" { if a.Type != "" {
addr.Params = vcard.Params{vcard.ParamType: {a.Type}} addr.Params = vcard.Params{vcard.ParamType: {a.Type}}
} }
@@ -533,10 +511,8 @@ func addressValues(card vcard.Card) []templates.AddressValue {
if a.Params != nil { if a.Params != nil {
typ = a.Params.Get(vcard.ParamType) typ = a.Params.Get(vcard.ParamType)
} }
out = append(out, templates.AddressValue{ value := strings.Join([]string{a.StreetAddress, a.Locality, a.Region, a.PostalCode, a.Country}, ", ")
Type: typ, Street: a.StreetAddress, City: a.Locality, out = append(out, templates.AddressValue{Type: typ, Value: value})
Region: a.Region, PostalCode: a.PostalCode, Country: a.Country,
})
} }
return out return out
} }
@@ -547,10 +523,22 @@ func contactFormFromCard(book, id string, data []byte) (templates.ContactFormDat
if err != nil { if err != nil {
return templates.ContactFormData{}, err return templates.ContactFormData{}, err
} }
var forename, surname string
if n := card.Name(); n != nil && (n.GivenName != "" || n.FamilyName != "") {
forename, surname = n.GivenName, n.FamilyName
} else if fn := strings.TrimSpace(card.PreferredValue(vcard.FieldFormattedName)); fn != "" {
parts := strings.Fields(fn)
if len(parts) > 1 {
forename, surname = parts[0], strings.Join(parts[1:], " ")
} else {
forename = parts[0]
}
}
return templates.ContactFormData{ return templates.ContactFormData{
Book: book, Book: book,
ID: id, ID: id,
FullName: card.PreferredValue(vcard.FieldFormattedName), Forename: forename,
Surname: surname,
Organization: card.PreferredValue(vcard.FieldOrganization), Organization: card.PreferredValue(vcard.FieldOrganization),
Note: card.PreferredValue(vcard.FieldNote), Note: card.PreferredValue(vcard.FieldNote),
Birthday: card.PreferredValue(vcard.FieldBirthday), Birthday: card.PreferredValue(vcard.FieldBirthday),
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"github.com/yourusername/caldav-server/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"github.com/yourusername/caldav-server/internal/web/templates" "git.arnef.de/arnef/nidus/internal/web/templates"
) )
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
+48 -4
View File
@@ -12,7 +12,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/yourusername/caldav-server/internal/web/templates" "git.arnef.de/arnef/nidus/internal/web/templates"
) )
// maxUploadMemory bounds how much of a multipart upload is buffered in // maxUploadMemory bounds how much of a multipart upload is buffered in
@@ -24,7 +24,7 @@ const maxUploadMemory = 32 << 20 // 32 MiB
// (internal/webdav) serves at /files/, so the web UI is just another view // (internal/webdav) serves at /files/, so the web UI is just another view
// onto the same files. // onto the same files.
func (s *Server) filesRoot(username string) string { func (s *Server) filesRoot(username string) string {
return filepath.Join(s.cfg.Storage.DataDir, "files", username) return filepath.Join(s.cfg.Storage.DataDir, username, "files")
} }
// sanitizeRelPath cleans a slash-separated relative path (as received from // sanitizeRelPath cleans a slash-separated relative path (as received from
@@ -142,7 +142,7 @@ func (s *Server) handleFilesMkdir(w http.ResponseWriter, r *http.Request, root,
} }
name := strings.TrimSpace(r.PostForm.Get("name")) name := strings.TrimSpace(r.PostForm.Get("name"))
if !resourceNameRe.MatchString(name) { if !resourceNameRe.MatchString(name) {
http.Error(w, "name must be 1-64 letters, digits, '-' or '_'", http.StatusBadRequest) http.Error(w, "name must be 1-64 letters, digits, dots, '-' or '_'", http.StatusBadRequest)
return return
} }
@@ -211,6 +211,16 @@ func (s *Server) handleFilesGet(w http.ResponseWriter, r *http.Request, username
return return
} }
// Determine sort order (name asc/desc) from query param, default to asc
sortBy := r.URL.Query().Get("sort_by")
sortDir := r.URL.Query().Get("sort_dir")
if sortBy == "" {
sortBy = "name"
}
if sortDir == "" {
sortDir = "asc"
}
// os.ReadDir already returns entries sorted by filename, so filtering // os.ReadDir already returns entries sorted by filename, so filtering
// into two passes keeps each group (directories, then files) // into two passes keeps each group (directories, then files)
// alphabetically sorted while grouping directories first. // alphabetically sorted while grouping directories first.
@@ -234,6 +244,40 @@ func (s *Server) handleFilesGet(w http.ResponseWriter, r *http.Request, username
files = append(files, entry) files = append(files, entry)
} }
} }
// Sort entries based on sortBy and sortDir
less := func(i, j templates.FileEntry) bool {
switch sortBy {
case "name":
if sortDir == "desc" {
return i.Name > j.Name
}
return i.Name < j.Name
default:
if sortDir == "desc" {
return i.ModTime > j.ModTime
}
return i.ModTime < j.ModTime
}
}
// Sort dirs
for i := 0; i < len(dirs)-1; i++ {
for j := i + 1; j < len(dirs); j++ {
if less(dirs[j], dirs[i]) {
dirs[i], dirs[j] = dirs[j], dirs[i]
}
}
}
// Sort files
for i := 0; i < len(files)-1; i++ {
for j := i + 1; j < len(files); j++ {
if less(files[j], files[i]) {
files[i], files[j] = files[j], files[i]
}
}
}
entries := append(dirs, files...) entries := append(dirs, files...)
var breadcrumbs []templates.Breadcrumb var breadcrumbs []templates.Breadcrumb
@@ -248,7 +292,7 @@ func (s *Server) handleFilesGet(w http.ResponseWriter, r *http.Request, username
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.FilesPage(username, breadcrumbs, entries, relPath).Render(context.Background(), w) _ = templates.FilesPage(username, breadcrumbs, entries, relPath, sortBy, sortDir).Render(context.Background(), w)
} }
func (s *Server) handleFilesUpload(w http.ResponseWriter, r *http.Request, root, fullPath string) { func (s *Server) handleFilesUpload(w http.ResponseWriter, r *http.Request, root, fullPath string) {
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/yourusername/caldav-server/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"github.com/yourusername/caldav-server/internal/web/templates" "git.arnef.de/arnef/nidus/internal/web/templates"
) )
// icsURLValid does a light sanity check on a subscription URL: it must be // icsURLValid does a light sanity check on a subscription URL: it must be
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context" "context"
"net/http" "net/http"
"github.com/yourusername/caldav-server/internal/web/templates" "git.arnef.de/arnef/nidus/internal/web/templates"
) )
func renderLogin(w http.ResponseWriter, errMsg string) { func renderLogin(w http.ResponseWriter, errMsg string) {
+4 -3
View File
@@ -6,13 +6,14 @@ import (
"regexp" "regexp"
"strings" "strings"
"github.com/yourusername/caldav-server/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"github.com/yourusername/caldav-server/internal/web/templates" "git.arnef.de/arnef/nidus/internal/web/templates"
) )
// resourceNameRe restricts calendar/address book names to characters that // resourceNameRe restricts calendar/address book names to characters that
// are safe as both a URL path segment and a filesystem directory name. // are safe as both a URL path segment and a filesystem directory name.
var resourceNameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`) // Includes dots for hidden files/folders (Unix convention).
var resourceNameRe = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,64}$`)
// hexColorRe validates the 6-digit hex color format produced by an HTML // hexColorRe validates the 6-digit hex color format produced by an HTML
// <input type="color">, e.g. "#3b82f6". // <input type="color">, e.g. "#3b82f6".
+4 -4
View File
@@ -10,10 +10,10 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/yourusername/caldav-server/internal/config" "git.arnef.de/arnef/nidus/internal/config"
"github.com/yourusername/caldav-server/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"github.com/yourusername/caldav-server/internal/icssub" "git.arnef.de/arnef/nidus/internal/icssub"
"github.com/yourusername/caldav-server/internal/store" "git.arnef.de/arnef/nidus/internal/store"
) )
// Server holds the dependencies needed by the web UI handlers. // Server holds the dependencies needed by the web UI handlers.
+3 -4
View File
@@ -11,9 +11,9 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/yourusername/caldav-server/internal/config" "git.arnef.de/arnef/nidus/internal/config"
"github.com/yourusername/caldav-server/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"github.com/yourusername/caldav-server/internal/store" "git.arnef.de/arnef/nidus/internal/store"
) )
func newTestServer(t *testing.T) *Server { func newTestServer(t *testing.T) *Server {
@@ -316,4 +316,3 @@ func TestAccountChangePassword(t *testing.T) {
t.Fatal("expected password to have changed") t.Fatal("expected password to have changed")
} }
} }
+2 -2
View File
@@ -46,7 +46,7 @@ func (s *Server) setSessionCookie(w http.ResponseWriter, token string) {
Value: token, Value: token,
Path: "/", Path: "/",
HttpOnly: true, HttpOnly: true,
Secure: s.cfg.TLS.Enabled, Secure: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(7 * 24 * time.Hour), Expires: time.Now().Add(7 * 24 * time.Hour),
}) })
@@ -58,7 +58,7 @@ func (s *Server) clearSessionCookie(w http.ResponseWriter) {
Value: "", Value: "",
Path: "/", Path: "/",
HttpOnly: true, HttpOnly: true,
Secure: s.cfg.TLS.Enabled, Secure: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
MaxAge: -1, MaxAge: -1,
}) })
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/yourusername/caldav-server/internal/web/templates" "git.arnef.de/arnef/nidus/internal/web/templates"
) )
// handleCalendarShare handles POST (create/update share) and DELETE // handleCalendarShare handles POST (create/update share) and DELETE
+47 -41
View File
@@ -26,14 +26,11 @@ type LabeledValue struct {
Value string Value string
} }
// AddressValue is one ADR entry with its TYPE parameter. // AddressValue is one ADR entry with its TYPE parameter; the full address
// is captured as a single free-form string.
type AddressValue struct { type AddressValue struct {
Type string Type string
Street string Value string
City string
Region string
PostalCode string
Country string
} }
// ContactFormData pre-fills the create/edit contact form. Phones, Emails, // ContactFormData pre-fills the create/edit contact form. Phones, Emails,
@@ -42,7 +39,8 @@ type AddressValue struct {
type ContactFormData struct { type ContactFormData struct {
Book string Book string
ID string // empty when creating a new contact ID string // empty when creating a new contact
FullName string Forename string
Surname string
Organization string Organization string
Birthday string // "YYYY-MM-DD", empty if not set Birthday string // "YYYY-MM-DD", empty if not set
Note string Note string
@@ -158,49 +156,50 @@ templ ContactsList(username, book string, contacts []ContactSummary) {
} }
} }
// typeSelect renders the TYPE dropdown shared by phone/email/address rows. // typeSelect renders the TYPE dropdown shared by email/address rows.
templ typeSelect(name, selected string) { templ typeSelect(name, selected string) {
<select name={ name } class="rounded-md border-gray-300 border px-2 py-1.5 text-sm"> <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="" selected?={ selected == "" }>Other</option>
<option value="home" selected?={ selected == "home" }>Privat</option> <option value="home" selected?={ selected == "home" }>Home</option>
<option value="work" selected?={ selected == "work" }>Geschäftlich</option> <option value="work" selected?={ selected == "work" }>Work</option>
</select>
}
// phoneTypeSelect renders the phone TYPE dropdown. An unspecified number is
// shown as "Mobile" (the vCard "cell" type), matching how mobile clients
// label a contact's main number.
templ phoneTypeSelect(name, selected string) {
<select name={ name } class="rounded-md border-gray-300 border px-2 py-1.5 text-sm">
<option value="cell" selected?={ selected == "cell" || selected == "" }>Mobile</option>
<option value="home" selected?={ selected == "home" }>Home</option>
<option value="work" selected?={ selected == "work" }>Work</option>
</select> </select>
} }
templ phoneRow(p LabeledValue) { templ phoneRow(p LabeledValue) {
<div class="form-row flex gap-2 items-center"> <div class="form-row flex gap-2 items-center">
@typeSelect("phone_type", p.Type) @phoneTypeSelect("phone_type", p.Type)
<input name="phone_value" type="tel" value={ p.Value } placeholder="Telefonnummer" <input name="phone_value" type="tel" value={ p.Value } placeholder="Phone number"
class="flex-1 rounded-md border-gray-300 border px-2 py-1.5 text-sm"/> 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> <button type="button" class="remove-row-btn text-red-600 hover:underline text-xs shrink-0">Remove</button>
</div> </div>
} }
templ emailRow(p LabeledValue) { templ emailRow(p LabeledValue) {
<div class="form-row flex gap-2 items-center"> <div class="form-row flex gap-2 items-center">
@typeSelect("email_type", p.Type) @typeSelect("email_type", p.Type)
<input name="email_value" type="email" value={ p.Value } placeholder="E-Mail-Adresse" <input name="email_value" type="email" value={ p.Value } placeholder="Email address"
class="flex-1 rounded-md border-gray-300 border px-2 py-1.5 text-sm"/> 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> <button type="button" class="remove-row-btn text-red-600 hover:underline text-xs shrink-0">Remove</button>
</div> </div>
} }
templ addressRow(a AddressValue) { 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="form-row flex gap-2 items-center">
<div class="col-span-2">
@typeSelect("address_type", a.Type) @typeSelect("address_type", a.Type)
</div> <input name="address_value" type="text" value={ a.Value } placeholder="Street, city, postal code, country"
<input name="address_street" type="text" value={ a.Street } placeholder="Straße und Hausnummer" class="flex-1 rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
class="col-span-2 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">Remove</button>
<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> </div>
} }
@@ -230,67 +229,74 @@ templ ContactForm(username string, data ContactFormData, errMsg string) {
<img id="photo-preview" src="" alt="" class="w-20 h-20 rounded-full object-cover hidden"/> <img id="photo-preview" src="" alt="" class="w-20 h-20 rounded-full object-cover hidden"/>
<div class="space-y-1"> <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"> <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 Choose photo
<input id="photo-input" name="photo" type="file" accept="image/*" class="hidden"/> <input id="photo-input" name="photo" type="file" accept="image/*" class="hidden"/>
</label> </label>
if data.PhotoDataURL != "" { if data.PhotoDataURL != "" {
<label class="flex items-center gap-1 text-xs text-gray-500"> <label class="flex items-center gap-1 text-xs text-gray-500">
<input id="remove-photo" name="remove_photo" type="checkbox" value="1"/> <input id="remove-photo" name="remove_photo" type="checkbox" value="1"/>
Foto entfernen Remove photo
</label> </label>
} }
</div> </div>
</div> </div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
<label class="block text-sm font-medium text-gray-700">Full name</label> <label class="block text-sm font-medium text-gray-700">First name</label>
<input name="full_name" type="text" required value={ data.FullName } <input name="forename" type="text" value={ data.Forename }
class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm"/> class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm"/>
</div> </div>
<div>
<label class="block text-sm font-medium text-gray-700">Last name</label>
<input name="surname" type="text" value={ data.Surname }
class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm"/>
</div>
</div>
<div> <div>
<label class="block text-sm font-medium text-gray-700">Organization</label> <label class="block text-sm font-medium text-gray-700">Organization</label>
<input name="organization" type="text" value={ data.Organization } <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"/> class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm"/>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700">Geburtstag</label> <label class="block text-sm font-medium text-gray-700">Birthday</label>
<input name="birthday" type="date" value={ data.Birthday } <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"/> class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm"/>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Telefonnummern</label> <label class="block text-sm font-medium text-gray-700 mb-2">Phones</label>
<div id="phones-container" class="space-y-2"> <div id="phones-container" class="space-y-2">
for _, p := range data.Phones { for _, p := range data.Phones {
@phoneRow(p) @phoneRow(p)
} }
</div> </div>
<button type="button" data-add-target="phones-container" class="add-row-btn mt-2 text-sm text-indigo-600 hover:underline"> <button type="button" data-add-target="phones-container" class="add-row-btn mt-2 text-sm text-indigo-600 hover:underline">
+ Telefonnummer hinzufügen + Add phone number
</button> </button>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">E-Mail-Adressen</label> <label class="block text-sm font-medium text-gray-700 mb-2">Emails</label>
<div id="emails-container" class="space-y-2"> <div id="emails-container" class="space-y-2">
for _, e := range data.Emails { for _, e := range data.Emails {
@emailRow(e) @emailRow(e)
} }
</div> </div>
<button type="button" data-add-target="emails-container" class="add-row-btn mt-2 text-sm text-indigo-600 hover:underline"> <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 + Add email address
</button> </button>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Adressen</label> <label class="block text-sm font-medium text-gray-700 mb-2">Addresses</label>
<div id="addresses-container" class="space-y-2"> <div id="addresses-container" class="space-y-2">
for _, a := range data.Addresses { for _, a := range data.Addresses {
@addressRow(a) @addressRow(a)
} }
</div> </div>
<button type="button" data-add-target="addresses-container" class="add-row-btn mt-2 text-sm text-indigo-600 hover:underline"> <button type="button" data-add-target="addresses-container" class="add-row-btn mt-2 text-sm text-indigo-600 hover:underline">
+ Adresse hinzufügen + Add address
</button> </button>
</div> </div>
+249 -215
View File
@@ -34,14 +34,11 @@ type LabeledValue struct {
Value string Value string
} }
// AddressValue is one ADR entry with its TYPE parameter. // AddressValue is one ADR entry with its TYPE parameter; the full address
// is captured as a single free-form string.
type AddressValue struct { type AddressValue struct {
Type string Type string
Street string Value string
City string
Region string
PostalCode string
Country string
} }
// ContactFormData pre-fills the create/edit contact form. Phones, Emails, // ContactFormData pre-fills the create/edit contact form. Phones, Emails,
@@ -50,7 +47,8 @@ type AddressValue struct {
type ContactFormData struct { type ContactFormData struct {
Book string Book string
ID string // empty when creating a new contact ID string // empty when creating a new contact
FullName string Forename string
Surname string
Organization string Organization string
Birthday string // "YYYY-MM-DD", empty if not set Birthday string // "YYYY-MM-DD", empty if not set
Note string Note string
@@ -115,7 +113,7 @@ func ContactsHome(username string, books []AddressBookSummary) templ.Component {
var templ_7745c5c3_Var3 templ.SafeURL var templ_7745c5c3_Var3 templ.SafeURL
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + b.Name)) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + b.Name))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 67, Col: 52} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 65, Col: 52}
} }
_, 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 {
@@ -128,7 +126,7 @@ func ContactsHome(username string, books []AddressBookSummary) templ.Component {
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(b.Name) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(b.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 67, Col: 115} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 65, Col: 115}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -141,7 +139,7 @@ func ContactsHome(username string, books []AddressBookSummary) templ.Component {
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d contact(s)", b.Count)) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d contact(s)", b.Count))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 68, Col: 73} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 66, Col: 73}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -204,7 +202,7 @@ func avatar(photoDataURL, sizeClass string) templ.Component {
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(photoDataURL) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(photoDataURL)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 81, Col: 25} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 79, Col: 25}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -295,7 +293,7 @@ func ContactsList(username, book string, contacts []ContactSummary) templ.Compon
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(book) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(book)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 94, Col: 45} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 92, Col: 45}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -308,7 +306,7 @@ func ContactsList(username, book string, contacts []ContactSummary) templ.Compon
var templ_7745c5c3_Var15 templ.SafeURL var templ_7745c5c3_Var15 templ.SafeURL
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/new")) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/new"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 97, Col: 57} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 95, Col: 57}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -321,7 +319,7 @@ func ContactsList(username, book string, contacts []ContactSummary) templ.Compon
var templ_7745c5c3_Var16 templ.SafeURL var templ_7745c5c3_Var16 templ.SafeURL
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/export")) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/export"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 101, Col: 60} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 99, Col: 60}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -334,7 +332,7 @@ func ContactsList(username, book string, contacts []ContactSummary) templ.Compon
var templ_7745c5c3_Var17 templ.SafeURL var templ_7745c5c3_Var17 templ.SafeURL
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/import")) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/import"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 107, Col: 60} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 105, Col: 60}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -360,7 +358,7 @@ func ContactsList(username, book string, contacts []ContactSummary) templ.Compon
var templ_7745c5c3_Var18 templ.SafeURL var templ_7745c5c3_Var18 templ.SafeURL
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/" + c.ID + "/edit")) templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/" + c.ID + "/edit"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 137, Col: 75} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 135, Col: 75}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -373,7 +371,7 @@ func ContactsList(username, book string, contacts []ContactSummary) templ.Compon
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(c.FullName) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(c.FullName)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 137, Col: 130} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 135, Col: 130}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -386,7 +384,7 @@ func ContactsList(username, book string, contacts []ContactSummary) templ.Compon
var templ_7745c5c3_Var20 string var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(c.Organization) templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(c.Organization)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 139, Col: 92} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 137, Col: 92}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -399,7 +397,7 @@ func ContactsList(username, book string, contacts []ContactSummary) templ.Compon
var templ_7745c5c3_Var21 string var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(c.Phone) templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(c.Phone)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 140, Col: 91} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 138, Col: 91}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -412,7 +410,7 @@ func ContactsList(username, book string, contacts []ContactSummary) templ.Compon
var templ_7745c5c3_Var22 string var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(c.Email) templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(c.Email)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 141, Col: 85} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 139, Col: 85}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -425,7 +423,7 @@ func ContactsList(username, book string, contacts []ContactSummary) templ.Compon
var templ_7745c5c3_Var23 templ.SafeURL var templ_7745c5c3_Var23 templ.SafeURL
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/" + c.ID + "/export")) templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/" + c.ID + "/export"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 143, Col: 77} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 141, Col: 77}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -438,7 +436,7 @@ func ContactsList(username, book string, contacts []ContactSummary) templ.Compon
var templ_7745c5c3_Var24 templ.SafeURL var templ_7745c5c3_Var24 templ.SafeURL
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/" + c.ID + "/delete")) templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/" + c.ID + "/delete"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 144, Col: 96} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 142, Col: 96}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -469,7 +467,7 @@ func ContactsList(username, book string, contacts []ContactSummary) templ.Compon
}) })
} }
// typeSelect renders the TYPE dropdown shared by phone/email/address rows. // typeSelect renders the TYPE dropdown shared by email/address rows.
func typeSelect(name, selected string) templ.Component { func typeSelect(name, selected string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 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 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
@@ -498,7 +496,7 @@ func typeSelect(name, selected string) templ.Component {
var templ_7745c5c3_Var26 string var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(name) templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 163, Col: 20} 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) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -514,7 +512,7 @@ func typeSelect(name, selected string) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">Sonstige</option> <option value=\"home\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">Other</option> <option value=\"home\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -524,7 +522,7 @@ func typeSelect(name, selected string) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Privat</option> <option value=\"work\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Home</option> <option value=\"work\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -534,7 +532,82 @@ func typeSelect(name, selected string) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Geschäftlich</option></select>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Work</option></select>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// phoneTypeSelect renders the phone TYPE dropdown. An unspecified number is
// shown as "Mobile" (the vCard "cell" type), matching how mobile clients
// label a contact's main number.
func phoneTypeSelect(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_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, "<select name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, 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: 172, Col: 20}
}
_, 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, 39, "\" class=\"rounded-md border-gray-300 border px-2 py-1.5 text-sm\"><option value=\"cell\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if selected == "cell" || selected == "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">Mobile</option> <option value=\"home\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if selected == "home" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">Home</option> <option value=\"work\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if selected == "work" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">Work</option></select>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -558,33 +631,33 @@ func phoneRow(p LabeledValue) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var27 := templ.GetChildren(ctx) templ_7745c5c3_Var29 := templ.GetChildren(ctx)
if templ_7745c5c3_Var27 == nil { if templ_7745c5c3_Var29 == nil {
templ_7745c5c3_Var27 = templ.NopComponent templ_7745c5c3_Var29 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"form-row flex gap-2 items-center\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<div class=\"form-row flex gap-2 items-center\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = typeSelect("phone_type", p.Type).Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = phoneTypeSelect("phone_type", p.Type).Render(ctx, templ_7745c5c3_Buffer)
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, 39, "<input name=\"phone_value\" type=\"tel\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<input name=\"phone_value\" type=\"tel\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var28 string var templ_7745c5c3_Var30 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Value) templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Value)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 173, Col: 54} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 182, Col: 54}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
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, 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" placeholder=\"Phone number\" 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\">Remove</button></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -608,12 +681,12 @@ func emailRow(p LabeledValue) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var29 := templ.GetChildren(ctx) templ_7745c5c3_Var31 := templ.GetChildren(ctx)
if templ_7745c5c3_Var29 == nil { if templ_7745c5c3_Var31 == nil {
templ_7745c5c3_Var29 = templ.NopComponent templ_7745c5c3_Var31 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<div class=\"form-row flex gap-2 items-center\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"form-row flex gap-2 items-center\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -621,20 +694,20 @@ func emailRow(p LabeledValue) 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, 42, "<input name=\"email_value\" type=\"email\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<input name=\"email_value\" type=\"email\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var30 string var templ_7745c5c3_Var32 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Value) templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Value)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 182, Col: 56} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 191, Col: 56}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
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, 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" placeholder=\"Email address\" 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\">Remove</button></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -658,12 +731,12 @@ func addressRow(a AddressValue) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var31 := templ.GetChildren(ctx) templ_7745c5c3_Var33 := templ.GetChildren(ctx)
if templ_7745c5c3_Var31 == nil { if templ_7745c5c3_Var33 == nil {
templ_7745c5c3_Var31 = templ.NopComponent templ_7745c5c3_Var33 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) 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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<div class=\"form-row flex gap-2 items-center\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -671,72 +744,20 @@ func addressRow(a AddressValue) 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, 45, "</div><input name=\"address_street\" type=\"text\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<input name=\"address_value\" 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: 193, 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: 195, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var34 string var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(a.PostalCode) templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(a.Value)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 197, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 200, Col: 57}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
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, 48, "\" placeholder=\"PLZ\" class=\"rounded-md border-gray-300 border px-2 py-1.5 text-sm\"> <input name=\"address_region\" type=\"text\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\" placeholder=\"Street, city, postal code, country\" 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\">Remove</button></div>")
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: 199, 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: 201, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -760,12 +781,12 @@ func ContactForm(username string, data ContactFormData, errMsg string) templ.Com
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var37 := templ.GetChildren(ctx) templ_7745c5c3_Var35 := templ.GetChildren(ctx)
if templ_7745c5c3_Var37 == nil { if templ_7745c5c3_Var35 == nil {
templ_7745c5c3_Var37 = templ.NopComponent templ_7745c5c3_Var35 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var38 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_Var36 := 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_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer { if !templ_7745c5c3_IsBuffer {
@@ -777,84 +798,84 @@ func ContactForm(username string, data ContactFormData, errMsg string) templ.Com
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<a href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var39 templ.SafeURL var templ_7745c5c3_Var37 templ.SafeURL
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + data.Book)) templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + data.Book))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 209, Col: 51} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 208, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" class=\"text-sm text-indigo-600 hover:underline\">&larr; ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, 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: 208, Col: 120}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</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, 58, "New contact")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "Edit contact")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</h1>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if errMsg != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<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_Var39 string
templ_7745c5c3_Var39, 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: 217, Col: 98}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
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, 52, "\" class=\"text-sm text-indigo-600 hover:underline\">&larr; ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</p>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var40 string }
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(data.Book) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, " <form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 209, Col: 120} return templ_7745c5c3_Err
}
var templ_7745c5c3_Var40 templ.SafeURL
templ_7745c5c3_Var40, 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: 221, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
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, 53, "</a><h1 class=\"text-2xl font-semibold mt-2 mb-6\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\" 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
}
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: 218, 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: 222, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -862,56 +883,69 @@ func ContactForm(username string, data ContactFormData, errMsg string) templ.Com
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, 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> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</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\">Choose photo <input id=\"photo-input\" name=\"photo\" type=\"file\" accept=\"image/*\" class=\"hidden\"></label> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if data.PhotoDataURL != "" { 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<label class=\"flex items-center gap-1 text-xs text-gray-500\"><input id=\"remove-photo\" name=\"remove_photo\" type=\"checkbox\" value=\"1\"> Remove photo</label>")
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, 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=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</div></div><div class=\"grid grid-cols-1 sm:grid-cols-2 gap-3\"><div><label class=\"block text-sm font-medium text-gray-700\">First name</label> <input name=\"forename\" type=\"text\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Forename)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 247, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\" 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\">Last name</label> <input name=\"surname\" type=\"text\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Surname)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 252, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\" class=\"mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm\"></div></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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var43 string var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.FullName) templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Organization)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 247, Col: 70} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 258, Col: 68}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
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, 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=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" 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\">Birthday</label> <input name=\"birthday\" type=\"date\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var44 string var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Organization) templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Birthday)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 252, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 263, Col: 60}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
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, 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=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" 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\">Phones</label><div id=\"phones-container\" class=\"space-y-2\">")
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: 257, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -921,7 +955,7 @@ func ContactForm(username string, data ContactFormData, errMsg string) templ.Com
return templ_7745c5c3_Err 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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</div><button type=\"button\" data-add-target=\"phones-container\" class=\"add-row-btn mt-2 text-sm text-indigo-600 hover:underline\">+ Add phone number</button></div><div><label class=\"block text-sm font-medium text-gray-700 mb-2\">Emails</label><div id=\"emails-container\" class=\"space-y-2\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -931,7 +965,7 @@ func ContactForm(username string, data ContactFormData, errMsg string) templ.Com
return templ_7745c5c3_Err 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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</div><button type=\"button\" data-add-target=\"emails-container\" class=\"add-row-btn mt-2 text-sm text-indigo-600 hover:underline\">+ Add email address</button></div><div><label class=\"block text-sm font-medium text-gray-700 mb-2\">Addresses</label><div id=\"addresses-container\" class=\"space-y-2\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -941,39 +975,39 @@ func ContactForm(username string, data ContactFormData, errMsg string) templ.Com
return templ_7745c5c3_Err 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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</div><button type=\"button\" data-add-target=\"addresses-container\" class=\"add-row-btn mt-2 text-sm text-indigo-600 hover:underline\">+ Add address</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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var46 string var templ_7745c5c3_Var45 string
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(data.Note) templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(data.Note)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 300, Col: 96} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 306, Col: 96}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</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_Var46 templ.SafeURL
templ_7745c5c3_Var46, 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: 312, Col: 53}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
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, 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=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "\" 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
}
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: 306, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
return nil return nil
}) })
templ_7745c5c3_Err = Layout("Contacts", username).Render(templ.WithChildren(ctx, templ_7745c5c3_Var38), templ_7745c5c3_Buffer) templ_7745c5c3_Err = Layout("Contacts", username).Render(templ.WithChildren(ctx, templ_7745c5c3_Var36), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+29 -4
View File
@@ -16,7 +16,7 @@ type FileEntry struct {
RelPath string // relative path (no leading slash) used to build the link RelPath string // relative path (no leading slash) used to build the link
} }
templ FilesPage(username string, breadcrumbs []Breadcrumb, entries []FileEntry, currentPath string) { templ FilesPage(username string, breadcrumbs []Breadcrumb, entries []FileEntry, currentPath string, sortBy string, sortDir string) {
@Layout("Files", username) { @Layout("Files", username) {
<div class="flex items-center justify-between mb-6 gap-4 flex-wrap"> <div class="flex items-center justify-between mb-6 gap-4 flex-wrap">
<h1 class="text-2xl font-semibold">Files</h1> <h1 class="text-2xl font-semibold">Files</h1>
@@ -33,6 +33,10 @@ templ FilesPage(username string, breadcrumbs []Breadcrumb, entries []FileEntry,
Upload folder Upload folder
<input id="upload-folder-input" type="file" webkitdirectory multiple class="hidden"/> <input id="upload-folder-input" type="file" webkitdirectory multiple class="hidden"/>
</label> </label>
<select id="sort-select" class="rounded-md border-gray-300 border px-2 py-1.5 text-sm text-gray-700">
<option value="name_asc" selected?={ sortBy == "name" && sortDir == "asc" }>Name (asc)</option>
<option value="name_desc" selected?={ sortBy == "name" && sortDir == "desc" }>Name (desc)</option>
</select>
</div> </div>
</div> </div>
@@ -48,6 +52,8 @@ templ FilesPage(username string, breadcrumbs []Breadcrumb, entries []FileEntry,
id="file-drop-zone" id="file-drop-zone"
data-current-path={ currentPath } data-current-path={ currentPath }
data-upload-url={ "/web/files/" + currentPath } data-upload-url={ "/web/files/" + currentPath }
data-sort-by={ sortBy }
data-sort-dir={ sortDir }
class="bg-white rounded-lg border-2 border-dashed border-gray-200 p-2 transition-colors" class="bg-white rounded-lg border-2 border-dashed border-gray-200 p-2 transition-colors"
> >
<!-- Table layout for wider screens (sm and up). --> <!-- Table layout for wider screens (sm and up). -->
@@ -60,9 +66,29 @@ templ FilesPage(username string, breadcrumbs []Breadcrumb, entries []FileEntry,
</colgroup> </colgroup>
<thead> <thead>
<tr class="text-left text-gray-400 border-b border-gray-100"> <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">
if sortBy == "name" {
if sortDir == "asc" {
<a href={ templ.URL("/web/files/" + currentPath + "?sort_by=name&sort_dir=desc") } class="hover:text-indigo-600">Name <span class="ml-1 text-xs"></span></a>
} else {
<a href={ templ.URL("/web/files/" + currentPath + "?sort_by=name&sort_dir=asc") } class="hover:text-indigo-600">Name <span class="ml-1 text-xs"></span></a>
}
} else {
<a href={ templ.URL("/web/files/" + currentPath + "?sort_by=name&sort_dir=asc") } class="hover:text-indigo-600">Name</a>
}
</th>
<th class="py-2 px-3 font-medium">Size</th> <th class="py-2 px-3 font-medium">Size</th>
<th class="py-2 px-3 font-medium">Modified</th> <th class="py-2 px-3 font-medium">
if sortBy == "modtime" {
if sortDir == "asc" {
<a href={ templ.URL("/web/files/" + currentPath + "?sort_by=modtime&sort_dir=desc") } class="hover:text-indigo-600">Modified <span class="ml-1 text-xs"></span></a>
} else {
<a href={ templ.URL("/web/files/" + currentPath + "?sort_by=modtime&sort_dir=asc") } class="hover:text-indigo-600">Modified <span class="ml-1 text-xs"></span></a>
}
} else {
<a href={ templ.URL("/web/files/" + currentPath + "?sort_by=modtime&sort_dir=asc") } class="hover:text-indigo-600">Modified</a>
}
</th>
<th class="py-2 px-3 font-medium"></th> <th class="py-2 px-3 font-medium"></th>
</tr> </tr>
</thead> </thead>
@@ -163,4 +189,3 @@ templ fileDeleteButton(e FileEntry) {
Delete Delete
</button> </button>
} }
+296 -128
View File
@@ -24,7 +24,7 @@ type FileEntry struct {
RelPath string // relative path (no leading slash) used to build the link RelPath string // relative path (no leading slash) used to build the link
} }
func FilesPage(username string, breadcrumbs []Breadcrumb, entries []FileEntry, currentPath string) templ.Component { func FilesPage(username string, breadcrumbs []Breadcrumb, entries []FileEntry, currentPath string, sortBy string, sortDir string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 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 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -57,176 +57,276 @@ func FilesPage(username string, breadcrumbs []Breadcrumb, entries []FileEntry, c
}() }()
} }
ctx = templ.InitializeContext(ctx) 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 flex-wrap\"><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> ") 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 flex-wrap\"><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> <select id=\"sort-select\" class=\"rounded-md border-gray-300 border px-2 py-1.5 text-sm text-gray-700\"><option value=\"name_asc\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if sortBy == "name" && sortDir == "asc" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, ">Name (asc)</option> <option value=\"name_desc\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if sortBy == "name" && sortDir == "desc" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, ">Name (desc)</option></select></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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, bc := range breadcrumbs { for _, bc := range breadcrumbs {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<span>/</span> <a href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<span>/</span> <a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 templ.SafeURL var templ_7745c5c3_Var3 templ.SafeURL
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + bc.Path + "/")) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + bc.Path + "/"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 43, Col: 54} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 47, Col: 54}
} }
_, 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, 3, "\" class=\"hover:underline text-indigo-600\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" class=\"hover:underline text-indigo-600\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(bc.Name) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(bc.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 43, Col: 106} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 47, Col: 106}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
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, "</a>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</a>")
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, "</nav><div id=\"file-drop-zone\" data-current-path=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</nav><div id=\"file-drop-zone\" data-current-path=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 49, Col: 34} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 53, Col: 34}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
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, "\" data-upload-url=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" data-upload-url=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/web/files/" + currentPath) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/web/files/" + currentPath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 50, Col: 48} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 54, Col: 48}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
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, 7, "\" class=\"bg-white rounded-lg border-2 border-dashed border-gray-200 p-2 transition-colors\"><!-- Table layout for wider screens (sm and up). --><table class=\"hidden sm:table w-full text-sm table-fixed\"><colgroup><col class=\"w-auto\"> <col class=\"w-20\"> <col class=\"w-36\"> <col class=\"w-16\"></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><th class=\"py-2 px-3 font-medium\"></th></tr></thead> <tbody>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" data-sort-by=\"")
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
}
templ_7745c5c3_Err = fileEntryLink(e).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</td><td class=\"py-2 px-3 text-gray-500 whitespace-nowrap\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(e.Size) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(sortBy)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 75, Col: 69} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 55, Col: 24}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
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, 10, "</td><td class=\"py-2 px-3 text-gray-500 whitespace-nowrap\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" data-sort-dir=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(e.ModTime) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(sortDir)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 76, Col: 72} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 56, Col: 26}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
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, 11, "</td><td class=\"py-2 px-3 text-right whitespace-nowrap\"><div class=\"flex items-center justify-end gap-3\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" class=\"bg-white rounded-lg border-2 border-dashed border-gray-200 p-2 transition-colors\"><!-- Table layout for wider screens (sm and up). --><table class=\"hidden sm:table w-full text-sm table-fixed\"><colgroup><col class=\"w-auto\"> <col class=\"w-20\"> <col class=\"w-36\"> <col class=\"w-16\"></colgroup> <thead><tr class=\"text-left text-gray-400 border-b border-gray-100\"><th class=\"py-2 px-3 font-medium\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = fileActions(e).Render(ctx, templ_7745c5c3_Buffer) if sortBy == "name" {
if sortDir == "asc" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<a href=\"")
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, 12, "</div></td></tr>") var templ_7745c5c3_Var9 templ.SafeURL
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + currentPath + "?sort_by=name&sort_dir=desc"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 72, Col: 89}
}
}
if len(entries) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<tr><td colspan=\"4\" class=\"py-6 px-3 text-center text-gray-400\">This folder is empty — drag &amp; drop files or folders here, or use the upload buttons above.</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</tbody></table><!-- Stacked card layout for narrow screens: same information as\n\t\t\t the table above (name, size, modified, delete), just\n\t\t\t wrapped onto its own lines instead of squeezed into\n\t\t\t columns, so nothing needs to be hidden or scrolled to. --><ul class=\"sm:hidden divide-y divide-gray-100\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, e := range entries {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<li class=\"py-2.5 px-1\"><div class=\"break-all\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = fileEntryLink(e).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div><div class=\"mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500 pl-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if e.Size != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, 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: 106, Col: 22}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
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, 18, "</span> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" class=\"hover:text-indigo-600\">Name <span class=\"ml-1 text-xs\">↓</span></a>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 templ.SafeURL
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(e.ModTime) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + currentPath + "?sort_by=name&sort_dir=asc"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 108, Col: 24} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 74, Col: 88}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
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, 20, "</span> <span class=\"ml-auto flex items-center gap-3\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" class=\"hover:text-indigo-600\">Name <span class=\"ml-1 text-xs\">↑</span></a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 templ.SafeURL
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + currentPath + "?sort_by=name&sort_dir=asc"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 77, Col: 87}
}
_, 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, 19, "\" class=\"hover:text-indigo-600\">Name</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</th><th class=\"py-2 px-3 font-medium\">Size</th><th class=\"py-2 px-3 font-medium\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if sortBy == "modtime" {
if sortDir == "asc" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 templ.SafeURL
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + currentPath + "?sort_by=modtime&sort_dir=desc"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 84, Col: 92}
}
_, 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, 22, "\" class=\"hover:text-indigo-600\">Modified <span class=\"ml-1 text-xs\">↓</span></a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 templ.SafeURL
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + currentPath + "?sort_by=modtime&sort_dir=asc"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 86, Col: 91}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" class=\"hover:text-indigo-600\">Modified <span class=\"ml-1 text-xs\">↑</span></a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 templ.SafeURL
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + currentPath + "?sort_by=modtime&sort_dir=asc"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 89, Col: 90}
}
_, 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, 26, "\" class=\"hover:text-indigo-600\">Modified</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</th><th class=\"py-2 px-3 font-medium\"></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, 28, "<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
}
templ_7745c5c3_Err = fileEntryLink(e).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</td><td class=\"py-2 px-3 text-gray-500 whitespace-nowrap\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, 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: 101, Col: 69}
}
_, 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, 30, "</td><td class=\"py-2 px-3 text-gray-500 whitespace-nowrap\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, 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: 102, Col: 72}
}
_, 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, 31, "</td><td class=\"py-2 px-3 text-right whitespace-nowrap\"><div class=\"flex items-center justify-end gap-3\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -234,18 +334,86 @@ func FilesPage(username string, breadcrumbs []Breadcrumb, entries []FileEntry, c
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, 21, "</span></div></li>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div></td></tr>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
if len(entries) == 0 { if len(entries) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<li class=\"py-6 px-3 text-center text-gray-400 text-sm\">This folder is empty — drag &amp; drop files or folders here, or use the upload buttons above.</li>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<tr><td colspan=\"4\" class=\"py-6 px-3 text-center text-gray-400\">This folder is empty — drag &amp; drop files or folders here, or use the upload buttons above.</td></tr>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</ul></div><p id=\"upload-status\" class=\"mt-3 text-sm text-gray-500\"></p><script type=\"module\" src=\"/web/static/files.js\"></script>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</tbody></table><!-- Stacked card layout for narrow screens: same information as\n\t\t\t the table above (name, size, modified, delete), just\n\t\t\t wrapped onto its own lines instead of squeezed into\n\t\t\t columns, so nothing needs to be hidden or scrolled to. --><ul class=\"sm:hidden divide-y divide-gray-100\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, e := range entries {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<li class=\"py-2.5 px-1\"><div class=\"break-all\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = fileEntryLink(e).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</div><div class=\"mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500 pl-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if e.Size != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, 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: 132, Col: 22}
}
_, 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, 38, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, 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: 134, Col: 24}
}
_, 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, 40, "</span> <span class=\"ml-auto flex items-center gap-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = fileActions(e).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</span></div></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(entries) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<li class=\"py-6 px-3 text-center text-gray-400 text-sm\">This folder is empty — drag &amp; drop files or folders here, or use the upload buttons above.</li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</ul></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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -275,70 +443,70 @@ func fileEntryLink(e FileEntry) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var11 := templ.GetChildren(ctx) templ_7745c5c3_Var19 := templ.GetChildren(ctx)
if templ_7745c5c3_Var11 == nil { if templ_7745c5c3_Var19 == nil {
templ_7745c5c3_Var11 = templ.NopComponent templ_7745c5c3_Var19 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
if e.IsDir { if e.IsDir {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<a href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var12 templ.SafeURL var templ_7745c5c3_Var20 templ.SafeURL
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + e.RelPath + "/")) templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + e.RelPath + "/"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 129, Col: 54} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 155, Col: 54}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
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, 25, "\" class=\"flex items-center gap-2 text-indigo-600 hover:underline\"><span aria-hidden=\"true\">📁</span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" class=\"flex items-center gap-2 text-indigo-600 hover:underline\"><span aria-hidden=\"true\">📁</span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var21 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(e.Name) templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(e.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 130, Col: 47} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 156, Col: 47}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
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, 26, "</a>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</a>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<a href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var14 templ.SafeURL var templ_7745c5c3_Var22 templ.SafeURL
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + e.RelPath)) templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + e.RelPath))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 133, Col: 48} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 159, Col: 48}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
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, 28, "\" target=\"_blank\" rel=\"noopener\" class=\"flex items-center gap-2 text-gray-700 hover:underline\"><span aria-hidden=\"true\">📄</span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" target=\"_blank\" rel=\"noopener\" class=\"flex items-center gap-2 text-gray-700 hover:underline\"><span aria-hidden=\"true\">📄</span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var23 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(e.Name) templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(e.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 134, Col: 47} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 160, Col: 47}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
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, 29, "</a>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</a>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -368,26 +536,26 @@ func fileActions(e FileEntry) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var16 := templ.GetChildren(ctx) templ_7745c5c3_Var24 := templ.GetChildren(ctx)
if templ_7745c5c3_Var16 == nil { if templ_7745c5c3_Var24 == nil {
templ_7745c5c3_Var16 = templ.NopComponent templ_7745c5c3_Var24 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
if !e.IsDir { if !e.IsDir {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<a href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var17 templ.SafeURL var templ_7745c5c3_Var25 templ.SafeURL
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + e.RelPath + "?download=1")) templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + e.RelPath + "?download=1"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 147, Col: 62} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 173, Col: 62}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
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, 31, "\" class=\"text-gray-500 hover:text-indigo-600 hover:underline text-xs font-medium\">Download</a>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" class=\"text-gray-500 hover:text-indigo-600 hover:underline text-xs font-medium\">Download</a>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -416,38 +584,38 @@ func fileDeleteButton(e FileEntry) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var18 := templ.GetChildren(ctx) templ_7745c5c3_Var26 := templ.GetChildren(ctx)
if templ_7745c5c3_Var18 == nil { if templ_7745c5c3_Var26 == nil {
templ_7745c5c3_Var18 = templ.NopComponent templ_7745c5c3_Var26 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<button type=\"button\" class=\"delete-entry-button text-red-500 hover:text-red-700 text-xs font-medium\" data-path=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<button type=\"button\" class=\"delete-entry-button text-red-500 hover:text-red-700 text-xs font-medium\" data-path=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var27 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.RelPath) templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.RelPath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 160, Col: 23} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 186, Col: 23}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
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, 33, "\" data-name=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" data-name=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var20 string var templ_7745c5c3_Var28 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Name) templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 161, Col: 20} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 187, Col: 20}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
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, 34, "\">Delete</button>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\">Delete</button>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+4 -4
View File
@@ -7,14 +7,14 @@ import (
"path/filepath" "path/filepath"
"sync" "sync"
"github.com/yourusername/caldav-server/internal/auth" "git.arnef.de/arnef/nidus/internal/auth"
"github.com/yourusername/caldav-server/internal/config" "git.arnef.de/arnef/nidus/internal/config"
xwebdav "golang.org/x/net/webdav" xwebdav "golang.org/x/net/webdav"
) )
// NewHandler returns an http.Handler that provides standard WebDAV file access, // NewHandler returns an http.Handler that provides standard WebDAV file access,
// mounted at the fixed URL /files/ for every user and rooted at // mounted at the fixed URL /files/ for every user and rooted at
// dataDir/files/<username>/ on disk. The URL is the same for all users — // dataDir/<username>/files on disk. The URL is the same for all users —
// which user's directory is served is resolved from the Basic Auth identity // which user's directory is served is resolved from the Basic Auth identity
// in the request context, not from the URL. // in the request context, not from the URL.
// //
@@ -38,7 +38,7 @@ func NewHandler(cfg *config.Config, dataDir string, logger *slog.Logger) http.Ha
h, ok := handlers[p.Username] h, ok := handlers[p.Username]
if !ok { if !ok {
username := p.Username username := p.Username
userDir := filepath.Join(dataDir, "files", username) userDir := filepath.Join(dataDir, username, "files")
if err := os.MkdirAll(userDir, 0o755); err != nil { if err := os.MkdirAll(userDir, 0o755); err != nil {
mu.Unlock() mu.Unlock()
logger.Error("creating user WebDAV dir", "user", username, "error", err) logger.Error("creating user WebDAV dir", "user", username, "error", err)
+5 -5
View File
@@ -9,8 +9,8 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/yourusername/caldav-server/internal/auth" "git.arnef.de/arnef/nidus/internal/auth"
filewebdav "github.com/yourusername/caldav-server/internal/webdav" filewebdav "git.arnef.de/arnef/nidus/internal/webdav"
) )
func testLogger() *slog.Logger { func testLogger() *slog.Logger {
@@ -54,10 +54,10 @@ func TestPerUserIsolationAndPrefix(t *testing.T) {
t.Fatalf("expected bob to get 404 for alice's file, got %d", bobRec.Code) t.Fatalf("expected bob to get 404 for alice's file, got %d", bobRec.Code)
} }
// Confirm the file physically landed under dataDir/files/alice/, not // Confirm the file physically landed under dataDir/alice/files/, not
// nested under an extra files/files/... path. // nested under an extra files/files/... path.
if _, err := os.Stat(dir + "/files/alice/note.txt"); err != nil { if _, err := os.Stat(dir + "/alice/files/note.txt"); err != nil {
t.Fatalf("expected file at dataDir/files/alice/note.txt: %v", err) t.Fatalf("expected file at dataDir/alice/files/note.txt: %v", err)
} }
} }
+62
View File
@@ -0,0 +1,62 @@
// Command migrate is a tool to restructure the data directory from the
// old format to the new unified format.
package main
import (
"flag"
"fmt"
"log/slog"
"os"
"git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/store"
)
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
fs := flag.NewFlagSet("migrate", flag.ContinueOnError)
verbose := fs.Bool("verbose", false, "enable verbose output")
if err := fs.Parse(args); err != nil {
return 2
}
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "error loading config: %v\n", err)
return 1
}
logger := buildLogger(*verbose)
st, err := store.NewStore(cfg.Storage.DataDir)
if err != nil {
fmt.Fprintf(os.Stderr, "error opening store %q: %v\n", cfg.Storage.DataDir, err)
return 1
}
logger.Info("starting migration", "data_dir", cfg.Storage.DataDir)
if err := st.Migrate(); err != nil {
logger.Error("migration failed", "error", err)
fmt.Fprintf(os.Stderr, "migration failed: %v\n", err)
return 1
}
logger.Info("migration completed successfully")
fmt.Println("Migration completed successfully")
return 0
}
func buildLogger(verbose bool) *slog.Logger {
level := slog.LevelInfo
if verbose {
level = slog.LevelDebug
}
handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: level,
})
return slog.New(handler)
}
+54 -48
View File
@@ -8,14 +8,13 @@
package main package main
import ( import (
"flag"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"github.com/yourusername/caldav-server/internal/config" "git.arnef.de/arnef/nidus/internal/config"
"github.com/yourusername/caldav-server/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"github.com/yourusername/caldav-server/internal/store" "git.arnef.de/arnef/nidus/internal/store"
) )
func main() { func main() {
@@ -23,18 +22,12 @@ func main() {
} }
func run(args []string) int { func run(args []string) int {
fs := flag.NewFlagSet("nidusctl", flag.ContinueOnError) if len(args) < 1 {
cfgPath := fs.String("config", "config.yaml", "path to configuration file")
if err := fs.Parse(args); err != nil {
return 2
}
rest := fs.Args()
if len(rest) < 1 {
usage() usage()
return 2 return 2
} }
cfg, err := config.Load(*cfgPath) cfg, err := config.Load()
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "error loading config: %v\n", err) fmt.Fprintf(os.Stderr, "error loading config: %v\n", err)
return 1 return 1
@@ -54,18 +47,20 @@ func run(args []string) int {
return 1 return 1
} }
switch rest[0] { switch args[0] {
case "user": case "user":
return runUser(dbase, rest[1:]) return runUser(dbase, args[1:])
case "calendar", "cal": case "calendar", "cal":
return runCalendar(dbase, st, rest[1:]) return runCalendar(dbase, st, args[1:])
case "addressbook", "card": case "addressbook", "card":
return runAddressBook(dbase, st, rest[1:]) return runAddressBook(dbase, st, args[1:])
case "migrate":
return runMigrate(st, args[1:])
case "help", "-h", "--help": case "help", "-h", "--help":
usage() usage()
return 0 return 0
default: default:
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", rest[0]) fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
usage() usage()
return 2 return 2
} }
@@ -75,25 +70,32 @@ func usage() {
fmt.Fprint(os.Stderr, `nidusctl - manage nidus DAV server users, resources, and sharing grants fmt.Fprint(os.Stderr, `nidusctl - manage nidus DAV server users, resources, and sharing grants
Usage: Usage:
nidusctl [-config config.yaml] user create <username> [--display-name NAME] [--email EMAIL] [--password PW] nidusctl user create <username> [--display-name NAME] [--email EMAIL] [--password PW]
nidusctl [-config config.yaml] user delete <username> nidusctl user delete <username>
nidusctl [-config config.yaml] user list nidusctl user list
nidusctl [-config config.yaml] user passwd <username> [--password PW] nidusctl user passwd <username> [--password PW]
nidusctl [-config config.yaml] calendar create <owner> <calendar> [--color '#RRGGBB'] nidusctl calendar create <owner> <calendar> [--color '#RRGGBB']
nidusctl [-config config.yaml] calendar color <owner> <calendar> <hex-color> nidusctl calendar color <owner> <calendar> <hex-color>
nidusctl [-config config.yaml] calendar delete <owner> <calendar> nidusctl calendar delete <owner> <calendar>
nidusctl [-config config.yaml] calendar list <owner> nidusctl calendar list <owner>
nidusctl [-config config.yaml] calendar share <owner> <calendar> <user> <read|write> nidusctl calendar share <owner> <calendar> <user> <read|write>
nidusctl [-config config.yaml] calendar unshare <owner> <calendar> <user> nidusctl calendar unshare <owner> <calendar> <user>
nidusctl [-config config.yaml] calendar shares <owner> <calendar> nidusctl calendar shares <owner> <calendar>
nidusctl [-config config.yaml] addressbook create <owner> <book> nidusctl addressbook create <owner> <book>
nidusctl [-config config.yaml] addressbook delete <owner> <book> nidusctl addressbook delete <owner> <book>
nidusctl [-config config.yaml] addressbook list <owner> nidusctl addressbook list <owner>
nidusctl [-config config.yaml] addressbook share <owner> <book> <user> <read|write> nidusctl addressbook share <owner> <book> <user> <read|write>
nidusctl [-config config.yaml] addressbook unshare <owner> <book> <user> nidusctl addressbook unshare <owner> <book> <user>
nidusctl [-config config.yaml] addressbook shares <owner> <book> nidusctl addressbook shares <owner> <book>
nidusctl migrate [--verbose]
nidusctl help
Configuration is done via environment variables:
NIDUS_DATA_DIR - data directory (default: ./data)
Examples: Examples:
nidusctl user create alice --display-name "Alice Smith" --email alice@example.com nidusctl user create alice --display-name "Alice Smith" --email alice@example.com
@@ -101,16 +103,10 @@ Examples:
nidusctl calendar share alice work bob write nidusctl calendar share alice work bob write
nidusctl calendar shares alice work nidusctl calendar shares alice work
nidusctl calendar unshare alice work bob nidusctl calendar unshare alice work bob
nidusctl migrate
`) `)
} }
// newFlagSet creates a flag.FlagSet configured for subcommand parsing
// (flags may appear before or after positional args, since callers parse
// flags first with fs.Parse then read fs.Args() for the rest).
func newFlagSet(name string) *flag.FlagSet {
return flag.NewFlagSet(name, flag.ContinueOnError)
}
func runCalendar(dbase *db.DB, st *store.Store, args []string) int { func runCalendar(dbase *db.DB, st *store.Store, args []string) int {
if len(args) < 1 { if len(args) < 1 {
usage() usage()
@@ -118,17 +114,17 @@ func runCalendar(dbase *db.DB, st *store.Store, args []string) int {
} }
switch args[0] { switch args[0] {
case "create": case "create":
fs := newFlagSet("calendar create") color := ""
color := fs.String("color", "", "hex color like #3b82f6 (optional)") if len(args) > 2 && args[1] == "--color" {
if err := fs.Parse(args[1:]); err != nil { color = args[2]
return 2 args = args[:1]
} }
if fs.NArg() != 2 { if len(args) != 3 {
fmt.Fprintln(os.Stderr, "usage: nidusctl calendar create <owner> <calendar> [--color '#RRGGBB']") fmt.Fprintln(os.Stderr, "usage: nidusctl calendar create <owner> <calendar> [--color '#RRGGBB']")
return 2 return 2
} }
owner, calName := fs.Arg(0), fs.Arg(1) owner, calName := args[1], args[2]
if err := dbase.CreateCalendarWithColor(owner, calName, *color); err != nil { if err := dbase.CreateCalendarWithColor(owner, calName, color); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1 return 1
} }
@@ -359,6 +355,16 @@ func runAddressBook(dbase *db.DB, st *store.Store, args []string) int {
} }
} }
func runMigrate(st *store.Store, args []string) int {
// Ignore args for now (could add --verbose flag in future if needed)
if err := st.Migrate(); err != nil {
fmt.Fprintf(os.Stderr, "migration failed: %v\n", err)
return 1
}
fmt.Println("Migration completed successfully")
return 0
}
// warnIfUnknownUser reports (without failing) if username is not a // warnIfUnknownUser reports (without failing) if username is not a
// registered user — the share is still recorded, since a user could be // registered user — the share is still recorded, since a user could be
// created afterwards. // created afterwards.
+19 -39
View File
@@ -9,28 +9,9 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/yourusername/caldav-server/internal/db" "git.arnef.de/arnef/nidus/internal/db"
) )
// writeTestConfig creates a minimal config.yaml in dir and returns its path.
func writeTestConfig(t *testing.T, dir string) string {
t.Helper()
cfgPath := filepath.Join(dir, "config.yaml")
dataDir := filepath.Join(dir, "data")
content := "storage:\n data_dir: " + dataDir + "\n" +
"users:\n" +
" alice:\n" +
" password: \"$2a$10$9.WEs0uz5TaNJLQbSTLrX.Te.BIe8XTTykVRzZbSHQMEkyFb8Sq/O\"\n" +
" bob:\n" +
" password: \"$2a$10$9.WEs0uz5TaNJLQbSTLrX.Te.BIe8XTTykVRzZbSHQMEkyFb8Sq/O\"\n"
if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil {
t.Fatalf("writing test config: %v", err)
}
return cfgPath
}
// runCLI runs the CLI's run() function, capturing stdout/stderr, and
// returns (exit code, combined stdout+stderr).
func runCLI(t *testing.T, args ...string) (int, string) { func runCLI(t *testing.T, args ...string) (int, string) {
t.Helper() t.Helper()
@@ -56,9 +37,9 @@ func runCLI(t *testing.T, args ...string) (int, string) {
func TestCalendarShareUnshareLifecycle(t *testing.T) { func TestCalendarShareUnshareLifecycle(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
cfgPath := writeTestConfig(t, dir) t.Setenv("NIDUS_DATA_DIR", filepath.Join(dir, "data"))
code, out := runCLI(t, "-config", cfgPath, "calendar", "share", "alice", "work", "bob", "write") code, out := runCLI(t, "calendar", "share", "alice", "work", "bob", "write")
if code != 0 { if code != 0 {
t.Fatalf("share exit code = %d, output: %s", code, out) t.Fatalf("share exit code = %d, output: %s", code, out)
} }
@@ -66,7 +47,7 @@ func TestCalendarShareUnshareLifecycle(t *testing.T) {
t.Errorf("output = %q, want to contain 'shared'", out) t.Errorf("output = %q, want to contain 'shared'", out)
} }
code, out = runCLI(t, "-config", cfgPath, "calendar", "shares", "alice", "work") code, out = runCLI(t, "calendar", "shares", "alice", "work")
if code != 0 { if code != 0 {
t.Fatalf("shares exit code = %d, output: %s", code, out) t.Fatalf("shares exit code = %d, output: %s", code, out)
} }
@@ -74,12 +55,12 @@ func TestCalendarShareUnshareLifecycle(t *testing.T) {
t.Errorf("output = %q, want to contain bob/write", out) t.Errorf("output = %q, want to contain bob/write", out)
} }
code, out = runCLI(t, "-config", cfgPath, "calendar", "unshare", "alice", "work", "bob") code, out = runCLI(t, "calendar", "unshare", "alice", "work", "bob")
if code != 0 { if code != 0 {
t.Fatalf("unshare exit code = %d, output: %s", code, out) t.Fatalf("unshare exit code = %d, output: %s", code, out)
} }
code, out = runCLI(t, "-config", cfgPath, "calendar", "shares", "alice", "work") code, out = runCLI(t, "calendar", "shares", "alice", "work")
if code != 0 { if code != 0 {
t.Fatalf("shares (after unshare) exit code = %d, output: %s", code, out) t.Fatalf("shares (after unshare) exit code = %d, output: %s", code, out)
} }
@@ -90,9 +71,9 @@ func TestCalendarShareUnshareLifecycle(t *testing.T) {
func TestCalendarShareInvalidPermission(t *testing.T) { func TestCalendarShareInvalidPermission(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
cfgPath := writeTestConfig(t, dir) t.Setenv("NIDUS_DATA_DIR", filepath.Join(dir, "data"))
code, out := runCLI(t, "-config", cfgPath, "calendar", "share", "alice", "work", "bob", "admin") code, out := runCLI(t, "calendar", "share", "alice", "work", "bob", "admin")
if code != 2 { if code != 2 {
t.Errorf("exit code = %d, want 2; output: %s", code, out) t.Errorf("exit code = %d, want 2; output: %s", code, out)
} }
@@ -103,9 +84,9 @@ func TestCalendarShareInvalidPermission(t *testing.T) {
func TestCalendarUnshareNotFound(t *testing.T) { func TestCalendarUnshareNotFound(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
cfgPath := writeTestConfig(t, dir) t.Setenv("NIDUS_DATA_DIR", filepath.Join(dir, "data"))
code, out := runCLI(t, "-config", cfgPath, "calendar", "unshare", "alice", "ghost", "bob") code, out := runCLI(t, "calendar", "unshare", "alice", "ghost", "bob")
if code != 1 { if code != 1 {
t.Errorf("exit code = %d, want 1; output: %s", code, out) t.Errorf("exit code = %d, want 1; output: %s", code, out)
} }
@@ -116,14 +97,14 @@ func TestCalendarUnshareNotFound(t *testing.T) {
func TestAddressBookShareUnshareLifecycle(t *testing.T) { func TestAddressBookShareUnshareLifecycle(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
cfgPath := writeTestConfig(t, dir) t.Setenv("NIDUS_DATA_DIR", filepath.Join(dir, "data"))
code, out := runCLI(t, "-config", cfgPath, "addressbook", "share", "alice", "contacts", "bob", "read") code, out := runCLI(t, "addressbook", "share", "alice", "contacts", "bob", "read")
if code != 0 { if code != 0 {
t.Fatalf("share exit code = %d, output: %s", code, out) t.Fatalf("share exit code = %d, output: %s", code, out)
} }
code, out = runCLI(t, "-config", cfgPath, "addressbook", "shares", "alice", "contacts") code, out = runCLI(t, "addressbook", "shares", "alice", "contacts")
if code != 0 { if code != 0 {
t.Fatalf("shares exit code = %d, output: %s", code, out) t.Fatalf("shares exit code = %d, output: %s", code, out)
} }
@@ -131,7 +112,7 @@ func TestAddressBookShareUnshareLifecycle(t *testing.T) {
t.Errorf("output = %q, want to contain bob/read", out) t.Errorf("output = %q, want to contain bob/read", out)
} }
code, out = runCLI(t, "-config", cfgPath, "addressbook", "unshare", "alice", "contacts", "bob") code, out = runCLI(t, "addressbook", "unshare", "alice", "contacts", "bob")
if code != 0 { if code != 0 {
t.Fatalf("unshare exit code = %d, output: %s", code, out) t.Fatalf("unshare exit code = %d, output: %s", code, out)
} }
@@ -139,9 +120,9 @@ func TestAddressBookShareUnshareLifecycle(t *testing.T) {
func TestUnknownUserWarningDoesNotBlockShare(t *testing.T) { func TestUnknownUserWarningDoesNotBlockShare(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
cfgPath := writeTestConfig(t, dir) t.Setenv("NIDUS_DATA_DIR", filepath.Join(dir, "data"))
code, out := runCLI(t, "-config", cfgPath, "calendar", "share", "alice", "work", "carol", "read") code, out := runCLI(t, "calendar", "share", "alice", "work", "carol", "read")
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, output: %s", code, out) t.Fatalf("exit code = %d, output: %s", code, out)
} }
@@ -154,10 +135,9 @@ func TestUnknownUserWarningDoesNotBlockShare(t *testing.T) {
} }
func TestNoArgsShowsUsage(t *testing.T) { func TestNoArgsShowsUsage(t *testing.T) {
dir := t.TempDir()
// No config needed since usage() is printed before config.Load for // No config needed since usage() is printed before config.Load for
// missing subcommands. // missing subcommands.
code, out := runCLI(t, "-config", filepath.Join(dir, "missing.yaml")) code, out := runCLI(t)
if code != 2 { if code != 2 {
t.Errorf("exit code = %d, want 2", code) t.Errorf("exit code = %d, want 2", code)
} }
@@ -168,9 +148,9 @@ func TestNoArgsShowsUsage(t *testing.T) {
func TestUnknownCommand(t *testing.T) { func TestUnknownCommand(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
cfgPath := writeTestConfig(t, dir) t.Setenv("NIDUS_DATA_DIR", filepath.Join(dir, "data"))
code, out := runCLI(t, "-config", cfgPath, "bogus") code, out := runCLI(t, "bogus")
if code != 2 { if code != 2 {
t.Errorf("exit code = %d, want 2", code) t.Errorf("exit code = %d, want 2", code)
} }
+45 -21
View File
@@ -6,7 +6,7 @@ import (
"os" "os"
"strings" "strings"
"github.com/yourusername/caldav-server/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"golang.org/x/term" "golang.org/x/term"
) )
@@ -31,31 +31,47 @@ func runUser(dbase *db.DB, args []string) int {
} }
func userCreate(dbase *db.DB, args []string) int { func userCreate(dbase *db.DB, args []string) int {
fs := newFlagSet("nidusctl user create") var displayName, email, password string
displayName := fs.String("display-name", "", "display name shown in DAV clients") var rest []string
email := fs.String("email", "", "email address")
password := fs.String("password", "", "password (omit to be prompted, recommended)") for i := 0; i < len(args); i++ {
if err := fs.Parse(args); err != nil { switch args[i] {
return 2 case "--display-name":
if i+1 < len(args) {
displayName = args[i+1]
i++
} }
rest := fs.Args() case "--email":
if i+1 < len(args) {
email = args[i+1]
i++
}
case "--password":
if i+1 < len(args) {
password = args[i+1]
i++
}
default:
rest = append(rest, args[i])
}
}
if len(rest) != 1 { if len(rest) != 1 {
fmt.Fprintln(os.Stderr, "usage: nidusctl user create <username> [--display-name NAME] [--email EMAIL] [--password PASSWORD]") fmt.Fprintln(os.Stderr, "usage: nidusctl user create <username> [--display-name NAME] [--email EMAIL] [--password PASSWORD]")
return 2 return 2
} }
username := rest[0] username := rest[0]
pw := *password if password == "" {
if pw == "" {
var err error var err error
pw, err = promptPassword(username) password, err = promptPassword(username)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "error reading password: %v\n", err) fmt.Fprintf(os.Stderr, "error reading password: %v\n", err)
return 1 return 1
} }
} }
if err := dbase.CreateUser(username, pw, *displayName, *email); err != nil { if err := dbase.CreateUser(username, password, displayName, email); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1 return 1
} }
@@ -98,29 +114,37 @@ func userList(dbase *db.DB, args []string) int {
} }
func userPasswd(dbase *db.DB, args []string) int { func userPasswd(dbase *db.DB, args []string) int {
fs := newFlagSet("nidusctl user passwd") var password string
password := fs.String("password", "", "new password (omit to be prompted, recommended)") var rest []string
if err := fs.Parse(args); err != nil {
return 2 for i := 0; i < len(args); i++ {
switch args[i] {
case "--password":
if i+1 < len(args) {
password = args[i+1]
i++
} }
rest := fs.Args() default:
rest = append(rest, args[i])
}
}
if len(rest) != 1 { if len(rest) != 1 {
fmt.Fprintln(os.Stderr, "usage: nidusctl user passwd <username> [--password PASSWORD]") fmt.Fprintln(os.Stderr, "usage: nidusctl user passwd <username> [--password PASSWORD]")
return 2 return 2
} }
username := rest[0] username := rest[0]
pw := *password if password == "" {
if pw == "" {
var err error var err error
pw, err = promptPassword(username) password, err = promptPassword(username)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "error reading password: %v\n", err) fmt.Fprintf(os.Stderr, "error reading password: %v\n", err)
return 1 return 1
} }
} }
if err := dbase.SetPassword(username, pw); err != nil { if err := dbase.SetPassword(username, password); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1 return 1
} }
+1 -1
View File
File diff suppressed because one or more lines are too long
+18 -12
View File
@@ -9,6 +9,7 @@
const filesInput = document.getElementById("upload-files-input"); const filesInput = document.getElementById("upload-files-input");
const folderInput = document.getElementById("upload-folder-input"); const folderInput = document.getElementById("upload-folder-input");
const newFolderButton = document.getElementById("new-folder-button"); const newFolderButton = document.getElementById("new-folder-button");
const sortSelect = document.getElementById("sort-select");
const status = document.getElementById("upload-status"); const status = document.getElementById("upload-status");
if (!dropZone) { if (!dropZone) {
return; return;
@@ -25,11 +26,6 @@
} }
const formData = new FormData(); const formData = new FormData();
for (const file of files) { 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; const relPath = file.webkitRelativePath || file.name;
formData.append("files", file, file.name); formData.append("files", file, file.name);
formData.append("paths", relPath); formData.append("paths", relPath);
@@ -56,7 +52,7 @@
void upload(Array.from(folderInput.files || [])); void upload(Array.from(folderInput.files || []));
folderInput.value = ""; folderInput.value = "";
}); });
const folderNameRe = /^[a-zA-Z0-9_-]{1,64}$/; const folderNameRe = /^[a-zA-Z0-9._-]{1,64}$/;
newFolderButton?.addEventListener("click", async () => { newFolderButton?.addEventListener("click", async () => {
const name = window.prompt("New folder name:"); const name = window.prompt("New folder name:");
if (!name) { if (!name) {
@@ -106,10 +102,22 @@
} }
}); });
}); });
// Recursively walk a dropped DataTransferItem (file or directory) into if (sortSelect) {
// a flat list of File objects, using the browser's non-standard but sortSelect.addEventListener("change", () => {
// widely-supported webkitGetAsEntry/FileSystemEntry APIs to support const value = sortSelect.value;
// dragging & dropping whole folders. let sortBy = "name";
let sortDir = "asc";
if (value === "name_asc") {
sortBy = "name";
sortDir = "asc";
}
else if (value === "name_desc") {
sortBy = "name";
sortDir = "desc";
}
window.location.href = `${uploadUrl}?sort_by=${sortBy}&sort_dir=${sortDir}`;
});
}
function readEntry(entry) { function readEntry(entry) {
return new Promise((resolve) => { return new Promise((resolve) => {
if (entry.isFile) { if (entry.isFile) {
@@ -163,8 +171,6 @@
} }
} }
if (entries.length === 0) { if (entries.length === 0) {
// Fallback for browsers without webkitGetAsEntry support: flat
// files only, no folder traversal.
void upload(Array.from(e.dataTransfer?.files || [])); void upload(Array.from(e.dataTransfer?.files || []));
return; return;
} }
+18 -12
View File
@@ -8,6 +8,7 @@
const filesInput = document.getElementById("upload-files-input") as HTMLInputElement | null; const filesInput = document.getElementById("upload-files-input") as HTMLInputElement | null;
const folderInput = document.getElementById("upload-folder-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 newFolderButton = document.getElementById("new-folder-button") as HTMLButtonElement | null;
const sortSelect = document.getElementById("sort-select") as HTMLSelectElement | null;
const status = document.getElementById("upload-status") as HTMLParagraphElement | null; const status = document.getElementById("upload-status") as HTMLParagraphElement | null;
if (!dropZone) { if (!dropZone) {
return; return;
@@ -27,11 +28,6 @@
} }
const formData = new FormData(); const formData = new FormData();
for (const file of files) { 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; const relPath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
formData.append("files", file, file.name); formData.append("files", file, file.name);
formData.append("paths", relPath); formData.append("paths", relPath);
@@ -61,7 +57,7 @@
folderInput.value = ""; folderInput.value = "";
}); });
const folderNameRe = /^[a-zA-Z0-9_-]{1,64}$/; const folderNameRe = /^[a-zA-Z0-9._-]{1,64}$/;
newFolderButton?.addEventListener("click", async () => { newFolderButton?.addEventListener("click", async () => {
const name = window.prompt("New folder name:"); const name = window.prompt("New folder name:");
@@ -112,10 +108,22 @@
}); });
}); });
// Recursively walk a dropped DataTransferItem (file or directory) into if (sortSelect) {
// a flat list of File objects, using the browser's non-standard but sortSelect.addEventListener("change", () => {
// widely-supported webkitGetAsEntry/FileSystemEntry APIs to support const value = sortSelect.value;
// dragging & dropping whole folders. let sortBy = "name";
let sortDir = "asc";
if (value === "name_asc") {
sortBy = "name";
sortDir = "asc";
} else if (value === "name_desc") {
sortBy = "name";
sortDir = "desc";
}
window.location.href = `${uploadUrl}?sort_by=${sortBy}&sort_dir=${sortDir}`;
});
}
function readEntry(entry: FileSystemEntry): Promise<File[]> { function readEntry(entry: FileSystemEntry): Promise<File[]> {
return new Promise((resolve) => { return new Promise((resolve) => {
if (entry.isFile) { if (entry.isFile) {
@@ -170,8 +178,6 @@
} }
} }
if (entries.length === 0) { if (entries.length === 0) {
// Fallback for browsers without webkitGetAsEntry support: flat
// files only, no folder traversal.
void upload(Array.from(e.dataTransfer?.files || [])); void upload(Array.from(e.dataTransfer?.files || []));
return; return;
} }