mirror of
https://github.com/Theodor-Springmann-Stiftung/musenalm.git
synced 2026-02-04 10:35:30 +00:00
+Orte
This commit is contained in:
90
controllers/api_places.go
Normal file
90
controllers/api_places.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/app"
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/middleware"
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/templating"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
URL_API_PLACES = "/api/places"
|
||||||
|
URL_API_PLACES_SEARCH = "/search"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
app.Register(&PlacesAPI{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlacesAPI struct{}
|
||||||
|
|
||||||
|
func (p *PlacesAPI) Up(app core.App, engine *templating.Engine) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PlacesAPI) Down(app core.App, engine *templating.Engine) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PlacesAPI) Setup(router *router.Router[*core.RequestEvent], app core.App, engine *templating.Engine) error {
|
||||||
|
rg := router.Group(URL_API_PLACES)
|
||||||
|
rg.BindFunc(middleware.Authenticated(app))
|
||||||
|
rg.BindFunc(middleware.IsAdminOrEditor())
|
||||||
|
rg.GET(URL_API_PLACES_SEARCH, p.searchHandler(app))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PlacesAPI) searchHandler(app core.App) HandleFunc {
|
||||||
|
return func(e *core.RequestEvent) error {
|
||||||
|
query := strings.TrimSpace(e.Request.URL.Query().Get("q"))
|
||||||
|
limit := parseLimit(e.Request.URL.Query().Get("limit"))
|
||||||
|
|
||||||
|
results, err := dbmodels.SearchPlaces(app, query, limit)
|
||||||
|
if err != nil {
|
||||||
|
app.Logger().Error("place search failed", "query", query, "limit", limit, "error", err)
|
||||||
|
return e.JSON(http.StatusInternalServerError, map[string]any{
|
||||||
|
"error": "failed to search places",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
response := make([]map[string]string, 0, len(results))
|
||||||
|
for _, place := range results {
|
||||||
|
if place == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
response = append(response, map[string]string{
|
||||||
|
"id": place.Id,
|
||||||
|
"name": place.Name(),
|
||||||
|
"detail": place.Pseudonyms(),
|
||||||
|
"annotation": place.Annotation(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.JSON(http.StatusOK, map[string]any{
|
||||||
|
"places": response,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLimit(value string) int {
|
||||||
|
if value == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := strconv.Atoi(value)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
43
dbmodels/places_functions.go
Normal file
43
dbmodels/places_functions.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package dbmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultPlacesSearchLimit = 10
|
||||||
|
maxPlacesSearchLimit = 50
|
||||||
|
)
|
||||||
|
|
||||||
|
// SearchPlaces performs a lightweight search against the places table using the provided term.
|
||||||
|
// It matches against the place name and pseudonyms fields.
|
||||||
|
func SearchPlaces(app core.App, term string, limit int) ([]*Place, error) {
|
||||||
|
places := []*Place{}
|
||||||
|
trimmed := strings.TrimSpace(term)
|
||||||
|
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = defaultPlacesSearchLimit
|
||||||
|
}
|
||||||
|
if limit > maxPlacesSearchLimit {
|
||||||
|
limit = maxPlacesSearchLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
query := app.RecordQuery(PLACES_TABLE).
|
||||||
|
Limit(int64(limit))
|
||||||
|
|
||||||
|
if trimmed != "" {
|
||||||
|
query = query.Where(dbx.Or(
|
||||||
|
dbx.Like(PLACES_NAME_FIELD, trimmed).Match(true, true),
|
||||||
|
dbx.Like(PLACES_PSEUDONYMS_FIELD, trimmed).Match(true, true),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := query.All(&places); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return places, nil
|
||||||
|
}
|
||||||
@@ -21,7 +21,6 @@ var deact_cookie = &http.Cookie{
|
|||||||
func Authenticated(app core.App) func(*core.RequestEvent) error {
|
func Authenticated(app core.App) func(*core.RequestEvent) error {
|
||||||
return func(e *core.RequestEvent) error {
|
return func(e *core.RequestEvent) error {
|
||||||
if strings.HasPrefix(e.Request.URL.Path, "/assets") ||
|
if strings.HasPrefix(e.Request.URL.Path, "/assets") ||
|
||||||
strings.HasPrefix(e.Request.URL.Path, "/api") ||
|
|
||||||
strings.HasPrefix(e.Request.URL.Path, "/_") {
|
strings.HasPrefix(e.Request.URL.Path, "/_") {
|
||||||
return e.Next()
|
return e.Next()
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -111,13 +111,20 @@ type AlmanachResult struct {
|
|||||||
<!-- Kurztitel -->
|
<!-- Kurztitel -->
|
||||||
<div class="inputwrapper">
|
<div class="inputwrapper">
|
||||||
<div class="flex flex-row justify-between">
|
<div class="flex flex-row justify-between">
|
||||||
<label for="preferred_title" class="inputlabel"><i class="ri-text"></i> Kurztitel</label>
|
<label for="preferred_title" class="inputlabel">Kurztitel</label>
|
||||||
</div>
|
</div>
|
||||||
<textarea name="preferred_title" id="preferred_title" class="inputinput no-enter" placeholder="" required autocomplete="off" rows="1">
|
<textarea name="preferred_title" id="preferred_title" class="inputinput no-enter" placeholder="" required autocomplete="off" rows="1">
|
||||||
{{- $model.result.Entry.PreferredTitle -}}
|
{{- $model.result.Entry.PreferredTitle -}}
|
||||||
</textarea>
|
</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<div class="flex items-center gap-2 text-lg font-bold text-gray-700">
|
||||||
|
<i class="ri-book-2-line"></i>
|
||||||
|
<span>Titeldaten & Anmerkungen</span>
|
||||||
|
</div>
|
||||||
|
<hr class="border-stone-200 mt-2" />
|
||||||
|
<div class="flex flex-col gap-4 mt-4">
|
||||||
<!-- Titles Section -->
|
<!-- Titles Section -->
|
||||||
<div id="titles"></div>
|
<div id="titles"></div>
|
||||||
<div-manager dm-target="titles">
|
<div-manager dm-target="titles">
|
||||||
@@ -126,7 +133,7 @@ type AlmanachResult struct {
|
|||||||
|
|
||||||
<div class="inputwrapper {{ if eq $model.result.Entry.TitleStmt "" }}hidden{{ end }}">
|
<div class="inputwrapper {{ if eq $model.result.Entry.TitleStmt "" }}hidden{{ end }}">
|
||||||
<div class="flex flex-row justify-between">
|
<div class="flex flex-row justify-between">
|
||||||
<label for="title" class="inputlabel menu-label"> <i class="ri-text"></i> Titel</label>
|
<label for="title" class="inputlabel menu-label">Titel</label>
|
||||||
<div class="pr-2">
|
<div class="pr-2">
|
||||||
<button class="dm-close-button font-bold input-label">
|
<button class="dm-close-button font-bold input-label">
|
||||||
<i class="ri-close-line"></i>
|
<i class="ri-close-line"></i>
|
||||||
@@ -141,7 +148,7 @@ type AlmanachResult struct {
|
|||||||
|
|
||||||
<div class="mt-2 inputwrapper {{ if eq $model.result.Entry.ParallelTitle "" }}hidden{{ end }}">
|
<div class="mt-2 inputwrapper {{ if eq $model.result.Entry.ParallelTitle "" }}hidden{{ end }}">
|
||||||
<div class="flex flex-row justify-between">
|
<div class="flex flex-row justify-between">
|
||||||
<label for="paralleltitle" class="inputlabel menu-label"><i class="ri-text"></i> Titel
|
<label for="paralleltitle" class="inputlabel menu-label">Titel
|
||||||
(übersetzt)</label>
|
(übersetzt)</label>
|
||||||
<div class="pr-2">
|
<div class="pr-2">
|
||||||
<button class="dm-close-button font-bold input-label">
|
<button class="dm-close-button font-bold input-label">
|
||||||
@@ -158,7 +165,7 @@ type AlmanachResult struct {
|
|||||||
|
|
||||||
<div class="mt-3 inputwrapper {{ if eq $model.result.Entry.SubtitleStmt "" }}hidden{{ end }}">
|
<div class="mt-3 inputwrapper {{ if eq $model.result.Entry.SubtitleStmt "" }}hidden{{ end }}">
|
||||||
<div class="flex flex-row justify-between">
|
<div class="flex flex-row justify-between">
|
||||||
<label for="subtitle" class="inputlabel menu-label"><i class="ri-text"></i> Untertitel</label>
|
<label for="subtitle" class="inputlabel menu-label">Untertitel</label>
|
||||||
<div class="pr-2">
|
<div class="pr-2">
|
||||||
<button class="dm-close-button font-bold input-label">
|
<button class="dm-close-button font-bold input-label">
|
||||||
<i class="ri-close-line"></i>
|
<i class="ri-close-line"></i>
|
||||||
@@ -173,8 +180,7 @@ type AlmanachResult struct {
|
|||||||
|
|
||||||
<div class="mt-3 inputwrapper {{ if eq $model.result.Entry.VariantTitle "" }}hidden{{ end }}">
|
<div class="mt-3 inputwrapper {{ if eq $model.result.Entry.VariantTitle "" }}hidden{{ end }}">
|
||||||
<div class="flex flex-row justify-between">
|
<div class="flex flex-row justify-between">
|
||||||
<label for="varianttitle" class="inputlabel menu-label"><i class="ri-text"></i>
|
<label for="varianttitle" class="inputlabel menu-label">Titelvarianten</label>
|
||||||
Titelvarianten</label>
|
|
||||||
<div class="pr-2">
|
<div class="pr-2">
|
||||||
<button class="dm-close-button font-bold input-label">
|
<button class="dm-close-button font-bold input-label">
|
||||||
<i class="ri-close-line"></i>
|
<i class="ri-close-line"></i>
|
||||||
@@ -189,7 +195,7 @@ type AlmanachResult struct {
|
|||||||
|
|
||||||
<div class="mt-3 inputwrapper {{ if eq $model.result.Entry.IncipitStmt "" }}hidden{{ end }}">
|
<div class="mt-3 inputwrapper {{ if eq $model.result.Entry.IncipitStmt "" }}hidden{{ end }}">
|
||||||
<div class="flex flex-row justify-between">
|
<div class="flex flex-row justify-between">
|
||||||
<label for="incipit" class="inputlabel menu-label"><i class="ri-text"></i> Incipit</label>
|
<label for="incipit" class="inputlabel menu-label">Incipit</label>
|
||||||
<div class="pr-2">
|
<div class="pr-2">
|
||||||
<button class="dm-close-button font-bold input-label">
|
<button class="dm-close-button font-bold input-label">
|
||||||
<i class="ri-close-line"></i>
|
<i class="ri-close-line"></i>
|
||||||
@@ -206,12 +212,12 @@ type AlmanachResult struct {
|
|||||||
<!-- Publication Information: Year and Edition - Always visible -->
|
<!-- Publication Information: Year and Edition - Always visible -->
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<div class="flex-1 inputwrapper">
|
<div class="flex-1 inputwrapper">
|
||||||
<label for="year" class="inputlabel"><i class="ri-calendar-line"></i> Jahr</label>
|
<label for="year" class="inputlabel">Jahr</label>
|
||||||
<input type="number" name="year" id="year" class="inputinput" placeholder="" autocomplete="off" value="{{ $model.result.Entry.Year }}" />
|
<input type="number" name="year" id="year" class="inputinput" placeholder="" autocomplete="off" value="{{ $model.result.Entry.Year }}" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 inputwrapper">
|
<div class="flex-1 inputwrapper">
|
||||||
<label for="edition" class="inputlabel"><i class="ri-file-copy-line"></i> Ausgabe</label>
|
<label for="edition" class="inputlabel">Ausgabe</label>
|
||||||
<textarea name="edition" id="edition" class="inputinput" placeholder="" autocomplete="off" rows="1">{{- $model.result.Entry.Edition -}}</textarea>
|
<textarea name="edition" id="edition" class="inputinput" placeholder="" autocomplete="off" rows="1">{{- $model.result.Entry.Edition -}}</textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,7 +230,7 @@ type AlmanachResult struct {
|
|||||||
|
|
||||||
<div class="inputwrapper {{ if eq $model.result.Entry.ResponsibilityStmt "" }}hidden{{ end }}">
|
<div class="inputwrapper {{ if eq $model.result.Entry.ResponsibilityStmt "" }}hidden{{ end }}">
|
||||||
<div class="flex flex-row justify-between">
|
<div class="flex flex-row justify-between">
|
||||||
<label for="responsibility_statement" class="inputlabel menu-label"><i class="ri-user-line"></i> Autorangabe</label>
|
<label for="responsibility_statement" class="inputlabel menu-label">Autorangabe</label>
|
||||||
<div class="pr-2">
|
<div class="pr-2">
|
||||||
<button class="dm-close-button font-bold input-label">
|
<button class="dm-close-button font-bold input-label">
|
||||||
<i class="ri-close-line"></i>
|
<i class="ri-close-line"></i>
|
||||||
@@ -237,7 +243,7 @@ type AlmanachResult struct {
|
|||||||
|
|
||||||
<div class="mt-3 inputwrapper {{ if eq $model.result.Entry.PublicationStmt "" }}hidden{{ end }}">
|
<div class="mt-3 inputwrapper {{ if eq $model.result.Entry.PublicationStmt "" }}hidden{{ end }}">
|
||||||
<div class="flex flex-row justify-between">
|
<div class="flex flex-row justify-between">
|
||||||
<label for="publication_statement" class="inputlabel menu-label"><i class="ri-book-line"></i> Publikationsangabe</label>
|
<label for="publication_statement" class="inputlabel menu-label">Publikationsangabe</label>
|
||||||
<div class="pr-2">
|
<div class="pr-2">
|
||||||
<button class="dm-close-button font-bold input-label">
|
<button class="dm-close-button font-bold input-label">
|
||||||
<i class="ri-close-line"></i>
|
<i class="ri-close-line"></i>
|
||||||
@@ -250,7 +256,7 @@ type AlmanachResult struct {
|
|||||||
|
|
||||||
<div class="mt-3 inputwrapper {{ if eq $model.result.Entry.PlaceStmt "" }}hidden{{ end }}">
|
<div class="mt-3 inputwrapper {{ if eq $model.result.Entry.PlaceStmt "" }}hidden{{ end }}">
|
||||||
<div class="flex flex-row justify-between">
|
<div class="flex flex-row justify-between">
|
||||||
<label for="place_statement" class="inputlabel menu-label"><i class="ri-map-pin-line"></i> Ortsangabe</label>
|
<label for="place_statement" class="inputlabel menu-label">Ortsangabe</label>
|
||||||
<div class="pr-2">
|
<div class="pr-2">
|
||||||
<button class="dm-close-button font-bold input-label">
|
<button class="dm-close-button font-bold input-label">
|
||||||
<i class="ri-close-line"></i>
|
<i class="ri-close-line"></i>
|
||||||
@@ -263,32 +269,71 @@ type AlmanachResult struct {
|
|||||||
</div-manager>
|
</div-manager>
|
||||||
|
|
||||||
<!-- Annotationen -->
|
<!-- Annotationen -->
|
||||||
<div id="annotation_section"></div>
|
<div class="inputwrapper">
|
||||||
<div-manager dm-target="annotation_section">
|
<label for="annotation" class="inputlabel">Annotationen</label>
|
||||||
<button class="dm-menu-button text-right w-full cursor-pointer"><i class="ri-add-line"></i>
|
|
||||||
Annotationen hinzufügen</button>
|
|
||||||
|
|
||||||
<div class="inputwrapper {{ if eq $model.result.Entry.Annotation "" }}hidden{{ end }}">
|
|
||||||
<div class="flex flex-row justify-between">
|
|
||||||
<label for="annotation" class="inputlabel menu-label"><i class="ri-sticky-note-line"></i> Annotationen</label>
|
|
||||||
<div class="pr-2">
|
|
||||||
<button class="dm-close-button font-bold input-label">
|
|
||||||
<i class="ri-close-line"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<textarea name="annotation" id="annotation" class="inputinput" placeholder="" autocomplete="off">{{- $model.result.Entry.Annotation -}}</textarea>
|
<textarea name="annotation" id="annotation" class="inputinput" placeholder="" autocomplete="off">{{- $model.result.Entry.Annotation -}}</textarea>
|
||||||
</div>
|
</div>
|
||||||
</div-manager>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<div class="flex items-center gap-2 text-lg font-bold text-gray-700">
|
||||||
|
<i class="ri-links-line"></i>
|
||||||
|
<span>Normdaten & Verknüpfungen</span>
|
||||||
|
</div>
|
||||||
|
<hr class="border-stone-200 mt-2" />
|
||||||
|
<div class="flex flex-col gap-4 mt-4">
|
||||||
|
<div class="inputwrapper">
|
||||||
|
<label for="places" class="inputlabel">Erscheinungs- und Verlagsorte</label>
|
||||||
|
<multi-select-simple
|
||||||
|
id="places"
|
||||||
|
name="places[]"
|
||||||
|
placeholder="Orte suchen..."
|
||||||
|
show-create-button="false"
|
||||||
|
data-endpoint="/api/places/search"
|
||||||
|
data-result-key="places"
|
||||||
|
data-minchars="1"
|
||||||
|
data-limit="15">
|
||||||
|
</multi-select-simple>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
const placesSelect = document.getElementById("places");
|
||||||
|
if (placesSelect) {
|
||||||
|
const initialPlaces = [
|
||||||
|
{{- range $i, $place := $model.result.Places }}
|
||||||
|
{ id: "{{ $place.Id }}", name: {{ printf "%q" $place.Name }}, additional_data: {{ printf "%q" $place.Pseudonyms }} },
|
||||||
|
{{- end -}}
|
||||||
|
];
|
||||||
|
const initialPlaceIds = [
|
||||||
|
{{- range $i, $place := $model.result.Places -}}
|
||||||
|
{{- if $i }},{{ end }}"{{ $place.Id }}"
|
||||||
|
{{- end -}}
|
||||||
|
];
|
||||||
|
if (initialPlaces.length > 0) {
|
||||||
|
placesSelect.setOptions(initialPlaces);
|
||||||
|
}
|
||||||
|
placesSelect.value = initialPlaceIds;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<!-- End Left Column -->
|
<!-- End Left Column -->
|
||||||
|
|
||||||
<!-- Right Column -->
|
<!-- Right Column -->
|
||||||
<div class="w-[28rem] shrink-0 flex flex-col gap-4">
|
<div class="w-[28rem] shrink-0 flex flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 text-lg font-bold text-gray-700">
|
||||||
|
<i class="ri-clipboard-line"></i>
|
||||||
|
<span>Bearbeitungsdaten</span>
|
||||||
|
</div>
|
||||||
|
<hr class="border-stone-200 mt-2" />
|
||||||
|
<div class="flex flex-col gap-4 mt-4">
|
||||||
<!-- Status -->
|
<!-- Status -->
|
||||||
<div class="inputwrapper">
|
<div class="inputwrapper">
|
||||||
<label for="type" class="inputlabel"><i class="ri-alarm-warning-line"></i> Status</label>
|
<label for="type" class="inputlabel">Status</label>
|
||||||
<select name="type" id="type" autocomplete="off" class="inputselect font-bold">
|
<select name="type" id="type" autocomplete="off" class="inputselect font-bold">
|
||||||
<option value="Unknown" {{ if eq $model.result.Entry.EditState "Unknown" }}selected{{ end }}>Unbekannt</option>
|
<option value="Unknown" {{ if eq $model.result.Entry.EditState "Unknown" }}selected{{ end }}>Unbekannt</option>
|
||||||
<option value="ToDo" {{ if eq $model.result.Entry.EditState "ToDo" }}selected{{ end }}>Zu erledigen</option>
|
<option value="ToDo" {{ if eq $model.result.Entry.EditState "ToDo" }}selected{{ end }}>Zu erledigen</option>
|
||||||
@@ -306,7 +351,7 @@ type AlmanachResult struct {
|
|||||||
|
|
||||||
<div class="inputwrapper {{ if eq $model.result.Entry.Comment "" }}hidden{{ end }}">
|
<div class="inputwrapper {{ if eq $model.result.Entry.Comment "" }}hidden{{ end }}">
|
||||||
<div class="flex flex-row justify-between">
|
<div class="flex flex-row justify-between">
|
||||||
<label for="edit_comment" class="inputlabel menu-label"><i class="ri-chat-1-line"></i> Bearbeitungsvermerk</label>
|
<label for="edit_comment" class="inputlabel menu-label">Bearbeitungsvermerk</label>
|
||||||
<div class="pr-2">
|
<div class="pr-2">
|
||||||
<button class="dm-close-button font-bold input-label">
|
<button class="dm-close-button font-bold input-label">
|
||||||
<i class="ri-close-line"></i>
|
<i class="ri-close-line"></i>
|
||||||
@@ -317,10 +362,19 @@ type AlmanachResult struct {
|
|||||||
<textarea name="edit_comment" id="edit_comment" class="inputinput" placeholder="" autocomplete="off">{{- $model.result.Entry.Comment -}}</textarea>
|
<textarea name="edit_comment" id="edit_comment" class="inputinput" placeholder="" autocomplete="off">{{- $model.result.Entry.Comment -}}</textarea>
|
||||||
</div>
|
</div>
|
||||||
</div-manager>
|
</div-manager>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 text-lg font-bold text-gray-700">
|
||||||
|
<i class="ri-information-line"></i>
|
||||||
|
<span>Metadaten</span>
|
||||||
|
</div>
|
||||||
|
<hr class="border-stone-200 mt-2" />
|
||||||
|
<div class="flex flex-col gap-4 mt-4">
|
||||||
<!-- Languages -->
|
<!-- Languages -->
|
||||||
<div class="inputwrapper">
|
<div class="inputwrapper">
|
||||||
<label for="languages" class="inputlabel"><i class="ri-earth-line"></i> Sprachen</label>
|
<label for="languages" class="inputlabel">Sprachen</label>
|
||||||
<multi-select-simple id="languages" show-create-button="false" placeholder="Sprachen suchen..."></multi-select-simple>
|
<multi-select-simple id="languages" show-create-button="false" placeholder="Sprachen suchen..."></multi-select-simple>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
const smlang = document.getElementById("languages");
|
const smlang = document.getElementById("languages");
|
||||||
@@ -330,7 +384,7 @@ type AlmanachResult struct {
|
|||||||
|
|
||||||
<!-- Nachweise - Always visible -->
|
<!-- Nachweise - Always visible -->
|
||||||
<div class="inputwrapper">
|
<div class="inputwrapper">
|
||||||
<label for="refs" class="inputlabel"><i class="ri-links-line"></i> Nachweise</label>
|
<label for="refs" class="inputlabel">Nachweise</label>
|
||||||
<textarea name="refs" id="refs" class="inputinput" placeholder="" autocomplete="off" rows="1">{{- $model.result.Entry.References -}}</textarea>
|
<textarea name="refs" id="refs" class="inputinput" placeholder="" autocomplete="off" rows="1">{{- $model.result.Entry.References -}}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -342,7 +396,7 @@ type AlmanachResult struct {
|
|||||||
|
|
||||||
<div class="inputwrapper {{ if eq $model.result.Entry.Extent "" }}hidden{{ end }}">
|
<div class="inputwrapper {{ if eq $model.result.Entry.Extent "" }}hidden{{ end }}">
|
||||||
<div class="flex flex-row justify-between">
|
<div class="flex flex-row justify-between">
|
||||||
<label for="extent" class="inputlabel menu-label"><i class="ri-file-text-line"></i> Struktur/Umfang</label>
|
<label for="extent" class="inputlabel menu-label">Struktur/Umfang</label>
|
||||||
<div class="pr-2">
|
<div class="pr-2">
|
||||||
<button class="dm-close-button font-bold input-label">
|
<button class="dm-close-button font-bold input-label">
|
||||||
<i class="ri-close-line"></i>
|
<i class="ri-close-line"></i>
|
||||||
@@ -355,7 +409,7 @@ type AlmanachResult struct {
|
|||||||
|
|
||||||
<div class="mt-3 inputwrapper {{ if eq $model.result.Entry.Dimensions "" }}hidden{{ end }}">
|
<div class="mt-3 inputwrapper {{ if eq $model.result.Entry.Dimensions "" }}hidden{{ end }}">
|
||||||
<div class="flex flex-row justify-between">
|
<div class="flex flex-row justify-between">
|
||||||
<label for="dimensions" class="inputlabel menu-label"><i class="ri-ruler-line"></i> Maße</label>
|
<label for="dimensions" class="inputlabel menu-label">Maße</label>
|
||||||
<div class="pr-2">
|
<div class="pr-2">
|
||||||
<button class="dm-close-button font-bold input-label">
|
<button class="dm-close-button font-bold input-label">
|
||||||
<i class="ri-close-line"></i>
|
<i class="ri-close-line"></i>
|
||||||
@@ -366,8 +420,10 @@ type AlmanachResult struct {
|
|||||||
<textarea name="dimensions" id="dimensions" class="inputinput" placeholder="" autocomplete="off" rows="1">{{- $model.result.Entry.Dimensions -}}</textarea>
|
<textarea name="dimensions" id="dimensions" class="inputinput" placeholder="" autocomplete="off" rows="1">{{- $model.result.Entry.Dimensions -}}</textarea>
|
||||||
</div>
|
</div>
|
||||||
</div-manager>
|
</div-manager>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Exemplare -->
|
<!-- Exemplare -->
|
||||||
<div class="mt-8">
|
<div class="mt-6">
|
||||||
<div class="flex items-center gap-2 text-lg font-bold text-gray-700">
|
<div class="flex items-center gap-2 text-lg font-bold text-gray-700">
|
||||||
<i class="ri-archive-line"></i>
|
<i class="ri-archive-line"></i>
|
||||||
<span>Exemplare</span>
|
<span>Exemplare</span>
|
||||||
@@ -383,7 +439,7 @@ type AlmanachResult struct {
|
|||||||
<div class="text-base font-bold" data-summary-container>
|
<div class="text-base font-bold" data-summary-container>
|
||||||
<span data-summary-field="owner" data-summary-hide-empty="true">{{ $item.Owner }}</span>
|
<span data-summary-field="owner" data-summary-hide-empty="true">{{ $item.Owner }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-2 py-0.5 rounded-full bg-stone-200 text-sm font-bold" data-summary-container>
|
<div class="px-2 py-0.5 bg-stone-200 text-sm font-bold rounded-sm" data-summary-container>
|
||||||
<span data-summary-field="identifier" data-summary-hide-empty="true">{{ $item.Identifier }}</span>
|
<span data-summary-field="identifier" data-summary-hide-empty="true">{{ $item.Identifier }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -482,7 +538,7 @@ type AlmanachResult struct {
|
|||||||
<div class="text-base font-bold" data-summary-container>
|
<div class="text-base font-bold" data-summary-container>
|
||||||
<span data-summary-field="owner" data-summary-hide-empty="true">—</span>
|
<span data-summary-field="owner" data-summary-hide-empty="true">—</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-2 py-0.5 rounded-full bg-stone-200 text-sm font-bold" data-summary-container>
|
<div class="px-2 py-0.5 bg-stone-200 text-sm font-bold" data-summary-container>
|
||||||
<span data-summary-field="identifier" data-summary-hide-empty="true">—</span>
|
<span data-summary-field="identifier" data-summary-hide-empty="true">—</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -189,7 +189,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mss-selected-item-pill {
|
.mss-selected-item-pill {
|
||||||
@apply bg-gray-200 text-gray-800 py-0.5 px-2 rounded text-xs leading-5; /* Adjusted font size and padding */
|
@apply bg-gray-200 text-gray-800 py-0.5 px-2 rounded leading-5 shadow-xs hover:shadow select-none; /* Adjusted font size and padding */
|
||||||
/* Tailwind classes from component: flex items-center */
|
/* Tailwind classes from component: flex items-center */
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +204,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mss-selected-item-delete-btn {
|
.mss-selected-item-delete-btn {
|
||||||
@apply bg-transparent border-none text-gray-600 opacity-70 cursor-pointer ml-1 text-base leading-none align-middle hover:opacity-100 hover:text-gray-900 disabled:opacity-40 disabled:cursor-not-allowed;
|
@apply bg-transparent border-none text-gray-600 opacity-70 cursor-pointer ml-2 text-lg leading-none align-middle hover:opacity-100 hover:text-gray-900 disabled:opacity-40 disabled:cursor-not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mss-input-controls-container {
|
.mss-input-controls-container {
|
||||||
@@ -220,7 +220,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mss-text-input {
|
.mss-text-input {
|
||||||
@apply py-1.5 px-2 text-sm;
|
@apply py-1.5 px-2;
|
||||||
/* Tailwind classes from component: w-full outline-none bg-transparent */
|
/* Tailwind classes from component: w-full outline-none bg-transparent */
|
||||||
}
|
}
|
||||||
.mss-text-input::placeholder {
|
.mss-text-input::placeholder {
|
||||||
@@ -235,12 +235,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mss-options-list {
|
.mss-options-list {
|
||||||
@apply bg-white border border-gray-300 rounded shadow-md; /* Using shadow-md as a softer default */
|
@apply bg-white border border-gray-300 rounded shadow-md list-none m-0; /* Using shadow-md as a softer default */
|
||||||
/* Tailwind classes from component: absolute z-20 w-full max-h-60 overflow-y-auto mt-1 hidden */
|
/* Tailwind classes from component: absolute z-20 w-full max-h-60 overflow-y-auto mt-1 hidden */
|
||||||
}
|
}
|
||||||
|
|
||||||
.mss-option-item {
|
.mss-option-item {
|
||||||
@apply text-gray-700 py-1.5 px-2.5 text-sm cursor-pointer transition-colors duration-75 hover:bg-gray-100;
|
@apply text-gray-700 py-1.5 px-2.5 cursor-pointer transition-colors duration-75 hover:bg-gray-100 list-none m-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mss-option-item-name {
|
.mss-option-item-name {
|
||||||
@@ -248,7 +248,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mss-option-item-detail {
|
.mss-option-item-detail {
|
||||||
@apply text-gray-500 text-xs ml-1.5;
|
@apply text-gray-500 ml-1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mss-option-item-highlighted {
|
.mss-option-item-highlighted {
|
||||||
|
|||||||
280
views/transform/items-editor.js
Normal file
280
views/transform/items-editor.js
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
const ROW_CLASS = "items-row";
|
||||||
|
const LIST_CLASS = "items-list";
|
||||||
|
const TEMPLATE_CLASS = "items-template";
|
||||||
|
const ADD_BUTTON_CLASS = "items-add-button";
|
||||||
|
const REMOVE_BUTTON_CLASS = "items-remove-button";
|
||||||
|
const EDIT_BUTTON_CLASS = "items-edit-button";
|
||||||
|
const CLOSE_BUTTON_CLASS = "items-close-button";
|
||||||
|
const SUMMARY_CLASS = "items-summary";
|
||||||
|
const EDIT_PANEL_CLASS = "items-edit-panel";
|
||||||
|
const REMOVED_INPUT_NAME = "items_removed[]";
|
||||||
|
|
||||||
|
export class ItemsEditor extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._list = null;
|
||||||
|
this._template = null;
|
||||||
|
this._addButton = null;
|
||||||
|
this._idPrefix = `items-editor-${crypto.randomUUID().slice(0, 8)}`;
|
||||||
|
this._handleAdd = this._onAddClick.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this._list = this.querySelector(`.${LIST_CLASS}`);
|
||||||
|
this._template = this.querySelector(`template.${TEMPLATE_CLASS}`);
|
||||||
|
this._addButton = this.querySelector(`.${ADD_BUTTON_CLASS}`);
|
||||||
|
|
||||||
|
if (!this._list || !this._template || !this._addButton) {
|
||||||
|
console.error("ItemsEditor: Missing list, template, or add button.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._addButton.addEventListener("click", this._handleAdd);
|
||||||
|
this._wireRemoveButtons();
|
||||||
|
this._wireEditButtons();
|
||||||
|
this._refreshRowIds();
|
||||||
|
this._syncAllSummaries();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
if (this._addButton) {
|
||||||
|
this._addButton.removeEventListener("click", this._handleAdd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onAddClick(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.addItem();
|
||||||
|
}
|
||||||
|
|
||||||
|
addItem() {
|
||||||
|
const fragment = this._template.content.cloneNode(true);
|
||||||
|
const newRow = fragment.querySelector(`.${ROW_CLASS}`);
|
||||||
|
|
||||||
|
if (!newRow) {
|
||||||
|
console.error("ItemsEditor: Template is missing a row element.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._list.appendChild(fragment);
|
||||||
|
|
||||||
|
this._wireRemoveButtons(newRow);
|
||||||
|
this._wireEditButtons(newRow);
|
||||||
|
this._assignRowFieldIds(newRow, this._rowIndex(newRow));
|
||||||
|
this._wireSummarySync(newRow);
|
||||||
|
this._syncSummary(newRow);
|
||||||
|
this._setRowMode(newRow, "edit");
|
||||||
|
}
|
||||||
|
|
||||||
|
removeItem(button) {
|
||||||
|
const row = button.closest(`.${ROW_CLASS}`);
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idInput = row.querySelector('input[name="items_id[]"]');
|
||||||
|
const itemId = idInput ? idInput.value.trim() : "";
|
||||||
|
if (itemId) {
|
||||||
|
this._ensureRemovalInput(itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
row.remove();
|
||||||
|
this._refreshRowIds();
|
||||||
|
}
|
||||||
|
|
||||||
|
_wireRemoveButtons(root = this) {
|
||||||
|
root.querySelectorAll(`.${REMOVE_BUTTON_CLASS}`).forEach((btn) => {
|
||||||
|
if (btn.dataset.itemsBound === "true") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.dataset.itemsBound = "true";
|
||||||
|
btn.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
this.removeItem(btn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_wireEditButtons(root = this) {
|
||||||
|
root.querySelectorAll(`.${EDIT_BUTTON_CLASS}`).forEach((btn) => {
|
||||||
|
if (btn.dataset.itemsBound === "true") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.dataset.itemsBound = "true";
|
||||||
|
btn.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const row = btn.closest(`.${ROW_CLASS}`);
|
||||||
|
if (row) {
|
||||||
|
this._setRowMode(row, "edit");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
root.querySelectorAll(`.${CLOSE_BUTTON_CLASS}`).forEach((btn) => {
|
||||||
|
if (btn.dataset.itemsBound === "true") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.dataset.itemsBound = "true";
|
||||||
|
btn.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const row = btn.closest(`.${ROW_CLASS}`);
|
||||||
|
if (row) {
|
||||||
|
this._setRowMode(row, "summary");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_setRowMode(row, mode) {
|
||||||
|
const summary = row.querySelector(`.${SUMMARY_CLASS}`);
|
||||||
|
const editor = row.querySelector(`.${EDIT_PANEL_CLASS}`);
|
||||||
|
if (!summary || !editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "edit") {
|
||||||
|
summary.classList.add("hidden");
|
||||||
|
editor.classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
summary.classList.remove("hidden");
|
||||||
|
editor.classList.add("hidden");
|
||||||
|
this._syncSummary(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_refreshRowIds() {
|
||||||
|
const rows = Array.from(this.querySelectorAll(`.${ROW_CLASS}`));
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
this._assignRowFieldIds(row, index);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_rowIndex(row) {
|
||||||
|
const rows = Array.from(this.querySelectorAll(`.${ROW_CLASS}`));
|
||||||
|
return rows.indexOf(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
_assignRowFieldIds(row, index) {
|
||||||
|
if (index < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
row.querySelectorAll("[data-field-label]").forEach((label) => {
|
||||||
|
const fieldName = label.getAttribute("data-field-label");
|
||||||
|
if (!fieldName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const field = row.querySelector(`[data-field="${fieldName}"]`);
|
||||||
|
if (!field) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldId = `${this._idPrefix}-${index}-${fieldName}`;
|
||||||
|
field.id = fieldId;
|
||||||
|
label.setAttribute("for", fieldId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_syncAllSummaries() {
|
||||||
|
this.querySelectorAll(`.${ROW_CLASS}`).forEach((row) => {
|
||||||
|
this._wireSummarySync(row);
|
||||||
|
this._syncSummary(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_wireSummarySync(row) {
|
||||||
|
if (row.dataset.summaryBound === "true") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
row.dataset.summaryBound = "true";
|
||||||
|
row.querySelectorAll("[data-field]").forEach((field) => {
|
||||||
|
field.addEventListener("input", () => this._syncSummary(row));
|
||||||
|
field.addEventListener("change", () => this._syncSummary(row));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_syncSummary(row) {
|
||||||
|
row.querySelectorAll("[data-summary-field]").forEach((summaryField) => {
|
||||||
|
const fieldName = summaryField.getAttribute("data-summary-field");
|
||||||
|
if (!fieldName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formField = row.querySelector(`[data-field="${fieldName}"]`);
|
||||||
|
if (!formField) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = this._readFieldValue(formField);
|
||||||
|
const container =
|
||||||
|
summaryField.getAttribute("data-summary-hide-empty") === "true"
|
||||||
|
? summaryField.closest("[data-summary-container]")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
this._setSummaryContent(summaryField, value);
|
||||||
|
summaryField.classList.remove("text-gray-400");
|
||||||
|
if (container) {
|
||||||
|
container.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this._setSummaryContent(summaryField, "—");
|
||||||
|
summaryField.classList.add("text-gray-400");
|
||||||
|
if (container) {
|
||||||
|
container.classList.add("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_setSummaryContent(summaryField, value) {
|
||||||
|
const link = summaryField.querySelector("[data-summary-link]");
|
||||||
|
if (link) {
|
||||||
|
if (value && value !== "—") {
|
||||||
|
link.setAttribute("href", value);
|
||||||
|
link.textContent = value;
|
||||||
|
} else {
|
||||||
|
link.setAttribute("href", "#");
|
||||||
|
link.textContent = "—";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
summaryField.textContent = value || "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_readFieldValue(field) {
|
||||||
|
if (field instanceof HTMLSelectElement) {
|
||||||
|
if (field.multiple) {
|
||||||
|
return Array.from(field.selectedOptions)
|
||||||
|
.map((opt) => opt.textContent.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
const selected = field.selectedOptions[0];
|
||||||
|
return selected ? selected.textContent.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) {
|
||||||
|
return field.value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
_ensureRemovalInput(itemId) {
|
||||||
|
const existing = Array.from(this.querySelectorAll(`input[name="${REMOVED_INPUT_NAME}"]`)).some(
|
||||||
|
(input) => input.value === itemId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hidden = document.createElement("input");
|
||||||
|
hidden.type = "hidden";
|
||||||
|
hidden.name = REMOVED_INPUT_NAME;
|
||||||
|
hidden.value = itemId;
|
||||||
|
this.appendChild(hidden);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,11 @@ const MSS_OPTION_ITEM_DETAIL_CLASS = "mss-option-item-detail";
|
|||||||
const MSS_HIGHLIGHTED_OPTION_CLASS = "mss-option-item-highlighted";
|
const MSS_HIGHLIGHTED_OPTION_CLASS = "mss-option-item-highlighted";
|
||||||
const MSS_HIDDEN_SELECT_CLASS = "mss-hidden-select";
|
const MSS_HIDDEN_SELECT_CLASS = "mss-hidden-select";
|
||||||
const MSS_NO_ITEMS_TEXT_CLASS = "mss-no-items-text";
|
const MSS_NO_ITEMS_TEXT_CLASS = "mss-no-items-text";
|
||||||
|
const MSS_LOADING_CLASS = "mss-loading";
|
||||||
|
|
||||||
|
const MSS_REMOTE_DEFAULT_MIN_CHARS = 1;
|
||||||
|
const MSS_REMOTE_DEFAULT_LIMIT = 10;
|
||||||
|
const MSS_REMOTE_FETCH_DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
// State classes for MultiSelectSimple
|
// State classes for MultiSelectSimple
|
||||||
const MSS_STATE_NO_SELECTION = "mss-state-no-selection";
|
const MSS_STATE_NO_SELECTION = "mss-state-no-selection";
|
||||||
@@ -220,6 +225,13 @@ export class MultiSelectSimple extends HTMLElement {
|
|||||||
this._highlightedIndex = -1;
|
this._highlightedIndex = -1;
|
||||||
this._isOptionsListVisible = false;
|
this._isOptionsListVisible = false;
|
||||||
|
|
||||||
|
this._remoteEndpoint = null;
|
||||||
|
this._remoteResultKey = "items";
|
||||||
|
this._remoteMinChars = MSS_REMOTE_DEFAULT_MIN_CHARS;
|
||||||
|
this._remoteLimit = MSS_REMOTE_DEFAULT_LIMIT;
|
||||||
|
this._remoteFetchController = null;
|
||||||
|
this._remoteFetchTimeout = null;
|
||||||
|
|
||||||
this._placeholder = this.getAttribute("placeholder") || "Search items...";
|
this._placeholder = this.getAttribute("placeholder") || "Search items...";
|
||||||
this._showCreateButton = this.getAttribute("show-create-button") !== "false";
|
this._showCreateButton = this.getAttribute("show-create-button") !== "false";
|
||||||
|
|
||||||
@@ -284,7 +296,12 @@ export class MultiSelectSimple extends HTMLElement {
|
|||||||
|
|
||||||
setOptions(newOptions) {
|
setOptions(newOptions) {
|
||||||
if (Array.isArray(newOptions) && newOptions.every((o) => o && typeof o.id === "string" && typeof o.name === "string")) {
|
if (Array.isArray(newOptions) && newOptions.every((o) => o && typeof o.id === "string" && typeof o.name === "string")) {
|
||||||
this._options = [...newOptions];
|
this._options = newOptions.map((option) => {
|
||||||
|
const normalized = { ...option };
|
||||||
|
normalized.name = this._normalizeText(normalized.name);
|
||||||
|
normalized.additional_data = this._normalizeText(normalized.additional_data);
|
||||||
|
return normalized;
|
||||||
|
});
|
||||||
const validValues = this._value.filter((id) => this._getItemById(id));
|
const validValues = this._value.filter((id) => this._getItemById(id));
|
||||||
if (validValues.length !== this._value.length) this.value = validValues;
|
if (validValues.length !== this._value.length) this.value = validValues;
|
||||||
else if (this.selectedItemsContainer) this._renderSelectedItems();
|
else if (this.selectedItemsContainer) this._renderSelectedItems();
|
||||||
@@ -336,6 +353,10 @@ export class MultiSelectSimple extends HTMLElement {
|
|||||||
|
|
||||||
this.placeholder = this.getAttribute("placeholder") || "Search items...";
|
this.placeholder = this.getAttribute("placeholder") || "Search items...";
|
||||||
this.showCreateButton = this.getAttribute("show-create-button") !== "false";
|
this.showCreateButton = this.getAttribute("show-create-button") !== "false";
|
||||||
|
this._remoteEndpoint = this.getAttribute("data-endpoint") || null;
|
||||||
|
this._remoteResultKey = this.getAttribute("data-result-key") || "items";
|
||||||
|
this._remoteMinChars = this._parsePositiveInt(this.getAttribute("data-minchars"), MSS_REMOTE_DEFAULT_MIN_CHARS);
|
||||||
|
this._remoteLimit = this._parsePositiveInt(this.getAttribute("data-limit"), MSS_REMOTE_DEFAULT_LIMIT);
|
||||||
|
|
||||||
if (this.name && this.hiddenSelect) this.hiddenSelect.name = this.name;
|
if (this.name && this.hiddenSelect) this.hiddenSelect.name = this.name;
|
||||||
|
|
||||||
@@ -380,10 +401,15 @@ export class MultiSelectSimple extends HTMLElement {
|
|||||||
if (this.createNewButton) this.createNewButton.removeEventListener("click", this._handleCreateNewButtonClick);
|
if (this.createNewButton) this.createNewButton.removeEventListener("click", this._handleCreateNewButtonClick);
|
||||||
if (this.selectedItemsContainer) this.selectedItemsContainer.removeEventListener("click", this._handleSelectedItemsContainerClick);
|
if (this.selectedItemsContainer) this.selectedItemsContainer.removeEventListener("click", this._handleSelectedItemsContainerClick);
|
||||||
clearTimeout(this._blurTimeout);
|
clearTimeout(this._blurTimeout);
|
||||||
|
if (this._remoteFetchTimeout) {
|
||||||
|
clearTimeout(this._remoteFetchTimeout);
|
||||||
|
this._remoteFetchTimeout = null;
|
||||||
|
}
|
||||||
|
this._cancelRemoteFetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
static get observedAttributes() {
|
static get observedAttributes() {
|
||||||
return ["disabled", "name", "value", "placeholder", "show-create-button"];
|
return ["disabled", "name", "value", "placeholder", "show-create-button", "data-endpoint", "data-result-key", "data-minchars", "data-limit"];
|
||||||
}
|
}
|
||||||
attributeChangedCallback(name, oldValue, newValue) {
|
attributeChangedCallback(name, oldValue, newValue) {
|
||||||
if (oldValue === newValue) return;
|
if (oldValue === newValue) return;
|
||||||
@@ -400,6 +426,10 @@ export class MultiSelectSimple extends HTMLElement {
|
|||||||
}
|
}
|
||||||
} else if (name === "placeholder") this.placeholder = newValue;
|
} else if (name === "placeholder") this.placeholder = newValue;
|
||||||
else if (name === "show-create-button") this.showCreateButton = newValue;
|
else if (name === "show-create-button") this.showCreateButton = newValue;
|
||||||
|
else if (name === "data-endpoint") this._remoteEndpoint = newValue || null;
|
||||||
|
else if (name === "data-result-key") this._remoteResultKey = newValue || "items";
|
||||||
|
else if (name === "data-minchars") this._remoteMinChars = this._parsePositiveInt(newValue, MSS_REMOTE_DEFAULT_MIN_CHARS);
|
||||||
|
else if (name === "data-limit") this._remoteLimit = this._parsePositiveInt(newValue, MSS_REMOTE_DEFAULT_LIMIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
formAssociatedCallback(form) {}
|
formAssociatedCallback(form) {}
|
||||||
@@ -458,10 +488,10 @@ export class MultiSelectSimple extends HTMLElement {
|
|||||||
</style>
|
</style>
|
||||||
<div class="${MSS_COMPONENT_WRAPPER_CLASS} relative">
|
<div class="${MSS_COMPONENT_WRAPPER_CLASS} relative">
|
||||||
<div class="${MSS_SELECTED_ITEMS_CONTAINER_CLASS} flex flex-wrap gap-1 mb-1 min-h-[38px]" aria-live="polite" tabindex="-1"></div>
|
<div class="${MSS_SELECTED_ITEMS_CONTAINER_CLASS} flex flex-wrap gap-1 mb-1 min-h-[38px]" aria-live="polite" tabindex="-1"></div>
|
||||||
<div class="${MSS_INPUT_CONTROLS_CONTAINER_CLASS} flex items-center space-x-2">
|
<div class="${MSS_INPUT_CONTROLS_CONTAINER_CLASS} flex items-center space-x-4">
|
||||||
<div class="${MSS_INPUT_WRAPPER_CLASS} relative rounded-md flex items-center flex-grow">
|
<div class="${MSS_INPUT_WRAPPER_CLASS} relative rounded-md flex items-center flex-grow">
|
||||||
<input type="text"
|
<input type="text"
|
||||||
class="${MSS_TEXT_INPUT_CLASS} w-full outline-none bg-transparent text-sm"
|
class="${MSS_TEXT_INPUT_CLASS} w-full outline-none bg-transparent"
|
||||||
placeholder="${this.placeholder}"
|
placeholder="${this.placeholder}"
|
||||||
aria-autocomplete="list"
|
aria-autocomplete="list"
|
||||||
aria-expanded="${this._isOptionsListVisible}"
|
aria-expanded="${this._isOptionsListVisible}"
|
||||||
@@ -483,11 +513,13 @@ export class MultiSelectSimple extends HTMLElement {
|
|||||||
const textEl = pillEl.querySelector('[data-ref="textEl"]');
|
const textEl = pillEl.querySelector('[data-ref="textEl"]');
|
||||||
const detailEl = pillEl.querySelector('[data-ref="detailEl"]'); // This now uses MSS_SELECTED_ITEM_PILL_DETAIL_CLASS
|
const detailEl = pillEl.querySelector('[data-ref="detailEl"]'); // This now uses MSS_SELECTED_ITEM_PILL_DETAIL_CLASS
|
||||||
const deleteBtn = pillEl.querySelector('[data-ref="deleteBtn"]');
|
const deleteBtn = pillEl.querySelector('[data-ref="deleteBtn"]');
|
||||||
textEl.textContent = itemData.name;
|
textEl.textContent = this._normalizeText(itemData.name);
|
||||||
if (itemData.additional_data) {
|
const detailText = this._normalizeText(itemData.additional_data);
|
||||||
detailEl.textContent = `(${itemData.additional_data})`;
|
if (detailText) {
|
||||||
|
detailEl.textContent = `(${detailText})`;
|
||||||
detailEl.classList.remove("hidden"); // Toggle visibility via JS
|
detailEl.classList.remove("hidden"); // Toggle visibility via JS
|
||||||
} else {
|
} else {
|
||||||
|
detailEl.textContent = "";
|
||||||
detailEl.classList.add("hidden"); // Toggle visibility via JS
|
detailEl.classList.add("hidden"); // Toggle visibility via JS
|
||||||
}
|
}
|
||||||
deleteBtn.setAttribute("aria-label", `Remove ${itemData.name}`);
|
deleteBtn.setAttribute("aria-label", `Remove ${itemData.name}`);
|
||||||
@@ -517,8 +549,9 @@ export class MultiSelectSimple extends HTMLElement {
|
|||||||
const li = fragment.firstElementChild;
|
const li = fragment.firstElementChild;
|
||||||
const nameEl = li.querySelector('[data-ref="nameEl"]');
|
const nameEl = li.querySelector('[data-ref="nameEl"]');
|
||||||
const detailEl = li.querySelector('[data-ref="detailEl"]');
|
const detailEl = li.querySelector('[data-ref="detailEl"]');
|
||||||
nameEl.textContent = itemData.name;
|
nameEl.textContent = this._normalizeText(itemData.name);
|
||||||
detailEl.textContent = itemData.additional_data ? `(${itemData.additional_data})` : "";
|
const detailText = this._normalizeText(itemData.additional_data);
|
||||||
|
detailEl.textContent = detailText ? `(${detailText})` : "";
|
||||||
li.dataset.id = itemData.id;
|
li.dataset.id = itemData.id;
|
||||||
li.setAttribute("aria-selected", String(index === this._highlightedIndex));
|
li.setAttribute("aria-selected", String(index === this._highlightedIndex));
|
||||||
const optionElementId = `option-${this.id || "mss"}-${itemData.id}`;
|
const optionElementId = `option-${this.id || "mss"}-${itemData.id}`;
|
||||||
@@ -569,6 +602,10 @@ export class MultiSelectSimple extends HTMLElement {
|
|||||||
}
|
}
|
||||||
_handleInput(event) {
|
_handleInput(event) {
|
||||||
const searchTerm = event.target.value;
|
const searchTerm = event.target.value;
|
||||||
|
if (this._remoteEndpoint) {
|
||||||
|
this._handleRemoteInput(searchTerm);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (searchTerm.length === 0) {
|
if (searchTerm.length === 0) {
|
||||||
this._filteredOptions = [];
|
this._filteredOptions = [];
|
||||||
this._isOptionsListVisible = false;
|
this._isOptionsListVisible = false;
|
||||||
@@ -576,8 +613,10 @@ export class MultiSelectSimple extends HTMLElement {
|
|||||||
const searchTermLower = searchTerm.toLowerCase();
|
const searchTermLower = searchTerm.toLowerCase();
|
||||||
this._filteredOptions = this._options.filter((item) => {
|
this._filteredOptions = this._options.filter((item) => {
|
||||||
if (this._value.includes(item.id)) return false;
|
if (this._value.includes(item.id)) return false;
|
||||||
const nameMatch = item.name.toLowerCase().includes(searchTermLower);
|
const normalizedName = this._normalizeText(item.name);
|
||||||
const additionalDataMatch = item.additional_data && item.additional_data.toLowerCase().includes(searchTermLower);
|
const nameMatch = normalizedName.toLowerCase().includes(searchTermLower);
|
||||||
|
const detailValue = this._normalizeText(item.additional_data);
|
||||||
|
const additionalDataMatch = detailValue && detailValue.toLowerCase().includes(searchTermLower);
|
||||||
return nameMatch || additionalDataMatch;
|
return nameMatch || additionalDataMatch;
|
||||||
});
|
});
|
||||||
this._isOptionsListVisible = this._filteredOptions.length > 0;
|
this._isOptionsListVisible = this._filteredOptions.length > 0;
|
||||||
@@ -668,4 +707,161 @@ export class MultiSelectSimple extends HTMLElement {
|
|||||||
if (this.inputElement && this.inputElement.value) this._handleInput({ target: this.inputElement });
|
if (this.inputElement && this.inputElement.value) this._handleInput({ target: this.inputElement });
|
||||||
if (this.inputElement && !this.hasAttribute("disabled")) this.inputElement.focus();
|
if (this.inputElement && !this.hasAttribute("disabled")) this.inputElement.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_parsePositiveInt(value, fallback) {
|
||||||
|
if (!value) return fallback;
|
||||||
|
const parsed = parseInt(value, 10);
|
||||||
|
if (Number.isNaN(parsed) || parsed <= 0) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleRemoteInput(searchTerm) {
|
||||||
|
if (this._remoteFetchTimeout) {
|
||||||
|
clearTimeout(this._remoteFetchTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchTerm.length < this._remoteMinChars) {
|
||||||
|
this._filteredOptions = [];
|
||||||
|
this._isOptionsListVisible = false;
|
||||||
|
this._renderOptionsList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._remoteFetchTimeout = setTimeout(() => {
|
||||||
|
this._fetchRemoteOptions(searchTerm);
|
||||||
|
}, MSS_REMOTE_FETCH_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
_cancelRemoteFetch() {
|
||||||
|
if (this._remoteFetchController) {
|
||||||
|
this._remoteFetchController.abort();
|
||||||
|
this._remoteFetchController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _fetchRemoteOptions(searchTerm) {
|
||||||
|
if (!this._remoteEndpoint) return;
|
||||||
|
|
||||||
|
this._cancelRemoteFetch();
|
||||||
|
this.classList.add(MSS_LOADING_CLASS);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
this._remoteFetchController = controller;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(this._remoteEndpoint, window.location.origin);
|
||||||
|
url.searchParams.set("q", searchTerm);
|
||||||
|
if (this._remoteLimit) {
|
||||||
|
url.searchParams.set("limit", String(this._remoteLimit));
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal: controller.signal,
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Remote fetch failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = this._extractRemoteOptions(payload);
|
||||||
|
this._applyRemoteResults(options);
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("MultiSelectSimple remote fetch error:", error);
|
||||||
|
this._filteredOptions = [];
|
||||||
|
this._isOptionsListVisible = false;
|
||||||
|
this._renderOptionsList();
|
||||||
|
} finally {
|
||||||
|
if (this._remoteFetchController === controller) {
|
||||||
|
this._remoteFetchController = null;
|
||||||
|
}
|
||||||
|
this.classList.remove(MSS_LOADING_CLASS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_extractRemoteOptions(payload) {
|
||||||
|
if (!payload) return [];
|
||||||
|
|
||||||
|
let entries = [];
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
entries = payload;
|
||||||
|
} else if (this._remoteResultKey && Array.isArray(payload[this._remoteResultKey])) {
|
||||||
|
entries = payload[this._remoteResultKey];
|
||||||
|
} else if (Array.isArray(payload.items)) {
|
||||||
|
entries = payload.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
.map((entry) => {
|
||||||
|
if (!entry) return null;
|
||||||
|
const id = entry.id ?? entry.ID ?? entry.value ?? "";
|
||||||
|
const name = entry.name ?? entry.title ?? entry.label ?? "";
|
||||||
|
const detail = entry.detail ?? entry.additional_data ?? entry.annotation ?? "";
|
||||||
|
const normalizedName = this._normalizeText(name);
|
||||||
|
const detailText = this._normalizeText(detail);
|
||||||
|
if (!id || !normalizedName) return null;
|
||||||
|
return {
|
||||||
|
id: String(id),
|
||||||
|
name: normalizedName,
|
||||||
|
additional_data: detailText,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyRemoteResults(options) {
|
||||||
|
const selected = new Set(this._value);
|
||||||
|
const merged = new Map();
|
||||||
|
|
||||||
|
this._options.forEach((opt) => {
|
||||||
|
if (opt?.id) {
|
||||||
|
merged.set(opt.id, opt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
options.forEach((opt) => {
|
||||||
|
if (opt?.id) {
|
||||||
|
merged.set(opt.id, opt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this._options = Array.from(merged.values());
|
||||||
|
this._filteredOptions = options.filter((opt) => opt && !selected.has(opt.id));
|
||||||
|
this._isOptionsListVisible = this._filteredOptions.length > 0;
|
||||||
|
this._highlightedIndex = this._isOptionsListVisible ? 0 : -1;
|
||||||
|
this._renderOptionsList();
|
||||||
|
}
|
||||||
|
|
||||||
|
_normalizeText(rawValue) {
|
||||||
|
if (rawValue === null || rawValue === undefined) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = String(rawValue).trim();
|
||||||
|
if (!text) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = text[0];
|
||||||
|
const last = text[text.length - 1];
|
||||||
|
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
||||||
|
text = text.slice(1, -1).trim();
|
||||||
|
if (!text) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user