@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
)
|
||||
|
||||
//go:embed "templates" "static"
|
||||
var files embed.FS
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: ¬es}
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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: ¬es, 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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.
|
||||
+10
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
@@ -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}()}));
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"))));
|
||||
});
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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 A–Z</option><option value='name_desc' {{if eqString (index .Filters "sort") "name_desc"}}selected{{end}}>Name Z–A</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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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 A–Z</option><option value='name_desc' {{if eqString (index .Filters "sort") "name_desc"}}selected{{end}}>Name Z–A</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}}
|
||||
@@ -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 A–Z</option><option value='name_desc' {{if eqString (index .Filters "sort") "name_desc"}}selected{{end}}>Name Z–A</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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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 A–Z</option><option value='name_desc' {{if eqString (index .Filters "sort") "name_desc"}}selected{{end}}>Name Z–A</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}}
|
||||
@@ -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 kurz–lang</option><option value='period_desc' {{if eqString (index .Filters "sort") "period_desc"}}selected{{end}}>Zeitraum lang–kurz</option><option value='name_asc' {{if eqString (index .Filters "sort") "name_asc"}}selected{{end}}>Name A–Z</option><option value='name_desc' {{if eqString (index .Filters "sort") "name_desc"}}selected{{end}}>Name Z–A</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}}
|
||||
@@ -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
Reference in New Issue
Block a user