diff --git a/backend/server/server.go b/backend/server/server.go index 7cb63b4..5bf2dbe 100644 --- a/backend/server/server.go +++ b/backend/server/server.go @@ -86,6 +86,8 @@ func StartServer(world *model.DfWorld, static embed.FS) { srv.RegisterWorldPage("/events", "eventTypes.html", func(p Parms) any { return srv.context.world.AllEventTypes() }) srv.RegisterWorldPage("/events/{type}", "eventType.html", func(p Parms) any { return srv.context.world.EventsOfType(p["type"]) }) + srv.router.PathPrefix("/search").Handler(searchHandler{server: srv}) + srv.router.PathPrefix("/load").Handler(srv.loader) spa := spaHandler{server: srv, staticFS: static, staticPath: "static", indexPath: "index.html"} @@ -174,7 +176,7 @@ func (h loadHandler) Progress() *loadProgress { func (h loadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/load/progress" { w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) + w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(h.Progress()) return @@ -301,3 +303,49 @@ func grouped[K comparable, T namedTyped](input map[K]T) map[string][]T { return output } + +type searchHandler struct { + server *DfServer +} + +type SearchResult struct { + Label string `json:"label"` + Value string `json:"value"` +} + +func (h searchHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + term := r.URL.Query().Get("term") + + var results []SearchResult + + results = seachMap(term, h.server.context.world.HistoricalFigures, results, "/hf") + results = seachMap(term, h.server.context.world.Entities, results, "/entity") + results = seachMap(term, h.server.context.world.Sites, results, "/site") + results = seachMap(term, h.server.context.world.Regions, results, "/region") + results = seachMap(term, h.server.context.world.Artifacts, results, "/artifavt") + results = seachMap(term, h.server.context.world.WorldConstructions, results, "/worldconstruction") + results = seachMap(term, h.server.context.world.DanceForms, results, "/danceForm") + results = seachMap(term, h.server.context.world.MusicalForms, results, "/musicalForm") + results = seachMap(term, h.server.context.world.PoeticForms, results, "/poeticForm") + results = seachMap(term, h.server.context.world.WrittenContents, results, "/writtencontent") + results = seachMap(term, h.server.context.world.Landmasses, results, "/landmass") + results = seachMap(term, h.server.context.world.MountainPeaks, results, "/mountain") + + sort.Slice(results, func(i, j int) bool { return results[i].Label < results[j].Label }) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(results) +} + +func seachMap[T model.Named](s string, input map[int]T, output []SearchResult, baseUrl string) []SearchResult { + for id, v := range input { + if strings.Contains(v.Name(), s) { + output = append(output, SearchResult{ + Label: util.Title(v.Name()), + Value: fmt.Sprintf("%s/%d", baseUrl, id), + }) + } + } + return output +} diff --git a/backend/static/js/autocomplete.js b/backend/static/js/autocomplete.js new file mode 100644 index 0000000..7bdf887 --- /dev/null +++ b/backend/static/js/autocomplete.js @@ -0,0 +1,170 @@ +const DEFAULTS = { + threshold: 2, + maximumItems: 5, + highlightTyped: true, + highlightClass: 'text-primary', + label: 'label', + value: 'value', + showValue: false, + showValueBeforeLabel: false, +}; + +class Autocomplete { + constructor(field, options) { + this.field = field; + this.options = Object.assign({}, DEFAULTS, options); + this.dropdown = null; + + field.parentNode.classList.add('dropdown'); + field.setAttribute('data-bs-toggle', 'dropdown'); + field.classList.add('dropdown-toggle'); + + const dropdown = ce(`
`); + if (this.options.dropdownClass) + dropdown.classList.add(this.options.dropdownClass); + + insertAfter(dropdown, field); + + this.dropdown = new bootstrap.Dropdown(field, this.options.dropdownOptions); + + field.addEventListener('click', (e) => { + if (this.createItems() === 0) { + e.stopPropagation(); + this.dropdown.hide(); + } + }); + + field.addEventListener('input', () => { + if (this.options.onInput) + this.options.onInput(this.field.value); + this.renderIfNeeded(); + }); + + field.addEventListener('keydown', (e) => { + if (e.keyCode === 27) { + this.dropdown.hide(); + return; + } + if (e.keyCode === 40) { + this.dropdown._menu.children[0]?.focus(); + return; + } + }); + } + + setData(data) { + this.options.data = data; + this.renderIfNeeded(); + } + + renderIfNeeded() { + if (this.createItems() > 0) + this.dropdown.show(); + else + this.field.click(); + } + + createItem(lookup, item) { + let label; + if (this.options.highlightTyped) { + const idx = removeDiacritics(item.label) + .toLowerCase() + .indexOf(removeDiacritics(lookup).toLowerCase()); + const className = Array.isArray(this.options.highlightClass) ? this.options.highlightClass.join(' ') + : (typeof this.options.highlightClass == 'string' ? this.options.highlightClass : ''); + label = item.label.substring(0, idx) + + `${item.label.substring(idx, idx + lookup.length)}` + + item.label.substring(idx + lookup.length, item.label.length); + } else { + label = item.label; + } + + if (this.options.showValue) { + if (this.options.showValueBeforeLabel) { + label = `${item.value} ${label}`; + } else { + label += ` ${item.value}`; + } + } + + return ce(``); + } + + createItems() { + const lookup = this.field.value; + if (lookup.length < this.options.threshold) { + this.dropdown.hide(); + return 0; + } + + const items = this.field.nextSibling; + items.innerHTML = ''; + + const keys = Object.keys(this.options.data); + + let count = 0; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const entry = this.options.data[key]; + const item = { + label: this.options.label ? entry[this.options.label] : key, + value: this.options.value ? entry[this.options.value] : entry + }; + + if (removeDiacritics(item.label).toLowerCase().indexOf(removeDiacritics(lookup).toLowerCase()) >= 0) { + items.appendChild(this.createItem(lookup, item)); + if (this.options.maximumItems > 0 && ++count >= this.options.maximumItems) + break; + } + } + + this.field.nextSibling.querySelectorAll('.dropdown-item').forEach((item) => { + item.addEventListener('click', (e) => { + let dataLabel = e.target.getAttribute('data-label'); + let dataValue = e.target.getAttribute('data-value'); + + this.field.value = dataLabel; + + if (this.options.onSelectItem) { + this.options.onSelectItem({ + value: dataValue, + label: dataLabel + }); + } + + this.dropdown.hide(); + }) + }); + + return items.childNodes.length; + } +} + +/** + * @param html + * @returns {Node} + */ +function ce(html) { + let div = document.createElement('div'); + div.innerHTML = html; + return div.firstChild; +} + +/** + * @param elem + * @param refElem + * @returns {*} + */ +function insertAfter(elem, refElem) { + return refElem.parentNode.insertBefore(elem, refElem.nextSibling); +} + +/** + * @param {String} str + * @returns {String} + */ +function removeDiacritics(str) { + return str + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, ''); +} \ No newline at end of file diff --git a/backend/templates/layout.html b/backend/templates/layout.html index cba6b17..463dfaa 100644 --- a/backend/templates/layout.html +++ b/backend/templates/layout.html @@ -12,6 +12,7 @@ + @@ -55,9 +56,26 @@ +