2
0

feat(ui): add chip-based keyword input for pages SEO

Replace plain text input with interactive chip UI for SEO keywords in repository pages settings. Users can add keywords individually with validation, and remove them by clicking the × button. Backend now trims whitespace from keywords. Improves UX for managing keyword lists.
This commit is contained in:
2026-03-15 22:00:42 -04:00
parent a2644bb47f
commit 82eddb0b09
4 changed files with 78 additions and 2 deletions

View File

@@ -725,6 +725,8 @@
"repo.settings.pages.seo_title": "SEO Title",
"repo.settings.pages.seo_description": "Meta Description",
"repo.settings.pages.seo_keywords": "Keywords",
"repo.settings.pages.seo_keywords.placeholder": "Add a keyword...",
"repo.settings.pages.seo_keywords.add": "Add",
"repo.settings.pages.og_image": "Open Graph Image URL",
"repo.vault.plugin_not_installed": "Vault Plugin Not Installed",
"repo.vault.plugin_not_installed_desc": "The Vault plugin is not installed on this server. Contact your administrator to enable secrets management.",

View File

@@ -4493,6 +4493,8 @@
"repo.settings.pages.seo_title": "SEO Title",
"repo.settings.pages.seo_description": "Meta Description",
"repo.settings.pages.seo_keywords": "Keywords",
"repo.settings.pages.seo_keywords.placeholder": "Add a keyword...",
"repo.settings.pages.seo_keywords.add": "Add",
"repo.settings.pages.og_image": "Open Graph Image URL",
"repo.settings.pages.brand_favicon_url": "Favicon URL",
"repo.settings.pages.brand_favicon_url_help": "URL to a custom favicon for your landing page (ICO, PNG, or SVG). Leave blank to use the default.",

View File

@@ -479,7 +479,13 @@ func PagesThemePost(ctx *context.Context) {
config.SEO.Description = ctx.FormString("seo_description")
keywords := ctx.FormString("seo_keywords")
if keywords != "" {
config.SEO.Keywords = strings.Split(keywords, ",")
parts := strings.Split(keywords, ",")
config.SEO.Keywords = make([]string, 0, len(parts))
for _, kw := range parts {
if trimmed := strings.TrimSpace(kw); trimmed != "" {
config.SEO.Keywords = append(config.SEO.Keywords, trimmed)
}
}
} else {
config.SEO.Keywords = nil
}

View File

@@ -73,7 +73,15 @@
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pages.seo_keywords"}}</label>
<input name="seo_keywords" value="{{.Config.SEO.Keywords}}" placeholder="keyword1, keyword2, keyword3">
<input type="hidden" name="seo_keywords" id="seo-keywords-hidden"
value="{{StringUtils.Join .Config.SEO.Keywords ","}}">
<div id="seo-keywords-chips" style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px;"></div>
<div class="tw-flex tw-gap-2">
<input type="text" id="seo-keyword-input"
placeholder="{{ctx.Locale.Tr "repo.settings.pages.seo_keywords.placeholder"}}"
class="tw-flex-1" style="font-size:13px;" maxlength="50">
<button class="ui small button" type="button" id="seo-keyword-add">{{ctx.Locale.Tr "repo.settings.pages.seo_keywords.add"}}</button>
</div>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pages.og_image"}}</label>
@@ -87,6 +95,64 @@
</div>
</div>
<script>
// === Keyword chips ===
(function() {
const hiddenInput = document.getElementById('seo-keywords-hidden');
const chipsContainer = document.getElementById('seo-keywords-chips');
const kwInput = document.getElementById('seo-keyword-input');
const addBtn = document.getElementById('seo-keyword-add');
function getKeywords() {
const val = hiddenInput.value.trim();
if (!val) return [];
return val.split(',').map(t => t.trim()).filter(Boolean);
}
function setKeywords(keywords) {
hiddenInput.value = keywords.join(',');
}
function renderChips() {
chipsContainer.innerHTML = '';
getKeywords().forEach(function(kw) {
const chip = document.createElement('span');
chip.className = 'ui small label';
chip.style.cssText = 'display:inline-flex;align-items:center;gap:4px;margin:0;';
chip.textContent = kw;
const x = document.createElement('button');
x.type = 'button';
x.style.cssText = 'background:none;border:none;cursor:pointer;padding:0;margin:0;font-size:14px;line-height:1;color:inherit;opacity:0.7;';
x.innerHTML = '&times;';
x.addEventListener('click', function() {
setKeywords(getKeywords().filter(t => t !== kw));
renderChips();
});
chip.appendChild(x);
chipsContainer.appendChild(chip);
});
}
function addKeyword() {
const val = kwInput.value.trim();
if (!val) return;
const keywords = getKeywords();
if (!keywords.includes(val)) {
keywords.push(val);
setKeywords(keywords);
renderChips();
}
kwInput.value = '';
kwInput.focus();
}
addBtn.addEventListener('click', addKeyword);
kwInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); addKeyword(); }
});
renderChips();
})();
function updateColorPreview(type) {
const colorInput = document.getElementById(type + '_color');
const preview = document.getElementById(type + '_preview');