+Inhalte edit page

This commit is contained in:
Simon Martens
2026-01-16 16:16:15 +01:00
parent b8dc2c952c
commit 8c96aaa88b
15 changed files with 1765 additions and 600 deletions

View File

@@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"slices"
"sort"
"strconv"
"strings"
@@ -15,11 +16,14 @@ import (
"github.com/Theodor-Springmann-Stiftung/musenalm/pagemodels"
"github.com/Theodor-Springmann-Stiftung/musenalm/templating"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/router"
)
const (
URL_ALMANACH_CONTENTS_EDIT = "contents/edit"
URL_ALMANACH_CONTENTS_INSERT = "contents/insert"
URL_ALMANACH_CONTENTS_DELETE = "contents/delete"
TEMPLATE_ALMANACH_CONTENTS_EDIT = "/almanach/contents/edit/"
)
@@ -45,6 +49,8 @@ func (p *AlmanachContentsEditPage) Setup(router *router.Router[*core.RequestEven
rg.BindFunc(middleware.IsAdminOrEditor())
rg.GET(URL_ALMANACH_CONTENTS_EDIT, p.GET(engine, app))
rg.POST(URL_ALMANACH_CONTENTS_EDIT, p.POSTSave(engine, app))
rg.POST(URL_ALMANACH_CONTENTS_INSERT, p.POSTInsert(engine, app))
rg.POST(URL_ALMANACH_CONTENTS_DELETE, p.POSTDelete(engine, app))
return nil
}
@@ -61,11 +67,13 @@ func (p *AlmanachContentsEditPage) GET(engine *templating.Engine, app core.App)
data["csrf_token"] = req.Session().Token
data["content_types"] = dbmodels.CONTENT_TYPE_VALUES
data["musenalm_types"] = dbmodels.MUSENALM_TYPE_VALUES
data["pagination_values"] = slices.Collect(maps.Values(dbmodels.MUSENALM_PAGINATION_VALUES))
data["pagination_values"] = paginationValuesSorted()
if msg := e.Request.URL.Query().Get("saved_message"); msg != "" {
data["success"] = msg
}
data["edit_content_id"] = strings.TrimSpace(e.Request.URL.Query().Get("edit_content"))
data["new_content"] = strings.TrimSpace(e.Request.URL.Query().Get("new_content"))
return engine.Response200(e, p.Template, data, p.Layout)
}
@@ -83,12 +91,145 @@ func (p *AlmanachContentsEditPage) renderError(engine *templating.Engine, app co
data["csrf_token"] = req.Session().Token
data["content_types"] = dbmodels.CONTENT_TYPE_VALUES
data["musenalm_types"] = dbmodels.MUSENALM_TYPE_VALUES
data["pagination_values"] = slices.Collect(maps.Values(dbmodels.MUSENALM_PAGINATION_VALUES))
data["pagination_values"] = paginationValuesSorted()
data["error"] = message
data["edit_content_id"] = strings.TrimSpace(e.Request.URL.Query().Get("edit_content"))
data["new_content"] = strings.TrimSpace(e.Request.URL.Query().Get("new_content"))
return engine.Response200(e, p.Template, data, p.Layout)
}
func (p *AlmanachContentsEditPage) POSTSave(engine *templating.Engine, app core.App) HandleFunc {
return func(e *core.RequestEvent) error {
id := e.Request.PathValue("id")
req := templating.NewRequest(e)
isHTMX := strings.EqualFold(e.Request.Header.Get("HX-Request"), "true")
if err := e.Request.ParseForm(); err != nil {
return p.renderSaveError(engine, app, e, req, nil, nil, err.Error(), isHTMX)
}
if err := req.CheckCSRF(e.Request.FormValue("csrf_token")); err != nil {
return p.renderSaveError(engine, app, e, req, nil, nil, err.Error(), isHTMX)
}
entry, err := dbmodels.Entries_MusenalmID(app, id)
if err != nil {
return engine.Response404(e, err, nil)
}
contents, err := dbmodels.Contents_Entry(app, entry.Id)
if err != nil {
return p.renderSaveError(engine, app, e, req, entry, nil, "Beiträge konnten nicht geladen werden.", isHTMX)
}
contentInputs := parseContentsForm(e.Request.PostForm)
contentOrder := parseContentsOrder(e.Request.PostForm)
orderMap := buildContentOrderMap(contentOrder)
user := req.User()
existingByID := make(map[string]*dbmodels.Content, len(contents))
for _, content := range contents {
existingByID[content.Id] = content
}
newContentIDs := make([]string, 0)
for contentID := range contentInputs {
if _, exists := existingByID[contentID]; !exists {
newContentIDs = append(newContentIDs, contentID)
}
}
if len(newContentIDs) > 1 {
sort.Slice(newContentIDs, func(i, j int) bool {
return orderMap[newContentIDs[i]] < orderMap[newContentIDs[j]]
})
}
var updatedContents []*dbmodels.Content
if err := app.RunInTransaction(func(tx core.App) error {
if len(orderMap) > 0 {
for _, content := range contents {
numbering, ok := orderMap[content.Id]
if !ok {
continue
}
if content.Numbering() == numbering {
continue
}
content.SetNumbering(numbering)
if err := tx.Save(content); err != nil {
return err
}
}
}
nextMusenalmID := 0
if len(newContentIDs) > 0 {
nextID, err := nextContentMusenalmID(tx)
if err != nil {
return err
}
nextMusenalmID = nextID
}
created := make([]*dbmodels.Content, 0, len(newContentIDs))
for _, tempID := range newContentIDs {
fields, ok := contentInputs[tempID]
if !ok {
continue
}
contentCollection, err := tx.FindCollectionByNameOrId(dbmodels.CONTENTS_TABLE)
if err != nil {
return err
}
record := core.NewRecord(contentCollection)
content := dbmodels.NewContent(record)
content.SetMusenalmID(nextMusenalmID)
nextMusenalmID++
content.SetEditState("Edited")
numbering := orderMap[tempID]
if numbering <= 0 {
numbering = float64(len(contents) + len(created) + 1)
}
if err := applyContentForm(content, entry, fields, user, numbering); err != nil {
return err
}
if err := tx.Save(content); err != nil {
return err
}
created = append(created, content)
}
for _, content := range contents {
fields, ok := contentInputs[content.Id]
if !ok {
continue
}
numbering := orderMap[content.Id]
if err := applyContentForm(content, entry, fields, user, numbering); err != nil {
return err
}
if err := tx.Save(content); err != nil {
return err
}
}
updatedContents = append(updatedContents, contents...)
updatedContents = append(updatedContents, created...)
return nil
}); err != nil {
app.Logger().Error("Failed to save contents", "entry_id", entry.Id, "error", err)
return p.renderSaveError(engine, app, e, req, entry, contentInputs, err.Error(), isHTMX)
}
if len(updatedContents) == 0 {
updatedContents = contents
}
go updateContentsFTS5(app, entry, updatedContents)
redirect := fmt.Sprintf("/almanach/%s/contents/edit?saved_message=%s", id, url.QueryEscape("Änderungen gespeichert."))
if isHTMX {
e.Response.Header().Set("HX-Redirect", redirect)
return e.String(http.StatusOK, "")
}
return e.Redirect(http.StatusSeeOther, redirect)
}
}
func (p *AlmanachContentsEditPage) POSTInsert(engine *templating.Engine, app core.App) HandleFunc {
return func(e *core.RequestEvent) error {
id := e.Request.PathValue("id")
req := templating.NewRequest(e)
@@ -106,40 +247,213 @@ func (p *AlmanachContentsEditPage) POSTSave(engine *templating.Engine, app core.
return engine.Response404(e, err, nil)
}
contents, err := dbmodels.Contents_Entry(app, entry.Id)
contentCollection, err := app.FindCollectionByNameOrId(dbmodels.CONTENTS_TABLE)
if err != nil {
return p.renderError(engine, app, e, "Beiträge konnten nicht geladen werden.")
return p.renderError(engine, app, e, "Beitrag konnte nicht vorbereitet werden.")
}
contentInputs := parseContentsForm(e.Request.PostForm)
user := req.User()
record := core.NewRecord(contentCollection)
record.Id = "tmp" + security.PseudorandomString(8)
record.MarkAsNew()
newContent := dbmodels.NewContent(record)
newContent.SetEntry(entry.Id)
newContent.SetYear(entry.Year())
newContent.SetEditState("Edited")
data := map[string]any{
"content": newContent,
"entry": entry,
"csrf_token": req.Session().Token,
"content_types": dbmodels.CONTENT_TYPE_VALUES,
"musenalm_types": dbmodels.MUSENALM_TYPE_VALUES,
"pagination_values": paginationValuesSorted(),
"open_edit": true,
"is_new": true,
"content_id": record.Id,
}
var builder strings.Builder
if err := engine.Render(&builder, "/almanach/contents/insert/", data, "fragment"); err != nil {
app.Logger().Error("Failed to render content insert", "entry_id", entry.Id, "error", err)
return p.renderError(engine, app, e, "Beitrag konnte nicht vorbereitet werden.")
}
return e.HTML(http.StatusOK, builder.String())
}
}
func (p *AlmanachContentsEditPage) POSTDelete(engine *templating.Engine, app core.App) HandleFunc {
return func(e *core.RequestEvent) error {
id := e.Request.PathValue("id")
req := templating.NewRequest(e)
if err := e.Request.ParseForm(); err != nil {
return p.renderError(engine, app, e, "Formulardaten ungültig.")
}
if err := req.CheckCSRF(e.Request.FormValue("csrf_token")); err != nil {
return p.renderError(engine, app, e, err.Error())
}
contentID := strings.TrimSpace(e.Request.FormValue("content_id"))
if contentID == "" {
return p.renderError(engine, app, e, "Beitrag konnte nicht gelöscht werden.")
}
entry, err := dbmodels.Entries_MusenalmID(app, id)
if err != nil {
return engine.Response404(e, err, nil)
}
var remaining []*dbmodels.Content
if err := app.RunInTransaction(func(tx core.App) error {
for _, content := range contents {
fields, ok := contentInputs[content.Id]
if !ok {
record, err := tx.FindRecordById(dbmodels.CONTENTS_TABLE, contentID)
if err != nil {
return err
}
content := dbmodels.NewContent(record)
if content.Entry() != entry.Id {
return fmt.Errorf("Beitrag gehört zu einem anderen Band.")
}
relationsTable := dbmodels.RelationTableName(dbmodels.CONTENTS_TABLE, dbmodels.AGENTS_TABLE)
relations, err := dbmodels.RContentsAgents_Content(tx, contentID)
if err != nil {
return err
}
for _, rel := range relations {
relRecord, err := tx.FindRecordById(relationsTable, rel.Id)
if err != nil {
continue
}
if err := applyContentForm(content, entry, fields, user); err != nil {
if err := tx.Delete(relRecord); err != nil {
return err
}
}
if err := tx.Delete(record); err != nil {
return err
}
remaining, err = dbmodels.Contents_Entry(tx, entry.Id)
if err != nil {
return err
}
dbmodels.Sort_Contents_Numbering(remaining)
for idx, content := range remaining {
content.SetNumbering(float64(idx + 1))
if err := tx.Save(content); err != nil {
return err
}
}
return nil
}); err != nil {
app.Logger().Error("Failed to save contents", "entry_id", entry.Id, "error", err)
return p.renderError(engine, app, e, err.Error())
app.Logger().Error("Failed to delete content", "entry_id", entry.Id, "content_id", contentID, "error", err)
return p.renderError(engine, app, e, "Beitrag konnte nicht gelöscht werden.")
}
go updateContentsFTS5(app, entry, contents)
go func(contentID string) {
_ = dbmodels.DeleteFTS5Content(app, contentID)
}(contentID)
redirect := fmt.Sprintf("/almanach/%s/contents/edit?saved_message=%s", id, url.QueryEscape("Änderungen gespeichert."))
if len(remaining) > 0 {
go updateContentsFTS5(app, entry, remaining)
}
redirect := fmt.Sprintf("/almanach/%s/contents/edit", id)
return e.Redirect(http.StatusSeeOther, redirect)
}
}
func (p *AlmanachContentsEditPage) renderSaveError(
engine *templating.Engine,
app core.App,
e *core.RequestEvent,
req *templating.Request,
entry *dbmodels.Entry,
contentInputs map[string]map[string][]string,
message string,
isHTMX bool,
) error {
if !isHTMX {
return p.renderError(engine, app, e, message)
}
if entry == nil {
id := e.Request.PathValue("id")
result, err := dbmodels.Entries_MusenalmID(app, id)
if err != nil {
return p.renderError(engine, app, e, message)
}
entry = result
}
contentID := ""
fields := map[string][]string{}
if contentInputs != nil {
for id, values := range contentInputs {
contentID = id
fields = values
break
}
}
if contentID == "" {
return p.renderError(engine, app, e, message)
}
content := (*dbmodels.Content)(nil)
contents, err := dbmodels.Contents_Entry(app, entry.Id)
if err == nil {
for _, existing := range contents {
if existing.Id == contentID {
content = existing
break
}
}
}
isNew := false
if content == nil {
contentCollection, err := app.FindCollectionByNameOrId(dbmodels.CONTENTS_TABLE)
if err != nil {
return p.renderError(engine, app, e, message)
}
record := core.NewRecord(contentCollection)
record.Id = contentID
record.MarkAsNew()
content = dbmodels.NewContent(record)
content.SetEditState("Edited")
isNew = true
}
numbering := 0.0
if order := parseContentsOrder(e.Request.PostForm); len(order) > 0 {
if mapped, ok := buildContentOrderMap(order)[contentID]; ok {
numbering = mapped
}
}
applyContentFormDraft(content, entry, fields, numbering)
data := map[string]any{
"content": content,
"content_id": contentID,
"entry": entry,
"csrf_token": req.Session().Token,
"content_types": dbmodels.CONTENT_TYPE_VALUES,
"musenalm_types": dbmodels.MUSENALM_TYPE_VALUES,
"pagination_values": paginationValuesSorted(),
"open_edit": true,
"is_new": isNew,
"error": message,
}
var builder strings.Builder
if err := engine.Render(&builder, "/almanach/contents/item/", data, "fragment"); err != nil {
app.Logger().Error("Failed to render content save error", "entry_id", entry.Id, "content_id", contentID, "error", err)
return p.renderError(engine, app, e, message)
}
return e.HTML(http.StatusOK, builder.String())
}
func parseContentsForm(form url.Values) map[string]map[string][]string {
contentInputs := map[string]map[string][]string{}
for key, values := range form {
@@ -168,8 +482,8 @@ func parseContentsForm(form url.Values) map[string]map[string][]string {
return contentInputs
}
func applyContentForm(content *dbmodels.Content, entry *dbmodels.Entry, fields map[string][]string, user *dbmodels.FixedUser) error {
preferredTitle := strings.TrimSpace(firstValue(fields["preferred_title"]))
func applyContentForm(content *dbmodels.Content, entry *dbmodels.Entry, fields map[string][]string, user *dbmodels.FixedUser, numbering float64) error {
preferredTitle := buildContentPreferredTitle(content, fields)
if preferredTitle == "" {
label := content.Id
if content.Numbering() > 0 {
@@ -178,26 +492,6 @@ func applyContentForm(content *dbmodels.Content, entry *dbmodels.Entry, fields m
return fmt.Errorf("Kurztitel ist erforderlich (Beitrag %s).", label)
}
yearValue := strings.TrimSpace(firstValue(fields["year"]))
year := 0
if yearValue != "" {
parsed, err := strconv.Atoi(yearValue)
if err != nil {
return fmt.Errorf("Ungültiges Jahr (Beitrag %s).", content.Id)
}
year = parsed
}
numberingValue := strings.TrimSpace(firstValue(fields["numbering"]))
numbering := 0.0
if numberingValue != "" {
parsed, err := strconv.ParseFloat(numberingValue, 64)
if err != nil {
return fmt.Errorf("Ungültige Nummerierung (Beitrag %s).", content.Id)
}
numbering = parsed
}
status := strings.TrimSpace(firstValue(fields["edit_state"]))
if status == "" {
status = content.EditState()
@@ -206,34 +500,180 @@ func applyContentForm(content *dbmodels.Content, entry *dbmodels.Entry, fields m
return fmt.Errorf("Ungültiger Status (Beitrag %s).", content.Id)
}
musenalmTypes := sanitizeContentStrings(fields["musenalm_type"])
if len(musenalmTypes) == 0 {
return fmt.Errorf("Musenalm-Typ ist erforderlich (Beitrag %s).", content.Id)
}
if numbering <= 0 {
numbering = content.Numbering()
}
content.SetPreferredTitle(preferredTitle)
content.SetVariantTitle(strings.TrimSpace(firstValue(fields["variant_title"])))
content.SetParallelTitle(strings.TrimSpace(firstValue(fields["parallel_title"])))
content.SetTitleStmt(strings.TrimSpace(firstValue(fields["title_statement"])))
content.SetSubtitleStmt(strings.TrimSpace(firstValue(fields["subtitle_statement"])))
content.SetIncipitStmt(strings.TrimSpace(firstValue(fields["incipit_statement"])))
content.SetResponsibilityStmt(strings.TrimSpace(firstValue(fields["responsibility_statement"])))
content.SetPlaceStmt(strings.TrimSpace(firstValue(fields["place_statement"])))
content.SetPublicationStmt(strings.TrimSpace(firstValue(fields["publication_statement"])))
content.SetYear(year)
if value, ok := optionalFieldValue(fields, "variant_title"); ok {
content.SetVariantTitle(value)
}
if value, ok := optionalFieldValue(fields, "parallel_title"); ok {
content.SetParallelTitle(value)
}
if value, ok := optionalFieldValue(fields, "title_statement"); ok {
content.SetTitleStmt(value)
}
if value, ok := optionalFieldValue(fields, "subtitle_statement"); ok {
content.SetSubtitleStmt(value)
}
if value, ok := optionalFieldValue(fields, "incipit_statement"); ok {
content.SetIncipitStmt(value)
}
if value, ok := optionalFieldValue(fields, "responsibility_statement"); ok {
content.SetResponsibilityStmt(value)
}
if value, ok := optionalFieldValue(fields, "place_statement"); ok {
content.SetPlaceStmt(value)
}
content.SetYear(entry.Year())
content.SetExtent(strings.TrimSpace(firstValue(fields["extent"])))
content.SetDimensions(strings.TrimSpace(firstValue(fields["dimensions"])))
content.SetLanguage(fields["language"])
content.SetContentType(fields["content_type"])
content.SetMusenalmType(fields["musenalm_type"])
content.SetLanguage(sanitizeContentStrings(fields["language"]))
if values, ok := fields["content_type"]; ok {
content.SetContentType(sanitizeContentStrings(values))
}
content.SetMusenalmType(musenalmTypes)
content.SetMusenalmPagination(strings.TrimSpace(firstValue(fields["musenalm_pagination"])))
content.SetNumbering(numbering)
content.SetEntry(entry.Id)
content.SetMusenalmID(entry.MusenalmID())
content.SetEditState(status)
content.SetComment(strings.TrimSpace(firstValue(fields["edit_comment"])))
content.SetAnnotation(strings.TrimSpace(firstValue(fields["annotation"])))
if value, ok := optionalFieldValue(fields, "edit_comment"); ok {
content.SetComment(value)
}
if value, ok := optionalFieldValue(fields, "annotation"); ok {
content.SetAnnotation(value)
}
if user != nil {
content.SetEditor(user.Id)
}
return nil
}
func applyContentFormDraft(content *dbmodels.Content, entry *dbmodels.Entry, fields map[string][]string, numbering float64) {
if value, ok := optionalFieldValue(fields, "variant_title"); ok {
content.SetVariantTitle(value)
}
if value, ok := optionalFieldValue(fields, "parallel_title"); ok {
content.SetParallelTitle(value)
}
if value, ok := optionalFieldValue(fields, "title_statement"); ok {
content.SetTitleStmt(value)
}
if value, ok := optionalFieldValue(fields, "subtitle_statement"); ok {
content.SetSubtitleStmt(value)
}
if value, ok := optionalFieldValue(fields, "incipit_statement"); ok {
content.SetIncipitStmt(value)
}
if value, ok := optionalFieldValue(fields, "responsibility_statement"); ok {
content.SetResponsibilityStmt(value)
}
if value, ok := optionalFieldValue(fields, "place_statement"); ok {
content.SetPlaceStmt(value)
}
if value, ok := optionalFieldValue(fields, "edit_comment"); ok {
content.SetComment(value)
}
if value, ok := optionalFieldValue(fields, "annotation"); ok {
content.SetAnnotation(value)
}
content.SetExtent(strings.TrimSpace(firstValue(fields["extent"])))
content.SetLanguage(sanitizeContentStrings(fields["language"]))
if values, ok := fields["content_type"]; ok {
content.SetContentType(sanitizeContentStrings(values))
}
content.SetMusenalmType(sanitizeContentStrings(fields["musenalm_type"]))
content.SetMusenalmPagination(strings.TrimSpace(firstValue(fields["musenalm_pagination"])))
content.SetEntry(entry.Id)
content.SetYear(entry.Year())
if status := strings.TrimSpace(firstValue(fields["edit_state"])); status != "" {
content.SetEditState(status)
}
if numbering > 0 {
content.SetNumbering(numbering)
}
if preferredTitle := buildContentPreferredTitle(content, fields); preferredTitle != "" {
content.SetPreferredTitle(preferredTitle)
}
}
func sanitizeContentStrings(values []string) []string {
cleaned := make([]string, 0, len(values))
seen := map[string]struct{}{}
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
cleaned = append(cleaned, value)
}
return cleaned
}
func buildContentPreferredTitle(content *dbmodels.Content, fields map[string][]string) string {
title := fieldOrCurrent(fields, "title_statement", content.TitleStmt())
if title != "" {
return title
}
subtitle := fieldOrCurrent(fields, "subtitle_statement", content.SubtitleStmt())
if subtitle != "" {
return subtitle
}
incipit := fieldOrCurrent(fields, "incipit_statement", content.IncipitStmt())
if incipit != "" {
return incipit
}
types := fields["musenalm_type"]
if _, ok := fields["musenalm_type"]; !ok {
types = content.MusenalmType()
}
types = sanitizeContentStrings(types)
typeLabel := strings.Join(types, ", ")
responsibility := fieldOrCurrent(fields, "responsibility_statement", content.ResponsibilityStmt())
if responsibility != "" && !strings.EqualFold(responsibility, "unbezeichnet") {
if typeLabel != "" {
return fmt.Sprintf("[%s] Unterzeichnet: %s", typeLabel, responsibility)
}
return fmt.Sprintf("Unterzeichnet: %s", responsibility)
}
extent := fieldOrCurrent(fields, "extent", content.Extent())
if typeLabel == "" {
typeLabel = "Beitrag"
}
if extent != "" {
return fmt.Sprintf("[%s %s]", typeLabel, extent)
}
return fmt.Sprintf("[%s]", typeLabel)
}
func optionalFieldValue(fields map[string][]string, key string) (string, bool) {
values, ok := fields[key]
if !ok {
return "", false
}
return strings.TrimSpace(firstValue(values)), true
}
func fieldOrCurrent(fields map[string][]string, key, current string) string {
if value, ok := optionalFieldValue(fields, key); ok {
return value
}
return strings.TrimSpace(current)
}
func firstValue(values []string) string {
if len(values) == 0 {
return ""
@@ -241,6 +681,30 @@ func firstValue(values []string) string {
return values[0]
}
func parseContentsOrder(form url.Values) []string {
raw := form["content_order[]"]
if len(raw) == 0 {
raw = form["content_order"]
}
order := make([]string, 0, len(raw))
for _, value := range raw {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
order = append(order, trimmed)
}
return order
}
func buildContentOrderMap(order []string) map[string]float64 {
orderMap := make(map[string]float64, len(order))
for index, id := range order {
orderMap[id] = float64(index + 1)
}
return orderMap
}
func updateContentsFTS5(app core.App, entry *dbmodels.Entry, contents []*dbmodels.Content) {
if len(contents) == 0 {
return
@@ -262,3 +726,40 @@ func updateContentsFTS5(app core.App, entry *dbmodels.Entry, contents []*dbmodel
}
}
}
func paginationValuesSorted() []string {
orderedKeys := []string{"", "ar", "röm", "alph", "sonst"}
for i := 1; i <= 8; i++ {
orderedKeys = append(orderedKeys, fmt.Sprintf("ar%d", i))
}
for i := 1; i <= 8; i++ {
orderedKeys = append(orderedKeys, fmt.Sprintf("röm%d", i))
}
values := make([]string, 0, len(dbmodels.MUSENALM_PAGINATION_VALUES))
seen := map[string]struct{}{}
for _, key := range orderedKeys {
value, ok := dbmodels.MUSENALM_PAGINATION_VALUES[key]
if !ok {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
values = append(values, value)
}
remainingKeys := slices.Collect(maps.Keys(dbmodels.MUSENALM_PAGINATION_VALUES))
sort.Strings(remainingKeys)
for _, key := range remainingKeys {
value := dbmodels.MUSENALM_PAGINATION_VALUES[key]
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
values = append(values, value)
}
return values
}

View File

@@ -67,3 +67,18 @@ func nextPlaceMusenalmID(app core.App) (int, error) {
}
return place.MusenalmID() + 1, nil
}
func nextContentMusenalmID(app core.App) (int, error) {
var content dbmodels.Content
err := app.RecordQuery(dbmodels.CONTENTS_TABLE).
OrderBy(dbmodels.MUSENALMID_FIELD + " DESC").
Limit(1).
One(&content)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 1, nil
}
return 0, err
}
return content.MusenalmID() + 1, nil
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,2 +1 @@
{{ block "body" . }}
{{ end }}
{{ block "body" . }}{{ end }}

View File

@@ -117,44 +117,25 @@
<div class="container-normal mx-auto mt-4 !px-0">
{{ template "_usermessage" $model }}
<form
autocomplete="off"
class="w-full dbform"
id="changecontentsform"
method="POST"
action="/almanach/{{ $model.result.Entry.MusenalmID }}/contents/edit">
<input type="hidden" name="csrf_token" value="{{ $model.csrf_token }}" />
<div class="flex flex-col gap-3">
{{- range $_, $content := $model.result.Contents -}}
{{- template "_content_edit" (Dict
"content" $content
"entry" $model.result.Entry
"content_types" $model.content_types
"musenalm_types" $model.musenalm_types
"pagination_values" $model.pagination_values
) -}}
{{- end -}}
</div>
<div class="w-full flex items-end justify-between gap-4 mt-6 flex-wrap">
<p class="text-sm text-gray-600" aria-live="polite"></p>
<div class="flex items-center gap-3 self-end flex-wrap">
<a href="/almanach/{{ $model.result.Entry.MusenalmID }}" class="resetbutton w-48 flex items-center gap-2 justify-center">
<i class="ri-close-line"></i>
<span>Abbrechen</span>
</a>
<a href="/almanach/{{ $model.result.Entry.MusenalmID }}/contents/edit" class="resetbutton w-48 flex items-center gap-2 justify-center">
<i class="ri-loop-left-line"></i>
<span>Reset</span>
</a>
<button type="submit" class="submitbutton w-48 flex items-center gap-2 justify-center">
<i class="ri-save-line"></i>
<span>Speichern</span>
</button>
</div>
</div>
</form>
<div id="contents-sync-indicator" class="fixed right-6 bottom-6 z-50 hidden rounded-full bg-stone-200 px-3 py-2 text-sm text-stone-700 shadow-md">
<i class="ri-loader-4-line spinning mr-2"></i>
Reihenfolge wird gespeichert
</div>
<input type="hidden" name="csrf_token" value="{{ $model.csrf_token }}" data-role="csrf-token" />
<div class="flex flex-col gap-1" data-role="contents-list" data-insert-endpoint="/almanach/{{ $model.result.Entry.MusenalmID }}/contents/insert">
{{- range $_, $content := $model.result.Contents -}}
{{- template "_content_item" (Dict
"content" $content
"entry" $model.result.Entry
"csrf_token" $model.csrf_token
"content_types" $model.content_types
"musenalm_types" $model.musenalm_types
"pagination_values" $model.pagination_values
"open_edit" false
"is_new" false
) -}}
{{- end -}}
</div>
</div>
</edit-page>
@@ -183,20 +164,417 @@
};
const initPage = () => {
document.querySelectorAll(".content-numbering").forEach((input, index) => {
input.value = index + 1;
});
const list = document.querySelector("[data-role='contents-list']");
if (!list) {
return;
}
document
.querySelectorAll("multi-select-simple[data-initial-options], multi-select-simple[data-initial-values]")
.forEach((el) => applyMultiSelectInit(el));
const getItems = () => Array.from(list.querySelectorAll("[data-role='content-item']"));
const removeGaps = () => {
list.querySelectorAll("[data-role='content-gap']").forEach((gap) => gap.remove());
};
const renderInsertGaps = () => {
removeGaps();
const insertEndpoint = list.dataset.insertEndpoint || "";
const items = getItems();
if (!insertEndpoint) {
return;
}
if (list.querySelector("[data-role='content-item'].data-editing")) {
return;
}
const createGap = (position, contentId, label) => {
const gap = document.createElement("div");
gap.className = "relative group h-6 -my-2.5";
gap.dataset.role = "content-gap";
const line = document.createElement("div");
line.className = "pointer-events-none absolute left-0 right-0 top-1/2 h-0.5 -translate-y-1/2 bg-slate-300 opacity-0 transition-opacity duration-150 group-hover:opacity-100";
const button = document.createElement("button");
button.type = "button";
button.className = "absolute left-1/2 top-1/2 z-[10000] -translate-x-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-150 rounded-full border border-slate-300 bg-stone-100 text-slate-700 px-3 py-2 text-base shadow-sm";
button.setAttribute("aria-label", "Beitrag einfügen");
button.setAttribute("hx-post", insertEndpoint);
button.setAttribute("hx-target", "closest [data-role='content-gap']");
button.setAttribute("hx-swap", "beforebegin");
button.setAttribute("hx-include", "[data-role='csrf-token']");
button.setAttribute("hx-vals", JSON.stringify({ position, content_id: contentId }));
button.innerHTML = label ? '<i class="ri-add-line"></i><span>Neuer Beitrag</span>' : '<i class="ri-add-line"></i>';
gap.appendChild(line);
gap.appendChild(button);
gap.appendChild(document.createElement("div")).className = "h-1";
return gap;
};
items.forEach((item) => {
const contentId = item.querySelector("[data-role='content-card']")?.dataset.contentId || "";
list.insertBefore(createGap("before", contentId, false), item);
});
list.appendChild(createGap("after", "", true));
if (window.htmx?.process) {
list.querySelectorAll("[data-role='content-gap']").forEach((gap) => {
window.htmx.process(gap);
});
}
};
const setEditSpacing = (active) => {
list.style.rowGap = active ? "0.75rem" : "";
list.style.paddingTop = active ? "0.25rem" : "";
list.style.paddingBottom = active ? "0.25rem" : "";
};
const syncEditSpacing = () => {
setEditSpacing(!!list.querySelector("[data-role='content-item'].data-editing"));
};
if (getItems().length === 0) {
return;
}
const isTempContentId = (contentId) => contentId && contentId.startsWith("tmp");
const orderEndpoint = getItems()[0]?.querySelector("form")?.getAttribute("action") || "";
const deleteEndpoint = window.location.pathname.replace(/\/contents\/edit\/?$/, "/contents/delete");
const csrfToken = document.querySelector("input[name='csrf_token']")?.value || "";
const syncIndicator = document.querySelector("#contents-sync-indicator");
let orderSyncTimer = null;
let isOrderSyncing = false;
let pendingOrderSync = false;
const setSyncIndicator = (active) => {
if (!syncIndicator) {
return;
}
syncIndicator.classList.toggle("hidden", !active);
};
const performOrderSync = () => {
if (!list || !orderEndpoint || !csrfToken || isOrderSyncing) {
pendingOrderSync = true;
return;
}
isOrderSyncing = true;
pendingOrderSync = false;
setSyncIndicator(true);
const payload = new URLSearchParams();
payload.set("csrf_token", csrfToken);
list.querySelectorAll("[data-role='content-item']").forEach((card) => {
const contentId = card.querySelector("[data-role='content-card']")?.dataset.contentId;
if (!contentId || isTempContentId(contentId)) {
return;
}
payload.append("content_order[]", contentId);
});
fetch(orderEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: payload.toString(),
})
.catch(() => null)
.finally(() => {
isOrderSyncing = false;
setSyncIndicator(false);
if (pendingOrderSync) {
performOrderSync();
}
});
};
const syncOrder = () => {
if (orderSyncTimer) {
clearTimeout(orderSyncTimer);
}
orderSyncTimer = setTimeout(() => {
performOrderSync();
}, 300);
};
const closeAll = () => {
getItems().forEach((item) => {
const view = item.querySelector("[data-role='content-view']");
const edit = item.querySelector("[data-role='content-edit']");
const editButton = item.querySelector("[data-role='content-edit-button']");
item.classList.remove("data-editing");
if (view) {
view.classList.remove("hidden");
}
if (edit) {
edit.classList.add("hidden");
}
if (editButton) {
editButton.classList.remove("hidden");
}
});
};
const openItem = (item) => {
closeAll();
const view = item.querySelector("[data-role='content-view']");
const edit = item.querySelector("[data-role='content-edit']");
if (view && edit) {
view.classList.add("hidden");
edit.classList.remove("hidden");
item.classList.add("data-editing");
}
setEditSpacing(true);
removeGaps();
getItems().forEach((other) => {
const otherButton = other.querySelector("[data-role='content-edit-button']");
if (!otherButton || other === item) {
return;
}
otherButton.classList.add("hidden");
});
};
const removeItem = (item) => {
const gap = item.previousElementSibling;
if (gap && gap.matches("[data-role='content-gap']")) {
gap.remove();
}
item.remove();
renderInsertGaps();
getItems().forEach((other) => {
const otherButton = other.querySelector("[data-role='content-edit-button']");
if (otherButton) {
otherButton.classList.remove("hidden");
}
});
};
const setupItem = (item) => {
if (!item || item.dataset.init === "true") {
return;
}
item.dataset.init = "true";
const editButton = item.querySelector("[data-role='content-edit-button']");
const cancelButton = item.querySelector("[data-role='content-edit-cancel']");
const deleteButton = item.querySelector("[data-role='content-delete']");
const deleteDialog = item.querySelector("[data-role='content-delete-dialog']");
const deleteConfirm = item.querySelector("[data-role='content-delete-confirm']");
const deleteCancel = item.querySelector("[data-role='content-delete-cancel']");
const view = item.querySelector("[data-role='content-view']");
const edit = item.querySelector("[data-role='content-edit']");
const form = item.querySelector("form");
if (editButton && view && edit) {
editButton.addEventListener("click", () => {
openItem(item);
});
}
if (cancelButton && view && edit) {
cancelButton.addEventListener("click", () => {
if (item.dataset.contentTemp === "true") {
removeItem(item);
return;
}
edit.classList.add("hidden");
view.classList.remove("hidden");
item.classList.remove("data-editing");
getItems().forEach((other) => {
const otherButton = other.querySelector("[data-role='content-edit-button']");
if (otherButton) {
otherButton.classList.remove("hidden");
}
});
syncEditSpacing();
renderInsertGaps();
});
}
if (deleteButton && deleteDialog) {
deleteButton.addEventListener("click", () => {
if (deleteDialog.showModal) {
deleteDialog.showModal();
} else {
deleteDialog.setAttribute("open", "true");
}
});
}
if (deleteCancel && deleteDialog) {
deleteCancel.addEventListener("click", () => {
deleteDialog.close();
});
deleteDialog.addEventListener("cancel", (event) => {
event.preventDefault();
deleteDialog.close();
});
}
if (deleteConfirm) {
deleteConfirm.addEventListener("click", () => {
if (item.dataset.contentTemp === "true") {
deleteDialog?.close();
removeItem(item);
return;
}
const payload = new URLSearchParams();
payload.set("csrf_token", csrfToken);
payload.set("content_id", item.querySelector("[data-role='content-card']")?.dataset.contentId || "");
fetch(deleteEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: payload.toString(),
})
.then((response) => {
if (response.redirected) {
window.location.assign(response.url);
} else {
window.location.reload();
}
})
.catch(() => null);
});
}
if (form) {
form.addEventListener("submit", () => {
form.querySelectorAll("input[name='content_order[]']").forEach((input) => input.remove());
getItems().forEach((card) => {
const contentId = card.querySelector("[data-role='content-card']")?.dataset.contentId;
if (!contentId) {
return;
}
const input = document.createElement("input");
input.type = "hidden";
input.name = "content_order[]";
input.value = contentId;
form.appendChild(input);
});
});
}
item.querySelectorAll("multi-select-simple[data-initial-options], multi-select-simple[data-initial-values]").forEach((el) => {
applyMultiSelectInit(el);
});
if (item.dataset.openEdit === "true") {
item.dataset.openEdit = "";
openItem(item);
}
};
getItems().forEach((item) => setupItem(item));
renderInsertGaps();
syncEditSpacing();
if (list.dataset.pageInit !== "true") {
list.dataset.pageInit = "true";
let draggedItem = null;
list.addEventListener("click", (event) => {
const moveUp = event.target.closest("[data-role='content-move-up']");
const moveDown = event.target.closest("[data-role='content-move-down']");
if (!moveUp && !moveDown) {
return;
}
event.preventDefault();
const item = event.target.closest("[data-role='content-item']");
if (!item) {
return;
}
if (moveUp) {
const prev = item.previousElementSibling;
if (prev) {
prev.before(item);
}
} else {
const next = item.nextElementSibling;
if (next) {
next.after(item);
}
}
syncOrder();
renderInsertGaps();
});
list.addEventListener("dragstart", (event) => {
if (event.target.closest("[data-role='content-edit-button']")) {
return;
}
if (event.target.closest("[data-role='content-move-up']") || event.target.closest("[data-role='content-move-down']")) {
return;
}
if (event.target.closest(".status-badge") || event.target.closest("multi-select-simple") || event.target.closest("select")) {
return;
}
const handle = event.target.closest("[data-role='content-drag-handle']");
if (!handle) {
event.preventDefault();
return;
}
const item = handle.closest("[data-role='content-item']");
if (!item) {
return;
}
draggedItem = item;
draggedItem.classList.add("opacity-60");
list.style.rowGap = "0.75rem";
list.style.paddingTop = "0.25rem";
list.style.paddingBottom = "0.25rem";
removeGaps();
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", "move");
});
list.addEventListener("dragover", (event) => {
if (!draggedItem) {
return;
}
event.preventDefault();
const targetItem = event.target.closest("[data-role='content-item']");
if (!targetItem || targetItem === draggedItem) {
return;
}
const rect = targetItem.getBoundingClientRect();
const before = event.clientY - rect.top < rect.height / 2;
if (before) {
targetItem.before(draggedItem);
} else {
targetItem.after(draggedItem);
}
});
list.addEventListener("dragend", () => {
if (draggedItem) {
draggedItem.classList.remove("opacity-60");
}
draggedItem = null;
list.style.rowGap = "";
list.style.paddingTop = "";
list.style.paddingBottom = "";
syncOrder();
renderInsertGaps();
});
const params = new URLSearchParams(window.location.search);
const editContentId = params.get("edit_content");
if (editContentId) {
const targetItem = getItems().find((item) => {
return item.querySelector(`[data-role='content-card'][data-content-id='${editContentId}']`);
});
if (targetItem) {
openItem(targetItem);
}
}
}
};
if (window.customElements?.whenDefined) {
window.customElements.whenDefined("multi-select-simple").then(() => {
requestAnimationFrame(initPage);
});
} else {
initPage();
}
const initWhenReady = () => {
if (window.customElements?.whenDefined) {
window.customElements.whenDefined("multi-select-simple").then(() => {
requestAnimationFrame(initPage);
});
} else {
initPage();
}
};
initWhenReady();
document.body.addEventListener("htmx:afterSwap", () => {
initWhenReady();
});
</script>

View File

@@ -0,0 +1,19 @@
{{- $content := index . "content" -}}
{{- $entry := index . "entry" -}}
{{- $csrf := index . "csrf_token" -}}
{{- $contentTypes := index . "content_types" -}}
{{- $musenalmTypes := index . "musenalm_types" -}}
{{- $paginationValues := index . "pagination_values" -}}
{{- $contentID := index . "content_id" -}}
{{- template "_content_item" (Dict
"content" $content
"content_id" $contentID
"entry" $entry
"csrf_token" $csrf
"content_types" $contentTypes
"musenalm_types" $musenalmTypes
"pagination_values" $paginationValues
"open_edit" true
"is_new" true
) -}}

View File

@@ -0,0 +1,23 @@
{{- $content := index . "content" -}}
{{- $entry := index . "entry" -}}
{{- $csrf := index . "csrf_token" -}}
{{- $contentTypes := index . "content_types" -}}
{{- $musenalmTypes := index . "musenalm_types" -}}
{{- $paginationValues := index . "pagination_values" -}}
{{- $contentID := index . "content_id" -}}
{{- $openEdit := index . "open_edit" -}}
{{- $isNew := index . "is_new" -}}
{{- $error := index . "error" -}}
{{- template "_content_item" (Dict
"content" $content
"content_id" $contentID
"entry" $entry
"csrf_token" $csrf
"content_types" $contentTypes
"musenalm_types" $musenalmTypes
"pagination_values" $paginationValues
"open_edit" $openEdit
"is_new" $isNew
"error" $error
) -}}

View File

@@ -72,6 +72,13 @@ type AlmanachResult struct {
<i class="ri-loop-left-line"></i> Reset
</a>
</div>
&middot;
<div>
<a href="/almanach/{{- $model.result.Entry.MusenalmID -}}/contents/edit" class="text-gray-700
no-underline hover:text-slate-950 block ">
<i class="ri-file-list-3-line"></i> Inhalte
</a>
</div>
</div>
{{- end -}}
</div>

View File

@@ -1,17 +1,24 @@
{{- $annotation := index . 0 -}}
{{- $label := "Annotation" -}}
{{- $fieldID := "annotation" -}}
{{- if gt (len .) 1 -}}
{{- if index . 1 -}}
{{- $label = index . 1 -}}
{{- end -}}
{{- end -}}
{{- if gt (len .) 2 -}}
{{- if index . 2 -}}
{{- $fieldID = index . 2 -}}
{{- end -}}
{{- end -}}
{{- $toolbarID := printf "%s-toolbar" $fieldID -}}
<div class="inputwrapper">
<div class="inputlabelrow">
<label for="annotation" class="inputlabel">{{ $label }}</label>
<label for="{{ $fieldID }}" class="inputlabel">{{ $label }}</label>
</div>
<trix-toolbar id="annotation-toolbar">
<trix-toolbar id="{{ $toolbarID }}">
<div class="trix-toolbar-container">
<!-- Text formatting group -->
<span class="trix-toolbar-group">
@@ -87,6 +94,6 @@
</div>
</trix-toolbar>
<textarea hidden id="annotation" name="annotation" autocomplete="off">{{- $annotation -}}</textarea>
<trix-editor input="annotation" toolbar="annotation-toolbar"></trix-editor>
<textarea hidden id="{{ $fieldID }}" name="{{ $fieldID }}" autocomplete="off">{{- $annotation -}}</textarea>
<trix-editor input="{{ $fieldID }}" toolbar="{{ $toolbarID }}"></trix-editor>
</div>

View File

@@ -1,93 +1,32 @@
{{- $content := index . "content" -}}
{{- $entry := index . "entry" -}}
{{- $contentTypes := index . "content_types" -}}
{{- $musenalmTypes := index . "musenalm_types" -}}
{{- $paginationValues := index . "pagination_values" -}}
{{- $prefix := printf "content_%s_" $content.Id -}}
{{- $baseID := printf "content-%s" $content.Id -}}
{{- $overrideID := index . "content_id" -}}
{{- $contentID := $content.Id -}}
{{- if and $overrideID (ne $overrideID "") -}}
{{- $contentID = $overrideID -}}
{{- end -}}
{{- $prefix := printf "content_%s_" $contentID -}}
{{- $baseID := printf "content-%s" $contentID -}}
{{- $annotationID := printf "%sannotation" $prefix -}}
{{- $annotationToolbar := printf "%sannotation-toolbar" $prefix -}}
<div class="border border-slate-200 bg-white rounded-xs p-3" data-content-id="{{ $content.Id }}" data-content-order="{{ $content.Numbering }}">
<div class="border border-slate-200 bg-white rounded-xs" data-role="content-card" data-content-id="{{ $contentID }}" data-content-order="{{ $content.Numbering }}">
<input type="hidden" name="{{ $prefix }}numbering" class="content-numbering" value="{{- $content.Numbering -}}" />
<input type="hidden" name="{{ $prefix }}musenalm_id" value="{{ $entry.MusenalmID }}" />
<input type="hidden" name="{{ $prefix }}entries" value="{{ $entry.Id }}" />
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<i class="ri-drag-move-2-line text-slate-400 cursor-grab"></i>
<div class="text-xs text-slate-500">Alm-ID {{ $entry.MusenalmID }}</div>
</div>
<div class="border border-dashed border-slate-300 bg-slate-50 px-2 py-1 text-xs text-slate-600 rounded-xs">
<i class="ri-image-add-line"></i> Scans folgen
</div>
</div>
<div class="mt-3 grid gap-2 xl:grid-cols-[1.3fr_1fr_1fr]">
<div class="grid gap-2">
<div class="inputwrapper">
<div class="inputlabelrow">
<label for="{{ $baseID }}-preferred-title" class="inputlabel">Kurztitel</label>
</div>
<textarea name="{{ $prefix }}preferred_title" id="{{ $baseID }}-preferred-title" class="inputinput no-enter" autocomplete="off" rows="1" required>{{- $content.PreferredTitle -}}</textarea>
</div>
<div class="inputwrapper">
<div class="inputlabelrow">
<label for="{{ $baseID }}-title" class="inputlabel">Titel</label>
</div>
<textarea name="{{ $prefix }}title_statement" id="{{ $baseID }}-title" class="inputinput no-enter" autocomplete="off" rows="1">{{- $content.TitleStmt -}}</textarea>
</div>
<div class="inputwrapper">
<div class="inputlabelrow">
<label for="{{ $baseID }}-incipit" class="inputlabel">Incipit</label>
</div>
<textarea name="{{ $prefix }}incipit_statement" id="{{ $baseID }}-incipit" class="inputinput no-enter" autocomplete="off" rows="1">{{- $content.IncipitStmt -}}</textarea>
</div>
</div>
<div class="grid gap-2">
<div class="inputwrapper">
<div class="inputlabelrow">
<label for="{{ $baseID }}-responsibility" class="inputlabel">Autorangabe</label>
</div>
<textarea name="{{ $prefix }}responsibility_statement" id="{{ $baseID }}-responsibility" class="inputinput no-enter" autocomplete="off" rows="1">{{- $content.ResponsibilityStmt -}}</textarea>
</div>
<div class="inputwrapper">
<div class="inputlabelrow">
<label for="{{ $baseID }}-extent" class="inputlabel">Seite</label>
</div>
<textarea name="{{ $prefix }}extent" id="{{ $baseID }}-extent" class="inputinput no-enter" autocomplete="off" rows="1">{{- $content.Extent -}}</textarea>
</div>
<div class="inputwrapper">
<div class="inputlabelrow">
<label for="{{ $baseID }}-pagination" class="inputlabel">Paginierung</label>
</div>
<select name="{{ $prefix }}musenalm_pagination" id="{{ $baseID }}-pagination" class="inputselect">
{{- range $_, $p := $paginationValues -}}
<option value="{{- $p -}}" {{ if eq $content.MusenalmPagination $p }}selected{{ end }}>{{- $p -}}</option>
{{- end -}}
</select>
</div>
</div>
<div class="grid gap-2">
<div class="inputwrapper">
<div class="inputlabelrow">
<label for="{{ $baseID }}-content-type" class="inputlabel">Inhaltstyp</label>
</div>
<multi-select-simple
id="{{ $baseID }}-content-type"
name="{{ $prefix }}content_type[]"
show-create-button="false"
placeholder="Inhaltstypen suchen..."
data-empty-text="Keine Typen verknüpft"
value='[{{- range $i, $t := $content.ContentType -}}{{- if $i }},{{ end -}}"{{ $t }}"{{- end -}}]'
data-initial-options='[{{- range $i, $t := $contentTypes -}}{{- if $i }},{{ end -}}{{ printf "{\"id\":%q,\"name\":%q}" $t $t }}{{- end -}}]'
data-initial-values='[{{- range $i, $t := $content.ContentType -}}{{- if $i }},{{ end -}}{{ printf "%q" $t }}{{- end -}}]'>
</multi-select-simple>
</div>
<div class="inputwrapper">
<div class="inputlabelrow">
<label for="{{ $baseID }}-musenalm-type" class="inputlabel">Musenalm-Typ</label>
</div>
<div class="border border-slate-200 bg-stone-100 rounded-xs">
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-slate-200 bg-stone-200 px-3 py-2">
<div class="flex items-center gap-3">
<button type="button" class="text-slate-600 cursor-grab text-sm" data-role="content-drag-handle" draggable="true" aria-label="Beitrag verschieben">
<i class="ri-drag-move-2-line"></i>
</button>
<button type="button" class="text-slate-600 px-1.5 py-1 text-sm rounded-xs hover:bg-stone-200" data-role="content-move-up" aria-label="Beitrag nach oben">
<i class="ri-arrow-up-line"></i>
</button>
<button type="button" class="text-slate-600 px-1.5 py-1 text-sm rounded-xs hover:bg-stone-200" data-role="content-move-down" aria-label="Beitrag nach unten">
<i class="ri-arrow-down-line"></i>
</button>
<multi-select-simple
id="{{ $baseID }}-musenalm-type"
name="{{ $prefix }}musenalm_type[]"
@@ -99,106 +38,171 @@
data-initial-values='[{{- range $i, $t := $content.MusenalmType -}}{{- if $i }},{{ end -}}{{ printf "%q" $t }}{{- end -}}]'>
</multi-select-simple>
</div>
<div class="inputwrapper">
<div class="inputlabelrow">
<label for="{{ $baseID }}-language" class="inputlabel">Sprache</label>
</div>
<multi-select-simple
id="{{ $baseID }}-language"
name="{{ $prefix }}language[]"
show-create-button="false"
placeholder="Sprachen suchen..."
data-empty-text="Keine Sprachen verknüpft"
value='[{{- range $i, $lang := $content.Language -}}{{- if $i }},{{ end -}}"{{ $lang }}"{{- end -}}]'
data-initial-values='[{{- range $i, $lang := $content.Language -}}{{- if $i }},{{ end -}}{{ printf "%q" $lang }}{{- end -}}]'>
</multi-select-simple>
<div class="flex items-center gap-2">
<select name="{{ $prefix }}edit_state" id="{{ $baseID }}-edit-state" class="inputselect font-bold status-select px-2 py-1" data-status="{{ $content.EditState }}">
<option value="Unknown" {{ if eq $content.EditState "Unknown" }}selected{{ end }}>Unbekannt</option>
<option value="ToDo" {{ if eq $content.EditState "ToDo" }}selected{{ end }}>Zu erledigen</option>
<option value="Review" {{ if eq $content.EditState "Review" }}selected{{ end }}>Überprüfen</option>
<option value="Edited" {{ if eq $content.EditState "Edited" }}selected{{ end }}>Erfasst</option>
</select>
</div>
</div>
<div class="px-3 py-2">
<div class="grid grid-cols-[8rem_1fr_1fr] gap-x-3 gap-y-2 items-start">
<label for="{{ $baseID }}-extent" class="text-sm font-bold text-gray-700">Seite &amp; Paginierung</label>
<textarea name="{{ $prefix }}extent" id="{{ $baseID }}-extent" class="inputinput no-enter whitespace-normal border border-slate-300 rounded-xs px-2 py-1 bg-white focus:outline-none focus:ring-2 focus:ring-slate-400/30" autocomplete="off" rows="1">{{- $content.Extent -}}</textarea>
<select name="{{ $prefix }}musenalm_pagination" id="{{ $baseID }}-pagination" class="inputselect border border-slate-300 rounded-xs px-2 py-1 bg-white focus:outline-none focus:ring-2 focus:ring-slate-400/30">
{{- range $_, $p := $paginationValues -}}
<option value="{{- $p -}}" {{ if eq $content.MusenalmPagination $p }}selected{{ end }}>{{- $p -}}</option>
{{- end -}}
</select>
</div>
<div id="{{ $baseID }}-edit-fields" class="mt-3 flex flex-col gap-3"></div>
<div-manager dm-target="{{ $baseID }}-edit-fields" class="flex items-center justify-end">
<button class="dm-menu-button text-right cursor-pointer whitespace-nowrap"><i class="ri-add-line"></i>
Felder hinzufügen</button>
<div class="grid grid-cols-[8rem_1fr_1.5rem] gap-x-3 gap-y-2 items-start {{ if eq (len $content.Language) 0 }}hidden{{ end }}">
<label for="{{ $baseID }}-language" class="text-sm font-bold text-gray-700">Sprache</label>
<multi-select-simple
id="{{ $baseID }}-language"
name="{{ $prefix }}language[]"
show-create-button="false"
placeholder="Sprachen suchen..."
data-empty-text="Keine Sprachen verknüpft"
value='[{{- range $i, $lang := $content.Language -}}{{- if $i }},{{ end -}}"{{ $lang }}"{{- end -}}]'
data-initial-values='[{{- range $i, $lang := $content.Language -}}{{- if $i }},{{ end -}}{{ printf "%q" $lang }}{{- end -}}]'>
</multi-select-simple>
<button class="dm-close-button text-gray-500">
<i class="ri-close-line"></i>
</button>
</div>
<div class="grid grid-cols-[8rem_1fr_1.5rem] gap-x-3 gap-y-2 items-start {{ if eq $content.TitleStmt "" }}hidden{{ end }}">
<label for="{{ $baseID }}-title" class="text-sm font-bold text-gray-700">Titel</label>
<textarea name="{{ $prefix }}title_statement" id="{{ $baseID }}-title" class="inputinput no-enter whitespace-normal border border-slate-300 rounded-xs px-2 py-1 bg-white focus:outline-none focus:ring-2 focus:ring-slate-400/30" autocomplete="off" rows="1">{{- $content.TitleStmt -}}</textarea>
<button class="dm-close-button text-gray-500">
<i class="ri-close-line"></i>
</button>
</div>
<div class="grid grid-cols-[8rem_1fr_1.5rem] gap-x-3 gap-y-2 items-start {{ if eq $content.SubtitleStmt "" }}hidden{{ end }}">
<label for="{{ $baseID }}-subtitle" class="text-sm font-bold text-gray-700">Untertitel</label>
<textarea name="{{ $prefix }}subtitle_statement" id="{{ $baseID }}-subtitle" class="inputinput no-enter whitespace-normal border border-slate-300 rounded-xs px-2 py-1 bg-white focus:outline-none focus:ring-2 focus:ring-slate-400/30" autocomplete="off" rows="1">{{- $content.SubtitleStmt -}}</textarea>
<button class="dm-close-button text-gray-500">
<i class="ri-close-line"></i>
</button>
</div>
<div class="grid grid-cols-[8rem_1fr_1.5rem] gap-x-3 gap-y-2 items-start {{ if eq $content.IncipitStmt "" }}hidden{{ end }}">
<label for="{{ $baseID }}-incipit" class="text-sm font-bold text-gray-700">Incipit</label>
<textarea name="{{ $prefix }}incipit_statement" id="{{ $baseID }}-incipit" class="inputinput no-enter whitespace-normal border border-slate-300 rounded-xs px-2 py-1 bg-white focus:outline-none focus:ring-2 focus:ring-slate-400/30" autocomplete="off" rows="1">{{- $content.IncipitStmt -}}</textarea>
<button class="dm-close-button text-gray-500">
<i class="ri-close-line"></i>
</button>
</div>
<div class="grid grid-cols-[8rem_1fr_1.5rem] gap-x-3 gap-y-2 items-start {{ if eq $content.ResponsibilityStmt "" }}hidden{{ end }}">
<label for="{{ $baseID }}-responsibility" class="text-sm font-bold text-gray-700">Autorangabe</label>
<textarea name="{{ $prefix }}responsibility_statement" id="{{ $baseID }}-responsibility" class="inputinput no-enter whitespace-normal border border-slate-300 rounded-xs px-2 py-1 bg-white focus:outline-none focus:ring-2 focus:ring-slate-400/30" autocomplete="off" rows="1">{{- $content.ResponsibilityStmt -}}</textarea>
<button class="dm-close-button text-gray-500">
<i class="ri-close-line"></i>
</button>
</div>
<div class="grid grid-cols-[8rem_1fr_1.5rem] gap-x-3 gap-y-2 items-start {{ if eq $content.ParallelTitle "" }}hidden{{ end }}">
<label for="{{ $baseID }}-parallel-title" class="text-sm font-bold text-gray-700">Paralleltitel</label>
<textarea name="{{ $prefix }}parallel_title" id="{{ $baseID }}-parallel-title" class="inputinput no-enter whitespace-normal border border-slate-300 rounded-xs px-2 py-1 bg-white focus:outline-none focus:ring-2 focus:ring-slate-400/30" autocomplete="off" rows="1">{{- $content.ParallelTitle -}}</textarea>
<button class="dm-close-button text-gray-500">
<i class="ri-close-line"></i>
</button>
</div>
<div class="grid grid-cols-[8rem_1fr_1.5rem] gap-x-3 gap-y-2 items-start {{ if eq $content.VariantTitle "" }}hidden{{ end }}">
<label for="{{ $baseID }}-variant-title" class="text-sm font-bold text-gray-700">Titelvarianten</label>
<textarea name="{{ $prefix }}variant_title" id="{{ $baseID }}-variant-title" class="inputinput no-enter whitespace-normal border border-slate-300 rounded-xs px-2 py-1 bg-white focus:outline-none focus:ring-2 focus:ring-slate-400/30" autocomplete="off" rows="1">{{- $content.VariantTitle -}}</textarea>
<button class="dm-close-button text-gray-500">
<i class="ri-close-line"></i>
</button>
</div>
<div class="grid grid-cols-[8rem_1fr_1.5rem] gap-x-3 gap-y-2 items-start {{ if eq $content.PlaceStmt "" }}hidden{{ end }}">
<label for="{{ $baseID }}-place" class="text-sm font-bold text-gray-700">Ortsangabe</label>
<textarea name="{{ $prefix }}place_statement" id="{{ $baseID }}-place" class="inputinput no-enter whitespace-normal border border-slate-300 rounded-xs px-2 py-1 bg-white focus:outline-none focus:ring-2 focus:ring-slate-400/30" autocomplete="off" rows="1">{{- $content.PlaceStmt -}}</textarea>
<button class="dm-close-button text-gray-500">
<i class="ri-close-line"></i>
</button>
</div>
<div class="grid grid-cols-[8rem_1fr_1.5rem] gap-x-3 gap-y-2 items-start {{ if eq $content.Annotation "" }}hidden{{ end }}">
<label for="{{ $annotationID }}" class="text-sm font-bold text-gray-700">Anmerkung</label>
<div class="border border-slate-300 rounded-xs bg-white px-2 py-1 focus-within:ring-2 focus-within:ring-slate-400/30">
<trix-toolbar id="{{ $annotationToolbar }}">
<div class="trix-toolbar-container">
<span class="trix-toolbar-group">
<button type="button" class="trix-toolbar-button" data-trix-attribute="bold" data-trix-key="b" title="Fett">
<i class="ri-bold"></i>
</button>
<button type="button" class="trix-toolbar-button" data-trix-attribute="italic" data-trix-key="i" title="Kursiv">
<i class="ri-italic"></i>
</button>
<button type="button" class="trix-toolbar-button" data-trix-attribute="strike" title="Gestrichen">
<i class="ri-strikethrough"></i>
</button>
<button type="button" class="trix-toolbar-button" data-trix-attribute="href" data-trix-action="link" data-trix-key="k" title="Link">
<i class="ri-links-line"></i>
</button>
</span>
<span class="trix-toolbar-group">
<button type="button" class="trix-toolbar-button" data-trix-attribute="heading1" title="Überschrift">
<i class="ri-h-1"></i>
</button>
<button type="button" class="trix-toolbar-button" data-trix-attribute="quote" title="Zitat">
<i class="ri-double-quotes-l"></i>
</button>
<button type="button" class="trix-toolbar-button" data-trix-attribute="bullet" title="Liste">
<i class="ri-list-unordered"></i>
</button>
<button type="button" class="trix-toolbar-button" data-trix-attribute="number" title="Aufzählung">
<i class="ri-list-ordered"></i>
</button>
<button type="button" class="trix-toolbar-button" data-trix-action="decreaseNestingLevel" title="Einzug verkleinern">
<i class="ri-indent-decrease"></i>
</button>
<button type="button" class="trix-toolbar-button" data-trix-action="increaseNestingLevel" title="Einzug vergrößern">
<i class="ri-indent-increase"></i>
</button>
</span>
<span class="trix-toolbar-group">
<button type="button" class="trix-toolbar-button" data-trix-action="undo" data-trix-key="z" title="Rückgängig">
<i class="ri-arrow-go-back-line"></i>
</button>
<button type="button" class="trix-toolbar-button" data-trix-action="redo" data-trix-key="shift+z" title="Wiederholen">
<i class="ri-arrow-go-forward-line"></i>
</button>
</span>
</div>
<div class="trix-dialogs" data-trix-dialogs>
<div class="trix-dialog trix-dialog--link" data-trix-dialog="href" data-trix-dialog-attribute="href">
<div class="trix-dialog__link-fields flex flex-row">
<input type="url" name="href" class="trix-input trix-input--dialog" placeholder="URL eingeben…" aria-label="URL" required data-trix-input>
<div class="trix-button-group flex-row">
<input type="button" class="trix-button trix-button--dialog" value="Link" data-trix-method="setAttribute">
<input type="button" class="trix-button trix-button--dialog" value="Unlink" data-trix-method="removeAttribute">
</div>
</div>
</div>
</div>
</trix-toolbar>
<textarea hidden id="{{ $annotationID }}" name="{{ $annotationID }}" autocomplete="off">{{- $content.Annotation -}}</textarea>
<trix-editor input="{{ $annotationID }}" toolbar="{{ $annotationToolbar }}" class="min-h-[6rem]"></trix-editor>
</div>
<button class="dm-close-button text-gray-500">
<i class="ri-close-line"></i>
</button>
</div>
</div-manager>
</div>
</div>
<details class="mt-3">
<summary class="text-sm font-bold text-slate-700 cursor-pointer select-none">
Weitere Felder &amp; Anmerkungen
</summary>
<div class="mt-3 grid gap-2">
<div class="grid gap-2 md:grid-cols-2">
<div class="inputwrapper">
<div class="inputlabelrow">
<label for="{{ $baseID }}-subtitle" class="inputlabel">Untertitel</label>
</div>
<textarea name="{{ $prefix }}subtitle_statement" id="{{ $baseID }}-subtitle" class="inputinput no-enter" autocomplete="off" rows="1">{{- $content.SubtitleStmt -}}</textarea>
</div>
<div class="inputwrapper">
<div class="inputlabelrow">
<label for="{{ $baseID }}-parallel-title" class="inputlabel">Paralleltitel</label>
</div>
<textarea name="{{ $prefix }}parallel_title" id="{{ $baseID }}-parallel-title" class="inputinput no-enter" autocomplete="off" rows="1">{{- $content.ParallelTitle -}}</textarea>
</div>
</div>
<div class="grid gap-2 md:grid-cols-2">
<div class="inputwrapper">
<div class="inputlabelrow">
<label for="{{ $baseID }}-variant-title" class="inputlabel">Titelvarianten</label>
</div>
<textarea name="{{ $prefix }}variant_title" id="{{ $baseID }}-variant-title" class="inputinput no-enter" autocomplete="off" rows="1">{{- $content.VariantTitle -}}</textarea>
</div>
<div class="inputwrapper">
<div class="inputlabelrow">
<label for="{{ $baseID }}-year" class="inputlabel">Jahr</label>
</div>
<input name="{{ $prefix }}year" id="{{ $baseID }}-year" class="inputinput" autocomplete="off" value="{{ if $content.Year }}{{ $content.Year }}{{ end }}" />
</div>
</div>
<div class="grid gap-2 md:grid-cols-2">
<div class="inputwrapper">
<div class="inputlabelrow">
<label for="{{ $baseID }}-publication" class="inputlabel">Publikationsangabe</label>
</div>
<textarea name="{{ $prefix }}publication_statement" id="{{ $baseID }}-publication" class="inputinput no-enter" autocomplete="off" rows="1">{{- $content.PublicationStmt -}}</textarea>
</div>
<div class="inputwrapper">
<div class="inputlabelrow">
<label for="{{ $baseID }}-place" class="inputlabel">Ortsangabe</label>
</div>
<textarea name="{{ $prefix }}place_statement" id="{{ $baseID }}-place" class="inputinput no-enter" autocomplete="off" rows="1">{{- $content.PlaceStmt -}}</textarea>
</div>
</div>
<div class="grid gap-2 md:grid-cols-2">
<div class="inputwrapper">
<div class="inputlabelrow">
<label for="{{ $baseID }}-dimensions" class="inputlabel">Maße</label>
</div>
<textarea name="{{ $prefix }}dimensions" id="{{ $baseID }}-dimensions" class="inputinput no-enter" autocomplete="off" rows="1">{{- $content.Dimensions -}}</textarea>
</div>
<div class="inputwrapper">
<div class="inputlabelrow">
<label for="{{ $baseID }}-edit-state" class="inputlabel">Status</label>
</div>
<select name="{{ $prefix }}edit_state" id="{{ $baseID }}-edit-state" class="inputselect font-bold">
<option value="Unknown" {{ if eq $content.EditState "Unknown" }}selected{{ end }}>Unbekannt</option>
<option value="ToDo" {{ if eq $content.EditState "ToDo" }}selected{{ end }}>Zu erledigen</option>
<option value="Review" {{ if eq $content.EditState "Review" }}selected{{ end }}>Überprüfen</option>
<option value="Edited" {{ if eq $content.EditState "Edited" }}selected{{ end }}>Erfasst</option>
</select>
</div>
</div>
<div class="inputwrapper">
<div class="inputlabelrow">
<label for="{{ $baseID }}-edit-comment" class="inputlabel">Bearbeitungsvermerk</label>
</div>
<textarea name="{{ $prefix }}edit_comment" id="{{ $baseID }}-edit-comment" class="inputinput no-enter" autocomplete="off" rows="1">{{- $content.Comment -}}</textarea>
</div>
<div class="inputwrapper">
<div class="inputlabelrow">
<label for="{{ $baseID }}-annotation" class="inputlabel">Anmerkung</label>
</div>
<textarea name="{{ $prefix }}annotation" id="{{ $baseID }}-annotation" class="inputinput" autocomplete="off" rows="2">{{- $content.Annotation -}}</textarea>
</div>
</div>
</details>
</div>

View File

@@ -0,0 +1,26 @@
{{- $entry := index . "entry" -}}
{{- $contentID := index . "content_id" -}}
{{- $position := index . "position" -}}
{{- $label := index . "label" -}}
<div class="relative group h-6 -my-2.5" data-role="content-gap">
<div class="pointer-events-none absolute left-0 right-0 top-1/2 h-0.5 -translate-y-1/2 bg-slate-300 opacity-0 transition-opacity duration-150 group-hover:opacity-100"></div>
<button
type="button"
class="absolute left-1/2 top-1/2 z-[10000] -translate-x-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-150 rounded-full border border-slate-300 bg-stone-100 text-slate-700 px-3 py-2 text-base shadow-sm"
data-role="content-insert"
data-position="{{ $position }}"
data-content-id="{{ $contentID }}"
aria-label="Beitrag einfügen"
hx-post="/almanach/{{ $entry.MusenalmID }}/contents/insert"
hx-target="closest [data-role='content-gap']"
hx-swap="beforebegin"
hx-include="[data-role='csrf-token']"
hx-vals='{{ printf "{\"position\":%q,\"content_id\":%q}" $position $contentID }}'>
<i class="ri-add-line"></i>
{{- if $label -}}
<span>Neuer Beitrag</span>
{{- end -}}
</button>
<div class="h-1"></div>
</div>

View File

@@ -0,0 +1,133 @@
{{- $content := index . "content" -}}
{{- $entry := index . "entry" -}}
{{- $csrf := index . "csrf_token" -}}
{{- $contentTypes := index . "content_types" -}}
{{- $musenalmTypes := index . "musenalm_types" -}}
{{- $paginationValues := index . "pagination_values" -}}
{{- $overrideID := index . "content_id" -}}
{{- $openEdit := index . "open_edit" -}}
{{- $isNew := index . "is_new" -}}
{{- $error := index . "error" -}}
{{- $contentID := $content.Id -}}
{{- if and $overrideID (ne $overrideID "") -}}
{{- $contentID = $overrideID -}}
{{- end -}}
<div data-role="content-item" class="relative {{ if $isNew }}data-new-content{{ end }} {{ if $openEdit }}data-editing{{ end }}" data-open-edit="{{ if $openEdit }}true{{ end }}" data-content-temp="{{ if $isNew }}true{{ end }}">
<div data-role="content-view" class="{{ if $openEdit }}hidden{{ end }} mt-2">
<div class="border border-slate-200 bg-stone-100 rounded-xs overflow-hidden">
<div class="flex items-center justify-between gap-4 border-b border-slate-200 bg-stone-200 px-3 py-2 cursor-grab" data-role="content-drag-handle" draggable="true" aria-label="Beitrag verschieben">
<div class="flex items-center gap-3 text-sm font-bold text-gray-800">
<div class="flex items-center gap-1">
<button type="button" class="text-slate-600 text-sm" data-role="content-move-up" aria-label="Beitrag nach oben">
<i class="ri-arrow-up-line"></i>
</button>
<button type="button" class="text-slate-600 text-sm" data-role="content-move-down" aria-label="Beitrag nach unten">
<i class="ri-arrow-down-line"></i>
</button>
</div>
{{- if $content.MusenalmType -}}
<span class="flex flex-wrap gap-1 text-gray-700 font-normal">
{{- range $i, $t := $content.MusenalmType -}}
<span class="bg-stone-100 px-1.5 py-0.5 rounded text-sm font-semibold">{{- $t -}}</span>
{{- end -}}
</span>
{{- end -}}
</div>
<div class="flex items-center gap-2">
<span class="status-badge text-xs" data-status="{{ $content.EditState }}">
<i class="status-icon {{- if eq $content.EditState "Edited" }} ri-checkbox-circle-line{{- else if eq $content.EditState "Seen" }} ri-information-line{{- else if eq $content.EditState "Review" }} ri-search-line{{- else if eq $content.EditState "ToDo" }} ri-list-check{{- else }} ri-forbid-2-line{{- end }}"></i>
{{- if eq $content.EditState "Edited" -}}Erfasst{{- else if eq $content.EditState "Review" -}}Überprüfen{{- else if eq $content.EditState "ToDo" -}}Zu erledigen{{- else if eq $content.EditState "Seen" -}}Autopsiert{{- else -}}Unbekannt{{- end -}}
</span>
<button type="button" class="resetbutton w-32 flex items-center gap-2 justify-center" data-role="content-edit-button">
<i class="ri-edit-2-line"></i>
<span>Bearbeiten</span>
</button>
</div>
</div>
<div class="grid gap-2 grid-cols-[8rem_1fr] items-start px-3 py-2">
{{- if $content.Extent -}}
<div class="text-sm font-bold text-gray-700">Seite</div>
<div class="text-base">{{- $content.Extent -}}</div>
{{- end -}}
{{- if $content.TitleStmt -}}
<div class="text-sm font-bold text-gray-700">Titel</div>
<div class="text-base italic">{{- $content.TitleStmt -}}</div>
{{- end -}}
{{- if $content.IncipitStmt -}}
<div class="text-sm font-bold text-gray-700">Incipit</div>
<div class="text-base italic">{{ $content.IncipitStmt }}…</div>
{{- end -}}
{{- if $content.ResponsibilityStmt -}}
<div class="text-sm font-bold text-gray-700">Autorangabe</div>
<div class="text-base italic">{{- $content.ResponsibilityStmt -}}</div>
{{- end -}}
{{- if $content.Annotation -}}
{{- $link := printf "%s%s" "/almanach/" $entry.MusenalmIDString -}}
<div class="text-sm font-bold text-gray-700">Anmerkung</div>
<div class="text-base">
{{- Safe (LinksAnnotation (ReplaceSlashParen $content.Annotation) $link) -}}
</div>
{{- end -}}
</div>
</div>
</div>
<div data-role="content-edit" class="{{ if not $openEdit }}hidden{{ end }} mt-2">
<form
autocomplete="off"
class="w-full dbform"
method="POST"
hx-boost="false"
hx-post="/almanach/{{ $entry.MusenalmID }}/contents/edit"
hx-target="closest [data-role='content-item']"
hx-swap="outerHTML"
action="/almanach/{{ $entry.MusenalmID }}/contents/edit">
<input type="hidden" name="csrf_token" value="{{ $csrf }}" />
{{- if $error -}}
<div class="mb-3 rounded-xs border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800">
{{ $error }}
</div>
{{- end -}}
{{- template "_content_edit" (Dict
"content" $content
"content_id" $contentID
"entry" $entry
"content_types" $contentTypes
"musenalm_types" $musenalmTypes
"pagination_values" $paginationValues
) -}}
<div class="w-full flex items-center justify-end gap-3 mt-4 flex-wrap">
<button type="button" class="resetbutton w-40 flex items-center gap-2 justify-center" data-role="content-edit-cancel">
<i class="ri-close-line"></i>
<span>Verwerfen</span>
</button>
<button type="button" class="resetbutton w-40 flex items-center gap-2 justify-center bg-red-50 text-red-800 hover:bg-red-100 hover:text-red-900" data-role="content-delete">
<i class="ri-delete-bin-line"></i>
<span>Eintrag löschen</span>
</button>
<button type="submit" class="submitbutton w-40 flex items-center gap-2 justify-center">
<i class="ri-save-line"></i>
<span>Speichern</span>
</button>
</div>
</form>
<dialog data-role="content-delete-dialog" class="fixed inset-0 m-auto rounded-md border border-slate-200 p-0 shadow-xl backdrop:bg-black/40">
<div class="p-5 w-[22rem]">
<div class="text-base font-bold text-gray-900">Eintrag löschen?</div>
{{- if $content.TitleStmt -}}
<div class="text-sm font-bold text-gray-900 mt-1">{{ $content.TitleStmt }}</div>
{{- end -}}
<p class="text-sm text-gray-700 mt-2">
Der Eintrag wird dauerhaft gelöscht. Verknüpfungen, Exemplare und Inhalte werden entfernt.
</p>
<div class="flex items-center justify-end gap-3 mt-4">
<button type="button" class="resetbutton w-auto px-3 py-1 text-sm" data-role="content-delete-cancel">Abbrechen</button>
<button type="button" class="submitbutton w-auto bg-red-700 hover:bg-red-800 px-3 py-1 text-sm" data-role="content-delete-confirm">
Löschen
</button>
</div>
</div>
</dialog>
</div>
</div>

View File

@@ -62,10 +62,20 @@ export class DivManager extends HTMLElement {
};
});
const existingButton = this._button;
this._button = this.querySelector(`.${DM_MENU_BUTTON_CLASS}`);
if (!this._button && existingButton) {
this._button = existingButton;
if (!this._button.parentElement) {
this.appendChild(this._button);
}
}
if (!this._button) {
console.error("DivManagerMenu needs a button element.");
return;
this._button = document.createElement("button");
this._button.type = "button";
this._button.classList.add(DM_MENU_BUTTON_CLASS, TAILWIND_HIDDEN_CLASS);
this._button.innerHTML = '<i class="ri-add-line"></i> Felder hinzufügen';
this.appendChild(this._button);
}
if (!this._originalButtonText) {
this._originalButtonText = this._button.innerHTML;
@@ -214,11 +224,15 @@ export class DivManager extends HTMLElement {
// Always ensure the hidden class is added
child.node.classList.add(TAILWIND_HIDDEN_CLASS);
this._clearFields(child.node);
const target = child.target();
if (target && target.contains(child.node)) {
target.removeChild(child.node);
}
if (!child.node.parentElement) {
this.appendChild(child.node);
}
// INFO: the order of these matter, dont fuck it up
this.renderButton();
this.renderMenu();
@@ -337,4 +351,26 @@ export class DivManager extends HTMLElement {
target.classList.toggle(TAILWIND_HIDDEN_CLASS, !hasVisibleChild);
});
}
_clearFields(container) {
if (!container) {
return;
}
container.querySelectorAll("input, textarea, select").forEach((field) => {
if (field.matches("input[type='checkbox'], input[type='radio']")) {
field.checked = false;
return;
}
if (field.matches("select")) {
field.selectedIndex = -1;
return;
}
field.value = "";
});
container.querySelectorAll("trix-editor").forEach((editor) => {
if (typeof editor.editor?.loadHTML === "function") {
editor.editor.loadHTML("");
}
});
}
}

View File

@@ -11,17 +11,19 @@ export class EditPage extends HTMLElement {
}
_setupStatusSelect() {
const statusSelect = this.querySelector(".status-select");
if (!statusSelect) {
const statusSelects = Array.from(this.querySelectorAll(".status-select"));
if (statusSelects.length === 0) {
return;
}
const statusIcon = this.querySelector(".status-icon");
statusSelect.addEventListener("change", (event) => {
const newStatus = event.target.value;
statusSelect.setAttribute("data-status", newStatus);
if (statusIcon) {
this._updateStatusIcon(statusIcon, newStatus);
}
statusSelects.forEach((statusSelect) => {
const statusIcon = statusSelect.parentElement?.querySelector(".status-icon");
statusSelect.addEventListener("change", (event) => {
const newStatus = event.target.value;
statusSelect.setAttribute("data-status", newStatus);
if (statusIcon) {
this._updateStatusIcon(statusIcon, newStatus);
}
});
});
}