diff --git a/README.md b/README.md index 5835ecd..4cd28c7 100644 --- a/README.md +++ b/README.md @@ -59,3 +59,5 @@ Documented `globalData` scope supported by UI/API mapping: UI note: - `/builds/new` now supports loading an existing draft, reviewing/editing values, saving draft, and only then starting the build. +- Template fields in `/builds/new` are grouped block-first by extracted internal block IDs (for example `m1710`, `c7886`, `r4830`) with heuristic fallback for fields without block IDs. +- Template field settings in `/templates/{id}` include a persistent `websiteSection` mapping (`hero`, `intro`, `services`, `service_item`, `about`, `team`, `testimonials`, `cta`, `contact`, `footer`, `gallery`, `other`) used by `/builds/new` grouping with fallback when not set. diff --git a/data/qctextbuilder.db b/data/qctextbuilder.db index d2ef412..41c8749 100644 Binary files a/data/qctextbuilder.db and b/data/qctextbuilder.db differ diff --git a/dist/qctextbuilder.exe b/dist/qctextbuilder.exe index db4cce0..736c2d3 100644 Binary files a/dist/qctextbuilder.exe and b/dist/qctextbuilder.exe differ diff --git a/internal/domain/models.go b/internal/domain/models.go index 9f8813d..b9bd364 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -40,6 +40,7 @@ type TemplateField struct { TemplateID int64 `json:"templateId"` ManifestID string `json:"manifestId"` Section string `json:"section"` + WebsiteSection string `json:"websiteSection"` KeyName string `json:"keyName"` Path string `json:"path"` FieldKind string `json:"fieldKind"` diff --git a/internal/domain/website_sections.go b/internal/domain/website_sections.go new file mode 100644 index 0000000..fa94ddf --- /dev/null +++ b/internal/domain/website_sections.go @@ -0,0 +1,130 @@ +package domain + +import ( + "regexp" + "strings" +) + +const ( + WebsiteSectionHero = "hero" + WebsiteSectionIntro = "intro" + WebsiteSectionServices = "services" + WebsiteSectionServiceItem = "service_item" + WebsiteSectionAbout = "about" + WebsiteSectionTeam = "team" + WebsiteSectionTestimonials = "testimonials" + WebsiteSectionCTA = "cta" + WebsiteSectionContact = "contact" + WebsiteSectionFooter = "footer" + WebsiteSectionGallery = "gallery" + WebsiteSectionOther = "other" +) + +var websiteSectionOrder = []string{ + WebsiteSectionHero, + WebsiteSectionIntro, + WebsiteSectionServices, + WebsiteSectionServiceItem, + WebsiteSectionAbout, + WebsiteSectionTeam, + WebsiteSectionTestimonials, + WebsiteSectionCTA, + WebsiteSectionContact, + WebsiteSectionFooter, + WebsiteSectionGallery, + WebsiteSectionOther, +} + +var websiteSectionLabels = map[string]string{ + WebsiteSectionHero: "Hero", + WebsiteSectionIntro: "Intro", + WebsiteSectionServices: "Services", + WebsiteSectionServiceItem: "Service Item", + WebsiteSectionAbout: "About", + WebsiteSectionTeam: "Team", + WebsiteSectionTestimonials: "Testimonials", + WebsiteSectionCTA: "CTA", + WebsiteSectionContact: "Contact", + WebsiteSectionFooter: "Footer", + WebsiteSectionGallery: "Gallery", + WebsiteSectionOther: "Other", +} + +var serviceItemIndexPattern = regexp.MustCompile(`_\d+$`) + +func WebsiteSectionOptions() []string { + out := make([]string, len(websiteSectionOrder)) + copy(out, websiteSectionOrder) + return out +} + +func WebsiteSectionLabel(section string) string { + normalized := NormalizeWebsiteSection(section) + if label, ok := websiteSectionLabels[normalized]; ok { + return label + } + return websiteSectionLabels[WebsiteSectionOther] +} + +func NormalizeWebsiteSection(section string) string { + normalized := strings.ToLower(strings.TrimSpace(section)) + for _, candidate := range websiteSectionOrder { + if normalized == candidate { + return candidate + } + } + return WebsiteSectionOther +} + +func SuggestWebsiteSection(field TemplateField) string { + section := strings.ToLower(strings.TrimSpace(field.Section)) + key := strings.ToLower(strings.TrimSpace(field.KeyName)) + path := strings.ToLower(strings.TrimSpace(field.Path)) + sample := strings.ToLower(strings.TrimSpace(field.SampleValue)) + fieldKind := strings.ToLower(strings.TrimSpace(field.FieldKind)) + + combined := strings.Join([]string{section, key, path}, " ") + if fieldKind == "image" || containsAny(combined, "gallery", "image", "img", "photo", "picture", "media") { + return WebsiteSectionGallery + } + if containsAny(combined, "testimonial") { + return WebsiteSectionTestimonials + } + if section == "services" || containsAny(combined, "service") { + if containsAny(combined, "item", "card") || serviceItemIndexPattern.MatchString(key) { + return WebsiteSectionServiceItem + } + return WebsiteSectionServices + } + if containsAny(combined, "team", "member", "staff", "employee", "founder") { + return WebsiteSectionTeam + } + if containsAny(combined, "welcome", "headline", "hero", "banner") { + return WebsiteSectionHero + } + if containsAny(combined, "intro", "introduction", "lead") { + return WebsiteSectionIntro + } + if containsAny(combined, "about", "company", "mission", "story") || len(sample) > 180 { + return WebsiteSectionAbout + } + if containsAny(combined, "cta", "highlight", "button", "calltoaction", "call_to_action") { + return WebsiteSectionCTA + } + if containsAny(combined, "contact", "email", "phone", "address") { + return WebsiteSectionContact + } + if containsAny(combined, "footer", "copyright", "imprint", "legal") { + return WebsiteSectionFooter + } + return WebsiteSectionOther +} + +func containsAny(value string, needles ...string) bool { + for _, needle := range needles { + if strings.Contains(value, needle) { + return true + } + } + return false +} diff --git a/internal/httpserver/handlers/handlers.go b/internal/httpserver/handlers/handlers.go index e0e741a..1de2669 100644 --- a/internal/httpserver/handlers/handlers.go +++ b/internal/httpserver/handlers/handlers.go @@ -99,6 +99,7 @@ type updateTemplateFieldItem struct { IsRequiredByUs *bool `json:"isRequiredByUs,omitempty"` DisplayLabel *string `json:"displayLabel,omitempty"` DisplayOrder *int `json:"displayOrder,omitempty"` + WebsiteSection *string `json:"websiteSection,omitempty"` Notes *string `json:"notes,omitempty"` } @@ -128,6 +129,7 @@ func (a *API) UpdateTemplateFields(w http.ResponseWriter, r *http.Request) { IsRequiredByUs: f.IsRequiredByUs, DisplayLabel: f.DisplayLabel, DisplayOrder: f.DisplayOrder, + WebsiteSection: f.WebsiteSection, Notes: f.Notes, }) } diff --git a/internal/httpserver/handlers/ui.go b/internal/httpserver/handlers/ui.go index 36eafad..4c94304 100644 --- a/internal/httpserver/handlers/ui.go +++ b/internal/httpserver/handlers/ui.go @@ -5,8 +5,11 @@ import ( "fmt" "net/http" "net/url" + "regexp" + "sort" "strconv" "strings" + "unicode" "github.com/go-chi/chi/v5" @@ -65,23 +68,69 @@ type templateFieldView struct { IsRequiredByUs bool DisplayLabel string DisplayOrder int + WebsiteSection string Notes string SampleValue string } +type websiteSectionOptionView struct { + Value string + Label string +} + type templateDetailPageData struct { pageData - Detail *templatesvc.TemplateDetail - Fields []templateFieldView + Detail *templatesvc.TemplateDetail + Fields []templateFieldView + WebsiteSectionOptions []websiteSectionOptionView } type buildFieldView struct { + Index int Path string DisplayLabel string SampleValue string Value string } +type buildFieldGroupView struct { + Title string + Fields []buildFieldView +} + +type buildFieldSectionView struct { + Key string + Title string + Description string + EditableGroups []buildFieldGroupView + EditableFields []buildFieldView + DisabledFields []buildFieldView +} + +type pendingField struct { + Field domain.TemplateField + View buildFieldView +} + +type fieldRole struct { + Label string + Order int +} + +var blockIDPattern = regexp.MustCompile(`(?i)(?:^|[_.])([mcr]\d{3,})(?:[_.]|$)`) +var looseBlockIDPattern = regexp.MustCompile(`(?i)([mcr]\d{3,})`) + +var knownBlockAreas = map[string]string{ + "m1710": "Hero / Haupttitel", + "c7886": "Intro / Einleitung", + "r4830": "Services", + "m4178": "Gallery / Medien", + "c2929": "Ueber uns / About", + "r4748": "Team", + "r1508": "Testimonials", + "c1165": "CTA / Highlight / Banner", +} + type buildNewPageData struct { pageData Templates []domain.Template @@ -89,6 +138,8 @@ type buildNewPageData struct { SelectedDraftID string SelectedTemplateID int64 SelectedManifestID string + FieldSections []buildFieldSectionView + EditableFields []buildFieldView EnabledFields []buildFieldView Form buildFormInput } @@ -188,11 +239,17 @@ func (u *UI) TemplateDetail(w http.ResponseWriter, r *http.Request) { IsRequiredByUs: f.IsRequiredByUs, DisplayLabel: f.DisplayLabel, DisplayOrder: f.DisplayOrder, + WebsiteSection: domain.NormalizeWebsiteSection(f.WebsiteSection), Notes: f.Notes, SampleValue: f.SampleValue, }) } - u.render.Render(w, "template_detail", templateDetailPageData{pageData: basePageData(r, "Template Detail", "/templates"), Detail: detail, Fields: fields}) + u.render.Render(w, "template_detail", templateDetailPageData{ + pageData: basePageData(r, "Template Detail", "/templates"), + Detail: detail, + Fields: fields, + WebsiteSectionOptions: websiteSectionOptions(), + }) } func (u *UI) OnboardTemplate(w http.ResponseWriter, r *http.Request) { @@ -228,6 +285,7 @@ func (u *UI) UpdateTemplateFields(w http.ResponseWriter, r *http.Request) { required := r.FormValue(fmt.Sprintf("field_required_%d", i)) == "on" label := r.FormValue(fmt.Sprintf("field_label_%d", i)) notes := r.FormValue(fmt.Sprintf("field_notes_%d", i)) + websiteSection := r.FormValue(fmt.Sprintf("field_website_section_%d", i)) order, err := strconv.Atoi(strings.TrimSpace(r.FormValue(fmt.Sprintf("field_order_%d", i)))) if err != nil { http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery("invalid display order")), http.StatusSeeOther) @@ -239,6 +297,7 @@ func (u *UI) UpdateTemplateFields(w http.ResponseWriter, r *http.Request) { IsRequiredByUs: boolPtr(required), DisplayLabel: strPtr(label), DisplayOrder: intPtr(order), + WebsiteSection: strPtr(websiteSection), Notes: strPtr(notes), }) } @@ -506,18 +565,564 @@ func (u *UI) loadBuildNewPageData(r *http.Request, page pageData, selectedDraftI return data, nil } data.SelectedManifestID = detail.Manifest.ID - for _, f := range detail.Fields { - if !f.IsEnabled || f.FieldKind != "text" { + data.EditableFields, data.FieldSections = buildFieldSections(detail.Fields, fieldValues) + data.EnabledFields = data.EditableFields + return data, nil +} + +func buildFieldSections(fields []domain.TemplateField, fieldValues map[string]string) ([]buildFieldView, []buildFieldSectionView) { + sectionOrder := []string{ + domain.WebsiteSectionHero, + domain.WebsiteSectionIntro, + domain.WebsiteSectionServices, + domain.WebsiteSectionAbout, + domain.WebsiteSectionTeam, + domain.WebsiteSectionTestimonials, + domain.WebsiteSectionCTA, + domain.WebsiteSectionContact, + domain.WebsiteSectionGallery, + domain.WebsiteSectionFooter, + domain.WebsiteSectionOther, + } + + sectionDescriptions := map[string]string{ + domain.WebsiteSectionHero: "Headline-nahe Felder, bevorzugt nach Block-ID gruppiert.", + domain.WebsiteSectionIntro: "Intro-/Einleitungs-Felder, bevorzugt nach Block-ID gruppiert.", + domain.WebsiteSectionServices: "Services-Felder, bevorzugt nach Block-ID gruppiert.", + domain.WebsiteSectionAbout: "About-Felder, bevorzugt nach Block-ID gruppiert.", + domain.WebsiteSectionTeam: "Team-Felder, bevorzugt nach Block-ID gruppiert.", + domain.WebsiteSectionTestimonials: "Testimonial-Felder, bevorzugt nach Block-ID gruppiert.", + domain.WebsiteSectionCTA: "CTA-/Highlight-Felder, bevorzugt nach Block-ID gruppiert.", + domain.WebsiteSectionContact: "Kontakt-Felder, bevorzugt nach Block-ID gruppiert.", + domain.WebsiteSectionGallery: "Media/Gallery-Felder, Bildfelder bleiben im MVP nicht editierbar.", + domain.WebsiteSectionFooter: "Footer-Felder, bevorzugt nach Block-ID gruppiert.", + domain.WebsiteSectionOther: "Aktive Textfelder ausserhalb der Kern-Sections, bevorzugt nach Block-ID gruppiert.", + } + + sectionsByKey := make(map[string]buildFieldSectionView, len(sectionOrder)) + pendingByKey := make(map[string][]pendingField, len(sectionOrder)) + for _, key := range sectionOrder { + sectionsByKey[key] = buildFieldSectionView{ + Key: key, + Title: domain.WebsiteSectionLabel(key), + Description: sectionDescriptions[key], + } + } + + for _, f := range fields { + targetSection := preferredBuildSection(f) + if isMediaOrGalleryField(f) || targetSection == domain.WebsiteSectionGallery { + labelFallback := domain.WebsiteSectionLabel(domain.WebsiteSectionGallery) + " - " + humanizeKey(f.KeyName) + if blockID := extractBlockID(f); blockID != "" { + labelFallback = "Media - " + blockGroupTitle(blockID) + } + media := sectionsByKey[domain.WebsiteSectionGallery] + media.DisabledFields = append(media.DisabledFields, buildFieldView{ + Path: f.Path, + DisplayLabel: effectiveLabel(f, labelFallback), + SampleValue: f.SampleValue, + Value: "", + }) + sectionsByKey[domain.WebsiteSectionGallery] = media + continue + } + if !f.IsEnabled || !strings.EqualFold(strings.TrimSpace(f.FieldKind), "text") { + continue + } + pf := pendingField{ + Field: f, + View: buildFieldView{ + Path: f.Path, + DisplayLabel: effectiveLabel(f, humanizeKey(f.KeyName)), + SampleValue: f.SampleValue, + Value: strings.TrimSpace(fieldValues[f.Path]), + }, + } + pendingByKey[targetSection] = append(pendingByKey[targetSection], pf) + } + + for _, key := range sectionOrder { + section := sectionsByKey[key] + items := pendingByKey[key] + switch key { + case domain.WebsiteSectionServices: + section = applyServicesGrouping(section, items) + case domain.WebsiteSectionTestimonials: + section = applyTestimonialsGrouping(section, items) + case domain.WebsiteSectionHero, domain.WebsiteSectionIntro, domain.WebsiteSectionAbout, domain.WebsiteSectionTeam, domain.WebsiteSectionCTA, domain.WebsiteSectionContact, domain.WebsiteSectionFooter: + section = applyTextGrouping(section, items) + case domain.WebsiteSectionOther: + section = applyOtherGrouping(section, items) + case domain.WebsiteSectionGallery: + // Gallery fields are handled as disabled entries only in this MVP. + default: + section = applyOtherGrouping(section, items) + } + sectionsByKey[key] = section + } + + sections := make([]buildFieldSectionView, 0, len(sectionOrder)) + for _, key := range sectionOrder { + sections = append(sections, sectionsByKey[key]) + } + return assignEditableIndexes(sections) +} + +func applyServicesGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView { + return applyBlockFirstGrouping(section, fields, "Services", applyServicesGroupingFallback) +} + +func applyServicesGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView { + titles := make([]pendingField, 0) + descriptions := make([]pendingField, 0) + other := make([]buildFieldView, 0) + + for _, pf := range fields { + key := strings.ToLower(strings.TrimSpace(pf.Field.KeyName)) + switch { + case strings.HasPrefix(key, "servicestitle_") || strings.HasPrefix(key, "servicestitle"): + titles = append(titles, pf) + case strings.HasPrefix(key, "servicesdescription_") || strings.HasPrefix(key, "servicesdescription"): + descriptions = append(descriptions, pf) + default: + pf.View.DisplayLabel = effectiveLabel(pf.Field, "Services - "+humanizeKey(pf.Field.KeyName)) + other = append(other, pf.View) + } + } + + maxCount := len(titles) + if len(descriptions) > maxCount { + maxCount = len(descriptions) + } + for i := 0; i < maxCount; i++ { + block := buildFieldGroupView{Title: fmt.Sprintf("Service %d", i+1)} + if i < len(titles) { + item := titles[i] + item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Service %d - Titel", i+1)) + block.Fields = append(block.Fields, item.View) + } + if i < len(descriptions) { + item := descriptions[i] + item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Service %d - Beschreibung", i+1)) + block.Fields = append(block.Fields, item.View) + } + if len(block.Fields) > 0 { + section.EditableGroups = append(section.EditableGroups, block) + } + } + section.EditableFields = append(section.EditableFields, other...) + return section +} + +func applyTestimonialsGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView { + return applyBlockFirstGrouping(section, fields, "Testimonials", applyTestimonialsGroupingFallback) +} + +func applyTestimonialsGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView { + names := make([]pendingField, 0) + titles := make([]pendingField, 0) + descriptions := make([]pendingField, 0) + other := make([]buildFieldView, 0) + + for _, pf := range fields { + key := strings.ToLower(strings.TrimSpace(pf.Field.KeyName)) + switch { + case strings.HasPrefix(key, "testimonialsname_") || strings.HasPrefix(key, "testimonialsname"): + names = append(names, pf) + case strings.HasPrefix(key, "testimonialstitle_") || strings.HasPrefix(key, "testimonialstitle"): + titles = append(titles, pf) + case strings.HasPrefix(key, "testimonialsdescription_") || strings.HasPrefix(key, "testimonialsdescription"): + descriptions = append(descriptions, pf) + default: + pf.View.DisplayLabel = effectiveLabel(pf.Field, "Testimonials - "+humanizeKey(pf.Field.KeyName)) + other = append(other, pf.View) + } + } + + maxCount := len(names) + if len(titles) > maxCount { + maxCount = len(titles) + } + if len(descriptions) > maxCount { + maxCount = len(descriptions) + } + for i := 0; i < maxCount; i++ { + block := buildFieldGroupView{Title: fmt.Sprintf("Testimonial %d", i+1)} + if i < len(names) { + item := names[i] + item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Testimonial %d - Name", i+1)) + block.Fields = append(block.Fields, item.View) + } + if i < len(titles) { + item := titles[i] + item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Testimonial %d - Titel", i+1)) + block.Fields = append(block.Fields, item.View) + } + if i < len(descriptions) { + item := descriptions[i] + item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Testimonial %d - Beschreibung", i+1)) + block.Fields = append(block.Fields, item.View) + } + if len(block.Fields) > 0 { + section.EditableGroups = append(section.EditableGroups, block) + } + } + section.EditableFields = append(section.EditableFields, other...) + return section +} + +func applyTextGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView { + return applyBlockFirstGrouping(section, fields, "Text", applyTextGroupingFallback) +} + +func applyTextGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView { + titles := make([]pendingField, 0) + descriptions := make([]pendingField, 0) + names := make([]pendingField, 0) + other := make([]buildFieldView, 0) + + for _, pf := range fields { + key := strings.ToLower(strings.TrimSpace(pf.Field.KeyName)) + switch { + case strings.HasPrefix(key, "texttitle_") || strings.HasPrefix(key, "texttitle") || strings.HasPrefix(key, "exttitle_") || strings.HasPrefix(key, "exttitle"): + titles = append(titles, pf) + case strings.HasPrefix(key, "textdescription_") || strings.HasPrefix(key, "textdescription") || strings.HasPrefix(key, "extdescription_") || strings.HasPrefix(key, "extdescription"): + descriptions = append(descriptions, pf) + case strings.HasPrefix(key, "textname_") || strings.HasPrefix(key, "textname") || strings.HasPrefix(key, "extname_") || strings.HasPrefix(key, "extname"): + names = append(names, pf) + default: + pf.View.DisplayLabel = effectiveLabel(pf.Field, "Text - "+humanizeKey(pf.Field.KeyName)) + other = append(other, pf.View) + } + } + + maxCount := len(titles) + if len(descriptions) > maxCount { + maxCount = len(descriptions) + } + if len(names) > maxCount { + maxCount = len(names) + } + for i := 0; i < maxCount; i++ { + block := buildFieldGroupView{Title: fmt.Sprintf("Textblock %d", i+1)} + if i < len(titles) { + item := titles[i] + item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Textblock %d - Titel", i+1)) + block.Fields = append(block.Fields, item.View) + } + if i < len(descriptions) { + item := descriptions[i] + item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Textblock %d - Beschreibung", i+1)) + block.Fields = append(block.Fields, item.View) + } + if i < len(names) { + item := names[i] + item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Textblock %d - Name", i+1)) + block.Fields = append(block.Fields, item.View) + } + if len(block.Fields) > 0 { + section.EditableGroups = append(section.EditableGroups, block) + } + } + section.EditableFields = append(section.EditableFields, other...) + return section +} + +func applyOtherGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView { + return applyBlockFirstGrouping(section, fields, "", applyOtherGroupingFallback) +} + +func applyOtherGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView { + for _, pf := range fields { + pf.View.DisplayLabel = effectiveLabel(pf.Field, normalizedSectionTitle(pf.Field.Section)+" - "+humanizeKey(pf.Field.KeyName)) + section.EditableFields = append(section.EditableFields, pf.View) + } + sort.SliceStable(section.EditableFields, func(i, j int) bool { + return section.EditableFields[i].Path < section.EditableFields[j].Path + }) + return section +} + +func applyBlockFirstGrouping(section buildFieldSectionView, fields []pendingField, fallbackPrefix string, fallback func(buildFieldSectionView, []pendingField) buildFieldSectionView) buildFieldSectionView { + grouped := map[string][]pendingField{} + withoutBlockID := make([]pendingField, 0) + for _, pf := range fields { + blockID := extractBlockID(pf.Field) + if blockID == "" { + withoutBlockID = append(withoutBlockID, pf) continue } - data.EnabledFields = append(data.EnabledFields, buildFieldView{ - Path: f.Path, - DisplayLabel: f.DisplayLabel, - SampleValue: f.SampleValue, - Value: strings.TrimSpace(fieldValues[f.Path]), + grouped[blockID] = append(grouped[blockID], pf) + } + + if len(grouped) > 0 { + blockIDs := make([]string, 0, len(grouped)) + for blockID := range grouped { + blockIDs = append(blockIDs, blockID) + } + sort.SliceStable(blockIDs, func(i, j int) bool { + li, lj := blockSortRank(blockIDs[i]), blockSortRank(blockIDs[j]) + if li != lj { + return li < lj + } + return blockIDs[i] < blockIDs[j] }) + + for _, blockID := range blockIDs { + items := grouped[blockID] + sort.SliceStable(items, func(i, j int) bool { + ri := deriveFieldRole(items[i].Field.KeyName) + rj := deriveFieldRole(items[j].Field.KeyName) + if ri.Order != rj.Order { + return ri.Order < rj.Order + } + return items[i].Field.Path < items[j].Field.Path + }) + + group := buildFieldGroupView{Title: blockGroupTitle(blockID)} + for _, item := range items { + role := deriveFieldRole(item.Field.KeyName) + fallbackLabel := role.Label + if fallbackLabel == "" { + fallbackLabel = humanizeKey(item.Field.KeyName) + } + item.View.DisplayLabel = effectiveLabel(item.Field, fallbackLabel) + group.Fields = append(group.Fields, item.View) + } + if len(group.Fields) > 0 { + section.EditableGroups = append(section.EditableGroups, group) + } + } } - return data, nil + + if len(withoutBlockID) > 0 { + if fallback != nil { + return fallback(section, withoutBlockID) + } + for _, pf := range withoutBlockID { + labelPrefix := fallbackPrefix + if labelPrefix == "" { + labelPrefix = normalizedSectionTitle(pf.Field.Section) + } + pf.View.DisplayLabel = effectiveLabel(pf.Field, labelPrefix+" - "+humanizeKey(pf.Field.KeyName)) + section.EditableFields = append(section.EditableFields, pf.View) + } + } + return section +} + +func extractBlockID(f domain.TemplateField) string { + candidates := []string{f.KeyName, f.Path} + for _, candidate := range candidates { + normalized := strings.ToLower(strings.TrimSpace(candidate)) + if normalized == "" { + continue + } + if match := blockIDPattern.FindStringSubmatch(normalized); len(match) > 1 { + return match[1] + } + if match := looseBlockIDPattern.FindStringSubmatch(normalized); len(match) > 1 { + return match[1] + } + } + return "" +} + +func blockGroupTitle(blockID string) string { + blockID = strings.ToLower(strings.TrimSpace(blockID)) + if blockID == "" { + return "Unbekannter Block" + } + if known, ok := knownBlockAreas[blockID]; ok { + return fmt.Sprintf("%s (%s)", known, blockID) + } + return "Block " + blockID +} + +func blockSortRank(blockID string) int { + switch strings.ToLower(strings.TrimSpace(blockID)) { + case "m1710": + return 10 + case "c7886": + return 20 + case "r4830": + return 30 + case "c2929": + return 40 + case "r4748": + return 50 + case "r1508": + return 60 + case "c1165": + return 70 + case "m4178": + return 80 + default: + return 1000 + } +} + +func deriveFieldRole(key string) fieldRole { + normalized := strings.ToLower(strings.TrimSpace(key)) + switch { + case strings.Contains(normalized, "subtitle"): + return fieldRole{Label: "Untertitel", Order: 15} + case strings.Contains(normalized, "title"): + return fieldRole{Label: "Titel", Order: 20} + case strings.Contains(normalized, "description"): + return fieldRole{Label: "Beschreibung", Order: 30} + case strings.Contains(normalized, "name"): + return fieldRole{Label: "Name", Order: 40} + case strings.Contains(normalized, "button") || strings.Contains(normalized, "cta"): + return fieldRole{Label: "CTA Text", Order: 50} + default: + return fieldRole{Label: humanizeKey(key), Order: 100} + } +} + +func assignEditableIndexes(sections []buildFieldSectionView) ([]buildFieldView, []buildFieldSectionView) { + editable := make([]buildFieldView, 0) + nextIndex := 0 + for si := range sections { + for gi := range sections[si].EditableGroups { + for fi := range sections[si].EditableGroups[gi].Fields { + sections[si].EditableGroups[gi].Fields[fi].Index = nextIndex + editable = append(editable, sections[si].EditableGroups[gi].Fields[fi]) + nextIndex++ + } + } + for fi := range sections[si].EditableFields { + sections[si].EditableFields[fi].Index = nextIndex + editable = append(editable, sections[si].EditableFields[fi]) + nextIndex++ + } + } + return editable, sections +} + +func preferredBuildSection(f domain.TemplateField) string { + websiteSection := strings.TrimSpace(f.WebsiteSection) + if websiteSection != "" { + normalized := domain.NormalizeWebsiteSection(websiteSection) + if normalized == domain.WebsiteSectionServiceItem { + return domain.WebsiteSectionServices + } + return normalized + } + return fallbackBuildSection(f) +} + +func fallbackBuildSection(f domain.TemplateField) string { + switch normalizedSection(f.Section) { + case "services": + return domain.WebsiteSectionServices + case "testimonials": + return domain.WebsiteSectionTestimonials + case "text": + if isMediaOrGalleryField(f) { + return domain.WebsiteSectionGallery + } + return domain.WebsiteSectionOther + default: + if isMediaOrGalleryField(f) { + return domain.WebsiteSectionGallery + } + return domain.WebsiteSectionOther + } +} + +func normalizedSection(raw string) string { + section := strings.ToLower(strings.TrimSpace(raw)) + switch section { + case "ext": + return "text" + default: + return section + } +} + +func normalizedSectionTitle(raw string) string { + switch normalizedSection(raw) { + case "text": + return "Text" + case "services": + return "Services" + case "testimonials": + return "Testimonials" + case "gallery", "media": + return "Media" + default: + return "Feld" + } +} + +func isMediaOrGalleryField(f domain.TemplateField) bool { + if strings.EqualFold(strings.TrimSpace(f.FieldKind), "image") { + return true + } + section := strings.ToLower(strings.TrimSpace(f.Section)) + key := strings.ToLower(strings.TrimSpace(f.KeyName)) + path := strings.ToLower(strings.TrimSpace(f.Path)) + if section == "gallery" || section == "media" { + return true + } + hints := []string{"gallery", "image", "img", "photo", "picture"} + for _, hint := range hints { + if strings.Contains(section, hint) || strings.Contains(key, hint) || strings.Contains(path, hint) { + return true + } + } + return false +} + +func effectiveLabel(f domain.TemplateField, fallback string) string { + if !isRawPathLikeLabel(f.DisplayLabel, f.Path) { + return strings.TrimSpace(f.DisplayLabel) + } + return strings.TrimSpace(fallback) +} + +func isRawPathLikeLabel(label string, path string) bool { + l := strings.TrimSpace(label) + if l == "" { + return true + } + if strings.EqualFold(l, strings.TrimSpace(path)) { + return true + } + if strings.Contains(l, ".") || strings.Contains(l, "_") { + return true + } + return false +} + +func humanizeKey(key string) string { + raw := strings.TrimSpace(key) + if raw == "" { + return "Inhalt" + } + base := raw + if idx := strings.Index(base, "_"); idx > 0 { + base = base[:idx] + } + runes := make([]rune, 0, len(base)+4) + for i, r := range base { + if i > 0 && unicode.IsUpper(r) { + runes = append(runes, ' ') + } + runes = append(runes, r) + } + human := strings.TrimSpace(string(runes)) + if human == "" { + return "Inhalt" + } + words := strings.Fields(strings.ToLower(human)) + for i := range words { + if len(words[i]) > 0 { + words[i] = strings.ToUpper(words[i][:1]) + words[i][1:] + } + } + return strings.Join(words, " ") } func buildFormInputFromRequest(r *http.Request) buildFormInput { @@ -642,3 +1247,15 @@ func defaultDraftStatus(status string) string { return "draft" } } + +func websiteSectionOptions() []websiteSectionOptionView { + values := domain.WebsiteSectionOptions() + out := make([]websiteSectionOptionView, 0, len(values)) + for _, value := range values { + out = append(out, websiteSectionOptionView{ + Value: value, + Label: domain.WebsiteSectionLabel(value), + }) + } + return out +} diff --git a/internal/httpserver/handlers/ui_grouping_test.go b/internal/httpserver/handlers/ui_grouping_test.go new file mode 100644 index 0000000..0f3f3c5 --- /dev/null +++ b/internal/httpserver/handlers/ui_grouping_test.go @@ -0,0 +1,117 @@ +package handlers + +import ( + "testing" + + "qctextbuilder/internal/domain" +) + +func TestExtractBlockID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + f domain.TemplateField + want string + }{ + { + name: "from key", + f: domain.TemplateField{ + KeyName: "textTitle_m1710_1", + Path: "text.textTitle_m1710_1", + }, + want: "m1710", + }, + { + name: "from path", + f: domain.TemplateField{ + KeyName: "servicesTitle_8", + Path: "services.servicesTitle_r4830_8", + }, + want: "r4830", + }, + { + name: "none", + f: domain.TemplateField{ + KeyName: "plainTitle", + Path: "text.plainTitle", + }, + want: "", + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := extractBlockID(tc.f) + if got != tc.want { + t.Fatalf("extractBlockID() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestApplyTextGroupingPrefersBlockID(t *testing.T) { + t.Parallel() + + section := buildFieldSectionView{Key: "text", Title: "Text"} + fields := []pendingField{ + { + Field: domain.TemplateField{ + Section: "text", + KeyName: "textTitle_m1710_1", + Path: "text.textTitle_m1710_1", + DisplayLabel: "text.textTitle_m1710_1", + }, + View: buildFieldView{Path: "text.textTitle_m1710_1"}, + }, + { + Field: domain.TemplateField{ + Section: "ext", + KeyName: "textDescription_c7886_3", + Path: "ext.textDescription_c7886_3", + DisplayLabel: "ext.textDescription_c7886_3", + }, + View: buildFieldView{Path: "ext.textDescription_c7886_3"}, + }, + { + Field: domain.TemplateField{ + Section: "text", + KeyName: "textDescription_m1710_2", + Path: "text.textDescription_m1710_2", + DisplayLabel: "text.textDescription_m1710_2", + }, + View: buildFieldView{Path: "text.textDescription_m1710_2"}, + }, + } + + got := applyTextGrouping(section, fields) + if len(got.EditableGroups) != 2 { + t.Fatalf("expected 2 block groups, got %d", len(got.EditableGroups)) + } + if got.EditableGroups[0].Title != "Hero / Haupttitel (m1710)" { + t.Fatalf("unexpected first group title: %q", got.EditableGroups[0].Title) + } + if len(got.EditableGroups[0].Fields) != 2 { + t.Fatalf("expected 2 fields in first group, got %d", len(got.EditableGroups[0].Fields)) + } + if got.EditableGroups[1].Title != "Intro / Einleitung (c7886)" { + t.Fatalf("unexpected second group title: %q", got.EditableGroups[1].Title) + } +} + +func TestPreferredBuildSectionUsesWebsiteSectionFirst(t *testing.T) { + t.Parallel() + + field := domain.TemplateField{ + Section: "text", + WebsiteSection: domain.WebsiteSectionCTA, + KeyName: "textTitle", + Path: "text.textTitle", + } + got := preferredBuildSection(field) + if got != domain.WebsiteSectionCTA { + t.Fatalf("preferredBuildSection() = %q, want %q", got, domain.WebsiteSectionCTA) + } +} diff --git a/internal/onboarding/service.go b/internal/onboarding/service.go index 49d0ec0..ed02b17 100644 --- a/internal/onboarding/service.go +++ b/internal/onboarding/service.go @@ -43,6 +43,7 @@ type FieldPatch struct { IsRequiredByUs *bool DisplayLabel *string DisplayOrder *int + WebsiteSection *string Notes *string } @@ -150,6 +151,9 @@ func (s *Service) UpdateTemplateFields(ctx context.Context, templateID int64, ma if patch.DisplayOrder != nil { fields[idx].DisplayOrder = *patch.DisplayOrder } + if patch.WebsiteSection != nil { + fields[idx].WebsiteSection = domain.NormalizeWebsiteSection(*patch.WebsiteSection) + } if patch.Notes != nil { fields[idx].Notes = strings.TrimSpace(*patch.Notes) } @@ -203,10 +207,17 @@ func flattenDiscovery(templateID int64, manifestID string, data qcclient.Generat enabled := kind == "text" fields = append(fields, domain.TemplateField{ - ID: fmt.Sprintf("%s-%d", manifestID, order+1), - TemplateID: templateID, - ManifestID: manifestID, - Section: section, + ID: fmt.Sprintf("%s-%d", manifestID, order+1), + TemplateID: templateID, + ManifestID: manifestID, + Section: section, + WebsiteSection: domain.SuggestWebsiteSection(domain.TemplateField{ + Section: section, + KeyName: key, + Path: path, + FieldKind: kind, + SampleValue: sample, + }), KeyName: key, Path: path, FieldKind: kind, diff --git a/internal/onboarding/service_test.go b/internal/onboarding/service_test.go index 8ab36b6..589606e 100644 --- a/internal/onboarding/service_test.go +++ b/internal/onboarding/service_test.go @@ -72,9 +72,11 @@ func TestFlattenDiscovery_DisablesImageLikeFields(t *testing.T) { byPath := map[string]string{} enabled := map[string]bool{} + websiteSections := map[string]string{} for _, f := range fields { byPath[f.Path] = f.FieldKind enabled[f.Path] = f.IsEnabled + websiteSections[f.Path] = f.WebsiteSection } imagePath := "gallery.galleryImage_m4178_15" @@ -84,6 +86,9 @@ func TestFlattenDiscovery_DisablesImageLikeFields(t *testing.T) { if enabled[imagePath] { t.Fatalf("field %s should be disabled by default", imagePath) } + if websiteSections[imagePath] != "gallery" { + t.Fatalf("field %s websiteSection = %q, want gallery", imagePath, websiteSections[imagePath]) + } textPath := "hero.title" if byPath[textPath] != "text" { @@ -92,4 +97,7 @@ func TestFlattenDiscovery_DisablesImageLikeFields(t *testing.T) { if !enabled[textPath] { t.Fatalf("field %s should be enabled by default", textPath) } + if websiteSections[textPath] != "hero" { + t.Fatalf("field %s websiteSection = %q, want hero", textPath, websiteSections[textPath]) + } } diff --git a/internal/store/sqlite/migrations/002_add_website_section_to_fields.sql b/internal/store/sqlite/migrations/002_add_website_section_to_fields.sql new file mode 100644 index 0000000..d86acde --- /dev/null +++ b/internal/store/sqlite/migrations/002_add_website_section_to_fields.sql @@ -0,0 +1,71 @@ +ALTER TABLE qc_template_fields +ADD COLUMN website_section TEXT NOT NULL DEFAULT 'other'; + +UPDATE qc_template_fields +SET website_section = CASE + WHEN lower(field_kind) = 'image' + OR lower(section) LIKE '%gallery%' + OR lower(section) LIKE '%media%' + OR lower(key_name) LIKE '%gallery%' + OR lower(key_name) LIKE '%image%' + OR lower(key_name) LIKE '%photo%' + OR lower(path) LIKE '%gallery%' + OR lower(path) LIKE '%image%' + OR lower(path) LIKE '%photo%' + THEN 'gallery' + WHEN lower(section) LIKE '%testimonial%' + OR lower(key_name) LIKE '%testimonial%' + OR lower(path) LIKE '%testimonial%' + THEN 'testimonials' + WHEN lower(section) = 'services' + AND (lower(key_name) LIKE '%item%' OR lower(key_name) GLOB '*_[0-9]*') + THEN 'service_item' + WHEN lower(section) = 'services' + OR lower(key_name) LIKE '%service%' + OR lower(path) LIKE '%service%' + THEN 'services' + WHEN lower(section) LIKE '%team%' + OR lower(key_name) LIKE '%team%' + OR lower(key_name) LIKE '%member%' + OR lower(path) LIKE '%team%' + OR lower(path) LIKE '%member%' + THEN 'team' + WHEN lower(section) LIKE '%hero%' + OR lower(key_name) LIKE '%hero%' + OR lower(path) LIKE '%hero%' + OR lower(key_name) LIKE '%welcome%' + OR lower(path) LIKE '%welcome%' + OR lower(key_name) LIKE '%banner%' + OR lower(path) LIKE '%banner%' + THEN 'hero' + WHEN lower(section) LIKE '%intro%' + OR lower(key_name) LIKE '%intro%' + OR lower(path) LIKE '%intro%' + THEN 'intro' + WHEN lower(section) LIKE '%about%' + OR lower(key_name) LIKE '%about%' + OR lower(path) LIKE '%about%' + OR length(sample_value) > 180 + THEN 'about' + WHEN lower(section) LIKE '%cta%' + OR lower(key_name) LIKE '%cta%' + OR lower(path) LIKE '%cta%' + OR lower(key_name) LIKE '%button%' + OR lower(path) LIKE '%button%' + OR lower(key_name) LIKE '%highlight%' + OR lower(path) LIKE '%highlight%' + THEN 'cta' + WHEN lower(section) LIKE '%contact%' + OR lower(key_name) LIKE '%contact%' + OR lower(path) LIKE '%contact%' + OR lower(key_name) LIKE '%email%' + OR lower(path) LIKE '%email%' + OR lower(key_name) LIKE '%phone%' + OR lower(path) LIKE '%phone%' + THEN 'contact' + WHEN lower(section) LIKE '%footer%' + OR lower(key_name) LIKE '%footer%' + OR lower(path) LIKE '%footer%' + THEN 'footer' + ELSE 'other' +END; diff --git a/internal/store/sqlite/store.go b/internal/store/sqlite/store.go index ddb82a5..ed19e29 100644 --- a/internal/store/sqlite/store.go +++ b/internal/store/sqlite/store.go @@ -188,10 +188,10 @@ func (s *Store) CreateManifest(ctx context.Context, manifest domain.TemplateMani for _, f := range fields { _, err := tx.ExecContext(ctx, ` INSERT INTO qc_template_fields ( - id, template_id, manifest_id, section, key_name, path, field_kind, + id, template_id, manifest_id, section, website_section, key_name, path, field_kind, sample_value, is_enabled, is_required_by_us, display_label, display_order, notes - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - f.ID, f.TemplateID, f.ManifestID, f.Section, f.KeyName, f.Path, f.FieldKind, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + f.ID, f.TemplateID, f.ManifestID, f.Section, domain.NormalizeWebsiteSection(f.WebsiteSection), f.KeyName, f.Path, f.FieldKind, f.SampleValue, boolToInt(f.IsEnabled), boolToInt(f.IsRequiredByUs), f.DisplayLabel, f.DisplayOrder, f.Notes, ) if err != nil { @@ -218,7 +218,7 @@ func (s *Store) GetActiveManifestByTemplateID(ctx context.Context, templateID in func (s *Store) ListFieldsByManifestID(ctx context.Context, manifestID string) ([]domain.TemplateField, error) { rows, err := s.db.QueryContext(ctx, ` - SELECT id, template_id, manifest_id, section, key_name, path, field_kind, sample_value, + SELECT id, template_id, manifest_id, section, website_section, key_name, path, field_kind, sample_value, is_enabled, is_required_by_us, display_label, display_order, notes FROM qc_template_fields WHERE manifest_id = ? @@ -233,13 +233,14 @@ func (s *Store) ListFieldsByManifestID(ctx context.Context, manifestID string) ( var f domain.TemplateField var isEnabled, isRequired int if err := rows.Scan( - &f.ID, &f.TemplateID, &f.ManifestID, &f.Section, &f.KeyName, &f.Path, &f.FieldKind, &f.SampleValue, + &f.ID, &f.TemplateID, &f.ManifestID, &f.Section, &f.WebsiteSection, &f.KeyName, &f.Path, &f.FieldKind, &f.SampleValue, &isEnabled, &isRequired, &f.DisplayLabel, &f.DisplayOrder, &f.Notes, ); err != nil { return nil, err } f.IsEnabled = isEnabled == 1 f.IsRequiredByUs = isRequired == 1 + f.WebsiteSection = domain.NormalizeWebsiteSection(f.WebsiteSection) fields = append(fields, f) } if err := rows.Err(); err != nil { @@ -271,10 +272,10 @@ func (s *Store) UpdateFields(ctx context.Context, manifestID string, fields []do for _, f := range fields { _, err := tx.ExecContext(ctx, ` INSERT INTO qc_template_fields ( - id, template_id, manifest_id, section, key_name, path, field_kind, + id, template_id, manifest_id, section, website_section, key_name, path, field_kind, sample_value, is_enabled, is_required_by_us, display_label, display_order, notes - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - f.ID, f.TemplateID, f.ManifestID, f.Section, f.KeyName, f.Path, f.FieldKind, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + f.ID, f.TemplateID, f.ManifestID, f.Section, domain.NormalizeWebsiteSection(f.WebsiteSection), f.KeyName, f.Path, f.FieldKind, f.SampleValue, boolToInt(f.IsEnabled), boolToInt(f.IsRequiredByUs), f.DisplayLabel, f.DisplayOrder, f.Notes, ) if err != nil { diff --git a/web/templates/build_new.gohtml b/web/templates/build_new.gohtml index afb2efb..10c9525 100644 --- a/web/templates/build_new.gohtml +++ b/web/templates/build_new.gohtml @@ -39,7 +39,7 @@ - +
{{.Description}}
{{end}} + + {{range .EditableGroups}} +| Field | Value | Sample | ||
|---|---|---|---|---|
|
-
- {{$f.DisplayLabel}} {{$f.Path}} + + {{.DisplayLabel}} {{.Path}} |
- - | {{$f.SampleValue}} | ++ | {{.SampleValue}} |
| No enabled text fields found for this template. | ||||
| Field | Value | Sample |
|---|---|---|
|
+
+ {{.DisplayLabel}} {{.Path}} + |
+ + | {{.SampleValue}} | +
| Field | Status | Sample |
|---|---|---|
| {{.DisplayLabel}} {{.Path}} |
+ Erkannt, deaktiviert (MVP ohne Bildlogik) | +{{.SampleValue}} | +
No enabled text fields found for this template.
+ {{end}} diff --git a/web/templates/template_detail.gohtml b/web/templates/template_detail.gohtml index 59815c5..0e9ff29 100644 --- a/web/templates/template_detail.gohtml +++ b/web/templates/template_detail.gohtml @@ -50,6 +50,7 @@