init
All checks were successful
release-tag / release-image (push) Successful in 1m35s

This commit is contained in:
2026-07-28 23:11:40 +02:00
parent 8eb01af82e
commit e5bc62ebf2
23 changed files with 3976 additions and 1 deletions

226
cmd/server/app.go Normal file
View File

@@ -0,0 +1,226 @@
package main
import (
"encoding/json"
"errors"
"io"
"io/fs"
"net/http"
"os"
"strconv"
"strings"
"kb-editor/internal/store"
)
type appConfig struct {
Mode string `json:"mode"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Writable bool `json:"writable"`
}
type app struct {
store *store.Store
web fs.FS
config appConfig
}
func newApp(s *store.Store, web fs.FS, configs ...appConfig) *app {
cfg := appConfig{Mode: "editor", Title: "Knowledge Base Editor", Subtitle: "JSON · Massenbearbeitung · Docker", Writable: true}
if len(configs) > 0 {
cfg = configs[0]
}
return &app{store: s, web: web, config: cfg}
}
func (a *app) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/health", a.handleHealth)
mux.HandleFunc("GET /api/config", a.handleConfig)
mux.HandleFunc("GET /api/items", a.handleList)
mux.HandleFunc("GET /api/search", a.handleSearch)
mux.HandleFunc("GET /api/facets", a.handleFacets)
mux.HandleFunc("GET /api/items/{key}", a.handleGet)
if a.config.Writable {
mux.HandleFunc("PUT /api/items/{key}", a.handlePut)
mux.HandleFunc("POST /api/bulk", a.handleBulk)
mux.HandleFunc("POST /api/reload", a.handleReload)
} else {
mux.HandleFunc("PUT /api/items/{key}", a.handleReadOnly)
mux.HandleFunc("POST /api/bulk", a.handleReadOnly)
mux.HandleFunc("POST /api/reload", a.handleReadOnly)
}
static := http.FileServer(http.FS(a.web))
mux.Handle("GET /", static)
return securityHeaders(mux)
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
next.ServeHTTP(w, r)
})
}
func (a *app) handleHealth(w http.ResponseWriter, r *http.Request) {
payload := map[string]any{
"ok": true,
"count": a.store.Count(),
"data_dir": a.store.DataDir(),
"mode": a.config.Mode,
"writable": a.config.Writable,
}
if a.config.Writable {
payload["backup_dir"] = a.store.BackupDir()
}
writeJSON(w, http.StatusOK, payload)
}
func (a *app) handleConfig(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, a.config)
}
func (a *app) handleList(w http.ResponseWriter, r *http.Request) {
q := queryFromURL(r)
writeJSON(w, http.StatusOK, a.store.List(q))
}
func (a *app) handleSearch(w http.ResponseWriter, r *http.Request) {
q := queryFromURL(r)
writeJSON(w, http.StatusOK, a.store.Search(q))
}
func (a *app) handleFacets(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
writeJSON(w, http.StatusOK, a.store.Facets(limit))
}
func queryFromURL(r *http.Request) store.Query {
v := r.URL.Query()
page, _ := strconv.Atoi(v.Get("page"))
pageSize, _ := strconv.Atoi(v.Get("page_size"))
return store.Query{
Q: v.Get("q"),
AutoReply: v.Get("auto_reply"),
Language: v.Get("language"),
CommunicationStyle: v.Get("communication_style"),
Source: v.Get("source"),
Page: page,
PageSize: pageSize,
}
}
func (a *app) handleGet(w http.ResponseWriter, r *http.Request) {
doc, meta, err := a.store.Get(r.PathValue("key"))
if errors.Is(err, os.ErrNotExist) {
writeError(w, http.StatusNotFound, "Eintrag nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"document": doc, "meta": meta})
}
func (a *app) handleReadOnly(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusForbidden, "Diese Instanz läuft im Google-/Viewer-Modus und ist schreibgeschützt")
}
func (a *app) handlePut(w http.ResponseWriter, r *http.Request) {
if !mustJSONContentType(w, r) {
return
}
var doc map[string]any
if err := decodeJSON(r, &doc); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON: "+err.Error())
return
}
meta, backup, err := a.store.Save(r.PathValue("key"), doc)
if errors.Is(err, os.ErrNotExist) {
writeError(w, http.StatusNotFound, "Eintrag nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "meta": meta, "backup": backup})
}
type bulkRequest struct {
Keys []string `json:"keys"`
AllMatching bool `json:"all_matching"`
Query store.Query `json:"query"`
Patch store.BulkPatch `json:"patch"`
DryRun bool `json:"dry_run"`
}
func (a *app) handleBulk(w http.ResponseWriter, r *http.Request) {
if !mustJSONContentType(w, r) {
return
}
var req bulkRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage: "+err.Error())
return
}
keys := req.Keys
if req.AllMatching {
keys = a.store.MatchingKeys(req.Query)
}
if len(keys) == 0 {
writeError(w, http.StatusBadRequest, "Keine Zieldateien ausgewählt")
return
}
result, err := a.store.ApplyBulk(keys, req.Patch, req.DryRun)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (a *app) handleReload(w http.ResponseWriter, r *http.Request) {
if !mustJSONContentType(w, r) {
return
}
if err := a.store.Reload(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "count": a.store.Count()})
}
func decodeJSON(r *http.Request, dst any) error {
dec := json.NewDecoder(io.LimitReader(r.Body, 8<<20))
dec.UseNumber()
if err := dec.Decode(dst); err != nil {
return err
}
var extra any
if err := dec.Decode(&extra); err != io.EOF {
if err == nil {
return errors.New("mehr als ein JSON-Wert im Request")
}
return err
}
return nil
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]any{"error": strings.TrimSpace(message)})
}

129
cmd/server/app_test.go Normal file
View File

@@ -0,0 +1,129 @@
package main
import (
"bytes"
"encoding/json"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"kb-editor/internal/store"
)
func TestBulkAllMatchingUsesJSONFilterNames(t *testing.T) {
dir := t.TempDir()
backup := filepath.Join(t.TempDir(), "backups")
t.Setenv("BACKUP_DIR", backup)
write := func(name string, auto bool) {
t.Helper()
b, _ := json.Marshal(map[string]any{"id": name, "title": name, "auto_reply": auto, "language": "de-DE"})
if err := os.WriteFile(filepath.Join(dir, name+".json"), b, 0o644); err != nil {
t.Fatal(err)
}
}
write("true-one", true)
write("false-one", false)
s, err := store.New(dir)
if err != nil {
t.Fatal(err)
}
web, err := fs.Sub(webFS, "web")
if err != nil {
t.Fatal(err)
}
h := newApp(s, web).routes()
body := []byte(`{"keys":[],"all_matching":true,"query":{"auto_reply":"false"},"patch":{"set_language":"en-US"},"dry_run":true}`)
req := httptest.NewRequest(http.MethodPost, "/api/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
var result store.BulkResult
if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Targeted != 1 || result.Changed != 1 {
t.Fatalf("unexpected result: %+v", result)
}
}
func TestGoogleModeBlocksWrites(t *testing.T) {
dir := t.TempDir()
b, _ := json.Marshal(map[string]any{"id": "KB-1", "title": "Test", "answer": "Lösung"})
if err := os.WriteFile(filepath.Join(dir, "one.json"), b, 0o644); err != nil {
t.Fatal(err)
}
s, err := store.New(dir)
if err != nil {
t.Fatal(err)
}
web, err := fs.Sub(webFS, "viewer")
if err != nil {
t.Fatal(err)
}
h := newApp(s, web, appConfig{Mode: "google", Title: "Helpdesk", Writable: false}).routes()
item := s.List(store.Query{Page: 1, PageSize: 10}).Items[0]
req := httptest.NewRequest(http.MethodPut, "/api/items/"+item.Key, bytes.NewBufferString(`{"title":"changed"}`))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
doc, _, err := s.Get(item.Key)
if err != nil {
t.Fatal(err)
}
if doc["title"] != "Test" {
t.Fatalf("document changed in google mode: %+v", doc)
}
}
func TestSearchEndpointReturnsRankedHits(t *testing.T) {
dir := t.TempDir()
write := func(name string, doc map[string]any) {
t.Helper()
b, _ := json.Marshal(doc)
if err := os.WriteFile(filepath.Join(dir, name+".json"), b, 0o644); err != nil {
t.Fatal(err)
}
}
write("exact", map[string]any{"id": "0x80070005", "title": "Zugriff verweigert", "text": "Berechtigungen prüfen"})
write("mention", map[string]any{"id": "KB-2", "title": "Allgemeiner Windows-Fehler", "answer": "Kann 0x80070005 enthalten"})
s, err := store.New(dir)
if err != nil {
t.Fatal(err)
}
web, err := fs.Sub(webFS, "viewer")
if err != nil {
t.Fatal(err)
}
h := newApp(s, web, appConfig{Mode: "google", Title: "Helpdesk", Writable: false}).routes()
req := httptest.NewRequest(http.MethodGet, "/api/search?q=0x80070005&page=1&page_size=20", nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
var result store.SearchResult
if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Total != 2 || len(result.Items) != 2 {
t.Fatalf("unexpected result: %+v", result)
}
if result.Items[0].ID != "0x80070005" {
t.Fatalf("exact ID should rank first: %+v", result.Items)
}
}

174
cmd/server/main.go Normal file
View File

@@ -0,0 +1,174 @@
package main
import (
"crypto/subtle"
"embed"
"flag"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"strings"
"time"
"kb-editor/internal/store"
)
//go:embed web/* viewer/*
var webFS embed.FS
func main() {
var dataDir string
var listen string
flag.StringVar(&dataDir, "data", envOr("DATA_DIR", "./data/knowledge"), "directory containing JSON knowledge files")
flag.StringVar(&listen, "listen", envOr("LISTEN_ADDR", ":8080"), "HTTP listen address")
flag.Parse()
cfg, staticDir, err := configFromEnv()
if err != nil {
log.Fatal(err)
}
s, err := store.New(dataDir)
if err != nil {
log.Fatalf("initialize store: %v", err)
}
reloadInterval, err := autoReloadInterval(cfg.Mode)
if err != nil {
log.Fatal(err)
}
if reloadInterval > 0 {
go startAutoReload(s, reloadInterval)
}
sub, err := fs.Sub(webFS, staticDir)
if err != nil {
log.Fatal(err)
}
app := newApp(s, sub, cfg)
handler := requestLogger(optionalBasicAuth(app.routes()))
srv := &http.Server{
Addr: listen,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 90 * time.Second,
}
log.Printf("KB service listening on %s", listen)
log.Printf("Mode: %s (writable=%t)", cfg.Mode, cfg.Writable)
log.Printf("Data directory: %s (%d JSON files indexed)", s.DataDir(), s.Count())
if reloadInterval > 0 {
log.Printf("Automatic index reload: %s", reloadInterval)
}
if u := os.Getenv("BASIC_AUTH_USER"); u != "" {
log.Printf("Basic authentication enabled for user %q", u)
}
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}
func configFromEnv() (appConfig, string, error) {
mode := strings.ToLower(strings.TrimSpace(envOr("APP_MODE", "editor")))
switch mode {
case "editor":
return appConfig{
Mode: "editor",
Title: envOr("APP_TITLE", "Knowledge Base Editor"),
Subtitle: envOr("APP_SUBTITLE", "JSON · Massenbearbeitung · Docker"),
Writable: true,
}, "web", nil
case "google", "viewer", "search":
return appConfig{
Mode: "google",
Title: envOr("APP_TITLE", "Helpdesk Search"),
Subtitle: envOr("APP_SUBTITLE", "Interne Wissenssuche für den Helpdesk"),
Writable: false,
}, "viewer", nil
default:
return appConfig{}, "", fmt.Errorf("invalid APP_MODE %q: expected editor or google", mode)
}
}
func autoReloadInterval(mode string) (time.Duration, error) {
raw := strings.TrimSpace(os.Getenv("AUTO_RELOAD_INTERVAL"))
if raw == "" {
if mode == "google" {
return 60 * time.Second, nil
}
return 0, nil
}
if raw == "0" || strings.EqualFold(raw, "off") || strings.EqualFold(raw, "disabled") {
return 0, nil
}
d, err := time.ParseDuration(raw)
if err != nil {
return 0, fmt.Errorf("invalid AUTO_RELOAD_INTERVAL %q: %w", raw, err)
}
if d < 5*time.Second {
return 0, fmt.Errorf("AUTO_RELOAD_INTERVAL must be 0/off or at least 5s")
}
return d, nil
}
func startAutoReload(s *store.Store, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
if err := s.Reload(); err != nil {
log.Printf("automatic index reload failed: %v", err)
}
}
}
func envOr(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return fallback
}
func optionalBasicAuth(next http.Handler) http.Handler {
user := os.Getenv("BASIC_AUTH_USER")
pass := os.Getenv("BASIC_AUTH_PASSWORD")
if user == "" && pass == "" {
return next
}
if user == "" || pass == "" {
log.Fatal("BASIC_AUTH_USER and BASIC_AUTH_PASSWORD must either both be set or both be empty")
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, p, ok := r.BasicAuth()
userOK := subtle.ConstantTimeCompare([]byte(u), []byte(user)) == 1
passOK := subtle.ConstantTimeCompare([]byte(p), []byte(pass)) == 1
if !ok || !userOK || !passOK {
w.Header().Set("WWW-Authenticate", `Basic realm="KB Helpdesk", charset="UTF-8"`)
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func requestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.RequestURI(), time.Since(start).Round(time.Millisecond))
})
}
func mustJSONContentType(w http.ResponseWriter, r *http.Request) bool {
ct := r.Header.Get("Content-Type")
if !strings.HasPrefix(ct, "application/json") {
http.Error(w, fmt.Sprintf("Content-Type must be application/json, got %q", ct), http.StatusUnsupportedMediaType)
return false
}
return true
}

382
cmd/server/viewer/app.js Normal file
View File

@@ -0,0 +1,382 @@
(() => {
'use strict';
const $ = (selector, root = document) => root.querySelector(selector);
const state = {
query: '',
page: 1,
pageSize: 20,
total: 0,
totalPages: 0,
facets: null,
config: null,
currentKey: null,
currentDoc: null,
};
const els = {
brandTitle: $('#brandTitle'), brandSubtitle: $('#brandSubtitle'), countBadge: $('#countBadge'),
hero: $('#hero'), heroSearchForm: $('#heroSearchForm'), heroSearch: $('#heroSearch'), quickLinks: $('#quickLinks'),
resultsView: $('#resultsView'), topSearchForm: $('#topSearchForm'), topSearch: $('#topSearch'),
resultCount: $('#resultCount'), resultHint: $('#resultHint'), clearSearch: $('#clearSearch'), sideFacets: $('#sideFacets'),
loading: $('#loading'), noResults: $('#noResults'), resultList: $('#resultList'), pagination: $('#pagination'),
articleDialog: $('#articleDialog'), closeArticle: $('#closeArticle'), articleEyebrow: $('#articleEyebrow'),
articleTitle: $('#articleTitle'), articleMeta: $('#articleMeta'), problemSection: $('#problemSection'),
articleProblem: $('#articleProblem'), answerSection: $('#answerSection'), articleAnswer: $('#articleAnswer'),
tagsSection: $('#tagsSection'), articleTags: $('#articleTags'), sourceSection: $('#sourceSection'),
articleSource: $('#articleSource'), articleSourceUri: $('#articleSourceUri'), sourceLink: $('#sourceLink'),
articlePath: $('#articlePath'), copyAnswer: $('#copyAnswer'), copyLink: $('#copyLink'), toastHost: $('#toastHost'),
};
async function api(url) {
const response = await fetch(url, {headers: {'Accept': 'application/json'}});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error || `${response.status} ${response.statusText}`);
return body;
}
function escapeHTML(value) {
return String(value ?? '').replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
}
function escapeRegex(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function highlighted(value, query = state.query) {
let safe = escapeHTML(value);
const terms = [...new Set(String(query).trim().split(/\s+/).filter(Boolean))]
.sort((a, b) => b.length - a.length);
if (!terms.length) return safe;
const regex = new RegExp(`(${terms.map(escapeRegex).join('|')})`, 'gi');
return safe.replace(regex, '<mark>$1</mark>');
}
function qs(params) {
const out = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== '' && value !== null && value !== undefined) out.set(key, String(value));
});
return out.toString();
}
function updateURL({replace = false} = {}) {
const params = new URLSearchParams();
if (state.query) params.set('q', state.query);
if (state.page > 1) params.set('page', String(state.page));
if (state.currentKey) params.set('doc', state.currentKey);
const url = `${location.pathname}${params.toString() ? `?${params}` : ''}`;
history[replace ? 'replaceState' : 'pushState']({}, '', url);
}
async function loadBootstrap() {
try {
const [config, health, facets] = await Promise.all([
api('/api/config'), api('/api/health'), api('/api/facets?limit=10')
]);
state.config = config;
state.facets = facets;
document.title = config.title || 'Helpdesk Search';
els.brandTitle.textContent = config.title || 'Helpdesk Search';
els.brandSubtitle.textContent = config.subtitle || 'Interne Wissenssuche für den Helpdesk';
els.countBadge.textContent = `${Number(health.count || 0).toLocaleString('de-DE')} Wissenseinträge`;
renderFacets();
} catch (error) {
els.countBadge.textContent = 'Wissensbasis nicht erreichbar';
toast(error.message, 'error');
}
}
function renderFacets() {
const categories = (state.facets?.categories?.length ? state.facets.categories : state.facets?.keywords) || [];
els.quickLinks.innerHTML = '';
els.sideFacets.innerHTML = '';
categories.slice(0, 7).forEach((facet) => {
const heroButton = document.createElement('button');
heroButton.type = 'button';
heroButton.className = 'quick-chip';
heroButton.innerHTML = `<span>${escapeHTML(facet.name)}</span><small>${facet.count.toLocaleString('de-DE')}</small>`;
heroButton.addEventListener('click', () => submitSearch(facet.name));
els.quickLinks.appendChild(heroButton);
const sideButton = document.createElement('button');
sideButton.type = 'button';
sideButton.className = 'facet-button';
sideButton.innerHTML = `<span>${escapeHTML(facet.name)}</span><small>${facet.count.toLocaleString('de-DE')}</small>`;
sideButton.addEventListener('click', () => submitSearch(facet.name));
els.sideFacets.appendChild(sideButton);
});
}
async function runSearch() {
const query = state.query.trim();
if (!query) {
showHome();
return;
}
showResults();
els.loading.classList.remove('hidden');
els.noResults.classList.add('hidden');
els.resultList.innerHTML = '';
els.pagination.classList.add('hidden');
try {
const data = await api(`/api/search?${qs({q: query, page: state.page, page_size: state.pageSize})}`);
state.page = data.page || 1;
state.total = data.total || 0;
state.totalPages = data.total_pages || 0;
renderResults(data.items || []);
renderPagination();
els.resultCount.textContent = `${state.total.toLocaleString('de-DE')} ${state.total === 1 ? 'Treffer' : 'Treffer'}`;
els.resultHint.textContent = `für „${query}`;
if (!state.total) els.noResults.classList.remove('hidden');
} catch (error) {
els.resultCount.textContent = 'Suche fehlgeschlagen';
els.resultHint.textContent = '';
toast(error.message, 'error');
} finally {
els.loading.classList.add('hidden');
}
}
function renderResults(items) {
els.resultList.innerHTML = '';
for (const item of items) {
const card = document.createElement('article');
card.className = 'result-card';
const tags = [...(item.categories || []), ...(item.keywords || [])].slice(0, 4);
card.innerHTML = `
<button class="result-main" type="button">
<div class="result-overline">
<span class="result-id">${highlighted(item.id || item.rel_path)}</span>
${item.source ? `<span class="result-source">${escapeHTML(item.source)}</span>` : ''}
</div>
<h2>${highlighted(item.title || '(ohne Titel)')}</h2>
${item.excerpt ? `<p>${highlighted(item.excerpt)}</p>` : '<p class="muted">Kein Beschreibungstext hinterlegt.</p>'}
<div class="result-tags">${tags.map(tag => `<span>${highlighted(tag)}</span>`).join('')}</div>
</button>
<div class="result-arrow" aria-hidden="true">→</div>`;
$('.result-main', card).addEventListener('click', () => openArticle(item.key));
card.addEventListener('dblclick', () => openArticle(item.key));
els.resultList.appendChild(card);
}
}
function renderPagination() {
els.pagination.innerHTML = '';
if (state.totalPages <= 1) {
els.pagination.classList.add('hidden');
return;
}
els.pagination.classList.remove('hidden');
const add = (label, page, {active = false, disabled = false, aria = ''} = {}) => {
const button = document.createElement('button');
button.type = 'button';
button.textContent = label;
button.className = `page-btn${active ? ' active' : ''}`;
button.disabled = disabled;
if (aria) button.setAttribute('aria-label', aria);
button.addEventListener('click', () => goPage(page));
els.pagination.appendChild(button);
};
add('', state.page - 1, {disabled: state.page <= 1, aria: 'Vorherige Seite'});
const pages = pageWindow(state.page, state.totalPages);
let previous = 0;
pages.forEach(page => {
if (previous && page - previous > 1) {
const gap = document.createElement('span');
gap.className = 'page-gap';
gap.textContent = '…';
els.pagination.appendChild(gap);
}
add(String(page), page, {active: page === state.page, aria: `Seite ${page}`});
previous = page;
});
add('', state.page + 1, {disabled: state.page >= state.totalPages, aria: 'Nächste Seite'});
}
function pageWindow(current, total) {
const candidates = new Set([1, total]);
for (let page = current - 2; page <= current + 2; page++) {
if (page >= 1 && page <= total) candidates.add(page);
}
return [...candidates].sort((a, b) => a - b);
}
function goPage(page) {
if (page < 1 || page > state.totalPages || page === state.page) return;
state.page = page;
state.currentKey = null;
updateURL();
runSearch();
window.scrollTo({top: 0, behavior: 'smooth'});
}
function submitSearch(value) {
const query = String(value ?? '').trim();
if (!query) return;
state.query = query;
state.page = 1;
state.currentKey = null;
els.heroSearch.value = query;
els.topSearch.value = query;
updateURL();
runSearch();
}
function showHome() {
state.query = '';
state.page = 1;
state.currentKey = null;
els.hero.classList.remove('hidden');
els.resultsView.classList.add('hidden');
els.heroSearch.value = '';
updateURL({replace: true});
setTimeout(() => els.heroSearch.focus(), 0);
}
function showResults() {
els.hero.classList.add('hidden');
els.resultsView.classList.remove('hidden');
els.topSearch.value = state.query;
}
async function openArticle(key, {updateHistory = true} = {}) {
try {
const data = await api(`/api/items/${encodeURIComponent(key)}`);
state.currentKey = key;
state.currentDoc = data.document || {};
renderArticle(state.currentDoc, data.meta || {});
if (updateHistory) updateURL();
if (!els.articleDialog.open) els.articleDialog.showModal();
} catch (error) {
toast(error.message, 'error');
}
}
function renderArticle(doc, meta) {
els.articleEyebrow.textContent = doc.id || meta.rel_path || 'Wissensartikel';
els.articleTitle.textContent = doc.title || '(ohne Titel)';
els.articleProblem.textContent = doc.text || '';
els.articleAnswer.textContent = doc.answer || '';
els.problemSection.classList.toggle('hidden', !doc.text);
els.answerSection.classList.toggle('hidden', !doc.answer);
const metaParts = [];
if (doc.language) metaParts.push(doc.language);
if (doc.communication_style) metaParts.push(doc.communication_style);
if (typeof doc.auto_reply === 'boolean') metaParts.push(`auto_reply: ${doc.auto_reply}`);
if (doc.min_score !== undefined && doc.min_score !== null) metaParts.push(`min_score: ${doc.min_score}`);
els.articleMeta.innerHTML = metaParts.map(value => `<span>${escapeHTML(value)}</span>`).join('');
const tags = [...new Set([...(Array.isArray(doc.categories) ? doc.categories : []), ...(Array.isArray(doc.keywords) ? doc.keywords : [])])];
els.articleTags.innerHTML = tags.map(tag => `<span>${escapeHTML(tag)}</span>`).join('');
els.tagsSection.classList.toggle('hidden', tags.length === 0);
const source = String(doc.source || '').trim();
const sourceURI = safeURL(doc.source_uri);
els.articleSource.textContent = source || 'Quelle';
els.articleSourceUri.textContent = sourceURI || '';
els.sourceSection.classList.toggle('hidden', !source && !sourceURI);
els.sourceLink.classList.toggle('hidden', !sourceURI);
if (sourceURI) els.sourceLink.href = sourceURI;
else els.sourceLink.removeAttribute('href');
els.articlePath.textContent = meta.rel_path || '';
}
function safeURL(value) {
const raw = String(value || '').trim();
if (!raw) return '';
try {
const parsed = new URL(raw);
return ['http:', 'https:'].includes(parsed.protocol) ? parsed.href : '';
} catch (_) {
return '';
}
}
function closeArticle({updateHistory = true} = {}) {
if (els.articleDialog.open) els.articleDialog.close();
state.currentKey = null;
state.currentDoc = null;
if (updateHistory) updateURL({replace: true});
}
async function copyText(text, message) {
try {
await navigator.clipboard.writeText(text);
toast(message, 'success');
} catch (_) {
toast('Kopieren wurde vom Browser blockiert.', 'error');
}
}
function toast(message, type = '') {
const el = document.createElement('div');
el.className = `toast ${type}`;
el.textContent = message;
els.toastHost.appendChild(el);
setTimeout(() => el.remove(), 3200);
}
function bindEvents() {
els.heroSearchForm.addEventListener('submit', event => {
event.preventDefault();
submitSearch(els.heroSearch.value);
});
els.topSearchForm.addEventListener('submit', event => {
event.preventDefault();
submitSearch(els.topSearch.value);
});
els.clearSearch.addEventListener('click', showHome);
els.closeArticle.addEventListener('click', () => closeArticle());
els.articleDialog.addEventListener('click', event => {
if (event.target === els.articleDialog) closeArticle();
});
els.articleDialog.addEventListener('cancel', event => {
event.preventDefault();
closeArticle();
});
els.copyAnswer.addEventListener('click', () => copyText(String(state.currentDoc?.answer || ''), 'Antwort kopiert.'));
els.copyLink.addEventListener('click', () => copyText(location.href, 'Artikellink kopiert.'));
document.addEventListener('keydown', event => {
if (event.key === '/' && !['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName)) {
event.preventDefault();
(state.query ? els.topSearch : els.heroSearch).focus();
}
});
window.addEventListener('popstate', () => hydrateFromURL({historyNavigation: true}));
}
async function hydrateFromURL({historyNavigation = false} = {}) {
const params = new URLSearchParams(location.search);
state.query = (params.get('q') || '').trim();
state.page = Math.max(1, Number.parseInt(params.get('page') || '1', 10) || 1);
const docKey = params.get('doc') || '';
if (state.query) {
els.heroSearch.value = state.query;
els.topSearch.value = state.query;
await runSearch();
} else {
showHome();
}
if (docKey) await openArticle(docKey, {updateHistory: false});
else if (historyNavigation && els.articleDialog.open) closeArticle({updateHistory: false});
}
async function init() {
bindEvents();
await loadBootstrap();
await hydrateFromURL();
}
init();
})();

View File

@@ -0,0 +1,142 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Helpdesk Search</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header class="topbar">
<a class="brand" href="/" aria-label="Zur Startseite">
<span class="brand-mark">H</span>
<span class="brand-copy">
<strong id="brandTitle">Helpdesk Search</strong>
<small id="brandSubtitle">Interne Wissenssuche für den Helpdesk</small>
</span>
</a>
<div class="top-meta">
<span class="mode-badge">Nur lesen</span>
<span id="countBadge" class="count-badge">Wissensbasis lädt …</span>
</div>
</header>
<main>
<section id="hero" class="hero">
<div class="hero-orb orb-one"></div>
<div class="hero-orb orb-two"></div>
<div class="hero-content">
<div class="eyebrow">INTERNES HELPDESK-WISSEN</div>
<h1>Was möchtest du lösen?</h1>
<p>Durchsuche Fehlercodes, Symptome, Produkte, Keywords und dokumentierte Lösungen in einer zentralen Wissensbasis.</p>
<form id="heroSearchForm" class="search-box hero-search" role="search">
<span class="search-icon" aria-hidden="true"></span>
<input id="heroSearch" type="search" placeholder="z. B. 0x80070005, Outlook startet nicht, BitLocker …" autocomplete="off" autofocus>
<kbd>/</kbd>
<button type="submit">Suchen</button>
</form>
<div id="quickLinks" class="quick-links" aria-label="Häufige Kategorien"></div>
</div>
</section>
<section id="resultsView" class="results-view hidden">
<div class="results-header">
<form id="topSearchForm" class="search-box top-search" role="search">
<span class="search-icon" aria-hidden="true"></span>
<input id="topSearch" type="search" autocomplete="off" aria-label="Wissensbasis durchsuchen">
<button type="submit">Suchen</button>
</form>
<div class="results-summary">
<div>
<strong id="resultCount">0 Treffer</strong>
<span id="resultHint"></span>
</div>
<button id="clearSearch" class="text-btn" type="button">Neue Suche</button>
</div>
</div>
<div class="results-layout">
<aside class="side-panel">
<div class="side-card">
<span class="side-label">Schnellzugriff</span>
<div id="sideFacets" class="facet-list"></div>
</div>
<div class="side-card help-card">
<span class="help-icon">?</span>
<strong>Such-Tipp</strong>
<p>Fehlercodes wie <code>0x80070005</code> oder konkrete Meldungsteile liefern meist die präzisesten Treffer.</p>
</div>
</aside>
<div class="results-column">
<div id="loading" class="loading hidden"><span></span><span></span><span></span></div>
<div id="noResults" class="no-results hidden">
<div class="no-results-icon"></div>
<h2>Keine passenden Einträge gefunden</h2>
<p>Versuche einen kürzeren Fehlercode, einen Produktnamen oder einzelne Wörter aus der Fehlermeldung.</p>
</div>
<div id="resultList" class="result-list" aria-live="polite"></div>
<nav id="pagination" class="pagination hidden" aria-label="Suchergebnisse"></nav>
</div>
</div>
</section>
</main>
<dialog id="articleDialog" class="article-dialog">
<article class="article-shell">
<header class="article-head">
<div>
<div id="articleEyebrow" class="article-eyebrow"></div>
<h2 id="articleTitle"></h2>
</div>
<button id="closeArticle" class="close-btn" type="button" aria-label="Artikel schließen">×</button>
</header>
<div class="article-body">
<div id="articleMeta" class="meta-row"></div>
<section id="problemSection" class="article-section">
<div class="section-kicker">PROBLEM / ERKENNUNG</div>
<div id="articleProblem" class="article-text"></div>
</section>
<section id="answerSection" class="article-section answer-section">
<div class="answer-head">
<div>
<div class="section-kicker">LÖSUNG / ANTWORT</div>
<strong>Empfohlene Vorgehensweise</strong>
</div>
<button id="copyAnswer" class="copy-btn" type="button">Antwort kopieren</button>
</div>
<div id="articleAnswer" class="article-text answer-text"></div>
</section>
<section id="tagsSection" class="article-section compact-section">
<div class="section-kicker">EINORDNUNG</div>
<div id="articleTags" class="tag-list"></div>
</section>
<section id="sourceSection" class="article-section source-section hidden">
<div class="section-kicker">QUELLE</div>
<div class="source-line">
<div>
<strong id="articleSource"></strong>
<span id="articleSourceUri"></span>
</div>
<a id="sourceLink" class="source-link" href="#" target="_blank" rel="noopener noreferrer">Quelle öffnen ↗</a>
</div>
</section>
</div>
<footer class="article-foot">
<span id="articlePath"></span>
<button id="copyLink" class="text-btn" type="button">Link zu diesem Artikel kopieren</button>
</footer>
</article>
</dialog>
<div id="toastHost" class="toast-host" aria-live="polite"></div>
<script src="/app.js" defer></script>
</body>
</html>

231
cmd/server/viewer/style.css Normal file
View File

@@ -0,0 +1,231 @@
:root {
color-scheme: dark;
--bg: #08101f;
--bg-soft: #0d1729;
--panel: rgba(14, 25, 44, .86);
--panel-solid: #101c30;
--line: rgba(139, 164, 205, .16);
--line-strong: rgba(139, 164, 205, .28);
--text: #ecf3ff;
--muted: #95a8c6;
--faint: #6d819f;
--accent: #79a7ff;
--accent-2: #8f7cff;
--accent-soft: rgba(121, 167, 255, .12);
--green: #64d9ad;
--danger: #ff8490;
--shadow: 0 22px 70px rgba(0, 0, 0, .36);
--radius: 18px;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
html { min-height: 100%; background: var(--bg); }
body {
min-height: 100vh;
margin: 0;
color: var(--text);
background:
radial-gradient(circle at 14% -10%, rgba(84, 122, 255, .11), transparent 30rem),
radial-gradient(circle at 95% 24%, rgba(127, 91, 255, .08), transparent 27rem),
var(--bg);
}
button, input { font: inherit; }
button { color: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
.hidden { display: none !important; }
.topbar {
height: 72px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 0 clamp(20px, 4vw, 64px);
border-bottom: 1px solid var(--line);
background: rgba(8, 16, 31, .76);
backdrop-filter: blur(18px);
position: sticky;
top: 0;
z-index: 20;
}
.brand { display: flex; align-items: center; gap: 12px; text-decoration: none; color: inherit; min-width: 0; }
.brand-mark {
width: 38px; height: 38px; display: grid; place-items: center; flex: 0 0 auto;
border: 1px solid rgba(121, 167, 255, .35); border-radius: 12px;
background: linear-gradient(135deg, rgba(121,167,255,.22), rgba(143,124,255,.16));
color: #cfe0ff; font-weight: 800; box-shadow: inset 0 1px rgba(255,255,255,.08);
}
.brand-copy { min-width: 0; display: grid; gap: 2px; }
.brand-copy strong { font-size: 14px; letter-spacing: .01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.brand-copy small { color: var(--muted); font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.top-meta { display: flex; align-items: center; gap: 9px; }
.mode-badge, .count-badge {
border: 1px solid var(--line); background: rgba(255,255,255,.025); border-radius: 999px;
color: var(--muted); padding: 6px 10px; font-size: 10px; white-space: nowrap;
}
.mode-badge { color: #9ee5ca; border-color: rgba(100,217,173,.2); background: rgba(100,217,173,.06); }
.hero { min-height: calc(100vh - 72px); display: grid; place-items: center; position: relative; overflow: hidden; padding: 56px 24px 100px; }
.hero-content { width: min(900px, 100%); text-align: center; position: relative; z-index: 2; }
.eyebrow, .section-kicker, .side-label, .article-eyebrow {
color: #93b6fa; font-weight: 760; font-size: 10px; letter-spacing: .13em;
}
.hero h1 { margin: 17px 0 13px; font-size: clamp(36px, 6vw, 64px); line-height: 1.02; letter-spacing: -.045em; }
.hero p { color: var(--muted); margin: 0 auto 34px; max-width: 680px; font-size: clamp(14px, 2vw, 17px); line-height: 1.65; }
.hero-orb { position: absolute; border-radius: 50%; filter: blur(1px); pointer-events: none; }
.orb-one { width: 420px; height: 420px; top: 12%; left: -250px; background: radial-gradient(circle, rgba(69,128,255,.11), transparent 68%); }
.orb-two { width: 520px; height: 520px; bottom: -270px; right: -180px; background: radial-gradient(circle, rgba(127,91,255,.11), transparent 68%); }
.search-box {
display: flex; align-items: center; gap: 10px;
border: 1px solid var(--line-strong); background: rgba(14, 25, 44, .92);
box-shadow: 0 16px 60px rgba(0,0,0,.25), inset 0 1px rgba(255,255,255,.035);
transition: border-color .18s, box-shadow .18s, transform .18s;
}
.search-box:focus-within { border-color: rgba(121,167,255,.7); box-shadow: 0 18px 70px rgba(0,0,0,.3), 0 0 0 4px rgba(121,167,255,.08); }
.hero-search { min-height: 64px; padding: 7px 8px 7px 20px; border-radius: 21px; }
.top-search { min-height: 54px; padding: 5px 6px 5px 17px; border-radius: 16px; width: min(840px, 100%); }
.search-icon { color: #a8bfeb; font-size: 24px; line-height: 1; transform: rotate(-15deg); }
.search-box input { flex: 1; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--text); font-size: 16px; }
.hero-search input { font-size: clamp(15px, 2vw, 18px); }
.search-box input::placeholder { color: #6f83a2; }
.search-box button {
border: 0; border-radius: 14px; background: linear-gradient(135deg, #6f9df5, #8072ec);
min-height: 46px; padding: 0 20px; font-weight: 720; font-size: 12px; cursor: pointer;
box-shadow: inset 0 1px rgba(255,255,255,.2), 0 8px 24px rgba(87,107,224,.2);
}
kbd { border: 1px solid var(--line); border-radius: 6px; padding: 3px 6px; color: var(--faint); background: rgba(255,255,255,.025); font-size: 10px; }
.quick-links { margin-top: 22px; display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 8px; }
.quick-chip, .facet-button {
border: 1px solid var(--line); background: rgba(255,255,255,.025); color: #bbcae2; cursor: pointer;
transition: border-color .15s, background .15s, transform .15s;
}
.quick-chip:hover, .facet-button:hover { border-color: rgba(121,167,255,.4); background: rgba(121,167,255,.08); transform: translateY(-1px); }
.quick-chip { border-radius: 999px; padding: 7px 11px; display: inline-flex; gap: 8px; align-items: center; font-size: 10px; }
.quick-chip small, .facet-button small { color: var(--faint); }
.results-view { width: min(1240px, calc(100% - 40px)); margin: 0 auto; padding: 42px 0 80px; }
.results-header { padding: 0 min(280px, 22vw) 24px 0; }
.results-summary { min-height: 48px; display: flex; justify-content: space-between; align-items: end; gap: 20px; margin-top: 20px; border-bottom: 1px solid var(--line); padding-bottom: 15px; }
.results-summary > div { display: flex; align-items: baseline; flex-wrap: wrap; gap: 7px; }
.results-summary strong { font-size: 13px; }
.results-summary span { color: var(--muted); font-size: 12px; }
.text-btn { border: 0; background: transparent; color: #93b6fa; cursor: pointer; padding: 5px; font-size: 11px; }
.text-btn:hover { color: #c5d8ff; }
.results-layout { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 34px; align-items: start; }
.side-panel { display: grid; gap: 13px; position: sticky; top: 102px; }
.side-card { border: 1px solid var(--line); background: rgba(13,23,41,.6); border-radius: 15px; padding: 14px; }
.facet-list { display: grid; gap: 4px; margin-top: 9px; }
.facet-button { width: 100%; border-radius: 9px; border-color: transparent; background: transparent; padding: 8px; display: flex; justify-content: space-between; text-align: left; font-size: 11px; }
.help-card { padding: 15px; }
.help-card .help-icon { width: 25px; height: 25px; display: grid; place-items: center; border-radius: 8px; color: #a9c4fa; background: var(--accent-soft); margin-bottom: 11px; font-size: 11px; font-weight: 800; }
.help-card strong { display: block; font-size: 11px; }
.help-card p { color: var(--muted); font-size: 10px; line-height: 1.55; margin: 6px 0 0; }
.help-card code { color: #b9cefa; }
.results-column { min-width: 0; }
.result-list { display: grid; gap: 12px; }
.result-card {
position: relative; display: grid; grid-template-columns: minmax(0,1fr) 38px; align-items: center;
border: 1px solid var(--line); background: linear-gradient(140deg, rgba(16,28,48,.82), rgba(11,21,38,.72));
border-radius: var(--radius); overflow: hidden; transition: border-color .16s, transform .16s, box-shadow .16s;
}
.result-card:hover { border-color: rgba(121,167,255,.34); transform: translateY(-1px); box-shadow: 0 13px 42px rgba(0,0,0,.17); }
.result-main { appearance: none; border: 0; background: transparent; color: inherit; text-align: left; padding: 20px 10px 20px 22px; cursor: pointer; min-width: 0; }
.result-overline { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; margin-bottom: 8px; }
.result-id { font: 650 10px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: #91b6ff; }
.result-source { color: var(--faint); font-size: 9px; border-left: 1px solid var(--line-strong); padding-left: 9px; }
.result-main h2 { margin: 0; font-size: 17px; line-height: 1.35; letter-spacing: -.012em; }
.result-main p { color: #a9bad2; font-size: 12px; line-height: 1.65; margin: 9px 0 0; max-width: 850px; }
.result-main p.muted { color: var(--faint); }
.result-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 13px; }
.result-tags span { color: #91a7c7; background: rgba(255,255,255,.025); border: 1px solid var(--line); border-radius: 999px; padding: 4px 8px; font-size: 9px; }
.result-arrow { color: #7892bd; font-size: 17px; }
mark { color: #ddecff; background: rgba(121,167,255,.16); border-radius: 3px; padding: 0 1px; }
.loading { display: flex; justify-content: center; gap: 7px; padding: 70px; }
.loading span { width: 7px; height: 7px; border-radius: 50%; background: #8baef1; animation: pulse 1s infinite ease-in-out; }
.loading span:nth-child(2) { animation-delay: .13s; }.loading span:nth-child(3) { animation-delay: .26s; }
@keyframes pulse { 0%,100% { opacity:.25; transform:translateY(0) } 50% { opacity:1; transform:translateY(-4px) } }
.no-results { text-align: center; padding: 70px 20px; border: 1px dashed var(--line-strong); border-radius: var(--radius); }
.no-results-icon { font-size: 32px; color: #7795c8; transform: rotate(-15deg); }
.no-results h2 { font-size: 18px; margin: 15px 0 5px; }
.no-results p { color: var(--muted); font-size: 12px; max-width: 520px; margin: 0 auto; line-height: 1.6; }
.pagination { display: flex; justify-content: center; align-items: center; gap: 6px; margin-top: 28px; }
.page-btn { width: 35px; height: 35px; border-radius: 9px; border: 1px solid var(--line); background: rgba(255,255,255,.025); color: #b6c5dd; cursor: pointer; font-size: 11px; }
.page-btn:hover:not(:disabled) { border-color: rgba(121,167,255,.45); background: rgba(121,167,255,.08); }
.page-btn.active { color: #edf4ff; background: rgba(121,167,255,.15); border-color: rgba(121,167,255,.45); }
.page-btn:disabled { opacity: .3; cursor: default; }
.page-gap { color: var(--faint); }
.article-dialog { width: min(940px, calc(100vw - 32px)); max-height: calc(100vh - 32px); padding: 0; color: var(--text); background: #0c1729; border: 1px solid var(--line-strong); border-radius: 22px; box-shadow: var(--shadow); overflow: hidden; }
.article-dialog::backdrop { background: rgba(2, 6, 13, .76); backdrop-filter: blur(7px); }
.article-shell { display: grid; grid-template-rows: auto minmax(0,1fr) auto; max-height: calc(100vh - 34px); }
.article-head { display: flex; justify-content: space-between; align-items: start; gap: 20px; padding: 26px 28px 20px; border-bottom: 1px solid var(--line); background: linear-gradient(145deg, rgba(121,167,255,.06), transparent 60%); }
.article-head h2 { margin: 8px 0 0; font-size: clamp(20px, 3vw, 29px); line-height: 1.25; letter-spacing: -.025em; }
.close-btn { width: 36px; height: 36px; flex: 0 0 auto; border: 1px solid var(--line); border-radius: 11px; background: rgba(255,255,255,.025); cursor: pointer; color: #aabbd4; font-size: 22px; line-height: 1; }
.close-btn:hover { border-color: var(--line-strong); color: var(--text); }
.article-body { overflow: auto; padding: 24px 28px 34px; }
.meta-row { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 22px; }
.meta-row span { border: 1px solid var(--line); background: rgba(255,255,255,.02); border-radius: 999px; color: var(--faint); padding: 5px 8px; font-size: 9px; }
.article-section { border-top: 1px solid var(--line); padding: 22px 0; }
.article-section:first-of-type { border-top: 0; }
.article-text { margin-top: 10px; color: #c6d3e6; font-size: 13px; line-height: 1.72; white-space: pre-wrap; overflow-wrap: anywhere; }
.answer-section { margin: 7px -10px 0; padding: 19px 18px 22px; border: 1px solid rgba(100,217,173,.17); border-radius: 15px; background: linear-gradient(135deg, rgba(100,217,173,.055), rgba(121,167,255,.035)); }
.answer-head { display: flex; justify-content: space-between; gap: 20px; align-items: center; }
.answer-head strong { display: block; font-size: 13px; margin-top: 5px; }
.answer-text { color: #d9e7e2; }
.copy-btn, .source-link { border: 1px solid var(--line-strong); border-radius: 10px; background: rgba(255,255,255,.035); padding: 8px 10px; color: #bdd0ef; font-size: 10px; cursor: pointer; text-decoration: none; white-space: nowrap; }
.copy-btn:hover, .source-link:hover { border-color: rgba(121,167,255,.45); color: #edf4ff; }
.compact-section { padding-bottom: 8px; }
.tag-list { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 11px; }
.tag-list span { border-radius: 999px; background: var(--accent-soft); color: #a9c5f8; padding: 5px 9px; font-size: 9px; }
.source-line { display: flex; justify-content: space-between; gap: 20px; align-items: center; margin-top: 10px; }
.source-line > div { min-width: 0; display: grid; gap: 4px; }
.source-line strong { font-size: 12px; }
.source-line span { color: var(--faint); font-size: 9px; overflow-wrap: anywhere; }
.article-foot { display: flex; justify-content: space-between; align-items: center; gap: 20px; min-height: 52px; padding: 10px 24px; border-top: 1px solid var(--line); background: rgba(7,14,26,.7); }
.article-foot > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--faint); font: 9px ui-monospace, SFMono-Regular, Menlo, monospace; }
.toast-host { position: fixed; right: 18px; bottom: 18px; display: grid; gap: 8px; z-index: 100; pointer-events: none; }
.toast { max-width: 420px; padding: 11px 13px; border-radius: 11px; color: #dbe7fb; background: #14233b; border: 1px solid #2c4264; box-shadow: var(--shadow); font-size: 11px; animation: toast-in .18s ease-out; }
.toast.success { color: #b6eed8; border-color: rgba(100,217,173,.35); }
.toast.error { color: #ffc2c8; border-color: rgba(255,132,144,.35); }
@keyframes toast-in { from { opacity: 0; transform: translateY(7px); } }
@media (max-width: 840px) {
.topbar { padding: 0 18px; }
.brand-copy small, .mode-badge { display: none; }
.count-badge { max-width: 42vw; overflow: hidden; text-overflow: ellipsis; }
.hero { padding-left: 18px; padding-right: 18px; }
.hero-search { min-height: 58px; padding-left: 15px; }
.hero-search kbd { display: none; }
.search-box button { padding: 0 14px; }
.results-view { width: min(100% - 28px, 1240px); padding-top: 24px; }
.results-header { padding-right: 0; }
.results-layout { grid-template-columns: 1fr; }
.side-panel { display: none; }
.result-main { padding: 17px 6px 17px 17px; }
.article-head, .article-body { padding-left: 20px; padding-right: 20px; }
}
@media (max-width: 520px) {
.topbar { height: 64px; }
.brand-mark { width: 34px; height: 34px; }
.count-badge { display: none; }
.hero { min-height: calc(100vh - 64px); }
.hero h1 { font-size: 38px; }
.hero p { font-size: 14px; }
.hero-search { display: grid; grid-template-columns: 24px minmax(0,1fr); padding: 12px 14px; border-radius: 18px; }
.hero-search button { grid-column: 1 / -1; width: 100%; }
.top-search button { display: none; }
.results-summary { align-items: center; }
.result-card { grid-template-columns: 1fr; }
.result-arrow { display: none; }
.result-main h2 { font-size: 15px; }
.result-main p { font-size: 11px; }
.article-dialog { width: calc(100vw - 14px); max-height: calc(100vh - 14px); border-radius: 17px; }
.article-shell { max-height: calc(100vh - 16px); }
.article-head { padding: 20px 17px 16px; }
.article-body { padding: 18px 17px 25px; }
.answer-head, .source-line, .article-foot { align-items: flex-start; flex-direction: column; }
.article-foot { gap: 4px; }
}

468
cmd/server/web/app.js Normal file
View File

@@ -0,0 +1,468 @@
(() => {
'use strict';
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
const state = {
page: 1,
pageSize: 60,
total: 0,
totalPages: 0,
items: [],
selected: new Set(),
currentKey: null,
currentDoc: null,
currentMeta: null,
dirty: false,
activeTab: 'form',
lastBulkPreviewSignature: '',
};
const els = {
brandTitle: $('#brandTitle'), brandSubtitle: $('#brandSubtitle'), healthPill: $('#healthPill'), reloadBtn: $('#reloadBtn'), bulkBtn: $('#bulkBtn'),
searchInput: $('#searchInput'), autoReplyFilter: $('#autoReplyFilter'), languageFilter: $('#languageFilter'),
sourceFilter: $('#sourceFilter'), styleFilter: $('#styleFilter'), selectPage: $('#selectPage'),
selectionCount: $('#selectionCount'), resultList: $('#resultList'), prevPage: $('#prevPage'), nextPage: $('#nextPage'),
pageLabel: $('#pageLabel'), totalLabel: $('#totalLabel'), emptyState: $('#emptyState'), editor: $('#editor'),
filePath: $('#filePath'), dirtyBadge: $('#dirtyBadge'), saveBtn: $('#saveBtn'), formatJsonBtn: $('#formatJsonBtn'),
formTab: $('#formTab'), rawTab: $('#rawTab'), rawEditor: $('#rawEditor'), rawError: $('#rawError'),
bulkDialog: $('#bulkDialog'), bulkTargetText: $('#bulkTargetText'), bulkAllMatching: $('#bulkAllMatching'),
allMatchingHint: $('#allMatchingHint'), bulkPreview: $('#bulkPreview'), previewBulkBtn: $('#previewBulkBtn'),
applyBulkBtn: $('#applyBulkBtn'), toastHost: $('#toastHost')
};
let searchTimer;
async function api(url, options = {}) {
const res = await fetch(url, options);
const contentType = res.headers.get('content-type') || '';
const body = contentType.includes('application/json') ? await res.json() : await res.text();
if (!res.ok) {
const msg = typeof body === 'object' && body?.error ? body.error : `${res.status} ${res.statusText}`;
throw new Error(msg);
}
return body;
}
function currentQuery(page = state.page) {
return {
q: els.searchInput.value.trim(),
auto_reply: els.autoReplyFilter.value,
language: els.languageFilter.value.trim(),
communication_style: els.styleFilter.value.trim(),
source: els.sourceFilter.value.trim(),
page,
page_size: state.pageSize,
};
}
function queryString(q) {
const p = new URLSearchParams();
Object.entries(q).forEach(([k, v]) => {
if (v !== '' && v !== null && v !== undefined && !(k === 'auto_reply' && v === 'any')) p.set(k, v);
});
return p.toString();
}
async function loadHealth() {
try {
const [h, config] = await Promise.all([api('/api/health'), api('/api/config')]);
if (config.title) {
els.brandTitle.textContent = config.title;
document.title = config.title;
}
if (config.subtitle) els.brandSubtitle.textContent = config.subtitle;
els.healthPill.textContent = `${h.count.toLocaleString('de-DE')} Dateien`;
els.healthPill.className = 'pill ok';
els.healthPill.title = `Daten: ${h.data_dir}\nBackups: ${h.backup_dir}`;
} catch (err) {
els.healthPill.textContent = 'Offline';
els.healthPill.className = 'pill';
toast(err.message, 'error');
}
}
async function loadList(resetPage = false) {
if (resetPage) state.page = 1;
els.resultList.innerHTML = '<div class="muted-text" style="padding:16px">Lade …</div>';
try {
const data = await api(`/api/items?${queryString(currentQuery())}`);
state.page = data.page || 1;
state.total = data.total;
state.totalPages = data.total_pages;
state.items = data.items || [];
renderList();
} catch (err) {
els.resultList.innerHTML = `<div class="inline-error" style="margin:12px">${escapeHTML(err.message)}</div>`;
}
}
function renderList() {
els.resultList.innerHTML = '';
if (state.items.length === 0) {
els.resultList.innerHTML = '<div class="muted-text" style="padding:20px;text-align:center">Keine Treffer.</div>';
}
for (const item of state.items) {
const row = document.createElement('div');
row.className = `result-item${item.key === state.currentKey ? ' active' : ''}`;
row.dataset.key = item.key;
const checked = state.selected.has(item.key) ? 'checked' : '';
const auto = item.auto_reply === true;
row.innerHTML = `
<input class="result-check" type="checkbox" ${checked} aria-label="Auswählen">
<div>
<div class="result-id">${escapeHTML(item.id || item.rel_path)}</div>
<div class="result-title">${escapeHTML(item.title || '(ohne Titel)')}</div>
<div class="result-meta">
<span><i class="bool-dot ${auto ? 'true' : ''}"></i>auto ${String(item.auto_reply ?? '')}</span>
<span>score ${item.min_score ?? ''}</span>
<span>${escapeHTML(item.language || '')}</span>
<span>${escapeHTML(item.source || '')}</span>
</div>
</div>`;
const cb = $('.result-check', row);
cb.addEventListener('click', (e) => {
e.stopPropagation();
toggleSelection(item.key, cb.checked);
});
row.addEventListener('click', () => openItem(item.key));
els.resultList.appendChild(row);
}
els.pageLabel.textContent = state.totalPages ? `Seite ${state.page} / ${state.totalPages}` : 'Seite 0 / 0';
els.totalLabel.textContent = `${state.total.toLocaleString('de-DE')} Treffer`;
els.prevPage.disabled = state.page <= 1;
els.nextPage.disabled = state.totalPages === 0 || state.page >= state.totalPages;
els.selectPage.checked = state.items.length > 0 && state.items.every(i => state.selected.has(i.key));
els.selectPage.indeterminate = !els.selectPage.checked && state.items.some(i => state.selected.has(i.key));
updateSelectionUI();
}
function toggleSelection(key, checked) {
if (checked) state.selected.add(key); else state.selected.delete(key);
renderSelectionOnly();
}
function renderSelectionOnly() {
els.selectionCount.textContent = `${state.selected.size.toLocaleString('de-DE')} ausgewählt`;
els.bulkBtn.disabled = state.selected.size === 0 && state.total === 0;
els.selectPage.checked = state.items.length > 0 && state.items.every(i => state.selected.has(i.key));
els.selectPage.indeterminate = !els.selectPage.checked && state.items.some(i => state.selected.has(i.key));
}
function updateSelectionUI() { renderSelectionOnly(); }
async function openItem(key) {
if (key === state.currentKey) return;
if (state.dirty && !confirm('Es gibt ungespeicherte Änderungen. Wirklich einen anderen Eintrag öffnen?')) return;
try {
const data = await api(`/api/items/${encodeURIComponent(key)}`);
state.currentKey = key;
state.currentDoc = data.document;
state.currentMeta = data.meta;
state.dirty = false;
showEditor();
fillForm();
setTab('form');
renderList();
} catch (err) {
toast(err.message, 'error');
}
}
function showEditor() {
els.emptyState.classList.add('hidden');
els.editor.classList.remove('hidden');
els.filePath.textContent = state.currentMeta?.rel_path || '';
setDirty(false);
}
function fillForm() {
$$('[data-field]').forEach(input => {
const name = input.dataset.field;
const val = state.currentDoc?.[name];
if (input.type === 'checkbox') input.checked = Boolean(val);
else input.value = val ?? '';
});
$$('[data-list-field]').forEach(input => {
const val = state.currentDoc?.[input.dataset.listField];
input.value = Array.isArray(val) ? val.join('\n') : '';
});
els.rawEditor.value = JSON.stringify(state.currentDoc, null, 2);
clearRawError();
}
function syncFormToDoc() {
if (!state.currentDoc) return;
$$('[data-field]').forEach(input => {
const name = input.dataset.field;
if (input.type === 'checkbox') state.currentDoc[name] = input.checked;
else if (input.type === 'number') {
if (input.value === '') delete state.currentDoc[name];
else state.currentDoc[name] = Number(input.value);
} else state.currentDoc[name] = input.value;
});
$$('[data-list-field]').forEach(input => {
state.currentDoc[input.dataset.listField] = lines(input.value);
});
}
function syncRawToDoc() {
try {
const parsed = JSON.parse(els.rawEditor.value);
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') throw new Error('Die JSON-Wurzel muss ein Objekt sein.');
state.currentDoc = parsed;
clearRawError();
return true;
} catch (err) {
els.rawError.textContent = `JSON-Fehler: ${err.message}`;
els.rawError.classList.remove('hidden');
return false;
}
}
function clearRawError() {
els.rawError.textContent = '';
els.rawError.classList.add('hidden');
}
function setDirty(v = true) {
state.dirty = v;
els.dirtyBadge.classList.toggle('hidden', !v);
}
function setTab(tab) {
if (tab === state.activeTab) return;
if (state.activeTab === 'raw' && tab === 'form') {
if (!syncRawToDoc()) return;
fillForm();
} else if (state.activeTab === 'form' && tab === 'raw') {
syncFormToDoc();
els.rawEditor.value = JSON.stringify(state.currentDoc, null, 2);
}
state.activeTab = tab;
$$('.tab').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
els.formTab.classList.toggle('active', tab === 'form');
els.rawTab.classList.toggle('active', tab === 'raw');
els.formatJsonBtn.classList.toggle('hidden', tab !== 'raw');
}
async function saveCurrent() {
if (!state.currentKey || !state.currentDoc) return;
if (state.activeTab === 'raw') {
if (!syncRawToDoc()) return;
} else syncFormToDoc();
els.saveBtn.disabled = true;
try {
const result = await api(`/api/items/${encodeURIComponent(state.currentKey)}`, {
method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(state.currentDoc)
});
state.currentMeta = result.meta;
setDirty(false);
toast(`Gespeichert. Backup: ${shortPath(result.backup)}`, 'success');
await loadList(false);
} catch (err) {
toast(err.message, 'error');
} finally {
els.saveBtn.disabled = false;
}
}
function openBulk() {
if (state.selected.size === 0 && state.total === 0) return;
resetBulkPreview();
els.bulkAllMatching.checked = state.selected.size === 0;
updateBulkTargetText();
els.bulkDialog.showModal();
}
function updateBulkTargetText() {
const all = els.bulkAllMatching.checked;
els.bulkTargetText.textContent = all
? `${state.total.toLocaleString('de-DE')} aktuelle Treffer als Ziel`
: `${state.selected.size.toLocaleString('de-DE')} explizit ausgewählte Dateien als Ziel`;
els.allMatchingHint.textContent = `Aktueller Filter: ${state.total.toLocaleString('de-DE')} Treffer`;
}
function buildPatch() {
const patch = {};
const enabled = id => $(`[data-enable="${id}"]`)?.checked;
if (enabled('bulkAutoReply')) patch.set_auto_reply = $('#bulkAutoReply').value === 'true';
if (enabled('bulkMinScore')) patch.set_min_score = Number($('#bulkMinScore').value);
if (enabled('bulkLanguage')) patch.set_language = $('#bulkLanguage').value;
if (enabled('bulkStyle')) patch.set_communication_style = $('#bulkStyle').value;
if (enabled('bulkSource')) patch.set_source = $('#bulkSource').value;
if (enabled('bulkSourceUri')) patch.set_source_uri = $('#bulkSourceUri').value;
const addK = lines($('#addKeywords').value), rmK = lines($('#removeKeywords').value);
const addC = lines($('#addCategories').value), rmC = lines($('#removeCategories').value);
if (addK.length) patch.add_keywords = addK;
if (rmK.length) patch.remove_keywords = rmK;
if (addC.length) patch.add_categories = addC;
if (rmC.length) patch.remove_categories = rmC;
const find = $('#replaceFind').value;
if (find) {
patch.find_replace = {
fields: $$('input[name="replaceField"]:checked').map(x => x.value),
find,
replace: $('#replaceWith').value,
regex: $('#replaceRegex').checked,
case_sensitive: $('#replaceCase').checked,
};
}
return patch;
}
function buildBulkRequest(dryRun) {
const q = currentQuery(1);
q.page = 0; q.page_size = 0;
return {
keys: Array.from(state.selected),
all_matching: els.bulkAllMatching.checked,
query: q,
patch: buildPatch(),
dry_run: dryRun,
};
}
async function previewBulk() {
const req = buildBulkRequest(true);
if (!Object.keys(req.patch).length) {
toast('Bitte mindestens eine Änderung festlegen.', 'error');
return;
}
els.previewBulkBtn.disabled = true;
try {
const result = await api('/api/bulk', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(req)
});
state.lastBulkPreviewSignature = signatureFor(req);
renderBulkPreview(result);
els.applyBulkBtn.disabled = result.changed === 0;
} catch (err) {
els.bulkPreview.textContent = err.message;
els.bulkPreview.className = 'preview-box warn';
els.applyBulkBtn.disabled = true;
} finally {
els.previewBulkBtn.disabled = false;
}
}
function renderBulkPreview(result) {
els.bulkPreview.className = `preview-box ${result.changed > 0 ? 'ok' : 'warn'}`;
els.bulkPreview.innerHTML = `
<strong>Vorschau:</strong> ${result.changed.toLocaleString('de-DE')} von ${result.targeted.toLocaleString('de-DE')} Dateien würden geändert,
${result.skipped.toLocaleString('de-DE')} bleiben unverändert.
${result.sample?.length ? `<div class="preview-samples">${result.sample.map(x => `<code>${escapeHTML(x.id || x.rel_path)} · ${escapeHTML(x.title || '')}</code>`).join('')}</div>` : ''}`;
}
async function applyBulk() {
const req = buildBulkRequest(false);
const sig = signatureFor({...req, dry_run: true});
if (sig !== state.lastBulkPreviewSignature) {
toast('Die Massenänderung wurde seit der Vorschau verändert. Bitte erneut Vorschau ausführen.', 'error');
els.applyBulkBtn.disabled = true;
return;
}
if (!confirm('Massenänderung jetzt wirklich auf die Zieldateien anwenden?')) return;
els.applyBulkBtn.disabled = true;
try {
const result = await api('/api/bulk', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(req)
});
toast(`${result.changed.toLocaleString('de-DE')} Dateien geändert. Backup: ${shortPath(result.backup)}`, 'success');
els.bulkDialog.close();
state.selected.clear();
state.currentKey = null; state.currentDoc = null; state.currentMeta = null; state.dirty = false;
els.editor.classList.add('hidden'); els.emptyState.classList.remove('hidden');
await Promise.all([loadList(true), loadHealth()]);
} catch (err) {
toast(err.message, 'error');
}
}
function resetBulkPreview() {
state.lastBulkPreviewSignature = '';
els.bulkPreview.className = 'preview-box hidden';
els.bulkPreview.textContent = '';
els.applyBulkBtn.disabled = true;
}
function signatureFor(obj) { return JSON.stringify(obj); }
function lines(s) { return s.split(/\r?\n/).map(x => x.trim()).filter(Boolean); }
function shortPath(p) { if (!p) return ''; const parts = p.split('/'); return parts.slice(-2).join('/'); }
function escapeHTML(s) { return String(s ?? '').replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c])); }
function toast(message, type = '') {
const el = document.createElement('div');
el.className = `toast ${type}`;
el.textContent = message;
els.toastHost.appendChild(el);
setTimeout(() => el.remove(), 5000);
}
function debounceReload() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => loadList(true), 240);
}
// Filters and navigation.
[els.searchInput, els.languageFilter, els.sourceFilter, els.styleFilter].forEach(el => el.addEventListener('input', debounceReload));
els.autoReplyFilter.addEventListener('change', () => loadList(true));
els.prevPage.addEventListener('click', () => { if (state.page > 1) { state.page--; loadList(); } });
els.nextPage.addEventListener('click', () => { if (state.page < state.totalPages) { state.page++; loadList(); } });
els.selectPage.addEventListener('change', () => {
for (const item of state.items) {
if (els.selectPage.checked) state.selected.add(item.key); else state.selected.delete(item.key);
}
renderList();
});
// Editor.
$$('[data-field], [data-list-field]').forEach(el => el.addEventListener('input', () => setDirty(true)));
els.rawEditor.addEventListener('input', () => { setDirty(true); clearRawError(); });
$$('.tab').forEach(btn => btn.addEventListener('click', () => setTab(btn.dataset.tab)));
els.saveBtn.addEventListener('click', saveCurrent);
els.formatJsonBtn.addEventListener('click', () => {
if (syncRawToDoc()) {
els.rawEditor.value = JSON.stringify(state.currentDoc, null, 2);
setDirty(true);
}
});
// Bulk modal.
els.bulkBtn.addEventListener('click', openBulk);
els.bulkAllMatching.addEventListener('change', () => { updateBulkTargetText(); resetBulkPreview(); });
$$('[data-enable]').forEach(toggle => toggle.addEventListener('change', () => {
const target = document.getElementById(toggle.dataset.enable);
if (target) target.disabled = !toggle.checked;
resetBulkPreview();
}));
$$('#bulkDialog input, #bulkDialog textarea, #bulkDialog select').forEach(el => {
if (el !== els.bulkAllMatching && !el.hasAttribute('data-enable')) el.addEventListener('input', resetBulkPreview);
});
els.previewBulkBtn.addEventListener('click', previewBulk);
els.applyBulkBtn.addEventListener('click', applyBulk);
els.reloadBtn.addEventListener('click', async () => {
if (state.dirty && !confirm('Ungespeicherte Änderungen verwerfen und Dateien neu einlesen?')) return;
try {
const r = await api('/api/reload', {method: 'POST', headers: {'Content-Type':'application/json'}, body:'{}'});
state.currentKey = null; state.currentDoc = null; state.currentMeta = null; state.dirty = false; state.selected.clear();
els.editor.classList.add('hidden'); els.emptyState.classList.remove('hidden');
toast(`${r.count.toLocaleString('de-DE')} Dateien neu eingelesen.`, 'success');
await Promise.all([loadList(true), loadHealth()]);
} catch (err) { toast(err.message, 'error'); }
});
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { e.preventDefault(); saveCurrent(); }
if (e.key === '/' && !['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) { e.preventDefault(); els.searchInput.focus(); }
});
window.addEventListener('beforeunload', e => { if (state.dirty) { e.preventDefault(); e.returnValue = ''; } });
loadHealth();
loadList(true);
})();

251
cmd/server/web/index.html Normal file
View File

@@ -0,0 +1,251 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>KB Mass Editor</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="app-shell">
<header class="topbar">
<div class="brand">
<div class="brand-mark">KB</div>
<div>
<div id="brandTitle" class="brand-title">Knowledge Base Editor</div>
<div id="brandSubtitle" class="brand-subtitle">JSON · Massenbearbeitung · Docker</div>
</div>
</div>
<div class="top-actions">
<span id="healthPill" class="pill muted">Verbinde …</span>
<button id="reloadBtn" class="btn ghost" title="Dateien neu einlesen">↻ Neu einlesen</button>
<button id="bulkBtn" class="btn primary" disabled>✦ Massenbearbeitung</button>
</div>
</header>
<aside class="sidebar">
<section class="filters">
<label class="search-wrap">
<span></span>
<input id="searchInput" type="search" placeholder="Code, Titel, Text, Keyword …" autocomplete="off">
<kbd>/</kbd>
</label>
<div class="filter-row">
<select id="autoReplyFilter" aria-label="Auto Reply Filter">
<option value="any">auto_reply: alle</option>
<option value="true">auto_reply: true</option>
<option value="false">auto_reply: false</option>
</select>
<input id="languageFilter" placeholder="Sprache, z. B. de-DE">
</div>
<div class="filter-row">
<input id="sourceFilter" placeholder="Quelle enthält …">
<input id="styleFilter" placeholder="Stil, z. B. formal">
</div>
</section>
<section class="list-tools">
<label class="check-label"><input id="selectPage" type="checkbox"> Seite auswählen</label>
<span id="selectionCount" class="muted-text">0 ausgewählt</span>
</section>
<div id="resultList" class="result-list" aria-live="polite"></div>
<footer class="pager">
<button id="prevPage" class="icon-btn" aria-label="Vorherige Seite"></button>
<div>
<strong id="pageLabel">Seite 1</strong>
<span id="totalLabel">0 Treffer</span>
</div>
<button id="nextPage" class="icon-btn" aria-label="Nächste Seite"></button>
</footer>
</aside>
<main class="main-pane">
<div id="emptyState" class="empty-state">
<div class="empty-icon">{ }</div>
<h1>JSON-Wissensbasis bearbeiten</h1>
<p>Wähle links einen Eintrag aus oder markiere mehrere Dateien für eine Massenänderung.</p>
<div class="empty-cards">
<div><strong>Sicher</strong><span>Automatische Backups vor jedem Schreibvorgang</span></div>
<div><strong>Schnell</strong><span>Indexierte Suche auch bei zehntausenden JSON-Dateien</span></div>
<div><strong>Flexibel</strong><span>Formularansicht und vollständiger Raw-JSON-Editor</span></div>
</div>
</div>
<section id="editor" class="editor hidden">
<div class="editor-head">
<div class="breadcrumb">
<span id="filePath"></span>
<span id="dirtyBadge" class="badge warn hidden">Ungespeichert</span>
</div>
<div class="editor-actions">
<button id="formatJsonBtn" class="btn ghost hidden">JSON formatieren</button>
<button id="saveBtn" class="btn success">Speichern <span class="shortcut">Ctrl S</span></button>
</div>
</div>
<div class="tabs" role="tablist">
<button class="tab active" data-tab="form">Formular</button>
<button class="tab" data-tab="raw">Raw JSON</button>
</div>
<div id="formTab" class="tab-panel active">
<div class="form-grid">
<label class="field span-2">
<span>ID</span>
<input data-field="id" placeholder="KB-…">
</label>
<label class="field span-10">
<span>Titel</span>
<input data-field="title" placeholder="Titel des KB-Artikels">
</label>
<label class="field span-12">
<span>Erkennungstext / Problem</span>
<textarea data-field="text" rows="7" placeholder="Beschreibung, Fehlerkontext, Erkennung …"></textarea>
</label>
<label class="field span-12">
<span>Antwort / Lösung</span>
<textarea data-field="answer" rows="10" placeholder="Lösungsschritte …"></textarea>
</label>
<label class="field span-3 switch-field">
<span>Automatische Antwort</span>
<span class="switch-line"><input data-field="auto_reply" type="checkbox"><span>auto_reply</span></span>
</label>
<label class="field span-3">
<span>Min. Score</span>
<input data-field="min_score" type="number" min="0" max="1" step="0.01">
</label>
<label class="field span-3">
<span>Sprache</span>
<input data-field="language" placeholder="de-DE">
</label>
<label class="field span-3">
<span>Kommunikationsstil</span>
<input data-field="communication_style" placeholder="formal">
</label>
<label class="field span-4">
<span>Quelle</span>
<input data-field="source" placeholder="Microsoft Learn">
</label>
<label class="field span-8">
<span>Quell-URL</span>
<input data-field="source_uri" placeholder="https://…">
</label>
<label class="field span-6">
<span>Keywords <small>eine Zeile pro Wert</small></span>
<textarea data-list-field="keywords" rows="7" placeholder="Windows&#10;0x80070005&#10;Zugriff verweigert"></textarea>
</label>
<label class="field span-6">
<span>Kategorien <small>eine Zeile pro Wert</small></span>
<textarea data-list-field="categories" rows="7" placeholder="Windows&#10;Aktivierung"></textarea>
</label>
</div>
</div>
<div id="rawTab" class="tab-panel">
<div id="rawError" class="inline-error hidden"></div>
<textarea id="rawEditor" class="raw-editor" spellcheck="false"></textarea>
</div>
</section>
</main>
</div>
<dialog id="bulkDialog" class="modal">
<form method="dialog" class="modal-card" id="bulkForm">
<header class="modal-head">
<div>
<h2>Massenbearbeitung</h2>
<p id="bulkTargetText"></p>
</div>
<button value="cancel" class="icon-btn" aria-label="Schließen">×</button>
</header>
<div class="modal-body">
<label class="target-choice">
<input id="bulkAllMatching" type="checkbox">
<span><strong>Alle aktuellen Treffer bearbeiten</strong><small id="allMatchingHint"></small></span>
</label>
<div class="bulk-grid">
<div class="bulk-section">
<h3>Felder setzen</h3>
<label class="bulk-control">
<input type="checkbox" data-enable="bulkAutoReply">
<span>auto_reply</span>
<select id="bulkAutoReply" disabled><option value="true">true</option><option value="false">false</option></select>
</label>
<label class="bulk-control">
<input type="checkbox" data-enable="bulkMinScore">
<span>min_score</span>
<input id="bulkMinScore" type="number" min="0" max="1" step="0.01" value="0.78" disabled>
</label>
<label class="bulk-control">
<input type="checkbox" data-enable="bulkLanguage">
<span>language</span>
<input id="bulkLanguage" value="de-DE" disabled>
</label>
<label class="bulk-control">
<input type="checkbox" data-enable="bulkStyle">
<span>communication_style</span>
<input id="bulkStyle" value="formal" disabled>
</label>
<label class="bulk-control">
<input type="checkbox" data-enable="bulkSource">
<span>source</span>
<input id="bulkSource" placeholder="Microsoft Learn" disabled>
</label>
<label class="bulk-control">
<input type="checkbox" data-enable="bulkSourceUri">
<span>source_uri</span>
<input id="bulkSourceUri" placeholder="https://…" disabled>
</label>
</div>
<div class="bulk-section">
<h3>Listen ändern</h3>
<label class="field compact"><span>Keywords hinzufügen</span><textarea id="addKeywords" rows="3" placeholder="ein Wert pro Zeile"></textarea></label>
<label class="field compact"><span>Keywords entfernen</span><textarea id="removeKeywords" rows="3"></textarea></label>
<label class="field compact"><span>Kategorien hinzufügen</span><textarea id="addCategories" rows="3"></textarea></label>
<label class="field compact"><span>Kategorien entfernen</span><textarea id="removeCategories" rows="3"></textarea></label>
</div>
</div>
<div class="bulk-section replace-section">
<h3>Suchen & Ersetzen <small>optional</small></h3>
<div class="replace-grid">
<input id="replaceFind" placeholder="Suchen nach …">
<input id="replaceWith" placeholder="Ersetzen durch …">
</div>
<div class="replace-options">
<label><input type="checkbox" name="replaceField" value="title" checked> Titel</label>
<label><input type="checkbox" name="replaceField" value="text" checked> Text</label>
<label><input type="checkbox" name="replaceField" value="answer" checked> Antwort</label>
<label><input id="replaceRegex" type="checkbox"> Regex</label>
<label><input id="replaceCase" type="checkbox"> Groß-/Kleinschreibung</label>
</div>
</div>
<div id="bulkPreview" class="preview-box muted hidden"></div>
</div>
<footer class="modal-foot">
<span class="muted-text">Vor dem Anwenden wird automatisch ein Backup erstellt.</span>
<div>
<button value="cancel" class="btn ghost">Abbrechen</button>
<button id="previewBulkBtn" type="button" class="btn">Vorschau</button>
<button id="applyBulkBtn" type="button" class="btn danger" disabled>Änderungen anwenden</button>
</div>
</footer>
</form>
</dialog>
<div id="toastHost" class="toast-host" aria-live="polite"></div>
<script src="/app.js" defer></script>
</body>
</html>

199
cmd/server/web/style.css Normal file
View File

@@ -0,0 +1,199 @@
:root {
color-scheme: dark;
--bg: #0b1020;
--panel: #11182b;
--panel-2: #151f35;
--panel-3: #1a2742;
--text: #eef3ff;
--muted: #93a0bb;
--border: #273654;
--accent: #7aa2ff;
--accent-2: #9d8cff;
--success: #47d7a7;
--danger: #ff6f7f;
--warning: #f2be61;
--shadow: 0 20px 60px rgba(0,0,0,.28);
--radius: 14px;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--text); }
body { overflow: hidden; }
button, input, textarea, select { font: inherit; }
button { cursor: pointer; }
.hidden { display: none !important; }
.muted-text { color: var(--muted); font-size: 12px; }
.app-shell {
display: grid;
grid-template-columns: 410px minmax(0, 1fr);
grid-template-rows: 70px calc(100vh - 70px);
min-height: 100vh;
}
.topbar {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
border-bottom: 1px solid var(--border);
background: rgba(11,16,32,.94);
backdrop-filter: blur(14px);
z-index: 5;
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark {
width: 38px; height: 38px; display: grid; place-items: center;
border-radius: 11px; font-weight: 800; letter-spacing: -.04em;
background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: #07101f;
box-shadow: 0 8px 25px rgba(122,162,255,.28);
}
.brand-title { font-size: 15px; font-weight: 750; }
.brand-subtitle { font-size: 11px; color: var(--muted); margin-top: 2px; }
.top-actions { display: flex; align-items: center; gap: 9px; }
.btn, .icon-btn {
border: 1px solid var(--border); color: var(--text); background: var(--panel-2);
border-radius: 9px; padding: 9px 13px; transition: .16s ease;
}
.btn:hover, .icon-btn:hover { border-color: #3a4e76; transform: translateY(-1px); }
.btn:disabled, .icon-btn:disabled { opacity: .42; cursor: not-allowed; transform: none; }
.btn.primary { border-color: transparent; background: linear-gradient(135deg, #5c8eff, #8c72f2); }
.btn.success { border-color: rgba(71,215,167,.35); background: rgba(71,215,167,.13); color: #8cf0cd; }
.btn.danger { border-color: rgba(255,111,127,.35); background: rgba(255,111,127,.13); color: #ff9ca7; }
.btn.ghost { background: transparent; }
.shortcut { opacity: .5; font-size: 10px; margin-left: 5px; }
.icon-btn { width: 36px; height: 36px; padding: 0; font-size: 22px; display: grid; place-items: center; }
.pill, .badge {
display: inline-flex; align-items: center; gap: 6px; border-radius: 999px; padding: 5px 9px;
font-size: 11px; border: 1px solid var(--border); background: rgba(255,255,255,.03);
}
.pill::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.pill.ok { color: var(--success); }
.pill.muted { color: var(--muted); }
.badge.warn { color: var(--warning); border-color: rgba(242,190,97,.25); }
.sidebar {
grid-column: 1;
grid-row: 2;
min-height: 0;
display: grid;
grid-template-rows: auto auto 1fr auto;
border-right: 1px solid var(--border);
background: #0e1526;
}
.filters { padding: 14px; border-bottom: 1px solid var(--border); }
.search-wrap {
display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 8px;
background: var(--panel-2); border: 1px solid var(--border); border-radius: 11px; padding: 0 10px;
}
.search-wrap:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(122,162,255,.08); }
.search-wrap input { border: 0; background: transparent; padding: 11px 0; outline: 0; color: var(--text); min-width: 0; }
kbd { color: var(--muted); border: 1px solid var(--border); padding: 1px 5px; border-radius: 5px; font-size: 10px; }
.filter-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
input, textarea, select {
width: 100%; color: var(--text); background: #0f1728; border: 1px solid var(--border); border-radius: 9px;
padding: 9px 10px; outline: none;
}
input:focus, textarea:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(122,162,255,.07); }
textarea { resize: vertical; line-height: 1.45; }
.list-tools { display: flex; justify-content: space-between; align-items: center; padding: 9px 14px; border-bottom: 1px solid var(--border); }
.check-label { display: flex; align-items: center; gap: 7px; font-size: 12px; color: #c8d2e8; }
.check-label input, .target-choice input, .replace-options input, .bulk-control > input[type="checkbox"] { width: auto; accent-color: var(--accent); }
.result-list { overflow: auto; min-height: 0; }
.result-item {
display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: 9px;
padding: 12px 13px; border-bottom: 1px solid rgba(39,54,84,.72); cursor: pointer; transition: background .12s;
}
.result-item:hover { background: rgba(122,162,255,.055); }
.result-item.active { background: rgba(122,162,255,.11); box-shadow: inset 3px 0 0 var(--accent); }
.result-check { margin-top: 4px; width: auto; accent-color: var(--accent); }
.result-title { font-size: 13px; line-height: 1.32; font-weight: 650; overflow-wrap: anywhere; }
.result-id { font-size: 10px; color: #9bb4e8; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; margin-bottom: 4px; }
.result-meta { display: flex; flex-wrap: wrap; gap: 5px 8px; margin-top: 7px; color: var(--muted); font-size: 10px; }
.bool-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; margin-right: 3px; background: var(--danger); }
.bool-dot.true { background: var(--success); }
.pager { display: grid; grid-template-columns: 36px 1fr 36px; align-items: center; gap: 10px; padding: 11px 13px; border-top: 1px solid var(--border); }
.pager div { text-align: center; display: grid; gap: 2px; }
.pager strong { font-size: 12px; }
.pager span { font-size: 10px; color: var(--muted); }
.main-pane { grid-column: 2; grid-row: 2; overflow: auto; background: radial-gradient(circle at 70% 0%, rgba(103,85,190,.10), transparent 28%), var(--bg); }
.empty-state { min-height: 100%; display: grid; place-content: center; justify-items: center; text-align: center; padding: 40px; }
.empty-icon { font: 700 42px ui-monospace, monospace; color: var(--accent); opacity: .8; }
.empty-state h1 { margin: 12px 0 6px; font-size: 25px; }
.empty-state > p { color: var(--muted); max-width: 580px; }
.empty-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; max-width: 760px; margin-top: 28px; }
.empty-cards div { text-align: left; background: rgba(17,24,43,.72); border: 1px solid var(--border); border-radius: 12px; padding: 14px; }
.empty-cards strong { display: block; font-size: 12px; margin-bottom: 5px; }
.empty-cards span { color: var(--muted); font-size: 11px; line-height: 1.4; }
.editor { min-height: 100%; }
.editor-head { position: sticky; top: 0; z-index: 4; display: flex; justify-content: space-between; align-items: center; padding: 12px 22px; border-bottom: 1px solid var(--border); background: rgba(11,16,32,.92); backdrop-filter: blur(14px); }
.breadcrumb { display: flex; align-items: center; gap: 9px; min-width: 0; }
#filePath { font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 55vw; }
.editor-actions { display: flex; gap: 8px; }
.tabs { display: flex; gap: 5px; padding: 14px 24px 0; }
.tab { border: 0; color: var(--muted); background: transparent; padding: 9px 12px; border-bottom: 2px solid transparent; }
.tab.active { color: var(--text); border-color: var(--accent); }
.tab-panel { display: none; padding: 18px 24px 50px; }
.tab-panel.active { display: block; }
.form-grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 14px; max-width: 1200px; margin: 0 auto; }
.span-2 { grid-column: span 2; } .span-3 { grid-column: span 3; } .span-4 { grid-column: span 4; }
.span-6 { grid-column: span 6; } .span-8 { grid-column: span 8; } .span-10 { grid-column: span 10; } .span-12 { grid-column: span 12; }
.field { display: grid; gap: 6px; min-width: 0; }
.field > span { font-size: 11px; color: #bac6dc; font-weight: 650; }
.field small { color: var(--muted); font-weight: 400; margin-left: 6px; }
.switch-field { align-content: end; }
.switch-line { min-height: 39px; display: flex; align-items: center; gap: 8px; padding: 0 10px; border: 1px solid var(--border); border-radius: 9px; background: #0f1728; }
.switch-line input { width: auto; accent-color: var(--accent); }
.raw-editor { min-height: calc(100vh - 190px); resize: none; font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; tab-size: 2; }
.inline-error { margin-bottom: 10px; padding: 10px 12px; border-radius: 9px; color: #ffb3bb; background: rgba(255,111,127,.09); border: 1px solid rgba(255,111,127,.25); font-size: 12px; }
.modal { width: min(1050px, calc(100vw - 40px)); max-height: calc(100vh - 40px); padding: 0; border: 1px solid var(--border); border-radius: 16px; color: var(--text); background: #0e1628; box-shadow: var(--shadow); }
.modal::backdrop { background: rgba(3,7,15,.72); backdrop-filter: blur(5px); }
.modal-card { display: grid; grid-template-rows: auto 1fr auto; max-height: calc(100vh - 42px); }
.modal-head, .modal-foot { display: flex; justify-content: space-between; align-items: center; padding: 16px 18px; border-bottom: 1px solid var(--border); }
.modal-head h2 { margin: 0; font-size: 18px; }
.modal-head p { margin: 3px 0 0; color: var(--muted); font-size: 11px; }
.modal-body { overflow: auto; padding: 18px; }
.modal-foot { border-bottom: 0; border-top: 1px solid var(--border); gap: 10px; }
.modal-foot > div { display: flex; gap: 8px; }
.target-choice { display: flex; gap: 10px; align-items: flex-start; padding: 12px; border-radius: 11px; background: rgba(122,162,255,.06); border: 1px solid rgba(122,162,255,.18); margin-bottom: 16px; }
.target-choice span { display: grid; gap: 3px; }
.target-choice strong { font-size: 12px; }
.target-choice small { color: var(--muted); }
.bulk-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
.bulk-section { border: 1px solid var(--border); border-radius: 12px; padding: 13px; background: rgba(255,255,255,.018); }
.bulk-section h3 { margin: 0 0 11px; font-size: 12px; }
.bulk-section h3 small { color: var(--muted); font-weight: 400; }
.bulk-control { display: grid; grid-template-columns: 20px 150px 1fr; gap: 8px; align-items: center; margin-top: 8px; font-size: 11px; }
.bulk-control input, .bulk-control select { padding: 7px 8px; }
.field.compact { margin-top: 9px; }
.field.compact textarea { min-height: 60px; padding: 7px 8px; font-size: 11px; }
.replace-section { margin-top: 15px; }
.replace-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
.replace-options { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 10px; color: #c2cce1; font-size: 11px; }
.replace-options label { display: flex; align-items: center; gap: 5px; }
.preview-box { margin-top: 15px; border-radius: 11px; border: 1px solid var(--border); padding: 12px; font-size: 12px; }
.preview-box.ok { color: #a1ebd3; background: rgba(71,215,167,.06); border-color: rgba(71,215,167,.2); }
.preview-box.warn { color: #f6d99e; background: rgba(242,190,97,.06); border-color: rgba(242,190,97,.2); }
.preview-samples { margin-top: 8px; display: grid; gap: 4px; max-height: 150px; overflow: auto; }
.preview-samples code { color: #aec5f8; font-size: 10px; }
.toast-host { position: fixed; right: 16px; bottom: 16px; display: grid; gap: 8px; z-index: 50; pointer-events: none; }
.toast { max-width: 420px; padding: 11px 13px; border-radius: 10px; background: #18243c; border: 1px solid #334869; box-shadow: var(--shadow); font-size: 12px; animation: toast-in .18s ease-out; }
.toast.error { border-color: rgba(255,111,127,.35); color: #ffc0c6; }
.toast.success { border-color: rgba(71,215,167,.35); color: #a1ebd3; }
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } }
@media (max-width: 950px) {
body { overflow: auto; }
.app-shell { grid-template-columns: 1fr; grid-template-rows: 70px minmax(420px, 48vh) auto; }
.topbar { grid-row: 1; }
.sidebar { grid-column: 1; grid-row: 2; border-right: 0; border-bottom: 1px solid var(--border); }
.main-pane { grid-column: 1; grid-row: 3; min-height: 60vh; }
.empty-cards { grid-template-columns: 1fr; }
.span-2,.span-3,.span-4,.span-6,.span-8,.span-10 { grid-column: span 12; }
.bulk-grid { grid-template-columns: 1fr; }
.top-actions .pill { display: none; }
}