char-slop/stars.hackery.site
git clone https://git.t4t.associates/char-slop/stars.hackery.site
92004f9
main
1import type { Actor } from "./hydrant.ts" ; 2import type { StarEntry } from "./hydrant.ts" ; 3 4const css = ` 5:root { font-family: Arial, Helvetica, sans-serif; color: #111; background: #fff; } 6* { box-sizing: border-box; } 7body { max-width: 998px; margin: 0 auto; padding: 0 16px 64px; font-size: 16px; line-height: 1.5; } 8a { color: #2c73af; text-underline-offset: 3px; } 9a:hover { color: #195789; } 10header { display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 56px; } 11header nav { display: flex; align-items: center; justify-content: flex-end; gap: 20px; min-width: 0; } 12header nav a { overflow-wrap: anywhere; } 13.nav-signin { padding: 5px 12px; border: 1px solid #aaa; border-radius: 4px; text-decoration: none; white-space: nowrap; } 14main { padding-top: 28px; } 15h1 { margin: 0 0 16px; font-size: 32px; line-height: 1.25; overflow-wrap: anywhere; } 16.intro { margin: 0 0 18px; font-size: 14px; } 17hr { border: 0; border-top: 1px solid #111; margin: 0 0 48px; } 18.heading-actor { display: inline-flex; align-items: center; gap: 12px; } 19.controls { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 16px 32px; margin: -16px 0 32px; } 20.controls form { margin: 0; } 21.controls .browse { flex: 1 1 300px; max-width: 480px; } 22.browse label { margin: 0 0 8px; } 23.field-row { display: flex; align-items: stretch; gap: 8px; } 24.field-row input { flex: 1; min-width: 0; width: auto; } 25.field-row button { flex: none; } 26#share { max-width: 560px; } 27#share label[for] { margin-bottom: 0; } 28.star { margin: 0 0 28px; padding: 16px; border: 1px solid #ddd; border-radius: 8px; box-shadow: 0 3px 15px #0003; } 29.star-content { border-left: 5px solid #34383a; padding-left: 10px; } 30.star-link { font-size: 20px; font-weight: 700; overflow-wrap: anywhere; } 31.domain { display: block; overflow-wrap: anywhere; } 32.comment { margin: 8px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; } 33.star-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 20px; margin-top: 11px; } 34.author { display: inline-flex; align-items: center; gap: 6px; } 35.avatar { width: 24px; height: 24px; border-radius: 50%; object-fit: cover; } 36.avatar.large { width: 40px; height: 40px; } 37form { margin: 24px 0; } 38label { display: block; margin: 12px 0; } 39input, textarea { display: block; width: 100%; padding: 9px 10px; font: inherit; border: 1px solid #aaa; border-radius: 4px; } 40textarea { min-height: 180px; resize: vertical; } 41button { padding: 7px 14px; font: inherit; color: #111; background: #f5f5f5; border: 1px solid #aaa; border-radius: 4px; cursor: pointer; } 42button:hover { background: #e9e9e9; } 43button.inline { font-size: 14px; } 44.star form { margin: 12px 0 0; } 45.panel { max-width: 480px; } 46.muted { color: #555; } 47@media (max-width: 600px) { body { padding: 0 16px 32px; } header { gap: 8px; min-height: 64px; } header nav { gap: 10px; } hr { margin-bottom: 32px; } .star { margin-bottom: 24px; } } 48` ; 49 50// Only replaces a title the user hasn't written themselves. 51const shareScript = ` 52const { url, title, csrf } = document.forms.share.elements; 53let fetched = ""; 54const untouched = () => !title.value || title.value === fetched; 55const lookup = async () => { 56const link = url.value; 57if (!link || !url.checkValidity() || !untouched()) return; 58title.placeholder = "Fetching title…"; 59try { 60const res = await fetch("/xrpc/site.hackery.stars.getTitle", { 61method: "POST", 62headers: { "content-type": "application/json", "x-csrf-token": csrf.value }, 63body: JSON.stringify({ url: link }), 64}); 65if (!res.ok || url.value !== link || !untouched()) return; 66fetched = title.value = (await res.json()).title; 67} finally { 68title.placeholder = ""; 69} 70}; 71url.addEventListener("change", lookup); 72lookup(); 73` ; 74const shareScriptHash = "sha256-" + new Uint8Array ( 75await crypto . subtle . digest ( "SHA-256" , new TextEncoder (). encode ( shareScript )), 76). toBase64 (); 77 78const ENTITIES :Record < string , string > = { 79"&" :"&" , 80"<" :"<" , 81">" :">" , 82'"' :""" , 83"'" :"'" , 84}; 85export const escape = ( s :string ) => s . replace ( / [&<>"'] / g , ( c ) => ENTITIES [ c ]); 86 87export const profilePath = ( identifier :string ) => 88`/ ${ encodeURIComponent ( identifier ). replaceAll ( "%3A" , ":" )} ` ; 89export const profileUrl = ( actor :Actor ) => 90profilePath ( actor . handleVerified === false ?actor . did :actor . handle ); 91 92const avatar = ( actor :Actor , large = false ) => 93actor . avatar 94 ?`<img class="avatar ${ large ? " large" : "" } " src=" ${ 95escape ( actor . avatar ) 96} " alt="">` 97 :"★" ; 98const unverified = ( actor :Actor ) => 99actor . handleVerified === false 100 ?' <small class="muted">(unverified)</small>' 101 :"" ; 102const actorLink = ( actor :Actor ) => 103`<a href=" ${ escape ( profileUrl ( actor ))} "> ${ escape ( actor . handle )} </a>` ; 104 105export const notice = ( text :string | undefined ) => 106text ?`<p class="muted"> ${ escape ( text )} </p>` :"" ; 107export const list = ( items :string [], empty :string , more ?:string ) => 108( items . join ( "" ) || `<p> ${ empty } </p>` ) + 109( more ?`<p><a href=" ${ escape ( more )} ">Older stars →</a></p>` :"" ); 110 111export function page ( 112title :string , 113body :string , 114account ?:{ handle :string ; url :string }, 115) :Response { 116const nav = account 117 ?`<a href=" ${ escape ( account . url )} "> ${ escape ( account . handle )} </a>` 118 :'<a class="nav-signin" href="/login">Sign in</a>' ; 119return new Response ( 120`<!doctype html> 121<html lang="en"> 122<head> 123<meta charset="utf-8"> 124<meta name="viewport" content="width=device-width,initial-scale=1"> 125<title> ${ escape ( title )} - Starboard</title> 126<style> ${ css } </style> 127</head> 128<body> 129<header> 130<a href="/">Starboard</a> 131<nav><a href="/share">Add Star</a> ${ nav } </nav> 132</header> 133<main> ${ body } </main> 134</body> 135</html>` , 136{ 137headers :{ 138"content-type" :"text/html; charset=utf-8" , 139"cache-control" :"no-store" , 140"x-content-type-options" :"nosniff" , 141"content-security-policy" : 142`default-src 'none'; img-src https:; style-src 'unsafe-inline'; script-src ' ${ shareScriptHash } '; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'` , 143}, 144}, 145); 146} 147 148export const browseForm = ` 149<form class="browse" method="get" action="/go"> 150<label for="browse-handle">Find stars by handle or DID</label> 151<div class="field-row"> 152<input id="browse-handle" name="handle" required placeholder="char.lt"> 153<button>Go</button> 154</div> 155</form>` ; 156 157export const signInForm = ` 158<h1>Sign in</h1> 159<p class="intro">Sign in with your ATProto account.</p> 160<hr> 161<form class="panel" method="post" action="/login"> 162<label>ATProto handle or DID<input name="handle" autocomplete="username" required placeholder="you.example.com"></label> 163<button>Sign in</button> 164</form>` ; 165 166// The hidden Share button comes first so that it's the default button for Enter. 167export function shareForm ( 168csrf :string , 169fields :{ url ?:string ; title ?:string ; comment ?:string ; notice ?:string }, 170) :string { 171const url = escape ( fields . url ?? "" ); 172const title = escape ( fields . title ?? "" ); 173const comment = escape ( fields . comment ?? "" ); 174return ` 175<h1>Add Star</h1> 176<p class="intro">Share a link and a comment.</p> 177<hr> 178${ notice ( fields . notice )} 179<form id="share" class="panel" method="post" action="/share"> 180<input type="hidden" name="csrf" value=" ${ csrf } "> 181<button hidden></button> 182<label>Link<input name="url" type="url" maxlength="2048" required value=" ${ url } "></label> 183<label for="share-title">Title (editable)</label> 184<div class="field-row"> 185<input id="share-title" name="title" maxlength="2048" value=" ${ title } "> 186<button formaction="/share/title">Fetch title</button> 187</div> 188<label>Comment (optional, up to 32,768 graphemes)<textarea name="comment"> ${ comment } </textarea></label> 189<button>Share</button> 190</form> 191<script> ${ shareScript } </script>` ; 192} 193 194export function followForm ( 195csrf :string , 196actor :Actor , 197followed :boolean , 198) :string { 199return ` 200<form method="post" action="/ ${ followed ? "unfollow" : "follow" } "> 201<input type="hidden" name="csrf" value=" ${ csrf } "> 202<input type="hidden" name="subject" value=" ${ escape ( actor . did )} "> 203<button> ${ followed ? "Unfollow" : "Follow" } </button> 204</form>` ; 205} 206 207export const welcome = ` 208<h1>Starboard</h1> 209<p class="intro">Links worth sharing, from people you follow.</p> 210<hr> 211<div class="panel"> ${ browseForm } </div>` ; 212 213export function feed ( csrf :string , body :string ) :string { 214return ` 215<h1>Starboard</h1> 216<p class="intro">Links shared by people you follow.</p> 217<hr> 218<div class="controls"> 219${ browseForm } 220<form method="post" action="/logout"> 221<input type="hidden" name="csrf" value=" ${ csrf } "> 222<button class="inline">Sign out</button> 223</form> 224</div> ${ body } ` ; 225} 226 227export function profile ( actor :Actor , follow :string , body :string ) :string { 228const handle = escape ( actor . handle ); 229const label = unverified ( actor ); 230return ` 231<h1 class="heading-actor"> ${ avatar ( actor , true )} ${ handle } ${ label } </h1> 232<p class="intro">Links shared by ${ handle } .</p> 233<hr> 234${ follow && `<div class="controls"> ${ follow } </div>` } ${ body } ` ; 235} 236 237export function star ( entry :StarEntry , actor :Actor , csrf ?:string ) :string { 238const { link} = entry ; 239const when = escape ( entry . star . createdAt ); 240const date = new Date ( entry . star . createdAt ); 241const label = Number . isNaN ( date . valueOf ()) 242 ?when 243 :new Intl . DateTimeFormat ( "en-GB" , { 244day :"numeric" , 245month :"long" , 246year :"numeric" , 247hour :"2-digit" , 248minute :"2-digit" , 249hour12 :false , 250timeZone :"UTC" , 251}). format ( date ); 252const domain = entry . star . title 253 ?`<small class="domain muted"> ${ escape ( new URL ( link ). hostname )} </small>` 254 :"" ; 255const comment = entry . star . comment 256 ?`<p class="comment"> ${ escape ( entry . star . comment )} </p>` 257 :"" ; 258const remove = csrf 259 ?` 260<form method="post" action="/delete"> 261<input type="hidden" name="csrf" value=" ${ csrf } "> 262<input type="hidden" name="uri" value=" ${ escape ( entry . uri )} "> 263<button class="inline">Delete</button> 264</form>` 265 :"" ; 266const author = ` ${ avatar ( actor )} ${ actorLink ( actor )} ${ unverified ( actor )} ` ; 267const href = escape ( link ); 268const title = escape ( entry . star . title || link ); 269return ` 270<article class="star"> 271<div class="star-content"> 272<a class="star-link" href=" ${ href } " rel="noopener noreferrer nofollow"> ${ title } </a> 273${ domain } 274${ comment } 275</div> 276<div class="star-meta"> 277<time datetime=" ${ when } ">Starred: ${ label } </time> 278<span class="author"> ${ author } </span> 279</div> ${ remove } 280</article>` ; 281}