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

View file

@ -200,8 +200,8 @@ async fn submit(
} }
} }
if name.is_empty() || message.is_empty() { if name.is_empty() {
return Html(render_error_page(&state.config, "Name and message are required.")); return Html(render_error_page(&state.config, "Name is required."));
} }
let max_name = state.config.max_name_length; let max_name = state.config.max_name_length;
if max_name > 0 && name.chars().count() > max_name { if max_name > 0 && name.chars().count() > max_name {
@ -277,6 +277,10 @@ async fn submit(
None 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 now = chrono::Utc::now();
let date = now.format("%Y-%m-%dT%H:%M:%S").to_string(); let date = now.format("%Y-%m-%dT%H:%M:%S").to_string();
@ -412,9 +416,9 @@ mod tests {
style: String::new(), style: String::new(),
form_prompt: "Thanks for visiting. Sign the guestbook!".into(), form_prompt: "Thanks for visiting. Sign the guestbook!".into(),
button_text: "sign".into(), button_text: "sign".into(),
label_name: "Your name:".into(), label_name: "name".into(),
label_website: "Your website (optional):".into(), label_website: "website (optional)".into(),
label_message: "Your message:".into(), label_message: "message (optional)".into(),
textarea_width: 400, textarea_width: 400,
textarea_height: 150, textarea_height: 150,
} }
@ -962,10 +966,59 @@ mod tests {
let (app, _rx) = test_app(config); let (app, _rx) = test_app(config);
let (_, body) = post_form(&app, "name=&message=").await; let (_, body) = post_form(&app, "name=&message=").await;
assert!(body.contains("<!DOCTYPE html>")); assert!(body.contains("<!DOCTYPE html>"));
assert!(body.contains("Name and message are required")); assert!(body.contains("Name is required"));
assert!(body.contains("back")); 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] #[tokio::test]
async fn test_submit_with_voice_note() { async fn test_submit_with_voice_note() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();

View file

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