6db9178449
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Non-default campaigns gain an optional colour override (background / text / link) in two sets — one for every theme, one for the dark theme only — and an "urgent" flag. - Colours ride profile.get as six trailing FlatBuffers strings on BannerCampaign (backward-compatible). The client resolves the cascade (dark <- dark ?? all, light <- all) per rendered theme and derives the strip border from the background in JS (no CSS color-mix, for the old Android WebView floor); AdBanner applies them as inline vars scoped to the strip. - Urgent is resolved entirely server-side: while any enabled, in-window urgent campaign exists, computeActiveSet returns only the urgent campaigns and bannerFor skips the eligibility gate — so a system notice reaches every viewer (paid / hint-holding / no_banner included) and preempts the ordinary feed. No wire field; it appears on each viewer's next profile.get. - Admin console (/_gm/banners): native colour pickers + a live light/dark preview of the strip, and an urgent toggle. The default campaign stays plain, enforced by the service and a DB CHECK. Migration 00009 is additive (nullable colour columns + a bool default + all-or-nothing / hex / default-plain CHECKs) — expand-contract, rollback-safe. Docs: ARCHITECTURE §10, UI_DESIGN, FUNCTIONAL (+ru). Tests: ads unit (urgent preempt + colour validation), codec + resolver unit, gateway transcode, and integration (colour round-trip + urgent bypass against real Postgres).
121 lines
8.4 KiB
Go Template
121 lines
8.4 KiB
Go Template
{{define "content" -}}
|
|
{{with .Data}}
|
|
<p class="note"><a href="/_gm/banners">← all campaigns</a></p>
|
|
<h1>{{.Name}}{{if .IsDefault}} <span class="pill">default</span>{{end}}</h1>
|
|
<section class="panel"><h2>Settings</h2>
|
|
{{if .IsDefault}}<p class="note">The default campaign is perpetual and fills the unsold remainder — only its name and messages are editable.</p>{{end}}
|
|
<form class="form col" method="post" action="/_gm/banners/{{.ID}}">
|
|
<label>Name <input type="text" name="name" value="{{.Name}}" maxlength="80" required></label>
|
|
{{if not .IsDefault}}
|
|
<label>Weight (%) <input type="number" name="weight" min="1" max="100" value="{{.Weight}}" required></label>
|
|
<label>Starts (UTC) <input type="datetime-local" name="starts_at" value="{{.StartsAt}}"></label>
|
|
<label>Ends (UTC) <input type="datetime-local" name="ends_at" value="{{.EndsAt}}"></label>
|
|
<label><input type="checkbox" name="enabled"{{if .Enabled}} checked{{end}}> Enabled</label>
|
|
<fieldset class="ovr">
|
|
<legend><label><input type="checkbox" name="urgent"{{if .Urgent}} checked{{end}}> Urgent</label></legend>
|
|
<p class="note">Shows to <em>everyone</em>, always — bypassing paid accounts, hint wallets and the no-banner role. While any urgent campaign is live it is the only thing the strip shows (other campaigns and the default are suppressed). For system alerts.</p>
|
|
</fieldset>
|
|
<fieldset class="ovr" data-ovr-group>
|
|
<legend><label><input type="checkbox" name="override_all_on" data-ovr="all"{{if .OverrideAllOn}} checked{{end}}> Colour override — all themes</label></legend>
|
|
<div class="swatches">
|
|
<label>Background <input type="color" name="override_bg" value="{{.AllBg}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
|
|
<label>Text <input type="color" name="override_fg" value="{{.AllFg}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
|
|
<label>Link <input type="color" name="override_link" value="{{.AllLink}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
|
|
</div>
|
|
</fieldset>
|
|
<fieldset class="ovr" data-ovr-group>
|
|
<legend><label><input type="checkbox" name="override_dark_on" data-ovr="dark"{{if .OverrideDarkOn}} checked{{end}}> Colour override — dark theme only</label></legend>
|
|
<div class="swatches">
|
|
<label>Background <input type="color" name="override_bg_dark" value="{{.DarkBg}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
|
|
<label>Text <input type="color" name="override_fg_dark" value="{{.DarkFg}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
|
|
<label>Link <input type="color" name="override_link_dark" value="{{.DarkLink}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
|
|
</div>
|
|
</fieldset>
|
|
{{end}}
|
|
<div><button type="submit">Save</button></div>
|
|
</form>
|
|
{{if not .IsDefault}}
|
|
<div class="banner-preview">
|
|
<div class="bp"><span class="bp-label">Light theme</span><div class="ad-frame light"><div class="ad-sample" id="prev-light"><span>Sample banner text — <span class="ad-link">a link</span></span></div></div></div>
|
|
<div class="bp"><span class="bp-label">Dark theme</span><div class="ad-frame dark"><div class="ad-sample" id="prev-dark"><span>Sample banner text — <span class="ad-link">a link</span></span></div></div></div>
|
|
</div>
|
|
<p class="note">Live preview of the strip on both themes. An empty override falls back to the neutral theme colours; the top/bottom border is derived from the background.</p>
|
|
<script>
|
|
(function(){
|
|
// Neutral fallbacks — mirror ui/src/app.css (--ad-bg / --text-muted / --accent).
|
|
var TOK={light:{bg:'#e3e7ee',fg:'#6b7280',link:'#2f6df6'},dark:{bg:'#272f3c',fg:'#9aa3b2',link:'#5b8cff'}};
|
|
function byName(n){return document.querySelector('[name="'+n+'"]');}
|
|
var allOn=byName('override_all_on'), darkOn=byName('override_dark_on');
|
|
if(!allOn||!darkOn){return;}
|
|
var f={ab:byName('override_bg'),af:byName('override_fg'),al:byName('override_link'),db:byName('override_bg_dark'),df:byName('override_fg_dark'),dl:byName('override_link_dark')};
|
|
var lightEl=document.getElementById('prev-light'), darkEl=document.getElementById('prev-dark');
|
|
function hexToRgb(h){h=h.replace('#','');return [parseInt(h.slice(0,2),16),parseInt(h.slice(2,4),16),parseInt(h.slice(4,6),16)];}
|
|
function pad(x){x=Math.max(0,Math.min(255,Math.round(x))).toString(16);return x.length<2?'0'+x:x;}
|
|
function rgbToHex(r){return '#'+pad(r[0])+pad(r[1])+pad(r[2]);}
|
|
function mix(a,b,t){return [a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t,a[2]+(b[2]-a[2])*t];}
|
|
function lum(r){return (0.2126*r[0]+0.7152*r[1]+0.0722*r[2])/255;}
|
|
// Derived border: nudge the background 14% toward black on a light bg, toward white on a dark bg.
|
|
function border(bg){var r=hexToRgb(bg);return rgbToHex(mix(r, lum(r)>0.5?[0,0,0]:[255,255,255], 0.14));}
|
|
function paint(el,c){
|
|
el.style.background=c.bg; el.style.color=c.fg;
|
|
el.style.borderTop='1px solid '+border(c.bg); el.style.borderBottom='1px solid '+border(c.bg);
|
|
var a=el.querySelector('.ad-link'); if(a){a.style.color=c.link;}
|
|
}
|
|
function resolve(){
|
|
var light=allOn.checked?{bg:f.ab.value,fg:f.af.value,link:f.al.value}:TOK.light;
|
|
var dark=darkOn.checked?{bg:f.db.value,fg:f.df.value,link:f.dl.value}
|
|
:(allOn.checked?{bg:f.ab.value,fg:f.af.value,link:f.al.value}:TOK.dark);
|
|
paint(lightEl,light); paint(darkEl,dark);
|
|
document.querySelectorAll('.ovr .swatches label').forEach(function(lab){
|
|
var inp=lab.querySelector('input[type=color]'), hx=lab.querySelector('.hex');
|
|
if(inp&&hx){hx.textContent=inp.value;}
|
|
});
|
|
}
|
|
function toggleGroup(chk){chk.closest('fieldset').querySelectorAll('[data-ovr-color]').forEach(function(inp){inp.disabled=!chk.checked;});}
|
|
[allOn,darkOn].forEach(function(chk){chk.addEventListener('change',function(){toggleGroup(chk);resolve();});});
|
|
Object.keys(f).forEach(function(k){f[k].addEventListener('input',resolve);});
|
|
resolve();
|
|
})();
|
|
</script>
|
|
<form class="form" method="post" action="/_gm/banners/{{.ID}}/delete" onsubmit="return confirm('Delete this campaign and its messages?')">
|
|
<button type="submit" class="danger">Delete campaign</button>
|
|
</form>
|
|
{{end}}
|
|
</section>
|
|
<section class="panel"><h2>Messages</h2>
|
|
<div class="help-top">
|
|
<p class="note">Each message must be filled in both languages; the viewer sees the variant for the bot they play through. The messages of a campaign share its show weight, rotating in this order.</p>
|
|
<aside class="help">
|
|
<h4>Formatting</h4>
|
|
<p>Plain text is shown as-is (any HTML is escaped).</p>
|
|
<p>Add a link with markdown — <code>[visible text](https://example.com)</code> — which renders as the linked <em>visible text</em>.</p>
|
|
<p>Only <code>https://</code>, <code>http://</code> and root-relative <code>/path</code> targets become links; any other scheme (e.g. <code>javascript:</code>, <code>ftp:</code>) is dropped and shown as plain text.</p>
|
|
<p>Keep it short: an over-long line scrolls in the strip; the same formatting applies when editing a message above.</p>
|
|
</aside>
|
|
</div>
|
|
{{range .Messages}}
|
|
<div class="row">
|
|
<form class="form col" method="post" action="/_gm/banners/{{$.Data.ID}}/messages/{{.ID}}">
|
|
<label>English <textarea name="body_en" rows="3" maxlength="500" required>{{.BodyEn}}</textarea></label>
|
|
<label>Russian <textarea name="body_ru" rows="3" maxlength="500" required>{{.BodyRu}}</textarea></label>
|
|
<div class="actions">
|
|
<button type="submit">Save</button>
|
|
</div>
|
|
</form>
|
|
<div class="actions">
|
|
<form class="form" method="post" action="/_gm/banners/{{$.Data.ID}}/messages/{{.ID}}/move"><input type="hidden" name="dir" value="up"><button type="submit"{{if .First}} disabled{{end}}>↑</button></form>
|
|
<form class="form" method="post" action="/_gm/banners/{{$.Data.ID}}/messages/{{.ID}}/move"><input type="hidden" name="dir" value="down"><button type="submit"{{if .Last}} disabled{{end}}>↓</button></form>
|
|
<form class="form" method="post" action="/_gm/banners/{{$.Data.ID}}/messages/{{.ID}}/delete" onsubmit="return confirm('Delete this message?')"><button type="submit" class="danger">Delete</button></form>
|
|
</div>
|
|
</div>
|
|
{{else}}<p class="note">no messages yet</p>{{end}}
|
|
<h3>Add message</h3>
|
|
<form class="form col" method="post" action="/_gm/banners/{{.ID}}/messages">
|
|
<label>English <textarea name="body_en" rows="3" maxlength="500" required></textarea></label>
|
|
<label>Russian <textarea name="body_ru" rows="3" maxlength="500" required></textarea></label>
|
|
<div><button type="submit">Add message</button></div>
|
|
</form>
|
|
</section>
|
|
{{end}}
|
|
{{- end}}
|