refactor(frontend): port search-zen-50 style to Go templates
Replace React SPA with simple Go templates using search-zen-50 visual style. No JavaScript required - pure HTML/CSS with clean teal accent color scheme, monospace logo, and minimal design. - Simplified base.html without HTMX or autocomplete JS - Clean homepage with centered search box - Results page with sticky header and category tabs - Simplified CSS matching search-zen-50 aesthetics Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
168cb78fab
commit
37420ae5a8
5 changed files with 291 additions and 1411 deletions
|
|
@ -19,6 +19,7 @@ package main
|
||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -30,7 +31,7 @@ import (
|
||||||
"github.com/metamorphosis-dev/kafka/internal/httpapi"
|
"github.com/metamorphosis-dev/kafka/internal/httpapi"
|
||||||
"github.com/metamorphosis-dev/kafka/internal/middleware"
|
"github.com/metamorphosis-dev/kafka/internal/middleware"
|
||||||
"github.com/metamorphosis-dev/kafka/internal/search"
|
"github.com/metamorphosis-dev/kafka/internal/search"
|
||||||
"github.com/metamorphosis-dev/kafka/internal/spa"
|
"github.com/metamorphosis-dev/kafka/internal/views"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
@ -80,15 +81,23 @@ func main() {
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// API routes - handled by Go
|
// HTML template routes
|
||||||
mux.HandleFunc("/healthz", h.Healthz)
|
mux.HandleFunc("/", h.Index)
|
||||||
mux.HandleFunc("/search", h.Search)
|
mux.HandleFunc("/search", h.Search)
|
||||||
|
mux.HandleFunc("/preferences", h.Preferences)
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
mux.HandleFunc("/healthz", h.Healthz)
|
||||||
mux.HandleFunc("/autocompleter", h.Autocompleter)
|
mux.HandleFunc("/autocompleter", h.Autocompleter)
|
||||||
mux.HandleFunc("/opensearch.xml", h.OpenSearch(cfg.Server.BaseURL))
|
mux.HandleFunc("/opensearch.xml", h.OpenSearch(cfg.Server.BaseURL))
|
||||||
|
|
||||||
// SPA handler - serves React app for all other routes
|
// Serve embedded static files (CSS, JS, images).
|
||||||
spaHandler := spa.NewHandler()
|
staticFS, err := views.StaticFS()
|
||||||
mux.Handle("/", spaHandler)
|
if err != nil {
|
||||||
|
log.Fatalf("failed to load static files: %v", err)
|
||||||
|
}
|
||||||
|
var subFS fs.FS = staticFS
|
||||||
|
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(subFS))))
|
||||||
|
|
||||||
// Apply middleware: global rate limit → burst rate limit → per-IP rate limit → CORS → security headers → handler.
|
// Apply middleware: global rate limit → burst rate limit → per-IP rate limit → CORS → security headers → handler.
|
||||||
var handler http.Handler = mux
|
var handler http.Handler = mux
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,15 +1,14 @@
|
||||||
{{define "base"}}
|
{{define "base"}}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" data-theme="light">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="referrer" content="no-referrer">
|
<meta name="referrer" content="no-referrer">
|
||||||
<meta name="robots" content="noarchive">
|
<meta name="robots" content="noarchive">
|
||||||
<meta name="description" content="kafka — a privacy-respecting, open metasearch engine">
|
<meta name="description" content="kafka — a privacy-respecting, open metasearch engine">
|
||||||
<title>{{template "title" .}}kafka</title>
|
<title>{{if .Query}}{{.Query}} — {{end}}kafka</title>
|
||||||
<link rel="stylesheet" href="/static/css/kafka.css">
|
<link rel="stylesheet" href="/static/css/kafka.css">
|
||||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
|
||||||
<link rel="icon" href="/static/img/favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="/static/img/favicon.svg" type="image/svg+xml">
|
||||||
<link title="kafka" type="application/opensearchdescription+xml" rel="search" href="/opensearch.xml">
|
<link title="kafka" type="application/opensearchdescription+xml" rel="search" href="/opensearch.xml">
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -22,12 +21,6 @@
|
||||||
</svg>
|
</svg>
|
||||||
<span class="site-name">kafka</span>
|
<span class="site-name">kafka</span>
|
||||||
</a>
|
</a>
|
||||||
<button id="settings-trigger" class="settings-trigger" aria-label="Preferences" aria-expanded="false" aria-controls="settings-popover">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
|
|
||||||
<circle cx="12" cy="12" r="3"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="{{if .Query}}page-results{{else}}page-home{{end}}">
|
<main class="{{if .Query}}page-results{{else}}page-home{{end}}">
|
||||||
|
|
@ -37,133 +30,6 @@
|
||||||
<footer>
|
<footer>
|
||||||
<p>Powered by <a href="https://git.ashisgreat.xyz/penal-colony/kafka">kafka</a> — a privacy-respecting, open metasearch engine{{if .SourceURL}} · <a href="{{.SourceURL}}">Source</a>{{end}} · <a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPLv3</a></p>
|
<p>Powered by <a href="https://git.ashisgreat.xyz/penal-colony/kafka">kafka</a> — a privacy-respecting, open metasearch engine{{if .SourceURL}} · <a href="{{.SourceURL}}">Source</a>{{end}} · <a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPLv3</a></p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="/static/js/settings.js"></script>
|
|
||||||
|
|
||||||
<div id="settings-popover" data-open="false" role="dialog" aria-label="Preferences" aria-modal="true">
|
|
||||||
<div class="settings-popover-header">
|
|
||||||
Preferences
|
|
||||||
<button class="settings-popover-close" aria-label="Close">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="settings-popover-body"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="autocomplete-dropdown"></div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
var input = document.getElementById('q');
|
|
||||||
var dropdown = document.getElementById('autocomplete-dropdown');
|
|
||||||
var form = document.getElementById('search-form');
|
|
||||||
var debounceTimer = null;
|
|
||||||
var suggestions = [];
|
|
||||||
var activeIndex = -1;
|
|
||||||
var fetchController = null;
|
|
||||||
|
|
||||||
function escapeRegex(str) {
|
|
||||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
}
|
|
||||||
|
|
||||||
function highlight(text, query) {
|
|
||||||
if (!query) return text;
|
|
||||||
var re = new RegExp('^(' + escapeRegex(query) + ')', 'i');
|
|
||||||
return text.replace(re, '<mark>$1</mark>');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDropdown() {
|
|
||||||
if (suggestions.length === 0) {
|
|
||||||
dropdown.classList.remove('open');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var html = '';
|
|
||||||
for (var i = 0; i < suggestions.length; i++) {
|
|
||||||
var escaped = highlight(suggestions[i], input.value);
|
|
||||||
html += '<div class="autocomplete-suggestion" data-index="' + i + '">' + escaped + '</div>';
|
|
||||||
}
|
|
||||||
html += '<div class="autocomplete-footer">↑↓ navigate · Enter select · Esc close</div>';
|
|
||||||
dropdown.innerHTML = html;
|
|
||||||
dropdown.classList.add('open');
|
|
||||||
activeIndex = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeDropdown() {
|
|
||||||
dropdown.classList.remove('open');
|
|
||||||
suggestions = [];
|
|
||||||
activeIndex = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectSuggestion(index) {
|
|
||||||
if (index < 0 || index >= suggestions.length) return;
|
|
||||||
input.value = suggestions[index];
|
|
||||||
closeDropdown();
|
|
||||||
form.submit();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateActive(newIndex) {
|
|
||||||
var items = dropdown.querySelectorAll('.autocomplete-suggestion');
|
|
||||||
items.forEach(function (el) { el.classList.remove('active'); });
|
|
||||||
if (newIndex >= 0 && newIndex < items.length) {
|
|
||||||
items[newIndex].classList.add('active');
|
|
||||||
items[newIndex].scrollIntoView({ block: 'nearest' });
|
|
||||||
}
|
|
||||||
activeIndex = newIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchSuggestions(query) {
|
|
||||||
if (fetchController) fetchController.abort();
|
|
||||||
fetchController = new AbortController();
|
|
||||||
fetch('/autocompleter?q=' + encodeURIComponent(query), { signal: fetchController.signal })
|
|
||||||
.then(function (r) { return r.json(); })
|
|
||||||
.then(function (data) {
|
|
||||||
suggestions = data || [];
|
|
||||||
renderDropdown();
|
|
||||||
})
|
|
||||||
.catch(function (e) {
|
|
||||||
if (e.name !== 'AbortError') suggestions = [];
|
|
||||||
dropdown.classList.remove('open');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
input.addEventListener('input', function () {
|
|
||||||
clearTimeout(debounceTimer);
|
|
||||||
var q = input.value.trim();
|
|
||||||
if (q.length < 2) { closeDropdown(); return; }
|
|
||||||
debounceTimer = setTimeout(function () { fetchSuggestions(q); }, 250);
|
|
||||||
});
|
|
||||||
|
|
||||||
input.addEventListener('keydown', function (e) {
|
|
||||||
if (!dropdown.classList.contains('open')) return;
|
|
||||||
var items = dropdown.querySelectorAll('.autocomplete-suggestion');
|
|
||||||
if (e.key === 'ArrowDown') {
|
|
||||||
e.preventDefault();
|
|
||||||
updateActive(Math.min(activeIndex + 1, items.length - 1));
|
|
||||||
} else if (e.key === 'ArrowUp') {
|
|
||||||
e.preventDefault();
|
|
||||||
updateActive(Math.max(activeIndex - 1, -1));
|
|
||||||
} else if (e.key === 'Enter' && activeIndex >= 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
selectSuggestion(activeIndex);
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
closeDropdown();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
input.addEventListener('blur', function () {
|
|
||||||
setTimeout(closeDropdown, 150);
|
|
||||||
});
|
|
||||||
|
|
||||||
dropdown.addEventListener('mousedown', function (e) {
|
|
||||||
var item = e.target.closest('.autocomplete-suggestion');
|
|
||||||
if (item) {
|
|
||||||
e.preventDefault();
|
|
||||||
var idx = parseInt(item.getAttribute('data-index'), 10);
|
|
||||||
selectSuggestion(idx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}());
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,25 @@
|
||||||
{{define "title"}}{{end}}
|
{{define "title"}}{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="search-hero">
|
<div class="home-container">
|
||||||
<div class="hero-logo">
|
<a href="/" class="home-logo">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<circle cx="11" cy="11" r="8"/>
|
<circle cx="11" cy="11" r="8"/>
|
||||||
<path d="m21 21-4.35-4.35"/>
|
<path d="m21 21-4.35-4.35"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
<span class="home-logo-text">kafka</span>
|
||||||
<p class="hero-tagline">Search the web privately, without tracking or censorship.</p>
|
</a>
|
||||||
|
<p class="home-tagline">Private meta-search, powered by open source.</p>
|
||||||
|
|
||||||
|
<form class="search-form" method="GET" action="/search" role="search">
|
||||||
<div class="search-box">
|
<div class="search-box">
|
||||||
<form method="GET" action="/search" role="search" id="search-form">
|
<input type="text" name="q" placeholder="Search the web…" autocomplete="off" autofocus>
|
||||||
<input type="text" name="q" id="q" placeholder="Search the web…" autocomplete="off" autofocus>
|
<button type="submit" class="search-btn" aria-label="Search">
|
||||||
<button type="submit" class="search-box-submit" aria-label="Search">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<circle cx="11" cy="11" r="8"/>
|
<circle cx="11" cy="11" r="8"/>
|
||||||
<path d="m21 21-4.35-4.35"/>
|
<path d="m21 21-4.35-4.35"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Category Tiles -->
|
|
||||||
<div class="category-tiles">
|
|
||||||
<a href="/search" class="category-tile">
|
|
||||||
<span class="category-tile-icon">🌐</span>
|
|
||||||
<span>All</span>
|
|
||||||
</a>
|
|
||||||
<a href="/search?category=images" class="category-tile">
|
|
||||||
<span class="category-tile-icon">🖼️</span>
|
|
||||||
<span>Images</span>
|
|
||||||
</a>
|
|
||||||
<a href="/search?category=news" class="category-tile">
|
|
||||||
<span class="category-tile-icon">📰</span>
|
|
||||||
<span>News</span>
|
|
||||||
</a>
|
|
||||||
<a href="/search?category=videos" class="category-tile">
|
|
||||||
<span class="category-tile-icon">🎬</span>
|
|
||||||
<span>Videos</span>
|
|
||||||
</a>
|
|
||||||
<a href="/search?category=maps" class="category-tile">
|
|
||||||
<span class="category-tile-icon">🗺️</span>
|
|
||||||
<span>Maps</span>
|
|
||||||
</a>
|
|
||||||
<a href="/search?category=shopping" class="category-tile disabled" title="Coming soon">
|
|
||||||
<span class="category-tile-icon">🛒</span>
|
|
||||||
<span>Shopping</span>
|
|
||||||
</a>
|
|
||||||
<a href="/search?category=music" class="category-tile disabled" title="Coming soon">
|
|
||||||
<span class="category-tile-icon">🎵</span>
|
|
||||||
<span>Music</span>
|
|
||||||
</a>
|
|
||||||
<a href="/search?category=weather" class="category-tile disabled" title="Coming soon">
|
|
||||||
<span class="category-tile-icon">🌤️</span>
|
|
||||||
<span>Weather</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="results"></div>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
@ -1,58 +1,38 @@
|
||||||
{{define "title"}}{{if .Query}}{{.Query}} — {{end}}{{end}}
|
{{define "title"}}{{if .Query}}{{.Query}} — {{end}}{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="results-layout">
|
<div class="results-container">
|
||||||
<!-- Compact search bar spans all columns -->
|
<div class="results-header">
|
||||||
<div class="search-compact">
|
<a href="/" class="results-logo">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="11" cy="11" r="8"/>
|
||||||
|
<path d="m21 21-4.35-4.35"/>
|
||||||
|
</svg>
|
||||||
|
<span>kafka</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<form class="header-search" method="GET" action="/search" role="search">
|
||||||
<div class="search-box">
|
<div class="search-box">
|
||||||
<form method="GET" action="/search" role="search" id="search-form">
|
<input type="text" name="q" value="{{.Query}}" placeholder="Search…" autocomplete="off">
|
||||||
<input type="text" name="q" id="q" value="{{.Query}}" autocomplete="off" autofocus
|
<button type="submit" class="search-btn" aria-label="Search">
|
||||||
hx-get="/search" hx-target="#results" hx-trigger="keyup changed delay:500ms" hx-include="this">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<button type="submit" class="search-box-submit" aria-label="Search">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<circle cx="11" cy="11" r="8"/>
|
<circle cx="11" cy="11" r="8"/>
|
||||||
<path d="m21 21-4.35-4.35"/>
|
<path d="m21 21-4.35-4.35"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="category-tabs" role="tablist">
|
||||||
|
<a href="/search?q={{.Query | urlquery}}&category=" class="category-tab {{if eq .ActiveCategory ""}}active{{end}}">All</a>
|
||||||
|
<a href="/search?q={{.Query | urlquery}}&category=general" class="category-tab {{if eq .ActiveCategory "general"}}active{{end}}">General</a>
|
||||||
|
<a href="/search?q={{.Query | urlquery}}&category=it" class="category-tab {{if eq .ActiveCategory "it"}}active{{end}}">IT</a>
|
||||||
|
<a href="/search?q={{.Query | urlquery}}&category=news" class="category-tab {{if eq .ActiveCategory "news"}}active{{end}}">News</a>
|
||||||
|
<a href="/search?q={{.Query | urlquery}}&category=images" class="category-tab {{if eq .ActiveCategory "images"}}active{{end}}">Images</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Center: Results -->
|
<div class="results-content">
|
||||||
<div class="results-column" id="results">
|
|
||||||
{{template "results_inner" .}}
|
{{template "results_inner" .}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: Knowledge Panel + Related Searches -->
|
|
||||||
<aside class="right-column" id="right-column">
|
|
||||||
{{if and .Infoboxes (gt (len .Infoboxes) 0)}}
|
|
||||||
{{with index .Infoboxes 0}}
|
|
||||||
<div class="knowledge-panel">
|
|
||||||
{{if .img_src}}<img class="knowledge-panel-thumb" src="{{.img_src}}" alt="{{.title}}" loading="lazy">{{end}}
|
|
||||||
{{if .title}}<div class="knowledge-panel-title">{{.title}}</div>{{end}}
|
|
||||||
{{if .content}}<div class="knowledge-panel-content">{{.content}}</div>{{end}}
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{if .Suggestions}}
|
|
||||||
<div class="sidebar-card">
|
|
||||||
<div class="sidebar-card-title">Related Searches</div>
|
|
||||||
<div class="related-searches">
|
|
||||||
{{range .Suggestions}}
|
|
||||||
<a class="related-search-link" href="/search?q={{. | urlquery}}">{{.}}</a>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{if .UnresponsiveEngines}}
|
|
||||||
<div class="sidebar-card">
|
|
||||||
<div class="sidebar-card-title">Engine Status</div>
|
|
||||||
<ul class="unresponsive-engines">
|
|
||||||
{{range .UnresponsiveEngines}}<li>{{index . 0}}: {{index . 1}}</li>{{end}}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue