ui: defaults

This commit is contained in:
Lewis Wynne 2026-04-28 01:22:28 +01:00
parent 71a381b62a
commit 3f46b7669d
4 changed files with 138 additions and 101 deletions

View file

@ -169,12 +169,12 @@ impl Config {
.unwrap_or_default(),
form_prompt: env::var("BOOK_FORM_PROMPT").unwrap_or_default(),
button_text: env::var("BOOK_BUTTON_TEXT")
.unwrap_or_else(|_| "sign".into()),
.unwrap_or_else(|_| "Submit".into()),
label_name: env::var("BOOK_LABEL_NAME").unwrap_or_else(|_| "name".into()),
label_website: env::var("BOOK_LABEL_WEBSITE")
.unwrap_or_else(|_| "website (optional)".into()),
label_message: env::var("BOOK_LABEL_MESSAGE")
.unwrap_or_else(|_| "message".into()),
.unwrap_or_else(|_| "message (optional)".into()),
textarea_width: env::var("BOOK_TEXTAREA_WIDTH")
.unwrap_or_else(|_| "320".into())
.parse()

View file

@ -32,7 +32,7 @@ pub fn render_page(template: &str, config: &Config, entries: &[Entry], form_html
pub fn render_form(config: &Config) -> String {
let website_section = if config.enable_website_links {
format!(
"\n<label class=\"guestbook-label\" for=\"website\">{label}</label>\n<input class=\"guestbook-input\" id=\"website\" name=\"website\" placeholder=\"{label}\">\n",
"<label class=\"guestbook-label\" for=\"website\">{label}</label>\n<input class=\"guestbook-input\" id=\"website\" name=\"website\">\n",
label = config.label_website
)
} else {
@ -41,7 +41,7 @@ pub fn render_form(config: &Config) -> String {
let captcha_section = if config.enable_captcha {
format!(
"\n<label class=\"guestbook-label\" for=\"captcha\">{label}</label>\n<input class=\"guestbook-input\" id=\"captcha\" name=\"captcha\" placeholder=\"{label}\" required>\n",
"<label class=\"guestbook-label\" for=\"captcha\">{label}</label>\n<input class=\"guestbook-input\" id=\"captcha\" name=\"captcha\" required>\n",
label = config.captcha_question
)
} else {
@ -50,9 +50,9 @@ pub fn render_form(config: &Config) -> String {
let drawing_section = if config.enable_drawings {
format!(
r##"<span class="guestbook-drawing-wrap"><span class="guestbook-drawing-inline"><a href="#" class="guestbook-drawing-toggle">add a drawing</a></span>
<span class="guestbook-drawing-content"></span></span><input type="hidden" name="drawing"><script>(function(){{
var inl=document.querySelector('.guestbook-drawing-inline'),
r##"<span class="guestbook-label">drawing (optional)</span>
<span class="guestbook-drawing-wrap"><span class="guestbook-drawing-tools"></span><span class="guestbook-drawing-content"></span></span><input type="hidden" name="drawing"><script>(function(){{
var inl=document.querySelector('.guestbook-drawing-tools'),
cnt=document.querySelector('.guestbook-drawing-content'),
hid=document.querySelector('[name=drawing]'),
c,x,d=false,lx,ly,h=[],col='#000',sz=5;
@ -69,48 +69,38 @@ pub fn render_form(config: &Config) -> String {
c.addEventListener('touchstart',function(e){{e.preventDefault();save();var p=tpos(e);lx=p[0];ly=p[1];dot(lx,ly)}});
c.addEventListener('touchmove',function(e){{e.preventDefault();var p=tpos(e);x.beginPath();x.moveTo(lx,ly);x.lineTo(p[0],p[1]);x.stroke();lx=p[0];ly=p[1]}});
}}
function showCanvas(){{
inl.innerHTML='';
var sw=[{{c:'#000',n:'black'}},{{c:'#e03131',n:'red'}},{{c:'#2f9e44',n:'green'}},{{c:'#1971c2',n:'blue'}}];
sw.forEach(function(s,i){{
var sp=document.createElement('span');
sp.className='guestbook-swatch'+(i===0?' active':'');
sp.setAttribute('data-c',s.c);sp.style.background=s.c;
sp.setAttribute('role','button');sp.setAttribute('aria-label',s.n);
sp.addEventListener('click',function(){{
inl.querySelectorAll('.guestbook-swatch').forEach(function(el){{el.classList.remove('active')}});
sp.classList.add('active');col=s.c;x.strokeStyle=col;
}});
inl.appendChild(sp);
var sw=[{{c:'#000',n:'black'}},{{c:'#e03131',n:'red'}},{{c:'#2f9e44',n:'green'}},{{c:'#1971c2',n:'blue'}}];
sw.forEach(function(s,i){{
var sp=document.createElement('span');
sp.className='guestbook-swatch'+(i===0?' active':'');
sp.style.background=s.c;
sp.setAttribute('role','button');sp.setAttribute('aria-label',s.n);
sp.addEventListener('click',function(){{
inl.querySelectorAll('.guestbook-swatch').forEach(function(el){{el.classList.remove('active')}});
sp.classList.add('active');col=s.c;x.strokeStyle=col;
}});
var sl=document.createElement('input');
sl.type='range';sl.className='guestbook-size-slider';sl.min='1';sl.max='20';sl.value='5';sl.setAttribute('aria-label','Brush size');
sl.addEventListener('input',function(){{sz=parseInt(sl.value);x.lineWidth=sz}});
inl.appendChild(document.createTextNode(' '));inl.appendChild(sl);
inl.appendChild(document.createTextNode(' | '));
var undo=document.createElement('a');undo.href='#';undo.textContent='undo';
undo.addEventListener('click',function(e){{e.preventDefault();if(h.length)x.putImageData(h.pop(),0,0)}});
inl.appendChild(undo);
inl.appendChild(document.createTextNode(' | '));
var disc=document.createElement('a');disc.href='#';disc.textContent='discard';
disc.addEventListener('click',function(e){{
e.preventDefault();h=[];col='#000';sz=5;d=false;cnt.innerHTML='';hid.value='';setInit();
}});
inl.appendChild(disc);
c=document.createElement('canvas');c.className='guestbook-canvas';c.width={w};c.height={h};c.setAttribute('aria-label','Drawing canvas');
cnt.innerHTML='';cnt.appendChild(c);bindCanvas();
c.closest('form').addEventListener('submit',function(){{
var px=new Uint32Array(x.getImageData(0,0,c.width,c.height).data.buffer);
if(px.some(function(v){{return v!==0}})){{hid.value=c.toDataURL('image/png')}}
}});
}}
function setInit(){{
inl.innerHTML='';
var a=document.createElement('a');a.href='#';a.textContent='add a drawing';
a.addEventListener('click',function(e){{e.preventDefault();showCanvas()}});
inl.appendChild(a);
}}
inl.querySelector('a').addEventListener('click',function(e){{e.preventDefault();showCanvas()}});
inl.appendChild(sp);
}});
var sl=document.createElement('input');
sl.type='range';sl.className='guestbook-size-slider';sl.min='1';sl.max='20';sl.value='5';sl.setAttribute('aria-label','Brush size');
sl.addEventListener('input',function(){{sz=parseInt(sl.value);x.lineWidth=sz}});
inl.appendChild(document.createTextNode(' '));inl.appendChild(sl);
inl.appendChild(document.createTextNode(' | '));
var undo=document.createElement('a');undo.href='#';undo.textContent='undo';
undo.addEventListener('click',function(e){{e.preventDefault();if(h.length)x.putImageData(h.pop(),0,0)}});
inl.appendChild(undo);
inl.appendChild(document.createTextNode(' | '));
var clr=document.createElement('a');clr.href='#';clr.textContent='clear';
clr.addEventListener('click',function(e){{
e.preventDefault();h=[];x.clearRect(0,0,c.width,c.height);hid.value='';
}});
inl.appendChild(clr);
c=document.createElement('canvas');c.className='guestbook-canvas';c.width={w};c.height={h};c.setAttribute('aria-label','Drawing canvas');
cnt.appendChild(c);bindCanvas();
c.closest('form').addEventListener('submit',function(){{
var px=new Uint32Array(x.getImageData(0,0,c.width,c.height).data.buffer);
if(px.some(function(v){{return v!==0}})){{hid.value=c.toDataURL('image/png')}}
}});
}})();</script>"##,
w = config.canvas_width,
h = config.canvas_height,
@ -121,9 +111,10 @@ pub fn render_form(config: &Config) -> String {
let voice_note_section = if config.enable_voice_notes {
format!(
r##"<span class="guestbook-voice-wrap"><span class="guestbook-voice-inline"><a href="#" class="guestbook-voice-record">add a voice note</a> <span class="guestbook-voice-timer"></span></span><span class="guestbook-voice-playback"></span></span><input type="hidden" name="voice_note"><script>(function(){{
r##"<span class="guestbook-label">voice note (optional)</span>
<span class="guestbook-voice-wrap"><span class="guestbook-voice-controls"></span><span class="guestbook-voice-playback"></span></span><input type="hidden" name="voice_note"><script>(function(){{
var maxDur={max_dur};
var inl=document.querySelector('.guestbook-voice-inline'),
var inl=document.querySelector('.guestbook-voice-controls'),
pb=document.querySelector('.guestbook-voice-playback'),
hid=document.querySelector('[name=voice_note]'),
rec=null,chunks=[],iv=null,st=0;
@ -132,7 +123,8 @@ pub fn render_form(config: &Config) -> String {
if(rec&&rec.state==='recording'){{rec.stop();rec.stream.getTracks().forEach(function(t){{t.stop()}})}}
rec=null;chunks=[];clearInterval(iv);iv=null;pb.innerHTML='';hid.value='';
inl.innerHTML='';
var a=document.createElement('a');a.href='#';a.textContent='add a voice note';
var a=document.createElement('a');a.href='#';a.className='guestbook-voice-record';
a.textContent='record';
a.addEventListener('click',function(e){{e.preventDefault();startRec()}});
inl.appendChild(a);
}}
@ -160,7 +152,7 @@ pub fn render_form(config: &Config) -> String {
inl.appendChild(re);inl.appendChild(document.createTextNode(' | '));inl.appendChild(disc);
var url=URL.createObjectURL(blob);
var au=document.createElement('audio');au.controls=true;au.preload='metadata';au.src=url;
pb.innerHTML='';pb.appendChild(au);
pb.appendChild(au);
var rd=new FileReader();rd.onload=function(){{hid.value=rd.result}};rd.readAsDataURL(blob);
}}
function startRec(){{
@ -171,11 +163,11 @@ pub fn render_form(config: &Config) -> String {
rec.onstop=function(){{setResult()}};
rec.start();setRec();
}}).catch(function(){{
inl.querySelector('a').textContent='add a voice note';
inl.appendChild(document.createTextNode(' (mic denied)'));
inl.innerHTML='';
inl.appendChild(document.createTextNode('mic access denied'));
}});
}}
inl.querySelector('a').addEventListener('click',function(e){{e.preventDefault();startRec()}});
setInit();
}})();</script>"##,
max_dur = config.voice_note_max_duration,
)
@ -184,23 +176,24 @@ pub fn render_form(config: &Config) -> String {
};
format!(
r#"<form class="guestbook-form" method="post" action="/submit" accept-charset="UTF-8">
r#"<details class="guestbook-details">
<summary class="guestbook-summary">Leave your own message.</summary>
<form class="guestbook-form" method="post" action="/submit" accept-charset="UTF-8">
<label class="guestbook-label" for="name">{label_name}</label>
<input class="guestbook-input" id="name" name="name" placeholder="{label_name}" required>
{website_section}
<label class="guestbook-label" for="message">{label_message}</label>
<textarea class="guestbook-textarea" id="message" name="message" placeholder="{label_message}" style="width:{tw}px;height:{th}px" required></textarea>
{captcha_section}
{drawing_section}{voice_note_section}<input name="url" aria-hidden="true" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)" tabindex="-1" autocomplete="off"><button class="guestbook-button" type="submit">{button}</button>
</form>"#,
<input class="guestbook-input" id="name" name="name" required>
{website_section}<label class="guestbook-label" for="message">{label_message}</label>
<textarea class="guestbook-textarea" id="message" name="message" style="width:{tw}px;height:{th}px"></textarea>
{drawing_section}{voice_note_section}{captcha_section}<input name="url" aria-hidden="true" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)" tabindex="-1" autocomplete="off"><button class="guestbook-button" type="submit">{button}</button>
</form>
</details>"#,
label_name = config.label_name,
website_section = website_section,
label_message = config.label_message,
tw = config.textarea_width,
th = config.textarea_height,
captcha_section = captcha_section,
drawing_section = drawing_section,
voice_note_section = voice_note_section,
captcha_section = captcha_section,
button = config.button_text,
)
}
@ -357,9 +350,9 @@ mod tests {
style: String::new(),
form_prompt: "Thanks for visiting. Sign the guestbook!".into(),
button_text: "sign".into(),
label_name: "Your name:".into(),
label_website: "Your website (optional):".into(),
label_message: "Your message:".into(),
label_name: "name".into(),
label_website: "website (optional)".into(),
label_message: "message (optional)".into(),
textarea_width: 400,
textarea_height: 150,
}
@ -521,7 +514,7 @@ mod tests {
let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form);
assert!(html.contains("&lt;b&gt;hacker&lt;/b&gt;"));
assert!(html.contains("&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;"));
assert!(!html.contains("<script>"));
assert!(!html.contains("<script>alert("));
}
#[test]
@ -547,8 +540,8 @@ mod tests {
let mut config = test_config();
config.enable_drawings = true;
let form = render_form(&config);
assert!(form.contains("add a drawing"));
assert!(form.contains("guestbook-drawing-toggle"));
assert!(form.contains("guestbook-drawing-wrap"));
assert!(form.contains("guestbook-drawing-content"));
assert!(form.contains("name=\"drawing\""));
}
@ -556,7 +549,7 @@ mod tests {
fn test_render_form_hides_drawing_when_disabled() {
let config = test_config();
let form = render_form(&config);
assert!(!form.contains("add a drawing"));
assert!(!form.contains("guestbook-drawing-wrap"));
assert!(!form.contains("name=\"drawing\""));
}
@ -657,8 +650,8 @@ mod tests {
let mut config = test_config();
config.enable_voice_notes = true;
let form = render_form(&config);
assert!(form.contains("add a voice note"));
assert!(form.contains("guestbook-voice-record"));
assert!(form.contains("guestbook-voice-wrap"));
assert!(form.contains("guestbook-voice-controls"));
assert!(form.contains("name=\"voice_note\""));
}
@ -666,7 +659,7 @@ mod tests {
fn test_render_form_hides_voice_note_when_disabled() {
let config = test_config();
let form = render_form(&config);
assert!(!form.contains("add a voice note"));
assert!(!form.contains("guestbook-voice-wrap"));
assert!(!form.contains("name=\"voice_note\""));
}
}

View file

@ -200,8 +200,8 @@ async fn submit(
}
}
if name.is_empty() || message.is_empty() {
return Html(render_error_page(&state.config, "Name and message are required."));
if name.is_empty() {
return Html(render_error_page(&state.config, "Name is required."));
}
let max_name = state.config.max_name_length;
if max_name > 0 && name.chars().count() > max_name {
@ -277,6 +277,10 @@ async fn submit(
None
};
if message.is_empty() && drawing_bytes.is_none() && voice_note_bytes.is_none() {
return Html(render_error_page(&state.config, "Please leave a message, drawing, or voice note."));
}
let now = chrono::Utc::now();
let date = now.format("%Y-%m-%dT%H:%M:%S").to_string();
@ -412,9 +416,9 @@ mod tests {
style: String::new(),
form_prompt: "Thanks for visiting. Sign the guestbook!".into(),
button_text: "sign".into(),
label_name: "Your name:".into(),
label_website: "Your website (optional):".into(),
label_message: "Your message:".into(),
label_name: "name".into(),
label_website: "website (optional)".into(),
label_message: "message (optional)".into(),
textarea_width: 400,
textarea_height: 150,
}
@ -962,10 +966,59 @@ mod tests {
let (app, _rx) = test_app(config);
let (_, body) = post_form(&app, "name=&message=").await;
assert!(body.contains("<!DOCTYPE html>"));
assert!(body.contains("Name and message are required"));
assert!(body.contains("Name is required"));
assert!(body.contains("back"));
}
#[tokio::test]
async fn test_submit_rejects_when_message_drawing_voicenote_all_empty() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.enable_drawings = true;
config.enable_voice_notes = true;
let (app, _rx) = test_app(config);
let (_, body) = post_form(&app, "name=alice&message=").await;
assert!(body.contains("Please leave a message"));
}
#[tokio::test]
async fn test_submit_accepts_drawing_only() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.enable_drawings = true;
config.canvas_width = 400;
config.canvas_height = 200;
let (app, _rx) = test_app(config);
let png = fake_png(400, 200);
let drawing_data = base64::engine::general_purpose::STANDARD.encode(&png);
let data_url = format!("data:image/png;base64,{drawing_data}");
let body = format!(
"name=alice&message=&drawing={}",
urlencoding::encode(&data_url)
);
let (_, resp) = post_form(&app, &body).await;
assert!(resp.contains("pending approval"));
}
#[tokio::test]
async fn test_submit_accepts_voice_note_only() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.enable_voice_notes = true;
let (app, _rx) = test_app(config);
let webm = fake_webm();
let voice_data = base64::engine::general_purpose::STANDARD.encode(&webm);
let data_url = format!("data:audio/webm;codecs=opus;base64,{voice_data}");
let body = format!(
"name=alice&message=&voice_note={}",
urlencoding::encode(&data_url)
);
let (_, resp) = post_form(&app, &body).await;
assert!(resp.contains("pending approval"));
}
#[tokio::test]
async fn test_submit_with_voice_note() {
let dir = tempfile::tempdir().unwrap();

View file

@ -16,25 +16,17 @@ body {
}
.guestbook-form {}
.guestbook-label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
display: block;
}
.guestbook-input {
display: block;
margin-bottom: 0.2em;
margin-bottom: 0.4em;
}
.guestbook-textarea {
display: block;
box-sizing: border-box;
max-width: 100%;
margin-bottom: 0.2em;
margin-bottom: 0.4em;
}
.guestbook-button {
display: block;
@ -48,21 +40,16 @@ body {
max-width: 100%;
height: auto;
}
.guestbook-canvas-tools {
display: block;
}
.guestbook-canvas-tools a {
cursor: pointer;
}
.guestbook-drawing-wrap {
display: block;
margin-bottom: 0.4em;
}
.guestbook-drawing-inline a {
.guestbook-drawing-tools {
display: block;
}
.guestbook-drawing-tools a {
cursor: pointer;
}
.guestbook-drawing-content:empty {
display: none;
}
.guestbook-drawing-content {
display: block;
}
@ -91,6 +78,10 @@ body {
/* Voice notes */
.guestbook-voice-wrap {
display: block;
margin-bottom: 0.4em;
}
.guestbook-voice-controls a {
cursor: pointer;
}
.guestbook-voice-record.recording {
color: red;