Changeset 746d98a in Klonkt
- Timestamp:
- 09/30/2026 09:08:48 AM (80 minutes ago)
- Branches:
- main
- Parents:
- 31680c3
- Files:
-
- 4 added
- 14 edited
-
src/assets/js/mod/admin-site-edit.js (modified) (1 diff)
-
src/assets/js/mod/profile-links.js (added)
-
src/middleware/render.js (modified) (1 diff)
-
src/middleware/ward-profile.js (added)
-
src/routes/account.js (modified) (4 diffs)
-
src/routes/admin-sites.js (modified) (7 diffs)
-
src/server.js (modified) (2 diffs)
-
src/services/PlatformIcons.js (modified) (1 diff)
-
src/services/i18n.js (modified) (3 diffs)
-
src/views/pages/account.ejs (modified) (1 diff)
-
src/views/pages/admin-site-edit.ejs (modified) (2 diffs)
-
src/views/pages/ward-profile.ejs (added)
-
src/views/partials/bottom-tab.ejs (modified) (2 diffs)
-
src/views/partials/chrome.ejs (modified) (1 diff)
-
src/views/partials/profile-header.ejs (modified) (5 diffs)
-
src/views/partials/topnav.ejs (modified) (2 diffs)
-
src/views/shell.ejs (modified) (1 diff)
-
test/ward-profile.test.js (added)
Legend:
- Unmodified
- Added
- Removed
-
src/assets/js/mod/admin-site-edit.js
r31680c3 r746d98a 42 42 if (sh) sh.textContent = location.host + (sh.dataset.prefix || '/'); 43 43 })(); 44 (function() { 45 // Profile-links repeater: add row from <template>, remove on click. 46 var rows = document.getElementById('profile-links-rows'); 47 var tpl = document.getElementById('profile-link-template'); 48 var add = document.getElementById('profile-link-add'); 49 if (!rows || !tpl || !add) return; 50 51 add.addEventListener('click', function() { 52 var clone = tpl.content.cloneNode(true); 53 rows.appendChild(clone); 54 }); 55 rows.addEventListener('click', function(e) { 56 if (e.target && e.target.classList.contains('pl-remove')) { 57 var row = e.target.closest('.profile-link-row'); 58 if (row) row.remove(); 59 } 60 }); 61 })(); 44 // De profiellinks staan sinds 30-9 bij je profiel: zie mod/profile-links.js. 62 45 63 46 // P63 — Profile photo picker: upload via /admin/sites/upload-photo, -
src/middleware/render.js
r31680c3 r746d98a 213 213 audioTracks: data.audioTracks || res.locals.audioTracks || [], 214 214 siteUrlBase: res.locals.siteUrlBase || '', 215 // De wardmodus (middleware/ward-profile.js): een bezoeker van een ward-site ziet 216 // alleen het profiel, en de chrome biedt dan niets aan wat toch dicht is. 217 wardProfile: !!res.locals.wardProfile, 215 218 footerNewsletter: getSetting('footer_newsletter') === '1', // newsletter sign-up in footer (premium) 216 219 agendaEnabled: getSetting('agenda_enabled') === '1', // show agenda/events in the pill (premium, opt-in) -
src/routes/account.js
r31680c3 r746d98a 14 14 15 15 import express from 'express'; 16 import { listPlatforms, buildProfileLinks, parseProfileLinks } from '../services/PlatformIcons.js'; 16 17 import path from 'path'; 17 18 import fs from 'fs'; … … 69 70 pageTitle: 'Account', 70 71 bodyClass: 'on-special', 72 // De profiellinks staan sinds 30-9 hier, bij je profiel, en niet meer op de 73 // Appearance-pagina (Robins verzoek). 74 pageJs: 'profile-links', 75 platforms: listPlatforms(), 76 profileLinks: editableSite ? parseProfileLinks(editableSite.profile_links) : [], 71 77 account, 72 78 hasPassword, … … 106 112 function ownedSite(user) { 107 113 if (!user) return null; 108 let site = db.prepare('SELECT id, title, tagline, slug, owner_id, profile_photo FROM sites WHERE owner_id = ? ORDER BY created_at LIMIT 1').get(user.id); 114 // profile_links hoort erbij sinds de links hier staan (30-9). Zonder die kolom 115 // toonde de pagina nul links, en wiste opslaan ze dus. 116 let site = db.prepare('SELECT id, title, tagline, slug, owner_id, profile_photo, profile_links FROM sites WHERE owner_id = ? ORDER BY created_at LIMIT 1').get(user.id); 109 117 if (!site && user.role === 'god') { 110 118 site = getPrimarySite(); // primary/main site as fallback … … 128 136 }); 129 137 138 // ==================== PROFIELLINKS ==================== 139 // Sinds 30-9 hier en niet meer op Appearance: ze horen bij je profiel, naast je 140 // naam, bio en foto. Dezelfde rechten als de site-naam hierboven: de eigenaar 141 // van de site, of god. 142 router.post('/links', requireAuth, (req, res) => { 143 const site = ownedSite(req.session.user); 144 if (!site) return res.redirect('/account?error=' + encodeURIComponent('Geen site om te bewerken.')); 145 if (site.owner_id !== req.session.user.id && req.session.user.role !== 'god') { 146 return res.redirect('/account?error=' + encodeURIComponent('Geen rechten om deze site te bewerken.')); 147 } 148 db.prepare('UPDATE sites SET profile_links = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?') 149 .run(buildProfileLinks(req.body), site.id); 150 res.redirect('/account?success=' + encodeURIComponent('Links bijgewerkt') + '#links'); 151 }); 152 130 153 // ==================== UPDATE BIO ==================== 131 154 const RESERVED_USERNAMES = new Set(['admin', 'account', 'auth', 'login', 'register', 'logout', 'user', 'users', 'api', 'fediverse', 'posts', 'media', 'audio', 'assets', 'cirkel', 'authorize_interaction']); -
src/routes/admin-sites.js
r31680c3 r746d98a 55 55 }); 56 56 57 /** Coerce req.body fields into the JSON profile_links array. */58 function buildProfileLinks(body) {59 const platforms = body.profile_link_platform || [];60 const urls = body.profile_link_url || [];61 const arr = [];62 const platformsArr = Array.isArray(platforms) ? platforms : [platforms];63 const urlsArr = Array.isArray(urls) ? urls : [urls];64 for (let i = 0; i < platformsArr.length; i++) {65 const p = (platformsArr[i] || '').toString().trim();66 const u = (urlsArr[i] || '').toString().trim();67 if (!p || !u) continue;68 if (!PLATFORMS[p]) continue;69 if (!/^https?:\/\//i.test(u) && p !== 'email') continue;70 if (p === 'email' && !/^mailto:|^[^\s@]+@[^\s@]+$/i.test(u)) continue;71 arr.push({ platform: p, url: u });72 }73 return arr.length ? JSON.stringify(arr) : null;74 }75 57 76 58 /** … … 211 193 accents: ThemeService.listAccents(), 212 194 platforms: listPlatforms(), 213 parsedLinks: [],214 195 apAliases: '', 215 196 error: null, … … 275 256 if (!site) return res.redirect('/admin/sites?error=Not+found'); 276 257 277 let parsedLinks = [];278 if (site.profile_links) {279 try { parsedLinks = JSON.parse(site.profile_links) || []; } catch {}280 }281 258 282 259 let apAliases = ''; … … 293 270 accents: ThemeService.listAccents(), 294 271 platforms: listPlatforms(), 295 parsedLinks,296 272 apAliases, 297 273 success: req.query.success || null, … … 333 309 const feedAlt = ['timeline', 'auto'].includes(f.feed_alt_view) ? f.feed_alt_view : 'reader'; 334 310 const feedViewDef = f.feed_view_default === 'grid' ? 'grid' : feedAlt; 335 const profileLinksJson = buildProfileLinks(f); 311 // De profiellinks staan sinds 30-9 bij je profiel (/account). Deze pagina 312 // schrijft ze dus NIET meer: met de velden weg zou elke keer opslaan hier ze 313 // wissen. De kolom blijft van /account/links. 336 314 337 315 // FEP-7628 aliases — validated/resolved before anything is written. … … 366 344 palette = ?, accent = ?, theme_override = ?, profile_photo = ?, 367 345 profile_enabled = ?, 368 profile_links = ?,369 346 ap_aliases = ?, 370 347 is_public = ?, robots_index = ?, require_login_to_comment = ?, … … 386 363 f.profile_photo || null, 387 364 f.profile_enabled ? 1 : 0, 388 profileLinksJson,389 365 apAliasesJson, 390 366 f.is_public ? 1 : 0, -
src/server.js
r31680c3 r746d98a 22 22 import { getThumbnail, getRemoteThumbnail, verifyImg, THUMB_SIZES } from './services/ThumbnailService.js'; 23 23 import { resolveSite, loadAudioTracks, loadTheme } from './middleware/site.js'; 24 import { wardProfileGate } from './middleware/ward-profile.js'; 24 25 import { isViewer } from './middleware/auth.js'; 25 26 import { renderPage } from './middleware/render.js'; … … 396 397 }); 397 398 399 // De wardmodus (30-9): heeft deze Klonkt guardians, dan ziet een bezoeker alleen 400 // het profiel. Hier en niet eerder: de federatie, OAuth, /media en /assets hangen 401 // hierboven en moeten gewoon blijven werken. Zie middleware/ward-profile.js. 402 app.use(wardProfileGate); 398 403 app.use('/auth', authRoutes); 399 404 app.use('/account', accountRoutes); -
src/services/PlatformIcons.js
r31680c3 r746d98a 127 127 } 128 128 129 export default { PLATFORMS, PLATFORM_ORDER, listPlatforms, CONTACT_PLATFORMS, linkHref }; 129 /** 130 * De profiellinks uit een formulier (profile_link_platform[] + profile_link_url[]) 131 * als de JSON die in sites.profile_links staat, of null als er niets over is. 132 * 133 * Stond in routes/admin-sites.js, toen de links op de Appearance-pagina stonden. 134 * Sinds 30-9 bewerk je ze bij je profiel (/account), en een tweede kopie van 135 * deze keuring loopt gegarandeerd uit de pas -- dus hij staat hier. 136 */ 137 export function buildProfileLinks(body) { 138 const platforms = (body && body.profile_link_platform) || []; 139 const urls = (body && body.profile_link_url) || []; 140 const arr = []; 141 const platformsArr = Array.isArray(platforms) ? platforms : [platforms]; 142 const urlsArr = Array.isArray(urls) ? urls : [urls]; 143 for (let i = 0; i < platformsArr.length; i++) { 144 const p = (platformsArr[i] || '').toString().trim(); 145 const u = (urlsArr[i] || '').toString().trim(); 146 if (!p || !u) continue; 147 if (!PLATFORMS[p]) continue; 148 if (!/^https?:\/\//i.test(u) && p !== 'email') continue; 149 if (p === 'email' && !/^mailto:|^[^\s@]+@[^\s@]+$/i.test(u)) continue; 150 arr.push({ platform: p, url: u }); 151 } 152 return arr.length ? JSON.stringify(arr) : null; 153 } 154 155 /** sites.profile_links terug als lijst; een kapotte waarde is een lege lijst. */ 156 export function parseProfileLinks(json) { 157 if (!json) return []; 158 try { const a = JSON.parse(json); return Array.isArray(a) ? a : []; } catch { return []; } 159 } 160 161 export default { PLATFORMS, PLATFORM_ORDER, listPlatforms, CONTACT_PLATFORMS, linkHref, buildProfileLinks, parseProfileLinks }; -
src/services/i18n.js
r31680c3 r746d98a 843 843 'acct.tagline': 'Tagline', 844 844 'acct.tagline_hint': '— korte oneliner (optioneel)', 845 ' acct.site_save': 'Site opslaan',845 'ward.followers_only': 'Berichten van {name} zijn alleen te zien voor volgers.', 'asite.links_moved': 'Je profiellinks staan nu bij je profiel.', 'asite.links_moved_link': 'Naar je profiel', 'acct.links_save': 'Links opslaan', 'acct.link_platform': 'Platform', 'acct.link_url': 'Adres', 'acct.link_remove': 'Link weghalen', 'acct.site_save': 'Site opslaan', 846 846 'acct.password_change': 'Wachtwoord wijzigen', 847 847 'acct.password_current': 'Huidig wachtwoord', … … 1852 1852 'acct.tagline': 'Tagline', 1853 1853 'acct.tagline_hint': '— short one-liner (optional)', 1854 ' acct.site_save': 'Save site',1854 'ward.followers_only': 'Posts by {name} are only visible to followers.', 'asite.links_moved': 'Your profile links now live with your profile.', 'asite.links_moved_link': 'Go to your profile', 'acct.links_save': 'Save links', 'acct.link_platform': 'Platform', 'acct.link_url': 'Address', 'acct.link_remove': 'Remove link', 'acct.site_save': 'Save site', 1855 1855 'acct.password_change': 'Change password', 1856 1856 'acct.password_current': 'Current password', … … 2861 2861 'acct.tagline': 'Slogan', 2862 2862 'acct.tagline_hint': '— kurzer Einzeiler (optional)', 2863 ' acct.site_save': 'Seite speichern',2863 'ward.followers_only': 'Beiträge von {name} sind nur für Follower sichtbar.', 'asite.links_moved': 'Deine Profil-Links findest du jetzt bei deinem Profil.', 'asite.links_moved_link': 'Zum Profil', 'acct.links_save': 'Links speichern', 'acct.link_platform': 'Plattform', 'acct.link_url': 'Adresse', 'acct.link_remove': 'Link entfernen', 'acct.site_save': 'Seite speichern', 2864 2864 'acct.password_change': 'Passwort ändern', 2865 2865 'acct.password_current': 'Aktuelles Passwort', -
src/views/pages/account.ejs
r31680c3 r746d98a 113 113 </form> 114 114 </section> 115 <% } %> 116 117 <%# ── PROFIELLINKS ─────────────────────────────────────────── 118 Sinds 30-9 hier, bij je profiel, en niet meer op de Appearance-pagina: ze 119 horen bij je naam, bio en foto. In de wardmodus is dat profiel het enige 120 dat een bezoeker van de site ziet. %> 121 <% if (typeof editableSite !== 'undefined' && editableSite && canMutate) { %> 122 <section class="ax-card" id="links"> 123 <div class="ax-card-title"><%= t('asite.links') %></div> 124 <p class="form-hint"><%= t('asite.links_hint') %></p> 125 <form action="/account/links" method="post" class="ax-form"> 126 <div id="profile-links-rows"> 127 <% (profileLinks || []).forEach(function (l) { %> 128 <div class="profile-link-row"> 129 <select name="profile_link_platform[]" aria-label="<%= t('acct.link_platform') %>"> 130 <% platforms.forEach(function (p) { %> 131 <option value="<%= p.key %>" <%= l.platform === p.key ? 'selected' : '' %>><%= p.label %></option> 132 <% }); %> 133 </select> 134 <input type="text" name="profile_link_url[]" value="<%= l.url %>" placeholder="https://…" required aria-label="<%= t('acct.link_url') %>"> 135 <button type="button" class="ax-btn pl-remove" aria-label="<%= t('acct.link_remove') %>">×</button> 136 </div> 137 <% }); %> 138 </div> 139 <template id="profile-link-template"> 140 <div class="profile-link-row"> 141 <select name="profile_link_platform[]" aria-label="<%= t('acct.link_platform') %>"> 142 <% platforms.forEach(function (p) { %> 143 <option value="<%= p.key %>"><%= p.label %></option> 144 <% }); %> 145 </select> 146 <input type="text" name="profile_link_url[]" placeholder="https://…" required aria-label="<%= t('acct.link_url') %>"> 147 <button type="button" class="ax-btn pl-remove" aria-label="<%= t('acct.link_remove') %>">×</button> 148 </div> 149 </template> 150 <div class="ax-form-actions"> 151 <button type="button" id="profile-link-add" class="ax-btn"><%= t('asite.link_add') %></button> 152 <button type="submit" class="ax-btn ax-btn-primary"><%= t('acct.links_save') %></button> 153 </div> 154 </form> 155 </section> 156 <style> 157 .profile-link-row { display: grid; grid-template-columns: 1fr auto; gap: .5rem; margin-bottom: .5rem; } 158 .profile-link-row select { padding: .45rem .6rem; grid-column: 1 / -1; } 159 .profile-link-row input { min-width: 0; } 160 @media (min-width: 520px) { 161 .profile-link-row { grid-template-columns: 160px 1fr auto; } 162 .profile-link-row select { grid-column: auto; } 163 } 164 </style> 115 165 <% } %> 116 166 -
src/views/pages/admin-site-edit.ejs
r31680c3 r746d98a 150 150 </fieldset> 151 151 152 <fieldset class="profile-links-fieldset"> 153 <legend><%= t('asite.links') %></legend> 154 <p class="form-hint"><%= t('asite.links_hint') %></p> 155 156 <div id="profile-links-rows"> 157 <% (parsedLinks || []).forEach(function(l) { %> 158 <div class="profile-link-row"> 159 <select name="profile_link_platform[]"> 160 <% platforms.forEach(function(p) { %> 161 <option value="<%= p.key %>" <%= l.platform === p.key ? 'selected' : '' %>><%= p.label %></option> 162 <% }); %> 163 </select> 164 <input type="text" name="profile_link_url[]" value="<%= l.url %>" placeholder="https://…" required> 165 <button type="button" class="btn btn-danger pl-remove">×</button> 166 </div> 167 <% }); %> 168 </div> 169 170 <template id="profile-link-template"> 171 <div class="profile-link-row"> 172 <select name="profile_link_platform[]"> 173 <% platforms.forEach(function(p) { %> 174 <option value="<%= p.key %>"><%= p.label %></option> 175 <% }); %> 176 </select> 177 <input type="text" name="profile_link_url[]" placeholder="https://…" required> 178 <button type="button" class="btn btn-danger pl-remove">×</button> 179 </div> 180 </template> 181 182 <button type="button" id="profile-link-add" class="btn"><%= t('asite.link_add') %></button> 183 </fieldset> 152 <%# De profiellinks staan sinds 30-9 bij je profiel, naast je naam, bio en foto: 153 dat is waar ze bij horen, en in de wardmodus is dat profiel het enige dat 154 een bezoeker ziet. %> 155 <p class="form-hint profile-links-moved"><%= t('asite.links_moved') %> <a href="/account#links"><%= t('asite.links_moved_link') %></a></p> 184 156 185 157 <%# Aliassen en verhuizen staan sinds 14-8 op /admin/migrate, bij elkaar met … … 477 449 button below. Prevents horizontal overflow on phones (fixed 160px column + 478 450 non-shrinkable input together ~447px > screen width). */ 479 .profile-link-row {480 display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; margin-bottom: 0.5rem;481 }482 .profile-link-row select { padding: 0.45rem 0.6rem; grid-column: 1 / -1; }483 .profile-link-row input { min-width: 0; }484 @media (min-width: 520px) {485 .profile-link-row { grid-template-columns: 160px 1fr auto; }486 .profile-link-row select { grid-column: auto; }487 }488 .profile-links-fieldset details > label { margin-top: 0.4rem; }489 451 </style> -
src/views/partials/bottom-tab.ejs
r31680c3 r746d98a 48 48 </a> 49 49 50 <!-- Zoek --> 50 <!-- Zoek (niet in de wardmodus: daar is zoeken dicht voor een bezoeker) --> 51 <% if (!(typeof wardProfile !== 'undefined' && wardProfile)) { %> 51 52 <button type="button" class="bottom-tab-item<%= _active === 'search' ? ' is-active' : '' %>" 52 53 id="bottom-tab-search-toggle" aria-label="<%= t('nav.search') %>"> … … 57 58 <span class="bottom-tab-label"><%= t('tab.search') %></span> 58 59 </button> 60 <% } %> 59 61 60 62 <!-- Plus (FAB-style, only when user can post) --> -
src/views/partials/chrome.ejs
r31680c3 r746d98a 34 34 <% if (!_headerless) { %> 35 35 <%- include('profile-header') %> 36 <% if (!_isAdmin && (typeof site !== 'undefined' && site)) { %><%- include('view-switcher') %><% } %> 36 <%# Niet in de wardmodus: een bezoeker ziet daar alleen het profiel, en er 37 is geen stroom om tussen Lezen en Grid te wisselen. %> 38 <% if (!_isAdmin && (typeof site !== 'undefined' && site) && !(typeof wardProfile !== 'undefined' && wardProfile)) { %><%- include('view-switcher') %><% } %> 37 39 <% } %> 38 40 <% } %> -
src/views/partials/profile-header.ejs
r31680c3 r746d98a 10 10 // Show profile-header unless we're on an admin page or the site explicitly 11 11 // disabled it via profile_enabled=0. 12 const _showProfile = site 13 && (site.profile_enabled === undefined || site.profile_enabled !== 0) 14 && !(typeof bodyClass !== 'undefined' && bodyClass.indexOf('on-admin') >= 0); 12 // In de wardmodus IS de pagina het profiel (pages/ward-profile.ejs). Dan valt de 13 // kop hier weg, maar de volgdialoog hieronder blijft: de volgknop op die pagina 14 // opent hem. 15 const _wardPage = typeof wardProfile !== 'undefined' && wardProfile; 16 const _showProfile = site && (_wardPage || ( 17 (site.profile_enabled === undefined || site.profile_enabled !== 0) 18 && !(typeof bodyClass !== 'undefined' && bodyClass.indexOf('on-admin') >= 0))); 15 19 16 20 // profile_name and profile_bio used to be optional overrides exposed in the … … 20 24 const _displayName = (site && (site.profile_name || site.title)) || ''; 21 25 const _bioText = (site && (site.profile_bio || site.description || site.tagline)) || ''; 26 // Volgen via de fediverse. Hier boven en niet in de kop: op de wardpagina valt 27 // de kop weg, maar de volgdialoog onderaan hoort er dan nog te staan. 28 const _showFollow = (typeof canManageFedi === 'undefined' || !canManageFedi) && site && site.slug && (typeof apEnabled === 'undefined' || apEnabled); 22 29 23 30 // Parse profile_links JSON column once. … … 38 45 39 46 <% if (_showProfile) { %> 47 <% if (!_wardPage) { %> 40 48 <aside class="profile-header" aria-label="Site profile"> 41 49 <div class="container profile-header-inner"> … … 74 82 <%# Follow-via-fediverse: a compact icon in the links row (keeps the header 75 83 short), and hidden for the owner — you don't follow yourself. %> 76 <% var _showFollow = (typeof canManageFedi === 'undefined' || !canManageFedi) && site && site.slug && (typeof apEnabled === 'undefined' || apEnabled); %>77 84 <% if (_profileLinks.length || _showFollow) { %> 78 85 <% const _platforms = (typeof platforms_catalog !== 'undefined' && platforms_catalog) || {}; %> … … 144 151 </div> 145 152 </div> 153 <% } /* !_wardPage */ %> 146 154 <% if (_showFollow) { %> 147 155 <%# Follow-via-fediverse modal — replaces the native prompt() (which looked ugly). %> -
src/views/partials/topnav.ejs
r31680c3 r746d98a 16 16 // Fediverse pages (on-special) get the same top-nav "back to site" treatment as admin. 17 17 const _isSpecial = typeof bodyClass !== 'undefined' && bodyClass.indexOf('on-special') >= 0; 18 const _hasSearch = !site || site.show_search === undefined || site.show_search; 18 // In de wardmodus is zoeken dicht voor een bezoeker: geen knop naar een deur op slot. 19 const _hasSearch = (!site || site.show_search === undefined || site.show_search) 20 && !(typeof wardProfile !== 'undefined' && wardProfile); 19 21 const _canPost = user && (typeof canMutate === 'undefined' || canMutate) 20 22 && permissions && permissions.canCreatePost && permissions.canCreatePost(user, site); … … 47 49 <nav class="top-nav top-nav-desktop" aria-label="Hoofdnavigatie"> 48 50 49 <% if (!_isAdmin && !_isSpecial) { %> 51 <%# Niet in de wardmodus: daar is het archief dicht voor een bezoeker. %> 52 <% if (!_isAdmin && !_isSpecial && !(typeof wardProfile !== 'undefined' && wardProfile)) { %> 50 53 <a class="nav-link" href="<%= _siteUrlBase %>/archive" 51 54 hx-get="<%= _siteUrlBase %>/archive?partial=1" hx-target="#pcms-main" -
src/views/shell.ejs
r31680c3 r746d98a 130 130 // ontwikkeling een jaar gecachet, dus zonder bump bereikt een reparatie 131 131 // iedereen behalve wie de fout al heeft. 132 const MOD_V = 6 7;132 const MOD_V = 68; 133 133 %><!DOCTYPE html> 134 134 <html lang="<%- _e(lang) %>" data-palette="<%- _e((typeof palette !== 'undefined' && palette) ? palette : (safeSite.palette || 'klonkt')) %>">
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)