mirror of
https://github.com/Theodor-Springmann-Stiftung/musenalm.git
synced 2026-02-04 02:25:30 +00:00
+FEATURE: double entry info
This commit is contained in:
100
controllers/api_entries.go
Normal file
100
controllers/api_entries.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
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_ENTRIES = "/api/entries"
|
||||||
|
URL_API_ENTRIES_SEARCH = "/search"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
app.Register(&EntriesAPI{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type EntriesAPI struct{}
|
||||||
|
|
||||||
|
func (p *EntriesAPI) Up(app core.App, engine *templating.Engine) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *EntriesAPI) Down(app core.App, engine *templating.Engine) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *EntriesAPI) Setup(router *router.Router[*core.RequestEvent], app core.App, engine *templating.Engine) error {
|
||||||
|
rg := router.Group(URL_API_ENTRIES)
|
||||||
|
rg.BindFunc(middleware.Authenticated(app))
|
||||||
|
rg.BindFunc(middleware.IsAdminOrEditor())
|
||||||
|
rg.GET(URL_API_ENTRIES_SEARCH, p.searchHandler(app))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *EntriesAPI) searchHandler(app core.App) HandleFunc {
|
||||||
|
return func(e *core.RequestEvent) error {
|
||||||
|
query := strings.TrimSpace(e.Request.URL.Query().Get("q"))
|
||||||
|
limit := parseEntriesLimit(e.Request.URL.Query().Get("limit"))
|
||||||
|
|
||||||
|
results, err := dbmodels.TitleSearchEntries(app, query)
|
||||||
|
if err != nil {
|
||||||
|
app.Logger().Error("entry search failed", "query", query, "limit", limit, "error", err)
|
||||||
|
return e.JSON(http.StatusInternalServerError, map[string]any{
|
||||||
|
"error": "failed to search entries",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]bool{}
|
||||||
|
response := make([]map[string]string, 0, len(results))
|
||||||
|
for _, entry := range results {
|
||||||
|
if entry == nil || seen[entry.Id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[entry.Id] = true
|
||||||
|
|
||||||
|
detail := ""
|
||||||
|
if entry.Year() != 0 {
|
||||||
|
detail = strconv.Itoa(entry.Year())
|
||||||
|
}
|
||||||
|
|
||||||
|
response = append(response, map[string]string{
|
||||||
|
"id": entry.Id,
|
||||||
|
"name": entry.PreferredTitle(),
|
||||||
|
"detail": detail,
|
||||||
|
})
|
||||||
|
if limit > 0 && len(response) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.JSON(http.StatusOK, map[string]any{
|
||||||
|
"entries": response,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEntriesLimit(value string) int {
|
||||||
|
if value == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := strconv.Atoi(value)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -18,6 +18,7 @@ type AlmanachResult struct {
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<almanach-edit-page>
|
<almanach-edit-page>
|
||||||
|
<duplicate-warning-checker></duplicate-warning-checker>
|
||||||
|
|
||||||
<div class="flex container-normal bg-slate-100 mx-auto px-8">
|
<div class="flex container-normal bg-slate-100 mx-auto px-8">
|
||||||
<div class="flex flex-row w-full justify-between">
|
<div class="flex flex-row w-full justify-between">
|
||||||
@@ -166,7 +167,11 @@ type AlmanachResult struct {
|
|||||||
<div class="inputlabelrow">
|
<div class="inputlabelrow">
|
||||||
<label for="preferred_title" class="inputlabel">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">{{- $model.result.Entry.PreferredTitle -}}</textarea>
|
<textarea name="preferred_title" id="preferred_title" class="inputinput no-enter" placeholder="" required autocomplete="off" rows="1" data-duplicate-check data-duplicate-endpoint="/api/entries/search" data-duplicate-result-key="entries" data-duplicate-current-id="{{ if not $model.is_new }}{{ $model.result.Entry.Id }}{{ end }}">{{- $model.result.Entry.PreferredTitle -}}</textarea>
|
||||||
|
<div class="duplicate-warning hidden" data-duplicate-warning-for="preferred_title">
|
||||||
|
<i class="ri-information-line"></i>
|
||||||
|
<span data-duplicate-count></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
{{ $place := $model.result.Place }}
|
{{ $place := $model.result.Place }}
|
||||||
|
|
||||||
<edit-page>
|
<edit-page>
|
||||||
|
<duplicate-warning-checker></duplicate-warning-checker>
|
||||||
<div class="flex container-normal bg-slate-100 mx-auto px-8">
|
<div class="flex container-normal bg-slate-100 mx-auto px-8">
|
||||||
<div class="flex flex-row w-full justify-between">
|
<div class="flex flex-row w-full justify-between">
|
||||||
<div class="flex flex-col justify-end-safe flex-2/5">
|
<div class="flex flex-col justify-end-safe flex-2/5">
|
||||||
@@ -115,7 +116,11 @@
|
|||||||
<div class="inputlabelrow">
|
<div class="inputlabelrow">
|
||||||
<label for="name" class="inputlabel">Name</label>
|
<label for="name" class="inputlabel">Name</label>
|
||||||
</div>
|
</div>
|
||||||
<textarea name="name" id="name" class="inputinput no-enter" autocomplete="off" rows="1">{{- $place.Name -}}</textarea>
|
<textarea name="name" id="name" class="inputinput no-enter" autocomplete="off" rows="1" data-duplicate-check data-duplicate-endpoint="/api/places/search" data-duplicate-result-key="places" data-duplicate-current-id="{{ if not $model.is_new }}{{ $place.Id }}{{ end }}">{{- $place.Name -}}</textarea>
|
||||||
|
<div class="duplicate-warning hidden" data-duplicate-warning-for="name">
|
||||||
|
<i class="ri-information-line"></i>
|
||||||
|
<span data-duplicate-count></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="inputwrapper">
|
<div class="inputwrapper">
|
||||||
<div class="inputlabelrow">
|
<div class="inputlabelrow">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
{{ $agent := $model.result.Agent }}
|
{{ $agent := $model.result.Agent }}
|
||||||
|
|
||||||
<edit-page>
|
<edit-page>
|
||||||
|
<duplicate-warning-checker></duplicate-warning-checker>
|
||||||
<div class="flex container-normal bg-slate-100 mx-auto px-8">
|
<div class="flex container-normal bg-slate-100 mx-auto px-8">
|
||||||
<div class="flex flex-row w-full justify-between">
|
<div class="flex flex-row w-full justify-between">
|
||||||
<div class="flex flex-col justify-end-safe flex-2/5">
|
<div class="flex flex-col justify-end-safe flex-2/5">
|
||||||
@@ -117,7 +118,11 @@
|
|||||||
<div class="inputlabelrow">
|
<div class="inputlabelrow">
|
||||||
<label for="name" class="inputlabel">Name</label>
|
<label for="name" class="inputlabel">Name</label>
|
||||||
</div>
|
</div>
|
||||||
<textarea name="name" id="name" class="inputinput no-enter" autocomplete="off" rows="1">{{- $agent.Name -}}</textarea>
|
<textarea name="name" id="name" class="inputinput no-enter" autocomplete="off" rows="1" data-duplicate-check data-duplicate-endpoint="/api/agents/search" data-duplicate-result-key="agents" data-duplicate-current-id="{{ if not $model.is_new }}{{ $agent.Id }}{{ end }}">{{- $agent.Name -}}</textarea>
|
||||||
|
<div class="duplicate-warning hidden" data-duplicate-warning-for="name">
|
||||||
|
<i class="ri-information-line"></i>
|
||||||
|
<span data-duplicate-count></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="inputwrapper">
|
<div class="inputwrapper">
|
||||||
<div class="inputlabelrow">
|
<div class="inputlabelrow">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
{{ $series := $model.result.Series }}
|
{{ $series := $model.result.Series }}
|
||||||
|
|
||||||
<edit-page>
|
<edit-page>
|
||||||
|
<duplicate-warning-checker></duplicate-warning-checker>
|
||||||
<div class="flex container-normal bg-slate-100 mx-auto px-8">
|
<div class="flex container-normal bg-slate-100 mx-auto px-8">
|
||||||
<div class="flex flex-row w-full justify-between">
|
<div class="flex flex-row w-full justify-between">
|
||||||
<div class="flex flex-col justify-end-safe flex-2/5">
|
<div class="flex flex-col justify-end-safe flex-2/5">
|
||||||
@@ -117,7 +118,11 @@
|
|||||||
<div class="inputlabelrow">
|
<div class="inputlabelrow">
|
||||||
<label for="title" class="inputlabel">Reihentitel</label>
|
<label for="title" class="inputlabel">Reihentitel</label>
|
||||||
</div>
|
</div>
|
||||||
<textarea name="title" id="title" class="inputinput no-enter" autocomplete="off" rows="1">{{- $series.Title -}}</textarea>
|
<textarea name="title" id="title" class="inputinput no-enter" autocomplete="off" rows="1" data-duplicate-check data-duplicate-endpoint="/api/series/search" data-duplicate-result-key="series" data-duplicate-current-id="{{ if not $model.is_new }}{{ $series.Id }}{{ end }}">{{- $series.Title -}}</textarea>
|
||||||
|
<div class="duplicate-warning hidden" data-duplicate-warning-for="title">
|
||||||
|
<i class="ri-information-line"></i>
|
||||||
|
<span data-duplicate-count></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="inputwrapper">
|
<div class="inputwrapper">
|
||||||
<div class="inputlabelrow">
|
<div class="inputlabelrow">
|
||||||
|
|||||||
96
views/transform/duplicate-warning.js
Normal file
96
views/transform/duplicate-warning.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
const DEBOUNCE_DELAY_MS = 100;
|
||||||
|
|
||||||
|
export class DuplicateWarningChecker extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._fields = null;
|
||||||
|
this._boundHandlers = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
// Find all fields marked for duplicate checking
|
||||||
|
this._fields = document.querySelectorAll("[data-duplicate-check]");
|
||||||
|
|
||||||
|
this._fields.forEach((field) => {
|
||||||
|
const handler = this._createHandler(field);
|
||||||
|
this._boundHandlers.set(field, handler);
|
||||||
|
field.addEventListener("input", handler);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
this._boundHandlers.forEach((handler, field) => {
|
||||||
|
field.removeEventListener("input", handler);
|
||||||
|
});
|
||||||
|
this._boundHandlers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
_createHandler(field) {
|
||||||
|
let timeout = null;
|
||||||
|
return (event) => {
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
this._checkDuplicates(field);
|
||||||
|
}, DEBOUNCE_DELAY_MS);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async _checkDuplicates(field) {
|
||||||
|
const value = field.value.trim();
|
||||||
|
const endpoint = field.getAttribute("data-duplicate-endpoint");
|
||||||
|
const resultKey = field.getAttribute("data-duplicate-result-key");
|
||||||
|
const currentId = field.getAttribute("data-duplicate-current-id") || "";
|
||||||
|
const warningEl = document.querySelector(`[data-duplicate-warning-for="${field.id}"]`);
|
||||||
|
|
||||||
|
if (!warningEl || !endpoint || !resultKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide warning if field is empty
|
||||||
|
if (value === "") {
|
||||||
|
warningEl.classList.add("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch duplicates
|
||||||
|
try {
|
||||||
|
const url = new URL(endpoint, window.location.origin);
|
||||||
|
url.searchParams.set("q", value);
|
||||||
|
url.searchParams.set("limit", "100"); // Get all to filter
|
||||||
|
|
||||||
|
const response = await fetch(url.toString());
|
||||||
|
if (!response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const results = data[resultKey] || [];
|
||||||
|
|
||||||
|
// Filter out current item if editing
|
||||||
|
let filtered = results;
|
||||||
|
if (currentId) {
|
||||||
|
filtered = results.filter((item) => item.id !== currentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter for exact matches only (case-insensitive)
|
||||||
|
const exactMatches = filtered.filter((item) => item.name && item.name.toLowerCase() === value.toLowerCase());
|
||||||
|
|
||||||
|
// Show or hide warning
|
||||||
|
if (exactMatches.length > 0) {
|
||||||
|
const countEl = warningEl.querySelector("[data-duplicate-count]");
|
||||||
|
if (countEl) {
|
||||||
|
const plural = exactMatches.length === 1 ? "" : "e";
|
||||||
|
countEl.textContent = `Der Name ist bereits vorhanden (${exactMatches.length} Treffer${plural})`;
|
||||||
|
}
|
||||||
|
warningEl.classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
warningEl.classList.add("hidden");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Silently fail - don't show errors to user
|
||||||
|
console.error("Duplicate check failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,10 @@
|
|||||||
disabled:opacity-50 py-1 px-3;
|
disabled:opacity-50 py-1 px-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dbform .duplicate-warning {
|
||||||
|
@apply text-sm text-sky-600 mt-0.5 pl-2.5 pb-1.5 flex items-center gap-1;
|
||||||
|
}
|
||||||
|
|
||||||
/* Status select color coding */
|
/* Status select color coding */
|
||||||
.status-select[data-status="Edited"] {
|
.status-select[data-status="Edited"] {
|
||||||
@apply bg-green-100 text-green-900;
|
@apply bg-green-100 text-green-900;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { AlmanachEditPage } from "./almanach-edit.js";
|
|||||||
import { RelationsEditor } from "./relations-editor.js";
|
import { RelationsEditor } from "./relations-editor.js";
|
||||||
import { EditPage } from "./edit-page.js";
|
import { EditPage } from "./edit-page.js";
|
||||||
import { FabMenu } from "./fab-menu.js";
|
import { FabMenu } from "./fab-menu.js";
|
||||||
|
import { DuplicateWarningChecker } from "./duplicate-warning.js";
|
||||||
|
|
||||||
const FILTER_LIST_ELEMENT = "filter-list";
|
const FILTER_LIST_ELEMENT = "filter-list";
|
||||||
const FAB_MENU_ELEMENT = "fab-menu";
|
const FAB_MENU_ELEMENT = "fab-menu";
|
||||||
@@ -47,6 +48,7 @@ const ITEMS_EDITOR_ELEMENT = "items-editor";
|
|||||||
const ALMANACH_EDIT_PAGE_ELEMENT = "almanach-edit-page";
|
const ALMANACH_EDIT_PAGE_ELEMENT = "almanach-edit-page";
|
||||||
const RELATIONS_EDITOR_ELEMENT = "relations-editor";
|
const RELATIONS_EDITOR_ELEMENT = "relations-editor";
|
||||||
const EDIT_PAGE_ELEMENT = "edit-page";
|
const EDIT_PAGE_ELEMENT = "edit-page";
|
||||||
|
const DUPLICATE_WARNING_ELEMENT = "duplicate-warning-checker";
|
||||||
|
|
||||||
customElements.define(INT_LINK_ELEMENT, IntLink);
|
customElements.define(INT_LINK_ELEMENT, IntLink);
|
||||||
customElements.define(ABBREV_TOOLTIPS_ELEMENT, AbbreviationTooltips);
|
customElements.define(ABBREV_TOOLTIPS_ELEMENT, AbbreviationTooltips);
|
||||||
@@ -67,6 +69,7 @@ customElements.define(ALMANACH_EDIT_PAGE_ELEMENT, AlmanachEditPage);
|
|||||||
customElements.define(RELATIONS_EDITOR_ELEMENT, RelationsEditor);
|
customElements.define(RELATIONS_EDITOR_ELEMENT, RelationsEditor);
|
||||||
customElements.define(EDIT_PAGE_ELEMENT, EditPage);
|
customElements.define(EDIT_PAGE_ELEMENT, EditPage);
|
||||||
customElements.define(FAB_MENU_ELEMENT, FabMenu);
|
customElements.define(FAB_MENU_ELEMENT, FabMenu);
|
||||||
|
customElements.define(DUPLICATE_WARNING_ELEMENT, DuplicateWarningChecker);
|
||||||
|
|
||||||
function PathPlusQuery() {
|
function PathPlusQuery() {
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
|
|||||||
Reference in New Issue
Block a user