Initial commit
CI / test (push) Canceled after 0s

This commit is contained in:
2026-09-12 22:22:17 +02:00
commit 904d14b64c
314 changed files with 31884 additions and 0 deletions
+165
View File
@@ -0,0 +1,165 @@
package web
import (
"errors"
"net/http"
"strings"
"gardomatic.kleiax.de/lib/client"
"github.com/julienschmidt/httprouter"
)
type accountForm struct {
CSRFToken string `form:"csrf_token"`
Name string `form:"name"`
Color string `form:"color"`
Email string `form:"email"`
CurrentPassword string `form:"current_password"`
NewPassword string `form:"new_password"`
NewPasswordConfirmation string `form:"new_password_confirmation"`
Errors map[string]string
Message string
}
func (app *application) account(w http.ResponseWriter, r *http.Request) {
user, _ := userFromContext(r.Context())
app.renderAccount(w, r, accountForm{Name: user.Name, Color: user.Color, Email: user.Email, Errors: map[string]string{}}, http.StatusOK)
}
func (app *application) accountSessionDeletePost(w http.ResponseWriter, r *http.Request) {
id := httprouter.ParamsFromContext(r.Context()).ByName("sessionID")
response, err := client.FromContext(r.Context()).DeleteAccountSession(r.Context(), id)
if err != nil {
app.handleAPIError(w, r, err)
return
}
client.ForwardCookies(w, response)
http.Redirect(w, r, webPath("account")+gardenQuerySuffix(r), http.StatusSeeOther)
}
func (app *application) accountProfilePost(w http.ResponseWriter, r *http.Request) {
var form accountForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.Name = strings.TrimSpace(form.Name)
form.Color = strings.TrimSpace(form.Color)
form.Errors = map[string]string{}
if form.Name == "" {
form.Errors["name"] = "Ein Name ist erforderlich."
}
if len(form.Errors) == 0 {
user, _, err := client.FromContext(r.Context()).UpdateAccountProfile(r.Context(), form.Name, form.Color)
if err == nil {
form.Name, form.Color, form.Email, form.Message = user.Name, user.Color, user.Email, "Profil gespeichert."
app.renderAccount(w, r, form, http.StatusOK)
return
}
app.copyAccountError(&form, err)
if len(form.Errors) == 0 {
app.handleAPIError(w, r, err)
return
}
}
app.renderAccount(w, r, form, http.StatusUnprocessableEntity)
}
func (app *application) accountPasswordPost(w http.ResponseWriter, r *http.Request) {
var form accountForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
user, _ := userFromContext(r.Context())
form.Name, form.Color, form.Email, form.Errors = user.Name, user.Color, user.Email, map[string]string{}
if form.NewPassword != form.NewPasswordConfirmation {
form.Errors["new_password_confirmation"] = "Die Passwörter stimmen nicht überein."
}
if len(form.NewPassword) < 8 {
form.Errors["new_password"] = "Mindestens 8 Zeichen erforderlich."
}
if len(form.Errors) == 0 {
_, err := client.FromContext(r.Context()).UpdateAccountPassword(r.Context(), form.CurrentPassword, form.NewPassword)
if err == nil {
form.CurrentPassword, form.NewPassword, form.NewPasswordConfirmation = "", "", ""
form.Message = "Passwort geändert."
app.renderAccount(w, r, form, http.StatusOK)
return
}
app.copyAccountError(&form, err)
if len(form.Errors) == 0 {
app.handleAPIError(w, r, err)
return
}
}
app.renderAccount(w, r, form, http.StatusUnprocessableEntity)
}
func (app *application) accountEmailPost(w http.ResponseWriter, r *http.Request) {
var form accountForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
user, _ := userFromContext(r.Context())
form.Name, form.Color, form.Errors = user.Name, user.Color, map[string]string{}
form.Email = strings.TrimSpace(form.Email)
if form.Email == "" {
form.Errors["email"] = "Eine E-Mail-Adresse ist erforderlich."
}
if len(form.Errors) == 0 {
_, err := client.FromContext(r.Context()).RequestAccountEmailChange(r.Context(), form.Email, form.CurrentPassword)
if err == nil {
form.CurrentPassword = ""
form.Message = "Bestätigungslink wurde an die neue Adresse gesendet."
app.renderAccount(w, r, form, http.StatusOK)
return
}
app.copyAccountError(&form, err)
if len(form.Errors) == 0 {
app.handleAPIError(w, r, err)
return
}
}
app.renderAccount(w, r, form, http.StatusUnprocessableEntity)
}
func (app *application) accountEmailConfirm(w http.ResponseWriter, r *http.Request) {
data := app.newTemplateData(r)
data.Form = acceptInviteForm{Token: r.URL.Query().Get("token")}
app.render(w, http.StatusOK, "email_confirm.tmpl", data)
}
func (app *application) accountEmailConfirmPost(w http.ResponseWriter, r *http.Request) {
var form acceptInviteForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
if _, _, err := client.FromContext(r.Context()).ConfirmAccountEmail(r.Context(), form.Token); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("account"), http.StatusSeeOther)
}
func (app *application) renderAccount(w http.ResponseWriter, r *http.Request, form accountForm, status int) {
data := app.newTemplateData(r)
data.Form = form
if !app.loadOptionalGarden(w, r, data) {
return
}
sessions, _, err := client.FromContext(r.Context()).AccountSessions(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
}
data.AccountSessions = sessions
app.render(w, status, "account.tmpl", data)
}
func (app *application) copyAccountError(form *accountForm, err error) {
var apiError *client.APIError
if errors.As(err, &apiError) {
if apiError.StatusCode == http.StatusUnprocessableEntity {
form.Errors = apiError.Validation
}
if apiError.StatusCode == http.StatusUnauthorized {
form.Errors["current_password"] = "Das aktuelle Passwort ist falsch."
}
}
}
+416
View File
@@ -0,0 +1,416 @@
package web
import (
"errors"
"fmt"
"net/http"
"strings"
"gardomatic.kleiax.de/lib/client"
)
type adminRoleForm struct {
Role string `form:"role"`
}
type adminUserInviteForm struct {
Name string `form:"name"`
Email string `form:"email"`
}
type roleSettingsForm struct {
Name string `form:"name"`
Scope string `form:"scope"`
Label string `form:"label"`
Permissions []string `form:"permissions"`
}
func applicationPermissionOptions() []permissionOption {
return []permissionOption{{"gardens:create", "Gärten anlegen"}, {"global_species:write", "Globale Pflanzen verwalten"}, {"users:manage", "Nutzerrollen verwalten"}, {"application_settings:write", "Instanzeinstellungen verwalten"}, {"roles:manage", "Rollen verwalten"}}
}
func gardenPermissionOptions() []permissionOption {
return []permissionOption{
{"garden:read", "Garten sehen"}, {"garden:update", "Garten bearbeiten"}, {"garden:delete", "Garten löschen"}, {"members:write", "Mitglieder verwalten"}, {"species:write", "Pflanzenarten verwalten"}, {"content:write", "Tagebuch und Zuordnungen bearbeiten"},
{"plants:create", "Pflanzen anlegen"}, {"plants:read:own", "Eigene Pflanzen sehen"}, {"plants:read:other", "Andere Pflanzen sehen"}, {"plants:update:own", "Eigene Pflanzen bearbeiten"}, {"plants:update:other", "Andere Pflanzen bearbeiten"}, {"plants:delete:own", "Eigene Pflanzen löschen"}, {"plants:delete:other", "Andere Pflanzen löschen"},
{"locations:create", "Orte anlegen"}, {"locations:read:own", "Eigene Orte sehen"}, {"locations:read:other", "Andere Orte sehen"}, {"locations:update:own", "Eigene Orte bearbeiten"}, {"locations:update:other", "Andere Orte bearbeiten"}, {"locations:delete:own", "Eigene Orte löschen"}, {"locations:delete:other", "Andere Orte löschen"},
{"tasks:create", "Aufgaben anlegen"}, {"tasks:read:own", "Eigene Aufgaben sehen"}, {"tasks:read:other", "Andere Aufgaben sehen"}, {"tasks:update:own", "Eigene Aufgaben bearbeiten"}, {"tasks:update:other", "Andere Aufgaben bearbeiten"}, {"tasks:delete:own", "Eigene Aufgaben löschen"}, {"tasks:delete:other", "Andere Aufgaben löschen"}, {"tasks:complete:own", "Eigene Aufgaben erledigen"}, {"tasks:complete:other", "Andere Aufgaben erledigen"},
}
}
type adminSpeciesCategoryForm struct {
Name string `form:"name"`
SortOrder int `form:"sort_order"`
Active bool `form:"active"`
Lifecycle string `form:"lifecycle"`
}
type adminTaskPriorityForm struct {
Name string `form:"name"`
Value int `form:"value"`
SortOrder int `form:"sort_order"`
Active bool `form:"active"`
}
type adminApplicationSettingsForm struct {
LifecycleStatusEnabled bool `form:"lifecycle_status_enabled"`
LifecycleRemovalMonth int `form:"lifecycle_removal_month"`
LifecycleRemovalDay int `form:"lifecycle_removal_day"`
Timezone string `form:"timezone"`
}
type adminTestMailForm struct {
Email string `form:"email"`
}
func (app *application) requireAdmin(next http.Handler) http.Handler {
return app.requireActivatedUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok || !user.IsAdmin() {
app.clientError(w, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
}))
}
func (app *application) requireApplicationPermission(permission string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok || !user.Can(permission) {
app.clientError(w, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func (app *application) admin(w http.ResponseWriter, r *http.Request) {
apiClient := client.FromContext(r.Context())
users, _, err := apiClient.AdminUsers(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
}
categories, _, err := client.FromContext(r.Context()).AdminSpeciesCategories(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
}
priorities, _, err := client.FromContext(r.Context()).AdminTaskPriorities(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
}
settings, _, err := client.FromContext(r.Context()).AdminApplicationSettings(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
}
environmentVariables, _, err := apiClient.AdminEnvironment(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
}
applicationRoles, gardenRoles, _, err := apiClient.AdminRoles(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
}
data := app.newTemplateData(r)
if !app.loadOptionalGarden(w, r, data) {
return
}
data.AdminUsers, data.SpeciesCategories, data.TaskPriorities = users, categories, priorities
data.ApplicationRoles, data.GardenRoles = applicationRoles, gardenRoles
data.ApplicationPermissions, data.GardenPermissions = applicationPermissionOptions(), gardenPermissionOptions()
data.RoleEditors = []roleEditorData{
globalRoleEditor("roles-settings", "Instanzrollen", "Diese Rollen gelten für die gesamte Serverinstanz.", "application", applicationRoles, data.ApplicationPermissions, data.CSRFToken),
globalRoleEditor("garden-role-templates", "Gartenrollen", "Diese Vorlagen gelten in allen Gärten und können je Garten überschrieben werden.", "garden", gardenRoles, data.GardenPermissions, data.CSRFToken),
}
for i := range data.RoleEditors {
data.RoleEditors[i].Garden = data.Garden
}
data.ApplicationSettings = &settings
data.EnvironmentVariables = append(environmentVariables, app.webEnvironmentVariables()...)
if r.URL.Query().Get("test-mail") == "sent" {
data.Flash = "Die Testmail wurde versendet."
}
if r.URL.Query().Get("test-mail") == "invalid" {
data.Flash = "Bitte gib eine gültige Empfängeradresse für die Testmail ein."
}
if r.URL.Query().Get("invitation") == "sent" {
data.Flash = "Die Einladung wurde versendet."
}
if r.URL.Query().Get("invitation") == "duplicate" {
data.Flash = "Für diese E-Mail-Adresse existiert bereits ein aktiver Account."
}
if r.URL.Query().Get("invitation") == "invalid" {
data.Flash = "Die Einladung ist ungültig. Bitte prüfe Name und E-Mail-Adresse."
}
if r.URL.Query().Get("deletion") == "done" {
data.Flash = "Der Nutzer wurde gelöscht und seine Kontodaten wurden anonymisiert."
}
if r.URL.Query().Get("deletion") == "owner" {
data.Flash = "Der Nutzer besitzt noch mindestens einen Garten. Übertrage zuerst das Eigentum."
}
app.render(w, http.StatusOK, "admin.tmpl", data)
}
func (app *application) adminUserInvitePost(w http.ResponseWriter, r *http.Request) {
var form adminUserInviteForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
input := client.AdminUserInviteInput{Name: strings.TrimSpace(form.Name), Email: strings.TrimSpace(form.Email)}
if _, _, err := client.FromContext(r.Context()).InviteAdminUser(r.Context(), input); err != nil {
var apiError *client.APIError
if errors.As(err, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity {
status := "invalid"
if apiError.Validation["email"] == "a user with this email address already exists" {
status = "duplicate"
}
http.Redirect(w, r, adminPath(r, "#users-settings", "invitation", status), http.StatusSeeOther)
return
}
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, "#users-settings", "invitation", "sent"), http.StatusSeeOther)
}
func (app *application) adminUserDeletePost(w http.ResponseWriter, r *http.Request) {
userID, err := app.readPathID(r, "userID")
if err != nil {
app.notFound(w)
return
}
if _, err = client.FromContext(r.Context()).DeleteAdminUser(r.Context(), userID); err != nil {
var apiError *client.APIError
if errors.As(err, &apiError) && apiError.StatusCode == http.StatusConflict {
http.Redirect(w, r, adminPath(r, "#users-settings", "deletion", "owner"), http.StatusSeeOther)
return
}
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, "#users-settings", "deletion", "done"), http.StatusSeeOther)
}
func (app *application) webEnvironmentVariables() []client.EnvironmentVariable {
return []client.EnvironmentVariable{
{Component: "Web", Name: "GARDOMATIC_ENV", Value: app.config.Env},
{Component: "Web", Name: "GARDOMATIC_WEB_HOST", Value: app.config.Host},
{Component: "Web", Name: "GARDOMATIC_WEB_PORT", Value: fmt.Sprint(app.config.Port)},
{Component: "Web", Name: "GARDOMATIC_API_BASE_URL", Value: app.config.APIBaseURL},
{Component: "Web", Name: "GARDOMATIC_SESSION_COOKIE_NAME", Value: app.config.SessionCookieName},
{Component: "Web", Name: "GARDOMATIC_COOKIE_SECURE", Value: fmt.Sprint(app.config.CookieSecure)},
}
}
func (app *application) adminRoleCreatePost(w http.ResponseWriter, r *http.Request) {
var form roleSettingsForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
_, _, err := client.FromContext(r.Context()).CreateAdminRole(r.Context(), client.RoleInput{Name: strings.TrimSpace(form.Name), Scope: form.Scope, Label: strings.TrimSpace(form.Label), Permissions: form.Permissions})
if err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, adminRoleEditorAnchor(form.Scope)), http.StatusSeeOther)
}
func (app *application) adminRoleUpdatePost(w http.ResponseWriter, r *http.Request) {
var form roleSettingsForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
_, _, err := client.FromContext(r.Context()).UpdateAdminRole(r.Context(), form.Name, client.RoleInput{Label: strings.TrimSpace(form.Label), Permissions: form.Permissions})
if err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, adminRoleEditorAnchor(form.Scope)), http.StatusSeeOther)
}
func (app *application) adminRoleDeletePost(w http.ResponseWriter, r *http.Request) {
var form roleSettingsForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
if _, err := client.FromContext(r.Context()).DeleteAdminRole(r.Context(), form.Name); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, adminRoleEditorAnchor(form.Scope)), http.StatusSeeOther)
}
func adminRoleEditorAnchor(scope string) string {
if scope == "garden" {
return "#garden-role-templates"
}
return "#roles-settings"
}
func (app *application) adminApplicationSettingsPost(w http.ResponseWriter, r *http.Request) {
var form adminApplicationSettingsForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
timezone := strings.TrimSpace(form.Timezone)
input := client.ApplicationSettingsInput{
LifecycleStatusEnabled: &form.LifecycleStatusEnabled,
LifecycleRemovalMonth: &form.LifecycleRemovalMonth,
LifecycleRemovalDay: &form.LifecycleRemovalDay,
Timezone: &timezone,
}
if _, _, err := client.FromContext(r.Context()).UpdateAdminApplicationSettings(r.Context(), input); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, "#lifecycle-settings"), http.StatusSeeOther)
}
func (app *application) adminTestMailPost(w http.ResponseWriter, r *http.Request) {
var form adminTestMailForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
if _, err := client.FromContext(r.Context()).SendAdminTestMail(r.Context(), strings.TrimSpace(form.Email)); err != nil {
var apiError *client.APIError
if errors.As(err, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity {
http.Redirect(w, r, adminPath(r, "#mail-settings", "test-mail", "invalid"), http.StatusSeeOther)
return
}
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, "#mail-settings", "test-mail", "sent"), http.StatusSeeOther)
}
func (app *application) adminTaskPriorityCreatePost(w http.ResponseWriter, r *http.Request) {
var form adminTaskPriorityForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
name, active := strings.TrimSpace(form.Name), true
if _, _, err := client.FromContext(r.Context()).CreateAdminTaskPriority(r.Context(), client.TaskPriorityInput{Name: &name, Value: &form.Value, SortOrder: &form.SortOrder, Active: &active}); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, "#task-priorities"), http.StatusSeeOther)
}
func (app *application) adminTaskPriorityUpdatePost(w http.ResponseWriter, r *http.Request) {
id, err := app.readPathID(r, "priorityID")
if err != nil {
app.notFound(w)
return
}
var form adminTaskPriorityForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
name := strings.TrimSpace(form.Name)
if _, _, err := client.FromContext(r.Context()).UpdateAdminTaskPriority(r.Context(), id, client.TaskPriorityInput{Name: &name, Value: &form.Value, SortOrder: &form.SortOrder, Active: &form.Active}); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, "#task-priorities"), http.StatusSeeOther)
}
func (app *application) adminTaskPriorityDeletePost(w http.ResponseWriter, r *http.Request) {
id, err := app.readPathID(r, "priorityID")
if err != nil {
app.notFound(w)
return
}
if _, err := client.FromContext(r.Context()).DeleteAdminTaskPriority(r.Context(), id); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, "#task-priorities"), http.StatusSeeOther)
}
func (app *application) adminSpeciesCategoryCreatePost(w http.ResponseWriter, r *http.Request) {
var form adminSpeciesCategoryForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
name, active := strings.TrimSpace(form.Name), true
input := client.SpeciesCategoryInput{Name: &name, SortOrder: &form.SortOrder, Active: &active, Lifecycle: &form.Lifecycle}
if _, _, err := client.FromContext(r.Context()).CreateAdminSpeciesCategory(r.Context(), input); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, "#species-categories"), http.StatusSeeOther)
}
func (app *application) adminSpeciesCategoryUpdatePost(w http.ResponseWriter, r *http.Request) {
categoryID, err := app.readPathID(r, "categoryID")
if err != nil {
app.notFound(w)
return
}
var form adminSpeciesCategoryForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
name := strings.TrimSpace(form.Name)
input := client.SpeciesCategoryInput{Name: &name, SortOrder: &form.SortOrder, Active: &form.Active, Lifecycle: &form.Lifecycle}
if _, _, err := client.FromContext(r.Context()).UpdateAdminSpeciesCategory(r.Context(), categoryID, input); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, "#species-categories"), http.StatusSeeOther)
}
func (app *application) adminSpeciesCategoryDeletePost(w http.ResponseWriter, r *http.Request) {
categoryID, err := app.readPathID(r, "categoryID")
if err != nil {
app.notFound(w)
return
}
if _, err := client.FromContext(r.Context()).DeleteAdminSpeciesCategory(r.Context(), categoryID); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, "#species-categories"), http.StatusSeeOther)
}
func (app *application) adminUserRolePost(w http.ResponseWriter, r *http.Request) {
userID, err := app.readPathID(r, "userID")
if err != nil {
app.notFound(w)
return
}
var form adminRoleForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
if _, _, err := client.FromContext(r.Context()).UpdateAdminUserRole(r.Context(), userID, form.Role); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, ""), http.StatusSeeOther)
}
func (app *application) settings(w http.ResponseWriter, r *http.Request) {
data := app.newTemplateData(r)
if !app.loadOptionalGarden(w, r, data) {
return
}
app.render(w, http.StatusOK, "settings.tmpl", data)
}
+191
View File
@@ -0,0 +1,191 @@
package web
import (
"encoding/base64"
"errors"
"net/http"
"strings"
"time"
"gardomatic.kleiax.de/lib/client"
)
type signInForm struct {
CSRFToken string `form:"csrf_token"`
Email string `form:"email"`
Password string `form:"password"`
RememberEmail bool `form:"remember_email"`
Errors map[string]string
Message string
}
type activationForm struct {
CSRFToken string `form:"csrf_token"`
Token string `form:"token"`
Password string `form:"password"`
PasswordConfirm string `form:"password_confirm"`
SetPassword bool `form:"set_password"`
Errors map[string]string
Message string
}
func (app *application) signIn(w http.ResponseWriter, r *http.Request) {
if app.isAuthenticated(r) {
http.Redirect(w, r, app.authenticatedLandingPage(r), http.StatusSeeOther)
return
}
data := app.newTemplateData(r)
form := signInForm{Errors: make(map[string]string)}
if cookie, err := r.Cookie("gardomatic_remembered_email"); err == nil {
if decoded, decodeErr := base64.RawURLEncoding.DecodeString(cookie.Value); decodeErr == nil {
form.Email, form.RememberEmail = string(decoded), true
}
}
data.Form = form
app.render(w, http.StatusOK, "signin.tmpl", data)
}
func (app *application) signInPost(w http.ResponseWriter, r *http.Request) {
var form signInForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.Email = strings.TrimSpace(form.Email)
form.Errors = make(map[string]string)
if form.Email == "" {
form.Errors["email"] = "E-Mail-Adresse ist erforderlich."
}
if form.Password == "" {
form.Errors["password"] = "Passwort ist erforderlich."
}
if len(form.Errors) != 0 {
data := app.newTemplateData(r)
data.Form = form
app.render(w, http.StatusUnprocessableEntity, "signin.tmpl", data)
return
}
apiClient := client.FromContext(r.Context())
user, response, err := apiClient.CreateSession(r.Context(), client.Credentials{Email: form.Email, Password: form.Password})
if err != nil {
var apiError *client.APIError
if errors.As(err, &apiError) && (apiError.StatusCode == http.StatusUnauthorized || apiError.StatusCode == http.StatusUnprocessableEntity) {
form.Message = "E-Mail-Adresse oder Passwort ist ungültig."
data := app.newTemplateData(r)
data.Form = form
app.render(w, http.StatusUnprocessableEntity, "signin.tmpl", data)
return
}
app.serverError(w, err)
return
}
client.ForwardCookies(w, response)
remembered := &http.Cookie{Name: "gardomatic_remembered_email", Path: webPath("login"), HttpOnly: true, Secure: app.config.CookieSecure, SameSite: http.SameSiteLaxMode}
if form.RememberEmail {
remembered.Value = base64.RawURLEncoding.EncodeToString([]byte(form.Email))
remembered.Expires = time.Now().Add(365 * 24 * time.Hour)
remembered.MaxAge = 365 * 24 * 60 * 60
} else if _, err := r.Cookie("gardomatic_remembered_email"); err == nil {
remembered.Expires = time.Unix(1, 0)
remembered.MaxAge = -1
} else {
remembered = nil
}
if remembered != nil {
http.SetCookie(w, remembered)
}
if !user.Activated {
http.Redirect(w, r, webPath("activate"), http.StatusSeeOther)
return
}
http.Redirect(w, r, pathWithQuery(webPath("gardens"), "auto", 1), http.StatusSeeOther)
}
func (app *application) activateUser(w http.ResponseWriter, r *http.Request) {
if user, ok := userFromContext(r.Context()); ok && user.Activated {
http.Redirect(w, r, webPath("gardens"), http.StatusSeeOther)
return
}
data := app.newTemplateData(r)
data.Form = activationForm{
Token: strings.TrimSpace(r.URL.Query().Get("token")),
SetPassword: r.URL.Query().Get("set-password") == "1",
Errors: make(map[string]string),
}
app.render(w, http.StatusOK, "activate.tmpl", data)
}
func (app *application) activateUserPost(w http.ResponseWriter, r *http.Request) {
var form activationForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.Token = strings.TrimSpace(form.Token)
form.Errors = make(map[string]string)
if form.Token == "" {
form.Errors["token"] = "Aktivierungstoken ist erforderlich."
}
if form.SetPassword {
if len(form.Password) < 8 {
form.Errors["password"] = "Das Passwort muss mindestens 8 Zeichen lang sein."
} else if len(form.Password) > 72 {
form.Errors["password"] = "Das Passwort darf höchstens 72 Zeichen lang sein."
}
if form.Password != form.PasswordConfirm {
form.Errors["password_confirm"] = "Die Passwörter stimmen nicht überein."
}
}
if len(form.Errors) == 0 {
apiClient := client.FromContext(r.Context())
var err error
if form.SetPassword {
_, _, err = apiClient.ActivateInvitedUser(r.Context(), form.Token, form.Password)
} else {
_, _, err = apiClient.ActivateUser(r.Context(), form.Token)
}
if err == nil {
http.Redirect(w, r, webPath("gardens"), http.StatusSeeOther)
return
}
var apiError *client.APIError
if errors.As(err, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity {
if _, ok := apiError.Validation["token"]; ok {
form.Errors["token"] = "Aktivierungstoken ist ungültig oder abgelaufen."
} else {
form.Errors = apiError.Validation
form.Message = "Der Account konnte nicht aktiviert werden."
}
} else {
app.handleAPIError(w, r, err)
return
}
}
data := app.newTemplateData(r)
data.Form = form
app.render(w, http.StatusUnprocessableEntity, "activate.tmpl", data)
}
func (app *application) authenticatedLandingPage(r *http.Request) string {
if user, ok := userFromContext(r.Context()); ok && !user.Activated {
return webPath("activate")
}
return webPath("gardens")
}
func (app *application) signOutPost(w http.ResponseWriter, r *http.Request) {
apiClient := client.FromContext(r.Context())
response, err := apiClient.DeleteSession(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
}
client.ForwardCookies(w, response)
http.Redirect(w, r, webPath("home"), http.StatusSeeOther)
}
+93
View File
@@ -0,0 +1,93 @@
package web
import (
"gardomatic.kleiax.de/lib/client"
"net/http"
"strings"
)
type careInstructionForm struct {
Text string `form:"text"`
Status string `form:"status"`
}
func (app *application) careInstructionSave(w http.ResponseWriter, r *http.Request) {
gardenID, e := app.readPathID(r, "gardenID")
if e != nil {
app.notFound(w)
return
}
speciesID, e := app.readPathID(r, "speciesID")
if e != nil {
app.notFound(w)
return
}
var form careInstructionForm
if e = app.decodePostForm(r, &form); e != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.Text = strings.TrimSpace(form.Text)
if form.Status == "" {
form.Status = "untested"
}
input := client.CareInstructionInput{Text: &form.Text, Status: &form.Status}
instructionID, _ := app.readPathID(r, "instructionID")
if instructionID > 0 {
_, _, e = client.FromContext(r.Context()).UpdateCareInstruction(r.Context(), gardenID, speciesID, instructionID, input)
} else {
_, _, e = client.FromContext(r.Context()).CreateCareInstruction(r.Context(), gardenID, speciesID, input)
}
if e != nil {
app.handleAPIError(w, r, e)
return
}
if r.Header.Get("HX-Request") == "true" {
apiClient := client.FromContext(r.Context())
instructions, _, loadErr := apiClient.CareInstructions(r.Context(), gardenID, speciesID)
if loadErr != nil {
app.handleAPIError(w, r, loadErr)
return
}
data := app.newTemplateData(r)
garden, _, loadErr := apiClient.Garden(r.Context(), gardenID)
if loadErr != nil {
app.handleAPIError(w, r, loadErr)
return
}
species, _, loadErr := apiClient.Species(r.Context(), gardenID, speciesID)
if loadErr != nil {
app.handleAPIError(w, r, loadErr)
return
}
data.Garden = &garden
data.SpeciesID = speciesID
data.SpeciesGlobal = species.GardenID == nil
data.CareInstructions = instructions
app.renderTemplate(w, http.StatusOK, "species_form.tmpl", "care_instruction_list", data)
return
}
http.Redirect(w, r, pathWithQuery(webPath("species.edit", gardenID, speciesID), "step", "care"), http.StatusSeeOther)
}
func (app *application) careInstructionDelete(w http.ResponseWriter, r *http.Request) {
gardenID, e := app.readPathID(r, "gardenID")
if e != nil {
app.notFound(w)
return
}
speciesID, e := app.readPathID(r, "speciesID")
if e != nil {
app.notFound(w)
return
}
instructionID, e := app.readPathID(r, "instructionID")
if e != nil {
app.notFound(w)
return
}
if _, e = client.FromContext(r.Context()).DeleteCareInstruction(r.Context(), gardenID, speciesID, instructionID); e != nil {
app.handleAPIError(w, r, e)
return
}
http.Redirect(w, r, pathWithQuery(webPath("species.edit", gardenID, speciesID), "step", "care"), http.StatusSeeOther)
}
+219
View File
@@ -0,0 +1,219 @@
package web
import (
"net/http"
"sort"
"strconv"
"strings"
"time"
"gardomatic.kleiax.de/lib/client"
)
func collectionFilters(r *http.Request, keys ...string) map[string]string {
filters := make(map[string]string, len(keys))
for _, key := range keys {
filters[key] = strings.TrimSpace(r.URL.Query().Get(key))
}
return filters
}
func filterAndSortGardens(values []client.Garden, filters map[string]string) []client.Garden {
query := strings.ToLower(filters["q"])
result := make([]client.Garden, 0, len(values))
for _, value := range values {
if query != "" && !strings.Contains(strings.ToLower(value.Name+" "+value.Description), query) {
continue
}
if filters["role"] != "" && value.Role != filters["role"] {
continue
}
result = append(result, value)
}
sort.SliceStable(result, func(i, j int) bool {
switch filters["sort"] {
case "name_desc":
return strings.ToLower(result[i].Name) > strings.ToLower(result[j].Name)
case "newest":
return result[i].CreatedAt.After(result[j].CreatedAt)
case "oldest":
return result[i].CreatedAt.Before(result[j].CreatedAt)
default:
return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name)
}
})
return result
}
func filterAndSortPlants(values []client.Plant, assignments map[int][]client.PlantLocation, filters map[string]string) []client.Plant {
query := strings.ToLower(filters["q"])
speciesID, _ := strconv.Atoi(filters["species"])
locationID, _ := strconv.Atoi(filters["location"])
result := make([]client.Plant, 0, len(values))
for _, value := range values {
if query != "" && !containsTerms(query, value.Name, value.Notes, strings.Join(value.Tags, " ")) {
continue
}
if filters["status"] != "" && value.Status != filters["status"] {
continue
}
if speciesID > 0 && (value.SpeciesID == nil || *value.SpeciesID != speciesID) {
continue
}
if locationID > 0 && !plantHasLocation(assignments[value.ID], locationID) {
continue
}
result = append(result, value)
}
sort.SliceStable(result, func(i, j int) bool {
switch filters["sort"] {
case "name_desc":
return strings.ToLower(result[i].Name) > strings.ToLower(result[j].Name)
case "newest":
return result[i].CreatedAt.After(result[j].CreatedAt)
case "oldest":
return result[i].CreatedAt.Before(result[j].CreatedAt)
default:
return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name)
}
})
return result
}
func plantHasLocation(assignments []client.PlantLocation, locationID int) bool {
for _, assignment := range assignments {
if assignment.LocationID == locationID && assignment.RemovedAt == nil {
return true
}
}
return false
}
func filterAndSortSpecies(values []client.Species, filters map[string]string) []client.Species {
query := strings.ToLower(filters["q"])
result := make([]client.Species, 0, len(values))
for _, value := range values {
if query != "" && !containsTerms(query, value.CommonName, value.Cultivar, value.BotanicalName, value.Category, strings.Join(value.Tags, " ")) {
continue
}
if filters["origin"] == "global" && value.GardenID != nil || filters["origin"] == "garden" && value.GardenID == nil {
continue
}
result = append(result, value)
}
sort.SliceStable(result, func(i, j int) bool {
left, right := strings.ToLower(result[i].CommonName+" "+result[i].Cultivar), strings.ToLower(result[j].CommonName+" "+result[j].Cultivar)
switch filters["sort"] {
case "name_desc":
return left > right
case "newest":
return result[i].CreatedAt.After(result[j].CreatedAt)
case "oldest":
return result[i].CreatedAt.Before(result[j].CreatedAt)
default:
return left < right
}
})
return result
}
func filterAndSortLocations(values []client.Location, filters map[string]string) []client.Location {
query, kind := strings.ToLower(filters["q"]), strings.ToLower(filters["kind"])
result := make([]client.Location, 0, len(values))
for _, value := range values {
if query != "" && !containsTerms(query, value.Name, value.Description, value.Kind) {
continue
}
if kind != "" && !strings.Contains(strings.ToLower(value.Kind), kind) {
continue
}
result = append(result, value)
}
sort.SliceStable(result, func(i, j int) bool {
switch filters["sort"] {
case "name_desc":
return strings.ToLower(result[i].Name) > strings.ToLower(result[j].Name)
case "newest":
return result[i].CreatedAt.After(result[j].CreatedAt)
case "oldest":
return result[i].CreatedAt.Before(result[j].CreatedAt)
default:
return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name)
}
})
return result
}
func sortTasks(values []client.Task, sortBy string) {
sort.SliceStable(values, func(i, j int) bool {
switch sortBy {
case "due_desc":
return taskSortTime(values[i]).After(taskSortTime(values[j]))
case "month_asc":
return taskMonthBefore(values[i], values[j], false)
case "month_desc":
return taskMonthBefore(values[i], values[j], true)
case "period_asc":
return taskPeriodBefore(values[i], values[j], false)
case "period_desc":
return taskPeriodBefore(values[i], values[j], true)
case "name_asc":
return strings.ToLower(values[i].Title) < strings.ToLower(values[j].Title)
case "name_desc":
return strings.ToLower(values[i].Title) > strings.ToLower(values[j].Title)
case "priority_desc":
return values[i].Priority > values[j].Priority
default:
return taskSortTime(values[i]).Before(taskSortTime(values[j]))
}
})
}
// taskSortMonth returns the calendar month of the task's due window. Missing
// dates sort after dated tasks in both directions.
func taskSortMonth(task client.Task) int {
value := task.DueAtStart
if value == nil {
value = task.DueAtEnd
}
if value == nil {
return 13
}
return int(value.Month())
}
func taskMonthBefore(left, right client.Task, descending bool) bool {
leftMissing := left.DueAtStart == nil && left.DueAtEnd == nil
rightMissing := right.DueAtStart == nil && right.DueAtEnd == nil
if leftMissing || rightMissing {
return !leftMissing && rightMissing
}
if descending {
return taskSortMonth(left) > taskSortMonth(right)
}
return taskSortMonth(left) < taskSortMonth(right)
}
// taskSortPeriod returns the length of a task's due window. A single date is
// a zero-length window; tasks without dates sort after dated tasks.
func taskSortPeriod(task client.Task) time.Duration {
if task.DueAtStart == nil && task.DueAtEnd == nil {
return time.Duration(1<<63 - 1)
}
if task.DueAtStart == nil || task.DueAtEnd == nil {
return 0
}
return task.DueAtEnd.Sub(*task.DueAtStart)
}
func taskPeriodBefore(left, right client.Task, descending bool) bool {
leftMissing := left.DueAtStart == nil && left.DueAtEnd == nil
rightMissing := right.DueAtStart == nil && right.DueAtEnd == nil
if leftMissing || rightMissing {
return !leftMissing && rightMissing
}
if descending {
return taskSortPeriod(left) > taskSortPeriod(right)
}
return taskSortPeriod(left) < taskSortPeriod(right)
}
+94
View File
@@ -0,0 +1,94 @@
package web
import (
"testing"
"time"
"gardomatic.kleiax.de/lib/client"
)
func TestCollectionFiltersAndSorting(t *testing.T) {
older := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
newer := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
gardens := filterAndSortGardens([]client.Garden{
{Name: "Ziergarten", Role: "viewer", CreatedAt: newer},
{Name: "Acker", Description: "Gemüse", Role: "owner", CreatedAt: older},
}, map[string]string{"q": "gemüse", "role": "owner", "sort": "newest"})
if len(gardens) != 1 || gardens[0].Name != "Acker" {
t.Fatalf("unexpected gardens result: %#v", gardens)
}
speciesID, locationID := 4, 8
plants := filterAndSortPlants([]client.Plant{
{ID: 1, Name: "Zucchini", Status: "active", SpeciesID: &speciesID, CreatedAt: newer},
{ID: 2, Name: "Aster", Status: "dormant", CreatedAt: older},
}, map[int][]client.PlantLocation{1: {{PlantID: 1, LocationID: locationID}}}, map[string]string{"status": "active", "species": "4", "location": "8", "sort": "name_desc"})
if len(plants) != 1 || plants[0].Name != "Zucchini" {
t.Fatalf("unexpected plants result: %#v", plants)
}
gardenID := 3
species := filterAndSortSpecies([]client.Species{
{CommonName: "Tomate", GardenID: &gardenID},
{CommonName: "Bohne", GardenID: nil},
}, map[string]string{"origin": "global", "sort": "name_asc"})
if len(species) != 1 || species[0].CommonName != "Bohne" {
t.Fatalf("unexpected species result: %#v", species)
}
locations := filterAndSortLocations([]client.Location{
{Name: "Nordbeet", Kind: "Beet"},
{Name: "Terrasse", Kind: "Topf"},
}, map[string]string{"kind": "beet", "sort": "name_asc"})
if len(locations) != 1 || locations[0].Name != "Nordbeet" {
t.Fatalf("unexpected locations result: %#v", locations)
}
}
func TestTaskSortingByMonthAndPeriod(t *testing.T) {
date := func(year int, month time.Month, day int) *time.Time {
value := time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
return &value
}
tasks := []client.Task{
{ID: 1, Title: "Langer Zeitraum", DueAtStart: date(2026, time.March, 1), DueAtEnd: date(2026, time.March, 11)},
{ID: 2, Title: "Kurzer Zeitraum", DueAtStart: date(2026, time.January, 1), DueAtEnd: date(2026, time.January, 2)},
{ID: 3, Title: "Einzeltermin", DueAtStart: date(2026, time.February, 1)},
}
sortTasks(tasks, "month_asc")
if tasks[0].ID != 2 || tasks[1].ID != 3 || tasks[2].ID != 1 {
t.Fatalf("month sort = %#v, want January, February, March", tasks)
}
sortTasks(tasks, "period_desc")
if tasks[0].ID != 1 || tasks[1].ID != 2 || tasks[2].ID != 3 {
t.Fatalf("period sort = %#v, want longest first", tasks)
}
}
func TestTaskSearchDateFiltersUseStartOrEnd(t *testing.T) {
start := time.Date(2026, time.January, 31, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, time.February, 2, 0, 0, 0, 0, time.UTC)
task := client.Task{DueAtStart: &start, DueAtEnd: &end}
if !taskInMonth(task, int(time.January)) || !taskInMonth(task, int(time.February)) {
t.Fatal("task should match both its start and end month")
}
if taskInMonth(task, int(time.March)) {
t.Fatal("task should not match a month between start and end")
}
if !taskInYear(task, 2026) || taskInYear(task, 2025) {
t.Fatal("task year filter matched the wrong year")
}
middleYear := time.Date(2024, time.June, 1, 0, 0, 0, 0, time.UTC)
if got := taskYears([]client.Task{task, {DueAtStart: &middleYear}}); len(got) != 3 || got[0] != 2024 || got[1] != 2025 || got[2] != 2026 {
t.Fatalf("task years = %#v, want [2024 2025 2026]", got)
}
filtered := filterTasks([]client.Task{
{ID: 1, DueAtStart: &start, DueAtEnd: &end},
{ID: 2, DueAtStart: func() *time.Time { value := time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC); return &value }()},
}, map[string]string{"month": "2", "year": "2026"})
if len(filtered) != 1 || filtered[0].ID != 1 {
t.Fatalf("filtered tasks = %#v, want task 1", filtered)
}
}
+19
View File
@@ -0,0 +1,19 @@
package web
import (
"context"
"gardomatic.kleiax.de/lib/client"
)
type contextKey string
const (
isAuthenticatedContextKey = contextKey("isAuthenticated")
userContextKey = contextKey("user")
)
func userFromContext(ctx context.Context) (client.User, bool) {
user, ok := ctx.Value(userContextKey).(client.User)
return user, ok
}
+475
View File
@@ -0,0 +1,475 @@
package web
import (
"errors"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"gardomatic.kleiax.de/lib/client"
)
func (app *application) gardenDashboard(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
apiClient := client.FromContext(r.Context())
garden, _, err := apiClient.Garden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
tasks, _, err := apiClient.Tasks(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
plants, _, err := apiClient.Plants(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
locations, _, err := apiClient.Locations(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
journalEntries, _, err := apiClient.JournalEntries(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
pinboardEntries, _, err := apiClient.JournalEntriesByType(r.Context(), gardenID, client.JournalEntryTypePinboard)
if err != nil {
app.handleAPIError(w, r, err)
return
}
images, _, err := apiClient.Images(r.Context(), gardenID, "", "")
if err != nil {
var apiError *client.APIError
if !errors.As(err, &apiError) || apiError.StatusCode != http.StatusNotFound {
app.handleAPIError(w, r, err)
return
}
}
horizon := time.Now().AddDate(0, 0, 30)
upcoming := make([]client.Task, 0)
for _, task := range tasks {
if task.CompletedAt != nil {
continue
}
due := task.DueAtEnd
if due == nil {
due = task.DueAtStart
}
if due != nil && !due.After(horizon) {
upcoming = append(upcoming, task)
}
}
sort.SliceStable(upcoming, func(i, j int) bool { return taskSortTime(upcoming[i]).Before(taskSortTime(upcoming[j])) })
if len(upcoming) > 8 {
upcoming = upcoming[:8]
}
data := app.newTemplateData(r)
data.Garden = &garden
data.Tasks = upcoming
data.Plants = plants
data.Locations = locations
data.JournalEntries = journalEntries
data.PinboardEntries = pinboardEntries
data.PhotoCount = len(images)
app.render(w, http.StatusOK, "dashboard.tmpl", data)
}
func taskSortTime(task client.Task) time.Time {
if task.DueAtEnd != nil {
return *task.DueAtEnd
}
if task.DueAtStart != nil {
return *task.DueAtStart
}
return time.Unix(1<<62, 0)
}
func (app *application) taskCalendar(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
view := r.URL.Query().Get("view")
if view != "month" {
view = "week"
}
anchor := time.Now()
if value := r.URL.Query().Get("date"); value != "" {
if parsed, parseErr := time.Parse("2006-01-02", value); parseErr == nil {
anchor = parsed
}
}
start, end := calendarRange(anchor, view)
apiClient := client.FromContext(r.Context())
garden, _, err := apiClient.Garden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
tasks, _, err := apiClient.Tasks(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
days := make([]calendarDay, 0)
for day := start; day.Before(end); day = day.AddDate(0, 0, 1) {
entry := calendarDay{Date: day}
for _, task := range tasks {
if task.CompletedAt == nil && taskOverlapsDay(task, day) {
entry.Tasks = append(entry.Tasks, task)
}
}
days = append(days, entry)
}
step := 7
if view == "month" {
step = 1
}
previous := start.AddDate(0, 0, -step)
next := end
if view == "month" {
previous = start.AddDate(0, -1, 0)
next = start.AddDate(0, 1, 0)
}
data := app.newTemplateData(r)
data.Garden = &garden
data.CalendarDays = days
data.CalendarStart = start
data.CalendarEnd = end.Add(-time.Nanosecond)
data.CalendarView = view
data.PreviousDate = previous.Format("2006-01-02")
data.NextDate = next.Format("2006-01-02")
app.render(w, http.StatusOK, "task_calendar.tmpl", data)
}
func calendarRange(anchor time.Time, view string) (time.Time, time.Time) {
y, m, d := anchor.Date()
loc := anchor.Location()
start := time.Date(y, m, d, 0, 0, 0, 0, loc)
if view == "month" {
start = time.Date(y, m, 1, 0, 0, 0, 0, loc)
return start, start.AddDate(0, 1, 0)
}
offset := (int(start.Weekday()) + 6) % 7
start = start.AddDate(0, 0, -offset)
return start, start.AddDate(0, 0, 7)
}
func taskOverlapsDay(task client.Task, day time.Time) bool {
dayEnd := day.AddDate(0, 0, 1)
start, end := task.DueAtStart, task.DueAtEnd
if start == nil {
start = end
}
if end == nil {
end = start
}
if start == nil {
return false
}
return start.Before(dayEnd) && !end.Before(day)
}
func (app *application) gardenSearch(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
query := strings.TrimSpace(r.URL.Query().Get("q"))
mode := r.URL.Query().Get("mode")
month, _ := strconv.Atoi(r.URL.Query().Get("month"))
if month < 1 || month > 12 {
month = 0
}
year, _ := strconv.Atoi(r.URL.Query().Get("year"))
if year < 1 {
year = 0
}
apiClient := client.FromContext(r.Context())
garden, _, err := apiClient.Garden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
tasks, _, err := apiClient.Tasks(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
result := searchResults{Query: query, Mode: mode, Month: month, MonthName: germanMonthName(month), Year: year, Years: taskYears(tasks)}
if query != "" || month > 0 || year > 0 {
plants, _, err := apiClient.Plants(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
species, _, err := apiClient.SpeciesForGarden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
journal, _, err := apiClient.JournalEntries(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
pinboard, _, err := apiClient.JournalEntriesByType(r.Context(), gardenID, client.JournalEntryTypePinboard)
if err != nil {
app.handleAPIError(w, r, err)
return
}
tags, _, err := apiClient.GardenTags(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
if mode != "month" {
if queryMonth := parseGermanMonth(query); queryMonth > 0 {
month = queryMonth
}
}
matchedTags := map[string]string{}
if mode != "month" {
for _, tag := range tags {
if containsTerms(query, tag) {
matchedTags[strings.ToLower(tag)] = tag
}
}
}
addMonthTags := func(values []string) {
if mode == "month" {
for _, tag := range values {
matchedTags[strings.ToLower(tag)] = tag
}
}
}
for _, item := range tasks {
textMatch := query == "" || containsTerms(query, item.Title, item.Description, strings.Join(item.Tags, " "))
monthMatch := month == 0 || taskInMonth(item, month)
yearMatch := year == 0 || taskInYear(item, year)
if textMatch && monthMatch && yearMatch {
addMonthTags(item.Tags)
result.Tasks = append(result.Tasks, taskSearchCard(gardenID, item))
result.All = append(result.All, result.Tasks[len(result.Tasks)-1])
}
}
for _, item := range plants {
if (mode == "month" && plantInMonth(item, month)) || (mode != "month" && containsTerms(query, item.Name, item.Notes, string(item.Attributes), strings.Join(item.Tags, " "))) {
addMonthTags(item.Tags)
result.Plants = append(result.Plants, plantSearchCard(gardenID, item))
result.All = append(result.All, result.Plants[len(result.Plants)-1])
}
}
for _, item := range species {
if (mode == "month" && speciesInMonth(item, month)) || (mode != "month" && (containsTerms(query, item.CommonName, item.Cultivar, item.BotanicalName, item.Category, item.Notes, string(item.Attributes), strings.Join(item.Tags, " ")) || (month > 0 && speciesInMonth(item, month)))) {
addMonthTags(item.Tags)
result.Species = append(result.Species, speciesSearchCard(gardenID, item))
result.All = append(result.All, result.Species[len(result.Species)-1])
}
}
for _, item := range journal {
if (mode == "month" && int(item.CreatedAt.Month()) == month) || (mode != "month" && containsTerms(query, item.Title, strings.Join(item.Tags, " "))) {
addMonthTags(item.Tags)
result.Journal = append(result.Journal, gardenEntrySearchCard(gardenID, item))
result.All = append(result.All, result.Journal[len(result.Journal)-1])
}
}
for _, item := range pinboard {
if (mode == "month" && int(item.CreatedAt.Month()) == month) || (mode != "month" && containsTerms(query, item.Title, item.Body, strings.Join(item.Tags, " "))) {
addMonthTags(item.Tags)
result.Pinboard = append(result.Pinboard, gardenEntrySearchCard(gardenID, item))
result.All = append(result.All, result.Pinboard[len(result.Pinboard)-1])
}
}
seenTagUsages := map[string]bool{}
addTagUsages := func(card searchCard, values []string) {
for _, tag := range values {
if _, ok := matchedTags[strings.ToLower(tag)]; !ok {
continue
}
if seenTagUsages[card.URL] {
continue
}
seenTagUsages[card.URL] = true
usage := tagUsageCard(card, matchedTags[strings.ToLower(tag)])
result.Tags = append(result.Tags, usage)
allSeen := false
for _, existing := range result.All {
if existing.URL == usage.URL {
allSeen = true
break
}
}
if !allSeen {
result.All = append(result.All, usage)
}
}
}
for _, item := range tasks {
addTagUsages(taskSearchCard(gardenID, item), item.Tags)
}
for _, item := range plants {
addTagUsages(plantSearchCard(gardenID, item), item.Tags)
}
for _, item := range species {
addTagUsages(speciesSearchCard(gardenID, item), item.Tags)
}
for _, item := range journal {
addTagUsages(gardenEntrySearchCard(gardenID, item), item.Tags)
}
for _, item := range pinboard {
addTagUsages(gardenEntrySearchCard(gardenID, item), item.Tags)
}
}
data := app.newTemplateData(r)
data.Garden = &garden
data.Search = result
app.render(w, http.StatusOK, "search.tmpl", data)
}
func taskSearchCard(gardenID int, item client.Task) searchCard {
return searchCard{item.Title, item.Description, taskDue(item), "Aufgabe", webPath("task.edit", gardenID, item.ID)}
}
func plantSearchCard(gardenID int, item client.Plant) searchCard {
return searchCard{item.Name, item.Notes, plantStatusName(item.Status), "Im Garten", webPath("plant.edit", gardenID, item.ID)}
}
func speciesSearchCard(gardenID int, item client.Species) searchCard {
title := item.CommonName
if item.Cultivar != "" {
title += " · " + item.Cultivar
}
return searchCard{title, item.BotanicalName, speciesSeason(item), "Pflanze", webPath("species.edit", gardenID, item.ID)}
}
func gardenEntrySearchCard(gardenID int, item client.JournalEntry) searchCard {
path, label := "journal", "Tagebuch"
if item.EntryType == client.JournalEntryTypePinboard {
path, label = "pinboard", "Pinnwand"
}
return searchCard{item.Title, item.AuthorName, item.CreatedAt.Local().Format("02.01.2006 · 15:04 Uhr"), label, webPath("garden."+path, gardenID) + fmt.Sprintf("#entry-%d", item.ID)}
}
func tagUsageCard(card searchCard, tag string) searchCard {
card.Meta = "#" + tag + " · " + card.Meta
return card
}
func speciesSeason(item client.Species) string {
if item.HarvestMonthFrom != nil {
return fmt.Sprintf("Erntezeit ab %s", germanMonthName(*item.HarvestMonthFrom))
}
return item.Category
}
func plantInMonth(item client.Plant, month int) bool {
return (item.AcquiredAt != nil && int(item.AcquiredAt.Month()) == month) || int(item.CreatedAt.Month()) == month
}
func germanMonthName(month int) string {
names := []string{"", "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"}
if month >= 1 && month <= 12 {
return names[month]
}
return ""
}
func containsTerms(query string, values ...string) bool {
query = strings.ToLower(strings.TrimSpace(query))
for _, term := range strings.Fields(query) {
term = strings.TrimPrefix(term, "#")
found := false
for _, value := range values {
if strings.Contains(strings.ToLower(value), term) {
found = true
break
}
}
if !found {
return false
}
}
return query != ""
}
func parseKeywords(value string) []string {
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
if part = strings.TrimSpace(part); part != "" {
result = append(result, part)
}
}
return result
}
func parseGermanMonth(value string) int {
names := []string{"januar", "februar", "märz", "april", "mai", "juni", "juli", "august", "september", "oktober", "november", "dezember"}
value = strings.ToLower(strings.TrimSpace(value))
for i, name := range names {
if value == name || value == strings.TrimSuffix(name, "uar") {
return i + 1
}
}
return 0
}
func taskInMonth(task client.Task, month int) bool {
return (task.DueAtStart != nil && int(task.DueAtStart.Month()) == month) ||
(task.DueAtEnd != nil && int(task.DueAtEnd.Month()) == month)
}
func taskInYear(task client.Task, year int) bool {
return (task.DueAtStart != nil && task.DueAtStart.Year() == year) ||
(task.DueAtEnd != nil && task.DueAtEnd.Year() == year)
}
func taskYears(tasks []client.Task) []int {
minYear, maxYear := 0, 0
setYear := func(year int) {
if minYear == 0 || year < minYear {
minYear = year
}
if year > maxYear {
maxYear = year
}
}
for _, task := range tasks {
if task.DueAtStart != nil {
setYear(task.DueAtStart.Year())
}
if task.DueAtEnd != nil {
setYear(task.DueAtEnd.Year())
}
}
if minYear == 0 {
return nil
}
result := make([]int, 0, maxYear-minYear+1)
for year := minYear; year <= maxYear; year++ {
result = append(result, year)
}
return result
}
func speciesInMonth(item client.Species, month int) bool {
return monthInRange(month, item.SowMonthFrom, item.SowMonthTo) || monthInRange(month, item.PlantingMonthFrom, item.PlantingMonthTo) || monthInRange(month, item.HarvestMonthFrom, item.HarvestMonthTo)
}
func monthInRange(month int, from, to *int) bool {
if from == nil || to == nil {
return false
}
return monthRangeMatches(month, *from, *to)
}
func monthRangeMatches(month, from, to int) bool {
if from <= to {
return month >= from && month <= to
}
return month >= from || month <= to
}
+3
View File
@@ -0,0 +1,3 @@
// Package web implements Gardomatic's server-rendered browser application and
// proxies authenticated user operations through the typed API client.
package web
+218
View File
@@ -0,0 +1,218 @@
package web
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"gardomatic.kleiax.de/lib/client"
"github.com/julienschmidt/httprouter"
)
func TestPlantAndLocationCanBeEdited(t *testing.T) {
var plantInput client.PlantInput
var locationInput client.LocationInput
assignmentUpdated := false
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/3/plants/5":
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &plantInput); err != nil {
t.Fatal(err)
}
_, _ = w.Write([]byte(`{"plant":{"id":5,"garden_id":3,"name":"Neue Rose"}}`))
case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/3/plants/5/locations/9":
assignmentUpdated = true
_, _ = w.Write([]byte(`{"plant_location":{"id":9,"plant_id":5,"location_id":6,"quantity":2}}`))
case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/3/locations/6":
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &locationInput); err != nil {
t.Fatal(err)
}
_, _ = w.Write([]byte(`{"location":{"id":6,"garden_id":3,"name":"Südbeet"}}`))
default:
http.NotFound(w, r)
}
})
app := newAPIBackedTestApplication(t, apiHandler)
plantForm := url.Values{"name": {"Neue Rose"}, "species_id": {"0"}, "location_id": {"6"}, "assignment_id": {"9"}, "quantity": {"2"}, "status": {"active"}}
plantRequest := httptest.NewRequest(http.MethodPost, "/g/3/plants/edit/5", strings.NewReader(plantForm.Encode()))
plantRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
plantRequest = taskWebRequest(plantRequest, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}, {Key: "plantID", Value: "5"}})
plantResponse := httptest.NewRecorder()
app.plantEditPost(plantResponse, plantRequest)
if plantResponse.Code != http.StatusSeeOther || !plantInput.ClearSpeciesID || !assignmentUpdated {
t.Fatalf("plant edit: status=%d input=%+v assignment=%v body=%s", plantResponse.Code, plantInput, assignmentUpdated, plantResponse.Body.String())
}
locationForm := url.Values{"name": {"Südbeet"}, "parent_id": {"0"}, "area_sqm": {"4,5"}, "return_to": {"/g/3/locations"}}
locationRequest := httptest.NewRequest(http.MethodPost, "/g/3/locations/edit/6", strings.NewReader(locationForm.Encode()))
locationRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
locationRequest = taskWebRequest(locationRequest, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}, {Key: "locationID", Value: "6"}})
locationResponse := httptest.NewRecorder()
app.locationEditPost(locationResponse, locationRequest)
if locationResponse.Code != http.StatusSeeOther || !locationInput.ClearParentID || locationInput.AreaSQM == nil || *locationInput.AreaSQM != 4.5 {
t.Fatalf("location edit: status=%d input=%+v body=%s", locationResponse.Code, locationInput, locationResponse.Body.String())
}
}
func TestGardenAndSpeciesCanBeEdited(t *testing.T) {
var gardenInput client.UpdateGardenInput
var speciesInput client.SpeciesInput
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/3":
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &gardenInput); err != nil {
t.Fatal(err)
}
_, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Neuer Garten"}}`))
case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/3/species/7":
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &speciesInput); err != nil {
t.Fatal(err)
}
_, _ = w.Write([]byte(`{"species":{"id":7,"garden_id":3,"common_name":"Neue Rose"}}`))
default:
http.NotFound(w, r)
}
})
app := newAPIBackedTestApplication(t, apiHandler)
gardenForm := url.Values{"name": {"Neuer Garten"}, "description": {"Südseite"}}
gardenRequest := httptest.NewRequest(http.MethodPost, "/gardens/edit/3", strings.NewReader(gardenForm.Encode()))
gardenRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
gardenRequest = taskWebRequest(gardenRequest, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}})
gardenResponse := httptest.NewRecorder()
app.gardenEditPost(gardenResponse, gardenRequest)
if gardenResponse.Code != http.StatusSeeOther || gardenInput.Name == nil || *gardenInput.Name != "Neuer Garten" {
t.Fatalf("garden edit: status=%d input=%+v", gardenResponse.Code, gardenInput)
}
speciesForm := url.Values{"common_name": {"Neue Rose"}, "sow_month_from": {"0"}, "sow_month_to": {"0"}}
speciesRequest := httptest.NewRequest(http.MethodPost, "/g/3/species/edit/7", strings.NewReader(speciesForm.Encode()))
speciesRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
speciesRequest = taskWebRequest(speciesRequest, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}, {Key: "speciesID", Value: "7"}})
speciesResponse := httptest.NewRecorder()
app.speciesSave(speciesResponse, speciesRequest)
if speciesResponse.Code != http.StatusSeeOther || speciesInput.CommonName == nil || *speciesInput.CommonName != "Neue Rose" || !speciesInput.ClearSowRange {
t.Fatalf("species edit: status=%d input=%+v", speciesResponse.Code, speciesInput)
}
}
func TestGardenCanBeDeleted(t *testing.T) {
deleted := false
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete || r.URL.Path != "/v1/gardens/3" {
http.NotFound(w, r)
return
}
deleted = true
w.WriteHeader(http.StatusNoContent)
})
app := newAPIBackedTestApplication(t, apiHandler)
request := httptest.NewRequest(http.MethodPost, "/gardens/delete/3", nil)
request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}})
response := httptest.NewRecorder()
app.gardenDeletePost(response, request)
if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/gardens" || !deleted {
t.Fatalf("garden delete: status=%d location=%q deleted=%v body=%s", response.Code, response.Header().Get("Location"), deleted, response.Body.String())
}
}
func TestTaskTemplateCanBeCreated(t *testing.T) {
var input client.SpeciesTaskTemplateInput
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodPost || r.URL.Path != "/v1/gardens/3/species/7/task-templates" {
http.NotFound(w, r)
return
}
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &input); err != nil {
t.Fatal(err)
}
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"task_template":{"id":9,"species_id":7,"title":"Schneiden"}}`))
})
app := newAPIBackedTestApplication(t, apiHandler)
form := url.Values{"title": {"Schneiden"}, "trigger_type": {"month_of_year"}, "month_from": {"11"}, "day_from": {"15"}, "duration": {"2"}, "duration_unit": {"week"}, "recurrence": {"monthly"}, "recurrence_interval": {"2"}, "priority": {"5"}, "active": {"true"}}
request := httptest.NewRequest(http.MethodPost, "/g/3/task-templates/new?species_id=7", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}})
response := httptest.NewRecorder()
app.taskTemplateSave(response, request)
if response.Code != http.StatusSeeOther || input.TriggerType == nil || *input.TriggerType != "month_of_year" || input.MonthFrom == nil || *input.MonthFrom != 11 || input.DayFrom == nil || *input.DayFrom != 15 || input.Duration == nil || *input.Duration != 2 || input.RecurrenceInterval == nil || *input.RecurrenceInterval != 2 {
t.Fatalf("template create: status=%d input=%+v body=%s", response.Code, input, response.Body.String())
}
if input.TriggerOffset != nil || input.TriggerOffsetUnit != nil {
t.Errorf("calendar template unexpectedly sends disabled relative fields: %+v", input)
}
}
func TestTaskTemplateDialogShowsMissingNameError(t *testing.T) {
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/3":
_, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof","role":"owner"}}`))
case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/3/species/7":
_, _ = w.Write([]byte(`{"species":{"id":7,"common_name":"Tomate"}}`))
case r.Method == http.MethodGet && r.URL.Path == "/v1/task-priorities":
_, _ = w.Write([]byte(`{"priorities":[{"id":1,"name":"Normal","value":0,"active":true}]}`))
default:
t.Errorf("unexpected API request: %s %s", r.Method, r.URL.Path)
http.NotFound(w, r)
}
})
app := newAPIBackedTestApplication(t, apiHandler)
form := url.Values{"trigger_type": {"month_of_year"}, "month_from": {"9"}, "day_from": {"1"}, "duration": {"0"}, "duration_unit": {"day"}, "trigger_offset_unit": {"day"}, "recurrence_interval": {"1"}}
request := httptest.NewRequest(http.MethodPost, "/g/3/task-templates/new?species_id=7", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("HX-Request", "true")
request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}})
response := httptest.NewRecorder()
app.taskTemplateSave(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String())
}
if body := response.Body.String(); !strings.Contains(body, "Ein Name ist erforderlich.") {
t.Errorf("missing name validation message: %s", body)
}
if response.Header().Get("HX-Retarget") != "#task-template-dialog-host" {
t.Errorf("HX-Retarget: got %q", response.Header().Get("HX-Retarget"))
}
form.Set("title", "Ausgegeizte Triebe entfernen")
form.Set("month_from", "0")
request = httptest.NewRequest(http.MethodPost, "/g/3/task-templates/new?species_id=7", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("HX-Request", "true")
request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}})
response = httptest.NewRecorder()
app.taskTemplateSave(response, request)
body := response.Body.String()
if response.Code != http.StatusOK {
t.Fatalf("status with filled name: got %d, want %d; body: %s", response.Code, http.StatusOK, body)
}
if !strings.Contains(body, "value='Ausgegeizte Triebe entfernen'") {
t.Errorf("filled name was not preserved: %s", body)
}
if strings.Contains(body, "Ein Name ist erforderlich.") {
t.Errorf("filled name was incorrectly rejected: %s", body)
}
if !strings.Contains(body, "Tag und Monat für „Ab“ sind erforderlich.") {
t.Errorf("actual date validation message is missing: %s", body)
}
}
+8
View File
@@ -0,0 +1,8 @@
package web
import (
"embed"
)
//go:embed "templates" "static"
var files embed.FS
+626
View File
@@ -0,0 +1,626 @@
package web
import (
"html"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
"gardomatic.kleiax.de/lib/client"
"github.com/go-playground/form/v4"
)
type webHandlerTransport struct {
handler http.Handler
}
func (transport webHandlerTransport) RoundTrip(request *http.Request) (*http.Response, error) {
recorder := httptest.NewRecorder()
transport.handler.ServeHTTP(recorder, request)
response := recorder.Result()
response.Request = request
return response, nil
}
func newAPIBackedTestApplication(t *testing.T, handler http.Handler) *application {
t.Helper()
templateCache, err := newTemplateCache()
if err != nil {
t.Fatal(err)
}
apiClient, err := client.New("https://api.example", client.WithHTTPClient(&http.Client{Transport: webHandlerTransport{handler: handler}}))
if err != nil {
t.Fatal(err)
}
return &application{
config: Config{SessionCookieName: "gardomatic_session", CookieSecure: false},
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
templateCache: templateCache,
formDecoder: form.NewDecoder(),
apiClient: apiClient,
}
}
func TestSettingsKeepsSelectedGarden(t *testing.T) {
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/session":
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","email":"alice@example.com","activated":true,"permissions":["gardens:create"]}}`))
case "/v1/gardens/3":
_, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof","role":"owner"}}`))
default:
http.NotFound(w, r)
}
})
app := newAPIBackedTestApplication(t, apiHandler)
request := httptest.NewRequest(http.MethodGet, "/settings?garden=3", nil)
response := httptest.NewRecorder()
app.routes().ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String())
}
body := response.Body.String()
if !strings.Contains(body, "href='/g/3'>Hinterhof</a>") || !strings.Contains(body, "href='/settings?garden=3'") {
t.Fatalf("selected garden was not kept in settings: %s", body)
}
}
func TestHealthAndPrivacyPagesArePublic(t *testing.T) {
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/session":
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"you must be authenticated"}`))
case "/v1/healthcheck":
_, _ = w.Write([]byte(`{"status":"available","server_time":"2026-09-11T10:00:00Z","system_info":{"environment":"test","version":"v1"}}`))
default:
http.NotFound(w, r)
}
})
app := newAPIBackedTestApplication(t, apiHandler)
for _, test := range []struct {
path string
want string
}{
{"/healtcheck", "Serverzeit"},
{"/datenschutz", "Verarbeitete Daten"},
} {
request := httptest.NewRequest(http.MethodGet, test.path, nil)
response := httptest.NewRecorder()
app.routes().ServeHTTP(response, request)
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), test.want) {
t.Errorf("GET %s: status=%d, missing %q: %s", test.path, response.Code, test.want, response.Body.String())
}
}
}
func TestPinboardRequestsOnlyPinboardEntries(t *testing.T) {
requestedType := ""
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/session":
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","email":"alice@example.com","activated":true,"permissions":["gardens:create"]}}`))
case "/v1/gardens/3":
_, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof","role":"owner"}}`))
case "/v1/gardens/3/journal":
requestedType = r.URL.Query().Get("type")
_, _ = w.Write([]byte(`{"journal_entries":[{"id":8,"garden_id":3,"entry_type":"pinboard","title":"Sitzecke","body":"Bank bauen"}]}`))
default:
http.NotFound(w, r)
}
})
app := newAPIBackedTestApplication(t, apiHandler)
request := httptest.NewRequest(http.MethodGet, "/g/3/pinboard", nil)
response := httptest.NewRecorder()
app.routes().ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String())
}
if requestedType != client.JournalEntryTypePinboard {
t.Fatalf("entry type: got %q, want %q", requestedType, client.JournalEntryTypePinboard)
}
if body := response.Body.String(); !strings.Contains(body, "Sitzecke") || !strings.Contains(body, "/g/3/pinboard/edit/8") {
t.Fatalf("pinboard entry missing: %s", body)
}
}
func TestGardenPageUsesIncomingSession(t *testing.T) {
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/session":
cookie, err := r.Cookie("gardomatic_session")
if err != nil || cookie.Value != "browser-session" {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"you must be authenticated"}`))
return
}
http.SetCookie(w, &http.Cookie{Name: "gardomatic_session", Value: "browser-session", Path: "/", HttpOnly: true, MaxAge: 1800})
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","email":"alice@example.com","activated":true,"permissions":["gardens:create"]}}`))
case "/v1/gardens":
_, _ = w.Write([]byte(`{"gardens":[{"id":3,"name":"Hinterhof","description":"Gemüse"}]}`))
default:
http.NotFound(w, r)
}
})
app := newAPIBackedTestApplication(t, apiHandler)
request := httptest.NewRequest(http.MethodGet, "/gardens", nil)
request.AddCookie(&http.Cookie{Name: "gardomatic_session", Value: "browser-session"})
response := httptest.NewRecorder()
app.routes().ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String())
}
var refreshedSession *http.Cookie
for _, cookie := range response.Result().Cookies() {
if cookie.Name == "gardomatic_session" {
refreshedSession = cookie
break
}
}
if refreshedSession == nil || refreshedSession.Value != "browser-session" || refreshedSession.MaxAge != 1800 {
t.Errorf("refreshed session cookie was not forwarded: got %+v", refreshedSession)
}
if body := response.Body.String(); !strings.Contains(body, "Hinterhof") || !strings.Contains(body, "Alice") {
t.Errorf("page does not contain garden and user: %s", body)
}
body := response.Body.String()
if !strings.Contains(body, `href='/gardens/new'`) {
t.Errorf("page does not link to the garden creation page: %s", body)
}
for _, want := range []string{"class='user-menu'", `href='/gardens'`, `href='/account'`, ">Nutzer ", "Abmelden"} {
if !strings.Contains(body, want) {
t.Errorf("user menu does not contain %q: %s", want, body)
}
}
if strings.Contains(body, `<nav aria-label='Gartennavigation'>`) {
t.Errorf("garden overview unexpectedly renders an empty garden navigation: %s", body)
}
if strings.Contains(body, `<form action='/gardens'`) {
t.Errorf("garden creation form is still rendered on the overview: %s", body)
}
}
func TestGardenCreatePage(t *testing.T) {
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/session" {
http.NotFound(w, r)
return
}
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","email":"alice@example.com","activated":true,"permissions":["gardens:create"]}}`))
})
app := newAPIBackedTestApplication(t, apiHandler)
request := httptest.NewRequest(http.MethodGet, "/gardens/new", nil)
request.AddCookie(&http.Cookie{Name: "gardomatic_session", Value: "browser-session"})
response := httptest.NewRecorder()
app.routes().ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String())
}
body := response.Body.String()
if !strings.Contains(body, "Garten anlegen") || !strings.Contains(body, "name='name'") || !strings.Contains(body, `href='/gardens'`) {
t.Errorf("garden creation page is incomplete: %s", body)
}
}
func TestGardenEditPageIncludesMemberManagement(t *testing.T) {
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/session":
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","email":"alice@example.com","activated":true}}`))
case "/v1/gardens/3":
_, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof","description":"Gemüse","role":"owner"}}`))
case "/v1/gardens/3/members":
_, _ = w.Write([]byte(`{"members":[{"garden_id":3,"user_id":7,"name":"Alice","email":"alice@example.com","role":"owner"}]}`))
case "/v1/gardens/3/invites":
_, _ = w.Write([]byte(`{"invites":[{"id":11,"garden_id":3,"email":"bob@example.com","role":"member","expires_at":"2026-09-10T12:00:00Z"}]}`))
case "/v1/gardens/3/roles":
_, _ = w.Write([]byte(`{"roles":[{"role":{"name":"owner","scope":"garden","label":"Eigentümer","system":true},"effective_permissions":["garden:delete"]},{"role":{"name":"member","scope":"garden","label":"Mitglied","system":true},"effective_permissions":["garden:read"]},{"role":{"name":"worker","scope":"garden","label":"Mitarbeiter","system":true},"effective_permissions":["garden:read","tasks:complete:own"]}]}`))
default:
http.NotFound(w, r)
}
})
app := newAPIBackedTestApplication(t, apiHandler)
request := httptest.NewRequest(http.MethodGet, "/gardens/edit/3", nil)
request.AddCookie(&http.Cookie{Name: "gardomatic_session", Value: "browser-session"})
response := httptest.NewRecorder()
app.routes().ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{"Bearbeitungsbereiche", "href='#garden-general'", "href='#garden-members'", "href='#garden-invites'", "href='#garden-role-editor'", "href='#garden-delete'", "Allgemein", "Mitglieder", "Alice", "Mitglied einladen", "Mitarbeiter", "Gartenspezifische Rollenrechte", "data-role-select", ">Neu</option>", "bob@example.com", "Garten löschen", "delete-garden-dialog", "Endgültig löschen", "action='/gardens/delete/3'"} {
if !strings.Contains(body, want) {
t.Errorf("garden edit page does not contain %q: %s", want, body)
}
}
if strings.Contains(body, `href='/g/3/members'`) {
t.Errorf("navigation still contains the separate members link: %s", body)
}
}
func TestProtectedPageRedirectsWithoutSession(t *testing.T) {
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/session" {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"you must be authenticated"}`))
return
}
http.NotFound(w, r)
})
app := newAPIBackedTestApplication(t, apiHandler)
request := httptest.NewRequest(http.MethodGet, "/gardens", nil)
response := httptest.NewRecorder()
app.routes().ServeHTTP(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("status: got %d, want %d", response.Code, http.StatusSeeOther)
}
if location := response.Header().Get("Location"); location != "/login" {
t.Errorf("Location: got %q, want %q", location, "/login")
}
}
func TestSignInForwardsAPISessionCookie(t *testing.T) {
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/v1/session" {
http.NotFound(w, r)
return
}
http.SetCookie(w, &http.Cookie{Name: "gardomatic_session", Value: "new-session", Path: "/", HttpOnly: true})
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","activated":true}}`))
})
app := newAPIBackedTestApplication(t, apiHandler)
form := url.Values{"email": {"alice@example.com"}, "password": {"correct horse battery staple"}}
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request = request.WithContext(client.NewContext(request.Context(), app.apiClient))
response := httptest.NewRecorder()
app.signInPost(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusSeeOther, response.Body.String())
}
if cookies := response.Result().Cookies(); len(cookies) != 1 || cookies[0].Value != "new-session" {
t.Fatalf("forwarded cookies: got %+v", cookies)
}
}
func TestInactiveSignInRedirectsToActivation(t *testing.T) {
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/v1/session" {
http.NotFound(w, r)
return
}
http.SetCookie(w, &http.Cookie{Name: "gardomatic_session", Value: "inactive-session", Path: "/", HttpOnly: true})
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","activated":false}}`))
})
app := newAPIBackedTestApplication(t, apiHandler)
form := url.Values{"email": {"alice@example.com"}, "password": {"correct horse battery staple"}}
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request = request.WithContext(client.NewContext(request.Context(), app.apiClient))
response := httptest.NewRecorder()
app.signInPost(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusSeeOther, response.Body.String())
}
if location := response.Header().Get("Location"); location != "/activate" {
t.Errorf("Location: got %q, want %q", location, "/activate")
}
if cookies := response.Result().Cookies(); len(cookies) != 1 || cookies[0].Value != "inactive-session" {
t.Fatalf("forwarded cookies: got %+v", cookies)
}
}
func TestInactiveSessionRedirectsProtectedPageToActivation(t *testing.T) {
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/session" {
t.Fatalf("protected API resource should not be requested, got %s", r.URL.Path)
}
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","activated":false}}`))
})
app := newAPIBackedTestApplication(t, apiHandler)
request := httptest.NewRequest(http.MethodGet, "/gardens", nil)
request.AddCookie(&http.Cookie{Name: "gardomatic_session", Value: "inactive-session"})
response := httptest.NewRecorder()
app.routes().ServeHTTP(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("status: got %d, want %d", response.Code, http.StatusSeeOther)
}
if location := response.Header().Get("Location"); location != "/activate" {
t.Errorf("Location: got %q, want %q", location, "/activate")
}
}
func TestActivationTokenActivatesUser(t *testing.T) {
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut || r.URL.Path != "/v1/users/activated" {
http.NotFound(w, r)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), `"token":"ABCDEFGHIJKLMNOPQRSTUVWXYZ"`) {
t.Fatalf("activation request body: %s", body)
}
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","activated":true}}`))
})
app := newAPIBackedTestApplication(t, apiHandler)
form := url.Values{"token": {"ABCDEFGHIJKLMNOPQRSTUVWXYZ"}}
request := httptest.NewRequest(http.MethodPost, "/activate", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request = request.WithContext(client.NewContext(request.Context(), app.apiClient))
response := httptest.NewRecorder()
app.activateUserPost(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusSeeOther, response.Body.String())
}
if location := response.Header().Get("Location"); location != "/gardens" {
t.Errorf("Location: got %q, want %q", location, "/gardens")
}
}
func TestActivationLinkPrefillsToken(t *testing.T) {
app := newAPIBackedTestApplication(t, http.NotFoundHandler())
request := httptest.NewRequest(http.MethodGet, "/activate?token=ABCDEFGHIJKLMNOPQRSTUVWXYZ", nil)
response := httptest.NewRecorder()
app.activateUser(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String())
}
if body := response.Body.String(); !strings.Contains(body, `value='ABCDEFGHIJKLMNOPQRSTUVWXYZ'`) {
t.Errorf("activation page does not contain token: %s", body)
}
}
func TestRoutesAcceptCSRFTokenFromLoginPage(t *testing.T) {
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/session" {
http.NotFound(w, r)
return
}
if r.Method == http.MethodGet {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"you must be authenticated"}`))
return
}
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","activated":true}}`))
})
app := newAPIBackedTestApplication(t, apiHandler)
handler := app.routes()
getRequest := httptest.NewRequest(http.MethodGet, "/login", nil)
getResponse := httptest.NewRecorder()
handler.ServeHTTP(getResponse, getRequest)
if getResponse.Code != http.StatusOK {
t.Fatalf("GET status: got %d, want %d", getResponse.Code, http.StatusOK)
}
tokenMatch := regexp.MustCompile(`name='csrf_token' value='([^']+)'`).FindStringSubmatch(getResponse.Body.String())
if len(tokenMatch) != 2 {
t.Fatal("login page does not contain a CSRF token")
}
form := url.Values{
"csrf_token": {html.UnescapeString(tokenMatch[1])},
"email": {"alice@example.com"},
"password": {"correct horse battery staple"},
}
postRequest := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
postRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
postRequest.Header.Set("Sec-Fetch-Site", "same-origin")
for _, cookie := range getResponse.Result().Cookies() {
postRequest.AddCookie(cookie)
}
postResponse := httptest.NewRecorder()
handler.ServeHTTP(postResponse, postRequest)
if postResponse.Code != http.StatusSeeOther {
t.Fatalf("POST status: got %d, want %d; body: %s", postResponse.Code, http.StatusSeeOther, postResponse.Body.String())
}
}
func TestPlantFormLoadsAvailableSpecies(t *testing.T) {
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/session":
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","activated":true}}`))
case "/v1/gardens/3":
_, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof"}}`))
case "/v1/gardens/3/species":
_, _ = w.Write([]byte(`{"species":[{"id":11,"common_name":"Tomate","cultivar":"Ochsenherz"}]}`))
case "/v1/gardens/3/locations":
_, _ = w.Write([]byte(`{"locations":[{"id":12,"garden_id":3,"name":"Hochbeet"}]}`))
case "/v1/task-priorities":
_, _ = w.Write([]byte(`{"priorities":[{"id":1,"name":"Normal","value":0,"active":true}]}`))
default:
http.NotFound(w, r)
}
})
app := newAPIBackedTestApplication(t, apiHandler)
request := httptest.NewRequest(http.MethodGet, "/g/3/plants/new?location_id=12", nil)
request.AddCookie(&http.Cookie{Name: "gardomatic_session", Value: "browser-session"})
response := httptest.NewRecorder()
app.routes().ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String())
}
if body := response.Body.String(); !strings.Contains(body, "Ochsenherz") {
t.Errorf("species option missing from form: %s", body)
}
if body := response.Body.String(); !strings.Contains(body, "value='12' selected") {
t.Errorf("preselected location missing from form: %s", body)
}
}
func TestLocationsPageRendersHierarchy(t *testing.T) {
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/session":
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","activated":true}}`))
case "/v1/gardens/3":
_, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof"}}`))
case "/v1/gardens/3/locations":
_, _ = w.Write([]byte(`{"locations":[{"id":2,"garden_id":3,"parent_id":1,"name":"Reihe 1"},{"id":1,"garden_id":3,"name":"Hochbeet"}]}`))
default:
http.NotFound(w, r)
}
})
app := newAPIBackedTestApplication(t, apiHandler)
request := httptest.NewRequest(http.MethodGet, "/g/3/locations", nil)
request.AddCookie(&http.Cookie{Name: "gardomatic_session", Value: "browser-session"})
response := httptest.NewRecorder()
app.routes().ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status: got %d; %s", response.Code, response.Body.String())
}
body := response.Body.String()
if root, child := strings.Index(body, "Hochbeet"), strings.Index(body, "Reihe 1"); root < 0 || child < root {
t.Errorf("hierarchy missing or reversed: %s", body)
}
if !strings.Contains(body, `href='/g/3/locations/view/1'`) {
t.Errorf("location tile does not link to its detail page: %s", body)
}
for _, want := range []string{"data-view-variant='list' hidden", "class='card-grid' data-view-variant='grid'", "data-view-variants data-view-key='locations'"} {
if !strings.Contains(body, want) {
t.Errorf("separate flat tile and hierarchical list views missing %q: %s", want, body)
}
}
if strings.Contains(body, "location-tree collection") || strings.Contains(body, "location-tree' data-view-key") {
t.Errorf("tile view must not reuse the hierarchical location tree: %s", body)
}
}
func TestLocationDetailIncludesFormAndAssignedPlants(t *testing.T) {
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/session":
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","activated":true}}`))
case "/v1/gardens/3":
_, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof","role":"owner"}}`))
case "/v1/gardens/3/locations/1":
_, _ = w.Write([]byte(`{"location":{"id":1,"garden_id":3,"name":"Hochbeet","description":"Sonnige Lage"}}`))
case "/v1/gardens/3/locations":
_, _ = w.Write([]byte(`{"locations":[{"id":1,"garden_id":3,"name":"Hochbeet"}]}`))
case "/v1/gardens/3/plants":
_, _ = w.Write([]byte(`{"plants":[{"id":10,"garden_id":3,"name":"Tomate","status":"active"}]}`))
case "/v1/gardens/3/plants/10/locations":
_, _ = w.Write([]byte(`{"plant_locations":[{"id":20,"plant_id":10,"location_id":1,"quantity":3,"planted_at":"2026-05-01T00:00:00Z"}]}`))
default:
http.NotFound(w, r)
}
})
app := newAPIBackedTestApplication(t, apiHandler)
request := httptest.NewRequest(http.MethodGet, "/g/3/locations/view/1", nil)
request.AddCookie(&http.Cookie{Name: "gardomatic_session", Value: "browser-session"})
response := httptest.NewRecorder()
app.routes().ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{"Ort bearbeiten", "Sonnige Lage", "Pflanzen an diesem Ort", "Tomate", "Anzahl: 3", "01 May 2026"} {
if !strings.Contains(body, want) {
t.Errorf("location detail does not contain %q: %s", want, body)
}
}
for _, want := range []string{"id='location-form'", "class='actions location-form-actions'", "form='location-form'>Speichern", ">Abbrechen</a>", "class='danger'>Ort löschen"} {
if !strings.Contains(body, want) {
t.Errorf("location action row does not contain %q: %s", want, body)
}
}
}
func TestInlineLocationCreationKeepsProgressiveFallback(t *testing.T) {
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/v1/session":
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","activated":true}}`))
case r.URL.Path == "/v1/gardens/3":
_, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof"}}`))
case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/3/locations":
_, _ = w.Write([]byte(`{"locations":[]}`))
case r.Method == http.MethodPost && r.URL.Path == "/v1/gardens/3/locations":
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"location":{"id":12,"garden_id":3,"name":"Kräuterbeet"}}`))
default:
http.NotFound(w, r)
}
})
app := newAPIBackedTestApplication(t, apiHandler)
handler := app.routes()
getRequest := httptest.NewRequest(http.MethodGet, "/g/3/locations/new?return_to=/g/3/plants/new", nil)
getRequest.Header.Set("HX-Request", "true")
getRequest.AddCookie(&http.Cookie{Name: "gardomatic_session", Value: "browser-session"})
getResponse := httptest.NewRecorder()
handler.ServeHTTP(getResponse, getRequest)
if getResponse.Code != http.StatusOK || !strings.Contains(getResponse.Body.String(), "<dialog") || strings.Contains(getResponse.Body.String(), "<!doctype") {
t.Fatalf("inline GET: status=%d body=%s", getResponse.Code, getResponse.Body.String())
}
tokenMatch := regexp.MustCompile(`name='csrf_token' value='([^']+)'`).FindStringSubmatch(getResponse.Body.String())
if len(tokenMatch) != 2 {
t.Fatal("inline form has no CSRF token")
}
invalidForm := url.Values{"csrf_token": {html.UnescapeString(tokenMatch[1])}, "return_to": {"/g/3/plants/new"}}
invalidRequest := httptest.NewRequest(http.MethodPost, "/g/3/locations/new", strings.NewReader(invalidForm.Encode()))
invalidRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
invalidRequest.Header.Set("HX-Request", "true")
invalidRequest.Header.Set("Sec-Fetch-Site", "same-origin")
invalidRequest.AddCookie(&http.Cookie{Name: "gardomatic_session", Value: "browser-session"})
for _, cookie := range getResponse.Result().Cookies() {
invalidRequest.AddCookie(cookie)
}
invalidResponse := httptest.NewRecorder()
handler.ServeHTTP(invalidResponse, invalidRequest)
if invalidResponse.Code != http.StatusOK || invalidResponse.Header().Get("HX-Retarget") != "#location-dialog-host" || !strings.Contains(invalidResponse.Body.String(), "Ein Name ist erforderlich") {
t.Fatalf("inline validation: status=%d retarget=%q body=%s", invalidResponse.Code, invalidResponse.Header().Get("HX-Retarget"), invalidResponse.Body.String())
}
form := url.Values{"csrf_token": {html.UnescapeString(tokenMatch[1])}, "name": {"Kräuterbeet"}, "return_to": {"/g/3/plants/new"}}
postRequest := httptest.NewRequest(http.MethodPost, "/g/3/locations/new", strings.NewReader(form.Encode()))
postRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
postRequest.Header.Set("HX-Request", "true")
postRequest.Header.Set("Sec-Fetch-Site", "same-origin")
postRequest.AddCookie(&http.Cookie{Name: "gardomatic_session", Value: "browser-session"})
for _, cookie := range getResponse.Result().Cookies() {
postRequest.AddCookie(cookie)
}
postResponse := httptest.NewRecorder()
handler.ServeHTTP(postResponse, postRequest)
if postResponse.Code != http.StatusCreated || postResponse.Header().Get("HX-Trigger") != "locationCreated" || !strings.Contains(postResponse.Body.String(), "value='12' selected") {
t.Fatalf("inline POST: status=%d trigger=%q body=%s", postResponse.Code, postResponse.Header().Get("HX-Trigger"), postResponse.Body.String())
}
}
+259
View File
@@ -0,0 +1,259 @@
package web
import (
"errors"
"net/http"
"strings"
"gardomatic.kleiax.de/lib/client"
)
type gardenForm struct {
CSRFToken string `form:"csrf_token"`
Name string `form:"name"`
Description string `form:"description"`
ImageData string `form:"image_data"`
ImageID int `form:"image_id"`
Errors map[string]string
}
type gardenEditForms struct {
Garden gardenForm
Invite inviteForm
}
type gardenRoleSettingsForm struct {
Name string `form:"name"`
Permissions []string `form:"permissions"`
}
func (app *application) gardens(w http.ResponseWriter, r *http.Request) {
apiClient := client.FromContext(r.Context())
gardens, _, err := apiClient.Gardens(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
}
if r.URL.Query().Get("auto") == "1" && len(gardens) == 1 {
http.Redirect(w, r, webPath("garden.dashboard", gardens[0].ID), http.StatusSeeOther)
return
}
data := app.newTemplateData(r)
data.Filters = collectionFilters(r, "q", "role", "sort")
data.Gardens = filterAndSortGardens(gardens, data.Filters)
data.Gardens, data.Pagination = paginateCollection(r, data.Gardens)
app.render(w, http.StatusOK, "gardens.tmpl", data)
}
func (app *application) gardenCreate(w http.ResponseWriter, r *http.Request) {
data := app.newTemplateData(r)
data.Form = gardenForm{Errors: make(map[string]string)}
app.render(w, http.StatusOK, "garden_new.tmpl", data)
}
func (app *application) gardenCreatePost(w http.ResponseWriter, r *http.Request) {
var form gardenForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.Name = strings.TrimSpace(form.Name)
form.Description = strings.TrimSpace(form.Description)
form.Errors = make(map[string]string)
if form.Name == "" {
form.Errors["name"] = "Ein Name ist erforderlich."
}
apiClient := client.FromContext(r.Context())
if len(form.Errors) == 0 {
input := client.CreateGardenInput{Name: form.Name, Description: form.Description, ImageData: form.ImageData}
if form.ImageID > 0 {
imageID := form.ImageID
input.ImageID = &imageID
}
garden, _, err := apiClient.CreateGarden(r.Context(), input)
if err == nil {
http.Redirect(w, r, webPath("plants", garden.ID), http.StatusSeeOther)
return
}
var apiError *client.APIError
if errors.As(err, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity {
form.Errors = apiError.Validation
} else {
app.handleAPIError(w, r, err)
return
}
}
data := app.newTemplateData(r)
data.Form = form
app.render(w, http.StatusUnprocessableEntity, "garden_new.tmpl", data)
}
func (app *application) gardenEdit(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
app.renderGardenEdit(w, r, gardenID, http.StatusOK, gardenEditForms{})
}
func (app *application) gardenEditPost(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
var form gardenForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.Name, form.Description, form.Errors = strings.TrimSpace(form.Name), strings.TrimSpace(form.Description), map[string]string{}
if form.Name == "" {
form.Errors["name"] = "Ein Name ist erforderlich."
}
apiClient := client.FromContext(r.Context())
if len(form.Errors) == 0 {
name, description, imageData := form.Name, form.Description, form.ImageData
input := client.UpdateGardenInput{Name: &name, Description: &description, ImageData: &imageData}
if form.ImageID > 0 {
imageID := form.ImageID
input.ImageID = &imageID
}
_, _, updateErr := apiClient.UpdateGarden(r.Context(), gardenID, input)
if updateErr == nil {
http.Redirect(w, r, webPath("gardens"), http.StatusSeeOther)
return
}
var apiError *client.APIError
if errors.As(updateErr, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity {
form.Errors = apiError.Validation
} else {
app.handleAPIError(w, r, updateErr)
return
}
}
app.renderGardenEdit(w, r, gardenID, http.StatusUnprocessableEntity, gardenEditForms{Garden: form})
}
func (app *application) gardenDeletePost(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
if _, err = client.FromContext(r.Context()).DeleteGarden(r.Context(), gardenID); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("gardens"), http.StatusSeeOther)
}
func (app *application) gardenRoleSettingsPost(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
var form gardenRoleSettingsForm
if err = app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
if _, _, err = client.FromContext(r.Context()).UpdateGardenRoleSettings(r.Context(), gardenID, form.Name, form.Permissions); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("garden.edit", gardenID)+"#garden-role-editor", http.StatusSeeOther)
}
func (app *application) gardenRoleCreatePost(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
var form roleSettingsForm
if err = app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
_, _, err = client.FromContext(r.Context()).CreateGardenRole(r.Context(), gardenID, client.RoleInput{Name: strings.TrimSpace(form.Name), Label: strings.TrimSpace(form.Label), Permissions: form.Permissions})
if err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("garden.edit", gardenID)+"#garden-role-editor", http.StatusSeeOther)
}
func (app *application) gardenRoleDeletePost(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
var form roleSettingsForm
if err = app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
if _, err = client.FromContext(r.Context()).DeleteGardenRole(r.Context(), gardenID, form.Name); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("garden.edit", gardenID)+"#garden-role-editor", http.StatusSeeOther)
}
func (app *application) renderGardenEdit(w http.ResponseWriter, r *http.Request, gardenID, status int, forms gardenEditForms) {
apiClient := client.FromContext(r.Context())
garden, _, err := apiClient.Garden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
if forms.Garden.Errors == nil {
forms.Garden = gardenForm{Name: garden.Name, Description: garden.Description, ImageData: garden.ImageData, Errors: map[string]string{}}
if garden.ImageID != nil {
forms.Garden.ImageID = *garden.ImageID
}
}
if forms.Invite.Errors == nil {
forms.Invite = inviteForm{Role: "member", Errors: map[string]string{}}
}
members, _, err := apiClient.GardenMembers(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
invites := []client.GardenInvite{}
roleSettings := []client.GardenRoleSetting{}
gardenRoles := []client.Role{}
if garden.Can("members:write") {
invites, _, err = apiClient.GardenInvites(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
roleSettings, _, err = apiClient.GardenRoleSettings(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
for _, setting := range roleSettings {
gardenRoles = append(gardenRoles, setting.Role)
}
}
data := app.newTemplateData(r)
data.Garden, data.Members, data.Invites, data.Form = &garden, members, invites, forms
data.GardenRoles, data.GardenRoleSettings = gardenRoles, roleSettings
data.GardenPermissions = gardenPermissionOptions()
if garden.Can("garden:delete") {
data.RoleEditors = []roleEditorData{gardenOverrideRoleEditor(gardenID, roleSettings, data.GardenPermissions, data.CSRFToken)}
}
data.Images, err = app.loadImageLibrary(r, gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
app.render(w, status, "garden_form.tmpl", data)
}
+38
View File
@@ -0,0 +1,38 @@
package web
import (
"net/http"
"time"
"gardomatic.kleiax.de/lib/client"
)
func (app *application) home(w http.ResponseWriter, r *http.Request) {
if app.isAuthenticated(r) {
http.Redirect(w, r, webPath("gardens"), http.StatusSeeOther)
return
}
data := app.newTemplateData(r)
app.render(w, http.StatusOK, "home.tmpl", data)
}
func ping(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("OK"))
}
func (app *application) healthcheck(w http.ResponseWriter, r *http.Request) {
data := app.newTemplateData(r)
data.SystemTime = time.Now()
data.WebVersion = version
health, _, err := client.FromContext(r.Context()).Healthcheck(r.Context())
if err != nil {
data.HealthError = "Die API ist derzeit nicht erreichbar."
} else {
data.Health = &health
}
app.render(w, http.StatusOK, "healthcheck.tmpl", data)
}
func (app *application) privacy(w http.ResponseWriter, r *http.Request) {
app.render(w, http.StatusOK, "privacy.tmpl", app.newTemplateData(r))
}
+21
View File
@@ -0,0 +1,21 @@
package web
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestPing(t *testing.T) {
app := newTestApplication(t)
request := httptest.NewRequest(http.MethodGet, "/ping", nil)
response := httptest.NewRecorder()
app.routes().ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Errorf("status: got %d, want %d", response.Code, http.StatusOK)
}
if body := response.Body.String(); body != "OK" {
t.Errorf("body: got %q, want %q", body, "OK")
}
}
+211
View File
@@ -0,0 +1,211 @@
package web
import (
"bytes"
"errors"
"fmt"
"net/http"
"runtime/debug"
"strconv"
"strings"
"time"
"gardomatic.kleiax.de/lib/client"
"github.com/go-playground/form/v4"
"github.com/julienschmidt/httprouter"
"github.com/justinas/nosurf"
)
func (app *application) serverError(w http.ResponseWriter, err error) {
trace := fmt.Sprintf("%s\n%s", err.Error(), debug.Stack())
app.logger.Error("request failed", "error", trace)
app.renderErrorPage(w, http.StatusInternalServerError, "Etwas ist schiefgelaufen", "Die Anfrage konnte nicht verarbeitet werden. Bitte versuche es später noch einmal.")
}
func (app *application) clientError(w http.ResponseWriter, status int) {
title, message := http.StatusText(status), "Die Anfrage konnte nicht verarbeitet werden."
switch status {
case http.StatusBadRequest:
title, message = "Ungültige Anfrage", "Die übermittelten Daten konnten nicht gelesen werden."
case http.StatusForbidden:
title, message = "Zugriff nicht erlaubt", "Du hast für diese Aktion nicht die erforderliche Berechtigung."
case http.StatusNotFound:
title, message = "Seite nicht gefunden", "Die gewünschte Seite oder der Eintrag existiert nicht."
case http.StatusRequestEntityTooLarge:
title, message = "Datei zu groß", "Mindestens eine hochgeladene Datei überschreitet die erlaubte Größe."
}
app.renderErrorPage(w, status, title, message)
}
func (app *application) renderErrorPage(w http.ResponseWriter, status int, title, message string) {
ts, ok := app.templateCache["error.tmpl"]
if !ok {
http.Error(w, title, status)
return
}
data := &templateData{commonTemplateData: commonTemplateData{CurrentYear: time.Now().Year(), ErrorStatus: status, ErrorTitle: title, ErrorMessage: message}}
buf := new(bytes.Buffer)
if err := ts.ExecuteTemplate(buf, "base", data.pageView("error.tmpl")); err != nil {
http.Error(w, title, status)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, _ = buf.WriteTo(w)
}
func (app *application) loadOptionalGarden(w http.ResponseWriter, r *http.Request, data *templateData) bool {
rawID := strings.TrimSpace(r.URL.Query().Get("garden"))
if rawID == "" {
return true
}
gardenID, err := strconv.Atoi(rawID)
if err != nil || gardenID < 1 {
app.notFound(w)
return false
}
garden, _, err := client.FromContext(r.Context()).Garden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return false
}
data.Garden = &garden
return true
}
func gardenQuerySuffix(r *http.Request) string {
gardenID, err := strconv.Atoi(r.URL.Query().Get("garden"))
if err != nil || gardenID < 1 {
return ""
}
return "?garden=" + strconv.Itoa(gardenID)
}
func gardenAwarePath(path string, garden *client.Garden) string {
if garden == nil {
return path
}
return pathWithQuery(path, "garden", garden.ID)
}
func adminPath(r *http.Request, fragment string, pairs ...any) string {
if gardenID, err := strconv.Atoi(r.URL.Query().Get("garden")); err == nil && gardenID > 0 {
pairs = append(pairs, "garden", gardenID)
}
return pathWithQuery(webPath("admin"), pairs...) + fragment
}
func (app *application) notFound(w http.ResponseWriter) {
app.clientError(w, http.StatusNotFound)
}
func (app *application) newTemplateData(r *http.Request) *templateData {
data := &templateData{
commonTemplateData: commonTemplateData{
CurrentYear: time.Now().Year(),
IsAuthenticated: app.isAuthenticated(r),
CSRFToken: nosurf.Token(r),
},
adminTemplateData: adminTemplateData{TaskPriorities: []client.TaskPriority{{Name: "Niedrig", Value: -5, Active: true}, {Name: "Normal", Value: 0, Active: true}, {Name: "Erhöht", Value: 3, Active: true}, {Name: "Hoch", Value: 5, Active: true}}},
}
if user, ok := userFromContext(r.Context()); ok {
data.CurrentUser = &user
data.IsActivated = user.Activated
}
return data
}
func (app *application) loadTagSuggestions(r *http.Request, gardenID int) []string {
tags, _, err := client.FromContext(r.Context()).GardenTags(r.Context(), gardenID)
if err != nil {
app.logger.Warn("could not load optional tag suggestions", "garden_id", gardenID, "error", err)
return nil
}
return tags
}
func (app *application) render(w http.ResponseWriter, status int, page string, data *templateData) {
app.renderTemplate(w, status, page, "base", data)
}
func (app *application) renderTemplate(w http.ResponseWriter, status int, page, name string, data *templateData) {
ts, ok := app.templateCache[page]
if !ok {
err := fmt.Errorf("the template %s does not exist", page)
app.serverError(w, err)
return
}
buf := new(bytes.Buffer)
err := ts.ExecuteTemplate(buf, name, data.pageView(page))
if err != nil {
app.serverError(w, err)
return
}
w.WriteHeader(status)
_, _ = buf.WriteTo(w)
}
func (app *application) decodePostForm(r *http.Request, dst any) error {
err := r.ParseForm()
if err != nil {
return err
}
err = app.formDecoder.Decode(dst, r.PostForm)
if err != nil {
var invalidDecoderError *form.InvalidDecoderError
if errors.As(err, &invalidDecoderError) {
panic(err)
}
return err
}
return nil
}
func (app *application) readPathID(r *http.Request, name string) (int, error) {
value := httprouter.ParamsFromContext(r.Context()).ByName(name)
id, err := strconv.Atoi(value)
if err != nil || id < 1 {
return 0, fmt.Errorf("invalid %s path parameter", name)
}
return id, nil
}
func (app *application) handleAPIError(w http.ResponseWriter, r *http.Request, err error) {
var apiError *client.APIError
if errors.As(err, &apiError) {
switch apiError.StatusCode {
case http.StatusUnauthorized:
http.Redirect(w, r, webPath("login"), http.StatusSeeOther)
return
case http.StatusForbidden:
if user, ok := userFromContext(r.Context()); ok && !user.Activated {
http.Redirect(w, r, webPath("activate"), http.StatusSeeOther)
return
}
app.clientError(w, http.StatusForbidden)
return
case http.StatusNotFound:
app.notFound(w)
return
}
}
app.serverError(w, err)
}
func (app *application) isAuthenticated(r *http.Request) bool {
isAuthenticated, ok := r.Context().Value(isAuthenticatedContextKey).(bool)
if !ok {
return false
}
return isAuthenticated
}
+65
View File
@@ -0,0 +1,65 @@
package web
import (
"errors"
"net/http"
"gardomatic.kleiax.de/lib/client"
)
func (app *application) loadImageLibrary(r *http.Request, gardenID int) ([]client.Image, error) {
images, _, err := client.FromContext(r.Context()).Images(r.Context(), gardenID, "", "")
if err != nil {
var apiError *client.APIError
if errors.As(err, &apiError) && apiError.StatusCode == http.StatusNotFound {
return nil, nil
}
}
return images, err
}
func (app *application) images(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
apiClient := client.FromContext(r.Context())
garden, _, err := apiClient.Garden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
filters := collectionFilters(r, "q", "source")
images, _, err := apiClient.Images(r.Context(), gardenID, filters["q"], filters["source"])
if err != nil {
app.handleAPIError(w, r, err)
return
}
data := app.newTemplateData(r)
data.Garden = &garden
data.Images, data.Pagination = paginateCollection(r, images)
data.Filters = filters
app.render(w, http.StatusOK, "images.tmpl", data)
}
func (app *application) imageData(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
imageID, err := app.readPathID(r, "imageID")
if err != nil {
app.notFound(w)
return
}
data, mediaType, _, err := client.FromContext(r.Context()).ImageData(r.Context(), gardenID, imageID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
w.Header().Set("Content-Type", mediaType)
w.Header().Set("Cache-Control", "private, max-age=86400")
_, _ = w.Write(data)
}
+352
View File
@@ -0,0 +1,352 @@
package web
import (
"bytes"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"strconv"
"strings"
"time"
"gardomatic.kleiax.de/lib/client"
)
const maxJournalFormSize = 100 << 20
type journalForm struct {
Title string
Body string
DateTime string
Keywords string
TagSuggestions []string
Attachments []client.JournalAttachment
LibraryImageIDs []int
Errors map[string]string
}
type gardenEntryView struct {
entryType string
path string
}
var (
journalEntryView = gardenEntryView{entryType: client.JournalEntryTypeJournal, path: "journal"}
pinboardEntryView = gardenEntryView{entryType: client.JournalEntryTypePinboard, path: "pinboard"}
)
func (app *application) journal(w http.ResponseWriter, r *http.Request) {
app.gardenEntries(w, r, journalEntryView)
}
func (app *application) pinboard(w http.ResponseWriter, r *http.Request) {
app.gardenEntries(w, r, pinboardEntryView)
}
func (app *application) gardenEntries(w http.ResponseWriter, r *http.Request, view gardenEntryView) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
apiClient := client.FromContext(r.Context())
garden, _, err := apiClient.Garden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
entries, _, err := apiClient.JournalEntriesByType(r.Context(), gardenID, view.entryType)
if err != nil {
app.handleAPIError(w, r, err)
return
}
data := app.newTemplateData(r)
entries, data.Pagination = paginateCollection(r, entries)
data.Garden, data.JournalEntries, data.ContentKind = &garden, entries, view.entryType
app.render(w, http.StatusOK, "journal.tmpl", data)
}
func (app *application) journalCreate(w http.ResponseWriter, r *http.Request) {
app.gardenEntryCreate(w, r, journalEntryView)
}
func (app *application) pinboardCreate(w http.ResponseWriter, r *http.Request) {
app.gardenEntryCreate(w, r, pinboardEntryView)
}
func (app *application) gardenEntryCreate(w http.ResponseWriter, r *http.Request, view gardenEntryView) {
form := journalForm{DateTime: time.Now().Format("2006-01-02T15:04"), Errors: map[string]string{}}
app.loadJournalTagSuggestions(r, &form)
app.renderJournalForm(w, r, form, http.StatusOK, 0, view)
}
func (app *application) journalEdit(w http.ResponseWriter, r *http.Request) {
app.gardenEntryEdit(w, r, journalEntryView)
}
func (app *application) pinboardEdit(w http.ResponseWriter, r *http.Request) {
app.gardenEntryEdit(w, r, pinboardEntryView)
}
func (app *application) gardenEntryEdit(w http.ResponseWriter, r *http.Request, view gardenEntryView) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
entryID, err := app.readPathID(r, "entryID")
if err != nil {
app.notFound(w)
return
}
entry, _, err := client.FromContext(r.Context()).JournalEntry(r.Context(), gardenID, entryID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
if entry.EntryType != view.entryType {
app.notFound(w)
return
}
form := journalForm{Title: entry.Title, Body: entry.Body, DateTime: entry.CreatedAt.In(time.Local).Format("2006-01-02T15:04"), Keywords: strings.Join(entry.Tags, ", "), Attachments: entry.Attachments, Errors: map[string]string{}}
app.loadJournalTagSuggestions(r, &form)
app.renderJournalForm(w, r, form, http.StatusOK, entryID, view)
}
func (app *application) journalSave(w http.ResponseWriter, r *http.Request) {
app.gardenEntrySave(w, r, journalEntryView)
}
func (app *application) pinboardSave(w http.ResponseWriter, r *http.Request) {
app.gardenEntrySave(w, r, pinboardEntryView)
}
func (app *application) gardenEntrySave(w http.ResponseWriter, r *http.Request, view gardenEntryView) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
entryID := 0
if id, readErr := app.readPathID(r, "entryID"); readErr == nil {
entryID = id
}
r.Body = http.MaxBytesReader(w, r.Body, maxJournalFormSize)
if err = r.ParseMultipartForm(16 << 20); err != nil {
app.clientError(w, http.StatusRequestEntityTooLarge)
return
}
form := journalForm{Title: strings.TrimSpace(r.FormValue("title")), Body: strings.TrimSpace(r.FormValue("body")), DateTime: r.FormValue("date_time"), Keywords: r.FormValue("keywords"), Errors: map[string]string{}}
for _, value := range r.MultipartForm.Value["library_image_id"] {
if id, parseErr := strconv.Atoi(value); parseErr == nil && id > 0 {
form.LibraryImageIDs = append(form.LibraryImageIDs, id)
}
}
if form.DateTime == "" {
form.DateTime = time.Now().Format("2006-01-02T15:04")
}
app.loadJournalTagSuggestions(r, &form)
if form.Title == "" && view.entryType == client.JournalEntryTypeJournal {
form.Errors["title"] = "Ein Titel ist erforderlich."
}
if len(form.Body) > 100_000 {
form.Errors["body"] = "Der Text darf höchstens 100.000 Zeichen enthalten."
}
createdAt, parseErr := time.ParseInLocation("2006-01-02T15:04", form.DateTime, time.Local)
if parseErr != nil {
form.Errors["date_time"] = "Bitte ein gültiges Datum und eine gültige Uhrzeit angeben."
}
apiClient := client.FromContext(r.Context())
if entryID > 0 {
if existing, _, getErr := apiClient.JournalEntry(r.Context(), gardenID, entryID); getErr == nil {
if existing.EntryType != view.entryType {
app.notFound(w)
return
}
form.Attachments = existing.Attachments
} else {
app.handleAPIError(w, r, getErr)
return
}
}
if len(form.Errors) > 0 {
app.renderJournalForm(w, r, form, http.StatusUnprocessableEntity, entryID, view)
return
}
entryType := view.entryType
input := client.JournalEntryInput{Title: &form.Title, Body: &form.Body, CreatedAt: &createdAt, Tags: parseKeywords(form.Keywords), EntryType: &entryType}
var entry client.JournalEntry
if entryID > 0 {
entry, _, err = apiClient.UpdateJournalEntry(r.Context(), gardenID, entryID, input)
} else {
entry, _, err = apiClient.CreateJournalEntry(r.Context(), gardenID, input)
}
if err != nil {
var apiError *client.APIError
if errors.As(err, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity {
form.Errors = apiError.Validation
app.renderJournalForm(w, r, form, http.StatusUnprocessableEntity, entryID, view)
return
}
app.handleAPIError(w, r, err)
return
}
entryID = entry.ID
for _, value := range r.MultipartForm.Value["remove_attachment"] {
if id, parseErr := strconv.Atoi(value); parseErr == nil {
_, _ = apiClient.DeleteJournalAttachment(r.Context(), gardenID, entryID, id)
}
}
body := form.Body
for index, header := range r.MultipartForm.File["embedded_images"] {
attachment, uploadErr := app.uploadJournalFile(r, apiClient, gardenID, entryID, header)
if uploadErr != nil {
app.handleAPIError(w, r, uploadErr)
return
}
url := webPath(view.path+".attachment", gardenID, entryID, attachment.ID)
body = strings.ReplaceAll(body, fmt.Sprintf("/pending-journal-image/%d", index), url)
}
for _, header := range r.MultipartForm.File["attachments"] {
if _, uploadErr := app.uploadJournalFile(r, apiClient, gardenID, entryID, header); uploadErr != nil {
app.handleAPIError(w, r, uploadErr)
return
}
}
for _, imageID := range form.LibraryImageIDs {
if _, _, attachErr := apiClient.AttachJournalLibraryImage(r.Context(), gardenID, entryID, imageID); attachErr != nil {
app.handleAPIError(w, r, attachErr)
return
}
}
if body != form.Body {
_, _, err = apiClient.UpdateJournalEntry(r.Context(), gardenID, entryID, client.JournalEntryInput{Body: &body})
}
if err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("garden."+view.path, gardenID), http.StatusSeeOther)
}
func (app *application) uploadJournalFile(r *http.Request, apiClient *client.Client, gardenID, entryID int, header *multipart.FileHeader) (client.JournalAttachment, error) {
file, err := header.Open()
if err != nil {
return client.JournalAttachment{}, err
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, (25<<20)+1))
if err != nil {
return client.JournalAttachment{}, err
}
if len(data) > 25<<20 {
return client.JournalAttachment{}, fmt.Errorf("anhang %q ist größer als 25 MB", header.Filename)
}
mediaType := strings.Split(header.Header.Get("Content-Type"), ";")[0]
attachment, _, err := apiClient.UploadJournalAttachment(r.Context(), gardenID, entryID, header.Filename, mediaType, data)
return attachment, err
}
func (app *application) journalDelete(w http.ResponseWriter, r *http.Request) {
app.gardenEntryDelete(w, r, journalEntryView)
}
func (app *application) pinboardDelete(w http.ResponseWriter, r *http.Request) {
app.gardenEntryDelete(w, r, pinboardEntryView)
}
func (app *application) gardenEntryDelete(w http.ResponseWriter, r *http.Request, view gardenEntryView) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
entryID, err := app.readPathID(r, "entryID")
if err != nil {
app.notFound(w)
return
}
apiClient := client.FromContext(r.Context())
entry, _, err := apiClient.JournalEntry(r.Context(), gardenID, entryID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
if entry.EntryType != view.entryType {
app.notFound(w)
return
}
if _, err = apiClient.DeleteJournalEntry(r.Context(), gardenID, entryID); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("garden."+view.path, gardenID), http.StatusSeeOther)
}
func (app *application) journalAttachment(w http.ResponseWriter, r *http.Request) {
app.gardenEntryAttachment(w, r)
}
func (app *application) pinboardAttachment(w http.ResponseWriter, r *http.Request) {
app.gardenEntryAttachment(w, r)
}
func (app *application) gardenEntryAttachment(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
entryID, err := app.readPathID(r, "entryID")
if err != nil {
app.notFound(w)
return
}
attachmentID, err := app.readPathID(r, "attachmentID")
if err != nil {
app.notFound(w)
return
}
attachment, _, err := client.FromContext(r.Context()).JournalAttachment(r.Context(), gardenID, entryID, attachmentID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
w.Header().Set("Content-Type", attachment.MediaType)
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", strings.ReplaceAll(attachment.FileName, "\"", "")))
w.Header().Set("Cache-Control", "private, max-age=3600")
http.ServeContent(w, r, attachment.FileName, attachment.CreatedAt, bytes.NewReader(attachment.Data))
}
func (app *application) renderJournalForm(w http.ResponseWriter, r *http.Request, form journalForm, status, entryID int, view gardenEntryView) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
garden, _, err := client.FromContext(r.Context()).Garden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
data := app.newTemplateData(r)
data.Garden, data.Form, data.EntryID, data.UseJournalEditor, data.ContentKind = &garden, form, entryID, true, view.entryType
data.Images, err = app.loadImageLibrary(r, gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
app.render(w, status, "journal_form.tmpl", data)
}
func (app *application) loadJournalTagSuggestions(r *http.Request, form *journalForm) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
return
}
if tags, _, tagErr := client.FromContext(r.Context()).GardenTags(r.Context(), gardenID); tagErr == nil {
form.TagSuggestions = tags
}
}
+99
View File
@@ -0,0 +1,99 @@
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gardomatic.kleiax.de/lib/client"
)
func TestJournalTemplateRendersMetadataMarkdownAndMedia(t *testing.T) {
app := newTestApplication(t)
garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "owner"}
entry := client.JournalEntry{ID: 7, GardenID: 3, AuthorName: "Ada", Title: "Ernte", Body: "**Drei** Tomaten.\n\n<script>alert(1)</script>", CreatedAt: time.Date(2026, 9, 5, 14, 30, 0, 0, time.Local), Tags: []string{"tomaten"}, Attachments: []client.JournalAttachment{{ID: 9, EntryID: 7, FileName: "foto.jpg", MediaType: "image/jpeg", Size: 2048}}}
response := httptest.NewRecorder()
app.render(response, http.StatusOK, "journal.tmpl", &templateData{commonTemplateData: commonTemplateData{Garden: &garden}, journalTemplateData: journalTemplateData{JournalEntries: []client.JournalEntry{entry}}})
body := response.Body.String()
for _, want := range []string{"<strong>Drei</strong>", "05.09.2026 · 14:30 Uhr", "Ada", "tomaten", "/g/3/journal/attachments/7/9", "Tagebuch", "data-card-href='/g/3/journal/edit/7'"} {
if !strings.Contains(body, want) {
t.Errorf("journal output missing %q: %s", want, body)
}
}
if strings.Contains(body, "<script>alert(1)</script>") {
t.Fatalf("raw HTML was not escaped: %s", body)
}
if strings.Contains(body, ">Bearbeiten</a>") {
t.Fatalf("journal tile still exposes a separate edit link: %s", body)
}
}
func TestJournalFormOffersBothEditorModesAndCaptureInputs(t *testing.T) {
app := newTestApplication(t)
garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "owner"}
response := httptest.NewRecorder()
app.render(response, http.StatusOK, "journal_form.tmpl", &templateData{commonTemplateData: commonTemplateData{Garden: &garden, Form: journalForm{Body: "# Notiz", Errors: map[string]string{}}, UseJournalEditor: true}})
body := response.Body.String()
for _, want := range []string{"toastui-editor-3.2.2.min.js", "data-journal-editor", "data-journal-body", "data-image-camera", "data-video-record", "data-video-pause", "data-video-apply", "Mediendateien auswählen", "Audio aufnehmen", "data-audio-dialog", "data-audio-preview", "data-audio-pause", "data-audio-apply", "data-recording-indicator", "name='embedded_images'", "data-tag-editor", "data-tag-suggestions"} {
if !strings.Contains(body, want) {
t.Errorf("journal form missing %q: %s", want, body)
}
}
if strings.Contains(body, "data-image-preview") || strings.Contains(body, "data-image-file") {
t.Fatalf("photo capture should expose one button, not a persistent preview/file picker: %s", body)
}
}
func TestPinboardReusesEntryAndPhotoComponents(t *testing.T) {
app := newTestApplication(t)
garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "owner"}
entry := client.JournalEntry{ID: 8, GardenID: 3, EntryType: client.JournalEntryTypePinboard, AuthorName: "Ada", Title: "Sitzecke", Body: "Bank unter den Apfelbaum", CreatedAt: time.Date(2026, 9, 6, 10, 0, 0, 0, time.Local), Attachments: []client.JournalAttachment{{ID: 11, EntryID: 8, FileName: "idee.jpg", MediaType: "image/jpeg", Size: 1024}}}
listResponse := httptest.NewRecorder()
app.render(listResponse, http.StatusOK, "journal.tmpl", &templateData{commonTemplateData: commonTemplateData{Garden: &garden, ContentKind: client.JournalEntryTypePinboard}, journalTemplateData: journalTemplateData{JournalEntries: []client.JournalEntry{entry}}})
listBody := listResponse.Body.String()
for _, want := range []string{"Pinnwand", "pinboard-list", "data-card-href='/g/3/pinboard/edit/8'", "/g/3/pinboard/attachments/8/11", "Bank unter den Apfelbaum"} {
if !strings.Contains(listBody, want) {
t.Errorf("pinboard output missing %q: %s", want, listBody)
}
}
formResponse := httptest.NewRecorder()
app.render(formResponse, http.StatusOK, "journal_form.tmpl", &templateData{commonTemplateData: commonTemplateData{Garden: &garden, ContentKind: client.JournalEntryTypePinboard, Form: journalForm{DateTime: "2026-09-06T10:00", Errors: map[string]string{}}, UseJournalEditor: true}, mediaTemplateData: mediaTemplateData{Images: []client.Image{{ID: 4, FileName: "vorhanden.jpg"}}}})
formBody := formResponse.Body.String()
for _, want := range []string{"Neue Notiz", "Fotos auswählen", "Foto mit Gerät aufnehmen", "Aus Bilderdatenbank", "href='/g/3/pinboard'"} {
if !strings.Contains(formBody, want) {
t.Errorf("pinboard form missing %q: %s", want, formBody)
}
}
if strings.Contains(formBody, "name='title' value='' maxlength='500' required") {
t.Fatalf("pinboard title is still required: %s", formBody)
}
if strings.Contains(formBody, "data-video-record") || strings.Contains(formBody, "data-audio-record") {
t.Fatalf("simple pinboard form exposes journal recording controls: %s", formBody)
}
}
func TestPinboardSearchCardLinksBackToPinboard(t *testing.T) {
card := gardenEntrySearchCard(3, client.JournalEntry{ID: 8, EntryType: client.JournalEntryTypePinboard, Title: "Sitzecke"})
if card.Type != "Pinnwand" || card.URL != "/g/3/pinboard#entry-8" {
t.Fatalf("pinboard search card: %+v", card)
}
}
func TestDashboardShowsJournalEntryCount(t *testing.T) {
app := newTestApplication(t)
garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "owner"}
entries := []client.JournalEntry{{ID: 1}, {ID: 2}, {ID: 3}}
response := httptest.NewRecorder()
app.render(response, http.StatusOK, "dashboard.tmpl", &templateData{commonTemplateData: commonTemplateData{Garden: &garden}, journalTemplateData: journalTemplateData{JournalEntries: entries, PinboardEntries: []client.JournalEntry{{ID: 4}}}})
body := response.Body.String()
if !strings.Contains(body, "href='/g/3/journal'") || !strings.Contains(body, "<span class='summary-number'>3</span><h3>Tagebuch</h3>") {
t.Fatalf("dashboard output missing journal count: %s", body)
}
if !strings.Contains(body, "href='/g/3/pinboard'") || !strings.Contains(body, "<span class='summary-number'>1</span><h3>Pinnwand</h3>") {
t.Fatalf("dashboard output missing pinboard count: %s", body)
}
}
+355
View File
@@ -0,0 +1,355 @@
package web
import (
"errors"
"net/http"
"strconv"
"strings"
"gardomatic.kleiax.de/lib/client"
)
type locationForm struct {
CSRFToken string `form:"csrf_token"`
ParentID int `form:"parent_id"`
Name string `form:"name"`
Description string `form:"description"`
ImageData string `form:"image_data"`
ImageID int `form:"image_id"`
Kind string `form:"kind"`
AreaSQM string `form:"area_sqm"`
SunExposure string `form:"sun_exposure"`
SoilCondition string `form:"soil_condition"`
SoilReaction string `form:"soil_reaction"`
ReturnTo string `form:"return_to"`
Errors map[string]string
}
func (app *application) locations(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
apiClient := client.FromContext(r.Context())
garden, _, err := apiClient.Garden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
locations, _, err := apiClient.Locations(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
data := app.newTemplateData(r)
data.Filters = collectionFilters(r, "q", "kind", "sort")
locations = filterAndSortLocations(locations, data.Filters)
locationTree := buildLocationTree(locations)
locationTree, data.Pagination = paginateCollection(r, locationTree)
data.Garden, data.Locations, data.LocationTree = &garden, flattenLocationTree(locationTree), locationTree
app.render(w, http.StatusOK, "locations.tmpl", data)
}
func (app *application) locationCreate(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
form := locationForm{ReturnTo: safeLocationReturn(r.URL.Query().Get("return_to"), gardenID), Errors: make(map[string]string)}
app.renderLocationForm(w, r, gardenID, form, http.StatusOK, r.Header.Get("HX-Request") == "true")
}
func (app *application) locationCreatePost(w http.ResponseWriter, r *http.Request) {
app.locationSave(w, r)
}
func (app *application) locationEdit(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
locationID, err := app.readPathID(r, "locationID")
if err != nil {
app.notFound(w)
return
}
location, _, err := client.FromContext(r.Context()).Location(r.Context(), gardenID, locationID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
form := locationForm{Name: location.Name, Description: location.Description, ImageData: location.ImageData, Kind: location.Kind, ReturnTo: webPath("locations", gardenID), Errors: map[string]string{}}
if location.ImageID != nil {
form.ImageID = *location.ImageID
}
if location.ParentID != nil {
form.ParentID = *location.ParentID
}
if location.AreaSQM != nil {
form.AreaSQM = strconv.FormatFloat(*location.AreaSQM, 'f', -1, 64)
}
if location.SunExposure != nil {
form.SunExposure = *location.SunExposure
}
if location.SoilCondition != nil {
form.SoilCondition = *location.SoilCondition
}
if location.SoilReaction != nil {
form.SoilReaction = *location.SoilReaction
}
app.renderLocationForm(w, r, gardenID, form, http.StatusOK, false, locationID)
}
func (app *application) locationEditPost(w http.ResponseWriter, r *http.Request) {
app.locationSave(w, r)
}
func (app *application) locationSave(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
locationID := 0
if id, readErr := app.readPathID(r, "locationID"); readErr == nil {
locationID = id
}
var form locationForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.Name, form.Description, form.Kind = strings.TrimSpace(form.Name), strings.TrimSpace(form.Description), strings.TrimSpace(form.Kind)
form.Errors = make(map[string]string)
if form.Name == "" {
form.Errors["name"] = "Ein Name ist erforderlich."
}
var area *float64
if strings.TrimSpace(form.AreaSQM) != "" {
value, parseErr := strconv.ParseFloat(strings.ReplaceAll(form.AreaSQM, ",", "."), 64)
if parseErr != nil || value < 0 {
form.Errors["area_sqm"] = "Die Fläche muss eine nichtnegative Zahl sein."
} else {
area = &value
}
}
if len(form.Errors) == 0 {
name, description, kind := form.Name, form.Description, form.Kind
imageData := form.ImageData
input := client.LocationInput{Name: &name, Description: &description, ImageData: &imageData, Kind: &kind, AreaSQM: area}
if form.ImageID > 0 {
imageID := form.ImageID
input.ImageID = &imageID
}
if form.ParentID > 0 {
parentID := form.ParentID
input.ParentID = &parentID
} else {
input.ClearParentID = true
}
if strings.TrimSpace(form.SunExposure) != "" {
exposure := strings.TrimSpace(form.SunExposure)
input.SunExposure = &exposure
}
input.SoilCondition = &form.SoilCondition
input.SoilReaction = &form.SoilReaction
var location client.Location
var createErr error
if locationID > 0 {
location, _, createErr = client.FromContext(r.Context()).UpdateLocation(r.Context(), gardenID, locationID, input)
} else {
location, _, createErr = client.FromContext(r.Context()).CreateLocation(r.Context(), gardenID, input)
}
if createErr == nil {
if r.Header.Get("HX-Request") == "true" && locationID == 0 {
w.Header().Set("HX-Trigger", "locationCreated")
data := app.newTemplateData(r)
data.Garden = &client.Garden{ID: gardenID}
data.Locations = []client.Location{location}
app.renderTemplate(w, http.StatusCreated, "location_form.tmpl", "location_option", data)
return
}
if locationID > 0 {
http.Redirect(w, r, webPath("locations", gardenID), http.StatusSeeOther)
return
}
returnTo := safeLocationReturn(form.ReturnTo, gardenID)
if returnTo == webPath("plant.new", gardenID) {
returnTo += "?location_id=" + strconv.Itoa(location.ID)
}
http.Redirect(w, r, returnTo, http.StatusSeeOther)
return
}
var apiError *client.APIError
if errors.As(createErr, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity {
form.Errors = apiError.Validation
} else {
app.handleAPIError(w, r, createErr)
return
}
}
inline := r.Header.Get("HX-Request") == "true"
status := http.StatusUnprocessableEntity
if inline {
// htmx does not swap 422 responses by default. Retarget the validated
// dialog fragment while retaining 422 for the normal HTML form.
w.Header().Set("HX-Retarget", "#location-dialog-host")
w.Header().Set("HX-Reswap", "innerHTML")
status = http.StatusOK
}
app.renderLocationForm(w, r, gardenID, form, status, inline, locationID)
}
func (app *application) locationDelete(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
locationID, err := app.readPathID(r, "locationID")
if err != nil {
app.notFound(w)
return
}
if _, err := client.FromContext(r.Context()).DeleteLocation(r.Context(), gardenID, locationID); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("locations", gardenID), http.StatusSeeOther)
}
func (app *application) renderLocationForm(w http.ResponseWriter, r *http.Request, gardenID int, form locationForm, status int, inline bool, locationIDs ...int) {
apiClient := client.FromContext(r.Context())
garden, _, err := apiClient.Garden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
locations, _, err := apiClient.Locations(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
data := app.newTemplateData(r)
data.Garden, data.Locations, data.Form = &garden, locations, form
data.Images, err = app.loadImageLibrary(r, gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
if len(locationIDs) > 0 && locationIDs[0] > 0 {
locationID := locationIDs[0]
data.LocationID = locationID
for _, location := range locations {
if location.ID == locationID {
data.LocationCreatedBy = location.CreatedBy
break
}
}
plants, _, plantsErr := apiClient.Plants(r.Context(), gardenID)
if plantsErr != nil {
app.handleAPIError(w, r, plantsErr)
return
}
for _, plant := range plants {
assignments, _, assignmentErr := apiClient.PlantLocations(r.Context(), gardenID, plant.ID)
if assignmentErr != nil {
app.handleAPIError(w, r, assignmentErr)
return
}
for _, assignment := range assignments {
if assignment.LocationID != locationID {
continue
}
item := locationPlant{Plant: plant, Assignment: assignment}
if assignment.RemovedAt == nil && !isHistoricalPlant(plant) {
data.LocationPlants = append(data.LocationPlants, item)
}
if isHistoricalPlant(plant) {
year := locationPlantHistoryYear(plant, assignment, data.CurrentYear)
for i := range data.LocationHistory {
if data.LocationHistory[i].Year == year {
data.LocationHistory[i].Plants = append(data.LocationHistory[i].Plants, item)
year = 0
break
}
}
if year != 0 {
for i := 0; i < len(data.LocationHistory); i++ {
if data.LocationHistory[i].Year < year {
data.LocationHistory = append(data.LocationHistory, locationPlantHistory{})
copy(data.LocationHistory[i+1:], data.LocationHistory[i:])
data.LocationHistory[i] = locationPlantHistory{Year: year, Plants: []locationPlant{item}}
year = 0
break
}
}
if year != 0 {
data.LocationHistory = append(data.LocationHistory, locationPlantHistory{Year: year, Plants: []locationPlant{item}})
}
}
}
}
}
for year := data.CurrentYear; year >= data.CurrentYear-2; year-- {
found := false
for _, history := range data.LocationHistory {
if history.Year == year {
found = true
break
}
}
if !found {
data.LocationHistory = append(data.LocationHistory, locationPlantHistory{Year: year})
}
}
// Keep the fixed three-year window in descending order.
for i := 0; i < len(data.LocationHistory); i++ {
for j := i + 1; j < len(data.LocationHistory); j++ {
if data.LocationHistory[j].Year > data.LocationHistory[i].Year {
data.LocationHistory[i], data.LocationHistory[j] = data.LocationHistory[j], data.LocationHistory[i]
}
}
}
}
if inline {
app.renderTemplate(w, status, "location_form.tmpl", "location_dialog", data)
} else {
app.render(w, status, "location_form.tmpl", data)
}
}
func isHistoricalPlant(plant client.Plant) bool {
return plant.Status == "dead" || plant.Status == "removed"
}
func locationPlantHistoryYear(plant client.Plant, assignment client.PlantLocation, currentYear int) int {
date := plant.RemovedAt
if date == nil {
date = assignment.RemovedAt
}
if date == nil && !plant.UpdatedAt.IsZero() {
date = &plant.UpdatedAt
}
if date == nil && !plant.CreatedAt.IsZero() {
date = &plant.CreatedAt
}
if date == nil {
return currentYear
}
if date.Year() < currentYear-2 || date.Year() > currentYear {
return 0
}
return date.Year()
}
func safeLocationReturn(value string, gardenID int) string {
plantForm := webPath("plant.new", gardenID)
if value == plantForm {
return value
}
return webPath("locations", gardenID)
}
+161
View File
@@ -0,0 +1,161 @@
package web
import (
"errors"
"net/http"
"strings"
"gardomatic.kleiax.de/lib/client"
)
type inviteForm struct {
CSRFToken string `form:"csrf_token"`
Email string `form:"email"`
Role string `form:"role"`
Errors map[string]string
}
func (app *application) gardenMembers(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
http.Redirect(w, r, webPath("garden.edit", gardenID), http.StatusSeeOther)
}
func (app *application) gardenInvitePost(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
var form inviteForm
if err = app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.Email, form.Role, form.Errors = strings.TrimSpace(form.Email), strings.TrimSpace(form.Role), map[string]string{}
_, _, err = client.FromContext(r.Context()).CreateGardenInvite(r.Context(), gardenID, client.GardenInviteInput{Email: form.Email, Role: form.Role})
if err != nil {
var apiError *client.APIError
if errors.As(err, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity {
form.Errors = apiError.Validation
} else {
app.handleAPIError(w, r, err)
return
}
}
if len(form.Errors) == 0 {
http.Redirect(w, r, webPath("garden.edit", gardenID), http.StatusSeeOther)
return
}
app.renderGardenEdit(w, r, gardenID, http.StatusUnprocessableEntity, gardenEditForms{Invite: form})
}
type roleForm struct {
CSRFToken string `form:"csrf_token"`
Role string `form:"role"`
}
func (app *application) gardenMemberRolePost(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
userID, err := app.readPathID(r, "userID")
if err != nil {
app.notFound(w)
return
}
var form roleForm
if err = app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
if _, _, err = client.FromContext(r.Context()).UpdateGardenMember(r.Context(), gardenID, userID, form.Role); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("garden.edit", gardenID), http.StatusSeeOther)
}
func (app *application) gardenMemberDeletePost(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
userID, err := app.readPathID(r, "userID")
if err != nil {
app.notFound(w)
return
}
if _, err = client.FromContext(r.Context()).DeleteGardenMember(r.Context(), gardenID, userID); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("garden.edit", gardenID), http.StatusSeeOther)
}
func (app *application) gardenOwnershipPost(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
userID, err := app.readPathID(r, "userID")
if err != nil {
app.notFound(w)
return
}
if _, err = client.FromContext(r.Context()).TransferGardenOwnership(r.Context(), gardenID, userID); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("garden.edit", gardenID), http.StatusSeeOther)
}
func (app *application) gardenInviteDeletePost(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
inviteID, err := app.readPathID(r, "inviteID")
if err != nil {
app.notFound(w)
return
}
if _, err = client.FromContext(r.Context()).DeleteGardenInvite(r.Context(), gardenID, inviteID); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("garden.edit", gardenID), http.StatusSeeOther)
}
type acceptInviteForm struct {
CSRFToken string `form:"csrf_token"`
Token string `form:"token"`
}
func (app *application) acceptInvite(w http.ResponseWriter, r *http.Request) {
data := app.newTemplateData(r)
data.Form = acceptInviteForm{Token: r.URL.Query().Get("token")}
app.render(w, http.StatusOK, "invite.tmpl", data)
}
func (app *application) acceptInvitePost(w http.ResponseWriter, r *http.Request) {
var form acceptInviteForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
member, _, err := client.FromContext(r.Context()).AcceptGardenInvite(r.Context(), form.Token)
if err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("plants", member.GardenID), http.StatusSeeOther)
}
+114
View File
@@ -0,0 +1,114 @@
package web
import (
"context"
"errors"
"fmt"
"net/http"
"gardomatic.kleiax.de/lib/client"
"github.com/justinas/nosurf"
)
func secureHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self'; style-src-attr 'unsafe-inline'; script-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; manifest-src 'self'; connect-src 'self'")
w.Header().Set("Permissions-Policy", "camera=(self), microphone=(self)")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "deny")
next.ServeHTTP(w, r)
})
}
func (app *application) logRequest(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
app.logger.Info("request", "remote_addr", r.RemoteAddr, "proto", r.Proto, "method", r.Method, "uri", r.URL.RequestURI())
next.ServeHTTP(w, r)
})
}
func (app *application) recoverPanic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if recovered := recover(); recovered != nil {
w.Header().Set("Connection", "close")
app.serverError(w, fmt.Errorf("panic: %v", recovered))
}
}()
next.ServeHTTP(w, r)
})
}
func (app *application) requireAuthentication(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !app.isAuthenticated(r) {
http.Redirect(w, r, webPath("login"), http.StatusSeeOther)
return
}
w.Header().Set("Cache-Control", "no-store")
next.ServeHTTP(w, r)
})
}
func (app *application) requireActivatedUser(next http.Handler) http.Handler {
return app.requireAuthentication(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok || !user.Activated {
http.Redirect(w, r, webPath("activate"), http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
}))
}
func (app *application) noSurf(next http.Handler) http.Handler {
csrfHandler := nosurf.New(next)
csrfHandler.SetIsTLSFunc(func(r *http.Request) bool {
return r.TLS != nil || app.config.CookieSecure
})
csrfHandler.SetBaseCookie(http.Cookie{
Name: "gardomatic_csrf",
HttpOnly: true,
Path: "/",
SameSite: http.SameSiteLaxMode,
Secure: app.config.CookieSecure,
})
return csrfHandler
}
func (app *application) withAPIClient(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestClient, err := app.apiClient.ForRequest(r)
if err != nil {
app.serverError(w, err)
return
}
ctx := client.NewContext(r.Context(), requestClient)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (app *application) authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
apiClient := client.FromContext(r.Context())
if apiClient == nil {
app.serverError(w, errors.New("API client missing from request context"))
return
}
user, response, err := apiClient.Session(r.Context())
if err != nil {
var apiError *client.APIError
if errors.As(err, &apiError) && apiError.StatusCode == http.StatusUnauthorized {
next.ServeHTTP(w, r)
return
}
app.serverError(w, err)
return
}
client.ForwardCookies(w, response)
ctx := context.WithValue(r.Context(), isAuthenticatedContextKey, true)
ctx = context.WithValue(ctx, userContextKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
+96
View File
@@ -0,0 +1,96 @@
package web
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/justinas/nosurf"
)
func TestSecureHeaders(t *testing.T) {
rr := httptest.NewRecorder()
r, err := http.NewRequest(http.MethodGet, "/", nil)
if err != nil {
t.Fatal(err)
}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
})
secureHeaders(next).ServeHTTP(rr, r)
rs := rr.Result()
expectedValue := "default-src 'self'; style-src 'self'; style-src-attr 'unsafe-inline'; script-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; manifest-src 'self'; connect-src 'self'"
if got := rs.Header.Get("Content-Security-Policy"); got != expectedValue {
t.Errorf("Content-Security-Policy: got %q, want %q", got, expectedValue)
}
expectedValue = "strict-origin-when-cross-origin"
if got := rs.Header.Get("Referrer-Policy"); got != expectedValue {
t.Errorf("Referrer-Policy: got %q, want %q", got, expectedValue)
}
expectedValue = "nosniff"
if got := rs.Header.Get("X-Content-Type-Options"); got != expectedValue {
t.Errorf("X-Content-Type-Options: got %q, want %q", got, expectedValue)
}
expectedValue = "deny"
if got := rs.Header.Get("X-Frame-Options"); got != expectedValue {
t.Errorf("X-Frame-Options: got %q, want %q", got, expectedValue)
}
if got := rs.Header.Get("Permissions-Policy"); got != "camera=(self), microphone=(self)" {
t.Errorf("Permissions-Policy: got %q, want %q", got, "camera=(self), microphone=(self)")
}
if rs.StatusCode != http.StatusOK {
t.Errorf("status: got %d, want %d", rs.StatusCode, http.StatusOK)
}
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
body = bytes.TrimSpace(body)
if got := string(body); got != "OK" {
t.Errorf("body: got %q, want %q", got, "OK")
}
}
func TestNoSurfAcceptsSameOriginHTTPFromLAN(t *testing.T) {
app := &application{config: Config{CookieSecure: false}}
handler := app.noSurf(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
_, _ = w.Write([]byte(nosurf.Token(r)))
return
}
w.WriteHeader(http.StatusNoContent)
}))
getRequest := httptest.NewRequest(http.MethodGet, "http://192.168.1.50:4040/login", nil)
getResponse := httptest.NewRecorder()
handler.ServeHTTP(getResponse, getRequest)
csrfCookie := getResponse.Result().Cookies()[0]
form := url.Values{"csrf_token": {getResponse.Body.String()}}
postRequest := httptest.NewRequest(http.MethodPost, "http://192.168.1.50:4040/login", bytes.NewBufferString(form.Encode()))
postRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
postRequest.Header.Set("Origin", "http://192.168.1.50:4040")
postRequest.AddCookie(csrfCookie)
postResponse := httptest.NewRecorder()
handler.ServeHTTP(postResponse, postRequest)
if postResponse.Code != http.StatusNoContent {
t.Fatalf("status: got %d, want %d", postResponse.Code, http.StatusNoContent)
}
}
+71
View File
@@ -0,0 +1,71 @@
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"gardomatic.kleiax.de/lib/client"
)
func TestPlantPageNamesAreUsedThroughoutTheApplication(t *testing.T) {
app := newTestApplication(t)
user := client.User{ID: 1, Activated: true}
garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "owner"}
tests := []struct {
name string
template string
data *templateData
want []string
}{
{
name: "garden plants",
template: "plant.tmpl",
data: &templateData{commonTemplateData: commonTemplateData{Filters: map[string]string{}}},
want: []string{"<title>Im Garten", "<h2>Im Garten</h2>"},
},
{
name: "species",
template: "species.tmpl",
data: &templateData{commonTemplateData: commonTemplateData{Filters: map[string]string{}}},
want: []string{"<title>Pflanzen", "<h2>Pflanzen</h2>"},
},
{
name: "settings",
template: "settings.tmpl",
data: &templateData{},
want: []string{"for='view-plants'>Im Garten</label>", "for='view-species'>Pflanzen</label>"},
},
{
name: "search",
template: "search.tmpl",
data: &templateData{searchTemplateData: searchTemplateData{Search: searchResults{Query: "Tomate"}}},
want: []string{"<h3>Im Garten</h3>", "<h3>Pflanzen und Pflanzzeiten</h3>"},
},
{
name: "dashboard",
template: "dashboard.tmpl",
data: &templateData{},
want: []string{"href='/g/3/plants'><span class='summary-number'>0</span><h3>Im Garten</h3>"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.data.IsAuthenticated = true
tt.data.IsActivated = true
tt.data.CurrentUser = &user
tt.data.Garden = &garden
response := httptest.NewRecorder()
app.render(response, http.StatusOK, tt.template, tt.data)
body := response.Body.String()
for _, want := range append([]string{"href='/g/3/plants'>Im Garten</a>", "href='/g/3/species'>Pflanzen</a>"}, tt.want...) {
if !strings.Contains(body, want) {
t.Errorf("%s is missing %q", tt.template, want)
}
}
})
}
}
+81
View File
@@ -0,0 +1,81 @@
package web
import (
"net/http"
"net/url"
"strconv"
)
const defaultCollectionPageSize = 20
type paginationData struct {
Page int
TotalPages int
PreviousURL string
NextURL string
}
func paginateCollection[T any](r *http.Request, values []T) ([]T, *paginationData) {
pageSize := collectionPageSize(r)
totalPages := (len(values) + pageSize - 1) / pageSize
if totalPages <= 1 {
return values, nil
}
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
if page > totalPages {
page = totalPages
}
start := (page - 1) * pageSize
end := min(start+pageSize, len(values))
pagination := &paginationData{Page: page, TotalPages: totalPages}
if page > 1 {
pagination.PreviousURL = collectionPageURL(r.URL, page-1)
}
if page < totalPages {
pagination.NextURL = collectionPageURL(r.URL, page+1)
}
return values[start:end], pagination
}
func collectionPageSize(r *http.Request) int {
userSuffix := ""
if user, ok := userFromContext(r.Context()); ok {
userSuffix = "." + strconv.Itoa(user.ID)
}
for _, cookieName := range []string{"gardomatic.entries-per-page" + userSuffix, "gardomatic.tasks-per-page" + userSuffix} {
cookie, err := r.Cookie(cookieName)
if err != nil {
continue
}
pageSize, err := strconv.Atoi(cookie.Value)
if err == nil && validCollectionPageSize(pageSize) {
return pageSize
}
}
return defaultCollectionPageSize
}
func validCollectionPageSize(pageSize int) bool {
switch pageSize {
case 10, 20, 50, 100:
return true
default:
return false
}
}
func collectionPageURL(current *url.URL, page int) string {
query := current.Query()
query.Set("page", strconv.Itoa(page))
path := current.Path
if path == "" {
path = "?"
} else {
path += "?"
}
return path + query.Encode()
}
+38
View File
@@ -0,0 +1,38 @@
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestPaginateCollectionPreservesQueryParameters(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/g/3/plants?q=tomate&sort=name_asc&page=2", nil)
request.AddCookie(&http.Cookie{Name: "gardomatic.entries-per-page", Value: "10"})
values := make([]int, 25)
for index := range values {
values[index] = index + 1
}
page, pagination := paginateCollection(request, values)
if len(page) != 10 || page[0] != 11 || page[9] != 20 {
t.Fatalf("page = %v, want values 11 through 20", page)
}
if pagination == nil || pagination.Page != 2 || pagination.TotalPages != 3 {
t.Fatalf("pagination = %+v, want page 2 of 3", pagination)
}
for _, pageURL := range []string{pagination.PreviousURL, pagination.NextURL} {
if !strings.Contains(pageURL, "q=tomate") || !strings.Contains(pageURL, "sort=name_asc") {
t.Fatalf("pagination URL does not preserve filters: %q", pageURL)
}
}
}
func TestPaginateCollectionOmitsNavigationForOnePage(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/gardens", nil)
page, pagination := paginateCollection(request, []int{1, 2})
if len(page) != 2 || pagination != nil {
t.Fatalf("page = %v, pagination = %+v; want unmodified page without navigation", page, pagination)
}
}
+143
View File
@@ -0,0 +1,143 @@
package web
import (
"fmt"
"net/url"
"strings"
)
// webRoutes is the single source of truth for browser-facing dynamic paths.
// Parameter values are passed to webPath in the order in which they occur here.
var webRoutes = map[string]string{
"home": "/",
"healthcheck": "/healtcheck",
"privacy": "/datenschutz",
"login": "/login",
"activate": "/activate",
"logout": "/logout",
"account": "/account",
"account.profile": "/account/profile",
"account.password": "/account/password",
"account.email": "/account/email",
"account.email-confirm": "/account/email-confirm",
"account.session.delete": "/account/sessions/:sessionID/delete",
"settings": "/settings",
"admin": "/admin",
"admin.user.invite": "/admin/user-invite",
"admin.user.role": "/admin/users/:userID/role",
"admin.user.delete": "/admin/users/:userID/delete",
"admin.role.new": "/admin/roles/new",
"admin.role.update": "/admin/roles/update",
"admin.role.delete": "/admin/roles/delete",
"admin.application-settings": "/admin/application-settings",
"admin.test-mail": "/admin/test-mail",
"admin.species-category.new": "/admin/species-category-new",
"admin.species-category.edit": "/admin/species-category-edit/:categoryID",
"admin.species-category.delete": "/admin/species-category-delete/:categoryID",
"admin.task-priority.new": "/admin/task-priority-new",
"admin.task-priority.edit": "/admin/task-priority-edit/:priorityID",
"admin.task-priority.delete": "/admin/task-priority-delete/:priorityID",
"gardens": "/gardens",
"garden.new": "/gardens/new",
"garden.edit": "/gardens/edit/:gardenID",
"garden.delete": "/gardens/delete/:gardenID",
"garden.dashboard": "/g/:gardenID",
"garden.search": "/g/:gardenID/search",
"garden.journal": "/g/:gardenID/journal",
"garden.pinboard": "/g/:gardenID/pinboard",
"garden.images": "/g/:gardenID/images",
"garden.image.data": "/g/:gardenID/images/:imageID/data",
"journal.new": "/g/:gardenID/journal/new",
"journal.edit": "/g/:gardenID/journal/edit/:entryID",
"journal.delete": "/g/:gardenID/journal/delete/:entryID",
"journal.attachment": "/g/:gardenID/journal/attachments/:entryID/:attachmentID",
"pinboard.new": "/g/:gardenID/pinboard/new",
"pinboard.edit": "/g/:gardenID/pinboard/edit/:entryID",
"pinboard.delete": "/g/:gardenID/pinboard/delete/:entryID",
"pinboard.attachment": "/g/:gardenID/pinboard/attachments/:entryID/:attachmentID",
"garden.members": "/g/:gardenID/members",
"garden.invite.new": "/g/:gardenID/member-invite-new",
"garden.member.role": "/g/:gardenID/members/:userID/role",
"garden.member.delete": "/g/:gardenID/members/:userID/delete",
"garden.member.transfer": "/g/:gardenID/members/:userID/transfer",
"garden.invite.delete": "/g/:gardenID/member-invite-delete/:inviteID",
"garden.role.update": "/g/:gardenID/roles/update",
"garden.role.new": "/g/:gardenID/roles/new",
"garden.role.delete": "/g/:gardenID/roles/delete",
"invite": "/invite",
"plants": "/g/:gardenID/plants",
"plant.new": "/g/:gardenID/plants/new",
"plant.edit": "/g/:gardenID/plants/edit/:plantID",
"plant.delete": "/g/:gardenID/plants/delete/:plantID",
"plant.status": "/g/:gardenID/plants/status/:plantID",
"plant.template-opt-out": "/g/:gardenID/plant-template-opt-out/:plantID/:templateID",
"plant.task-row": "/g/:gardenID/plant-task-row",
"plant.template-tasks": "/g/:gardenID/plant-template-tasks",
"plant.template-task": "/g/:gardenID/plant-template-task/:templateID",
"plant.location.new": "/g/:gardenID/plant-location-new/:plantID",
"plant.location.edit": "/g/:gardenID/plant-location-edit/:plantID/:assignmentID",
"plant.location.delete": "/g/:gardenID/plant-location-delete/:plantID/:assignmentID",
"locations": "/g/:gardenID/locations",
"location.new": "/g/:gardenID/locations/new",
"location.view": "/g/:gardenID/locations/view/:locationID",
"location.edit": "/g/:gardenID/locations/edit/:locationID",
"location.delete": "/g/:gardenID/locations/delete/:locationID",
"species": "/g/:gardenID/species",
"species.new": "/g/:gardenID/species/new",
"species.edit": "/g/:gardenID/species/edit/:speciesID",
"species.delete": "/g/:gardenID/species/delete/:speciesID",
"species.care.new": "/g/:gardenID/species-care/:speciesID/new",
"species.care.edit": "/g/:gardenID/species-care/:speciesID/edit/:instructionID",
"species.care.delete": "/g/:gardenID/species-care/:speciesID/delete/:instructionID",
"tasks": "/g/:gardenID/tasks",
"tasks.calendar": "/g/:gardenID/tasks/calendar",
"task.new": "/g/:gardenID/tasks/new",
"task.edit": "/g/:gardenID/tasks/edit/:taskID",
"task.complete": "/g/:gardenID/tasks/complete/:taskID",
"task.reopen": "/g/:gardenID/tasks/reopen/:taskID",
"task.delete": "/g/:gardenID/tasks/delete/:taskID",
"task-template.new": "/g/:gardenID/task-templates/new",
"task-template.edit": "/g/:gardenID/task-templates/edit/:templateID",
"task-template.delete": "/g/:gardenID/task-templates/delete/:templateID",
}
func routePath(name string) string {
pattern, ok := webRoutes[name]
if !ok {
panic("unknown web route: " + name)
}
return pattern
}
func webPath(name string, params ...any) string {
parts := strings.Split(routePath(name), "/")
paramIndex := 0
for i, part := range parts {
if !strings.HasPrefix(part, ":") {
continue
}
if paramIndex >= len(params) {
panic(fmt.Sprintf("route %q needs more parameters", name))
}
parts[i] = url.PathEscape(fmt.Sprint(params[paramIndex]))
paramIndex++
}
if paramIndex != len(params) {
panic(fmt.Sprintf("route %q received too many parameters", name))
}
return strings.Join(parts, "/")
}
func pathWithQuery(path string, pairs ...any) string {
if len(pairs)%2 != 0 {
panic("pathWithQuery requires key/value pairs")
}
values := url.Values{}
for i := 0; i < len(pairs); i += 2 {
values.Set(fmt.Sprint(pairs[i]), fmt.Sprint(pairs[i+1]))
}
if len(values) == 0 {
return path
}
return path + "?" + values.Encode()
}
+53
View File
@@ -0,0 +1,53 @@
package web
import (
"net/http/httptest"
"testing"
"gardomatic.kleiax.de/lib/client"
)
func TestWebPathBuildsRegisteredRoutes(t *testing.T) {
tests := []struct {
name string
params []any
want string
}{
{"home", nil, "/"},
{"garden.dashboard", []any{3}, "/g/3"},
{"plant.edit", []any{3, 7}, "/g/3/plants/edit/7"},
{"account.session.delete", []any{"session/with slash"}, "/account/sessions/session%2Fwith%20slash/delete"},
}
for _, test := range tests {
if got := webPath(test.name, test.params...); got != test.want {
t.Errorf("webPath(%q): got %q, want %q", test.name, got, test.want)
}
}
}
func TestPathWithQueryEncodesValues(t *testing.T) {
got := pathWithQuery(webPath("location.new", 3), "return_to", webPath("plant.new", 3), "name", "Süd Beet")
want := "/g/3/locations/new?name=S%C3%BCd+Beet&return_to=%2Fg%2F3%2Fplants%2Fnew"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestGardenAwareAdminPaths(t *testing.T) {
garden := &client.Garden{ID: 3}
if got := gardenAwarePath(webPath("admin.role.new"), garden); got != "/admin/roles/new?garden=3" {
t.Fatalf("gardenAwarePath: got %q", got)
}
request := httptest.NewRequest("POST", "/admin/test-mail?garden=3", nil)
if got := adminPath(request, "#mail-settings", "test-mail", "sent"); got != "/admin?garden=3&test-mail=sent#mail-settings" {
t.Fatalf("adminPath: got %q", got)
}
}
func TestEveryRoutePatternStartsWithSlash(t *testing.T) {
for name, pattern := range webRoutes {
if pattern == "" || pattern[0] != '/' {
t.Errorf("route %q has invalid pattern %q", name, pattern)
}
}
}
+179
View File
@@ -0,0 +1,179 @@
package web
import (
"errors"
"net/http"
"strings"
"time"
"gardomatic.kleiax.de/lib/client"
)
type assignmentForm struct {
CSRFToken string `form:"csrf_token"`
LocationID int `form:"location_id"`
Quantity int `form:"quantity"`
PlantedAt string `form:"planted_at"`
Notes string `form:"notes"`
Errors map[string]string
}
func (app *application) plantLocationCreate(w http.ResponseWriter, r *http.Request) {
app.renderPlantLocationForm(w, r, assignmentForm{Quantity: 1, Errors: map[string]string{}}, http.StatusOK, 0)
}
func (app *application) plantLocationEdit(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
plantID, err := app.readPathID(r, "plantID")
if err != nil {
app.notFound(w)
return
}
assignmentID, err := app.readPathID(r, "assignmentID")
if err != nil {
app.notFound(w)
return
}
items, _, err := client.FromContext(r.Context()).PlantLocations(r.Context(), gardenID, plantID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
form := assignmentForm{Errors: map[string]string{}}
found := false
for _, item := range items {
if item.ID == assignmentID {
found = true
form.LocationID = item.LocationID
form.Quantity = item.Quantity
form.Notes = item.Notes
if item.PlantedAt != nil {
form.PlantedAt = item.PlantedAt.Format("2006-01-02")
}
break
}
}
if !found {
app.notFound(w)
return
}
app.renderPlantLocationForm(w, r, form, http.StatusOK, assignmentID)
}
func (app *application) plantLocationSave(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
plantID, err := app.readPathID(r, "plantID")
if err != nil {
app.notFound(w)
return
}
assignmentID, _ := app.readPathID(r, "assignmentID")
var form assignmentForm
if err = app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.Notes = strings.TrimSpace(form.Notes)
form.Errors = map[string]string{}
if form.LocationID < 1 {
form.Errors["location_id"] = "Ein Ort ist erforderlich."
}
if form.Quantity < 1 {
form.Errors["quantity"] = "Die Anzahl muss mindestens 1 sein."
}
var plantedAt *time.Time
if form.PlantedAt != "" {
parsed, parseErr := time.Parse("2006-01-02", form.PlantedAt)
if parseErr != nil {
form.Errors["planted_at"] = "Das Datum ist ungültig."
} else {
plantedAt = &parsed
}
}
if len(form.Errors) == 0 {
locationID, quantity, notes := form.LocationID, form.Quantity, form.Notes
input := client.PlantLocationInput{LocationID: &locationID, Quantity: &quantity, PlantedAt: plantedAt, ClearPlantedAt: plantedAt == nil, Notes: &notes}
apiClient := client.FromContext(r.Context())
if assignmentID > 0 {
_, _, err = apiClient.UpdatePlantLocation(r.Context(), gardenID, plantID, assignmentID, input)
} else {
_, _, err = apiClient.CreatePlantLocation(r.Context(), gardenID, plantID, input)
}
if err == nil {
http.Redirect(w, r, webPath("plants", gardenID), http.StatusSeeOther)
return
}
var apiError *client.APIError
if errors.As(err, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity {
form.Errors = apiError.Validation
} else {
app.handleAPIError(w, r, err)
return
}
}
app.renderPlantLocationForm(w, r, form, http.StatusUnprocessableEntity, assignmentID)
}
func (app *application) plantLocationDeletePost(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
plantID, err := app.readPathID(r, "plantID")
if err != nil {
app.notFound(w)
return
}
assignmentID, err := app.readPathID(r, "assignmentID")
if err != nil {
app.notFound(w)
return
}
if _, err = client.FromContext(r.Context()).DeletePlantLocation(r.Context(), gardenID, plantID, assignmentID); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("plants", gardenID), http.StatusSeeOther)
}
func (app *application) renderPlantLocationForm(w http.ResponseWriter, r *http.Request, form assignmentForm, status, assignmentID int) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
plantID, err := app.readPathID(r, "plantID")
if err != nil {
app.notFound(w)
return
}
apiClient := client.FromContext(r.Context())
garden, _, err := apiClient.Garden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
plant, _, err := apiClient.Plant(r.Context(), gardenID, plantID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
locations, _, err := apiClient.Locations(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
data := app.newTemplateData(r)
data.Garden = &garden
data.Plants = []client.Plant{plant}
data.PlantID = plantID
data.LocationID = assignmentID
data.Locations = locations
data.Form = form
app.render(w, status, "plant_location_form.tmpl", data)
}
+244
View File
@@ -0,0 +1,244 @@
package web
import (
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"gardomatic.kleiax.de/lib/client"
)
func (app *application) savePlantTasks(r *http.Request, gardenID, plantID int, forms []plantTaskForm) error {
apiClient := client.FromContext(r.Context())
for _, form := range forms {
if form.ID > 0 {
existing, _, err := apiClient.Task(r.Context(), gardenID, form.ID)
if err != nil {
return err
}
if existing.PlantID == nil || *existing.PlantID != plantID || existing.TemplateID != nil {
return fmt.Errorf("task %d does not belong to plant %d or was generated from a template", form.ID, plantID)
}
if form.Delete {
if _, err = apiClient.DeleteTask(r.Context(), gardenID, form.ID); err != nil {
return err
}
continue
}
}
if form.Delete {
continue
}
start, _ := parseTaskTime(form.DueAtStart, "", map[string]string{})
end, _ := parseTaskTime(form.DueAtEnd, "", map[string]string{})
active := form.Active
input := client.TaskInput{PlantID: &plantID, Title: &form.Title, Description: &form.Description, DueAtStart: start, DueAtEnd: end, ClearDueAtStart: start == nil, ClearDueAtEnd: end == nil, Priority: &form.Priority, Active: &active, Recurrence: &form.Recurrence, RecurrenceInterval: &form.RecurrenceInterval, Tags: parseKeywords(form.Keywords), PlantStatusOnCompletion: &form.PlantStatusOnCompletion}
if form.LocationID > 0 {
input.LocationID = &form.LocationID
} else {
input.ClearLocationID = true
}
if form.ID > 0 {
if _, _, err := apiClient.UpdateTask(r.Context(), gardenID, form.ID, input); err != nil {
return err
}
} else if _, _, err := apiClient.CreateTask(r.Context(), gardenID, input); err != nil {
return err
}
}
return nil
}
func (app *application) savePlantTemplateStates(r *http.Request, gardenID, plantID, speciesID int) error {
templates, _, err := client.FromContext(r.Context()).SpeciesTaskTemplates(r.Context(), gardenID, speciesID)
if err != nil {
return err
}
for _, item := range templates {
values, present := r.PostForm["plant_template_active_"+strconv.Itoa(item.ID)]
if !present {
continue
}
enabled := len(values) > 0 && values[len(values)-1] == "true"
if _, err = client.FromContext(r.Context()).SetTaskTemplateOptOut(r.Context(), gardenID, plantID, item.ID, !enabled); err != nil {
return err
}
}
return nil
}
func (app *application) plantTaskRow(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
form := plantTaskFromValues(r.URL.Query())
newTask := r.URL.Query().Get("new") == "true" || form.RowKey == ""
if form.RowKey == "" {
form.RowKey = "task-new-" + strconv.FormatInt(time.Now().UnixNano(), 36)
form.Active = true
}
form.Errors = map[string]string{}
data := app.newTemplateData(r)
if !app.loadPlantTaskDialogData(w, r, gardenID, data) {
return
}
data.PendingPlantName = form.PendingPlantName
data.Garden = &client.Garden{ID: gardenID}
data.Form, data.TaskDialogNew = form, newTask
app.renderTemplate(w, http.StatusOK, "plant_form.tmpl", "plant_task_dialog", data)
}
func (app *application) plantTaskRowPost(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
if err = r.ParseForm(); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form := plantTaskFromValues(r.PostForm)
form.Title, form.Description = strings.TrimSpace(form.Title), strings.TrimSpace(form.Description)
form.Errors = map[string]string{}
if form.Title == "" {
form.Errors["title"] = "Ein Titel ist erforderlich."
}
start, _ := parseTaskTime(form.DueAtStart, "due_at_start", form.Errors)
end, _ := parseTaskTime(form.DueAtEnd, "due_at_end", form.Errors)
if start != nil && end != nil && start.After(*end) {
form.Errors["due_at_end"] = "Das Ende darf nicht vor dem Beginn liegen."
}
newTask := r.PostForm.Get("new") == "true"
data := app.newTemplateData(r)
data.Garden, data.Form, data.TaskDialogNew = &client.Garden{ID: gardenID}, form, newTask
data.PendingPlantName = form.PendingPlantName
if len(form.Errors) > 0 {
if !app.loadPlantTaskDialogData(w, r, gardenID, data) {
return
}
w.Header().Set("HX-Retarget", "#plant-task-dialog-host")
w.Header().Set("HX-Reswap", "innerHTML")
app.renderTemplate(w, http.StatusOK, "plant_form.tmpl", "plant_task_dialog", data)
return
}
w.Header().Set("HX-Trigger", "plantTaskSaved")
data.PlantTasks = []plantTaskForm{form}
app.renderTemplate(w, http.StatusOK, "plant_form.tmpl", "plant_task_row_fragment", data)
}
func (app *application) loadPlantTaskDialogData(w http.ResponseWriter, r *http.Request, gardenID int, data *templateData) bool {
apiClient := client.FromContext(r.Context())
var err error
data.TaskPriorities, _, err = apiClient.TaskPriorities(r.Context())
if err == nil {
data.Locations, _, err = apiClient.Locations(r.Context(), gardenID)
}
if err == nil {
data.Plants, _, err = apiClient.Plants(r.Context(), gardenID)
}
if err != nil {
app.handleAPIError(w, r, err)
return false
}
data.TagSuggestions = app.loadTagSuggestions(r, gardenID)
return true
}
func plantTaskFromValues(values url.Values) plantTaskForm {
id, _ := strconv.Atoi(values.Get("plant_task_id"))
priority, _ := strconv.Atoi(values.Get("plant_task_priority"))
plantID, _ := strconv.Atoi(values.Get("plant_task_plant_id"))
if plantID == 0 {
plantID, _ = strconv.Atoi(values.Get("plant_id"))
}
locationID, _ := strconv.Atoi(values.Get("plant_task_location_id"))
if locationID == 0 {
locationID, _ = strconv.Atoi(values.Get("location_id"))
}
recurrenceInterval, _ := strconv.Atoi(values.Get("plant_task_recurrence_interval"))
if recurrenceInterval < 1 {
recurrenceInterval = 1
}
pendingPlantName := values.Get("pending_plant_name")
if pendingPlantName == "" {
pendingPlantName = values.Get("name")
}
return plantTaskForm{
ID: id, RowKey: values.Get("plant_task_row_key"), Title: values.Get("plant_task_title"),
Description: values.Get("plant_task_description"), DueAtStart: values.Get("plant_task_due_at_start"), PlantID: plantID,
DueAtEnd: values.Get("plant_task_due_at_end"), Priority: priority, LocationID: locationID, Recurrence: values.Get("plant_task_recurrence"), RecurrenceInterval: recurrenceInterval, Keywords: values.Get("plant_task_keywords"), PlantStatusOnCompletion: values.Get("plant_task_status_on_completion"),
PendingPlantName: strings.TrimSpace(pendingPlantName),
Active: values.Get("plant_task_active") != "false", Delete: values.Get("plant_task_delete") == "true",
}
}
func (app *application) plantTemplateTasks(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
speciesID, _ := strconv.Atoi(r.URL.Query().Get("species_id"))
plantID, _ := strconv.Atoi(r.URL.Query().Get("plant_id"))
data := app.newTemplateData(r)
data.Garden, data.SpeciesID, data.PlantID = &client.Garden{ID: gardenID}, speciesID, plantID
data.TaskTemplates, data.TemplateOptOuts = map[int][]client.SpeciesTaskTemplate{}, map[int]bool{}
if speciesID > 0 {
templates, _, loadErr := client.FromContext(r.Context()).SpeciesTaskTemplates(r.Context(), gardenID, speciesID)
if loadErr != nil {
app.handleAPIError(w, r, loadErr)
return
}
data.TaskTemplates[speciesID] = templates
}
if plantID > 0 {
ids, _, loadErr := client.FromContext(r.Context()).TaskTemplateOptOuts(r.Context(), gardenID, plantID)
if loadErr != nil {
app.handleAPIError(w, r, loadErr)
return
}
for _, id := range ids {
data.TemplateOptOuts[id] = true
}
}
app.renderTemplate(w, http.StatusOK, "plant_form.tmpl", "plant_template_tasks", data)
}
func (app *application) plantTemplateTaskView(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
templateID, err := app.readPathID(r, "templateID")
if err != nil {
app.notFound(w)
return
}
speciesID, _ := strconv.Atoi(r.URL.Query().Get("species_id"))
if speciesID < 1 {
app.notFound(w)
return
}
item, _, err := client.FromContext(r.Context()).SpeciesTaskTemplate(r.Context(), gardenID, speciesID, templateID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
data := app.newTemplateData(r)
data.Garden, data.SpeciesID = &client.Garden{ID: gardenID}, speciesID
priorities, _, err := client.FromContext(r.Context()).TaskPriorities(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
}
data.TaskPriorities = priorities
data.TaskTemplates = map[int][]client.SpeciesTaskTemplate{speciesID: {item}}
app.renderTemplate(w, http.StatusOK, "plant_form.tmpl", "plant_template_task_dialog", data)
}
+276
View File
@@ -0,0 +1,276 @@
package web
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"gardomatic.kleiax.de/lib/client"
"github.com/julienschmidt/httprouter"
)
func TestPlantListUsesClickableCardsAndStatusMenu(t *testing.T) {
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/gardens/3":
_, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof","role":"owner"}}`))
case "/v1/gardens/3/plants":
_, _ = w.Write([]byte(`{"plants":[{"id":5,"garden_id":3,"name":"Rose","status":"active"}]}`))
case "/v1/gardens/3/species":
_, _ = w.Write([]byte(`{"species":[]}`))
case "/v1/gardens/3/locations":
_, _ = w.Write([]byte(`{"locations":[]}`))
case "/v1/gardens/3/plants/5/locations":
_, _ = w.Write([]byte(`{"plant_locations":[]}`))
default:
http.NotFound(w, r)
}
})
app := newAPIBackedTestApplication(t, apiHandler)
request := httptest.NewRequest(http.MethodGet, "/g/3/plants", nil)
request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}})
response := httptest.NewRecorder()
app.plants(response, request)
body := response.Body.String()
for _, want := range []string{"card--clickable plant-card", "class='card-link' href='/g/3/plants/edit/5'", "plant-card-menu", "/g/3/plants/status/5"} {
if !strings.Contains(body, want) {
t.Errorf("plant list does not contain %q: %s", want, body)
}
}
for _, unwanted := range []string{"Weiteren Ort zuordnen", ">Bearbeiten</a>", ">Löschen</button>"} {
if strings.Contains(body, unwanted) {
t.Errorf("plant list still contains %q: %s", unwanted, body)
}
}
}
func TestPlantEditRendersAllAssignmentsAndDeleteAction(t *testing.T) {
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/gardens/3":
_, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof","role":"owner"}}`))
case "/v1/gardens/3/plants/5":
_, _ = w.Write([]byte(`{"plant":{"id":5,"garden_id":3,"name":"Rose","status":"active"}}`))
case "/v1/gardens/3/plants/5/locations":
_, _ = w.Write([]byte(`{"plant_locations":[{"id":9,"plant_id":5,"location_id":6,"quantity":2},{"id":10,"plant_id":5,"location_id":7,"quantity":4}]}`))
case "/v1/gardens/3/species":
_, _ = w.Write([]byte(`{"species":[]}`))
case "/v1/gardens/3/locations":
_, _ = w.Write([]byte(`{"locations":[{"id":6,"name":"Südbeet"},{"id":7,"name":"Topf"}]}`))
case "/v1/task-priorities":
_, _ = w.Write([]byte(`{"priorities":[{"id":1,"name":"Normal","value":0,"active":true},{"id":2,"name":"Erhöht","value":3,"active":true}]}`))
default:
http.NotFound(w, r)
}
})
app := newAPIBackedTestApplication(t, apiHandler)
request := httptest.NewRequest(http.MethodGet, "/g/3/plants/edit/5", nil)
request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}, {Key: "plantID", Value: "5"}})
response := httptest.NewRecorder()
app.plantEdit(response, request)
body := response.Body.String()
if strings.Count(body, "data-assignment-row") != 3 { // two rows plus the template row
t.Fatalf("expected both assignments and the add-row template: %s", body)
}
for _, want := range []string{"value='9'", "value='10'", "data-add-assignment", "Pflanze löschen", "/g/3/plants/delete/5"} {
if !strings.Contains(body, want) {
t.Errorf("plant form does not contain %q: %s", want, body)
}
}
}
func TestPlantSaveUpdatesCreatesAndDeletesAssignments(t *testing.T) {
var updated, created, deleted bool
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/3/plants/5":
_, _ = w.Write([]byte(`{"plant":{"id":5,"garden_id":3,"name":"Rose"}}`))
case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/3/plants/5/locations/9":
updated = true
_, _ = w.Write([]byte(`{"plant_location":{"id":9}}`))
case r.Method == http.MethodPost && r.URL.Path == "/v1/gardens/3/plants/5/locations":
created = true
_, _ = w.Write([]byte(`{"plant_location":{"id":11}}`))
case r.Method == http.MethodDelete && r.URL.Path == "/v1/gardens/3/plants/5/locations/10":
deleted = true
w.WriteHeader(http.StatusNoContent)
default:
http.NotFound(w, r)
}
})
app := newAPIBackedTestApplication(t, apiHandler)
form := url.Values{
"name": {"Rose"},
"species_id": {"0"},
"status": {"active"},
"assignment_id": {"9", "0", "10"},
"location_id": {"6", "7", "0"},
"quantity": {"2", "3", "1"},
}
request := httptest.NewRequest(http.MethodPost, "/g/3/plants/edit/5", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}, {Key: "plantID", Value: "5"}})
response := httptest.NewRecorder()
app.plantEditPost(response, request)
if response.Code != http.StatusSeeOther || !updated || !created || !deleted {
t.Fatalf("status=%d updated=%v created=%v deleted=%v body=%s", response.Code, updated, created, deleted, response.Body.String())
}
}
func TestPlantStatusPostOnlyUpdatesStatus(t *testing.T) {
var input client.PlantInput
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &input); err != nil {
t.Fatal(err)
}
_, _ = w.Write([]byte(`{"plant":{"id":5,"status":"dormant"}}`))
})
app := newAPIBackedTestApplication(t, apiHandler)
request := httptest.NewRequest(http.MethodPost, "/g/3/plants/status/5", strings.NewReader("status=dormant"))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}, {Key: "plantID", Value: "5"}})
response := httptest.NewRecorder()
app.plantStatusPost(response, request)
if response.Code != http.StatusSeeOther || input.Status == nil || *input.Status != "dormant" || input.Name != nil {
t.Fatalf("status=%d input=%+v body=%s", response.Code, input, response.Body.String())
}
}
func TestPlantTaskFragmentsAndAutomaticNameHooks(t *testing.T) {
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/gardens/3":
_, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof","role":"owner"}}`))
case "/v1/gardens/3/species":
_, _ = w.Write([]byte(`{"species":[{"id":7,"common_name":"Tomate","cultivar":"Roma"}]}`))
case "/v1/gardens/3/locations":
_, _ = w.Write([]byte(`{"locations":[{"id":11,"garden_id":3,"name":"Gewächshaus"}]}`))
case "/v1/gardens/3/plants":
_, _ = w.Write([]byte(`{"plants":[]}`))
case "/v1/gardens/3/tags":
_, _ = w.Write([]byte(`{"tags":["Frühjahr"]}`))
case "/v1/task-priorities":
_, _ = w.Write([]byte(`{"priorities":[{"id":1,"name":"Normal","value":0,"active":true},{"id":2,"name":"Erhöht","value":3,"active":true}]}`))
case "/v1/gardens/3/species/7/task-templates":
_, _ = w.Write([]byte(`{"task_templates":[{"id":9,"species_id":7,"title":"Ausgeizen","active":true}]}`))
case "/v1/gardens/3/species/7/task-templates/9":
_, _ = w.Write([]byte(`{"task_template":{"id":9,"species_id":7,"title":"Ausgeizen","description":"Seitentriebe entfernen","trigger_type":"month_of_year","month_from":5,"month_to":8,"active":true}}`))
default:
http.NotFound(w, r)
}
})
app := newAPIBackedTestApplication(t, apiHandler)
params := httprouter.Params{{Key: "gardenID", Value: "3"}}
request := taskWebRequest(httptest.NewRequest(http.MethodGet, "/g/3/plants/new", nil), app.apiClient, params)
response := httptest.NewRecorder()
app.plantCreate(response, request)
body := response.Body.String()
for _, want := range []string{"data-plant-name", "data-plant-species", "data-plant-name-value='Tomate · Roma'", "/g/3/plant-template-tasks", "/g/3/plant-task-row"} {
if !strings.Contains(body, want) {
t.Errorf("plant form does not contain %q: %s", want, body)
}
}
fragmentRequest := taskWebRequest(httptest.NewRequest(http.MethodGet, "/g/3/plant-template-tasks?species_id=7", nil), app.apiClient, params)
fragmentResponse := httptest.NewRecorder()
app.plantTemplateTasks(fragmentResponse, fragmentRequest)
if fragmentResponse.Code != http.StatusOK || !strings.Contains(fragmentResponse.Body.String(), "Ausgeizen") || !strings.Contains(fragmentResponse.Body.String(), "plant_template_active_9") {
t.Fatalf("template fragment: status=%d body=%s", fragmentResponse.Code, fragmentResponse.Body.String())
}
rowRequest := taskWebRequest(httptest.NewRequest(http.MethodGet, "/g/3/plant-task-row?new=true&name=Jungpflanze&location_id=11", nil), app.apiClient, params)
rowResponse := httptest.NewRecorder()
app.plantTaskRow(rowResponse, rowRequest)
rowBody := rowResponse.Body.String()
for _, want := range []string{
"plant_task_title", "plant_task_description", "plant_task_keywords", "plant_task_plant_id",
"plant_task_location_id", "plant_task_due_at_start", "plant_task_due_at_end",
"plant_task_recurrence_interval", "plant_task_recurrence", "plant_task_priority",
"plant_task_status_on_completion", "Jungpflanze · wird beim Speichern angelegt",
"value='11' selected", "data-tag-suggestion='Frühjahr'",
} {
if !strings.Contains(rowBody, want) {
t.Errorf("plant task dialog does not contain %q: %s", want, rowBody)
}
}
if rowResponse.Code != http.StatusOK || !strings.Contains(rowBody, "plant_task_active") {
t.Fatalf("task row fragment: status=%d body=%s", rowResponse.Code, rowResponse.Body.String())
}
viewRequest := taskWebRequest(httptest.NewRequest(http.MethodGet, "/g/3/plant-template-task/9?species_id=7", nil), app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}, {Key: "templateID", Value: "9"}})
viewResponse := httptest.NewRecorder()
app.plantTemplateTaskView(viewResponse, viewRequest)
if viewResponse.Code != http.StatusOK || !strings.Contains(viewResponse.Body.String(), "<dialog") || !strings.Contains(viewResponse.Body.String(), "Seitentriebe entfernen") || strings.Contains(viewResponse.Body.String(), "<form") {
t.Fatalf("template detail fragment: status=%d body=%s", viewResponse.Code, viewResponse.Body.String())
}
}
func TestPlantTaskDialogReturnsCompactEditableRow(t *testing.T) {
app := newAPIBackedTestApplication(t, http.NotFoundHandler())
form := url.Values{"new": {"true"}, "plant_task_row_key": {"task-new-1"}, "plant_task_id": {"0"}, "plant_task_title": {"Anbinden"}, "plant_task_description": {"Locker befestigen"}, "plant_task_priority": {"3"}, "plant_task_active": {"true"}}
request := httptest.NewRequest(http.MethodPost, "/g/3/plant-task-row", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("HX-Request", "true")
request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}})
response := httptest.NewRecorder()
app.plantTaskRowPost(response, request)
body := response.Body.String()
if response.Code != http.StatusOK || response.Header().Get("HX-Trigger") != "plantTaskSaved" || !strings.Contains(body, ">Anbinden</button>") || !strings.Contains(body, "class='switch'") || strings.Contains(body, "<textarea") {
t.Fatalf("task row fragment: status=%d trigger=%q body=%s", response.Code, response.Header().Get("HX-Trigger"), body)
}
}
func TestPlantCreateSavesManualTaskAndTemplateState(t *testing.T) {
var taskInput client.TaskInput
var optedOut bool
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodPost && r.URL.Path == "/v1/gardens/3/plants":
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"plant":{"id":5,"garden_id":3,"name":"Tomate"}}`))
case r.Method == http.MethodPost && r.URL.Path == "/v1/gardens/3/tasks":
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &taskInput); err != nil {
t.Fatal(err)
}
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"task":{"id":12,"garden_id":3,"title":"Anbinden"}}`))
case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/3/species/7/task-templates":
_, _ = w.Write([]byte(`{"task_templates":[{"id":9,"species_id":7,"title":"Ausgeizen"}]}`))
case r.Method == http.MethodPost && r.URL.Path == "/v1/gardens/3/plants/5/task-template-opt-outs/9":
optedOut = true
w.WriteHeader(http.StatusNoContent)
default:
http.NotFound(w, r)
}
})
app := newAPIBackedTestApplication(t, apiHandler)
form := url.Values{
"name": {"Tomate"}, "species_id": {"7"}, "status": {"active"},
"plant_task_id": {"0"}, "plant_task_title": {"Anbinden"}, "plant_task_description": {"Locker befestigen"},
"plant_task_due_at_start": {""}, "plant_task_due_at_end": {""}, "plant_task_priority": {"3"}, "plant_task_active": {"true"}, "plant_task_delete": {"false"},
"plant_template_active_9": {"false"},
}
request := httptest.NewRequest(http.MethodPost, "/g/3/plants/new", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}})
response := httptest.NewRecorder()
app.plantCreatePost(response, request)
if response.Code != http.StatusSeeOther || taskInput.Title == nil || *taskInput.Title != "Anbinden" || taskInput.PlantID == nil || *taskInput.PlantID != 5 || taskInput.Active == nil || !*taskInput.Active || !optedOut {
t.Fatalf("status=%d task=%+v optedOut=%v body=%s", response.Code, taskInput, optedOut, response.Body.String())
}
}
+525
View File
@@ -0,0 +1,525 @@
package web
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"gardomatic.kleiax.de/lib/client"
)
type plantForm struct {
CSRFToken string `form:"csrf_token"`
Name string `form:"name"`
SpeciesID int `form:"species_id"`
LocationIDs []int `form:"location_id"`
Quantities []int `form:"quantity"`
AssignmentIDs []int `form:"assignment_id"`
Assignments []plantAssignmentForm `form:"-"`
TaskIDs []int `form:"plant_task_id"`
TaskRowKeys []string `form:"plant_task_row_key"`
TaskTitles []string `form:"plant_task_title"`
TaskDescriptions []string `form:"plant_task_description"`
TaskDueAtStarts []string `form:"plant_task_due_at_start"`
TaskDueAtEnds []string `form:"plant_task_due_at_end"`
TaskPriorities []int `form:"plant_task_priority"`
TaskPlantIDs []int `form:"plant_task_plant_id"`
TaskLocationIDs []int `form:"plant_task_location_id"`
TaskRecurrences []string `form:"plant_task_recurrence"`
TaskRecurrenceIntervals []int `form:"plant_task_recurrence_interval"`
TaskKeywords []string `form:"plant_task_keywords"`
TaskStatuses []string `form:"plant_task_status_on_completion"`
TaskActives []bool `form:"plant_task_active"`
TaskDeletes []bool `form:"plant_task_delete"`
Tasks []plantTaskForm `form:"-"`
Notes string `form:"notes"`
ImageData string `form:"image_data"`
ImageID int `form:"image_id"`
AcquiredAt string `form:"acquired_at"`
Status string `form:"status"`
Keywords string `form:"keywords"`
Errors map[string]string
}
type plantTaskForm struct {
ID, Priority, PlantID, LocationID, RecurrenceInterval int
RowKey string
Title, Description, Recurrence, Keywords, PlantStatusOnCompletion string
PendingPlantName string
DueAtStart, DueAtEnd string
Active, Delete bool
Errors map[string]string
}
type plantAssignmentForm struct {
AssignmentID int
LocationID int
Quantity int
}
func (form *plantForm) buildAssignments() {
form.Assignments = make([]plantAssignmentForm, len(form.LocationIDs))
for i, locationID := range form.LocationIDs {
form.Assignments[i].LocationID = locationID
if i < len(form.Quantities) {
form.Assignments[i].Quantity = form.Quantities[i]
}
if i < len(form.AssignmentIDs) {
form.Assignments[i].AssignmentID = form.AssignmentIDs[i]
}
}
}
func (form *plantForm) buildTasks() {
form.Tasks = make([]plantTaskForm, len(form.TaskTitles))
for i := range form.TaskTitles {
form.Tasks[i].Title = strings.TrimSpace(form.TaskTitles[i])
if i < len(form.TaskIDs) {
form.Tasks[i].ID = form.TaskIDs[i]
}
if i < len(form.TaskRowKeys) {
form.Tasks[i].RowKey = form.TaskRowKeys[i]
}
if i < len(form.TaskDescriptions) {
form.Tasks[i].Description = strings.TrimSpace(form.TaskDescriptions[i])
}
if i < len(form.TaskDueAtStarts) {
form.Tasks[i].DueAtStart = form.TaskDueAtStarts[i]
}
if i < len(form.TaskDueAtEnds) {
form.Tasks[i].DueAtEnd = form.TaskDueAtEnds[i]
}
if i < len(form.TaskPriorities) {
form.Tasks[i].Priority = form.TaskPriorities[i]
}
if i < len(form.TaskPlantIDs) {
form.Tasks[i].PlantID = form.TaskPlantIDs[i]
}
if i < len(form.TaskLocationIDs) {
form.Tasks[i].LocationID = form.TaskLocationIDs[i]
}
if i < len(form.TaskRecurrences) {
form.Tasks[i].Recurrence = form.TaskRecurrences[i]
}
if i < len(form.TaskRecurrenceIntervals) {
form.Tasks[i].RecurrenceInterval = form.TaskRecurrenceIntervals[i]
}
if i < len(form.TaskKeywords) {
form.Tasks[i].Keywords = form.TaskKeywords[i]
}
if i < len(form.TaskStatuses) {
form.Tasks[i].PlantStatusOnCompletion = form.TaskStatuses[i]
}
if i < len(form.TaskActives) {
form.Tasks[i].Active = form.TaskActives[i]
}
if i < len(form.TaskDeletes) {
form.Tasks[i].Delete = form.TaskDeletes[i]
}
if form.Tasks[i].RowKey == "" {
form.Tasks[i].RowKey = "task-" + strconv.Itoa(form.Tasks[i].ID) + "-" + strconv.Itoa(i)
}
}
}
func (app *application) plants(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
apiClient := client.FromContext(r.Context())
garden, _, err := apiClient.Garden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
plants, _, err := apiClient.Plants(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
species, _, err := apiClient.SpeciesForGarden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
data := app.newTemplateData(r)
data.Garden = &garden
data.Plants = plants
data.Species = species
locations, _, err := apiClient.Locations(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
data.Locations = locations
data.LocationNames = make(map[int]string, len(locations))
for _, location := range locations {
data.LocationNames[location.ID] = location.Name
}
data.PlantLocations = make(map[int][]client.PlantLocation, len(plants))
for _, plant := range plants {
assignments, _, assignmentErr := apiClient.PlantLocations(r.Context(), gardenID, plant.ID)
if assignmentErr != nil {
app.handleAPIError(w, r, assignmentErr)
return
}
data.PlantLocations[plant.ID] = assignments
}
data.Filters = collectionFilters(r, "q", "status", "species", "location", "sort")
data.Plants = filterAndSortPlants(plants, data.PlantLocations, data.Filters)
data.Plants, data.Pagination = paginateCollection(r, data.Plants)
app.render(w, http.StatusOK, "plant.tmpl", data)
}
func (app *application) plantCreate(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
locationID, _ := strconv.Atoi(r.URL.Query().Get("location_id"))
app.renderPlantForm(w, r, gardenID, plantForm{Status: "alive", Assignments: []plantAssignmentForm{{LocationID: locationID, Quantity: 1}}, Errors: make(map[string]string)}, http.StatusOK)
}
func (app *application) plantCreatePost(w http.ResponseWriter, r *http.Request) {
app.plantSave(w, r)
}
func (app *application) plantEdit(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
plantID, err := app.readPathID(r, "plantID")
if err != nil {
app.notFound(w)
return
}
apiClient := client.FromContext(r.Context())
plant, _, err := apiClient.Plant(r.Context(), gardenID, plantID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
form := plantForm{Name: plant.Name, Notes: plant.Notes, ImageData: plant.ImageData, Status: plant.Status, Keywords: strings.Join(plant.Tags, ", "), Errors: map[string]string{}}
if plant.ImageID != nil {
form.ImageID = *plant.ImageID
}
if plant.SpeciesID != nil {
form.SpeciesID = *plant.SpeciesID
}
if plant.AcquiredAt != nil {
form.AcquiredAt = plant.AcquiredAt.Format("2006-01-02")
}
assignments, _, err := apiClient.PlantLocations(r.Context(), gardenID, plantID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
for _, assignment := range assignments {
form.Assignments = append(form.Assignments, plantAssignmentForm{AssignmentID: assignment.ID, LocationID: assignment.LocationID, Quantity: assignment.Quantity})
}
if len(form.Assignments) == 0 {
form.Assignments = []plantAssignmentForm{{Quantity: 1}}
}
app.renderPlantForm(w, r, gardenID, form, http.StatusOK, plantID)
}
func (app *application) plantEditPost(w http.ResponseWriter, r *http.Request) { app.plantSave(w, r) }
func (app *application) plantSave(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
plantID := 0
if id, readErr := app.readPathID(r, "plantID"); readErr == nil {
plantID = id
}
var form plantForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.buildAssignments()
form.buildTasks()
form.Name = strings.TrimSpace(form.Name)
form.Notes = strings.TrimSpace(form.Notes)
form.Errors = make(map[string]string)
if form.Name == "" {
form.Errors["name"] = "Ein Name ist erforderlich."
}
if form.Status == "" {
form.Status = "alive"
}
for i, assignment := range form.Assignments {
if assignment.LocationID > 0 && assignment.Quantity < 1 {
form.Errors["quantity_"+strconv.Itoa(i)] = "Die Anzahl muss mindestens 1 sein."
}
}
for i, task := range form.Tasks {
if task.Delete {
continue
}
if task.Title == "" {
form.Errors["plant_task_title_"+strconv.Itoa(i)] = "Ein Titel ist erforderlich."
}
start, _ := parseTaskTime(task.DueAtStart, "plant_task_due_at_start_"+strconv.Itoa(i), form.Errors)
end, _ := parseTaskTime(task.DueAtEnd, "plant_task_due_at_end_"+strconv.Itoa(i), form.Errors)
if start != nil && end != nil && start.After(*end) {
form.Errors["plant_task_due_at_end_"+strconv.Itoa(i)] = "Das Ende darf nicht vor dem Beginn liegen."
}
}
var acquiredAt *time.Time
if form.AcquiredAt != "" {
parsed, parseErr := time.Parse("2006-01-02", form.AcquiredAt)
if parseErr != nil {
form.Errors["acquired_at"] = "Das Datum ist ungültig."
} else {
acquiredAt = &parsed
}
}
if len(form.Errors) == 0 {
name, notes, status := form.Name, form.Notes, form.Status
imageData := form.ImageData
input := client.PlantInput{Name: &name, Notes: &notes, ImageData: &imageData, Status: &status, AcquiredAt: acquiredAt, ClearAcquiredAt: acquiredAt == nil, Tags: parseKeywords(form.Keywords)}
if form.ImageID > 0 {
imageID := form.ImageID
input.ImageID = &imageID
}
if form.SpeciesID > 0 {
speciesID := form.SpeciesID
input.SpeciesID = &speciesID
} else {
input.ClearSpeciesID = true
}
apiClient := client.FromContext(r.Context())
var plant client.Plant
if plantID > 0 {
plant, _, err = apiClient.UpdatePlant(r.Context(), gardenID, plantID, input)
} else {
plant, _, err = apiClient.CreatePlant(r.Context(), gardenID, input)
}
if err == nil {
for _, assignment := range form.Assignments {
if assignment.LocationID == 0 {
if assignment.AssignmentID > 0 {
_, err = apiClient.DeletePlantLocation(r.Context(), gardenID, plant.ID, assignment.AssignmentID)
}
} else {
locationID, quantity := assignment.LocationID, assignment.Quantity
assignmentInput := client.PlantLocationInput{LocationID: &locationID, Quantity: &quantity, PlantedAt: acquiredAt, ClearPlantedAt: acquiredAt == nil}
if assignment.AssignmentID > 0 {
_, _, err = apiClient.UpdatePlantLocation(r.Context(), gardenID, plant.ID, assignment.AssignmentID, assignmentInput)
} else {
_, _, err = apiClient.CreatePlantLocation(r.Context(), gardenID, plant.ID, assignmentInput)
}
}
if err != nil {
break
}
}
if err == nil {
err = app.savePlantTasks(r, gardenID, plant.ID, form.Tasks)
}
if err == nil && form.SpeciesID > 0 {
err = app.savePlantTemplateStates(r, gardenID, plant.ID, form.SpeciesID)
}
if err != nil && plantID == 0 {
_, _ = apiClient.DeletePlant(r.Context(), gardenID, plant.ID)
}
}
if err == nil {
http.Redirect(w, r, webPath("plants", gardenID), http.StatusSeeOther)
return
}
var apiError *client.APIError
if errors.As(err, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity {
form.Errors = apiError.Validation
} else {
app.handleAPIError(w, r, err)
return
}
}
app.renderPlantForm(w, r, gardenID, form, http.StatusUnprocessableEntity, plantID)
}
func (app *application) plantStatusPost(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
plantID, err := app.readPathID(r, "plantID")
if err != nil {
app.notFound(w)
return
}
if err = r.ParseForm(); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
status := r.PostForm.Get("status")
if status != "alive" && status != "dead" && status != "removed" && status != "infested" && status != "harvested" && status != "dormant" {
app.clientError(w, http.StatusUnprocessableEntity)
return
}
if _, _, err = client.FromContext(r.Context()).UpdatePlant(r.Context(), gardenID, plantID, client.PlantInput{Status: &status}); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("plants", gardenID), http.StatusSeeOther)
}
func (app *application) plantDelete(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
plantID, err := app.readPathID(r, "plantID")
if err != nil {
app.notFound(w)
return
}
if _, err := client.FromContext(r.Context()).DeletePlant(r.Context(), gardenID, plantID); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("plants", gardenID), http.StatusSeeOther)
}
func (app *application) plantTemplateOptOutPost(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
plantID, err := app.readPathID(r, "plantID")
if err != nil {
app.notFound(w)
return
}
templateID, err := app.readPathID(r, "templateID")
if err != nil {
app.notFound(w)
return
}
if err = r.ParseForm(); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
optedOut := r.PostForm.Get("opted_out") != "false"
if _, err = client.FromContext(r.Context()).SetTaskTemplateOptOut(r.Context(), gardenID, plantID, templateID, optedOut); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("plant.edit", gardenID, plantID), http.StatusSeeOther)
}
func (app *application) renderPlantForm(w http.ResponseWriter, r *http.Request, gardenID int, form plantForm, status int, plantIDs ...int) {
apiClient := client.FromContext(r.Context())
garden, _, err := apiClient.Garden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
species, _, err := apiClient.SpeciesForGarden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
data := app.newTemplateData(r)
data.Garden = &garden
data.Species = species
priorities, _, err := apiClient.TaskPriorities(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
}
data.TaskPriorities = priorities
locations, _, err := apiClient.Locations(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
data.Locations = locations
images, err := app.loadImageLibrary(r, gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
data.Images = images
data.Form = form
data.SpeciesID = form.SpeciesID
data.TaskTemplates = map[int][]client.SpeciesTaskTemplate{}
data.TemplateOptOuts = map[int]bool{}
if len(plantIDs) > 0 {
data.PlantID = plantIDs[0]
plant, _, plantErr := apiClient.Plant(r.Context(), gardenID, data.PlantID)
if plantErr != nil {
app.handleAPIError(w, r, plantErr)
return
}
data.PlantCreatedBy = plant.CreatedBy
if form.Tasks == nil {
tasks, _, taskErr := apiClient.Tasks(r.Context(), gardenID)
var apiError *client.APIError
if taskErr != nil && !(errors.As(taskErr, &apiError) && apiError.StatusCode == http.StatusNotFound) {
app.handleAPIError(w, r, taskErr)
return
}
for _, task := range tasks {
if task.PlantID == nil || *task.PlantID != data.PlantID || task.TemplateID != nil {
continue
}
active := task.Active == nil || *task.Active
item := plantTaskForm{ID: task.ID, PlantID: data.PlantID, RowKey: "task-" + strconv.Itoa(task.ID), Title: task.Title, Description: task.Description, Priority: task.Priority, Active: active, Recurrence: task.Recurrence, RecurrenceInterval: task.RecurrenceInterval, Keywords: strings.Join(task.Tags, ", ")}
if task.LocationID != nil {
item.LocationID = *task.LocationID
}
if task.PlantStatusOnCompletion != nil {
item.PlantStatusOnCompletion = *task.PlantStatusOnCompletion
}
if task.DueAtStart != nil {
item.DueAtStart = task.DueAtStart.Local().Format("2006-01-02")
}
if task.DueAtEnd != nil {
item.DueAtEnd = task.DueAtEnd.Local().Format("2006-01-02")
}
form.Tasks = append(form.Tasks, item)
}
data.Form = form
}
if form.SpeciesID > 0 {
ids, _, optErr := apiClient.TaskTemplateOptOuts(r.Context(), gardenID, data.PlantID)
if optErr != nil {
app.handleAPIError(w, r, optErr)
return
}
for _, id := range ids {
data.TemplateOptOuts[id] = true
}
}
}
if form.SpeciesID > 0 {
templates, _, templateErr := apiClient.SpeciesTaskTemplates(r.Context(), gardenID, form.SpeciesID)
if templateErr != nil {
app.handleAPIError(w, r, templateErr)
return
}
data.TaskTemplates[form.SpeciesID] = templates
for _, item := range templates {
if values, present := r.PostForm["plant_template_active_"+strconv.Itoa(item.ID)]; present {
data.TemplateOptOuts[item.ID] = len(values) == 0 || values[len(values)-1] != "true"
}
}
}
app.render(w, status, "plant_form.tmpl", data)
}
+62
View File
@@ -0,0 +1,62 @@
package web
import (
"strconv"
"gardomatic.kleiax.de/lib/client"
)
type roleEditorRole struct {
Name string
Label string
Permissions []string
Editable bool
EditLabel bool
Deletable bool
}
type roleEditorData struct {
ID string
Title string
Description string
Scope string
Roles []roleEditorRole
Permissions []permissionOption
CreateAction string
UpdateAction string
DeleteAction string
CSRFToken string
Garden *client.Garden
}
func globalRoleEditor(id, title, description, scope string, roles []client.Role, permissions []permissionOption, csrfToken string) roleEditorData {
result := roleEditorData{ID: id, Title: title, Description: description, Scope: scope, Permissions: permissions, CreateAction: webPath("admin.role.new"), UpdateAction: webPath("admin.role.update"), DeleteAction: webPath("admin.role.delete"), CSRFToken: csrfToken}
for _, role := range roles {
effective := []string{}
for _, permission := range permissions {
if clientRoleHasPermission(role, permission.Name) {
effective = append(effective, permission.Name)
}
}
result.Roles = append(result.Roles, roleEditorRole{Name: role.Name, Label: role.Label, Permissions: effective, Editable: role.Name != "owner", EditLabel: true, Deletable: !role.System})
}
return result
}
func gardenOverrideRoleEditor(gardenID int, roles []client.GardenRoleSetting, permissions []permissionOption, csrfToken string) roleEditorData {
id := strconv.Itoa(gardenID)
result := roleEditorData{ID: "garden-role-editor", Title: "Gartenspezifische Rollenrechte", Description: "Diese Einstellungen überschreiben die globalen Gartenrollen nur für diesen Garten.", Scope: "garden", Permissions: permissions, CreateAction: webPath("garden.role.new", id), UpdateAction: webPath("garden.role.update", id), DeleteAction: webPath("garden.role.delete", id), CSRFToken: csrfToken}
for _, setting := range roles {
result.Roles = append(result.Roles, roleEditorRole{Name: setting.Role.Name, Label: setting.Role.Label, Permissions: setting.EffectivePermissions, Editable: setting.Role.Name != "owner", Deletable: setting.Role.GardenID != nil})
}
return result
}
func clientRoleHasPermission(role client.Role, permission string) bool {
for _, granted := range role.Permissions {
if granted == permission || granted == "*" || granted == "garden:*" && permission != "garden:delete" {
return true
}
}
return false
}
+139
View File
@@ -0,0 +1,139 @@
package web
import (
"net/http"
"github.com/julienschmidt/httprouter"
"github.com/justinas/alice"
)
func (app *application) routes() http.Handler {
dynamicRouter := httprouter.New()
dynamicRouter.NotFound = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
app.notFound(w)
})
dynamicRouter.HandlerFunc(http.MethodGet, routePath("home"), app.home)
dynamicRouter.HandlerFunc(http.MethodGet, routePath("healthcheck"), app.healthcheck)
dynamicRouter.HandlerFunc(http.MethodGet, routePath("privacy"), app.privacy)
dynamicRouter.HandlerFunc(http.MethodGet, routePath("login"), app.signIn)
dynamicRouter.HandlerFunc(http.MethodPost, routePath("login"), app.signInPost)
dynamicRouter.HandlerFunc(http.MethodGet, routePath("activate"), app.activateUser)
dynamicRouter.HandlerFunc(http.MethodPost, routePath("activate"), app.activateUserPost)
dynamicRouter.Handler(http.MethodPost, routePath("logout"), app.requireAuthentication(http.HandlerFunc(app.signOutPost)))
dynamicRouter.Handler(http.MethodGet, routePath("account"), app.requireActivatedUser(http.HandlerFunc(app.account)))
dynamicRouter.Handler(http.MethodPost, routePath("account.profile"), app.requireActivatedUser(http.HandlerFunc(app.accountProfilePost)))
dynamicRouter.Handler(http.MethodPost, routePath("account.password"), app.requireActivatedUser(http.HandlerFunc(app.accountPasswordPost)))
dynamicRouter.Handler(http.MethodPost, routePath("account.email"), app.requireActivatedUser(http.HandlerFunc(app.accountEmailPost)))
dynamicRouter.Handler(http.MethodGet, routePath("account.email-confirm"), app.requireActivatedUser(http.HandlerFunc(app.accountEmailConfirm)))
dynamicRouter.Handler(http.MethodPost, routePath("account.email-confirm"), app.requireActivatedUser(http.HandlerFunc(app.accountEmailConfirmPost)))
dynamicRouter.Handler(http.MethodPost, routePath("account.session.delete"), app.requireActivatedUser(http.HandlerFunc(app.accountSessionDeletePost)))
dynamicRouter.Handler(http.MethodGet, routePath("settings"), app.requireActivatedUser(http.HandlerFunc(app.settings)))
dynamicRouter.Handler(http.MethodGet, routePath("admin"), app.requireAdmin(http.HandlerFunc(app.admin)))
dynamicRouter.Handler(http.MethodPost, routePath("admin.user.invite"), app.requireAdmin(http.HandlerFunc(app.adminUserInvitePost)))
dynamicRouter.Handler(http.MethodPost, routePath("admin.user.role"), app.requireAdmin(http.HandlerFunc(app.adminUserRolePost)))
dynamicRouter.Handler(http.MethodPost, routePath("admin.user.delete"), app.requireAdmin(http.HandlerFunc(app.adminUserDeletePost)))
dynamicRouter.Handler(http.MethodPost, routePath("admin.role.new"), app.requireAdmin(http.HandlerFunc(app.adminRoleCreatePost)))
dynamicRouter.Handler(http.MethodPost, routePath("admin.role.update"), app.requireAdmin(http.HandlerFunc(app.adminRoleUpdatePost)))
dynamicRouter.Handler(http.MethodPost, routePath("admin.role.delete"), app.requireAdmin(http.HandlerFunc(app.adminRoleDeletePost)))
dynamicRouter.Handler(http.MethodPost, routePath("admin.application-settings"), app.requireAdmin(http.HandlerFunc(app.adminApplicationSettingsPost)))
dynamicRouter.Handler(http.MethodPost, routePath("admin.test-mail"), app.requireAdmin(http.HandlerFunc(app.adminTestMailPost)))
dynamicRouter.Handler(http.MethodPost, routePath("admin.species-category.new"), app.requireAdmin(http.HandlerFunc(app.adminSpeciesCategoryCreatePost)))
dynamicRouter.Handler(http.MethodPost, routePath("admin.species-category.edit"), app.requireAdmin(http.HandlerFunc(app.adminSpeciesCategoryUpdatePost)))
dynamicRouter.Handler(http.MethodPost, routePath("admin.species-category.delete"), app.requireAdmin(http.HandlerFunc(app.adminSpeciesCategoryDeletePost)))
dynamicRouter.Handler(http.MethodPost, routePath("admin.task-priority.new"), app.requireAdmin(http.HandlerFunc(app.adminTaskPriorityCreatePost)))
dynamicRouter.Handler(http.MethodPost, routePath("admin.task-priority.edit"), app.requireAdmin(http.HandlerFunc(app.adminTaskPriorityUpdatePost)))
dynamicRouter.Handler(http.MethodPost, routePath("admin.task-priority.delete"), app.requireAdmin(http.HandlerFunc(app.adminTaskPriorityDeletePost)))
dynamicRouter.Handler(http.MethodGet, routePath("gardens"), app.requireActivatedUser(http.HandlerFunc(app.gardens)))
dynamicRouter.Handler(http.MethodGet, routePath("garden.new"), app.requireActivatedUser(app.requireApplicationPermission("gardens:create", http.HandlerFunc(app.gardenCreate))))
dynamicRouter.Handler(http.MethodPost, routePath("garden.new"), app.requireActivatedUser(app.requireApplicationPermission("gardens:create", http.HandlerFunc(app.gardenCreatePost))))
dynamicRouter.Handler(http.MethodGet, routePath("garden.edit"), app.requireActivatedUser(http.HandlerFunc(app.gardenEdit)))
dynamicRouter.Handler(http.MethodPost, routePath("garden.edit"), app.requireActivatedUser(http.HandlerFunc(app.gardenEditPost)))
dynamicRouter.Handler(http.MethodPost, routePath("garden.delete"), app.requireActivatedUser(http.HandlerFunc(app.gardenDeletePost)))
dynamicRouter.Handler(http.MethodGet, routePath("garden.dashboard"), app.requireActivatedUser(http.HandlerFunc(app.gardenDashboard)))
dynamicRouter.Handler(http.MethodGet, routePath("garden.search"), app.requireActivatedUser(http.HandlerFunc(app.gardenSearch)))
dynamicRouter.Handler(http.MethodGet, routePath("garden.journal"), app.requireActivatedUser(http.HandlerFunc(app.journal)))
dynamicRouter.Handler(http.MethodGet, routePath("garden.pinboard"), app.requireActivatedUser(http.HandlerFunc(app.pinboard)))
dynamicRouter.Handler(http.MethodGet, routePath("garden.images"), app.requireActivatedUser(http.HandlerFunc(app.images)))
dynamicRouter.Handler(http.MethodGet, routePath("garden.image.data"), app.requireActivatedUser(http.HandlerFunc(app.imageData)))
dynamicRouter.Handler(http.MethodGet, routePath("journal.new"), app.requireActivatedUser(http.HandlerFunc(app.journalCreate)))
dynamicRouter.Handler(http.MethodPost, routePath("journal.new"), app.requireActivatedUser(http.HandlerFunc(app.journalSave)))
dynamicRouter.Handler(http.MethodGet, routePath("journal.edit"), app.requireActivatedUser(http.HandlerFunc(app.journalEdit)))
dynamicRouter.Handler(http.MethodPost, routePath("journal.edit"), app.requireActivatedUser(http.HandlerFunc(app.journalSave)))
dynamicRouter.Handler(http.MethodPost, routePath("journal.delete"), app.requireActivatedUser(http.HandlerFunc(app.journalDelete)))
dynamicRouter.Handler(http.MethodGet, routePath("journal.attachment"), app.requireActivatedUser(http.HandlerFunc(app.journalAttachment)))
dynamicRouter.Handler(http.MethodGet, routePath("pinboard.new"), app.requireActivatedUser(http.HandlerFunc(app.pinboardCreate)))
dynamicRouter.Handler(http.MethodPost, routePath("pinboard.new"), app.requireActivatedUser(http.HandlerFunc(app.pinboardSave)))
dynamicRouter.Handler(http.MethodGet, routePath("pinboard.edit"), app.requireActivatedUser(http.HandlerFunc(app.pinboardEdit)))
dynamicRouter.Handler(http.MethodPost, routePath("pinboard.edit"), app.requireActivatedUser(http.HandlerFunc(app.pinboardSave)))
dynamicRouter.Handler(http.MethodPost, routePath("pinboard.delete"), app.requireActivatedUser(http.HandlerFunc(app.pinboardDelete)))
dynamicRouter.Handler(http.MethodGet, routePath("pinboard.attachment"), app.requireActivatedUser(http.HandlerFunc(app.pinboardAttachment)))
dynamicRouter.Handler(http.MethodGet, routePath("garden.members"), app.requireActivatedUser(http.HandlerFunc(app.gardenMembers)))
dynamicRouter.Handler(http.MethodPost, routePath("garden.invite.new"), app.requireActivatedUser(http.HandlerFunc(app.gardenInvitePost)))
dynamicRouter.Handler(http.MethodPost, routePath("garden.member.role"), app.requireActivatedUser(http.HandlerFunc(app.gardenMemberRolePost)))
dynamicRouter.Handler(http.MethodPost, routePath("garden.member.delete"), app.requireActivatedUser(http.HandlerFunc(app.gardenMemberDeletePost)))
dynamicRouter.Handler(http.MethodPost, routePath("garden.member.transfer"), app.requireActivatedUser(http.HandlerFunc(app.gardenOwnershipPost)))
dynamicRouter.Handler(http.MethodPost, routePath("garden.invite.delete"), app.requireActivatedUser(http.HandlerFunc(app.gardenInviteDeletePost)))
dynamicRouter.Handler(http.MethodPost, routePath("garden.role.update"), app.requireActivatedUser(http.HandlerFunc(app.gardenRoleSettingsPost)))
dynamicRouter.Handler(http.MethodPost, routePath("garden.role.new"), app.requireActivatedUser(http.HandlerFunc(app.gardenRoleCreatePost)))
dynamicRouter.Handler(http.MethodPost, routePath("garden.role.delete"), app.requireActivatedUser(http.HandlerFunc(app.gardenRoleDeletePost)))
dynamicRouter.Handler(http.MethodGet, routePath("invite"), app.requireActivatedUser(http.HandlerFunc(app.acceptInvite)))
dynamicRouter.Handler(http.MethodPost, routePath("invite"), app.requireActivatedUser(http.HandlerFunc(app.acceptInvitePost)))
dynamicRouter.Handler(http.MethodGet, routePath("plants"), app.requireActivatedUser(http.HandlerFunc(app.plants)))
dynamicRouter.Handler(http.MethodGet, routePath("plant.new"), app.requireActivatedUser(http.HandlerFunc(app.plantCreate)))
dynamicRouter.Handler(http.MethodPost, routePath("plant.new"), app.requireActivatedUser(http.HandlerFunc(app.plantCreatePost)))
dynamicRouter.Handler(http.MethodGet, routePath("plant.edit"), app.requireActivatedUser(http.HandlerFunc(app.plantEdit)))
dynamicRouter.Handler(http.MethodPost, routePath("plant.edit"), app.requireActivatedUser(http.HandlerFunc(app.plantEditPost)))
dynamicRouter.Handler(http.MethodPost, routePath("plant.delete"), app.requireActivatedUser(http.HandlerFunc(app.plantDelete)))
dynamicRouter.Handler(http.MethodPost, routePath("plant.status"), app.requireActivatedUser(http.HandlerFunc(app.plantStatusPost)))
dynamicRouter.Handler(http.MethodPost, routePath("plant.template-opt-out"), app.requireActivatedUser(http.HandlerFunc(app.plantTemplateOptOutPost)))
dynamicRouter.Handler(http.MethodGet, routePath("plant.task-row"), app.requireActivatedUser(http.HandlerFunc(app.plantTaskRow)))
dynamicRouter.Handler(http.MethodPost, routePath("plant.task-row"), app.requireActivatedUser(http.HandlerFunc(app.plantTaskRowPost)))
dynamicRouter.Handler(http.MethodGet, routePath("plant.template-tasks"), app.requireActivatedUser(http.HandlerFunc(app.plantTemplateTasks)))
dynamicRouter.Handler(http.MethodGet, routePath("plant.template-task"), app.requireActivatedUser(http.HandlerFunc(app.plantTemplateTaskView)))
dynamicRouter.Handler(http.MethodGet, routePath("plant.location.new"), app.requireActivatedUser(http.HandlerFunc(app.plantLocationCreate)))
dynamicRouter.Handler(http.MethodPost, routePath("plant.location.new"), app.requireActivatedUser(http.HandlerFunc(app.plantLocationSave)))
dynamicRouter.Handler(http.MethodGet, routePath("plant.location.edit"), app.requireActivatedUser(http.HandlerFunc(app.plantLocationEdit)))
dynamicRouter.Handler(http.MethodPost, routePath("plant.location.edit"), app.requireActivatedUser(http.HandlerFunc(app.plantLocationSave)))
dynamicRouter.Handler(http.MethodPost, routePath("plant.location.delete"), app.requireActivatedUser(http.HandlerFunc(app.plantLocationDeletePost)))
dynamicRouter.Handler(http.MethodGet, routePath("locations"), app.requireActivatedUser(http.HandlerFunc(app.locations)))
dynamicRouter.Handler(http.MethodGet, routePath("location.new"), app.requireActivatedUser(http.HandlerFunc(app.locationCreate)))
dynamicRouter.Handler(http.MethodPost, routePath("location.new"), app.requireActivatedUser(http.HandlerFunc(app.locationCreatePost)))
dynamicRouter.Handler(http.MethodGet, routePath("location.view"), app.requireActivatedUser(http.HandlerFunc(app.locationEdit)))
dynamicRouter.Handler(http.MethodGet, routePath("location.edit"), app.requireActivatedUser(http.HandlerFunc(app.locationEdit)))
dynamicRouter.Handler(http.MethodPost, routePath("location.edit"), app.requireActivatedUser(http.HandlerFunc(app.locationEditPost)))
dynamicRouter.Handler(http.MethodPost, routePath("location.delete"), app.requireActivatedUser(http.HandlerFunc(app.locationDelete)))
dynamicRouter.Handler(http.MethodGet, routePath("species"), app.requireActivatedUser(http.HandlerFunc(app.speciesList)))
dynamicRouter.Handler(http.MethodGet, routePath("species.new"), app.requireActivatedUser(http.HandlerFunc(app.speciesCreate)))
dynamicRouter.Handler(http.MethodPost, routePath("species.new"), app.requireActivatedUser(http.HandlerFunc(app.speciesSave)))
dynamicRouter.Handler(http.MethodGet, routePath("species.edit"), app.requireActivatedUser(http.HandlerFunc(app.speciesEdit)))
dynamicRouter.Handler(http.MethodPost, routePath("species.edit"), app.requireActivatedUser(http.HandlerFunc(app.speciesSave)))
dynamicRouter.Handler(http.MethodPost, routePath("species.delete"), app.requireActivatedUser(http.HandlerFunc(app.speciesDelete)))
dynamicRouter.Handler(http.MethodPost, routePath("species.care.new"), app.requireActivatedUser(http.HandlerFunc(app.careInstructionSave)))
dynamicRouter.Handler(http.MethodPost, routePath("species.care.edit"), app.requireActivatedUser(http.HandlerFunc(app.careInstructionSave)))
dynamicRouter.Handler(http.MethodPost, routePath("species.care.delete"), app.requireActivatedUser(http.HandlerFunc(app.careInstructionDelete)))
dynamicRouter.Handler(http.MethodGet, routePath("tasks"), app.requireActivatedUser(http.HandlerFunc(app.tasks)))
dynamicRouter.Handler(http.MethodGet, routePath("tasks.calendar"), app.requireActivatedUser(http.HandlerFunc(app.taskCalendar)))
dynamicRouter.Handler(http.MethodGet, routePath("task.new"), app.requireActivatedUser(http.HandlerFunc(app.taskCreate)))
dynamicRouter.Handler(http.MethodPost, routePath("task.new"), app.requireActivatedUser(http.HandlerFunc(app.taskSave)))
dynamicRouter.Handler(http.MethodGet, routePath("task.edit"), app.requireActivatedUser(http.HandlerFunc(app.taskEdit)))
dynamicRouter.Handler(http.MethodPost, routePath("task.edit"), app.requireActivatedUser(http.HandlerFunc(app.taskSave)))
dynamicRouter.Handler(http.MethodPost, routePath("task.complete"), app.requireActivatedUser(http.HandlerFunc(app.taskComplete)))
dynamicRouter.Handler(http.MethodPost, routePath("task.reopen"), app.requireActivatedUser(http.HandlerFunc(app.taskReopen)))
dynamicRouter.Handler(http.MethodPost, routePath("task.delete"), app.requireActivatedUser(http.HandlerFunc(app.taskDelete)))
dynamicRouter.Handler(http.MethodGet, routePath("task-template.new"), app.requireActivatedUser(http.HandlerFunc(app.taskTemplateCreate)))
dynamicRouter.Handler(http.MethodPost, routePath("task-template.new"), app.requireActivatedUser(http.HandlerFunc(app.taskTemplateSave)))
dynamicRouter.Handler(http.MethodGet, routePath("task-template.edit"), app.requireActivatedUser(http.HandlerFunc(app.taskTemplateEdit)))
dynamicRouter.Handler(http.MethodPost, routePath("task-template.edit"), app.requireActivatedUser(http.HandlerFunc(app.taskTemplateSave)))
dynamicRouter.Handler(http.MethodPost, routePath("task-template.delete"), app.requireActivatedUser(http.HandlerFunc(app.taskTemplateDelete)))
dynamic := alice.New(app.withAPIClient, app.authenticate, app.noSurf).Then(dynamicRouter)
router := http.NewServeMux()
router.Handle("/static/", http.FileServer(http.FS(files)))
router.HandleFunc("/ping", ping)
router.Handle("/", dynamic)
standard := alice.New(app.recoverPanic, app.logRequest, secureHeaders)
return standard.Then(router)
}
+392
View File
@@ -0,0 +1,392 @@
package web
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"gardomatic.kleiax.de/lib/client"
)
type speciesForm struct {
Global bool `form:"global"`
CommonName string `form:"common_name"`
Cultivar string `form:"cultivar"`
BotanicalName string `form:"botanical_name"`
CategoryID int `form:"category_id"`
SunExposure string `form:"sun_exposure"`
SoilCondition string `form:"soil_condition"`
SoilReaction string `form:"soil_reaction"`
WinterProtection string `form:"winter_protection"`
SpacingCM int `form:"spacing_cm"`
HeightCM int `form:"height_cm"`
Notes string `form:"notes"`
ImageData string `form:"image_data"`
ImageID int `form:"image_id"`
Attributes string `form:"attributes"`
TemplateSpeciesID int `form:"template_species_id"`
Keywords string `form:"keywords"`
SowMonthFrom int `form:"sow_month_from"`
SowDayFrom int `form:"sow_day_from"`
SowMonthTo int `form:"sow_month_to"`
SowDayTo int `form:"sow_day_to"`
PlantingMonthFrom int `form:"planting_month_from"`
PlantingDayFrom int `form:"planting_day_from"`
PlantingMonthTo int `form:"planting_month_to"`
PlantingDayTo int `form:"planting_day_to"`
HarvestMonthFrom int `form:"harvest_month_from"`
HarvestDayFrom int `form:"harvest_day_from"`
HarvestMonthTo int `form:"harvest_month_to"`
HarvestDayTo int `form:"harvest_day_to"`
SowDuration int `form:"sow_duration"`
SowDurationUnit string `form:"sow_duration_unit"`
PlantingDuration int `form:"planting_duration"`
PlantingDurationUnit string `form:"planting_duration_unit"`
HarvestDuration int `form:"harvest_duration"`
HarvestDurationUnit string `form:"harvest_duration_unit"`
Errors map[string]string
}
func (app *application) speciesList(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
apiClient := client.FromContext(r.Context())
garden, _, err := apiClient.Garden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
species, _, err := apiClient.SpeciesForGarden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
data := app.newTemplateData(r)
data.Filters = collectionFilters(r, "q", "origin", "sort")
data.Garden, data.Species = &garden, filterAndSortSpecies(species, data.Filters)
data.Species, data.Pagination = paginateCollection(r, data.Species)
app.render(w, http.StatusOK, "species.tmpl", data)
}
func (app *application) speciesCreate(w http.ResponseWriter, r *http.Request) {
form := speciesForm{SowDayFrom: 1, PlantingDayFrom: 1, HarvestDayFrom: 1, SowDuration: 1, PlantingDuration: 1, HarvestDuration: 1, SowDurationUnit: "week", PlantingDurationUnit: "week", HarvestDurationUnit: "week", Errors: map[string]string{}}
if templateID, _ := strconv.Atoi(r.URL.Query().Get("template_id")); templateID > 0 {
gardenID, err := app.readPathID(r, "gardenID")
if err == nil {
if source, _, loadErr := client.FromContext(r.Context()).Species(r.Context(), gardenID, templateID); loadErr == nil {
form = speciesFormFromSpecies(source)
form.CommonName = ""
form.Global = false
form.TemplateSpeciesID = source.ID
} else {
app.handleAPIError(w, r, loadErr)
return
}
}
}
app.renderSpeciesForm(w, r, form, http.StatusOK, 0)
}
func (app *application) speciesEdit(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
speciesID, err := app.readPathID(r, "speciesID")
if err != nil {
app.notFound(w)
return
}
value, _, err := client.FromContext(r.Context()).Species(r.Context(), gardenID, speciesID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
if value.GardenID != nil && *value.GardenID != gardenID {
app.notFound(w)
return
}
form := speciesFormFromSpecies(value)
app.renderSpeciesForm(w, r, form, http.StatusOK, speciesID)
}
func speciesFormFromSpecies(value client.Species) speciesForm {
form := speciesForm{Global: value.GardenID == nil, CommonName: value.CommonName, Cultivar: value.Cultivar, BotanicalName: value.BotanicalName, Notes: value.Notes, ImageData: value.ImageData, Attributes: string(value.Attributes), Keywords: strings.Join(value.Tags, ", "), Errors: map[string]string{}}
if value.ImageID != nil {
form.ImageID = *value.ImageID
}
if value.SunExposure != nil {
form.SunExposure = *value.SunExposure
}
if value.SoilCondition != nil {
form.SoilCondition = *value.SoilCondition
}
if value.SoilReaction != nil {
form.SoilReaction = *value.SoilReaction
}
if value.WinterProtection != nil {
form.WinterProtection = *value.WinterProtection
}
copyOptionalInt(value.SpacingCM, &form.SpacingCM)
copyOptionalInt(value.HeightCM, &form.HeightCM)
copyOptionalInt(value.CategoryID, &form.CategoryID)
copyOptionalInt(value.SowMonthFrom, &form.SowMonthFrom)
copyOptionalInt(value.SowDayFrom, &form.SowDayFrom)
copyOptionalInt(value.SowMonthTo, &form.SowMonthTo)
copyOptionalInt(value.SowDayTo, &form.SowDayTo)
copyOptionalInt(value.PlantingMonthFrom, &form.PlantingMonthFrom)
copyOptionalInt(value.PlantingDayFrom, &form.PlantingDayFrom)
copyOptionalInt(value.PlantingMonthTo, &form.PlantingMonthTo)
copyOptionalInt(value.PlantingDayTo, &form.PlantingDayTo)
copyOptionalInt(value.HarvestMonthFrom, &form.HarvestMonthFrom)
copyOptionalInt(value.HarvestDayFrom, &form.HarvestDayFrom)
copyOptionalInt(value.HarvestMonthTo, &form.HarvestMonthTo)
copyOptionalInt(value.HarvestDayTo, &form.HarvestDayTo)
form.SowDuration, form.SowDurationUnit = rangeDuration(value.SowMonthFrom, value.SowDayFrom, value.SowMonthTo, value.SowDayTo)
form.PlantingDuration, form.PlantingDurationUnit = rangeDuration(value.PlantingMonthFrom, value.PlantingDayFrom, value.PlantingMonthTo, value.PlantingDayTo)
form.HarvestDuration, form.HarvestDurationUnit = rangeDuration(value.HarvestMonthFrom, value.HarvestDayFrom, value.HarvestMonthTo, value.HarvestDayTo)
return form
}
func (app *application) speciesSave(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
speciesID := 0
if id, readErr := app.readPathID(r, "speciesID"); readErr == nil {
speciesID = id
}
var form speciesForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.CommonName, form.Cultivar, form.BotanicalName, form.Notes = strings.TrimSpace(form.CommonName), strings.TrimSpace(form.Cultivar), strings.TrimSpace(form.BotanicalName), strings.TrimSpace(form.Notes)
form.Errors = map[string]string{}
if form.CommonName == "" {
form.Errors["common_name"] = "Ein Name ist erforderlich."
}
input := speciesInput(form)
if len(form.Errors) == 0 {
apiClient := client.FromContext(r.Context())
var saveErr error
savedSpeciesID := speciesID
if speciesID > 0 {
_, _, saveErr = apiClient.UpdateSpecies(r.Context(), gardenID, speciesID, input)
} else {
var created client.Species
created, _, saveErr = apiClient.CreateSpecies(r.Context(), gardenID, input)
savedSpeciesID = created.ID
if saveErr == nil && form.TemplateSpeciesID > 0 {
saveErr = app.copySpeciesTaskTemplates(r, apiClient, gardenID, form.TemplateSpeciesID, created.ID)
if saveErr != nil {
_, _ = apiClient.DeleteSpecies(r.Context(), gardenID, created.ID)
}
}
}
if saveErr == nil {
if r.FormValue("continue") == "care" {
http.Redirect(w, r, pathWithQuery(webPath("species.edit", gardenID, savedSpeciesID), "step", "care"), http.StatusSeeOther)
return
}
http.Redirect(w, r, webPath("species", gardenID), http.StatusSeeOther)
return
}
var apiError *client.APIError
if errors.As(saveErr, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity {
form.Errors = apiError.Validation
} else {
app.handleAPIError(w, r, saveErr)
return
}
}
app.renderSpeciesForm(w, r, form, http.StatusUnprocessableEntity, speciesID)
}
func speciesInput(form speciesForm) client.SpeciesInput {
input := client.SpeciesInput{Global: form.Global, CommonName: &form.CommonName, Cultivar: &form.Cultivar, BotanicalName: &form.BotanicalName, Notes: &form.Notes, ImageData: &form.ImageData, Tags: parseKeywords(form.Keywords)}
if form.ImageID > 0 {
imageID := form.ImageID
input.ImageID = &imageID
}
input.SunExposure, input.SoilCondition, input.SoilReaction, input.WinterProtection = &form.SunExposure, &form.SoilCondition, &form.SoilReaction, &form.WinterProtection
if form.SpacingCM > 0 {
input.SpacingCM = &form.SpacingCM
}
if form.HeightCM > 0 {
input.HeightCM = &form.HeightCM
}
if json.Valid([]byte(form.Attributes)) {
input.Attributes = json.RawMessage(form.Attributes)
}
if form.CategoryID > 0 {
input.CategoryID = &form.CategoryID
} else {
input.ClearCategoryID = true
}
assignPositive := func(value int) *int {
if value > 0 {
return &value
}
return nil
}
form.SowMonthTo, form.SowDayTo = rangeEnd(form.SowMonthFrom, form.SowDayFrom, form.SowDuration, form.SowDurationUnit)
form.PlantingMonthTo, form.PlantingDayTo = rangeEnd(form.PlantingMonthFrom, form.PlantingDayFrom, form.PlantingDuration, form.PlantingDurationUnit)
form.HarvestMonthTo, form.HarvestDayTo = rangeEnd(form.HarvestMonthFrom, form.HarvestDayFrom, form.HarvestDuration, form.HarvestDurationUnit)
input.SowMonthFrom, input.SowDayFrom, input.SowMonthTo, input.SowDayTo = assignPositive(form.SowMonthFrom), assignPositive(form.SowDayFrom), assignPositive(form.SowMonthTo), assignPositive(form.SowDayTo)
input.PlantingMonthFrom, input.PlantingDayFrom, input.PlantingMonthTo, input.PlantingDayTo = assignPositive(form.PlantingMonthFrom), assignPositive(form.PlantingDayFrom), assignPositive(form.PlantingMonthTo), assignPositive(form.PlantingDayTo)
input.HarvestMonthFrom, input.HarvestDayFrom, input.HarvestMonthTo, input.HarvestDayTo = assignPositive(form.HarvestMonthFrom), assignPositive(form.HarvestDayFrom), assignPositive(form.HarvestMonthTo), assignPositive(form.HarvestDayTo)
input.ClearSowRange = form.SowMonthFrom == 0 && form.SowMonthTo == 0
input.ClearPlantingRange = form.PlantingMonthFrom == 0 && form.PlantingMonthTo == 0
input.ClearHarvestRange = form.HarvestMonthFrom == 0 && form.HarvestMonthTo == 0
input.ClearSowDayFrom, input.ClearSowDayTo = form.SowDayFrom == 0, form.SowDayTo == 0
input.ClearPlantingDayFrom, input.ClearPlantingDayTo = form.PlantingDayFrom == 0, form.PlantingDayTo == 0
input.ClearHarvestDayFrom, input.ClearHarvestDayTo = form.HarvestDayFrom == 0, form.HarvestDayTo == 0
return input
}
func rangeEnd(month, day, duration int, unit string) (int, int) {
if month < 1 {
return 0, 0
}
if day < 1 {
day = 1
}
start := time.Date(2024, time.Month(month), day, 0, 0, 0, 0, time.UTC)
switch unit {
case "month":
start = start.AddDate(0, duration, 0)
case "week":
start = start.AddDate(0, 0, 7*duration)
default:
start = start.AddDate(0, 0, duration)
}
return int(start.Month()), start.Day()
}
func rangeDuration(fromMonth, fromDay, toMonth, toDay *int) (int, string) {
if fromMonth == nil || toMonth == nil {
return 1, "week"
}
fd, td := 1, 1
if fromDay != nil {
fd = *fromDay
}
if toDay != nil {
td = *toDay
}
from := time.Date(2024, time.Month(*fromMonth), fd, 0, 0, 0, 0, time.UTC)
to := time.Date(2024, time.Month(*toMonth), td, 0, 0, 0, 0, time.UTC)
if to.Before(from) {
to = to.AddDate(1, 0, 0)
}
days := int(to.Sub(from).Hours() / 24)
if days%7 == 0 && days > 0 {
return days / 7, "week"
}
return days, "day"
}
func (app *application) copySpeciesTaskTemplates(r *http.Request, apiClient *client.Client, gardenID, sourceID, targetID int) error {
templates, _, err := apiClient.SpeciesTaskTemplates(r.Context(), gardenID, sourceID)
if err != nil {
return err
}
for _, item := range templates {
if item.Origin != "" && item.Origin != "manual" {
continue
}
title, description, triggerType := item.Title, item.Description, item.TriggerType
triggerOffset, triggerOffsetUnit := item.TriggerOffset, item.TriggerOffsetUnit
duration, durationUnit := item.Duration, item.DurationUnit
recurrence, recurrenceInterval, priority, active := item.Recurrence, item.RecurrenceInterval, item.Priority, item.Active
input := client.SpeciesTaskTemplateInput{
Title: &title, Description: &description, TriggerType: &triggerType,
MonthFrom: item.MonthFrom, DayFrom: item.DayFrom, MonthTo: item.MonthTo, DayTo: item.DayTo,
OffsetDaysFrom: item.OffsetDaysFrom, OffsetDaysTo: item.OffsetDaysTo, IntervalDays: item.IntervalDays,
TriggerOffset: &triggerOffset, TriggerOffsetUnit: &triggerOffsetUnit,
Duration: &duration, DurationUnit: &durationUnit, Recurrence: &recurrence,
RecurrenceInterval: &recurrenceInterval, Priority: &priority, Active: &active,
}
if _, _, err = apiClient.CreateSpeciesTaskTemplate(r.Context(), gardenID, targetID, input); err != nil {
return err
}
}
return nil
}
func (app *application) speciesDelete(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
speciesID, err := app.readPathID(r, "speciesID")
if err != nil {
app.notFound(w)
return
}
if _, err := client.FromContext(r.Context()).DeleteSpecies(r.Context(), gardenID, speciesID); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("species", gardenID), http.StatusSeeOther)
}
func (app *application) renderSpeciesForm(w http.ResponseWriter, r *http.Request, form speciesForm, status, speciesID int) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
apiClient := client.FromContext(r.Context())
garden, _, err := apiClient.Garden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
data := app.newTemplateData(r)
data.Garden, data.Form, data.SpeciesID, data.SpeciesGlobal = &garden, form, speciesID, form.Global
data.WizardStep = r.URL.Query().Get("step")
if speciesID > 0 && data.WizardStep == "" {
data.WizardStep = "all"
} else if data.WizardStep != "general" && data.WizardStep != "care" && data.WizardStep != "tasks" {
data.WizardStep = "general"
}
data.Images, err = app.loadImageLibrary(r, gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
data.TagSuggestions = app.loadTagSuggestions(r, gardenID)
categories, _, err := apiClient.SpeciesCategories(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
}
data.SpeciesCategories = categories
if speciesID == 0 {
data.Species, _, err = apiClient.SpeciesForGarden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
}
if speciesID > 0 {
templates, _, err := apiClient.SpeciesTaskTemplates(r.Context(), gardenID, speciesID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
data.TaskTemplates = map[int][]client.SpeciesTaskTemplate{speciesID: templates}
data.CareInstructions, _, err = apiClient.CareInstructions(r.Context(), gardenID, speciesID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
}
app.render(w, status, "species_form.tmpl", data)
}
+9
View File
@@ -0,0 +1,9 @@
/*!
* Cropper.js v1.6.2
* https://fengyuanchen.github.io/cropperjs
*
* Copyright 2015-present Chen Fengyuan
* Released under the MIT license
*
* Date: 2024-04-21T07:43:02.731Z
*/.cropper-container{-webkit-touch-callout:none;direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{backface-visibility:hidden;display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed}
+384
View File
@@ -0,0 +1,384 @@
:root {
color-scheme: light;
--ink: #1f2a22;
--muted: #667267;
--paper: #f4f1e8;
--surface: #fffdf8;
--leaf: #315d3a;
--leaf-light: #dce9d9;
--line: #d7d7ca;
--danger: #a3372b;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; color: var(--ink); background: var(--paper); line-height: 1.5; }
a { color: var(--leaf); }
:focus-visible { outline: .2rem solid var(--leaf); outline-offset: .2rem; }
.skip-link { position: fixed; z-index: 10; top: .5rem; left: .5rem; padding: .65rem 1rem; color: white; background: var(--leaf); border-radius: .5rem; transform: translateY(-150%); }
.skip-link:focus { transform: translateY(0); }
header, nav, main, footer { width: min(70rem, calc(100% - 2rem)); margin-inline: auto; }
.site-header { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding-block: 1.5rem .5rem; }
.site-header h1 { flex: 0 0 auto; margin: 0; font-size: 1.25rem; letter-spacing: .04em; }
.site-header-garden { min-width: 0; flex: 1; overflow: hidden; color: var(--ink); font-weight: 700; text-align: center; text-decoration: none; text-overflow: ellipsis; white-space: nowrap; }
.site-header-garden:hover { color: var(--leaf); }
nav { display: flex; align-items: center; gap: 1rem; padding: .75rem 1rem; background: var(--surface); border: 1px solid var(--line); border-radius: 1rem; }
nav a { text-decoration: none; font-weight: 650; }
.nav-search { display: inline-flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; margin-left: auto; }
.nav-search span { position: relative; display: block; width: .9rem; height: .9rem; border: .14rem solid currentColor; border-radius: 50%; }
.nav-search span::after { position: absolute; right: -.45rem; bottom: -.3rem; width: .5rem; height: .14rem; background: currentColor; border-radius: 999px; content: ''; transform: rotate(45deg); transform-origin: left center; }
.user-menu { position: relative; z-index: 5; }
.user-menu summary { display: flex; align-items: center; justify-content: center; width: 2.75rem; height: 2.75rem; color: white; background: var(--leaf); border-radius: .55rem; cursor: pointer; list-style: none; }
.user-menu summary::-webkit-details-marker { display: none; }
.user-menu summary:hover, .user-menu[open] summary { background: var(--ink); }
.burger-icon, .burger-icon::before, .burger-icon::after { display: block; width: 1.25rem; height: .125rem; background: currentColor; border-radius: 999px; }
.burger-icon { position: relative; }
.burger-icon::before, .burger-icon::after { position: absolute; left: 0; content: ''; }
.burger-icon::before { top: -.4rem; }
.burger-icon::after { top: .4rem; }
.user-menu-content { position: absolute; top: calc(100% + .5rem); right: 0; display: grid; width: max-content; min-width: 12rem; padding: .5rem; background: var(--surface); border: 1px solid var(--line); border-radius: .75rem; box-shadow: 0 .75rem 2rem rgb(31 42 34 / .18); }
.user-menu-content a, .user-menu-content .link-button { display: block; width: 100%; padding: .65rem .75rem; color: var(--ink); border-radius: .4rem; text-align: left; text-decoration: none; font-weight: 650; }
.user-menu-content a:hover, .user-menu-content .link-button:hover { color: var(--leaf); background: var(--leaf-light); }
.user-menu-content a span { display: block; color: var(--muted); font-size: .8rem; font-weight: 500; }
.user-menu-content form { display: block; }
main { padding-block: 2rem 4rem; }
footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding-block: 2rem; border-top: 1px solid var(--line); color: var(--muted); }
footer nav { width: auto; margin: 0; padding: 0; background: transparent; border: 0; }
h2 { font-size: clamp(2rem, 6vw, 3.75rem); line-height: 1.05; margin: .25rem 0 1rem; }
h3 { margin: 0 0 .5rem; }
.hero { max-width: 48rem; padding-block: 7vw; }
.eyebrow, .status { color: var(--leaf); font-size: .78rem; font-weight: 750; letter-spacing: .12em; text-transform: uppercase; }
.page-heading { width: 100%; display: flex; align-items: end; justify-content: space-between; gap: 1rem; padding: 0 0 1.5rem; }
.page-heading h2 { margin-bottom: 0; }
.page-heading > .actions { margin-top: 0; }
.icon-button { position: relative; display: inline-flex; flex: 0 0 auto; align-items: center; justify-content: center; width: clamp(2.25rem, 6vw, 3.75rem); height: clamp(2.25rem, 6vw, 3.75rem); color: white; background: var(--leaf); border-radius: .55rem; text-decoration: none; }
.icon-button::before, .icon-button::after { position: absolute; top: 50%; left: 50%; width: 45%; height: .125rem; background: currentColor; border-radius: 999px; content: ''; transform: translate(-50%, -50%); }
.icon-button::after { transform: translate(-50%, -50%) rotate(90deg); }
.icon-button:hover { background: var(--ink); }
.card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 1rem; margin-bottom: 2rem; }
.card, .panel { display: block; padding: 1.25rem; color: inherit; text-decoration: none; background: var(--surface); border: 1px solid var(--line); border-radius: 1rem; box-shadow: 0 .5rem 1.5rem rgb(31 42 34 / .05); }
.card:hover { border-color: var(--leaf); }
.card--interactive { cursor: pointer; }
.card--clickable { position: relative; }
.card-link::after { position: absolute; inset: 0; border-radius: inherit; content: ''; }
.card-action { position: relative; z-index: 1; }
.card--image { position: relative; min-height: 15rem; padding: 0; overflow: hidden; background-position: center; background-size: cover; }
.card--image .card-content { position: absolute; inset: auto 0 0; padding: 1rem 1.25rem; color: white; background: rgb(20 30 22 / .76); backdrop-filter: blur(2px); }
.card--image .card-content a, .card--image .status { color: white; }
.card--image .card-content > :last-child { margin-bottom: 0; }
.garden-summary { width: 100%; max-width: none; margin-bottom: 2rem; }
.garden-summary.card--image { min-height: clamp(16rem, 42vw, 28rem); }
.plant-card { padding-right: 3.75rem; }
.plant-card.card--image .card-content { max-height: 100%; overflow-y: auto; scrollbar-width: thin; }
.plant-location-summary p { display: grid; grid-template-columns: minmax(2ch, auto) 1ch minmax(0, 1fr); gap: .35rem; margin: 0; }
.plant-location-summary p span:first-child { text-align: right; }
.plant-location-summary p span:nth-child(2) { text-align: center; }
.plant-card-menu { position: absolute; top: .75rem; right: .75rem; }
.plant-card-menu summary { width: 2rem; height: 2rem; }
.plant-card-menu .burger-icon, .plant-card-menu .burger-icon::before, .plant-card-menu .burger-icon::after { width: .9rem; }
.plant-card-menu .user-menu-content { z-index: 2; }
.menu-heading { padding: .35rem .75rem; color: var(--muted); font-size: .8rem; font-weight: 700; }
.user-menu-content .link-button:disabled { color: var(--muted); background: var(--leaf-light); cursor: default; }
.panel { max-width: 44rem; }
.narrow { max-width: 30rem; margin-inline: auto; }
form { display: grid; gap: .55rem; }
label { margin-top: .65rem; font-weight: 700; }
input, select, textarea, button { width: 100%; padding: .75rem .85rem; color: inherit; background: white; border: 1px solid var(--line); border-radius: .55rem; font: inherit; }
textarea { resize: vertical; }
button, .button { display: inline-block; width: auto; padding: .75rem 1rem; color: white; background: var(--leaf); border: 0; border-radius: .55rem; text-decoration: none; font-weight: 750; cursor: pointer; }
.inline-form { display: inline; }
.link-button { padding: 0; color: var(--leaf); background: none; }
.field-error, .form-message.error { margin: 0; color: var(--danger); }
.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; margin-top: 1rem; }
.actions > a { display: inline-block; width: auto; padding: .75rem 1rem; color: var(--leaf); background: var(--leaf-light); border-radius: .55rem; text-decoration: none; font-weight: 750; }
.actions > a:hover { color: white; background: var(--ink); }
.actions > .inline-form { display: inline-flex; }
.field-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; margin-top: .65rem; }
.field-heading label { margin: 0; }
.location-panel { max-width: none; }
.location-tree { margin: 0; padding-left: 1.5rem; list-style: none; border-left: 1px solid var(--line); }
.location-tree:first-child { padding-left: 0; border-left: 0; }
.location-tree .location-tree { margin: .75rem 0 .25rem 1rem; }
.location-row { display: block; padding: .75rem 1rem; color: inherit; background: var(--surface); border: 1px solid var(--line); border-radius: .65rem; text-decoration: none; }
.location-row:hover { border-color: var(--leaf); }
.location-row > div { display: flex; align-items: baseline; gap: .75rem; }
.location-row h3, .location-row p { margin: 0; }
.location-panel > .location-tree > li + li { margin-top: .75rem; }
.location-detail, .location-detail-plants, .plant-detail { margin-inline: auto; }
.location-detail-plants { margin-top: 1rem; }
.location-plant-list { display: grid; gap: .5rem; }
.location-plant-list .card { min-height: 0; padding: .85rem 1rem; }
.location-plant-list h3, .location-plant-list p { margin: 0; }
.location-history { margin: 1rem auto 0; }
.location-history > h3 { margin-bottom: 1rem; }
.location-history-year + .location-history-year { margin-top: 1.5rem; }
.location-history-year h4 { margin: 0 0 .65rem; }
.location-history-year .card-grid { margin-bottom: 0; }
.location-dialog { width: min(42rem, calc(100% - 2rem)); border: 1px solid var(--line); border-radius: 1rem; box-shadow: 0 1rem 4rem rgb(31 42 34 / .25); }
.location-dialog::backdrop, .task-template-dialog::backdrop, .plant-task-dialog::backdrop, .confirm-dialog::backdrop { background: rgb(31 42 34 / .35); }
.task-template-dialog, .plant-task-dialog { width: min(44rem, calc(100% - 2rem)); max-height: calc(100vh - 2rem); overflow-y: auto; border: 1px solid var(--line); border-radius: 1rem; box-shadow: 0 1rem 4rem rgb(31 42 34 / .25); }
.confirm-dialog { width: min(32rem, calc(100% - 2rem)); padding: 1.5rem; color: var(--ink); background: var(--surface); border: 1px solid var(--line); border-radius: 1rem; box-shadow: 0 1rem 4rem rgb(31 42 34 / .25); }
.confirm-dialog p { margin-bottom: 0; color: var(--muted); }
.confirm-dialog .actions { justify-content: flex-end; }
.secondary { color: var(--leaf); background: var(--leaf-light); }
.task-list { display: grid; gap: 1rem; margin-top: 1rem; }
.task-card { max-width: none; }
.task-card.card--interactive:hover { border-color: var(--leaf); }
.task-card.completed { opacity: .65; }
.task-heading { display: flex; justify-content: space-between; gap: 1rem; }
.task-card-footer { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
.task-links { margin-block: .5rem 0; color: var(--muted); }
.task-card-actions { position: relative; flex: 0 0 auto; justify-content: flex-end; margin-top: 0; }
.danger { background: var(--danger); }
.danger-text { color: var(--danger); }
.template-section { max-width: none; margin-bottom: 1rem; }
.template-row { padding-block: .8rem; border-top: 1px solid var(--line); }
.species-detail, .species-task-templates { margin-inline: auto; }
.species-task-templates { margin-top: 1rem; }
.species-support-grid { display: grid; grid-template-columns: 1fr; width: 100%; max-width: none; align-items: start; gap: 1rem; margin: 0; }
.species-support-grid > .panel { width: 100%; max-width: none; margin: 0; scroll-margin-top: 1rem; }
.species-support-grid > .species-task-templates { margin-top: 0; }
.care-instruction-list { display: grid; gap: .75rem; margin-top: .75rem; }
.care-instruction { padding-top: .75rem; border-top: 1px solid var(--line); }
.care-instruction:first-child { padding-top: 0; border-top: 0; }
.care-instruction-form { display: grid; grid-template-columns: minmax(0, 1fr) minmax(8rem, 11rem); align-items: stretch; gap: .75rem; }
.care-instruction-form > textarea { min-height: 5.5rem; margin: 0; }
.care-instruction-controls { display: flex; flex-direction: column; gap: .5rem; }
.care-instruction-controls label { display: block; margin: 0; }
.care-instruction-actions { display: flex; justify-content: center; gap: .5rem; margin-top: auto; }
.care-action { display: inline-flex; align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem; padding: 0; }
.care-action svg { width: 1.25rem; height: 1.25rem; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.care-action-save { background: var(--leaf); }
.care-action-remove { background: var(--danger); }
.care-action:hover { background: var(--ink); }
.care-instruction-add > summary { width: 2.5rem; height: 2.5rem; margin-top: .75rem; cursor: pointer; list-style: none; }
.care-instruction-add > summary::-webkit-details-marker { display: none; }
.plain-fieldset { display: contents; }
.plain-fieldset:disabled { opacity: .72; }
.species-template-list { margin-top: .75rem; }
.species-template-row { display: flex; align-items: center; min-height: 3rem; border-top: 1px solid var(--line); }
.species-template-open { flex: 1; padding-inline: 0; color: var(--ink); background: transparent; text-align: left; }
.species-template-open:hover { color: var(--leaf); }
.species-template-row form { flex: 0 0 auto; }
.template-remove { width: 2.5rem; height: 2.5rem; padding: 0; color: white; background: var(--danger); font-size: 1.35rem; line-height: 1; }
.danger:hover, .template-remove:hover { background: #7f291f; }
.species-template-add { width: 2.5rem; height: 2.5rem; margin-top: .75rem; margin-left: auto; }
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: 1rem; }
.filter-bar { width: 100%; max-width: none; margin-bottom: 2rem; }
.image-library-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:1rem; }
.image-library-item { margin:0; overflow:hidden; padding:0; }
.image-library-item img { width:100%; aspect-ratio:3/2; object-fit:cover; display:block; }
.image-library-item figcaption { padding:.75rem 1rem; color:var(--muted-color); font-size:.9rem; }
.image-picker-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(120px,1fr)); gap:.75rem; max-height:28rem; overflow:auto; }
.image-picker-grid label { cursor:pointer; margin:0; }
.image-picker-grid img { width:100%; aspect-ratio:3/2; object-fit:cover; border-radius:.4rem; }
.image-library-dialog { width:min(56rem,calc(100% - 2rem)); }
.image-picker-item { display:flex; flex-direction:column; gap:.35rem; padding:.35rem; background:transparent; color:inherit; border:2px solid transparent; text-align:left; }
.image-picker-item:hover, .image-picker-item:focus-visible, .image-picker-item.is-selected { border-color:var(--leaf); background:var(--surface); }
.image-picker-item span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:100%; }
.filter-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)) auto; align-items: end; gap: 1rem; }
.filter-fields--plants { grid-template-columns: repeat(5, minmax(0, 1fr)) auto; }
.filter-fields--tasks { grid-template-columns: repeat(7, minmax(0, 1fr)) auto; }
.filter-fields label { display: block; margin-top: 0; }
.filter-submit { justify-self: end; white-space: nowrap; }
fieldset { margin-top: 1rem; border: 1px solid var(--line); border-radius: .55rem; }
.assignment-fieldset { display: grid; gap: .75rem; padding: 1rem; }
.assignment-list { display: grid; gap: .75rem; }
.assignment-row { display: grid; grid-template-columns: minmax(10rem, 1fr) minmax(6rem, 8rem) auto; align-items: end; gap: .75rem; }
.assignment-row label { display: block; margin: 0 0 .3rem; }
.assignment-remove { width: 2.75rem; height: 2.75rem; padding: 0; font-size: 1.35rem; line-height: 1; }
.assignment-add { width: 2.5rem; height: 2.5rem; justify-self: end; }
.create-location-link { justify-self: start; }
.plant-task-fieldset { display: grid; gap: .8rem; padding: 1rem; }
.plant-task-list { display: grid; gap: .8rem; }
.plant-task-row, .plant-template-task-row { display: flex; align-items: center; gap: .5rem; min-height: 3.5rem; padding: .45rem .6rem; border: 1px solid var(--line); border-radius: .55rem; background: var(--surface); }
.plant-task-name { flex: 1; padding: .55rem; color: var(--ink); background: transparent; text-align: left; }
.plant-task-name:hover { color: var(--leaf); background: var(--leaf-light); }
.plant-task-add { width: 2.5rem; height: 2.5rem; justify-self: end; }
.switch { position: relative; display: inline-flex; flex: 0 0 auto; width: 3rem; height: 1.7rem; margin: 0; }
.switch input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.switch span { width: 100%; height: 100%; background: var(--line); border-radius: 999px; cursor: pointer; transition: background .15s ease; }
.switch span::after { display: block; width: 1.25rem; height: 1.25rem; margin: .225rem; background: white; border-radius: 50%; box-shadow: 0 .1rem .25rem rgb(31 42 34 / .25); content: ''; transition: transform .15s ease; }
.switch input:checked + span { background: var(--leaf); }
.switch input:checked + span::after { transform: translateX(1.3rem); }
.switch input:focus-visible + span { outline: .2rem solid var(--leaf); outline-offset: .15rem; }
.task-detail-list { display: grid; grid-template-columns: auto 1fr; gap: .4rem 1rem; }
.task-detail-list dt { font-weight: 700; }
.task-detail-list dd { margin: 0; }
.delete-form { margin-top: 1.5rem; }
.checkbox { display: flex; align-items: center; gap: .5rem; }
.checkbox input { width: auto; }
.image-editor { display: grid; gap: .75rem; padding: 1rem; }
.image-preview { display: grid; aspect-ratio: 3 / 2; place-items: center; overflow: hidden; color: var(--muted); background-color: var(--leaf-light); background-position: center; background-size: cover; border: 1px solid var(--line); border-radius: .65rem; }
.image-actions { display: flex; flex-wrap: wrap; gap: .65rem; }
.image-file-button { margin: 0; cursor: pointer; }
.image-file-button input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.image-dialog, .camera-dialog { width: min(60rem, calc(100% - 2rem)); border: 1px solid var(--line); border-radius: 1rem; }
.image-dialog::backdrop, .camera-dialog::backdrop { background: rgb(31 42 34 / .6); }
.cropper-stage { height: min(60vh, 36rem); margin-bottom: 1rem; }
.cropper-stage img { display: block; max-width: 100%; }
.camera-dialog video { display: block; width: 100%; max-height: 65vh; margin-bottom: 1rem; background: var(--ink); border-radius: .65rem; object-fit: contain; }
.camera-dialog audio { display: block; width: 100%; margin: 1rem 0; }
.camera-dialog audio[hidden] { display: none; }
.collection[data-view-mode='list'] { display: grid; grid-template-columns: 1fr; gap: .5rem; }
.collection[data-view-mode='list'] .card { min-height: 0; padding: .85rem 1rem; }
.collection[data-view-mode='list'] .card--image { background-image: none !important; }
.collection[data-view-mode='list'] .card--image .card-content { position: static; padding: 0; color: inherit; background: transparent; backdrop-filter: none; }
.collection[data-view-mode='list'] .card--image .card-content a, .collection[data-view-mode='list'] .card--image .status { color: var(--leaf); }
.task-list[data-view-mode='grid'] { grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr)); }
.locations-views .card-grid { margin-bottom: 0; }
.view-toggle { display: inline-grid; flex: 0 0 3rem; width: 3rem; height: 3rem; padding: .7rem; place-items: center; color: var(--leaf); background: var(--leaf-light); border: 0; border-radius: .55rem; line-height: 1; }
.view-toggle:hover { color: white; background: var(--ink); }
.view-toggle svg { grid-area: 1 / 1; width: 1.35rem; height: 1.35rem; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; }
.view-toggle svg rect { fill: currentColor; stroke: none; }
.view-toggle-list-icon { display: none; }
.view-toggle[data-view-mode='list'] .view-toggle-grid-icon { display: none; }
.view-toggle[data-view-mode='list'] .view-toggle-list-icon { display: block; }
.compatibility-warning { margin: .75rem 0; padding: .75rem 1rem; border: 1px solid #d99a21; border-radius: .55rem; color: #684300; background: #fff4d6; }
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: 1rem; margin-bottom: 2rem; }
.summary-number { display: block; font-size: 2.5rem; font-weight: 800; color: var(--leaf); }
.calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(10rem, 1fr)); gap: .75rem; overflow-x: auto; }
.calendar-day { min-height: 9rem; padding: .8rem; background: var(--surface); border: 1px solid var(--line); border-radius: .75rem; }
.calendar-day h3 { font-size: .9rem; }
.calendar-task { display: grid; margin-top: .5rem; padding: .55rem; color: inherit; background: var(--leaf-light); border-radius: .45rem; text-decoration: none; }
.calendar-task span, .muted { color: var(--muted); font-size: .85rem; }
.view-switch { width: auto; margin: 0 0 1rem; justify-content: start; }
.view-switch [aria-current='page'] { text-decoration: underline; }
.pagination { justify-content: center; width: 100%; margin-top: 1rem; margin-inline: 0; }
.pagination a { padding: .45rem .7rem; background: var(--leaf-light); border-radius: .45rem; }
.pagination span { color: var(--muted); }
.search-form { margin-bottom: 2rem; }
.search-fields { display: flex; align-items: end; gap: .75rem; }
.search-fields label { flex: 1 1 auto; margin: 0; }
.search-fields input, .search-fields select { flex: 1 1 auto; }
.search-fields button { flex: 0 0 auto; }
.search-tabs { width: 100%; margin-bottom: 1.5rem; overflow-x: auto; border-radius: .75rem; }
.search-tabs a { display: inline-flex; gap: .35rem; align-items: center; padding: .65rem .8rem; white-space: nowrap; }
.search-tabs a span { min-width: 1.5rem; padding: .05rem .35rem; color: var(--leaf); background: var(--leaf-light); border-radius: 999px; text-align: center; font-size: .8rem; }
.search-results { scroll-margin-top: 1rem; }
.search-card-list { display: grid; gap: .55rem; margin-bottom: 2rem; }
.search-card { display: flex; align-items: center; justify-content: space-between; gap: 1rem; min-height: 4.2rem; padding: .75rem 1rem; color: inherit; background: var(--surface); border: 1px solid var(--line); border-radius: .7rem; text-decoration: none; }
.search-card:hover { border-color: var(--leaf); }
.search-card > span { display: grid; min-width: 0; }
.search-card strong, .search-card span span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.search-card span span, .search-card small { color: var(--muted); }
.search-card em { flex: 0 0 auto; color: var(--muted); font-size: .75rem; font-style: normal; font-weight: 750; text-transform: uppercase; }
.journal-list { display: grid; gap: 1.25rem; }
.pinboard-list { grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); align-items: start; }
.pinboard-list .journal-media { grid-template-columns: 1fr; }
.pinboard-list .journal-media img { aspect-ratio: 3 / 2; object-fit: cover; }
.journal-entry { width: 100%; max-width: none; scroll-margin-top: 1rem; }
.journal-entry > header { display: flex; align-items: start; justify-content: space-between; gap: 1rem; width: auto; margin: -1.25rem -1.25rem .75rem; padding: 1.25rem 1.25rem .05rem; background: color-mix(in srgb, var(--author-color, #315d3a) 18%, transparent); border-radius: 1rem 1rem 0 0; }
.journal-entry > header h3 { padding: .2rem 0; }
.journal-entry h3 { font-size: 1.5rem; }
.journal-meta { margin: .15rem 0 .75rem; color: var(--muted); font-size: .9rem; }
.tag-list { display: flex; flex-wrap: wrap; gap: .4rem; margin: 0 0 1rem; padding: 0; list-style: none; }
.tag-list li { padding: .2rem .55rem; color: var(--leaf); background: var(--leaf-light); border-radius: 999px; font-size: .8rem; font-weight: 700; }
.tag-editor { position: relative; display: flex; flex-wrap: wrap; align-items: center; gap: .35rem; padding: .35rem .5rem; background: white; border: 1px solid var(--line); border-radius: .55rem; }
.tag-editor input { flex: 1 1 10rem; min-width: 10rem; padding: .4rem; border: 0; outline: 0; }
.tag-bubbles { display: contents; }
.tag-bubble { display: inline-flex; align-items: center; gap: .25rem; padding: .2rem .45rem .2rem .6rem; color: var(--leaf); background: var(--leaf-light); border-radius: 999px; font-size: .82rem; font-weight: 700; }
.tag-bubble-remove { width: 1.15rem; height: 1.15rem; padding: 0; color: var(--leaf); background: transparent; font-size: 1rem; line-height: 1; }
.tag-suggestions { position: absolute; z-index: 3; top: calc(100% + .2rem); left: 0; display: grid; min-width: 14rem; max-height: 12rem; overflow-y: auto; padding: .3rem; background: var(--surface); border: 1px solid var(--line); border-radius: .5rem; box-shadow: 0 .5rem 1.5rem rgb(31 42 34 / .15); }
.tag-suggestions button { padding: .4rem .55rem; color: var(--ink); background: transparent; border-radius: .3rem; text-align: left; }
.tag-suggestions button:hover, .tag-suggestions button:focus-visible { color: var(--leaf); background: var(--leaf-light); }
.markdown-body { overflow-wrap: anywhere; }
.markdown-body img { max-width: 100%; height: auto; border-radius: .65rem; }
.markdown-body pre { overflow-x: auto; padding: 1rem; background: var(--paper); border-radius: .55rem; }
.markdown-body blockquote { margin-inline: 0; padding-left: 1rem; border-left: .25rem solid var(--leaf-light); color: var(--muted); }
.journal-media { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 1rem; margin-top: 1.25rem; }
.journal-media figure { margin: 0; }
.journal-media img, .journal-media video { display: block; width: 100%; max-height: 30rem; border-radius: .65rem; object-fit: contain; background: var(--ink); }
.journal-media audio { width: 100%; }
.journal-media figcaption { margin-top: .35rem; color: var(--muted); font-size: .8rem; }
.journal-form { width: 100%; max-width: none; }
.journal-form > textarea { min-height: 18rem; }
.journal-attachments { display: flex; flex-wrap: wrap; align-items: center; gap: .65rem; padding: 1rem; }
.journal-attachments legend { font-weight: 700; }
.recording-indicator { display: inline-flex; align-items: center; gap: .16rem; height: 1.5rem; padding: .2rem .45rem; background: #fbe6e2; border-radius: .4rem; }
.recording-indicator i { display: block; width: .2rem; height: .45rem; background: var(--danger); border-radius: 999px; animation: recording-bars .8s ease-in-out infinite alternate; }
.recording-indicator i:nth-child(2) { animation-delay: -.6s; }
.recording-indicator i:nth-child(3) { animation-delay: -.4s; }
.recording-indicator i:nth-child(4) { animation-delay: -.2s; }
@keyframes recording-bars { from { height: .35rem; opacity: .55; } to { height: 1rem; opacity: 1; } }
.file-picker { margin: 0; cursor: pointer; }
.file-picker input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.attachment-preview { flex-basis: 100%; margin: .5rem 0 0; }
.attachment-checklist { display: grid; gap: .4rem; }
.attachment-row { display: flex; align-items: center; justify-content: space-between; gap: .75rem; padding: .35rem 0; border-top: 1px solid var(--line); }
.attachment-row a { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.attachment-remove { display: inline-flex; flex: 0 0 auto; align-items: center; justify-content: center; width: 2rem; height: 2rem; margin: 0; color: white; background: var(--danger); border-radius: .4rem; cursor: pointer; }
.attachment-remove input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.attachment-remove:has(input:checked) { background: var(--ink); }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.member-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .8rem 0; border-top: 1px solid var(--line); }
.account-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); gap: 1rem; }
.settings-stack { display: grid; max-width: 44rem; gap: 1rem; margin-inline: auto; }
.settings-stack .panel { width: 100%; max-width: none; }
.health-status dl { display: grid; gap: .75rem; margin: 0; }
.health-status dl div { display: grid; grid-template-columns: minmax(8rem, 1fr) 2fr; gap: 1rem; padding-bottom: .75rem; border-bottom: 1px solid var(--line); }
.health-status dt { font-weight: 700; }
.health-status dd { margin: 0; overflow-wrap: anywhere; }
.legal-content { max-width: 52rem; }
.settings-layout { display: grid; grid-template-columns: minmax(12rem, 15rem) minmax(0, 1fr); align-items: start; gap: 1rem; }
.settings-sidebar { position: sticky; top: 1rem; width: 100%; max-width: none; padding: .65rem; }
.settings-sidebar nav { display: grid; width: 100%; gap: .2rem; margin: 0; padding: 0; background: transparent; border: 0; border-radius: 0; }
.settings-sidebar a { padding: .65rem .75rem; color: var(--ink); border-radius: .5rem; text-decoration: none; }
.settings-sidebar a:hover, .settings-sidebar a:focus-visible { color: var(--leaf); background: var(--leaf-light); }
.settings-sidebar a[aria-current='page'] { color: var(--leaf); background: var(--leaf-light); font-weight: 700; }
.settings-content { display: grid; min-width: 0; gap: 1rem; }
.settings-content > .panel { width: 100%; max-width: none; margin: 0; scroll-margin-top: 1rem; }
.environment-list { display: grid; gap: .5rem; overflow-x: auto; }
.environment-row { display: grid; grid-template-columns: 4rem minmax(16rem, .8fr) minmax(16rem, 1fr); align-items: baseline; gap: .75rem; padding-block: .5rem; border-top: 1px solid var(--line); }
.environment-row code { overflow-wrap: anywhere; }
.admin-user-invite { display: grid; grid-template-columns: minmax(10rem, .7fr) minmax(14rem, 1fr) auto; align-items: end; gap: .75rem; margin-block: 1rem; }
.admin-user-invite label { margin-top: 0; }
.admin-user-actions { display: flex; align-items: end; gap: .75rem; }
.delete-user { position: relative; }
.delete-user > summary { padding: .75rem 1rem; color: var(--danger); background: var(--surface); border: 1px solid var(--danger); border-radius: .55rem; cursor: pointer; list-style: none; font-weight: 750; }
.delete-user > summary::-webkit-details-marker { display: none; }
.delete-user > form { position: absolute; z-index: 5; right: 0; width: min(20rem, 80vw); padding: 1rem; background: var(--surface); border: 1px solid var(--line); border-radius: .55rem; box-shadow: var(--shadow); }
.delete-user > form p { margin-top: 0; }
.danger-panel { display: flex; align-items: center; justify-content: space-between; gap: 1.5rem; border-color: rgb(163 55 43 / .35); }
.danger-panel p { max-width: 34rem; margin: 0; color: var(--muted); }
.danger-panel > button { flex: 0 0 auto; }
.admin-categories { max-width: none; margin-top: 1rem; }
.admin-category-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 1rem; padding-block: 1rem; border-top: 1px solid var(--line); }
form.admin-category-row { grid-template-columns: minmax(10rem, .6fr) minmax(12rem, 1fr) auto; }
form.admin-category-row fieldset { grid-column: 1 / -1; display: grid; gap: .4rem; padding: .75rem; }
.role-editor + .role-editor { margin-top: 1rem; }
.role-editor-form { display: grid; gap: .85rem; margin-top: 1rem; }
.role-editor-form fieldset { display: grid; gap: .4rem; padding: .75rem; }
.admin-category-row > form:first-child { display: grid; grid-template-columns: minmax(12rem, 1fr) 8rem auto auto; align-items: end; gap: .75rem; }
.admin-category-row label { margin-top: 0; }
.admin-category-create { max-width: 32rem; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--line); }
@media (max-width: 40rem) {
.search-fields { flex-wrap: wrap; }
.search-fields label { flex-basis: 100%; }
nav { flex-wrap: wrap; }
.page-heading { align-items: start; flex-direction: column; }
.page-heading--with-icon { align-items: end; flex-direction: row; }
.page-heading:has(.view-toggle) { align-items: end; flex-direction: row; }
.calendar-grid { grid-template-columns: repeat(7, minmax(8rem, 1fr)); }
.species-support-grid { grid-template-columns: 1fr; }
.care-instruction-form { grid-template-columns: 1fr; }
.care-instruction-controls { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; }
.care-instruction-actions { margin-top: 0; }
.member-row { align-items: start; flex-direction: column; }
.settings-layout { grid-template-columns: 1fr; }
.settings-sidebar { position: static; overflow-x: auto; }
.settings-sidebar nav { display: flex; flex-wrap: nowrap; }
.settings-sidebar a { flex: 0 0 auto; white-space: nowrap; }
.environment-row { grid-template-columns: auto 1fr; }
.environment-row code:last-child { grid-column: 1 / -1; }
.admin-user-invite { grid-template-columns: 1fr; align-items: stretch; }
.admin-user-actions { width: 100%; align-items: stretch; flex-direction: column; }
.admin-user-actions > form, .delete-user, .delete-user > summary { width: 100%; }
.danger-panel { align-items: start; flex-direction: column; }
.assignment-row { grid-template-columns: minmax(0, 1fr) 6rem auto; gap: .5rem; }
.admin-category-row, .admin-category-row > form:first-child { grid-template-columns: 1fr; align-items: stretch; }
.filter-fields, .filter-fields--plants, .filter-fields--tasks { grid-template-columns: 1fr 1fr; }
.filter-submit { grid-column: 2; }
}
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 NHN Cloud Corp.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+816
View File
@@ -0,0 +1,816 @@
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/static/sw.js");
});
}
const viewPreferenceKey = `gardomatic.view-preferences.${document.body.dataset.userId || "anonymous"}`;
function readViewPreferences() {
try { return { default: "grid", ...JSON.parse(localStorage.getItem(viewPreferenceKey) || "{}") }; }
catch (_) { return { default: "grid" }; }
}
function initializeCollections(root = document) {
const preferences = readViewPreferences();
const selectedMode = (key) => {
const selected = preferences[key];
return selected && selected !== "inherit" ? selected : preferences.default;
};
root.querySelectorAll("[data-background-image]").forEach((card) => {
card.style.backgroundImage = `url("${card.dataset.backgroundImage}")`;
});
root.querySelectorAll("[data-view-key]").forEach((collection) => {
collection.dataset.viewMode = selectedMode(collection.dataset.viewKey);
});
root.querySelectorAll("[data-view-variants]").forEach((container) => {
const mode = selectedMode(container.dataset.viewKey);
container.querySelectorAll(":scope > [data-view-variant]").forEach((variant) => {
variant.hidden = variant.dataset.viewVariant !== mode;
});
});
}
function initializeViewToggles(root = document) {
root.querySelectorAll("[data-view-toggle]").forEach((button) => {
const key = button.dataset.viewToggle;
const update = () => {
const preferences = readViewPreferences();
const mode = preferences[key] && preferences[key] !== "inherit" ? preferences[key] : preferences.default;
button.dataset.viewMode = mode;
button.setAttribute("aria-label", mode === "grid" ? "Als Liste anzeigen" : "Als Kacheln anzeigen");
button.title = button.getAttribute("aria-label");
};
button.addEventListener("click", () => {
const preferences = readViewPreferences();
const current = preferences[key] && preferences[key] !== "inherit" ? preferences[key] : preferences.default;
const next = current === "grid" ? "list" : "grid";
preferences[key] = next;
localStorage.setItem(viewPreferenceKey, JSON.stringify(preferences));
root.querySelectorAll(`[data-view-key="${key}"]`).forEach((collection) => {
collection.dataset.viewMode = next;
if (collection.hasAttribute("data-view-variants")) collection.querySelectorAll(":scope > [data-view-variant]").forEach((variant) => { variant.hidden = variant.dataset.viewVariant !== next; });
});
update();
});
update();
});
}
function initializeViewSettings() {
const form = document.querySelector("[data-view-settings]");
if (!form) return;
const preferences = readViewPreferences();
Array.from(form.elements).forEach((field) => {
if (!(field instanceof HTMLSelectElement)) return;
if (field.name === "entriesPerPage") field.value = preferences.entriesPerPage || preferences.tasksPerPage || "20";
else field.value = preferences[field.name] || (field.name === "default" ? "grid" : "inherit");
});
form.addEventListener("submit", (event) => {
event.preventDefault();
const next = {};
new FormData(form).forEach((value, key) => { next[key] = value; });
localStorage.setItem(viewPreferenceKey, JSON.stringify(next));
const userSuffix = document.body.dataset.userId ? `.${document.body.dataset.userId}` : "";
document.cookie = `gardomatic.entries-per-page${userSuffix}=${encodeURIComponent(next.entriesPerPage || "20")}; Path=/; Max-Age=31536000; SameSite=Lax`;
form.querySelector("[data-settings-message]").textContent = "Einstellungen gespeichert.";
});
}
function initializeSearchTabs() {
const tabs = Array.from(document.querySelectorAll("[data-search-tab]"));
if (!tabs.length) return;
const sections = tabs.map((tab) => document.getElementById(tab.dataset.searchTab)).filter(Boolean);
const select = (id) => {
tabs.forEach((tab) => {
const selected = tab.dataset.searchTab === id;
tab.setAttribute("aria-current", selected ? "page" : "false");
});
sections.forEach((section) => { section.hidden = section.id !== id; });
};
tabs.forEach((tab) => tab.addEventListener("click", () => select(tab.dataset.searchTab)));
const initial = window.location.hash.slice(1);
select(sections.some((section) => section.id === initial) ? initial : "search-all");
}
initializeCollections();
initializeViewToggles();
initializeViewSettings();
initializeSearchTabs();
document.querySelector("[data-history-back]")?.addEventListener("click", () => window.history.back());
function initializePlantCompatibility(root = document) {
const species = root.querySelector("[data-plant-species]");
const warning = root.querySelector("[data-compatibility-warning]");
if (!species || !warning) return;
const check = () => {
const expected = species.selectedOptions[0]?.dataset || {};
const problems = [];
root.querySelectorAll("[data-assignment-row] select[name='location_id']").forEach((select) => {
const actual = root.querySelector(`[data-location-id="${select.value}"]`)?.dataset || {};
const name = select.selectedOptions[0]?.textContent?.trim();
const fields=[];
if(expected.sun && actual.sun && expected.sun!==actual.sun) fields.push("Lichtverhältnis");
if(expected.soil && actual.soil && expected.soil!==actual.soil) fields.push("Bodenbeschaffenheit");
if(expected.reaction && actual.reaction && expected.reaction!==actual.reaction) fields.push("Bodenreaktion");
if(fields.length) problems.push(`${name}: ${fields.join(" und ")}`);
});
warning.hidden=problems.length===0;
warning.textContent=problems.length?`Hinweis: Standort und Art unterscheiden sich bei ${problems.join("; ")}.`:"";
};
root.addEventListener("change",(event)=>{if(event.target===species||event.target.matches("select[name='location_id']"))check()});
check();
}
initializePlantCompatibility();
function initializeJournalEditor() {
const form = document.querySelector("[data-journal-editor]");
const textarea = form?.querySelector("[data-journal-body]");
const mount = document.querySelector("#journal-editor");
if (!form || !textarea || !mount) return;
const embeddedInput = form.querySelector("[data-embedded-images]");
const embeddedFiles = new DataTransfer();
const richTextEnabled = readViewPreferences().journalEditor !== "plain" && window.toastui?.Editor;
if (richTextEnabled) {
const editor = new toastui.Editor({
el: mount,
height: "480px",
minHeight: "300px",
initialEditType: "wysiwyg",
previewStyle: "vertical",
initialValue: textarea.value,
language: "de-DE",
usageStatistics: false,
placeholder: form.dataset.entryKind === "pinboard" ? "Idee oder Notiz festhalten …" : "Was ist heute im Garten passiert?",
hooks: {
addImageBlobHook(blob, callback) {
const extension = (blob.type.split("/")[1] || "jpg").replace("jpeg", "jpg");
const file = blob instanceof File ? blob : new File([blob], `tagebuchbild-${Date.now()}.${extension}`, { type: blob.type || "image/jpeg" });
const index = embeddedFiles.files.length;
embeddedFiles.items.add(file);
embeddedInput.files = embeddedFiles.files;
callback(`/pending-journal-image/${index}`, file.name);
},
},
});
textarea.hidden = true;
form.addEventListener("submit", () => { textarea.value = editor.getMarkdown(); });
} else {
mount.hidden = true;
textarea.hidden = false;
textarea.placeholder = form.dataset.entryKind === "pinboard" ? "Idee oder Notiz festhalten …" : "Was ist heute im Garten passiert?";
}
const fileInputs = Array.from(form.querySelectorAll("[data-journal-files]"));
const generatedInput = form.querySelector("[data-journal-generated-files]");
const preview = form.querySelector("[data-attachment-preview]");
const libraryImages = Array.from(form.querySelectorAll("[data-journal-library-image]"));
let chosenFiles = new DataTransfer();
const chosenEntries = [];
const rebuildChosenFiles = () => {
chosenFiles = new DataTransfer();
chosenEntries.forEach((entry) => chosenFiles.items.add(entry.file));
generatedInput.files = chosenFiles.files;
};
const addChosenFile = (file, photoId) => { chosenEntries.push({ file, photoId }); rebuildChosenFiles(); renderFiles(); };
const replaceChosenFile = (file, photoId) => {
const entry = chosenEntries.find((value) => value.photoId === photoId);
if (!entry) return addChosenFile(file, photoId);
entry.file = file;
rebuildChosenFiles();
renderFiles();
};
const renderFiles = () => {
const nativeFiles = fileInputs.flatMap((input) => Array.from(input.files || []));
const uploaded = [...nativeFiles, ...Array.from(chosenFiles.files)].map((file) => {
const item = document.createElement("li");
item.textContent = `${file.name} · ${(file.size / 1024 / 1024).toFixed(1)} MB`;
return item;
});
const fromLibrary = libraryImages.filter((input) => input.checked).map((input) => {
const item = document.createElement("li");
const size = Number(input.dataset.imageSize || 0);
item.textContent = `${input.dataset.imageName || "Bild"} · ${(size / 1024 / 1024).toFixed(1)} MB · Bilderdatenbank`;
return item;
});
preview.replaceChildren(...uploaded,...fromLibrary);
};
fileInputs.forEach((input) => input.addEventListener("change", renderFiles));
libraryImages.forEach((input) => input.addEventListener("change", renderFiles));
renderFiles();
form.addEventListener("journal-photo-ready", (event) => {
const data = event.detail?.data;
if (!data?.startsWith("data:")) return;
const [header, encoded] = data.split(",", 2);
const mime = header.match(/data:([^;]+)/)?.[1] || "image/jpeg";
const binary = atob(encoded || "");
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
const file = new File([bytes], `tagebuchfoto-${Date.now()}.jpg`, { type: mime });
if (event.detail?.photoId) replaceChosenFile(file, event.detail.photoId);
else addChosenFile(file);
});
const recordButton = form.querySelector("[data-audio-record]");
const audioDialog = document.querySelector("[data-audio-dialog]");
const audioToggle = document.querySelector("[data-audio-toggle]");
const audioPause = document.querySelector("[data-audio-pause]");
const audioApply = document.querySelector("[data-audio-apply]");
const audioPreview = document.querySelector("[data-audio-preview]");
const audioCancel = document.querySelector("[data-audio-cancel]");
const audioStatus = document.querySelector("[data-audio-status]");
const audioError = document.querySelector("[data-audio-error]");
const recordingIndicator = document.querySelector("[data-audio-indicator]");
let recorder;
let recordingStream;
let chunks = [];
let cancelAudio = false;
let pendingAudio;
let audioPreviewURL;
const stopAudioStream = () => { recordingStream?.getTracks().forEach((track) => track.stop()); recordingStream = undefined; };
const clearAudioPreview = () => {
if (audioPreviewURL) URL.revokeObjectURL(audioPreviewURL);
audioPreviewURL = undefined;
pendingAudio = undefined;
audioPreview.removeAttribute("src");
audioPreview.load();
audioPreview.hidden = true;
audioApply.hidden = true;
audioPause.textContent = "Pause";
audioPause.hidden = true;
recordingIndicator.hidden = true;
};
recordButton?.addEventListener("click", () => {
clearAudioPreview();
audioError.textContent = "";
audioStatus.textContent = "Bereit zur Aufnahme.";
audioToggle.textContent = "Aufnahme starten";
audioToggle.hidden = false;
audioPause.hidden = true;
audioDialog?.showModal();
});
audioToggle?.addEventListener("click", async () => {
if (recorder?.state === "recording" || recorder?.state === "paused") { recorder.stop(); return; }
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
audioError.textContent = "Audioaufnahme ist in diesem Browser oder ohne HTTPS nicht verfügbar.";
return;
}
try {
recordingStream = await navigator.mediaDevices.getUserMedia({ audio: true });
recorder = new MediaRecorder(recordingStream);
chunks = [];
recorder.addEventListener("dataavailable", (event) => { if (event.data.size) chunks.push(event.data); });
recorder.addEventListener("stop", () => {
if (cancelAudio) { cancelAudio = false; chunks = []; stopAudioStream(); return; }
const type = recorder.mimeType || "audio/webm";
pendingAudio = new File(chunks, `audio-${new Date().toISOString().replace(/[:.]/g, "-")}.webm`, { type });
audioPreviewURL = URL.createObjectURL(pendingAudio);
audioPreview.src = audioPreviewURL;
audioPreview.hidden = false;
stopAudioStream();
audioToggle.hidden = true;
audioPause.hidden = true;
recordingIndicator.hidden = true;
audioApply.hidden = false;
audioStatus.textContent = "Aufnahme bereit. Bitte prüfen und übernehmen.";
});
recorder.start();
audioToggle.textContent = "Aufnahme stoppen";
audioPause.hidden = false;
recordingIndicator.hidden = false;
audioStatus.textContent = "Aufnahme läuft …";
} catch (_) {
audioError.textContent = "Das Mikrofon konnte nicht geöffnet werden. Bitte prüfe die Freigabe.";
}
});
audioPause?.addEventListener("click", () => {
if (!recorder) return;
if (recorder.state === "recording") { recorder.pause(); audioPause.textContent = "Fortsetzen"; recordingIndicator.hidden = true; audioStatus.textContent = "Aufnahme pausiert."; }
else if (recorder.state === "paused") { recorder.resume(); audioPause.textContent = "Pause"; recordingIndicator.hidden = false; audioStatus.textContent = "Aufnahme läuft …"; }
});
audioApply?.addEventListener("click", () => {
if (!pendingAudio) return;
addChosenFile(pendingAudio);
clearAudioPreview();
audioDialog?.close();
});
audioCancel?.addEventListener("click", () => { if (recorder?.state === "recording" || recorder?.state === "paused") { cancelAudio = true; recorder.stop(); } else stopAudioStream(); clearAudioPreview(); audioDialog?.close(); });
audioDialog?.addEventListener("close", () => { if (recorder?.state === "recording" || recorder?.state === "paused") { cancelAudio = true; recorder.stop(); } stopAudioStream(); clearAudioPreview(); });
const videoButton = form.querySelector("[data-video-record]");
const videoDialog = document.querySelector("[data-video-dialog]");
const videoPreview = document.querySelector("[data-video-preview]");
const videoToggle = document.querySelector("[data-video-toggle]");
const videoPause = document.querySelector("[data-video-pause]");
const videoApply = document.querySelector("[data-video-apply]");
const videoCancel = document.querySelector("[data-video-cancel]");
const videoSwitchCamera = document.querySelector("[data-video-switch-camera]");
const videoError = document.querySelector("[data-video-error]");
let videoStream;
let videoRecorder;
let videoChunks = [];
let cancelVideo = false;
let pendingVideo;
let videoPreviewURL;
let videoFacingMode = "environment";
const stopVideoStream = () => { videoStream?.getTracks().forEach((track) => track.stop()); videoStream = undefined; videoPreview.srcObject = null; };
const startVideoStream = async () => {
stopVideoStream();
videoStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: videoFacingMode } }, audio: true });
videoPreview.srcObject = videoStream;
await videoPreview.play();
const devices = await navigator.mediaDevices.enumerateDevices?.();
if (videoSwitchCamera) videoSwitchCamera.hidden = devices ? devices.filter((device) => device.kind === "videoinput").length < 2 : false;
};
const clearVideoPreview = () => {
videoPreview.pause();
if (videoPreviewURL) URL.revokeObjectURL(videoPreviewURL);
videoPreviewURL = undefined;
pendingVideo = undefined;
videoPreview.removeAttribute("src");
videoPreview.load();
videoPreview.controls = false;
videoPreview.muted = true;
videoApply.hidden = true;
videoPause.textContent = "Pause";
videoPause.hidden = true;
};
videoButton?.addEventListener("click", async () => {
clearVideoPreview();
videoToggle.hidden = false;
videoPause.hidden = true;
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) { videoError.textContent = "Videoaufnahme ist in diesem Browser oder ohne HTTPS nicht verfügbar."; videoDialog.showModal(); return; }
try {
await startVideoStream();
videoError.textContent = "";
videoToggle.textContent = "Aufnahme starten";
videoDialog.showModal();
} catch (_) { videoError.textContent = "Kamera und Mikrofon konnten nicht geöffnet werden. Bitte prüfe die Freigaben."; videoDialog.showModal(); }
});
videoSwitchCamera?.addEventListener("click", async () => {
videoFacingMode = videoFacingMode === "environment" ? "user" : "environment";
videoError.textContent = "Kamera wird gewechselt …";
try {
await startVideoStream();
videoError.textContent = "";
} catch (_) {
videoError.textContent = "Die andere Kamera konnte nicht geöffnet werden.";
}
});
videoToggle?.addEventListener("click", () => {
if (videoRecorder?.state === "recording" || videoRecorder?.state === "paused") { videoRecorder.stop(); return; }
if (!videoStream) return;
videoChunks = [];
videoRecorder = new MediaRecorder(videoStream);
if (videoSwitchCamera) videoSwitchCamera.hidden = true;
videoRecorder.addEventListener("dataavailable", (event) => { if (event.data.size) videoChunks.push(event.data); });
videoRecorder.addEventListener("stop", () => {
if (cancelVideo) { cancelVideo = false; videoChunks = []; stopVideoStream(); return; }
const type = videoRecorder.mimeType || "video/webm";
pendingVideo = new File(videoChunks, `video-${new Date().toISOString().replace(/[:.]/g, "-")}.webm`, { type });
stopVideoStream();
videoPreviewURL = URL.createObjectURL(pendingVideo);
videoPreview.src = videoPreviewURL;
videoPreview.controls = true;
videoPreview.muted = false;
videoToggle.hidden = true;
videoPause.hidden = true;
videoApply.hidden = false;
videoError.textContent = "Aufnahme bereit. Bitte prüfen und übernehmen.";
});
videoRecorder.start();
videoToggle.textContent = "Aufnahme stoppen";
videoPause.hidden = false;
});
videoPause?.addEventListener("click", () => {
if (!videoRecorder) return;
if (videoRecorder.state === "recording") { videoRecorder.pause(); videoPause.textContent = "Fortsetzen"; videoError.textContent = "Aufnahme pausiert."; }
else if (videoRecorder.state === "paused") { videoRecorder.resume(); videoPause.textContent = "Pause"; videoError.textContent = "Aufnahme läuft …"; }
});
videoApply?.addEventListener("click", () => {
if (!pendingVideo) return;
addChosenFile(pendingVideo);
clearVideoPreview();
videoDialog.close();
});
videoCancel?.addEventListener("click", () => { if (videoRecorder?.state === "recording" || videoRecorder?.state === "paused") { cancelVideo = true; videoRecorder.stop(); } else stopVideoStream(); clearVideoPreview(); videoDialog.close(); });
videoDialog?.addEventListener("close", () => { if (videoRecorder?.state === "recording" || videoRecorder?.state === "paused") { cancelVideo = true; videoRecorder.stop(); } stopVideoStream(); clearVideoPreview(); });
}
initializeJournalEditor();
function initializeTagEditors(root = document) {
root.querySelectorAll("[data-tag-editor]").forEach((editor) => {
if (editor.dataset.tagInitialized) return;
const input = editor.querySelector("[data-tag-input]");
const bubbles = editor.querySelector("[data-tag-bubbles]");
const suggestions = editor.querySelector("[data-tag-suggestions]");
if (!input || !bubbles || !suggestions) return;
let tags = input.value.split(",").map((tag) => tag.trim().toLowerCase()).filter(Boolean);
tags = [...new Set(tags)];
const render = () => {
bubbles.replaceChildren(...tags.map((tag) => {
const bubble = document.createElement("span");
bubble.className = "tag-bubble";
bubble.textContent = tag;
const remove = document.createElement("button");
remove.type = "button";
remove.className = "tag-bubble-remove";
remove.textContent = "×";
remove.setAttribute("aria-label", `${tag} entfernen`);
remove.addEventListener("click", () => { tags = tags.filter((value) => value !== tag); render(); });
bubble.append(remove);
return bubble;
}));
updateSuggestions();
};
const updateSuggestions = () => {
const prefix = input.value.trim().toLowerCase();
let visible = 0;
suggestions.querySelectorAll("[data-tag-suggestion]").forEach((button) => {
const value = button.dataset.tagSuggestion.toLowerCase();
const show = prefix.length > 0 && value.startsWith(prefix) && !tags.includes(value);
button.hidden = !show;
visible += show ? 1 : 0;
});
suggestions.hidden = visible === 0;
};
const add = (value) => {
value = value.trim().toLowerCase();
if (value && !tags.includes(value)) tags.push(value);
input.value = "";
render();
};
input.addEventListener("input", () => {
if (input.value.includes(",")) {
const values = input.value.split(",");
values.slice(0, -1).forEach(add);
input.value = values.at(-1).trim();
}
updateSuggestions();
});
input.addEventListener("keydown", (event) => {
if ((event.key === "Enter" || event.key === ",") && input.value.trim()) { event.preventDefault(); add(input.value); }
if (event.key === "Backspace" && !input.value && tags.length) { tags.pop(); render(); }
});
suggestions.querySelectorAll("[data-tag-suggestion]").forEach((button) => button.addEventListener("click", () => add(button.dataset.tagSuggestion)));
editor.closest("form")?.addEventListener("submit", () => { if (input.value.trim()) add(input.value); input.value = tags.join(", "); });
render();
editor.dataset.tagInitialized = "true";
});
}
initializeTagEditors();
document.addEventListener("click", (event) => {
document.querySelectorAll(".user-menu[open]").forEach((menu) => {
if (!menu.contains(event.target)) {
menu.removeAttribute("open");
}
});
if (event.target.closest("[data-close-dialog]")) {
event.target.closest("dialog")?.remove();
}
const dialogOpener = event.target.closest("[data-open-dialog]");
if (dialogOpener) {
const dialog = document.getElementById(dialogOpener.dataset.openDialog);
if (dialog instanceof HTMLDialogElement && !dialog.open) dialog.showModal();
}
if (event.target.closest("[data-cancel-dialog]")) {
event.target.closest("dialog")?.close();
}
const card = event.target.closest("[data-card-href]");
if (card && !event.target.closest("a, button, input, select, textarea, form, summary, details, video, audio, img")) {
window.location.assign(card.dataset.cardHref);
}
const addAssignment = event.target.closest("[data-add-assignment]");
if (addAssignment) {
const template = document.querySelector("#assignment-row-template");
const list = document.querySelector("[data-assignment-list]");
if (template && list) {
list.append(template.content.cloneNode(true));
refreshAssignmentFields(list);
list.lastElementChild?.querySelector("select")?.focus();
}
}
const removeAssignment = event.target.closest("[data-remove-assignment]");
if (removeAssignment) {
const row = removeAssignment.closest("[data-assignment-row]");
const assignmentID = row?.querySelector("[name='assignment_id']");
if (row && assignmentID?.value !== "0") {
row.querySelector("[name='location_id']").value = "0";
row.querySelector("[name='quantity']").value = "1";
row.hidden = true;
} else {
row?.remove();
}
refreshAssignmentFields(document.querySelector("[data-assignment-list]"));
}
const removePlantTask = event.target.closest("[data-remove-plant-task]");
if (removePlantTask) {
const row = removePlantTask.closest("[data-plant-task-row]");
const taskID = row?.querySelector("[name='plant_task_id']");
if (row && taskID?.value !== "0") {
row.querySelector("[data-plant-task-delete]").value = "true";
row.hidden = true;
row.querySelectorAll("[required]").forEach((field) => field.required = false);
} else {
row?.remove();
}
}
});
function initializePlantName() {
const name = document.querySelector("[data-plant-name]");
const species = document.querySelector("[data-plant-species]");
if (!name || !species || name.dataset.nameInitialized) return;
const selectedName = species.selectedOptions[0]?.dataset.plantNameValue || "";
let manuallyEdited = name.value !== "" && name.value !== selectedName;
if (!name.value && selectedName) name.value = selectedName;
name.addEventListener("input", () => { manuallyEdited = true; });
species.addEventListener("change", () => {
if (manuallyEdited) return;
name.value = species.selectedOptions[0]?.dataset.plantNameValue || "";
});
name.dataset.nameInitialized = "true";
}
initializePlantName();
function initializeImageEditors(root = document) {
root.querySelectorAll("[data-image-editor]").forEach((editor) => {
if (editor.dataset.imageInitialized) return;
const value = editor.querySelector("[data-image-value]");
const imageID = editor.querySelector("[data-image-id]");
const preview = editor.querySelector("[data-image-preview]");
const placeholder = preview?.querySelector("span");
const dialog = editor.querySelector("[data-image-dialog]");
const image = editor.querySelector("[data-cropper-image]");
const remove = editor.querySelector("[data-image-remove]");
const apply = editor.querySelector("[data-image-apply]");
const error = editor.querySelector("[data-image-error]");
const cameraDialog = editor.querySelector("[data-camera-dialog]");
const cameraVideo = editor.querySelector("[data-camera-video]");
const cameraCapture = editor.querySelector("[data-camera-capture]");
const cameraSwitch = editor.querySelector("[data-camera-switch]");
const cameraError = editor.querySelector("[data-camera-error]");
let cropper;
let cameraStream;
let cameraFacingMode = "environment";
if (value.value) {
if (preview) preview.style.backgroundImage = `url("${value.value}")`;
if (placeholder) placeholder.hidden = true;
if (remove) remove.hidden = false;
}
const openCropper = (source) => {
cropper?.destroy();
cropper = undefined;
apply.disabled = true;
error.textContent = "Bild wird geladen …";
image.onload = () => {
cropper = new Cropper(image, {
aspectRatio: 3 / 2,
viewMode: 1,
autoCropArea: 1,
background: false,
ready() {
apply.disabled = false;
error.textContent = "";
},
});
};
image.onerror = () => { error.textContent = "Das Bild konnte nicht geladen werden."; };
image.src = String(source);
dialog.showModal();
};
const choose = (event) => {
const file = event.target.files?.[0];
if (!file || !file.type.startsWith("image/")) return;
const reader = new FileReader();
reader.onload = () => openCropper(reader.result);
reader.readAsDataURL(file);
event.target.value = "";
};
editor.querySelector("[data-image-file]")?.addEventListener("change", choose);
const stopCamera = () => {
cameraStream?.getTracks().forEach((track) => track.stop());
cameraStream = undefined;
cameraVideo.srcObject = null;
cameraCapture.disabled = true;
};
const startCamera = async () => {
stopCamera();
cameraStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: cameraFacingMode } }, audio: false });
cameraVideo.srcObject = cameraStream;
await cameraVideo.play();
cameraCapture.disabled = cameraVideo.videoWidth === 0;
cameraVideo.onloadedmetadata = () => { cameraCapture.disabled = false; };
const devices = await navigator.mediaDevices.enumerateDevices?.();
if (cameraSwitch) cameraSwitch.hidden = devices ? devices.filter((device) => device.kind === "videoinput").length < 2 : false;
};
editor.querySelector("[data-image-camera]")?.addEventListener("click", async () => {
cameraError.textContent = "Webcam wird gestartet …";
cameraCapture.disabled = true;
cameraDialog.showModal();
if (!navigator.mediaDevices?.getUserMedia) {
cameraError.textContent = "Die Webcam ist in diesem Browser oder ohne HTTPS nicht verfügbar.";
return;
}
try {
await startCamera();
cameraError.textContent = "";
} catch (_) {
cameraError.textContent = "Die Webcam konnte nicht geöffnet werden. Bitte prüfe die Kamerafreigabe des Browsers.";
stopCamera();
}
});
cameraSwitch?.addEventListener("click", async () => {
cameraFacingMode = cameraFacingMode === "environment" ? "user" : "environment";
cameraError.textContent = "Kamera wird gewechselt …";
cameraCapture.disabled = true;
try {
await startCamera();
cameraError.textContent = "";
} catch (_) {
cameraError.textContent = "Die andere Kamera konnte nicht geöffnet werden.";
stopCamera();
}
});
editor.querySelector("[data-camera-cancel]")?.addEventListener("click", () => {
stopCamera();
cameraDialog.close();
});
cameraDialog?.addEventListener("close", stopCamera);
cameraCapture?.addEventListener("click", () => {
if (!cameraVideo.videoWidth || !cameraVideo.videoHeight) return;
const canvas = document.createElement("canvas");
canvas.width = cameraVideo.videoWidth;
canvas.height = cameraVideo.videoHeight;
canvas.getContext("2d").drawImage(cameraVideo, 0, 0);
const source = canvas.toDataURL("image/jpeg", 0.9);
stopCamera();
cameraDialog.close();
if (editor.hasAttribute("data-journal-photo-editor")) editor.dataset.photoId = `photo-${Date.now()}-${Math.random().toString(36).slice(2)}`;
openCropper(source);
});
editor.querySelector("[data-image-rotate-left]")?.addEventListener("click", () => cropper?.rotate(-90));
editor.querySelector("[data-image-rotate-right]")?.addEventListener("click", () => cropper?.rotate(90));
editor.querySelector("[data-image-cancel]")?.addEventListener("click", () => { dialog.close(); cropper?.destroy(); cropper = undefined; image.onload = null; });
apply?.addEventListener("click", () => {
if (!cropper) return;
const canvas = cropper.getCroppedCanvas({ width: 960, height: 640, imageSmoothingQuality: "high" });
if (!canvas) {
error.textContent = "Das Bild ist noch nicht bereit. Bitte versuche es gleich noch einmal.";
return;
}
const data = canvas.toDataURL("image/jpeg", 0.82);
value.value = data;
if (imageID) imageID.value = "";
if (editor.hasAttribute("data-journal-photo-editor")) editor.closest("form")?.dispatchEvent(new CustomEvent("journal-photo-ready", { detail: { data, photoId: editor.dataset.photoId } }));
if (preview) preview.style.backgroundImage = `url("${data}")`;
if (placeholder) placeholder.hidden = true;
if (remove) remove.hidden = false;
dialog.close();
cropper.destroy();
cropper = undefined;
});
remove?.addEventListener("click", () => {
value.value = "";
preview.style.backgroundImage = "";
placeholder.hidden = false;
remove.hidden = true;
if (imageID) imageID.value = "";
});
const libraryDialog = editor.querySelector("[data-image-library-dialog]");
editor.querySelector("[data-image-library-open]")?.addEventListener("click", () => libraryDialog?.showModal());
editor.querySelector("[data-image-library-cancel]")?.addEventListener("click", () => libraryDialog?.close());
editor.querySelectorAll("[data-image-library-select]").forEach((button) => button.addEventListener("click", () => {
value.value = "";
if (imageID) imageID.value = button.dataset.imageId || "";
if (preview) preview.style.backgroundImage = `url("${button.dataset.imageUrl}")`;
if (placeholder) placeholder.hidden = true;
if (remove) remove.hidden = false;
libraryDialog?.close();
}));
editor.dataset.imageInitialized = "true";
});
}
initializeImageEditors();
document.querySelector("[data-species-template]")?.addEventListener("change", (event) => {
const url = new URL(window.location.href);
if (event.target.value === "0") url.searchParams.delete("template_id");
else url.searchParams.set("template_id", event.target.value);
window.location.assign(url);
});
function initializeTaskTemplateTriggers(root = document) {
root.querySelectorAll("[data-template-trigger]").forEach((trigger) => {
if (trigger.dataset.triggerInitialized) return;
const update = () => {
const form = trigger.closest("form");
form?.querySelectorAll("[data-trigger-fields]").forEach((group) => {
const visible = group.dataset.triggerFields === trigger.value || (group.dataset.triggerFields === "relative" && trigger.value !== "month_of_year");
group.hidden = !visible;
group.disabled = !visible;
});
};
trigger.addEventListener("change", update);
trigger.dataset.triggerInitialized = "true";
update();
});
}
initializeTaskTemplateTriggers();
function initializeRoleEditors(root = document) {
root.querySelectorAll("[data-role-editor]").forEach((editor) => {
if (editor.dataset.roleEditorInitialized) return;
const select = editor.querySelector("[data-role-select]");
const update = () => editor.querySelectorAll("[data-role-pane]").forEach((pane) => {
pane.hidden = pane.dataset.rolePane !== select.value;
});
select?.addEventListener("change", update);
editor.dataset.roleEditorInitialized = "true";
update();
});
}
initializeRoleEditors();
document.addEventListener("change", (event) => {
const control = event.target.closest("[data-toggle-control]");
if (!control) return;
const row = control.closest(".plant-task-row, .plant-template-task-row");
const value = row?.querySelector("[data-toggle-value]");
if (value) value.value = control.checked ? "true" : "false";
});
function refreshAssignmentFields(list) {
list?.querySelectorAll("[data-assignment-row]").forEach((row, index) => {
const select = row.querySelector("[name='location_id']");
const quantity = row.querySelector("[name='quantity']");
const locationLabel = select?.previousElementSibling;
const quantityLabel = quantity?.previousElementSibling;
if (select) select.id = index === 0 ? "location_id" : `location_id_${index}`;
if (quantity) quantity.id = `quantity_${index}`;
if (locationLabel) locationLabel.htmlFor = select.id;
if (quantityLabel) quantityLabel.htmlFor = quantity.id;
});
}
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
document.querySelectorAll(".user-menu[open]").forEach((menu) => {
menu.removeAttribute("open");
menu.querySelector("summary")?.focus();
});
}
const card = event.target.closest("[data-card-href]");
if (card && !event.target.closest("a, button, input, select, textarea, form, summary, details") && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
window.location.assign(card.dataset.cardHref);
}
});
document.addEventListener("locationCreated", () => {
document.querySelector("#location-dialog-host dialog")?.remove();
});
document.addEventListener("plantTaskSaved", () => {
document.querySelector("#plant-task-dialog-host dialog")?.remove();
});
document.addEventListener("htmx:afterSwap", (event) => {
initializeTaskTemplateTriggers(event.detail.target || document);
initializeImageEditors(event.detail.target || document);
initializeCollections(event.detail.target || document);
initializeTagEditors(event.detail.target || document);
initializeViewToggles(event.detail.target || document);
initializeRoleEditors(event.detail.target || document);
const dialog = event.detail.target?.querySelector(".location-dialog, .task-template-dialog, .plant-task-dialog");
if (dialog && !dialog.open) {
dialog.showModal();
}
});
document.addEventListener("htmx:afterRequest", (event) => {
if (!event.detail.successful) return;
const trigger = event.detail.elt;
const addSection = trigger?.closest(".care-instruction-add");
if (addSection) addSection.querySelector("form")?.reset();
});
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
/*!
* TOAST UI Editor : i18n
* @version 3.2.2
* @author NHN Cloud FE Development Lab <dl_javascript@nhn.com>
* @license MIT
*/
!function(e,n){if("object"==typeof exports&&"object"==typeof module)module.exports=n(require("@toast-ui/editor"));else if("function"==typeof define&&define.amd)define(["@toast-ui/editor"],n);else{var t="object"==typeof exports?n(require("@toast-ui/editor")):n(e.toastui.Editor);for(var o in t)("object"==typeof exports?exports:e)[o]=t[o]}}(self,(function(e){return function(){"use strict";var n={213:function(n){n.exports=e}},t={};function o(e){var r=t[e];if(void 0!==r)return r.exports;var i=t[e]={exports:{}};return n[e](i,i.exports,o),i.exports}o.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return o.d(n,{a:n}),n},o.d=function(e,n){for(var t in n)o.o(n,t)&&!o.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:n[t]})},o.o=function(e,n){return Object.prototype.hasOwnProperty.call(e,n)},o.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})};var r={};return function(){o.r(r);var e=o(213);o.n(e)().setLanguage(["de","de-DE"],{Markdown:"Markdown",WYSIWYG:"WYSIWYG",Write:"Verfassen",Preview:"Vorschau",Headings:"Überschriften",Paragraph:"Text",Bold:"Fett",Italic:"Kursiv",Strike:"Durchgestrichen",Code:"Code",Line:"Trennlinie",Blockquote:"Blocktext","Unordered list":"Aufzählung","Ordered list":"Nummerierte Aufzählung",Task:"Aufgabe",Indent:"Einrücken",Outdent:"Ausrücken","Insert link":"Link einfügen","Insert CodeBlock":"Codeblock einfügen","Insert table":"Tabelle einfügen","Insert image":"Grafik einfügen",Heading:"Titel","Image URL":"Bild URL","Select image file":"Grafik auswählen","Choose a file":"Wähle eine Datei","No file":"Keine Datei",Description:"Beschreibung",OK:"OK",More:"Mehr",Cancel:"Abbrechen",File:"Datei",URL:"URL","Link text":"Anzuzeigender Text","Add row to up":"Zeile nach oben hinzufügen","Add row to down":"Zeile nach unten hinzufügen","Add column to left":"Spalte links hinzufügen","Add column to right":"Spalte rechts hinzufügen","Remove row":"Zeile entfernen","Remove column":"Spalte entfernen","Align column to left":"Links ausrichten","Align column to center":"Zentrieren","Align column to right":"Rechts ausrichten","Remove table":"Tabelle entfernen","Would you like to paste as table?":"Möchten Sie eine Tabelle einfügen?","Text color":"Textfarbe","Auto scroll enabled":"Autoscrollen aktiviert","Auto scroll disabled":"Autoscrollen deaktiviert","Choose language":"Sprache auswählen"})}(),r}()}));
+24
View File
@@ -0,0 +1,24 @@
{
"name": "Gardomatic",
"short_name": "Gardomatic",
"description": "Gärten, Pflanzen, Orte und Aufgaben verwalten.",
"start_url": "/gardens",
"scope": "/",
"display": "standalone",
"background_color": "#f4f1e8",
"theme_color": "#315d3a",
"icons": [
{
"src": "/static/icons/gardomatic-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/gardomatic-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Offline · Gardomatic</title>
</head>
<body>
<main>
<h1>Gardomatic ist gerade offline</h1>
<p>Bitte stelle die Netzwerkverbindung wieder her und versuche es erneut.</p>
</main>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
const CACHE_NAME = "gardomatic-shell-v9";
const APP_SHELL = [
"/static/css/main.css",
"/static/css/cropper-1.6.2.min.css",
"/static/js/htmx-2.0.10.min.js",
"/static/js/main.js",
"/static/js/cropper-1.6.2.min.js",
"/static/manifest.manifest",
"/static/icons/gardomatic-192.png",
"/static/icons/gardomatic-512.png",
"/static/offline.html",
];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)));
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((names) => Promise.all(names.filter((name) => name !== CACHE_NAME).map((name) => caches.delete(name)))),
);
});
self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET" || new URL(event.request.url).origin !== self.location.origin) {
return;
}
event.respondWith(fetch(event.request).catch(() => caches.match(event.request).then((response) => response || caches.match("/static/offline.html"))));
});
+232
View File
@@ -0,0 +1,232 @@
package web
import (
"errors"
"net/http"
"strconv"
"strings"
"gardomatic.kleiax.de/lib/client"
)
type taskTemplateForm struct {
Origin string `form:"origin"`
Title string `form:"title"`
Description string `form:"description"`
TriggerType string `form:"trigger_type"`
MonthFrom int `form:"month_from"`
DayFrom int `form:"day_from"`
MonthTo int `form:"month_to"`
DayTo int `form:"day_to"`
OffsetDaysFrom int `form:"offset_days_from"`
OffsetDaysTo int `form:"offset_days_to"`
IntervalDays int `form:"interval_days"`
TriggerOffset int `form:"trigger_offset"`
TriggerOffsetUnit string `form:"trigger_offset_unit"`
Duration int `form:"duration"`
DurationUnit string `form:"duration_unit"`
Recurrence string `form:"recurrence"`
RecurrenceInterval int `form:"recurrence_interval"`
Priority int `form:"priority"`
Active bool `form:"active"`
Errors map[string]string
}
func (app *application) taskTemplateCreate(w http.ResponseWriter, r *http.Request) {
speciesID, _ := strconv.Atoi(r.URL.Query().Get("species_id"))
if speciesID < 1 {
app.notFound(w)
return
}
app.renderTaskTemplateForm(w, r, taskTemplateForm{TriggerType: "month_of_year", DayFrom: 1, TriggerOffsetUnit: "day", DurationUnit: "day", RecurrenceInterval: 1, Active: true, Errors: map[string]string{}}, http.StatusOK, speciesID, 0)
}
func (app *application) taskTemplateEdit(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
templateID, err := app.readPathID(r, "templateID")
if err != nil {
app.notFound(w)
return
}
speciesID, _ := strconv.Atoi(r.URL.Query().Get("species_id"))
if speciesID < 1 {
app.notFound(w)
return
}
value, _, err := client.FromContext(r.Context()).SpeciesTaskTemplate(r.Context(), gardenID, speciesID, templateID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
form := taskTemplateForm{Origin: value.Origin, Title: value.Title, Description: value.Description, TriggerType: value.TriggerType, TriggerOffset: value.TriggerOffset, TriggerOffsetUnit: value.TriggerOffsetUnit, Duration: value.Duration, DurationUnit: value.DurationUnit, Recurrence: value.Recurrence, RecurrenceInterval: value.RecurrenceInterval, Priority: value.Priority, Active: value.Active, Errors: map[string]string{}}
if form.TriggerOffsetUnit == "" {
form.TriggerOffsetUnit = "day"
}
if form.DurationUnit == "" {
form.DurationUnit = "day"
}
if form.RecurrenceInterval < 1 {
form.RecurrenceInterval = 1
}
copyOptionalInt(value.MonthFrom, &form.MonthFrom)
copyOptionalInt(value.DayFrom, &form.DayFrom)
copyOptionalInt(value.MonthTo, &form.MonthTo)
copyOptionalInt(value.DayTo, &form.DayTo)
copyOptionalInt(value.OffsetDaysFrom, &form.OffsetDaysFrom)
copyOptionalInt(value.OffsetDaysTo, &form.OffsetDaysTo)
copyOptionalInt(value.IntervalDays, &form.IntervalDays)
app.renderTaskTemplateForm(w, r, form, http.StatusOK, speciesID, templateID)
}
func copyOptionalInt(source *int, destination *int) {
if source != nil {
*destination = *source
}
}
func (app *application) taskTemplateSave(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
speciesID, _ := strconv.Atoi(r.URL.Query().Get("species_id"))
if speciesID < 1 {
app.notFound(w)
return
}
templateID := 0
if id, readErr := app.readPathID(r, "templateID"); readErr == nil {
templateID = id
}
var form taskTemplateForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.Title, form.Description = strings.TrimSpace(form.Title), strings.TrimSpace(form.Description)
form.Errors = map[string]string{}
if form.Title == "" {
form.Errors["title"] = "Ein Name ist erforderlich."
}
if form.TriggerType == "month_of_year" && (form.MonthFrom < 1 || form.DayFrom < 1) {
form.Errors["date"] = "Tag und Monat für „Ab“ sind erforderlich."
}
if form.Duration < 0 || form.TriggerOffset < 0 {
form.Errors["duration"] = "Werte dürfen nicht negativ sein."
}
if form.Recurrence != "" && form.RecurrenceInterval < 1 {
form.Errors["recurrence_interval"] = "Der Faktor muss mindestens 1 sein."
}
input := taskTemplateInput(form)
if len(form.Errors) == 0 {
apiClient := client.FromContext(r.Context())
var saveErr error
if templateID > 0 {
_, _, saveErr = apiClient.UpdateSpeciesTaskTemplate(r.Context(), gardenID, speciesID, templateID, input)
} else {
_, _, saveErr = apiClient.CreateSpeciesTaskTemplate(r.Context(), gardenID, speciesID, input)
}
if saveErr == nil {
returnTo := webPath("species.edit", gardenID, speciesID)
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", returnTo)
w.WriteHeader(http.StatusNoContent)
return
}
http.Redirect(w, r, returnTo, http.StatusSeeOther)
return
}
var apiError *client.APIError
if errors.As(saveErr, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity {
form.Errors = apiError.Validation
} else {
app.handleAPIError(w, r, saveErr)
return
}
}
status := http.StatusUnprocessableEntity
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Retarget", "#task-template-dialog-host")
w.Header().Set("HX-Reswap", "innerHTML")
status = http.StatusOK
}
app.renderTaskTemplateForm(w, r, form, status, speciesID, templateID)
}
func taskTemplateInput(form taskTemplateForm) client.SpeciesTaskTemplateInput {
title, description, trigger := form.Title, form.Description, form.TriggerType
offsetUnit, durationUnit, recurrence := form.TriggerOffsetUnit, form.DurationUnit, form.Recurrence
input := client.SpeciesTaskTemplateInput{Title: &title, Description: &description, TriggerType: &trigger, Duration: &form.Duration, DurationUnit: &durationUnit, Recurrence: &recurrence, RecurrenceInterval: &form.RecurrenceInterval, Priority: &form.Priority, Active: &form.Active, ClearIntervalDays: true}
if form.TriggerType == "month_of_year" {
input.MonthFrom = &form.MonthFrom
if form.DayFrom > 0 {
input.DayFrom = &form.DayFrom
} else {
input.ClearDayFrom = true
}
} else {
input.TriggerOffset = &form.TriggerOffset
input.TriggerOffsetUnit = &offsetUnit
input.ClearDayFrom = true
}
return input
}
func (app *application) taskTemplateDelete(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
templateID, err := app.readPathID(r, "templateID")
if err != nil {
app.notFound(w)
return
}
speciesID, _ := strconv.Atoi(r.URL.Query().Get("species_id"))
if speciesID < 1 {
app.notFound(w)
return
}
if _, err := client.FromContext(r.Context()).DeleteSpeciesTaskTemplate(r.Context(), gardenID, speciesID, templateID); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, pathWithQuery(webPath("species.edit", gardenID, speciesID), "step", "tasks"), http.StatusSeeOther)
}
func (app *application) renderTaskTemplateForm(w http.ResponseWriter, r *http.Request, form taskTemplateForm, status, speciesID, templateID int) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
apiClient := client.FromContext(r.Context())
garden, _, err := apiClient.Garden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
species, _, err := apiClient.Species(r.Context(), gardenID, speciesID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
priorities, _, err := apiClient.TaskPriorities(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
}
data := app.newTemplateData(r)
data.Garden, data.Species, data.TaskPriorities, data.Form, data.SpeciesID, data.TemplateID = &garden, []client.Species{species}, priorities, form, speciesID, templateID
if r.Header.Get("HX-Request") == "true" {
app.renderTemplate(w, status, "task_template_form.tmpl", "task_template_dialog", data)
} else {
app.render(w, status, "task_template_form.tmpl", data)
}
}
+327
View File
@@ -0,0 +1,327 @@
package web
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"gardomatic.kleiax.de/lib/client"
)
type taskForm struct {
CSRFToken string `form:"csrf_token"`
PlantID int `form:"plant_id"`
LocationID int `form:"location_id"`
Title string `form:"title"`
Description string `form:"description"`
DueAtStart string `form:"due_at_start"`
DueAtEnd string `form:"due_at_end"`
Recurrence string `form:"recurrence"`
RecurrenceInterval int `form:"recurrence_interval"`
Priority int `form:"priority"`
Keywords string `form:"keywords"`
PlantStatusOnCompletion string `form:"status_on_completion"`
Errors map[string]string
}
func (app *application) tasks(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
apiClient := client.FromContext(r.Context())
garden, _, err := apiClient.Garden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
tasks, _, err := apiClient.Tasks(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
plants, _, err := apiClient.Plants(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
locations, _, err := apiClient.Locations(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
priorities, _, err := apiClient.TaskPriorities(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
}
taskYears := taskYears(tasks)
filters := collectionFilters(r, "status", "plant", "location", "priority", "month", "year", "sort")
if !r.URL.Query().Has("status") {
filters["status"] = "open"
}
tasks = filterTasks(tasks, filters)
sortTasks(tasks, filters["sort"])
tasks, pagination := paginateCollection(r, tasks)
data := app.newTemplateData(r)
data.Garden, data.Tasks, data.Plants, data.Locations, data.TaskPriorities, data.TaskYears = &garden, tasks, plants, locations, priorities, taskYears
data.Filters, data.Pagination = filters, pagination
app.render(w, http.StatusOK, "tasks.tmpl", data)
}
func filterTasks(tasks []client.Task, filters map[string]string) []client.Task {
result := make([]client.Task, 0, len(tasks))
plantID, _ := strconv.Atoi(filters["plant"])
locationID, _ := strconv.Atoi(filters["location"])
priority, _ := strconv.Atoi(filters["priority"])
month, _ := strconv.Atoi(filters["month"])
year, _ := strconv.Atoi(filters["year"])
for _, task := range tasks {
if task.Active != nil && !*task.Active {
continue
}
if filters["status"] == "open" && task.CompletedAt != nil {
continue
}
if filters["status"] == "done" && task.CompletedAt == nil {
continue
}
if plantID > 0 && (task.PlantID == nil || *task.PlantID != plantID) {
continue
}
if locationID > 0 && (task.LocationID == nil || *task.LocationID != locationID) {
continue
}
if filters["priority"] != "" && task.Priority != priority {
continue
}
if month > 0 && !taskInMonth(task, month) {
continue
}
if year > 0 && !taskInYear(task, year) {
continue
}
result = append(result, task)
}
return result
}
func (app *application) taskCreate(w http.ResponseWriter, r *http.Request) {
app.renderTaskForm(w, r, taskForm{RecurrenceInterval: 1, Errors: map[string]string{}}, http.StatusOK, 0)
}
func (app *application) taskEdit(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
taskID, err := app.readPathID(r, "taskID")
if err != nil {
app.notFound(w)
return
}
task, _, err := client.FromContext(r.Context()).Task(r.Context(), gardenID, taskID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
form := taskForm{Title: task.Title, Description: task.Description, Recurrence: task.Recurrence, RecurrenceInterval: task.RecurrenceInterval, Priority: task.Priority, Keywords: strings.Join(task.Tags, ", "), Errors: map[string]string{}}
if form.RecurrenceInterval < 1 {
form.RecurrenceInterval = 1
}
if task.PlantID != nil {
form.PlantID = *task.PlantID
}
if task.LocationID != nil {
form.LocationID = *task.LocationID
}
if task.PlantStatusOnCompletion != nil {
form.PlantStatusOnCompletion = *task.PlantStatusOnCompletion
}
if task.DueAtStart != nil {
form.DueAtStart = task.DueAtStart.Local().Format("2006-01-02")
}
if task.DueAtEnd != nil {
form.DueAtEnd = task.DueAtEnd.Local().Format("2006-01-02")
}
app.renderTaskForm(w, r, form, http.StatusOK, taskID)
}
func (app *application) taskSave(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
taskID := 0
if id, readErr := app.readPathID(r, "taskID"); readErr == nil {
taskID = id
}
var form taskForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.Title, form.Description = strings.TrimSpace(form.Title), strings.TrimSpace(form.Description)
form.Errors = map[string]string{}
if form.Title == "" {
form.Errors["title"] = "Ein Titel ist erforderlich."
}
input := client.TaskInput{Title: &form.Title, Description: &form.Description, Priority: &form.Priority, Tags: parseKeywords(form.Keywords)}
if form.PlantID > 0 {
input.PlantID = &form.PlantID
} else {
input.ClearPlantID = true
}
if form.LocationID > 0 {
input.LocationID = &form.LocationID
} else {
input.ClearLocationID = true
}
input.DueAtStart, input.ClearDueAtStart = parseTaskTime(form.DueAtStart, "due_at_start", form.Errors)
input.DueAtEnd, input.ClearDueAtEnd = parseTaskTime(form.DueAtEnd, "due_at_end", form.Errors)
if input.DueAtStart != nil && input.DueAtEnd != nil && input.DueAtStart.After(*input.DueAtEnd) {
form.Errors["due_at_end"] = "Das Ende darf nicht vor dem Beginn liegen."
}
if form.Recurrence != "" && form.Recurrence != "daily" && form.Recurrence != "weekly" && form.Recurrence != "monthly" && form.Recurrence != "yearly" {
form.Errors["recurrence"] = "Bitte eine gültige Wiederholung auswählen."
}
if form.Recurrence != "" && input.DueAtStart == nil && input.DueAtEnd == nil {
form.Errors["recurrence"] = "Für eine Wiederholung ist ein Fälligkeitsdatum erforderlich."
}
if form.Recurrence != "" && form.RecurrenceInterval < 1 {
form.Errors["recurrence_interval"] = "Der Faktor muss mindestens 1 sein."
}
input.Recurrence = &form.Recurrence
input.RecurrenceInterval = &form.RecurrenceInterval
input.PlantStatusOnCompletion = &form.PlantStatusOnCompletion
if form.PlantStatusOnCompletion != "" && form.PlantID < 1 {
form.Errors["plant_status_on_completion"] = "Bitte zuerst eine Pflanze auswählen."
}
if len(form.Errors) == 0 {
apiClient := client.FromContext(r.Context())
var saveErr error
if taskID > 0 {
_, _, saveErr = apiClient.UpdateTask(r.Context(), gardenID, taskID, input)
} else {
_, _, saveErr = apiClient.CreateTask(r.Context(), gardenID, input)
}
if saveErr == nil {
http.Redirect(w, r, webPath("tasks", gardenID), http.StatusSeeOther)
return
}
var apiError *client.APIError
if errors.As(saveErr, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity {
form.Errors = apiError.Validation
} else {
app.handleAPIError(w, r, saveErr)
return
}
}
app.renderTaskForm(w, r, form, http.StatusUnprocessableEntity, taskID)
}
func parseTaskTime(value, field string, validation map[string]string) (*time.Time, bool) {
if strings.TrimSpace(value) == "" {
return nil, true
}
parsed, err := time.ParseInLocation("2006-01-02", value, time.Local)
if err != nil {
// Accept legacy clients that still submit a datetime; current forms are day-only.
parsed, err = time.ParseInLocation("2006-01-02T15:04", value, time.Local)
}
if err != nil {
validation[field] = "Bitte ein vollständiges Datum angeben."
return nil, false
}
return &parsed, false
}
func (app *application) taskComplete(w http.ResponseWriter, r *http.Request) {
app.setTaskCompleted(w, r, true)
}
func (app *application) taskReopen(w http.ResponseWriter, r *http.Request) {
app.setTaskCompleted(w, r, false)
}
func (app *application) setTaskCompleted(w http.ResponseWriter, r *http.Request, completed bool) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
taskID, err := app.readPathID(r, "taskID")
if err != nil {
app.notFound(w)
return
}
if _, _, err := client.FromContext(r.Context()).UpdateTask(r.Context(), gardenID, taskID, client.TaskInput{Completed: &completed}); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("tasks", gardenID), http.StatusSeeOther)
}
func (app *application) taskDelete(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
taskID, err := app.readPathID(r, "taskID")
if err != nil {
app.notFound(w)
return
}
if _, err := client.FromContext(r.Context()).DeleteTask(r.Context(), gardenID, taskID); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("tasks", gardenID), http.StatusSeeOther)
}
func (app *application) renderTaskForm(w http.ResponseWriter, r *http.Request, form taskForm, status, taskID int) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
apiClient := client.FromContext(r.Context())
garden, _, err := apiClient.Garden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
plants, _, err := apiClient.Plants(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
locations, _, err := apiClient.Locations(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
priorities, _, err := apiClient.TaskPriorities(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
}
data := app.newTemplateData(r)
data.Garden, data.Plants, data.Locations, data.TaskPriorities, data.Form, data.TaskID = &garden, plants, locations, priorities, form, taskID
if taskID > 0 {
task, _, taskErr := apiClient.Task(r.Context(), gardenID, taskID)
if taskErr != nil {
app.handleAPIError(w, r, taskErr)
return
}
data.TaskCreatedBy = task.CreatedBy
}
data.TagSuggestions = app.loadTagSuggestions(r, gardenID)
app.render(w, status, "task_form.tmpl", data)
}
+86
View File
@@ -0,0 +1,86 @@
package web
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"gardomatic.kleiax.de/lib/client"
"github.com/julienschmidt/httprouter"
)
func taskWebRequest(request *http.Request, apiClient *client.Client, params httprouter.Params) *http.Request {
ctx := context.WithValue(request.Context(), httprouter.ParamsKey, params)
ctx = client.NewContext(ctx, apiClient)
return request.WithContext(ctx)
}
func TestTaskListAndCreateWebFlow(t *testing.T) {
var created client.TaskInput
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/3":
_, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof"}}`))
case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/3/tasks":
_, _ = w.Write([]byte(`{"tasks":[{"id":7,"garden_id":3,"title":"Tomaten gießen","plant_id":5,"location_id":6,"priority":5},{"id":9,"garden_id":3,"title":"Bereits erledigt","completed_at":"2026-09-01T10:00:00Z"}]}`))
case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/3/plants":
_, _ = w.Write([]byte(`{"plants":[{"id":5,"garden_id":3,"name":"Tomate"}]}`))
case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/3/locations":
_, _ = w.Write([]byte(`{"locations":[{"id":6,"garden_id":3,"name":"Beet"}]}`))
case r.Method == http.MethodGet && r.URL.Path == "/v1/task-priorities":
_, _ = w.Write([]byte(`{"priorities":[{"id":1,"name":"Normal","value":0,"active":true},{"id":2,"name":"Hoch","value":5,"active":true}]}`))
case r.Method == http.MethodPost && r.URL.Path == "/v1/gardens/3/tasks":
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &created); err != nil {
t.Fatal(err)
}
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"task":{"id":8,"garden_id":3,"title":"Ernten"}}`))
default:
http.NotFound(w, r)
}
})
app := newAPIBackedTestApplication(t, apiHandler)
params := httprouter.Params{{Key: "gardenID", Value: "3"}}
listRequest := taskWebRequest(httptest.NewRequest(http.MethodGet, "/g/3/tasks", nil), app.apiClient, params)
listResponse := httptest.NewRecorder()
app.tasks(listResponse, listRequest)
if listResponse.Code != http.StatusOK || !strings.Contains(listResponse.Body.String(), "Tomaten gießen") || !strings.Contains(listResponse.Body.String(), "Pflanze: Tomate") || strings.Contains(listResponse.Body.String(), "Bereits erledigt") {
t.Fatalf("task list: status=%d body=%s", listResponse.Code, listResponse.Body.String())
}
form := url.Values{"title": {"Ernten"}, "plant_id": {"5"}, "location_id": {"6"}, "priority": {"3"}, "due_at_start": {"2026-09-03T08:00"}, "due_at_end": {"2026-09-03T18:00"}, "recurrence": {"weekly"}, "recurrence_interval": {"2"}}
createRequest := httptest.NewRequest(http.MethodPost, "/g/3/tasks/new", strings.NewReader(form.Encode()))
createRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
createRequest = taskWebRequest(createRequest, app.apiClient, params)
createResponse := httptest.NewRecorder()
app.taskSave(createResponse, createRequest)
if createResponse.Code != http.StatusSeeOther || createResponse.Header().Get("Location") != "/g/3/tasks" {
t.Fatalf("create redirect: status=%d location=%q body=%s", createResponse.Code, createResponse.Header().Get("Location"), createResponse.Body.String())
}
if created.Title == nil || *created.Title != "Ernten" || created.PlantID == nil || *created.PlantID != 5 || created.DueAtEnd == nil || created.Recurrence == nil || *created.Recurrence != "weekly" || created.RecurrenceInterval == nil || *created.RecurrenceInterval != 2 {
t.Errorf("created task input: %+v", created)
}
}
func TestCollectionPageSizeUsesValidatedUserPreference(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/g/3/tasks", nil)
request = request.WithContext(context.WithValue(request.Context(), userContextKey, client.User{ID: 7}))
request.AddCookie(&http.Cookie{Name: "gardomatic.entries-per-page.7", Value: "50"})
if got := collectionPageSize(request); got != 50 {
t.Fatalf("collectionPageSize = %d, want 50", got)
}
request = httptest.NewRequest(http.MethodGet, "/g/3/tasks", nil)
request.AddCookie(&http.Cookie{Name: "gardomatic.entries-per-page", Value: "17"})
if got := collectionPageSize(request); got != 20 {
t.Fatalf("invalid collectionPageSize = %d, want default 20", got)
}
}
+327
View File
@@ -0,0 +1,327 @@
package web
import (
"time"
"gardomatic.kleiax.de/lib/client"
)
// templateData is the internal builder used by handlers. pageView projects it
// to the feature sets explicitly available to a particular page template.
type templateData struct {
commonTemplateData
accountTemplateData
adminTemplateData
gardenTemplateData
plantTemplateData
speciesTemplateData
locationTemplateData
taskTemplateData
journalTemplateData
mediaTemplateData
searchTemplateData
}
// CommonTemplateData contains values available to every rendered page.
type CommonTemplateData struct {
CurrentYear int
ErrorStatus int
ErrorTitle string
ErrorMessage string
ContentKind string
Form any
Flash string
IsAuthenticated bool
IsActivated bool
CSRFToken string
CurrentUser *client.User
Garden *client.Garden
Filters map[string]string
Pagination *paginationData
TagSuggestions []string
UseJournalEditor bool
Health *client.Health
HealthError string
SystemTime time.Time
WebVersion string
}
// AccountTemplateData contains account-management page data.
type AccountTemplateData struct{ AccountSessions []client.AccountSession }
// AdminTemplateData contains application-administration page data.
type AdminTemplateData struct {
AdminUsers []client.User
ApplicationRoles []client.Role
GardenRoles []client.Role
GardenRoleSettings []client.GardenRoleSetting
ApplicationPermissions []permissionOption
GardenPermissions []permissionOption
RoleEditors []roleEditorData
ApplicationSettings *client.ApplicationSettings
SpeciesCategories []client.SpeciesCategory
TaskPriorities []client.TaskPriority
EnvironmentVariables []client.EnvironmentVariable
}
// GardenTemplateData contains garden membership and invitation page data.
type GardenTemplateData struct {
Gardens []client.Garden
Members []client.GardenMember
Invites []client.GardenInvite
}
// PlantTemplateData contains plant, placement, and plant-task page data.
type PlantTemplateData struct {
Plants []client.Plant
PlantTasks []plantTaskForm
TaskDialogNew bool
PlantID int
PlantCreatedBy int
PlantLocations map[int][]client.PlantLocation
PendingPlantName string
}
// SpeciesTemplateData contains species, care, and task-template page data.
type SpeciesTemplateData struct {
Species []client.Species
TaskTemplates map[int][]client.SpeciesTaskTemplate
TemplateOptOuts map[int]bool
TemplateID int
SpeciesID int
SpeciesGlobal bool
TemplateSpeciesID int
CareInstructions []client.CareInstruction
WizardStep string
}
// LocationTemplateData contains location hierarchy and occupancy page data.
type LocationTemplateData struct {
Locations []client.Location
LocationID int
LocationCreatedBy int
LocationPlants []locationPlant
LocationHistory []locationPlantHistory
LocationTree []locationNode
LocationNames map[int]string
}
// TaskTemplateData contains task-list and calendar page data.
type TaskTemplateData struct {
TaskYears []int
Tasks []client.Task
TaskID int
TaskCreatedBy int
CalendarDays []calendarDay
CalendarStart time.Time
CalendarEnd time.Time
CalendarView string
PreviousDate string
NextDate string
}
// JournalTemplateData contains journal and pinboard page data.
type JournalTemplateData struct {
JournalEntries []client.JournalEntry
PinboardEntries []client.JournalEntry
JournalEntry *client.JournalEntry
EntryID int
}
// MediaTemplateData contains reusable garden image-library page data.
type MediaTemplateData struct {
Images []client.Image
PhotoCount int
}
// SearchTemplateData contains cross-resource search results.
type SearchTemplateData struct{ Search searchResults }
// Private aliases keep handler construction concise while the actual embedded
// projection types remain visible to html/template's reflection logic.
type commonTemplateData = CommonTemplateData
type accountTemplateData = AccountTemplateData
type adminTemplateData = AdminTemplateData
type gardenTemplateData = GardenTemplateData
type plantTemplateData = PlantTemplateData
type speciesTemplateData = SpeciesTemplateData
type locationTemplateData = LocationTemplateData
type taskTemplateData = TaskTemplateData
type journalTemplateData = JournalTemplateData
type mediaTemplateData = MediaTemplateData
type searchTemplateData = SearchTemplateData
type calendarDay struct {
Date time.Time
Tasks []client.Task
}
type searchResults struct {
Query string
Mode string
Month int
MonthName string
Year int
Years []int
All []searchCard
Tasks []searchCard
Plants []searchCard
Tags []searchCard
Species []searchCard
Journal []searchCard
Pinboard []searchCard
}
type searchCard struct {
Title string
Detail string
Meta string
Type string
URL string
}
type locationPlant struct {
Plant client.Plant
Assignment client.PlantLocation
}
type locationPlantHistory struct {
Year int
Plants []locationPlant
}
type permissionOption struct {
Name string
Label string
}
func (data *templateData) pageView(page string) any {
switch page {
case "activate.tmpl", "email_confirm.tmpl", "error.tmpl", "healthcheck.tmpl", "home.tmpl", "invite.tmpl", "privacy.tmpl", "settings.tmpl", "signin.tmpl":
return struct{ CommonTemplateData }{data.commonTemplateData}
case "account.tmpl":
return struct {
CommonTemplateData
AccountTemplateData
}{data.commonTemplateData, data.accountTemplateData}
case "admin.tmpl":
return struct {
CommonTemplateData
AdminTemplateData
}{data.commonTemplateData, data.adminTemplateData}
case "garden_form.tmpl":
return struct {
CommonTemplateData
AdminTemplateData
GardenTemplateData
MediaTemplateData
}{data.commonTemplateData, data.adminTemplateData, data.gardenTemplateData, data.mediaTemplateData}
case "garden_new.tmpl":
return struct {
CommonTemplateData
MediaTemplateData
}{data.commonTemplateData, data.mediaTemplateData}
case "gardens.tmpl":
return struct {
CommonTemplateData
GardenTemplateData
}{data.commonTemplateData, data.gardenTemplateData}
case "dashboard.tmpl":
return struct {
CommonTemplateData
PlantTemplateData
LocationTemplateData
TaskTemplateData
JournalTemplateData
MediaTemplateData
}{data.commonTemplateData, data.plantTemplateData, data.locationTemplateData, data.taskTemplateData, data.journalTemplateData, data.mediaTemplateData}
case "images.tmpl":
return struct {
CommonTemplateData
MediaTemplateData
}{data.commonTemplateData, data.mediaTemplateData}
case "journal.tmpl":
return struct {
CommonTemplateData
JournalTemplateData
}{data.commonTemplateData, data.journalTemplateData}
case "journal_form.tmpl":
return struct {
CommonTemplateData
JournalTemplateData
MediaTemplateData
}{data.commonTemplateData, data.journalTemplateData, data.mediaTemplateData}
case "location_form.tmpl":
return struct {
CommonTemplateData
LocationTemplateData
PlantTemplateData
MediaTemplateData
}{data.commonTemplateData, data.locationTemplateData, data.plantTemplateData, data.mediaTemplateData}
case "locations.tmpl":
return struct {
CommonTemplateData
LocationTemplateData
}{data.commonTemplateData, data.locationTemplateData}
case "plant.tmpl":
return struct {
CommonTemplateData
PlantTemplateData
SpeciesTemplateData
LocationTemplateData
}{data.commonTemplateData, data.plantTemplateData, data.speciesTemplateData, data.locationTemplateData}
case "plant_form.tmpl":
return struct {
CommonTemplateData
AdminTemplateData
PlantTemplateData
SpeciesTemplateData
LocationTemplateData
MediaTemplateData
}{data.commonTemplateData, data.adminTemplateData, data.plantTemplateData, data.speciesTemplateData, data.locationTemplateData, data.mediaTemplateData}
case "plant_location_form.tmpl":
return struct {
CommonTemplateData
PlantTemplateData
LocationTemplateData
}{data.commonTemplateData, data.plantTemplateData, data.locationTemplateData}
case "search.tmpl":
return struct {
CommonTemplateData
SearchTemplateData
}{data.commonTemplateData, data.searchTemplateData}
case "species.tmpl":
return struct {
CommonTemplateData
SpeciesTemplateData
}{data.commonTemplateData, data.speciesTemplateData}
case "species_form.tmpl":
return struct {
CommonTemplateData
AdminTemplateData
SpeciesTemplateData
MediaTemplateData
}{data.commonTemplateData, data.adminTemplateData, data.speciesTemplateData, data.mediaTemplateData}
case "task_calendar.tmpl":
return struct {
CommonTemplateData
TaskTemplateData
}{data.commonTemplateData, data.taskTemplateData}
case "task_form.tmpl", "tasks.tmpl":
return struct {
CommonTemplateData
AdminTemplateData
PlantTemplateData
LocationTemplateData
TaskTemplateData
}{data.commonTemplateData, data.adminTemplateData, data.plantTemplateData, data.locationTemplateData, data.taskTemplateData}
case "task_template_form.tmpl":
return struct {
CommonTemplateData
AdminTemplateData
SpeciesTemplateData
}{data.commonTemplateData, data.adminTemplateData, data.speciesTemplateData}
default:
panic("missing page data projection for " + page)
}
}
+426
View File
@@ -0,0 +1,426 @@
package web
import (
"bytes"
"fmt"
"html/template"
"io/fs"
"path/filepath"
"strings"
"time"
"gardomatic.kleiax.de/lib/client"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
)
type locationNode struct {
Location client.Location
Children []locationNode
}
func buildLocationTree(locations []client.Location) []locationNode {
children := make(map[int][]client.Location)
known := make(map[int]bool, len(locations))
for _, location := range locations {
known[location.ID] = true
}
for _, location := range locations {
parent := 0
if location.ParentID != nil && known[*location.ParentID] {
parent = *location.ParentID
}
children[parent] = append(children[parent], location)
}
var build func(int, map[int]bool) []locationNode
build = func(parent int, ancestors map[int]bool) []locationNode {
result := make([]locationNode, 0, len(children[parent]))
for _, location := range children[parent] {
if ancestors[location.ID] {
continue
}
next := make(map[int]bool, len(ancestors)+1)
for id := range ancestors {
next[id] = true
}
next[location.ID] = true
result = append(result, locationNode{Location: location, Children: build(location.ID, next)})
}
return result
}
return build(0, map[int]bool{})
}
func flattenLocationTree(nodes []locationNode) []client.Location {
locations := make([]client.Location, 0)
var appendNodes func([]locationNode)
appendNodes = func(items []locationNode) {
for _, node := range items {
locations = append(locations, node.Location)
appendNodes(node.Children)
}
}
appendNodes(nodes)
return locations
}
func humanDate(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format("02 Jan 2006 at 15:04")
}
func speciesName(species []client.Species, id *int) string {
if id == nil {
return "Ohne Artzuordnung"
}
for _, item := range species {
if item.ID == *id {
if item.Cultivar != "" {
return item.CommonName + " · " + item.Cultivar
}
return item.CommonName
}
}
return "Unbekannte Art"
}
func plantName(plants []client.Plant, id *int) string {
if id == nil {
return ""
}
for _, plant := range plants {
if plant.ID == *id {
return plant.Name
}
}
return "Unbekannte Pflanze"
}
func locationName(locations []client.Location, id *int) string {
if id == nil {
return ""
}
for _, location := range locations {
if location.ID == *id {
return location.Name
}
}
return "Unbekannter Ort"
}
func taskDue(task client.Task) string {
format := func(value *time.Time) string {
if value == nil {
return ""
}
return value.Local().Format("02.01.2006 15:04")
}
start, end := format(task.DueAtStart), format(task.DueAtEnd)
if start != "" && end != "" {
return start + " - " + end
}
if end != "" {
return "bis " + end
}
if start != "" {
return "ab " + start
}
return "Ohne Fälligkeit"
}
func taskDueDate(task client.Task) string {
format := func(value *time.Time) string {
if value == nil {
return ""
}
return value.Local().Format("02.01.2006")
}
start, end := format(task.DueAtStart), format(task.DueAtEnd)
if start != "" && end != "" {
if start == end {
return start
}
return start + " - " + end
}
if end != "" {
return "bis " + end
}
if start != "" {
return "ab " + start
}
return "Ohne Fälligkeit"
}
func priorityName(priority int) string {
switch {
case priority >= 5:
return "Hoch"
case priority > 0:
return "Erhöht"
case priority < 0:
return "Niedrig"
default:
return "Normal"
}
}
func configuredPriorityName(priorities []client.TaskPriority, priority int) string {
for _, option := range priorities {
if option.Value == priority {
return option.Name
}
}
return priorityName(priority)
}
type statusOption struct {
Value string
Label string
}
func plantStatuses() []statusOption {
return []statusOption{
{Value: "alive", Label: "Lebendig"},
{Value: "dead", Label: "Tot"},
{Value: "removed", Label: "Entfernt"},
{Value: "infested", Label: "Befallen"},
{Value: "harvested", Label: "Geerntet"},
}
}
func plantStatusName(status string) string {
for _, option := range plantStatuses() {
if option.Value == status {
return option.Label
}
}
return status
}
func careStatuses() []statusOption {
return []statusOption{{Value: "good", Label: "Gut"}, {Value: "bad", Label: "Schlecht"}, {Value: "untested", Label: "Ungetestet"}, {Value: "testing", Label: "In Testung"}, {Value: "planned", Label: "Geplant"}}
}
func lifecycleName(value *string) string {
if value == nil {
return ""
}
switch *value {
case "annual":
return "Einjährig"
case "biennial":
return "Zweijährig"
case "perennial":
return "Mehrjährig"
default:
return ""
}
}
type monthOption struct {
Value int
Label string
}
func months() []monthOption {
return []monthOption{
{Value: 1, Label: "Januar"}, {Value: 2, Label: "Februar"},
{Value: 3, Label: "März"}, {Value: 4, Label: "April"},
{Value: 5, Label: "Mai"}, {Value: 6, Label: "Juni"},
{Value: 7, Label: "Juli"}, {Value: 8, Label: "August"},
{Value: 9, Label: "September"}, {Value: 10, Label: "Oktober"},
{Value: 11, Label: "November"}, {Value: 12, Label: "Dezember"},
}
}
func recurrenceName(recurrence string) string {
switch recurrence {
case "daily":
return "Täglich"
case "weekly":
return "Wöchentlich"
case "monthly":
return "Monatlich"
case "yearly":
return "Jährlich"
default:
return ""
}
}
func recurrenceDescription(recurrence string, interval int) string {
if recurrence == "" {
return ""
}
if interval < 1 {
interval = 1
}
units := map[string][2]string{"daily": {"Tag", "Tage"}, "weekly": {"Woche", "Wochen"}, "monthly": {"Monat", "Monate"}, "yearly": {"Jahr", "Jahre"}}
unit, ok := units[recurrence]
if !ok {
return ""
}
label := unit[1]
if interval == 1 {
label = unit[0]
}
return fmt.Sprintf("Alle %d %s", interval, label)
}
func durationDescription(amount int, unit string) string {
units := map[string][2]string{"day": {"Tag", "Tage"}, "week": {"Woche", "Wochen"}, "month": {"Monat", "Monate"}}
labels, ok := units[unit]
if !ok {
return ""
}
label := labels[1]
if amount == 1 {
label = labels[0]
}
return fmt.Sprintf("%d %s", amount, label)
}
func plantTaskData(garden *client.Garden, task plantTaskForm) *templateData {
return &templateData{commonTemplateData: commonTemplateData{Garden: garden}, plantTemplateData: plantTemplateData{PlantTasks: []plantTaskForm{task}}}
}
var functions = template.FuncMap{
"webPath": webPath,
"pathWithQuery": pathWithQuery,
"gardenAwarePath": gardenAwarePath,
"dict": func(values ...any) map[string]any {
result := map[string]any{}
for i := 0; i+1 < len(values); i += 2 {
key, _ := values[i].(string)
result[key] = values[i+1]
}
return result
},
"humanDate": humanDate,
"journalDate": func(value time.Time) string { return value.Local().Format("02.01.2006 · 15:04 Uhr") },
"fileSize": func(value int64) string {
if value >= 1<<20 {
return fmt.Sprintf("%.1f MB", float64(value)/(1<<20))
}
if value >= 1<<10 {
return fmt.Sprintf("%.1f kB", float64(value)/(1<<10))
}
return fmt.Sprintf("%d B", value)
},
"hasPrefix": strings.HasPrefix,
"markdown": func(value string) template.HTML {
var output bytes.Buffer
parser := goldmark.New(goldmark.WithExtensions(extension.GFM))
if err := parser.Convert([]byte(value), &output); err != nil {
return template.HTML(template.HTMLEscapeString(value))
}
return template.HTML(output.String())
},
"speciesName": speciesName,
"plantName": plantName,
"locationName": locationName,
"taskDue": taskDue,
"taskDueDate": taskDueDate,
"priorityName": priorityName,
"configuredPriorityName": configuredPriorityName,
"plantStatuses": plantStatuses,
"plantStatusName": plantStatusName,
"careStatuses": careStatuses,
"lifecycleName": lifecycleName,
"months": months,
"recurrenceName": recurrenceName,
"recurrenceDescription": recurrenceDescription,
"durationDescription": durationDescription,
"plantTaskData": plantTaskData,
"calendarDate": func(value time.Time) string { return value.Format("02.01.2006") },
"dateValue": func(value time.Time) string { return value.Format("2006-01-02") },
"eqInt": func(left, right int) bool { return left == right },
"neInt": func(left, right int) bool { return left != right },
"containsInt": func(values []int, target int) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
},
"containsString": func(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
},
"add": func(left, right int) int { return left + right },
"sub": func(left, right int) int { return left - right },
"eqString": func(left, right string) bool { return left == right },
"neString": func(left, right string) bool { return left != right },
"stringValue": func(value *string) string {
if value == nil {
return ""
}
return *value
},
"canGarden": func(garden *client.Garden, permission string) bool {
return garden != nil && garden.Can(permission)
},
"canUser": func(user *client.User, permission string) bool {
return user != nil && user.Can(permission)
},
"canGardenResource": func(garden *client.Garden, user *client.User, createdBy int, own, other string) bool {
if garden == nil {
return false
}
if user == nil {
return garden.Can(other)
}
if createdBy == user.ID {
return garden.Can(own)
}
return garden.Can(other)
},
"isAppAdmin": func(user *client.User) bool { return user != nil && user.IsAdmin() },
"canGlobalSpecies": func(user *client.User) bool { return user != nil && user.Can("global_species:write") },
"canEditSpecies": func(garden *client.Garden, user *client.User, global bool, speciesID int) bool {
if speciesID == 0 {
return garden != nil && garden.Can("species:write") || user != nil && user.Can("global_species:write")
}
if global {
return user != nil && user.Can("global_species:write")
}
return garden != nil && garden.Can("species:write")
},
}
func newTemplateCache() (map[string]*template.Template, error) {
cache := map[string]*template.Template{}
pages, err := fs.Glob(files, "templates/pages/*.tmpl")
if err != nil {
return nil, err
}
for _, page := range pages {
name := filepath.Base(page)
patterns := []string{
"templates/layout/base.tmpl",
"templates/partials/*.tmpl",
"templates/fragments/*.tmpl",
page,
}
ts, err := template.New(name).Funcs(functions).ParseFS(files, patterns...)
if err != nil {
return nil, err
}
cache[name] = ts
}
return cache, nil
}
@@ -0,0 +1 @@
{{define "care_instruction_list"}}{{$editable := canEditSpecies .Garden .CurrentUser .SpeciesGlobal .SpeciesID}}<div id='care-instruction-list' class='care-instruction-list'>{{range .CareInstructions}}{{$instruction := .}}<article class='care-instruction'>{{if $editable}}<form class='care-instruction-form' method='POST' action='{{webPath "species.care.edit" $.Garden.ID $.SpeciesID .ID}}'><input type='hidden' name='csrf_token' value='{{$.CSRFToken}}'><textarea name='text' required>{{.Text}}</textarea><div class='care-instruction-controls'><label class='sr-only' for='care-status-{{.ID}}'>Status</label><select id='care-status-{{.ID}}' name='status' aria-label='Status'>{{range careStatuses}}<option value='{{.Value}}' {{if eqString $instruction.Status .Value}}selected{{end}}>{{.Label}}</option>{{end}}</select><div class='care-instruction-actions'><button type='submit' class='care-action care-action-save' aria-label='Speichern' title='Speichern'><svg viewBox='0 0 24 24' aria-hidden='true'><path d='M5 12.5 10 17l9-10'/></svg></button><button type='submit' class='care-action care-action-remove' formaction='{{webPath "species.care.delete" $.Garden.ID $.SpeciesID .ID}}' aria-label='Entfernen' title='Entfernen'><svg viewBox='0 0 24 24' aria-hidden='true'><path d='M5 7h14M9 7V5h6v2m-8 0 1 13h8l1-13M10 11v5m4-5v5'/></svg></button></div></div></form>{{else}}<p>{{.Text}}</p><span class='status'>{{.Status}}</span>{{end}}</article>{{else}}<p class='muted'>Noch keine Pflegeanweisungen.</p>{{end}}</div>{{end}}
@@ -0,0 +1,39 @@
{{define "location_fields"}}
{{$form := .Form}}
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
<input type='hidden' name='return_to' value='{{$form.ReturnTo}}'>
<label for='location-name'>Name</label>
<input id='location-name' name='name' value='{{$form.Name}}' required>
{{with index $form.Errors "name"}}<p class='field-error'>{{.}}</p>{{end}}
{{template "image_editor" (dict "ImageData" $form.ImageData "ImageID" $form.ImageID "Images" .Images "GardenID" .Garden.ID)}}
<label for='parent_id'>Übergeordneter Ort</label>
<select id='parent_id' name='parent_id'>
<option value='0'>Keiner</option>
{{range .Locations}}<option value='{{.ID}}' {{if eqInt $form.ParentID .ID}}selected{{end}}>{{.Name}}</option>{{end}}
</select>
{{with index $form.Errors "parent_id"}}<p class='field-error'>{{.}}</p>{{end}}
<label for='kind'>Art des Orts</label>
<input id='kind' name='kind' value='{{$form.Kind}}' placeholder='z. B. Beet, Topf oder Gewächshaus'>
<label for='area_sqm'>Fläche in m²</label>
<input id='area_sqm' name='area_sqm' inputmode='decimal' value='{{$form.AreaSQM}}'>
{{with index $form.Errors "area_sqm"}}<p class='field-error'>{{.}}</p>{{end}}
<label for='sun_exposure'>Licht</label>
<select id='sun_exposure' name='sun_exposure'><option value=''>Nicht angegeben</option><option value='sunny' {{if eqString $form.SunExposure "sunny"}}selected{{end}}>Sonnig</option><option value='partial_shade' {{if eqString $form.SunExposure "partial_shade"}}selected{{end}}>Halbschattig</option><option value='shade' {{if eqString $form.SunExposure "shade"}}selected{{end}}>Schattig</option></select>
<label for='soil_condition'>Bodenbeschaffenheit</label><select id='soil_condition' name='soil_condition'><option value=''>Nicht angegeben</option><option value='dry' {{if eqString $form.SoilCondition "dry"}}selected{{end}}>Trocken</option><option value='moist' {{if eqString $form.SoilCondition "moist"}}selected{{end}}>Feucht</option><option value='boggy' {{if eqString $form.SoilCondition "boggy"}}selected{{end}}>Sumpfig</option></select>
<label for='soil_reaction'>Bodenreaktion</label><select id='soil_reaction' name='soil_reaction'><option value=''>Nicht angegeben</option><option value='alkaline' {{if eqString $form.SoilReaction "alkaline"}}selected{{end}}>Basisch</option><option value='acidic' {{if eqString $form.SoilReaction "acidic"}}selected{{end}}>Sauer</option><option value='neutral' {{if eqString $form.SoilReaction "neutral"}}selected{{end}}>Neutral</option></select>
<label for='description'>Beschreibung</label>
<textarea id='description' name='description' rows='4'>{{$form.Description}}</textarea>
{{end}}
{{define "location_dialog"}}
<dialog class='location-dialog'>
<form action='{{webPath "location.new" .Garden.ID}}' method='POST'
hx-post='{{webPath "location.new" .Garden.ID}}' hx-target='#location_id' hx-swap='beforeend'>
<h3>Ort direkt anlegen</h3>
{{template "location_fields" .}}
<div class='actions'><button type='submit'>Anlegen und auswählen</button><button type='button' class='secondary' data-close-dialog>Abbrechen</button></div>
</form>
</dialog>
{{end}}
{{define "location_option"}}{{range .Locations}}<option value='{{.ID}}' selected>{{.Name}}</option>{{end}}{{end}}
@@ -0,0 +1,17 @@
{{define "plant_template_tasks"}}<div id='plant-template-tasks' class='plant-task-list'>{{if .SpeciesID}}{{range index .TaskTemplates .SpeciesID}}<div class='plant-template-task-row'><button type='button' class='plant-task-name' hx-get='{{pathWithQuery (webPath "plant.template-task" $.Garden.ID .ID) "species_id" $.SpeciesID}}' hx-target='#plant-task-dialog-host' hx-swap='innerHTML'>{{.Title}}</button><input type='hidden' name='plant_template_active_{{.ID}}' value='{{if index $.TemplateOptOuts .ID}}false{{else}}true{{end}}' data-toggle-value><label class='switch' aria-label='{{.Title}} aktivieren oder deaktivieren'><input type='checkbox' data-toggle-control {{if not (index $.TemplateOptOuts .ID)}}checked{{end}}><span></span></label></div>{{else}}<p class='muted'>Für diese Art sind keine Aufgabenvorlagen hinterlegt.</p>{{end}}{{else}}<p class='muted'>Nach Auswahl einer Art erscheinen hier deren Aufgabenvorlagen.</p>{{end}}</div>{{end}}
{{define "plant_task_row_fragment"}}{{range .PlantTasks}}
<div id='{{.RowKey}}' class='plant-task-row' data-plant-task-row>
<input type='hidden' name='plant_task_row_key' value='{{.RowKey}}'><input type='hidden' name='plant_task_id' value='{{.ID}}'><input type='hidden' name='plant_task_plant_id' value='{{.PlantID}}'><input type='hidden' name='pending_plant_name' value='{{.PendingPlantName}}'>
<input type='hidden' name='plant_task_title' value='{{.Title}}'><input type='hidden' name='plant_task_description' value='{{.Description}}'><input type='hidden' name='plant_task_due_at_start' value='{{.DueAtStart}}'><input type='hidden' name='plant_task_due_at_end' value='{{.DueAtEnd}}'><input type='hidden' name='plant_task_priority' value='{{.Priority}}'><input type='hidden' name='plant_task_location_id' value='{{.LocationID}}'><input type='hidden' name='plant_task_recurrence' value='{{.Recurrence}}'><input type='hidden' name='plant_task_recurrence_interval' value='{{.RecurrenceInterval}}'><input type='hidden' name='plant_task_keywords' value='{{.Keywords}}'><input type='hidden' name='plant_task_status_on_completion' value='{{.PlantStatusOnCompletion}}'><input type='hidden' name='plant_task_active' value='{{if .Active}}true{{else}}false{{end}}' data-toggle-value><input type='hidden' name='plant_task_delete' value='false' data-plant-task-delete>
<button type='button' class='plant-task-name' hx-get='{{webPath "plant.task-row" $.Garden.ID}}' hx-include='closest [data-plant-task-row]' hx-target='#plant-task-dialog-host' hx-swap='innerHTML'>{{.Title}}</button><label class='switch' aria-label='{{.Title}} aktivieren oder deaktivieren'><input type='checkbox' data-toggle-control {{if .Active}}checked{{end}}><span></span></label><button type='button' class='template-remove' data-remove-plant-task aria-label='{{.Title}} löschen' title='Aufgabe löschen'>×</button>
</div>{{end}}{{end}}
{{define "plant_task_dialog"}}{{$form := .Form}}
<dialog class='plant-task-dialog'><form method='POST' action='{{webPath "plant.task-row" .Garden.ID}}' hx-post='{{webPath "plant.task-row" .Garden.ID}}' hx-target='{{if .TaskDialogNew}}#plant-manual-tasks{{else}}#{{$form.RowKey}}{{end}}' hx-swap='{{if .TaskDialogNew}}beforeend{{else}}outerHTML{{end}}'>
<h3>{{if .TaskDialogNew}}Aufgabe hinzufügen{{else}}Aufgabe bearbeiten{{end}}</h3><input type='hidden' name='csrf_token' value='{{.CSRFToken}}'><input type='hidden' name='new' value='{{if .TaskDialogNew}}true{{else}}false{{end}}'><input type='hidden' name='plant_task_row_key' value='{{$form.RowKey}}'><input type='hidden' name='plant_task_id' value='{{$form.ID}}'><input type='hidden' name='plant_task_plant_id' value='{{$form.PlantID}}'><input type='hidden' name='pending_plant_name' value='{{.PendingPlantName}}'><input type='hidden' name='plant_task_active' value='{{if $form.Active}}true{{else}}false{{end}}'><input type='hidden' name='plant_task_delete' value='false'>
{{template "task_fields" (dict "Form" $form "Prefix" "plant_task_" "ShowPlant" true "LockPlant" true "PendingPlantName" .PendingPlantName "Plants" .Plants "Locations" .Locations "TaskPriorities" .TaskPriorities "TagSuggestions" .TagSuggestions)}}
<div class='actions'><button type='submit'>Übernehmen</button><button type='button' class='secondary' data-close-dialog>Abbrechen</button></div>
</form></dialog>{{end}}
{{define "plant_template_task_dialog"}}{{range index .TaskTemplates .SpeciesID}}<dialog class='plant-task-dialog'><p class='eyebrow'>Aufgabenvorlage{{if and .Origin (not (eqString .Origin "manual"))}} · automatisch{{end}}</p><h3>{{.Title}}</h3>{{with .Description}}<p>{{.}}</p>{{else}}<p class='muted'>Keine Beschreibung hinterlegt.</p>{{end}}<dl class='task-detail-list'><dt>Typ</dt><dd>{{if eqString .TriggerType "month_of_year"}}Datum{{else if eqString .TriggerType "relative_to_planting"}}Nach Pflanzdatum{{else if eqString .TriggerType "relative_to_species_planting"}}Nach Pflanzsaison{{else if eqString .TriggerType "relative_to_sowing"}}Nach Aussaat{{else if eqString .TriggerType "relative_to_harvest"}}Nach Ernte{{else}}Nach letzter Erledigung{{end}}</dd>{{if eqString .TriggerType "month_of_year"}}<dt>Ab</dt><dd>{{with .DayFrom}}{{.}}.{{end}}{{with .MonthFrom}}{{.}}{{end}}</dd>{{else}}<dt>Nach</dt><dd>{{durationDescription .TriggerOffset .TriggerOffsetUnit}}</dd>{{end}}<dt>Bis in</dt><dd>{{durationDescription .Duration .DurationUnit}}</dd><dt>Priorität</dt><dd>{{configuredPriorityName $.TaskPriorities .Priority}}</dd><dt>Wiederholung</dt><dd>{{with recurrenceDescription .Recurrence .RecurrenceInterval}}{{.}}{{else}}Keine{{end}}</dd></dl><div class='actions'><button type='button' class='secondary' data-close-dialog>Schließen</button></div></dialog>{{end}}{{end}}
@@ -0,0 +1,25 @@
{{define "task_template_fields"}}
{{$form := .Form}}
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
<label for='template-title'>Name</label><input id='template-title' name='title' value='{{$form.Title}}'>{{with index $form.Errors "title"}}<p class='field-error'>{{.}}</p>{{end}}
<label for='template-description'>Beschreibung</label><textarea id='template-description' name='description'>{{$form.Description}}</textarea>
{{if and $form.Origin (not (eqString $form.Origin "manual"))}}<p class='muted'>Automatisch aus den Saisonangaben der Art abgeleitet.</p><input type='hidden' name='trigger_type' value='{{$form.TriggerType}}'>{{end}}
<label for='template-trigger-type'>Typ</label><select id='template-trigger-type' name='trigger_type' data-template-trigger {{if and $form.Origin (not (eqString $form.Origin "manual"))}}disabled{{end}}><option value='month_of_year' {{if eqString $form.TriggerType "month_of_year"}}selected{{end}}>Datum</option><option value='relative_to_planting' {{if eqString $form.TriggerType "relative_to_planting"}}selected{{end}}>Nach Pflanzdatum</option><option value='relative_to_species_planting' {{if eqString $form.TriggerType "relative_to_species_planting"}}selected{{end}}>Nach Pflanzsaison</option><option value='relative_to_harvest' {{if eqString $form.TriggerType "relative_to_harvest"}}selected{{end}}>Nach Ernte</option><option value='relative_to_sowing' {{if eqString $form.TriggerType "relative_to_sowing"}}selected{{end}}>Nach Aussaat</option></select>
<fieldset data-trigger-fields='month_of_year' {{if not (eqString $form.TriggerType "month_of_year")}}hidden disabled{{end}}><legend>Ab</legend><div class='form-grid'><label>Tag<input type='number' min='1' max='31' name='day_from' value='{{$form.DayFrom}}'></label><label>Monat<select name='month_from'><option value='0'>Bitte wählen</option>{{range months}}<option value='{{.Value}}' {{if eqInt $form.MonthFrom .Value}}selected{{end}}>{{.Label}}</option>{{end}}</select></label></div>{{with index $form.Errors "date"}}<p class='field-error'>{{.}}</p>{{end}}</fieldset>
<fieldset data-trigger-fields='relative' {{if eqString $form.TriggerType "month_of_year"}}hidden disabled{{end}}><legend>Nach</legend><div class='form-grid'><label>Dauer<input type='number' min='0' name='trigger_offset' value='{{$form.TriggerOffset}}'></label><label>Einheit<select name='trigger_offset_unit'><option value='day' {{if eqString $form.TriggerOffsetUnit "day"}}selected{{end}}>Tage</option><option value='week' {{if eqString $form.TriggerOffsetUnit "week"}}selected{{end}}>Wochen</option><option value='month' {{if eqString $form.TriggerOffsetUnit "month"}}selected{{end}}>Monate</option></select></label></div></fieldset>
<fieldset><legend>Bis in</legend><div class='form-grid'><label>Dauer<input type='number' min='0' name='duration' value='{{$form.Duration}}'></label><label>Einheit<select name='duration_unit'><option value='day' {{if eqString $form.DurationUnit "day"}}selected{{end}}>Tage</option><option value='week' {{if eqString $form.DurationUnit "week"}}selected{{end}}>Wochen</option><option value='month' {{if eqString $form.DurationUnit "month"}}selected{{end}}>Monate</option></select></label></div>{{with index $form.Errors "duration"}}<p class='field-error'>{{.}}</p>{{end}}</fieldset>
<fieldset><legend>Wiederholung</legend><div class='form-grid'><label>Alle<input type='number' min='1' name='recurrence_interval' value='{{$form.RecurrenceInterval}}'></label><label>Einheit<select name='recurrence'><option value='' {{if eqString $form.Recurrence ""}}selected{{end}}>Keine Wiederholung</option><option value='daily' {{if eqString $form.Recurrence "daily"}}selected{{end}}>Tage</option><option value='weekly' {{if eqString $form.Recurrence "weekly"}}selected{{end}}>Wochen</option><option value='monthly' {{if eqString $form.Recurrence "monthly"}}selected{{end}}>Monate</option><option value='yearly' {{if eqString $form.Recurrence "yearly"}}selected{{end}}>Jahre</option></select></label></div><p class='muted'>Die nächste Aufgabe wird erst beim Erledigen angelegt.</p>{{with index $form.Errors "recurrence_interval"}}<p class='field-error'>{{.}}</p>{{end}}</fieldset>
<label for='template-priority'>Priorität</label><select id='template-priority' name='priority'>{{range .TaskPriorities}}<option value='{{.Value}}' {{if eqInt $form.Priority .Value}}selected{{end}}>{{.Name}}</option>{{end}}</select>
<label class='checkbox'><input type='checkbox' name='active' value='true' {{if $form.Active}}checked{{end}}> Aktiv</label>
{{end}}
{{define "task_template_dialog"}}
{{$species := index .Species 0}}
<dialog class='task-template-dialog'>
{{$action := pathWithQuery (webPath "task-template.new" .Garden.ID) "species_id" .SpeciesID}}{{if .TemplateID}}{{$action = pathWithQuery (webPath "task-template.edit" .Garden.ID .TemplateID) "species_id" .SpeciesID}}{{end}}<form action='{{$action}}' method='POST' hx-post='{{$action}}' hx-target='#task-template-dialog-host' hx-swap='innerHTML'>
<p class='eyebrow'>{{$species.CommonName}}</p><h3>{{if .TemplateID}}Aufgabe bearbeiten{{else}}Aufgabe anlegen{{end}}</h3>
{{template "task_template_fields" .}}
<div class='actions'><button type='submit'>Speichern</button><button type='button' class='secondary' data-close-dialog>Abbrechen</button></div>
</form>
</dialog>
{{end}}
+32
View File
@@ -0,0 +1,32 @@
{{define "base"}}
<!doctype html>
<html lang='de'>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1'>
<meta name='theme-color' content='#315d3a'>
<title>{{template "title" .}} - Gardomatic</title>
<link rel='stylesheet' href='/static/css/main.css'>
<link rel='stylesheet' href='/static/css/cropper-1.6.2.min.css'>
{{if .UseJournalEditor}}<link rel='stylesheet' href='/static/css/toastui-editor-3.2.2.min.css'>{{end}}
<link rel='manifest' href='/static/manifest.manifest'>
<link rel='apple-touch-icon' href='/static/icons/apple-touch-icon.png'>
</head>
<body {{with .CurrentUser}}data-user-id='{{.ID}}'{{end}}>
<a class='skip-link' href='#main-content'>Zum Inhalt springen</a>
{{template "header" .}}
{{template "nav" .}}
<main id='main-content' tabindex='-1'>
{{with .Flash}}
<div class='flash'>{{.}}</div>
{{end}}
{{template "main" .}}
</main>
{{template "footer" .}}
<script src='/static/js/htmx-2.0.10.min.js' defer></script>
<script src='/static/js/cropper-1.6.2.min.js' defer></script>
{{if .UseJournalEditor}}<script src='/static/js/toastui-editor-3.2.2.min.js' defer></script><script src='/static/js/toastui-editor-de-de-3.2.2.min.js' defer></script>{{end}}
<script src='/static/js/main.js' defer></script>
</body>
</html>
{{end}}
@@ -0,0 +1,8 @@
{{define "title"}}Benutzerkonto{{end}}
{{define "main"}}
<header class='page-heading'><div><p class='eyebrow'>Persönlich</p><h2>Benutzerkonto</h2></div></header>{{$form:=.Form}}{{with $form.Message}}<p class='form-message'>{{.}}</p>{{end}}
<div class='account-grid'><section class='panel'><h3>Profil</h3><form method='POST' action='{{with .Garden}}{{pathWithQuery (webPath "account.profile") "garden" .ID}}{{else}}{{webPath "account.profile"}}{{end}}'><input type='hidden' name='csrf_token' value='{{.CSRFToken}}'><label for='name'>Name</label><input id='name' name='name' value='{{$form.Name}}' required>{{with index $form.Errors "name"}}<p class='field-error'>{{.}}</p>{{end}}<label for='color'>Farbe für deine Einträge</label><input id='color' name='color' type='color' value='{{$form.Color}}' required><p class='muted'>Diese Farbe wird verwendet, um deine Tagebucheinträge schnell zu erkennen.</p>{{with index $form.Errors "color"}}<p class='field-error'>{{.}}</p>{{end}}<button>Profil speichern</button></form></section>
<section class='panel'><h3>E-Mail-Adresse</h3><p>Aktuell: {{.CurrentUser.Email}}</p><form method='POST' action='{{with .Garden}}{{pathWithQuery (webPath "account.email") "garden" .ID}}{{else}}{{webPath "account.email"}}{{end}}'><input type='hidden' name='csrf_token' value='{{.CSRFToken}}'><label for='email'>Neue E-Mail-Adresse</label><input id='email' type='email' name='email' value='{{$form.Email}}' required><label for='email-password'>Aktuelles Passwort</label><input id='email-password' type='password' name='current_password' required>{{with index $form.Errors "email"}}<p class='field-error'>{{.}}</p>{{end}}{{with index $form.Errors "current_password"}}<p class='field-error'>{{.}}</p>{{end}}<button>Bestätigung senden</button></form></section>
<section class='panel'><h3>Passwort</h3><form method='POST' action='{{with .Garden}}{{pathWithQuery (webPath "account.password") "garden" .ID}}{{else}}{{webPath "account.password"}}{{end}}'><input type='hidden' name='csrf_token' value='{{.CSRFToken}}'><label for='current-password'>Aktuelles Passwort</label><input id='current-password' type='password' name='current_password' required><label for='new-password'>Neues Passwort</label><input id='new-password' type='password' name='new_password' minlength='8' required>{{with index $form.Errors "new_password"}}<p class='field-error'>{{.}}</p>{{end}}<label for='confirm-password'>Neues Passwort bestätigen</label><input id='confirm-password' type='password' name='new_password_confirmation' required>{{with index $form.Errors "new_password_confirmation"}}<p class='field-error'>{{.}}</p>{{end}}{{with index $form.Errors "current_password"}}<p class='field-error'>{{.}}</p>{{end}}<button>Passwort ändern</button></form></section>
<section class='panel'><h3>Aktive Sitzungen</h3>{{if .AccountSessions}}{{range .AccountSessions}}{{$session := .}}<div class='member-row'><div><strong>{{if .Current}}Dieses Gerät{{else}}Weitere Sitzung{{end}}</strong><br><span class='muted'>Angemeldet: {{humanDate .CreatedAt}} · gültig bis {{humanDate .ExpiresAt}}</span></div><form action='{{with $.Garden}}{{pathWithQuery (webPath "account.session.delete" $session.ID) "garden" .ID}}{{else}}{{webPath "account.session.delete" .ID}}{{end}}' method='POST'><input type='hidden' name='csrf_token' value='{{$.CSRFToken}}'><button class='danger'>Widerrufen</button></form></div>{{end}}{{else}}<p>Keine aktiven Sitzungen gefunden.</p>{{end}}</section></div>
{{end}}
@@ -0,0 +1,28 @@
{{define "title"}}Account aktivieren{{end}}
{{define "main"}}
<section class='panel narrow'>
<h2>Account aktivieren</h2>
<p>Gib den Aktivierungstoken aus deiner E-Mail ein, um deinen Account freizuschalten.</p>
{{$form := .Form}}
{{with $form.Message}}<p class='form-message error'>{{.}}</p>{{end}}
<form action='{{webPath "activate"}}' method='POST'>
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
<label for='token'>Aktivierungstoken</label>
<input id='token' name='token' type='text' value='{{$form.Token}}' autocomplete='one-time-code' autocapitalize='characters' spellcheck='false' required>
{{with index $form.Errors "token"}}<p class='field-error'>{{.}}</p>{{end}}
{{if $form.SetPassword}}
<input type='hidden' name='set_password' value='true'>
<label for='password'>Passwort festlegen</label>
<input id='password' name='password' type='password' minlength='8' maxlength='72' autocomplete='new-password' required>
{{with index $form.Errors "password"}}<p class='field-error'>{{.}}</p>{{end}}
<label for='password-confirm'>Passwort wiederholen</label>
<input id='password-confirm' name='password_confirm' type='password' minlength='8' maxlength='72' autocomplete='new-password' required>
{{with index $form.Errors "password_confirm"}}<p class='field-error'>{{.}}</p>{{end}}
{{end}}
<button type='submit'>Account aktivieren</button>
</form>
</section>
{{end}}
+120
View File
@@ -0,0 +1,120 @@
{{define "title"}}Administration{{end}}
{{define "main"}}
<header class='page-heading'><div><p class='eyebrow'>Administration</p><h2>Anwendung verwalten</h2></div></header>
<div class='settings-layout'>
{{template "settings_nav" (dict "Kind" "admin")}}
<div class='settings-content'>
{{with .ApplicationSettings}}
<section class='panel' id='lifecycle-settings'>
<h3>Pflanzenlebensdauer</h3>
<p>Ein- und zweijährige Pflanzen können am globalen Stichtag automatisch als entfernt markiert werden. Mehrjährige Pflanzen sowie Pflanzen ohne Pflanzdatum bleiben unverändert.</p>
<form method='POST' action='{{gardenAwarePath (webPath "admin.application-settings") $.Garden}}'>
<input type='hidden' name='csrf_token' value='{{$.CSRFToken}}'>
<label class='checkbox'><input type='checkbox' name='lifecycle_status_enabled' value='true' {{if .LifecycleStatusEnabled}}checked{{end}}> Automatische Statusänderung aktivieren</label>
<div class='form-grid'>
<label for='lifecycle-removal-day'>Tag<input id='lifecycle-removal-day' name='lifecycle_removal_day' type='number' min='1' max='31' value='{{.LifecycleRemovalDay}}' required></label>
<label for='lifecycle-removal-month'>Monat<select id='lifecycle-removal-month' name='lifecycle_removal_month'>{{range months}}<option value='{{.Value}}' {{if eqInt $.ApplicationSettings.LifecycleRemovalMonth .Value}}selected{{end}}>{{.Label}}</option>{{end}}</select></label>
</div>
<label for='lifecycle-timezone'>Zeitzone</label><input id='lifecycle-timezone' name='timezone' value='{{.Timezone}}' required>
<p class='muted'>Als Ausgangspunkt dient „Erworben oder gepflanzt am“. Offene Aufgaben und aktive Standortzuordnungen werden beim Entfernen ebenfalls beendet.</p>
<button type='submit'>Einstellungen speichern</button>
</form>
</section>
{{end}}
<section class='panel' id='mail-settings'>
<h3>Mailversand testen</h3>
<p>Sendet über die aktuell konfigurierte Versandart eine Testmail an eine beliebige gültige E-Mail-Adresse.</p>
<form method='POST' action='{{gardenAwarePath (webPath "admin.test-mail") .Garden}}'>
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
<label for='test-mail-address'>Empfängeradresse</label>
<input id='test-mail-address' name='email' type='email' autocomplete='email' required>
<button type='submit'>Testmail senden</button>
</form>
</section>
<section class='panel' id='environment-settings'>
<h3>Umgebungsvariablen</h3>
<p>Wirksame Konfigurationswerte der API und Webanwendung. Sensible Werte werden ausschließlich maskiert angezeigt.</p>
<div class='environment-list'>
{{range .EnvironmentVariables}}
<div class='environment-row'>
<span class='status'>{{.Component}}</span>
<code>{{.Name}}</code>
<code>{{if .Value}}{{.Value}}{{else}}(leer){{end}}</code>
</div>
{{else}}<p>Keine Umgebungsvariablen verfügbar.</p>{{end}}
</div>
</section>
<section class='panel' id='users-settings'>
<h3>Nutzer und Rechte</h3>
<p>Lade neue Nutzer per E-Mail ein und verwalte anschließend ihre anwendungsweiten Rollen.</p>
<form class='admin-user-invite' method='POST' action='{{gardenAwarePath (webPath "admin.user.invite") .Garden}}'>
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
<label for='invite-user-name'>Name<input id='invite-user-name' name='name' autocomplete='name' required></label>
<label for='invite-user-email'>E-Mail-Adresse<input id='invite-user-email' name='email' type='email' autocomplete='email' required></label>
<button type='submit'>Nutzer einladen</button>
</form>
{{range .AdminUsers}}{{$user := .}}<article class='member-row'><div><strong>{{.Name}}</strong><br><span>{{.Email}}</span></div>
{{if neInt .ID $.CurrentUser.ID}}<div class='admin-user-actions'><form method='POST' action='{{gardenAwarePath (webPath "admin.user.role" .ID) $.Garden}}'><input type='hidden' name='csrf_token' value='{{$.CSRFToken}}'><label for='role-{{.ID}}'>Rolle</label><select id='role-{{.ID}}' name='role'>{{range $.ApplicationRoles}}<option value='{{.Name}}' {{if eqString .Name $user.Role}}selected{{end}}>{{.Label}}</option>{{end}}</select><button>Speichern</button></form><details class='delete-user'><summary>Nutzer löschen</summary><form method='POST' action='{{gardenAwarePath (webPath "admin.user.delete" .ID) $.Garden}}'><input type='hidden' name='csrf_token' value='{{$.CSRFToken}}'><p>Kontodaten anonymisieren und alle Zugriffe entfernen?</p><button type='submit' class='danger'>Endgültig löschen</button></form></details></div>{{else}}<span class='status'>Eigenes Konto · {{.Role}}</span>{{end}}</article>{{else}}<p>Keine Nutzer vorhanden.</p>{{end}}
</section>
{{range .RoleEditors}}{{template "role_editor" .}}{{end}}
<section class='panel admin-categories' id='species-categories'>
<h3>Artenkategorien</h3>
<p>Kategorien stehen anschließend in allen Artenformularen zur Auswahl.</p>
{{range .SpeciesCategories}}
<article class='admin-category-row'>
<form method='POST' action='{{gardenAwarePath (webPath "admin.species-category.edit" .ID) $.Garden}}'>
<input type='hidden' name='csrf_token' value='{{$.CSRFToken}}'>
<label for='category-name-{{.ID}}'>Name</label><input id='category-name-{{.ID}}' name='name' value='{{.Name}}' required>
<label for='category-order-{{.ID}}'>Reihenfolge</label><input id='category-order-{{.ID}}' name='sort_order' type='number' value='{{.SortOrder}}'>
<label for='category-lifecycle-{{.ID}}'>Lebensdauer</label><select id='category-lifecycle-{{.ID}}' name='lifecycle'><option value=''>Nicht festgelegt</option><option value='annual' {{if eqString (stringValue .Lifecycle) "annual"}}selected{{end}}>Einjährig</option><option value='biennial' {{if eqString (stringValue .Lifecycle) "biennial"}}selected{{end}}>Zweijährig</option><option value='perennial' {{if eqString (stringValue .Lifecycle) "perennial"}}selected{{end}}>Mehrjährig</option></select>
<label class='checkbox'><input type='checkbox' name='active' value='true' {{if .Active}}checked{{end}}> Aktiv</label>
<button type='submit'>Speichern</button>
</form>
{{if .Active}}<form method='POST' action='{{gardenAwarePath (webPath "admin.species-category.delete" .ID) $.Garden}}'><input type='hidden' name='csrf_token' value='{{$.CSRFToken}}'><button type='submit' class='link-button danger-text'>Deaktivieren</button></form>{{end}}
</article>
{{else}}<p>Noch keine Kategorien vorhanden.</p>{{end}}
<form class='admin-category-create' method='POST' action='{{gardenAwarePath (webPath "admin.species-category.new") .Garden}}'>
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
<h4>Neue Kategorie</h4>
<label for='new-category-name'>Name</label><input id='new-category-name' name='name' required>
<label for='new-category-order'>Reihenfolge</label><input id='new-category-order' name='sort_order' type='number' value='0'>
<label for='new-category-lifecycle'>Lebensdauer</label><select id='new-category-lifecycle' name='lifecycle'><option value=''>Nicht festgelegt</option><option value='annual'>Einjährig</option><option value='biennial'>Zweijährig</option><option value='perennial'>Mehrjährig</option></select>
<button type='submit'>Kategorie anlegen</button>
</form>
</section>
<section class='panel admin-categories' id='task-priorities'>
<h3>Aufgabenprioritäten</h3>
<p>Diese Prioritäten werden einheitlich für Aufgaben und Aufgabenvorlagen verwendet.</p>
{{range .TaskPriorities}}
<article class='admin-category-row'>
<form method='POST' action='{{gardenAwarePath (webPath "admin.task-priority.edit" .ID) $.Garden}}'>
<input type='hidden' name='csrf_token' value='{{$.CSRFToken}}'>
<label for='priority-name-{{.ID}}'>Name</label><input id='priority-name-{{.ID}}' name='name' value='{{.Name}}' required>
<label for='priority-value-{{.ID}}'>Wert</label><input id='priority-value-{{.ID}}' name='value' type='number' min='-100' max='100' value='{{.Value}}'>
<label for='priority-order-{{.ID}}'>Reihenfolge</label><input id='priority-order-{{.ID}}' name='sort_order' type='number' value='{{.SortOrder}}'>
<label class='checkbox'><input type='checkbox' name='active' value='true' {{if .Active}}checked{{end}}> Aktiv</label>
<button type='submit'>Speichern</button>
</form>
{{if .Active}}<form method='POST' action='{{gardenAwarePath (webPath "admin.task-priority.delete" .ID) $.Garden}}'><input type='hidden' name='csrf_token' value='{{$.CSRFToken}}'><button type='submit' class='link-button danger-text'>Deaktivieren</button></form>{{end}}
</article>
{{else}}<p>Noch keine Prioritäten vorhanden.</p>{{end}}
<form class='admin-category-create' method='POST' action='{{gardenAwarePath (webPath "admin.task-priority.new") .Garden}}'>
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
<h4>Neue Priorität</h4>
<label for='new-priority-name'>Name</label><input id='new-priority-name' name='name' required>
<label for='new-priority-value'>Wert</label><input id='new-priority-value' name='value' type='number' min='-100' max='100' value='0'>
<label for='new-priority-order'>Reihenfolge</label><input id='new-priority-order' name='sort_order' type='number' value='0'>
<button type='submit'>Priorität anlegen</button>
</form>
</section>
</div>
</div>
{{end}}
@@ -0,0 +1,7 @@
{{define "title"}}Übersicht{{end}}
{{define "main"}}
<header class='page-heading'><div><h2>{{.Garden.Name}}</h2></div><div class='actions'><a class='button secondary' href='{{webPath "tasks.calendar" .Garden.ID}}'>Kalender</a>{{if or (canGarden .Garden "garden:update") (canGarden .Garden "members:write") (canGarden .Garden "garden:delete")}}<a class='button' href='{{webPath "garden.edit" .Garden.ID}}'>Bearbeiten</a>{{end}}{{template "view_toggle" "dashboard"}}</div></header>
{{if or .Garden.ImageData .Garden.Description}}<section class='card garden-summary {{if .Garden.ImageData}}card--image{{end}}' {{if .Garden.ImageData}}data-background-image='{{.Garden.ImageData}}'{{end}}>{{with .Garden.Description}}<div class='card-content'><p>{{.}}</p></div>{{end}}</section>{{end}}
<section class='summary-grid collection' data-view-key='dashboard'><a class='card' href='{{webPath "plants" .Garden.ID}}'><span class='summary-number'>{{len .Plants}}</span><h3>Im Garten</h3></a><a class='card' href='{{webPath "locations" .Garden.ID}}'><span class='summary-number'>{{len .Locations}}</span><h3>Orte</h3></a><a class='card' href='{{webPath "tasks" .Garden.ID}}'><span class='summary-number'>{{len .Tasks}}</span><h3>Aufgaben</h3></a><a class='card' href='{{webPath "garden.journal" .Garden.ID}}'><span class='summary-number'>{{len .JournalEntries}}</span><h3>Tagebuch</h3></a><a class='card' href='{{webPath "garden.pinboard" .Garden.ID}}'><span class='summary-number'>{{len .PinboardEntries}}</span><h3>Pinnwand</h3></a><a class='card' href='{{webPath "garden.images" .Garden.ID}}'><span class='summary-number'>{{.PhotoCount}}</span><h3>Fotos</h3></a></section>
<section><div class='page-heading'><h3>Jetzt und demnächst</h3><a href='{{webPath "tasks.calendar" .Garden.ID}}'>Wochen- und Monatsansicht</a></div>{{range .Tasks}}{{$editable := canGardenResource $.Garden $.CurrentUser .CreatedBy "tasks:update:own" "tasks:update:other"}}<article class='panel task-card{{if $editable}} card--interactive{{end}}'{{if $editable}} role='link' tabindex='0' data-card-href='{{webPath "task.edit" $.Garden.ID .ID}}' aria-label='{{.Title}} öffnen'{{end}}><div class='task-heading'><h3>{{.Title}}</h3><strong>{{taskDueDate .}}</strong></div>{{with .Description}}<p>{{.}}</p>{{end}}</article>{{else}}<p>In den nächsten 30 Tagen stehen keine Aufgaben an.</p>{{end}}</section>
{{end}}
@@ -0,0 +1 @@
{{define "title"}}E-Mail bestätigen{{end}}{{define "main"}}<section class='panel narrow'><p class='eyebrow'>Benutzerkonto</p><h2>Neue E-Mail-Adresse bestätigen</h2><form method='POST'><input type='hidden' name='csrf_token' value='{{.CSRFToken}}'><input type='hidden' name='token' value='{{.Form.Token}}'><button>E-Mail-Adresse übernehmen</button></form></section>{{end}}
+9
View File
@@ -0,0 +1,9 @@
{{define "title"}}{{.ErrorTitle}}{{end}}
{{define "main"}}
<section class='panel narrow error-page'>
<p class='eyebrow'>Fehler {{.ErrorStatus}}</p>
<h2>{{.ErrorTitle}}</h2>
<p>{{.ErrorMessage}}</p>
<div class='actions'><button type='button' class='secondary' data-history-back>Zurück</button><a href='/'>Zur Startseite</a></div>
</section>
{{end}}
@@ -0,0 +1,56 @@
{{define "title"}}Garten bearbeiten{{end}}
{{define "main"}}
{{$garden := .Garden}}{{$current := .CurrentUser}}{{$forms := .Form}}
<header class='page-heading'><div><p class='eyebrow'>Garten</p><h2>Garten bearbeiten</h2></div></header>
<div class='settings-layout'>
<aside class='settings-sidebar panel'><nav aria-label='Bearbeitungsbereiche'>
{{if canGarden $garden "garden:update"}}<a href='#garden-general'>Allgemein</a>{{end}}
<a href='#garden-members'>Mitglieder</a>
{{if canGarden $garden "members:write"}}<a href='#garden-invites'>Einladungen</a>{{end}}
{{if canGarden $garden "garden:delete"}}<a href='#garden-role-editor'>Rollen</a><a href='#garden-delete'>Garten löschen</a>{{end}}
</nav></aside>
<div class='settings-content'>
{{if canGarden $garden "garden:update"}}
<section class='panel' id='garden-general'>
<h3>Allgemein</h3>
{{$form := $forms.Garden}}
<form method='POST' action='{{webPath "garden.edit" $garden.ID}}'>{{template "garden_fields" (dict "Form" $form "CSRFToken" .CSRFToken "Images" $.Images "GardenID" $garden.ID)}}<div class='actions'><button type='submit'>Speichern</button><a href='{{webPath "plants" $garden.ID}}'>Abbrechen</a></div></form>
</section>
{{end}}
<section class='panel member-list' id='garden-members'>
<h3>Mitglieder</h3>
{{range .Members}}{{$member := .}}
<article class='member-row'><div><strong>{{.Name}}</strong><br><span>{{.Email}} · {{.Role}}</span></div>
{{if and (canGarden $garden "members:write") (neInt .UserID $current.ID) (neString .Role "owner")}}
<div class='actions'>
<form method='POST' action='{{webPath "garden.member.role" $garden.ID .UserID}}'><input type='hidden' name='csrf_token' value='{{$.CSRFToken}}'><select name='role'>{{range $.GardenRoles}}{{if neString .Name "owner"}}<option value='{{.Name}}' {{if eqString .Name $member.Role}}selected{{end}}>{{.Label}}</option>{{end}}{{end}}</select><button>Rolle speichern</button></form>
<form method='POST' action='{{webPath "garden.member.delete" $garden.ID .UserID}}'><input type='hidden' name='csrf_token' value='{{$.CSRFToken}}'><button class='danger'>Entfernen</button></form>
{{if and (canGarden $garden "garden:delete") (neInt .UserID $current.ID)}}<form method='POST' action='{{webPath "garden.member.transfer" $garden.ID .UserID}}'><input type='hidden' name='csrf_token' value='{{$.CSRFToken}}'><button class='secondary'>Eigentum übertragen</button></form>{{end}}
</div>{{end}}
</article>{{else}}<p>Keine Mitglieder vorhanden.</p>{{end}}
</section>
{{if canGarden $garden "members:write"}}
<section class='panel' id='garden-invites'><h3>Mitglied einladen</h3>{{$invite := $forms.Invite}}<form method='POST' action='{{webPath "garden.invite.new" $garden.ID}}'><input type='hidden' name='csrf_token' value='{{.CSRFToken}}'><label for='email'>E-Mail-Adresse</label><input id='email' name='email' type='email' value='{{$invite.Email}}' required>{{with index $invite.Errors "email"}}<p class='field-error'>{{.}}</p>{{end}}<label for='role'>Rolle</label><select id='role' name='role'>{{range .GardenRoles}}{{if neString .Name "owner"}}<option value='{{.Name}}' {{if eqString .Name $invite.Role}}selected{{end}}>{{.Label}}</option>{{end}}{{end}}</select>{{with index $invite.Errors "role"}}<p class='field-error'>{{.}}</p>{{end}}<button>Einladung senden</button></form></section>
<section class='panel'><h3>Offene Einladungen</h3>{{range .Invites}}<div class='member-row'><span>{{.Email}} · {{.Role}} · bis {{humanDate .ExpiresAt}}</span><form method='POST' action='{{webPath "garden.invite.delete" $garden.ID .ID}}'><input type='hidden' name='csrf_token' value='{{$.CSRFToken}}'><button class='link-button danger-text'>Widerrufen</button></form></div>{{else}}<p>Keine offenen Einladungen.</p>{{end}}</section>
{{end}}
{{if canGarden $garden "garden:delete"}}{{range .RoleEditors}}{{template "role_editor" .}}{{end}}{{end}}
{{if canGarden $garden "garden:delete"}}
<section class='panel danger-panel' id='garden-delete'>
<div><h3>Garten löschen</h3><p>Der Garten und alle zugehörigen Inhalte werden dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.</p></div>
<button type='button' class='danger' aria-haspopup='dialog' aria-controls='delete-garden-dialog' data-open-dialog='delete-garden-dialog'>Garten löschen</button>
</section>
<dialog id='delete-garden-dialog' class='confirm-dialog' aria-labelledby='delete-garden-title'>
<form method='POST' action='{{webPath "garden.delete" $garden.ID}}'>
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
<h3 id='delete-garden-title'>„{{$garden.Name}}“ wirklich löschen?</h3>
<p>Alle Pflanzen, Orte, Aufgaben, Tagebucheinträge und Mitgliedschaften dieses Gartens werden unwiderruflich gelöscht.</p>
<div class='actions'><button type='button' class='secondary' data-cancel-dialog>Abbrechen</button><button type='submit' class='danger'>Endgültig löschen</button></div>
</form>
</dialog>
{{end}}
</div></div>
{{end}}
@@ -0,0 +1,16 @@
{{define "title"}}Garten anlegen{{end}}
{{define "main"}}
<section class='panel narrow'>
<p class='eyebrow'>Garten</p>
<h2>Garten anlegen</h2>
{{$form := .Form}}
<form method='POST'>
{{template "garden_fields" (dict "Form" $form "CSRFToken" .CSRFToken "Images" .Images "GardenID" 0 "Autofocus" true)}}
<div class='actions'>
<button type='submit'>Garten anlegen</button>
<a href='{{webPath "gardens"}}'>Abbrechen</a>
</div>
</form>
</section>
{{end}}
+22
View File
@@ -0,0 +1,22 @@
{{define "title"}}Gärten{{end}}
{{define "main"}}
<header class='page-heading'>
<div>
<p class='eyebrow'>Übersicht</p>
<h2>Deine Gärten</h2>
</div>
<div class='actions'>{{if canUser .CurrentUser "gardens:create"}}<a class='button' href='{{webPath "garden.new"}}'>Garten anlegen</a>{{end}}{{template "view_toggle" "gardens"}}</div>
</header>
<form class='panel filter-bar' method='GET'><div class='filter-fields'><div><label for='q'>Suche</label><input id='q' name='q' type='search' value='{{index .Filters "q"}}' placeholder='Name oder Beschreibung'></div><div><label for='role'>Rolle</label><select id='role' name='role'><option value=''>Alle</option><option value='owner' {{if eqString (index .Filters "role") "owner"}}selected{{end}}>Eigentümer</option><option value='admin' {{if eqString (index .Filters "role") "admin"}}selected{{end}}>Administration</option><option value='member' {{if eqString (index .Filters "role") "member"}}selected{{end}}>Mitglied</option><option value='viewer' {{if eqString (index .Filters "role") "viewer"}}selected{{end}}>Nur Lesen</option></select></div><div><label for='sort'>Sortierung</label><select id='sort' name='sort'><option value='name_asc'>Name AZ</option><option value='name_desc' {{if eqString (index .Filters "sort") "name_desc"}}selected{{end}}>Name ZA</option><option value='newest' {{if eqString (index .Filters "sort") "newest"}}selected{{end}}>Neueste zuerst</option><option value='oldest' {{if eqString (index .Filters "sort") "oldest"}}selected{{end}}>Älteste zuerst</option></select></div><button class='filter-submit'>Filtern</button></div></form>
<section class='card-grid collection' data-view-key='gardens'>
{{range .Gardens}}
<article class='card card--clickable card--interactive {{if .ImageData}}card--image{{end}}' {{if .ImageData}}data-background-image='{{.ImageData}}'{{end}} role='link' tabindex='0' data-card-href='{{webPath "garden.dashboard" .ID}}'><div class='card-content'><h3><a class='card-link' href='{{webPath "garden.dashboard" .ID}}'>{{.Name}}</a></h3>{{with .Description}}<p>{{.}}</p>{{end}}</div></article>
{{else}}
<p>Noch kein Garten vorhanden.</p>
{{end}}
</section>
{{template "pagination" .}}
{{end}}
@@ -0,0 +1,19 @@
{{define "title"}}Systemstatus{{end}}
{{define "main"}}
<header class='page-heading'><div><p class='eyebrow'>System</p><h2>Status</h2></div></header>
<section class='panel narrow health-status'>
{{with .Health}}
<dl>
<div><dt>Status</dt><dd><span class='status'>{{.Status}}</span></dd></div>
<div><dt>Umgebung</dt><dd>{{.SystemInfo.Environment}}</dd></div>
<div><dt>API-Version</dt><dd><code>{{.SystemInfo.Version}}</code></dd></div>
<div><dt>Serverzeit</dt><dd><time datetime='{{.ServerTime.Format "2006-01-02T15:04:05Z07:00"}}'>{{.ServerTime.Local.Format "02.01.2006 15:04:05 MST"}}</time></dd></div>
<div><dt>Systemzeit</dt><dd><time datetime='{{$.SystemTime.Format "2006-01-02T15:04:05Z07:00"}}'>{{$.SystemTime.Format "02.01.2006 15:04:05 MST"}}</time></dd></div>
<div><dt>Web-Version</dt><dd><code>{{$.WebVersion}}</code></dd></div>
</dl>
{{else}}
<p class='field-error'>{{.HealthError}}</p>
<dl><div><dt>Systemzeit</dt><dd><time datetime='{{.SystemTime.Format "2006-01-02T15:04:05Z07:00"}}'>{{.SystemTime.Format "02.01.2006 15:04:05 MST"}}</time></dd></div><div><dt>Web-Version</dt><dd><code>{{.WebVersion}}</code></dd></div></dl>
{{end}}
</section>
{{end}}
+10
View File
@@ -0,0 +1,10 @@
{{define "title"}}Home{{end}}
{{define "main"}}
<section class='hero'>
<p class='eyebrow'>Dein Garten. Klar organisiert.</p>
<h2>Pflanzenwissen und Gartenarbeit an einem Ort.</h2>
<p>Verwalte Gärten, Pflanzen, Orte und Aufgaben gemeinsam mit anderen.</p>
<a class='button' href='{{webPath "login"}}'>Jetzt anmelden</a>
</section>
{{end}}
+7
View File
@@ -0,0 +1,7 @@
{{define "title"}}Bilder{{end}}
{{define "main"}}
<header class='page-heading'><div><h2>Bilder</h2><p class='muted'>Alle Fotos dieses Gartens an einem Ort.</p></div></header>
<form class='panel filter-bar' method='GET'><div class='filter-fields'><div><label for='q'>Suche</label><input id='q' name='q' type='search' value='{{index .Filters "q"}}' placeholder='Dateiname'></div><div><label for='source'>Herkunft</label><select id='source' name='source'><option value=''>Alle</option><option value='plant' {{if eqString (index .Filters "source") "plant"}}selected{{end}}>Pflanzen</option><option value='species' {{if eqString (index .Filters "source") "species"}}selected{{end}}>Arten</option><option value='location' {{if eqString (index .Filters "source") "location"}}selected{{end}}>Orte</option><option value='garden' {{if eqString (index .Filters "source") "garden"}}selected{{end}}>Garten</option><option value='journal' {{if eqString (index .Filters "source") "journal"}}selected{{end}}>Tagebuch</option><option value='pinboard' {{if eqString (index .Filters "source") "pinboard"}}selected{{end}}>Pinnwand</option><option value='migration' {{if eqString (index .Filters "source") "migration"}}selected{{end}}>Übernommen</option></select></div><button class='filter-submit'>Filtern</button></div></form>
<section class='image-library-grid'>{{range .Images}}<figure class='panel image-library-item'><img src='/g/{{$.Garden.ID}}/images/{{.ID}}/data' alt='{{if .FileName}}{{.FileName}}{{else}}Gartenfoto{{end}}' loading='lazy'><figcaption>{{if .FileName}}{{.FileName}} · {{end}}{{fileSize .Size}} · {{.CreatedAt.Format "02.01.2006"}}</figcaption></figure>{{else}}<p>Noch keine Bilder vorhanden.</p>{{end}}</section>
{{template "pagination" .}}
{{end}}
+2
View File
@@ -0,0 +1,2 @@
{{define "title"}}Einladung{{end}}
{{define "main"}}<section class='panel narrow'><p class='eyebrow'>Zusammenarbeit</p><h2>Garteneinladung annehmen</h2><p>Die Einladung wird mit deiner angemeldeten E-Mail-Adresse abgeglichen.</p><form method='POST'><input type='hidden' name='csrf_token' value='{{.CSRFToken}}'><input type='hidden' name='token' value='{{.Form.Token}}'><button>Einladung annehmen</button></form></section>{{end}}
+17
View File
@@ -0,0 +1,17 @@
{{define "title"}}{{if eqString .ContentKind "pinboard"}}Pinnwand{{else}}Tagebuch{{end}}{{end}}
{{define "main"}}
{{$pinboard := eqString .ContentKind "pinboard"}}{{$path := "journal"}}{{if $pinboard}}{{$path = "pinboard"}}{{end}}
<header class='page-heading'><div><span class='eyebrow'>{{if $pinboard}}Ideen und Notizen{{else}}Gartentagebuch{{end}}</span><h2>{{if $pinboard}}Pinnwand{{else}}Tagebuch{{end}}</h2></div>{{if canGarden .Garden "content:write"}}<div class='actions'><a class='button' href='{{webPath (printf "%s.new" $path) .Garden.ID}}'>{{if $pinboard}}Notiz anlegen{{else}}Eintrag anlegen{{end}}</a></div>{{end}}</header>
{{$editable := canGarden .Garden "content:write"}}
<div class='journal-list{{if $pinboard}} pinboard-list{{end}}'>
{{range .JournalEntries}}
<article class='panel journal-entry{{if $editable}} card--clickable card--interactive{{end}}' id='entry-{{.ID}}' style='--author-color: {{.AuthorColor}}'{{if $editable}} role='link' tabindex='0' data-card-href='{{webPath (printf "%s.edit" $path) $.Garden.ID .ID}}' aria-label='{{if .Title}}{{.Title}}{{else}}Eintrag{{end}} bearbeiten'{{end}}>
<header><div>{{with .Title}}<h3>{{.}}</h3>{{end}}<p class='journal-meta'><time datetime='{{.CreatedAt.Format "2006-01-02T15:04:05Z07:00"}}'>{{journalDate .CreatedAt}}</time> · {{.AuthorName}}</p></div></header>
{{with .Tags}}<ul class='tag-list' aria-label='Schlagwörter'>{{range .}}<li>{{.}}</li>{{end}}</ul>{{end}}
<div class='markdown-body'>{{markdown .Body}}</div>
{{with .Attachments}}<div class='journal-media'>{{range .}}{{if hasPrefix .MediaType "image/"}}<figure><img src='/g/{{$.Garden.ID}}/{{$path}}/attachments/{{.EntryID}}/{{.ID}}' alt='{{.FileName}}' loading='lazy'><figcaption>{{.FileName}} · {{fileSize .Size}}</figcaption></figure>{{else if hasPrefix .MediaType "video/"}}<figure><video controls preload='metadata' src='/g/{{$.Garden.ID}}/{{$path}}/attachments/{{.EntryID}}/{{.ID}}'></video><figcaption>{{.FileName}} · {{fileSize .Size}}</figcaption></figure>{{else if hasPrefix .MediaType "audio/"}}<figure><audio controls preload='metadata' src='/g/{{$.Garden.ID}}/{{$path}}/attachments/{{.EntryID}}/{{.ID}}'></audio><figcaption>{{.FileName}} · {{fileSize .Size}}</figcaption></figure>{{end}}{{end}}</div>{{end}}
</article>
{{else}}<div class='panel'><p>{{if $pinboard}}Noch keine Notizen. Sammle hier spontane Ideen für deinen Garten.{{else}}Noch keine Tagebucheinträge. Halte fest, was in deinem Garten passiert.{{end}}</p></div>{{end}}
</div>
{{template "pagination" .}}
{{end}}
@@ -0,0 +1,32 @@
{{define "title"}}{{if eqString .ContentKind "pinboard"}}{{if .EntryID}}Notiz bearbeiten{{else}}Neue Notiz{{end}}{{else}}{{if .EntryID}}Tagebucheintrag bearbeiten{{else}}Neuer Tagebucheintrag{{end}}{{end}}{{end}}
{{define "main"}}
{{$pinboard := eqString .ContentKind "pinboard"}}{{$path := "journal"}}{{if $pinboard}}{{$path = "pinboard"}}{{end}}{{$editable := canGarden .Garden "content:write"}}
<header class='page-heading'><div><span class='eyebrow'>{{if $pinboard}}Pinnwand{{else}}Gartentagebuch{{end}}</span><h2>{{if $pinboard}}{{if .EntryID}}Notiz bearbeiten{{else}}Neue Notiz{{end}}{{else}}{{if .EntryID}}Eintrag bearbeiten{{else}}Neuer Eintrag{{end}}{{end}}</h2></div></header>
{{$form := .Form}}
<form class='panel journal-form' method='POST' enctype='multipart/form-data' data-journal-editor data-entry-kind='{{.ContentKind}}'>
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
<label for='title'>Titel{{if $pinboard}} <span class='muted'>(optional)</span>{{end}}</label><input id='title' name='title' value='{{$form.Title}}' maxlength='500' {{if not $pinboard}}required{{end}}>{{with index $form.Errors "title"}}<p class='field-error'>{{.}}</p>{{end}}
{{if $pinboard}}<input type='hidden' name='date_time' value='{{$form.DateTime}}'>{{else}}<label for='date-time'>Datum und Uhrzeit</label><input id='date-time' type='datetime-local' name='date_time' value='{{$form.DateTime}}' required>{{with index $form.Errors "date_time"}}<p class='field-error'>{{.}}</p>{{end}}{{end}}
<label for='journal-body'>Text</label>
<div id='journal-editor'></div><textarea id='journal-body' name='body' rows='14' maxlength='100000' data-journal-body>{{$form.Body}}</textarea>{{with index $form.Errors "body"}}<p class='field-error'>{{.}}</p>{{end}}
<label for='keywords'>Tags</label><div class='tag-editor' data-tag-editor><div class='tag-bubbles' data-tag-bubbles></div><input id='keywords' name='keywords' value='{{$form.Keywords}}' placeholder='Tag eingeben …' autocomplete='off' data-tag-input><div class='tag-suggestions' data-tag-suggestions role='listbox' hidden>{{range $form.TagSuggestions}}<button type='button' data-tag-suggestion='{{.}}'>{{.}}</button>{{end}}</div></div><p class='muted'>Mit Enter oder Komma übernehmen. Bereits verwendete Tags werden vorgeschlagen.</p>
{{with $form.Attachments}}<fieldset><legend>Vorhandene Anhänge</legend><div class='attachment-checklist'>{{range .}}<div class='attachment-row'><a href='{{webPath (printf "%s.attachment" $path) $.Garden.ID $.EntryID .ID}}' download='{{.FileName}}'>{{.FileName}} ({{fileSize .Size}})</a><label class='attachment-remove' title='Anhang löschen'><input type='checkbox' name='remove_attachment' value='{{.ID}}'><span aria-hidden='true'>×</span><span class='sr-only'>{{.FileName}} löschen</span></label></div>{{end}}</div></fieldset>{{end}}
<fieldset class='journal-attachments'><legend>{{if $pinboard}}Fotos{{else}}Fotos, Videos und Audio{{end}}</legend>
<label class='button secondary file-picker'>{{if $pinboard}}Fotos auswählen{{else}}Mediendateien auswählen{{end}}<input type='file' name='attachments' accept='{{if $pinboard}}image/*{{else}}image/*,video/*,audio/*{{end}}' multiple data-journal-files></label>
{{template "journal_photo_editor"}}
{{if .Images}}<button type='button' class='secondary' data-open-dialog='journal-image-library'>Aus Bilderdatenbank</button>{{end}}
{{if not $pinboard}}<button type='button' class='secondary' data-video-record>Video aufnehmen</button>
<button type='button' class='secondary' data-audio-record>Audio aufnehmen</button>{{end}}
<input type='file' name='attachments' multiple hidden data-journal-generated-files>
<input type='file' name='embedded_images' accept='image/*' multiple hidden data-embedded-images>
<ul class='attachment-preview' data-attachment-preview></ul>
</fieldset>
{{if .Images}}<dialog id='journal-image-library' class='image-library-dialog'><h3>Bilder aus der Bilderdatenbank auswählen</h3><p class='muted'>Es können mehrere Bilder ausgewählt werden.</p><div class='image-picker-grid'>{{range .Images}}<label class='image-picker-item'><input type='checkbox' name='library_image_id' value='{{.ID}}' data-journal-library-image data-image-name='{{if .FileName}}{{.FileName}}{{else}}Bild vom {{.CreatedAt.Format "02.01.2006"}}{{end}}' data-image-size='{{.Size}}' {{if containsInt $form.LibraryImageIDs .ID}}checked{{end}}><img src='/g/{{$.Garden.ID}}/images/{{.ID}}/data' alt='{{if .FileName}}{{.FileName}}{{else}}Bild auswählen{{end}}' loading='lazy'><span>{{if .FileName}}{{.FileName}}{{else}}{{.CreatedAt.Format "02.01.2006"}}{{end}}</span></label>{{end}}</div><div class='image-actions'><button type='button' data-cancel-dialog>Auswahl übernehmen</button></div></dialog>{{end}}
<div class='actions'>{{if $editable}}<button>Speichern</button>{{end}}<a href='{{webPath (printf "garden.%s" $path) .Garden.ID}}'>{{if $editable}}Abbrechen{{else}}Zurück{{end}}</a></div>
</form>
{{if not $pinboard}}
<dialog class='camera-dialog' data-video-dialog><h3>Video aufnehmen</h3><video muted playsinline data-video-preview></video><p class='field-error' data-video-error aria-live='polite'></p><div class='image-actions'><button type='button' class='secondary' data-video-switch-camera>Kamera wechseln</button><button type='button' data-video-toggle>Aufnahme starten</button><button type='button' class='secondary' data-video-pause hidden>Pause</button><button type='button' data-video-apply hidden>Übernehmen</button><button type='button' class='secondary' data-video-cancel>Abbrechen</button></div></dialog>
<dialog class='camera-dialog' data-audio-dialog><h3>Audio aufnehmen</h3><div class='recording-indicator' data-audio-indicator data-recording-indicator hidden aria-label='Aufnahme läuft'><i></i><i></i><i></i><i></i><i></i></div><audio controls data-audio-preview hidden></audio><p class='muted' data-audio-status>Bereit zur Aufnahme.</p><p class='field-error' data-audio-error aria-live='polite'></p><div class='image-actions'><button type='button' data-audio-toggle>Aufnahme starten</button><button type='button' class='secondary' data-audio-pause hidden>Pause</button><button type='button' data-audio-apply hidden>Übernehmen</button><button type='button' class='secondary' data-audio-cancel>Abbrechen</button></div></dialog>
{{end}}
{{if and .EntryID $editable}}<form class='delete-form' method='POST' action='{{webPath (printf "%s.delete" $path) .Garden.ID .EntryID}}'><input type='hidden' name='csrf_token' value='{{.CSRFToken}}'><button class='danger'>{{if $pinboard}}Notiz löschen{{else}}Eintrag löschen{{end}}</button></form>{{end}}
{{end}}
@@ -0,0 +1,42 @@
{{define "title"}}{{if .LocationID}}Ort bearbeiten{{else}}Ort anlegen{{end}}{{end}}
{{define "main"}}
{{$form := .Form}}
{{$canSave := canGarden .Garden "locations:create"}}{{if .LocationID}}{{$canSave = canGardenResource .Garden .CurrentUser .LocationCreatedBy "locations:update:own" "locations:update:other"}}{{end}}
<div class='{{if .LocationID}}settings-layout{{end}}'>
{{if .LocationID}}<aside class='settings-sidebar panel'><nav aria-label='Bearbeitungsbereiche'><a href='#location-general'>Allgemein</a><a href='#location-plants'>Pflanzen</a><a href='#location-history'>Historie</a></nav></aside>{{end}}
<div class='settings-content'><section class='panel location-detail' id='location-general'>
<h2>{{if .LocationID}}Ort bearbeiten{{else}}Ort anlegen{{end}}</h2>
<form id='location-form' action='{{if .LocationID}}{{webPath "location.edit" .Garden.ID .LocationID}}{{else}}{{webPath "location.new" .Garden.ID}}{{end}}' method='POST'>
{{template "location_fields" .}}
</form>
<div class='actions location-form-actions'>
{{if $canSave}}<button type='submit' form='location-form'>Speichern</button>{{end}}
<a href='{{$form.ReturnTo}}'>Abbrechen</a>
{{if and .LocationID (canGardenResource .Garden .CurrentUser .LocationCreatedBy "locations:delete:own" "locations:delete:other")}}<form class='inline-form' method='POST' action='{{webPath "location.delete" .Garden.ID .LocationID}}'><input type='hidden' name='csrf_token' value='{{.CSRFToken}}'><button type='submit' class='danger'>Ort löschen</button></form>{{end}}
</div>
</section>
{{if .LocationID}}
<section class='panel location-detail-plants' id='location-plants'>
<div class='page-heading'><h3>Pflanzen an diesem Ort</h3><div class='actions'>{{template "view_toggle" "location-plants"}}</div></div>
<div class='location-plant-list' data-view-key='location-plants'>
{{range .LocationPlants}}
{{$canEditPlant := canGardenResource $.Garden $.CurrentUser .Plant.CreatedBy "plants:update:own" "plants:update:other"}}<article class='card location-plant-item {{if $canEditPlant}}card--interactive{{end}}' {{if $canEditPlant}}role='link' tabindex='0' data-card-href='{{webPath "plant.edit" $.Garden.ID .Plant.ID}}'{{end}}><div class='card-content'><h3>{{if $canEditPlant}}<a href='{{webPath "plant.edit" $.Garden.ID .Plant.ID}}'>{{.Plant.Name}}</a>{{else}}{{.Plant.Name}}{{end}}</h3><p>Anzahl: {{.Assignment.Quantity}}{{with .Assignment.PlantedAt}} · gepflanzt am {{humanDate .}}{{end}}</p>{{with .Assignment.Notes}}<p>{{.}}</p>{{end}}</div></article>
{{else}}
<p>Diesem Ort sind noch keine Pflanzen zugeordnet.</p>
{{end}}
</div>
</section>
<section class='panel location-history' id='location-history'>
<h3>Historie der letzten 3 Jahre</h3>
{{range .LocationHistory}}
<section class='location-history-year'><h4>{{.Year}}</h4><div class='card-grid collection'>
{{range .Plants}}
{{$canEditPlant := canGardenResource $.Garden $.CurrentUser .Plant.CreatedBy "plants:update:own" "plants:update:other"}}<article class='card {{if $canEditPlant}}card--interactive{{end}} {{if .Plant.ImageData}}card--image{{end}}' {{if .Plant.ImageData}}data-background-image='{{.Plant.ImageData}}'{{end}} {{if $canEditPlant}}role='link' tabindex='0' data-card-href='{{webPath "plant.edit" $.Garden.ID .Plant.ID}}'{{end}}><div class='card-content'><h3>{{if $canEditPlant}}<a href='{{webPath "plant.edit" $.Garden.ID .Plant.ID}}'>{{.Plant.Name}}</a>{{else}}{{.Plant.Name}}{{end}}</h3><p>{{plantStatusName .Plant.Status}}{{with .Assignment.RemovedAt}} · entfernt am {{humanDate .}}{{end}}</p>{{with .Assignment.Notes}}<p>{{.}}</p>{{end}}</div></article>
{{else}}<p class='muted'>Keine Pflanzen in diesem Jahr.</p>{{end}}
</div></section>
{{end}}
</section>
{{end}}
</div></div>
{{end}}
@@ -0,0 +1,39 @@
{{define "title"}}Orte{{end}}
{{define "location_nodes"}}
<ul class='location-tree'>
{{range .}}
<li>
<a class='location-row' href='{{webPath "location.view" .Location.GardenID .Location.ID}}'>
<div><h3>{{.Location.Name}}</h3>{{with .Location.Kind}}<span class='status'>{{.}}</span>{{end}}</div>
{{with .Location.Description}}<p>{{.}}</p>{{end}}
</a>
{{with .Children}}{{template "location_nodes" .}}{{end}}
</li>
{{end}}
</ul>
{{end}}
{{define "main"}}
<header class='page-heading'>
<div><h2>Orte</h2></div>
<div class='actions'>{{if canGarden .Garden "locations:create"}}<a class='button' href='{{webPath "location.new" .Garden.ID}}'>Ort anlegen</a>{{end}}{{template "view_toggle" "locations"}}</div>
</header>
<form class='panel filter-bar' method='GET'><div class='filter-fields'><div><label for='q'>Suche</label><input id='q' name='q' type='search' value='{{index .Filters "q"}}' placeholder='Name oder Beschreibung'></div><div><label for='kind'>Art des Orts</label><input id='kind' name='kind' value='{{index .Filters "kind"}}' placeholder='z. B. Beet'></div><div><label for='sort'>Sortierung</label><select id='sort' name='sort'><option value='name_asc'>Name AZ</option><option value='name_desc' {{if eqString (index .Filters "sort") "name_desc"}}selected{{end}}>Name ZA</option><option value='newest' {{if eqString (index .Filters "sort") "newest"}}selected{{end}}>Neueste zuerst</option><option value='oldest' {{if eqString (index .Filters "sort") "oldest"}}selected{{end}}>Älteste zuerst</option></select></div><button class='filter-submit'>Filtern</button></div></form>
{{if .LocationTree}}
<section class='locations-views' data-view-variants data-view-key='locations'>
<div class='panel location-panel' data-view-variant='list' hidden>{{template "location_nodes" .LocationTree}}</div>
<div class='card-grid' data-view-variant='grid'>
{{range .Locations}}
<article class='card card--clickable card--interactive {{if .ImageData}}card--image{{end}}' {{if .ImageData}}data-background-image='{{.ImageData}}'{{end}} role='link' tabindex='0' data-card-href='{{webPath "location.view" .GardenID .ID}}'><div class='card-content'>
<h3><a class='card-link' href='{{webPath "location.view" .GardenID .ID}}'>{{.Name}}</a></h3>
{{with .Kind}}<span class='status'>{{.}}</span>{{end}}{{with .Description}}<p>{{.}}</p>{{end}}
</div></article>
{{end}}
</div>
</section>
{{else}}
<p>In diesem Garten wurden noch keine Orte angelegt.</p>
{{end}}
{{template "pagination" .}}
{{end}}
+42
View File
@@ -0,0 +1,42 @@
{{define "title"}}Im Garten{{end}}
{{define "main"}}
<header class='page-heading'>
<div><h2>Im Garten</h2></div>
<div class='actions'>{{if canGarden .Garden "plants:create"}}<a class='button' href='{{webPath "plant.new" .Garden.ID}}'>Pflanze erfassen</a>{{end}}{{template "view_toggle" "plants"}}</div>
</header>
<form class='panel filter-bar' method='GET'><div class='filter-fields filter-fields--plants'><div><label for='q'>Suche</label><input id='q' name='q' type='search' value='{{index .Filters "q"}}' placeholder='Name oder Notiz'></div><div><label for='status'>Status</label><select id='status' name='status'><option value=''>Alle</option>{{range plantStatuses}}<option value='{{.Value}}' {{if eqString (index $.Filters "status") .Value}}selected{{end}}>{{.Label}}</option>{{end}}</select></div><div><label for='species'>Art oder Sorte</label><select id='species' name='species'><option value=''>Alle</option>{{range .Species}}<option value='{{.ID}}' {{if eqString (index $.Filters "species") (printf "%d" .ID)}}selected{{end}}>{{.CommonName}}{{with .Cultivar}} · {{.}}{{end}}</option>{{end}}</select></div><div><label for='location'>Ort</label><select id='location' name='location'><option value=''>Alle</option>{{range .Locations}}<option value='{{.ID}}' {{if eqString (index $.Filters "location") (printf "%d" .ID)}}selected{{end}}>{{.Name}}</option>{{end}}</select></div><div><label for='sort'>Sortierung</label><select id='sort' name='sort'><option value='name_asc'>Name AZ</option><option value='name_desc' {{if eqString (index .Filters "sort") "name_desc"}}selected{{end}}>Name ZA</option><option value='newest' {{if eqString (index .Filters "sort") "newest"}}selected{{end}}>Neueste zuerst</option><option value='oldest' {{if eqString (index .Filters "sort") "oldest"}}selected{{end}}>Älteste zuerst</option></select></div><button class='filter-submit'>Filtern</button></div></form>
<section class='card-grid collection' data-view-key='plants'>
{{range .Plants}}
{{$plant := .}}
<article class='card card--clickable plant-card card--interactive {{if .ImageData}}card--image{{end}}' {{if .ImageData}}data-background-image='{{.ImageData}}'{{end}} role='link' tabindex='0' data-card-href='{{webPath "plant.edit" $.Garden.ID .ID}}'><div class='card-content'>
<p class='status'>{{plantStatusName .Status}}</p>
<h3><a class='card-link' href='{{webPath "plant.edit" $.Garden.ID .ID}}'>{{.Name}}</a></h3>
<p>{{speciesName $.Species .SpeciesID}}</p>
{{with .PlantedByName}}<p class='muted'>Gepflanzt von {{.}}</p>{{end}}
<div class='plant-location-summary'>{{range index $.PlantLocations .ID}}<p><span>{{.Quantity}}</span><span>x</span><span>{{index $.LocationNames .LocationID}}</span></p>{{end}}</div>
{{with .Notes}}<p>{{.}}</p>{{end}}</div>
{{if canGardenResource $.Garden $.CurrentUser .CreatedBy "plants:update:own" "plants:update:other"}}
<details class='user-menu plant-card-menu card-action'>
<summary aria-label='Status von {{.Name}} ändern' title='Status ändern'><span class='burger-icon' aria-hidden='true'></span></summary>
<div class='user-menu-content'>
<span class='menu-heading'>Status ändern</span>
{{range plantStatuses}}
<form method='POST' action='{{webPath "plant.status" $.Garden.ID $plant.ID}}'>
<input type='hidden' name='csrf_token' value='{{$.CSRFToken}}'>
<input type='hidden' name='status' value='{{.Value}}'>
<button type='submit' class='link-button' {{if eqString $plant.Status .Value}}disabled{{end}}>{{.Label}}</button>
</form>
{{end}}
</div>
</details>
{{end}}
</article>
{{else}}
<p>In diesem Garten wurden noch keine Pflanzen erfasst.</p>
{{end}}
</section>
{{template "pagination" .}}
{{end}}
@@ -0,0 +1,89 @@
{{define "title"}}{{if .PlantID}}Pflanze bearbeiten{{else}}Pflanze erfassen{{end}}{{end}}
{{define "main"}}
{{$canSave := canGarden .Garden "plants:create"}}{{if .PlantID}}{{$canSave = canGardenResource .Garden .CurrentUser .PlantCreatedBy "plants:update:own" "plants:update:other"}}{{end}}
<div class='{{if .PlantID}}settings-layout{{end}}'>
{{if .PlantID}}<aside class='settings-sidebar panel'><nav aria-label='Bearbeitungsbereiche'><a href='#plant-general'>Allgemein</a><a href='#plant-tasks'>Aufgaben</a></nav></aside>{{end}}
<div class='settings-content'><section class='panel plant-detail' id='plant-general'>
<h2>{{if .PlantID}}Pflanze bearbeiten{{else}}Pflanze erfassen{{end}}</h2>
{{$form := .Form}}
<form action='{{if .PlantID}}{{webPath "plant.edit" .Garden.ID .PlantID}}{{else}}{{webPath "plant.new" .Garden.ID}}{{end}}' method='POST'>
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
<label for='name'>Name</label>
<input id='name' name='name' value='{{$form.Name}}' required data-plant-name>
{{with index $form.Errors "name"}}<p class='field-error'>{{.}}</p>{{end}}
{{template "image_editor" (dict "ImageData" $form.ImageData "ImageID" $form.ImageID "Images" .Images "GardenID" .Garden.ID)}}
<label for='species_id'>Art oder Sorte</label>
<select id='species_id' name='species_id' data-plant-species hx-get='{{if .PlantID}}{{pathWithQuery (webPath "plant.template-tasks" .Garden.ID) "plant_id" .PlantID}}{{else}}{{webPath "plant.template-tasks" .Garden.ID}}{{end}}' hx-trigger='change' hx-target='#plant-template-tasks' hx-swap='outerHTML' hx-include='this'>
<option value='0' {{if eqInt $form.SpeciesID 0}}selected{{end}}>Ohne Artzuordnung</option>
{{range .Species}}<option value='{{.ID}}' data-plant-name-value='{{.CommonName}}{{with .Cultivar}} · {{.}}{{end}}' data-sun='{{stringValue .SunExposure}}' data-soil='{{stringValue .SoilCondition}}' data-reaction='{{stringValue .SoilReaction}}' {{if eqInt $form.SpeciesID .ID}}selected{{end}}>{{.CommonName}}{{with .Cultivar}} · {{.}}{{end}}</option>{{end}}
</select>
{{with index $form.Errors "species_id"}}<p class='field-error'>{{.}}</p>{{end}}
<fieldset class='assignment-fieldset'>
<legend>Orte und Anzahl</legend>
{{if not .PlantID}}{{$locationURL := pathWithQuery (webPath "location.new" .Garden.ID) "return_to" (webPath "plant.new" .Garden.ID)}}<a class='create-location-link' href='{{$locationURL}}' hx-get='{{$locationURL}}' hx-target='#location-dialog-host' hx-swap='innerHTML'>Ort direkt anlegen</a>{{end}}
<div class='assignment-list' data-assignment-list>
{{range $index, $assignment := $form.Assignments}}
<div class='assignment-row' data-assignment-row>
<input type='hidden' name='assignment_id' value='{{$assignment.AssignmentID}}'>
<div>
<label for='{{if eqInt $index 0}}location_id{{else}}location_id_{{$index}}{{end}}'>Ort</label>
<select id='{{if eqInt $index 0}}location_id{{else}}location_id_{{$index}}{{end}}' name='location_id' class='assignment-location'>
<option value='0' {{if eqInt $assignment.LocationID 0}}selected{{end}}>Noch keinem Ort zuordnen</option>
{{range $.Locations}}<option value='{{.ID}}' {{if eqInt $assignment.LocationID .ID}}selected{{end}}>{{.Name}}</option>{{end}}
</select>
</div>
<div class='quantity-field'>
<label for='quantity_{{$index}}'>Anzahl</label>
<input id='quantity_{{$index}}' name='quantity' type='number' min='1' value='{{$assignment.Quantity}}'>
{{with index $form.Errors (printf "quantity_%d" $index)}}<p class='field-error'>{{.}}</p>{{end}}
</div>
<button type='button' class='assignment-remove secondary' data-remove-assignment aria-label='Ort entfernen' title='Ort entfernen'>×</button>
</div>
{{end}}
</div>
<button type='button' class='icon-button assignment-add' data-add-assignment aria-label='Weiteren Ort hinzufügen' title='Weiteren Ort hinzufügen'></button>
</fieldset>
<template id='assignment-row-template'>
<div class='assignment-row' data-assignment-row>
<input type='hidden' name='assignment_id' value='0'>
<div><label>Ort</label><select name='location_id'><option value='0' selected>Noch keinem Ort zuordnen</option>{{range .Locations}}<option value='{{.ID}}'>{{.Name}}</option>{{end}}</select></div>
<div class='quantity-field'><label>Anzahl</label><input name='quantity' type='number' min='1' value='1'></div>
<button type='button' class='assignment-remove secondary' data-remove-assignment aria-label='Ort entfernen' title='Ort entfernen'>×</button>
</div>
</template>
<div hidden data-location-compatibilities>{{range .Locations}}<span data-location-id='{{.ID}}' data-sun='{{stringValue .SunExposure}}' data-soil='{{stringValue .SoilCondition}}' data-reaction='{{stringValue .SoilReaction}}'></span>{{end}}</div><div class='compatibility-warning' data-compatibility-warning role='status' hidden></div>
<label for='acquired_at'>Erworben oder gepflanzt am</label>
<input id='acquired_at' name='acquired_at' type='date' value='{{$form.AcquiredAt}}'>
{{with index $form.Errors "acquired_at"}}<p class='field-error'>{{.}}</p>{{end}}
<label for='status'>Status</label>
<select id='status' name='status'>
{{range plantStatuses}}<option value='{{.Value}}' {{if eqString $form.Status .Value}}selected{{end}}>{{.Label}}</option>{{end}}
</select>
{{template "tag_editor" .}}
<label for='notes'>Notizen</label>
<textarea id='notes' name='notes' rows='5'>{{$form.Notes}}</textarea>
{{with index $form.Errors "notes"}}<p class='field-error'>{{.}}</p>{{end}}
<fieldset class='plant-task-fieldset' id='plant-tasks'>
<legend>Aufgaben</legend>
{{template "plant_template_tasks" .}}
<div id='plant-manual-tasks' class='plant-task-list'>
{{range $form.Tasks}}{{template "plant_task_row_fragment" (plantTaskData $.Garden .)}}{{end}}
</div>
<button type='button' class='icon-button plant-task-add' hx-get='{{pathWithQuery (webPath "plant.task-row" .Garden.ID) "new" true "plant_id" .PlantID}}' hx-include='#name, #location_id' hx-target='#plant-task-dialog-host' hx-swap='innerHTML' aria-label='Weitere Aufgabe hinzufügen' title='Weitere Aufgabe hinzufügen'></button>
</fieldset>
<div class='actions'>{{if $canSave}}<button type='submit'>Speichern</button>{{end}}<a href='{{webPath "plants" .Garden.ID}}'>{{if $canSave}}Abbrechen{{else}}Zurück{{end}}</a></div>
</form>
<div id='location-dialog-host'></div>
<div id='plant-task-dialog-host'></div>
{{if and .PlantID (canGardenResource .Garden .CurrentUser .PlantCreatedBy "plants:delete:own" "plants:delete:other")}}<form class='delete-form' method='POST' action='{{webPath "plant.delete" .Garden.ID .PlantID}}'><input type='hidden' name='csrf_token' value='{{.CSRFToken}}'><button type='submit' class='danger'>Pflanze löschen</button></form>{{end}}
</section>
</div></div>
{{end}}
@@ -0,0 +1,2 @@
{{define "title"}}{{if .LocationID}}Zuordnung bearbeiten{{else}}Ort zuordnen{{end}}{{end}}
{{define "main"}}{{$editable := canGarden .Garden "content:write"}}<section class='panel narrow'><h2>{{(index .Plants 0).Name}}: Ort {{if .LocationID}}bearbeiten{{else}}zuordnen{{end}}</h2>{{$form:=.Form}}<form method='POST'><input type='hidden' name='csrf_token' value='{{.CSRFToken}}'><label for='location_id'>Ort</label><select id='location_id' name='location_id' required><option value='0'>Bitte wählen</option>{{range .Locations}}<option value='{{.ID}}' {{if eqInt $form.LocationID .ID}}selected{{end}}>{{.Name}}</option>{{end}}</select>{{with index $form.Errors "location_id"}}<p class='field-error'>{{.}}</p>{{end}}<label for='quantity'>Anzahl</label><input id='quantity' type='number' min='1' name='quantity' value='{{$form.Quantity}}' required><label for='planted_at'>Gepflanzt am</label><input id='planted_at' type='date' name='planted_at' value='{{$form.PlantedAt}}'><label for='notes'>Notizen</label><textarea id='notes' name='notes'>{{$form.Notes}}</textarea><div class='actions'>{{if $editable}}<button>Speichern</button>{{end}}<a href='{{webPath "plants" .Garden.ID}}'>{{if $editable}}Abbrechen{{else}}Zurück{{end}}</a></div></form></section>{{end}}
+15
View File
@@ -0,0 +1,15 @@
{{define "title"}}Datenschutz{{end}}
{{define "main"}}
<header class='page-heading'><div><p class='eyebrow'>Rechtliches</p><h2>Datenschutz</h2></div></header>
<article class='panel legal-content'>
<p>Diese Gardomatic-Instanz verarbeitet nur Daten, die für Benutzerkonten und die gemeinsame Gartenverwaltung benötigt werden.</p>
<h3>Verarbeitete Daten</h3>
<p>Dazu gehören Kontaktdaten des Benutzerkontos, Sitzungsdaten sowie die von Nutzern eingetragenen Garten-, Pflanzen-, Aufgaben-, Tagebuch- und Mediendaten. Serverprotokolle können außerdem technische Verbindungsdaten enthalten.</p>
<h3>Zweck und Weitergabe</h3>
<p>Die Daten werden zur Bereitstellung und Absicherung der Anwendung verarbeitet. Eine Weitergabe erfolgt nur an technisch notwendige Dienstleister des Instanzbetreibers oder wenn eine gesetzliche Verpflichtung besteht.</p>
<h3>Speicherdauer und Rechte</h3>
<p>Daten werden gelöscht oder anonymisiert, sobald sie für den Betrieb nicht mehr erforderlich sind. Betroffene Personen können beim Betreiber dieser Instanz Auskunft, Berichtigung, Löschung oder Einschränkung der Verarbeitung anfragen.</p>
<h3>Verantwortlicher</h3>
<p>Verantwortlich ist der Betreiber dieser Gardomatic-Instanz. Die konkreten Kontakt- und Hostingangaben sind vom Betreiber vor dem öffentlichen Einsatz zu ergänzen.</p>
</article>
{{end}}
+16
View File
@@ -0,0 +1,16 @@
{{define "title"}}Suche{{end}}
{{define "main"}}
<header class='page-heading'><div><h2>Suche</h2></div><div class='actions'>{{template "view_toggle" "search"}}</div></header>
<form class='search-form' method='GET'><label for='q'>Freitextsuche</label><div class='search-fields'><input id='q' name='q' type='search' value='{{.Search.Query}}' placeholder='z. B. Tomate, Rückschnitt oder #ernte'><button>Suchen</button></div><div class='search-fields'><label for='month'>Monat</label><select id='month' name='month'><option value=''>Alle Monate</option>{{range months}}<option value='{{.Value}}'{{if eq $.Search.Month .Value}} selected{{end}}>{{.Label}}</option>{{end}}</select><label for='year'>Jahr</label><select id='year' name='year'><option value=''>Alle Jahre</option>{{range .Search.Years}}<option value='{{.}}'{{if eq $.Search.Year .}} selected{{end}}>{{.}}</option>{{end}}</select><button name='mode' value='month'>Suchen</button></div><p class='muted'>Monat und Jahr beziehen sich bei Aufgaben auf das Start- oder Enddatum.</p></form>
{{if or .Search.Query .Search.Month .Search.Year}}
<nav class='search-tabs' aria-label='Suchkategorien'><a href='#search-all' data-search-tab='search-all' aria-current='page'>Alle <span>{{len .Search.All}}</span></a><a href='#search-journal' data-search-tab='search-journal'>Tagebuch <span>{{len .Search.Journal}}</span></a><a href='#search-pinboard' data-search-tab='search-pinboard'>Pinnwand <span>{{len .Search.Pinboard}}</span></a><a href='#search-plants' data-search-tab='search-plants'>Im Garten <span>{{len .Search.Plants}}</span></a><a href='#search-tasks' data-search-tab='search-tasks'>Aufgaben <span>{{len .Search.Tasks}}</span></a><a href='#search-tags' data-search-tab='search-tags'>Tags <span>{{len .Search.Tags}}</span></a><a href='#search-species' data-search-tab='search-species'>Pflanzen <span>{{len .Search.Species}}</span></a></nav>
<section id='search-all' class='search-results'><h3>Alle Treffer</h3>{{template "search_cards" .Search.All}}</section>
<section id='search-journal' class='search-results'><h3>Tagebuch</h3>{{template "search_cards" .Search.Journal}}</section>
<section id='search-pinboard' class='search-results'><h3>Pinnwand</h3>{{template "search_cards" .Search.Pinboard}}</section>
<section id='search-plants' class='search-results'><h3>Im Garten</h3>{{template "search_cards" .Search.Plants}}</section>
<section id='search-tasks' class='search-results'><h3>Aufgaben</h3>{{template "search_cards" .Search.Tasks}}</section>
<section id='search-tags' class='search-results'><h3>Tags</h3>{{template "search_cards" .Search.Tags}}</section>
<section id='search-species' class='search-results'><h3>Pflanzen und Pflanzzeiten</h3>{{template "search_cards" .Search.Species}}</section>
{{end}}
{{end}}
{{define "search_cards"}}<div class='search-card-list'>{{range .}}<a class='search-card' href='{{.URL}}'><span><strong>{{.Title}}</strong>{{with .Detail}}<span>{{.}}</span>{{end}}{{with .Meta}}<small>{{.}}</small>{{end}}</span><em>{{.Type}}</em></a>{{else}}<p>Keine Treffer.</p>{{end}}</div>{{end}}
@@ -0,0 +1,37 @@
{{define "title"}}Einstellungen{{end}}
{{define "main"}}
<header class='page-heading'><div><p class='eyebrow'>Nutzerkonto</p><h2>Einstellungen</h2></div></header>
<div class='settings-layout'>
{{template "settings_nav" (dict "Kind" "user")}}
<form class='settings-content' data-view-settings>
<section class='panel' id='list-settings'>
<h3>Seitengröße</h3>
<p>Lege fest, wie viele Einträge auf Listen- und Übersichtsseiten erscheinen.</p>
<label for='entries-per-page'>Einträge pro Seite</label>
<select id='entries-per-page' name='entriesPerPage'><option value='10'>10</option><option value='20'>20</option><option value='50'>50</option><option value='100'>100</option></select>
</section>
<section class='panel' id='view-settings'>
<h3>Ansichten</h3>
<label for='view-default'>Standard für alle Seiten</label>
<select id='view-default' name='default'><option value='grid'>Kacheln</option><option value='list'>Liste</option></select>
<fieldset><legend>Abweichungen je Seite</legend>
<label for='view-dashboard'>Gartenübersicht</label><select id='view-dashboard' name='dashboard'><option value='inherit'>Standard verwenden</option><option value='grid'>Kacheln</option><option value='list'>Liste</option></select>
<label for='view-gardens'>Gärten</label><select id='view-gardens' name='gardens'><option value='inherit'>Standard verwenden</option><option value='grid'>Kacheln</option><option value='list'>Liste</option></select>
<label for='view-plants'>Im Garten</label><select id='view-plants' name='plants'><option value='inherit'>Standard verwenden</option><option value='grid'>Kacheln</option><option value='list'>Liste</option></select>
<label for='view-species'>Pflanzen</label><select id='view-species' name='species'><option value='inherit'>Standard verwenden</option><option value='grid'>Kacheln</option><option value='list'>Liste</option></select>
<label for='view-locations'>Orte</label><select id='view-locations' name='locations'><option value='inherit'>Standard verwenden</option><option value='grid'>Kacheln</option><option value='list'>Liste</option></select>
<label for='view-tasks'>Aufgaben</label><select id='view-tasks' name='tasks'><option value='inherit'>Standard verwenden</option><option value='grid'>Kacheln</option><option value='list'>Liste</option></select>
<label for='view-location-plants'>Pflanzen an einem Ort</label><select id='view-location-plants' name='location-plants'><option value='inherit'>Standard verwenden</option><option value='grid'>Kacheln</option><option value='list'>Liste</option></select>
<label for='view-search'>Suchergebnisse</label><select id='view-search' name='search'><option value='inherit'>Standard verwenden</option><option value='grid'>Kacheln</option><option value='list'>Liste</option></select>
</fieldset>
<p class='form-message' data-settings-message aria-live='polite'></p>
<button type='submit'>Einstellungen speichern</button>
</section>
<section class='panel' id='editor-settings'>
<h3>Texteingabe</h3>
<p>Wähle, wie Texte im Tagebuch und an der Pinnwand bearbeitet werden.</p>
<label for='journal-editor-mode'>Editor</label>
<select id='journal-editor-mode' name='journalEditor'><option value='rich'>Formatierter Editor</option><option value='plain'>Einfaches Textfeld</option></select>
</section>
</form>
</div>{{end}}
+23
View File
@@ -0,0 +1,23 @@
{{define "title"}}Anmelden{{end}}
{{define "main"}}
<section class='panel narrow'>
<h2>Anmelden</h2>
{{$form := .Form}}
{{with $form.Message}}<p class='form-message error'>{{.}}</p>{{end}}
<form action='{{webPath "login"}}' method='POST'>
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
<label for='email'>E-Mail-Adresse</label>
<input id='email' name='email' type='email' value='{{$form.Email}}' autocomplete='email' required>
{{with index $form.Errors "email"}}<p class='field-error'>{{.}}</p>{{end}}
<label for='password'>Passwort</label>
<input id='password' name='password' type='password' autocomplete='current-password' required>
{{with index $form.Errors "password"}}<p class='field-error'>{{.}}</p>{{end}}
<label class='checkbox'><input name='remember_email' type='checkbox' value='true' {{if $form.RememberEmail}}checked{{end}}> E-Mail merken</label>
<button type='submit'>Anmelden</button>
</form>
</section>
{{end}}
+18
View File
@@ -0,0 +1,18 @@
{{define "title"}}Pflanzen{{end}}
{{define "main"}}
<header class='page-heading'>
<div><h2>Pflanzen</h2></div>
<div class='actions'>{{if or (canGarden .Garden "species:write") (canGlobalSpecies .CurrentUser)}}<a class='button' href='{{webPath "species.new" .Garden.ID}}'>Art anlegen</a>{{end}}{{template "view_toggle" "species"}}</div>
</header>
<form class='panel filter-bar' method='GET'><div class='filter-fields'><div><label for='q'>Suche</label><input id='q' name='q' type='search' value='{{index .Filters "q"}}' placeholder='Name, Sorte oder Kategorie'></div><div><label for='origin'>Herkunft</label><select id='origin' name='origin'><option value=''>Alle</option><option value='garden' {{if eqString (index .Filters "origin") "garden"}}selected{{end}}>Garteneigen</option><option value='global' {{if eqString (index .Filters "origin") "global"}}selected{{end}}>Global</option></select></div><div><label for='sort'>Sortierung</label><select id='sort' name='sort'><option value='name_asc'>Name AZ</option><option value='name_desc' {{if eqString (index .Filters "sort") "name_desc"}}selected{{end}}>Name ZA</option><option value='newest' {{if eqString (index .Filters "sort") "newest"}}selected{{end}}>Neueste zuerst</option><option value='oldest' {{if eqString (index .Filters "sort") "oldest"}}selected{{end}}>Älteste zuerst</option></select></div><button class='filter-submit'>Filtern</button></div></form>
<section class='card-grid collection' data-view-key='species'>
{{range .Species}}
<article class='card card--interactive {{if .ImageData}}card--image{{end}}' {{if .ImageData}}data-background-image='{{.ImageData}}'{{end}} role='link' tabindex='0' data-card-href='{{webPath "species.edit" $.Garden.ID .ID}}' aria-label='{{.CommonName}}{{with .Cultivar}} · {{.}}{{end}} öffnen'><div class='card-content'>
<span class='status'>{{if .GardenID}}Garteneigen{{else}}Global{{end}}</span>
<h3>{{.CommonName}}{{with .Cultivar}} · {{.}}{{end}}</h3>
{{with .BotanicalName}}<p>{{.}}</p>{{end}}</div>
</article>
{{else}}<p>Keine Arten verfügbar.</p>{{end}}
</section>
{{template "pagination" .}}
{{end}}
@@ -0,0 +1,72 @@
{{define "title"}}{{if .SpeciesID}}Art ansehen{{else}}Art anlegen{{end}}{{end}}
{{define "main"}}
{{$form := .Form}}
{{$editable := canEditSpecies .Garden .CurrentUser $form.Global .SpeciesID}}
{{$step := .WizardStep}}
{{$all := eqString $step "all"}}
{{$general := or $all (eqString $step "") (eqString $step "general")}}
<div class='{{if .SpeciesID}}settings-layout{{end}}'>
{{if .SpeciesID}}<aside class='settings-sidebar panel'><nav aria-label='Bearbeitungsbereiche'><a href='{{if $all}}#species-general{{else}}{{pathWithQuery (webPath "species.edit" .Garden.ID .SpeciesID) "step" "general"}}{{end}}' {{if and (not $all) $general}}aria-current='page'{{end}}>Allgemein</a><a href='{{if $all}}#species-care{{else}}{{pathWithQuery (webPath "species.edit" .Garden.ID .SpeciesID) "step" "care"}}{{end}}' {{if eqString $step "care"}}aria-current='page'{{end}}>Pflegeanweisungen</a><a href='{{if $all}}#species-tasks{{else}}{{pathWithQuery (webPath "species.edit" .Garden.ID .SpeciesID) "step" "tasks"}}{{end}}' {{if eqString $step "tasks"}}aria-current='page'{{end}}>Aufgabenvorlagen</a></nav></aside>{{end}}
<div class='settings-content'>
{{if $general}}
<section class='panel species-detail' id='species-general'>
<h2>{{if .SpeciesID}}{{if $editable}}{{if $form.Global}}Globale Art bearbeiten{{else}}Art bearbeiten{{end}}{{else}}Art ansehen{{end}}{{else}}Art anlegen{{end}}</h2>
{{if and .SpeciesID (not $editable)}}<p class='muted'>Diese Art ist nur lesbar.</p>{{end}}
<form method='POST'>
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
{{if and .SpeciesID $form.Global}}<input type='hidden' name='global' value='true'>{{end}}
{{if and (not .SpeciesID) (canGlobalSpecies .CurrentUser)}}<label class='checkbox'><input type='checkbox' name='global' value='true' {{if or $form.Global (not (canGarden .Garden "species:write"))}}checked{{end}} {{if not (canGarden .Garden "species:write")}}disabled{{end}}> Global anlegen <span class='muted'>für alle Gärten verfügbar</span></label>{{if not (canGarden .Garden "species:write")}}<input type='hidden' name='global' value='true'>{{end}}{{end}}
{{if not .SpeciesID}}<label for='template_species_id'>Vorlage</label><select id='template_species_id' name='template_species_id' data-species-template><option value='0'>Ohne Vorlage</option>{{range .Species}}<option value='{{.ID}}' {{if eqInt $form.TemplateSpeciesID .ID}}selected{{end}}>{{.CommonName}}{{with .Cultivar}} · {{.}}{{end}}</option>{{end}}</select><p class='muted'>Die Felder und Aufgabenvorlagen werden übernommen; der Name bleibt leer.</p>{{end}}
<fieldset class='plain-fieldset' {{if not $editable}}disabled{{end}}>
<label>Name</label><input name='common_name' value='{{$form.CommonName}}' required>{{with index $form.Errors "common_name"}}<p class='field-error'>{{.}}</p>{{end}}
<label>Sorte</label><input name='cultivar' value='{{$form.Cultivar}}'>
<label>Botanischer Name</label><input name='botanical_name' value='{{$form.BotanicalName}}'>
{{template "image_editor" (dict "ImageData" $form.ImageData "ImageID" $form.ImageID "Images" .Images "GardenID" .Garden.ID)}}
<input type='hidden' name='attributes' value='{{$form.Attributes}}'>
<label for='category_id'>Kategorie</label>
<select id='category_id' name='category_id'>
<option value='0' {{if eqInt $form.CategoryID 0}}selected{{end}}>Keine Kategorie</option>
{{range .SpeciesCategories}}{{if or .Active (eqInt $form.CategoryID .ID)}}<option value='{{.ID}}' {{if eqInt $form.CategoryID .ID}}selected{{end}}>{{.Name}}{{with lifecycleName .Lifecycle}} ({{.}}){{end}}{{if not .Active}} (inaktiv){{end}}</option>{{end}}{{end}}
</select>
{{with index $form.Errors "category_id"}}<p class='field-error'>{{.}}</p>{{end}}
<div class='form-grid'><label>Licht<select name='sun_exposure'><option value=''>Nicht angegeben</option><option value='sunny' {{if eqString $form.SunExposure "sunny"}}selected{{end}}>Sonnig</option><option value='partial_shade' {{if eqString $form.SunExposure "partial_shade"}}selected{{end}}>Halbschattig</option><option value='shade' {{if eqString $form.SunExposure "shade"}}selected{{end}}>Schattig</option></select></label><label>Bodenbeschaffenheit<select name='soil_condition'><option value=''>Nicht angegeben</option><option value='dry' {{if eqString $form.SoilCondition "dry"}}selected{{end}}>Trocken</option><option value='moist' {{if eqString $form.SoilCondition "moist"}}selected{{end}}>Feucht</option><option value='boggy' {{if eqString $form.SoilCondition "boggy"}}selected{{end}}>Sumpfig</option></select></label><label>Bodenreaktion<select name='soil_reaction'><option value=''>Nicht angegeben</option><option value='alkaline' {{if eqString $form.SoilReaction "alkaline"}}selected{{end}}>Basisch</option><option value='acidic' {{if eqString $form.SoilReaction "acidic"}}selected{{end}}>Sauer</option><option value='neutral' {{if eqString $form.SoilReaction "neutral"}}selected{{end}}>Neutral</option></select></label><label>Winterschutz<input name='winter_protection' value='{{$form.WinterProtection}}'></label><label>Pflanzabstand in cm<input type='number' min='1' name='spacing_cm' value='{{if $form.SpacingCM}}{{$form.SpacingCM}}{{end}}'></label><label>Höhe in cm<input type='number' min='1' name='height_cm' value='{{if $form.HeightCM}}{{$form.HeightCM}}{{end}}'></label></div>
{{template "season_range" (dict "Legend" "Aussaat" "Prefix" "sow" "Month" $form.SowMonthFrom "Day" $form.SowDayFrom "Duration" $form.SowDuration "Unit" $form.SowDurationUnit)}}
{{template "season_range" (dict "Legend" "Pflanzzeit" "Prefix" "planting" "Month" $form.PlantingMonthFrom "Day" $form.PlantingDayFrom "Duration" $form.PlantingDuration "Unit" $form.PlantingDurationUnit)}}
{{template "season_range" (dict "Legend" "Ernte" "Prefix" "harvest" "Month" $form.HarvestMonthFrom "Day" $form.HarvestDayFrom "Duration" $form.HarvestDuration "Unit" $form.HarvestDurationUnit)}}
{{template "tag_editor" .}}
<label>Notizen</label><textarea name='notes'>{{$form.Notes}}</textarea>
</fieldset>
<div class='actions'>{{if $editable}}{{if $all}}<button type='submit'>Speichern</button>{{else}}<button type='submit' name='continue' value='care'>Speichern und weiter</button><button type='submit' class='secondary'>Speichern und beenden</button>{{end}}{{end}}<a href='{{webPath "species" .Garden.ID}}'>{{if $editable}}Abbrechen{{else}}Zurück{{end}}</a>{{if and .SpeciesID $editable}}<button class='danger' type='submit' formaction='{{webPath "species.delete" .Garden.ID .SpeciesID}}' formmethod='post' formnovalidate>Art löschen</button>{{end}}</div>
</form>
</section>
{{end}}
{{if and .SpeciesID (or $all (eqString $step "care"))}}<div class='species-support-grid'>
<section class='panel care-instructions' id='species-care'>
<h3>Pflegeanweisungen</h3>
{{template "care_instruction_list" .}}
{{if $editable}}<details class='care-instruction-add'><summary class='icon-button' aria-label='Pflegeanweisung hinzufügen' title='Pflegeanweisung hinzufügen'></summary><form method='POST' action='{{webPath "species.care.new" .Garden.ID .SpeciesID}}' hx-post='{{webPath "species.care.new" .Garden.ID .SpeciesID}}' hx-target='#care-instruction-list' hx-swap='innerHTML'><input type='hidden' name='csrf_token' value='{{.CSRFToken}}'><label>Text<textarea name='text' required></textarea></label><label>Status<select name='status'>{{range careStatuses}}<option value='{{.Value}}'>{{.Label}}</option>{{end}}</select></label><button>Hinzufügen</button></form></details>{{end}}
{{if not $all}}<div class='actions'><a class='button' href='{{pathWithQuery (webPath "species.edit" .Garden.ID .SpeciesID) "step" "tasks"}}'>Weiter zu Aufgabenvorlagen</a><a href='{{webPath "species" .Garden.ID}}'>Überspringen und beenden</a></div>{{end}}
</section>
</div>{{end}}
{{if and .SpeciesID (or $all (eqString $step "tasks"))}}<div class='species-support-grid'>
<section class='panel species-task-templates' id='species-tasks'>
<h3>Aufgabenvorlagen</h3>
<div class='species-template-list'>
{{range index .TaskTemplates .SpeciesID}}
<div class='species-template-row'>
{{if $editable}}
<button type='button' class='species-template-open' hx-get='{{pathWithQuery (webPath "task-template.edit" $.Garden.ID .ID) "species_id" $.SpeciesID}}' hx-target='#task-template-dialog-host' hx-swap='innerHTML'>{{.Title}}{{if and .Origin (not (eqString .Origin "manual"))}} · automatisch{{end}}{{if not .Active}} · inaktiv{{end}}</button>
{{if or (not .Origin) (eqString .Origin "manual")}}<form method='POST' action='{{pathWithQuery (webPath "task-template.delete" $.Garden.ID .ID) "species_id" $.SpeciesID}}'><input type='hidden' name='csrf_token' value='{{$.CSRFToken}}'><button type='submit' class='template-remove' aria-label='{{.Title}} entfernen' title='Vorlage entfernen'>×</button></form>{{end}}
{{else}}<span>{{.Title}}</span>{{end}}
</div>
{{else}}<p class='muted'>Noch keine Vorlagen.</p>{{end}}
</div>
{{if $editable}}<button type='button' class='icon-button species-template-add' hx-get='{{pathWithQuery (webPath "task-template.new" .Garden.ID) "species_id" .SpeciesID}}' hx-target='#task-template-dialog-host' hx-swap='innerHTML' aria-label='Aufgabenvorlage hinzufügen' title='Aufgabenvorlage hinzufügen'></button>{{end}}
<div id='task-template-dialog-host'></div>
{{if not $all}}<div class='actions'><a class='button' href='{{webPath "species" .Garden.ID}}'>Fertig</a></div>{{end}}
</section>
</div>{{end}}
</div></div>
{{end}}
@@ -0,0 +1,6 @@
{{define "title"}}Aufgabenkalender{{end}}
{{define "main"}}
<header class='page-heading'><div><h2>Anstehende Aufgaben</h2><p>{{calendarDate .CalendarStart}} bis {{calendarDate .CalendarEnd}}</p></div><div class='actions'><a class='button secondary' href='{{pathWithQuery (webPath "tasks.calendar" .Garden.ID) "view" .CalendarView "date" .PreviousDate}}'>Zurück</a><a class='button secondary' href='{{pathWithQuery (webPath "tasks.calendar" .Garden.ID) "view" .CalendarView "date" .NextDate}}'>Weiter</a></div></header>
<nav class='view-switch' aria-label='Kalenderansicht'><a href='{{pathWithQuery (webPath "tasks.calendar" .Garden.ID) "view" "week" "date" (dateValue .CalendarStart)}}' {{if eqString .CalendarView "week"}}aria-current='page'{{end}}>Woche</a><a href='{{pathWithQuery (webPath "tasks.calendar" .Garden.ID) "view" "month" "date" (dateValue .CalendarStart)}}' {{if eqString .CalendarView "month"}}aria-current='page'{{end}}>Monat</a></nav>
<section class='calendar-grid {{.CalendarView}}'>{{range .CalendarDays}}<article class='calendar-day'><h3><time datetime='{{dateValue .Date}}'>{{calendarDate .Date}}</time></h3>{{range .Tasks}}{{$editable := canGardenResource $.Garden $.CurrentUser .CreatedBy "tasks:update:own" "tasks:update:other"}}{{if $editable}}<a class='calendar-task' href='{{webPath "task.edit" $.Garden.ID .ID}}'><strong>{{.Title}}</strong><span>{{taskDue .}}</span></a>{{else}}<div class='calendar-task'><strong>{{.Title}}</strong><span>{{taskDue .}}</span></div>{{end}}{{else}}<span class='muted'>Keine Aufgaben</span>{{end}}</article>{{end}}</section>
{{end}}
@@ -0,0 +1,13 @@
{{define "title"}}{{if .TaskID}}Aufgabe bearbeiten{{else}}Aufgabe anlegen{{end}}{{end}}
{{define "main"}}
{{$canSave := canGarden .Garden "tasks:create"}}{{if .TaskID}}{{$canSave = canGardenResource .Garden .CurrentUser .TaskCreatedBy "tasks:update:own" "tasks:update:other"}}{{end}}
<section class='panel narrow'>
<h2>{{if .TaskID}}Aufgabe bearbeiten{{else}}Neue Aufgabe{{end}}</h2>
<form method='POST'>
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
{{template "task_fields" (dict "Form" .Form "Prefix" "" "ShowPlant" true "Plants" .Plants "Locations" .Locations "TaskPriorities" .TaskPriorities "TagSuggestions" .TagSuggestions)}}
<div class='actions'>{{if $canSave}}<button type='submit'>Speichern</button>{{end}}<a href='{{webPath "tasks" .Garden.ID}}'>{{if $canSave}}Abbrechen{{else}}Zurück{{end}}</a></div>
</form>
{{if and .TaskID (canGardenResource .Garden .CurrentUser .TaskCreatedBy "tasks:delete:own" "tasks:delete:other")}}<form class='delete-form' method='POST' action='{{webPath "task.delete" .Garden.ID .TaskID}}'><input type='hidden' name='csrf_token' value='{{.CSRFToken}}'><button class='danger' type='submit'>Aufgabe löschen</button></form>{{end}}
</section>
{{end}}
@@ -0,0 +1,11 @@
{{define "title"}}Aufgabenvorlage{{end}}
{{define "main"}}
<section class='panel'>{{$species := index .Species 0}}
<p class='eyebrow'>{{$species.CommonName}}</p><h2>{{if .TemplateID}}Vorlage bearbeiten{{else}}Vorlage anlegen{{end}}</h2>
<form method='POST'>
{{template "task_template_fields" .}}
<div class='actions'><button type='submit'>Speichern</button><a href='{{webPath "species.edit" .Garden.ID .SpeciesID}}'>Abbrechen</a></div>
</form>
</section>
{{end}}
+32
View File
@@ -0,0 +1,32 @@
{{define "title"}}Aufgaben{{end}}
{{define "main"}}
<header class='page-heading'>
<div><h2>Aufgaben</h2></div>
<div class='actions'><a class='button secondary' href='{{webPath "tasks.calendar" .Garden.ID}}'>Kalender</a>{{if canGarden .Garden "tasks:create"}}<a class='button' href='{{webPath "task.new" .Garden.ID}}'>Aufgabe anlegen</a>{{end}}{{template "view_toggle" "tasks"}}</div>
</header>
<form class='panel filter-bar' method='GET'><div class='filter-fields filter-fields--tasks'><div><label for='status'>Status</label><select id='status' name='status'><option value=''>Alle</option><option value='open' {{if eqString (index .Filters "status") "open"}}selected{{end}}>Offen</option><option value='done' {{if eqString (index .Filters "status") "done"}}selected{{end}}>Erledigt</option></select></div><div><label for='plant'>Pflanze</label><select id='plant' name='plant'><option value=''>Alle</option>{{range .Plants}}<option value='{{.ID}}' {{if eqString (index $.Filters "plant") (printf "%d" .ID)}}selected{{end}}>{{.Name}}</option>{{end}}</select></div><div><label for='location'>Ort</label><select id='location' name='location'><option value=''>Alle</option>{{range .Locations}}<option value='{{.ID}}' {{if eqString (index $.Filters "location") (printf "%d" .ID)}}selected{{end}}>{{.Name}}</option>{{end}}</select></div><div><label for='priority'>Priorität</label><select id='priority' name='priority'><option value=''>Alle</option>{{range .TaskPriorities}}<option value='{{.Value}}' {{if eqString (index $.Filters "priority") (printf "%d" .Value)}}selected{{end}}>{{.Name}}</option>{{end}}</select></div><div><label for='month'>Monat</label><select id='month' name='month'><option value=''>Alle</option>{{range months}}<option value='{{.Value}}' {{if eqString (index $.Filters "month") (printf "%d" .Value)}}selected{{end}}>{{.Label}}</option>{{end}}</select></div><div><label for='year'>Jahr</label><select id='year' name='year'><option value=''>Alle</option>{{range .TaskYears}}<option value='{{.}}' {{if eqString (index $.Filters "year") (printf "%d" .)}}selected{{end}}>{{.}}</option>{{end}}</select></div><div><label for='sort'>Sortierung</label><select id='sort' name='sort'><option value='due_asc'>Fälligkeit aufsteigend</option><option value='due_desc' {{if eqString (index .Filters "sort") "due_desc"}}selected{{end}}>Fälligkeit absteigend</option><option value='month_asc' {{if eqString (index .Filters "sort") "month_asc"}}selected{{end}}>Monat aufsteigend</option><option value='month_desc' {{if eqString (index .Filters "sort") "month_desc"}}selected{{end}}>Monat absteigend</option><option value='period_asc' {{if eqString (index .Filters "sort") "period_asc"}}selected{{end}}>Zeitraum kurzlang</option><option value='period_desc' {{if eqString (index .Filters "sort") "period_desc"}}selected{{end}}>Zeitraum langkurz</option><option value='name_asc' {{if eqString (index .Filters "sort") "name_asc"}}selected{{end}}>Name AZ</option><option value='name_desc' {{if eqString (index .Filters "sort") "name_desc"}}selected{{end}}>Name ZA</option><option value='priority_desc' {{if eqString (index .Filters "sort") "priority_desc"}}selected{{end}}>Priorität</option></select></div><button class='filter-submit'>Filtern</button></div></form>
{{if .Tasks}}
<section class='task-list collection' data-view-key='tasks'>
{{range .Tasks}}
{{$editable := canGardenResource $.Garden $.CurrentUser .CreatedBy "tasks:update:own" "tasks:update:other"}}
{{$canComplete := canGardenResource $.Garden $.CurrentUser .CreatedBy "tasks:complete:own" "tasks:complete:other"}}
<article class='panel task-card{{if $editable}} card--interactive{{end}}{{if .CompletedAt}} completed{{end}}'{{if $editable}} role='link' tabindex='0' data-card-href='{{webPath "task.edit" $.Garden.ID .ID}}' aria-label='{{.Title}} bearbeiten'{{end}}>
<div class='task-heading'><div><span class='status'>{{configuredPriorityName $.TaskPriorities .Priority}}</span><h3>{{.Title}}</h3></div><strong>{{taskDueDate .}}</strong></div>
{{with .Description}}<p>{{.}}</p>{{end}}
{{with recurrenceDescription .Recurrence .RecurrenceInterval}}<p class='muted'>Wiederholung: {{.}}</p>{{end}}
<div class='task-card-footer'>
<p class='task-links'>{{with plantName $.Plants .PlantID}}Pflanze: {{.}}{{end}}{{with locationName $.Locations .LocationID}} · Ort: {{.}}{{end}}</p>
{{if $canComplete}}<div class='actions task-card-actions'>
{{if .CompletedAt}}
<form method='POST' action='{{webPath "task.reopen" $.Garden.ID .ID}}' class='inline-form'><input type='hidden' name='csrf_token' value='{{$.CSRFToken}}'><button type='submit'>Wieder öffnen</button></form>
{{else}}
<form method='POST' action='{{webPath "task.complete" $.Garden.ID .ID}}' class='inline-form'><input type='hidden' name='csrf_token' value='{{$.CSRFToken}}'><button type='submit'>Erledigt</button></form>
{{end}}
</div>{{end}}
</div>
</article>
{{end}}
</section>
{{else}}<p>In diesem Garten gibt es noch keine Aufgaben.</p>{{end}}
{{template "pagination" .}}
{{end}}
@@ -0,0 +1,8 @@
{{define "aside"}}
<aside>
{{with .Garden}}
<strong>{{.Name}}</strong>
{{with .Description}}<p>{{.}}</p>{{end}}
{{end}}
</aside>
{{end}}
@@ -0,0 +1,11 @@
{{define "footer"}}
<footer>
<span>Gardomatic · {{.CurrentYear}}</span>
<nav aria-label='Weitere Informationen'>
<a href='https://git.kleiax.de/kleiax/Gardomatic'>Git</a>
<a href='https://kleiax.de'>Kleiax</a>
<a href='{{webPath "healthcheck"}}'>Systemstatus</a>
<a href='{{webPath "privacy"}}'>Datenschutz</a>
</nav>
</footer>
{{end}}
@@ -0,0 +1,11 @@
{{define "garden_fields"}}
{{$form := .Form}}
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
<label for='name'>Name</label>
<input id='name' name='name' value='{{$form.Name}}' required {{if .Autofocus}}autofocus{{end}}>
{{with index $form.Errors "name"}}<p class='field-error'>{{.}}</p>{{end}}
<label for='description'>Beschreibung</label>
<textarea id='description' name='description' rows='3'>{{$form.Description}}</textarea>
{{with index $form.Errors "description"}}<p class='field-error'>{{.}}</p>{{end}}
{{template "image_editor" (dict "ImageData" $form.ImageData "ImageID" $form.ImageID "Images" .Images "GardenID" .GardenID)}}
{{end}}
@@ -0,0 +1,23 @@
{{define "header"}}
<header class='site-header'>
<h1>Gardomatic</h1>
{{with .Garden}}<a class='site-header-garden' href='{{webPath "garden.dashboard" .ID}}'>{{.Name}}</a>{{end}}
{{if .IsAuthenticated}}
<details class='user-menu'>
<summary aria-label='Benutzermenü öffnen'>
<span class='burger-icon' aria-hidden='true'></span>
</summary>
<div class='user-menu-content'>
{{if .IsActivated}}<a href='{{webPath "gardens"}}'>Gärten</a>{{end}}
{{with .CurrentUser}}<a href='{{with $.Garden}}{{pathWithQuery (webPath "account") "garden" .ID}}{{else}}{{webPath "account"}}{{end}}'>Nutzer <span>{{.Name}}</span></a>{{end}}
{{if .IsActivated}}<a href='{{with .Garden}}{{pathWithQuery (webPath "settings") "garden" .ID}}{{else}}{{webPath "settings"}}{{end}}'>Einstellungen</a>{{end}}
{{if isAppAdmin .CurrentUser}}<a href='{{gardenAwarePath (webPath "admin") .Garden}}'>Admin</a>{{end}}
<form action='{{webPath "logout"}}' method='POST'>
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
<button type='submit' class='link-button'>Abmelden</button>
</form>
</div>
</details>
{{end}}
</header>
{{end}}
@@ -0,0 +1,37 @@
{{define "image_editor"}}
<fieldset class='image-editor' data-image-editor>
<legend>Bild</legend>
<input type='hidden' name='image_data' value='{{.ImageData}}' data-image-value>
<input type='hidden' name='image_id' value='{{.ImageID}}' data-image-id>
<div class='image-preview' data-image-preview><span {{if .ImageData}}hidden{{end}}>Noch kein Bild gewählt</span></div>
<div class='image-actions'>
<label class='button secondary image-file-button'>Bild hochladen<input type='file' accept='image/jpeg,image/png,image/webp' data-image-file></label>
<button type='button' class='secondary' data-image-camera>Foto aufnehmen</button>
{{if .Images}}<button type='button' class='secondary' data-image-library-open>Aus Bilderdatenbank</button>{{end}}
<button type='button' class='secondary' data-image-remove {{if not .ImageData}}hidden{{end}}>Bild entfernen</button>
</div>
{{if .Images}}<dialog class='image-library-dialog' data-image-library-dialog><h3>Bild aus der Bilderdatenbank auswählen</h3><div class='image-picker-grid'>{{range .Images}}<button type='button' class='image-picker-item {{if eqInt $.ImageID .ID}}is-selected{{end}}' data-image-library-select data-image-id='{{.ID}}' data-image-url='/g/{{$.GardenID}}/images/{{.ID}}/data'><img src='/g/{{$.GardenID}}/images/{{.ID}}/data' alt='{{if .FileName}}{{.FileName}}{{else}}Bild auswählen{{end}}' loading='lazy'><span>{{if .FileName}}{{.FileName}}{{else}}{{.CreatedAt.Format "02.01.2006"}}{{end}}</span></button>{{end}}</div><div class='image-actions'><button type='button' class='secondary' data-image-library-cancel>Abbrechen</button></div></dialog>{{end}}
<dialog class='image-dialog' data-image-dialog>
<h3>Bild zuschneiden</h3>
<div class='cropper-stage'><img alt='Vorschau zum Zuschneiden' data-cropper-image></div>
<div class='image-actions'>
<button type='button' class='secondary' data-image-rotate-left aria-label='Nach links drehen'>↶ Drehen</button>
<button type='button' class='secondary' data-image-rotate-right aria-label='Nach rechts drehen'>↷ Drehen</button>
<button type='button' data-image-apply disabled>Übernehmen</button>
<button type='button' class='secondary' data-image-cancel>Abbrechen</button>
</div>
<p class='field-error' data-image-error aria-live='polite'></p>
</dialog>
<dialog class='camera-dialog' data-camera-dialog>
<h3>Foto aufnehmen</h3>
<video autoplay muted playsinline data-camera-video></video>
<p class='field-error' data-camera-error aria-live='polite'></p>
<div class='image-actions'>
<button type='button' class='secondary' data-camera-switch>Kamera wechseln</button>
<button type='button' data-camera-capture disabled>Foto aufnehmen</button>
<button type='button' class='secondary' data-camera-cancel>Abbrechen</button>
</div>
</dialog>
<p class='muted'>Das Bild wird im Verhältnis 3:2 zugeschnitten und für die Kacheldarstellung optimiert.</p>
</fieldset>
{{end}}
@@ -0,0 +1,8 @@
{{define "journal_photo_editor"}}
<div class='journal-photo-editor' data-image-editor data-journal-photo-editor>
<input type='hidden' name='journal_photo_data' value='' data-image-value>
<button type='button' class='secondary' data-image-camera>Foto mit Gerät aufnehmen</button>
<dialog class='image-dialog' data-image-dialog><h3>Foto zuschneiden</h3><div class='cropper-stage'><img alt='Vorschau zum Zuschneiden' data-cropper-image></div><div class='image-actions'><button type='button' class='secondary' data-image-rotate-left>↶ Drehen</button><button type='button' class='secondary' data-image-rotate-right>↷ Drehen</button><button type='button' data-image-apply disabled>Übernehmen</button><button type='button' class='secondary' data-image-cancel>Abbrechen</button></div><p class='field-error' data-image-error aria-live='polite'></p></dialog>
<dialog class='camera-dialog' data-camera-dialog><h3>Foto aufnehmen</h3><video autoplay muted playsinline data-camera-video></video><p class='field-error' data-camera-error aria-live='polite'></p><div class='image-actions'><button type='button' class='secondary' data-camera-switch>Kamera wechseln</button><button type='button' data-camera-capture disabled>Foto aufnehmen</button><button type='button' class='secondary' data-camera-cancel>Abbrechen</button></div></dialog>
</div>
{{end}}
+27
View File
@@ -0,0 +1,27 @@
{{define "nav"}}
{{if .IsAuthenticated}}
{{if .IsActivated}}
{{with .Garden}}
<nav aria-label='Gartennavigation'>
<a href='{{webPath "garden.dashboard" .ID}}'>Übersicht</a>
<a href='{{webPath "plants" .ID}}'>Im Garten</a>
<a href='{{webPath "species" .ID}}'>Pflanzen</a>
<a href='{{webPath "locations" .ID}}'>Orte</a>
<a href='{{webPath "tasks" .ID}}'>Aufgaben</a>
<a href='{{webPath "garden.journal" .ID}}'>Tagebuch</a>
<a href='{{webPath "garden.pinboard" .ID}}'>Pinnwand</a>
<a href='{{webPath "garden.images" .ID}}'>Bilder</a>
<a class='nav-search' href='{{webPath "garden.search" .ID}}' aria-label='Suche öffnen' title='Suche'><span aria-hidden='true'></span></a>
</nav>
{{end}}
{{else}}
<nav aria-label='Accountnavigation'>
<a href='{{webPath "activate"}}'>Account aktivieren</a>
</nav>
{{end}}
{{else}}
<nav aria-label='Accountnavigation'>
<a href='{{webPath "login"}}'>Anmelden</a>
</nav>
{{end}}
{{end}}
@@ -0,0 +1 @@
{{define "pagination"}}{{with .Pagination}}<nav class='pagination' aria-label='Seitennavigation'>{{with .PreviousURL}}<a href='{{.}}'>Zurück</a>{{end}}<span>Seite {{.Page}} von {{.TotalPages}}</span>{{with .NextURL}}<a href='{{.}}'>Weiter</a>{{end}}</nav>{{end}}{{end}}
@@ -0,0 +1,37 @@
{{define "role_editor"}}
<section class='panel role-editor' id='{{.ID}}' data-role-editor>
<h3>{{.Title}}</h3>
<p>{{.Description}}</p>
<label for='{{.ID}}-select'>Rolle</label>
<select id='{{.ID}}-select' data-role-select>
{{range $index, $role := .Roles}}<option value='role-{{$index}}'>{{$role.Label}}</option>{{end}}
<option value='new'>Neu</option>
</select>
{{range $index, $role := .Roles}}
<div data-role-pane='role-{{$index}}' {{if neInt $index 0}}hidden{{end}}>
{{if $role.Editable}}
<form method='POST' action='{{gardenAwarePath $.UpdateAction $.Garden}}' class='role-editor-form'>
<input type='hidden' name='csrf_token' value='{{$.CSRFToken}}'>
<input type='hidden' name='name' value='{{$role.Name}}'>
<input type='hidden' name='scope' value='{{$.Scope}}'>
{{if $role.EditLabel}}<label>Bezeichnung<input name='label' value='{{$role.Label}}' required></label>{{else}}<h4>{{$role.Label}}</h4>{{end}}
<fieldset><legend>Rechte</legend>{{range $.Permissions}}<label class='checkbox'><input type='checkbox' name='permissions' value='{{.Name}}' {{if containsString $role.Permissions .Name}}checked{{end}}> {{.Label}} <span class='muted'>{{.Name}}</span></label>{{end}}</fieldset>
<div class='actions'><button>Speichern</button>{{if $role.Deletable}}<button class='danger' formaction='{{gardenAwarePath $.DeleteAction $.Garden}}'>Rolle löschen</button>{{end}}</div>
</form>
{{else}}<p class='status'>{{$role.Label}} besitzt unveränderliche Eigentümerrechte.</p>{{end}}
</div>
{{end}}
<div data-role-pane='new' {{if .Roles}}hidden{{end}}>
<form method='POST' action='{{gardenAwarePath .CreateAction .Garden}}' class='role-editor-form'>
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
<input type='hidden' name='scope' value='{{.Scope}}'>
<label>Interner Name<input name='name' placeholder='helfer' required></label>
<label>Bezeichnung<input name='label' required></label>
<fieldset><legend>Rechte</legend>{{range .Permissions}}<label class='checkbox'><input type='checkbox' name='permissions' value='{{.Name}}'> {{.Label}} <span class='muted'>{{.Name}}</span></label>{{end}}</fieldset>
<button>Rolle anlegen</button>
</form>
</div>
</section>
{{end}}
@@ -0,0 +1,8 @@
{{define "season_range"}}
<fieldset class='season-range'><legend>{{.Legend}}</legend><div class='form-grid season-range-grid'>
<label>Monat von<select name='{{.Prefix}}_month_from'><option value='0'>Nicht angegeben</option>{{range months}}<option value='{{.Value}}' {{if eqInt $.Month .Value}}selected{{end}}>{{.Label}}</option>{{end}}</select></label>
<label>Tag von<input type='number' min='1' max='31' name='{{.Prefix}}_day_from' value='{{if .Day}}{{.Day}}{{else}}1{{end}}'></label>
<label>Zeitraum<input type='number' min='0' name='{{.Prefix}}_duration' value='{{.Duration}}'></label>
<label>Einheit<select name='{{.Prefix}}_duration_unit'><option value='day' {{if eqString .Unit "day"}}selected{{end}}>Tage</option><option value='week' {{if eqString .Unit "week"}}selected{{end}}>Wochen</option><option value='month' {{if eqString .Unit "month"}}selected{{end}}>Monate</option></select></label>
</div></fieldset>
{{end}}

Some files were not shown because too many files have changed in this diff Show More