the statusphere demo reworked into a vite/react app in a monorepo

Add basic UI and POST /status

+361 -26
+124 -22
src/pages/home.ts
··· 3 3 import { html } from '../view' 4 4 import { shell } from './shell' 5 5 6 + const STATUS_OPTIONS = [ 7 + '๐Ÿ‘', 8 + '๐Ÿ‘Ž', 9 + '๐Ÿ’™', 10 + '๐Ÿฅน', 11 + '๐Ÿ˜ง', 12 + '๐Ÿ˜ค', 13 + '๐Ÿ™ƒ', 14 + '๐Ÿ˜‰', 15 + '๐Ÿ˜Ž', 16 + '๐Ÿค“', 17 + '๐Ÿคจ', 18 + '๐Ÿฅณ', 19 + '๐Ÿ˜ญ', 20 + '๐Ÿ˜ค', 21 + '๐Ÿคฏ', 22 + '๐Ÿซก', 23 + '๐Ÿ’€', 24 + 'โœŠ', 25 + '๐Ÿค˜', 26 + '๐Ÿ‘€', 27 + '๐Ÿง ', 28 + '๐Ÿ‘ฉโ€๐Ÿ’ป', 29 + '๐Ÿง‘โ€๐Ÿ’ป', 30 + '๐Ÿฅท', 31 + '๐ŸงŒ', 32 + '๐Ÿฆ‹', 33 + '๐Ÿš€', 34 + ] 35 + 6 36 type Props = { 7 37 statuses: Status[] 8 38 profile?: { displayName?: string; handle: string } ··· 17 47 18 48 function content({ statuses, profile }: Props) { 19 49 return html`<div id="root"> 20 - <h1>Welcome to the Atmosphere</h1> 21 - ${profile 22 - ? html`<form action="/logout" method="post"> 23 - <p> 24 - Hi, <b>${profile.displayName || profile.handle}</b>. It's pretty 25 - special here. 26 - <button type="submit">Log out.</button> 27 - </p> 28 - </form>` 29 - : html`<p> 30 - It's pretty special here. 31 - <a href="/login">Log in.</a> 32 - </p>`} 33 - <ul> 34 - ${statuses.map((status) => { 35 - return html`<li> 36 - ${status.status} 37 - <a href="${toBskyLink(status.authorDid)}" target="_blank" 38 - >${status.authorDid}</a 39 - > 40 - </li>` 50 + <div class="error"></div> 51 + <div id="header"> 52 + <h1>Statusphere</h1> 53 + <p>Set your status on the Atmosphere.</p> 54 + </div> 55 + <div class="container"> 56 + <div class="card"> 57 + ${profile 58 + ? html`<form action="/logout" method="post" class="session-form"> 59 + <div> 60 + Hi, <strong>${profile.displayName || profile.handle}</strong>. 61 + what's your status today? 62 + </div> 63 + <div> 64 + <button type="submit">Log out</button> 65 + </div> 66 + </form>` 67 + : html`<p><a href="/login">Log in</a> to set your status!</p>`} 68 + </div> 69 + <div class=""> 70 + <div class="status-options"> 71 + ${STATUS_OPTIONS.map( 72 + (status) => 73 + html`<div class="status-option" data-value="${status}"> 74 + ${status} 75 + </div>` 76 + )} 77 + </div> 78 + </div> 79 + <div class="status-line no-line"> 80 + <div class="status">๐Ÿ‘</div> 81 + <div class="desc"> 82 + <a class="author" href="/">@pfrazee.com</a> 83 + is feeling ๐Ÿ‘ on Aug 12, 2024 84 + </div> 85 + </div> 86 + <div class="status-line"> 87 + <div class="status">๐Ÿ‘</div> 88 + <div class="desc"> 89 + <a class="author" href="/">@pfrazee.com</a> 90 + is feeling ๐Ÿ‘ on Aug 12, 2024 91 + </div> 92 + </div> 93 + <div class="status-line"> 94 + <div class="status">๐Ÿ‘</div> 95 + <div class="desc"> 96 + <a class="author" href="/">@pfrazee.com</a> 97 + is feeling ๐Ÿ‘ on Aug 12, 2024 98 + </div> 99 + </div> 100 + <div class="status-line"> 101 + <div class="status">๐Ÿ‘</div> 102 + <div class="desc"> 103 + <a class="author" href="/">@pfrazee.com</a> 104 + is feeling ๐Ÿ‘ on Aug 12, 2024 105 + </div> 106 + </div> 107 + <div class="status-line"> 108 + <div class="status">๐Ÿ‘</div> 109 + <div class="desc"> 110 + <a class="author" href="/">@pfrazee.com</a> 111 + is feeling ๐Ÿ‘ on Aug 12, 2024 112 + </div> 113 + </div> 114 + <div class="status-line"> 115 + <div class="status">๐Ÿ‘</div> 116 + <div class="desc"> 117 + <a class="author" href="/">@pfrazee.com</a> 118 + is feeling ๐Ÿ‘ on Aug 12, 2024 119 + </div> 120 + </div> 121 + ${statuses.map((status, i) => { 122 + return html` 123 + <div class=${i === 0 ? 'status-line no-line' : 'status-line'}> 124 + <div> 125 + <div class="status">${status.status}</div> 126 + </div> 127 + <div class="desc"> 128 + <a class="author" href=${toBskyLink(status.authorDid)} 129 + >@${status.authorDid}</a 130 + > 131 + is feeling ${status.status} on ${ts(status)} 132 + </div> 133 + </div> 134 + ` 41 135 })} 42 - </ul> 136 + </div> 137 + <script src="/public/home.js"></script> 43 138 </div>` 44 139 } 45 140 46 141 function toBskyLink(did: string) { 47 142 return `https://bsky.app/profile/${did}` 48 143 } 144 + 145 + function ts(status: Status) { 146 + const indexedAt = new Date(status.indexedAt) 147 + const updatedAt = new Date(status.updatedAt) 148 + if (updatedAt > indexedAt) return updatedAt.toDateString() 149 + return indexedAt.toDateString() 150 + }
+1 -1
src/pages/shell.ts
··· 4 4 return html`<html> 5 5 <head> 6 6 <title>${title}</title> 7 - <link rel="stylesheet" href="/public/styles.css"> 7 + <link rel="stylesheet" href="/public/styles.css" /> 8 8 </head> 9 9 <body> 10 10 ${content}
+26
src/public/home.js
··· 1 + Array.from(document.querySelectorAll('.status-option'), (el) => { 2 + el.addEventListener('click', async (ev) => { 3 + setError('') 4 + const res = await fetch('/status', { 5 + method: 'POST', 6 + headers: { 'content-type': 'application/json' }, 7 + body: JSON.stringify({ status: el.dataset.value }), 8 + }) 9 + const body = await res.json() 10 + if (body?.error) { 11 + setError(body.error) 12 + } else { 13 + location.reload() 14 + } 15 + }) 16 + }) 17 + 18 + function setError(str) { 19 + const errMsg = document.querySelector('.error') 20 + if (str) { 21 + errMsg.classList.add('visible') 22 + errMsg.textContent = str 23 + } else { 24 + errMsg.classList.remove('visible') 25 + } 26 + }
+144 -3
src/public/styles.css
··· 1 1 body { 2 2 font-family: Arial, Helvetica, sans-serif; 3 - } 4 3 5 - #root { 6 - padding: 20px; 4 + --border-color: #ddd; 5 + --gray-100: #fafafa; 6 + --gray-500: #666; 7 + --gray-700: #333; 8 + --primary-400:#2e8fff; 9 + --primary-500: #0078ff; 10 + --primary-600: #0066db; 11 + --error-500: #f00; 12 + --error-100: #fee; 7 13 } 8 14 9 15 /* ··· 33 39 #root, #__next { 34 40 isolation: isolate; 35 41 } 42 + 43 + /* 44 + Common components 45 + */ 46 + button { 47 + border: 0; 48 + background-color: var(--primary-500); 49 + border-radius: 50px; 50 + color: #fff; 51 + padding: 2px 10px; 52 + cursor: pointer; 53 + } 54 + button:hover { 55 + background: var(--primary-400); 56 + } 57 + 58 + /* 59 + Custom components 60 + */ 61 + .error { 62 + background-color: var(--error-100); 63 + color: var(--error-500); 64 + text-align: center; 65 + padding: 1rem; 66 + display: none; 67 + } 68 + .error.visible { 69 + display: block; 70 + } 71 + 72 + #header { 73 + background-color: #fff; 74 + text-align: center; 75 + padding: 0.5rem 0 1.5rem; 76 + } 77 + 78 + #header h1 { 79 + font-size: 5rem; 80 + } 81 + 82 + .container { 83 + display: flex; 84 + flex-direction: column; 85 + gap: 4px; 86 + margin: 0 auto; 87 + max-width: 600px; 88 + padding: 20px; 89 + } 90 + 91 + .card { 92 + /* border: 1px solid var(--border-color); */ 93 + border-radius: 6px; 94 + padding: 10px 16px; 95 + background-color: #fff; 96 + } 97 + .card > :first-child { 98 + margin-top: 0; 99 + } 100 + .card > :last-child { 101 + margin-bottom: 0; 102 + } 103 + 104 + .session-form { 105 + display: flex; 106 + flex-direction: row; 107 + align-items: center; 108 + justify-content: space-between; 109 + } 110 + 111 + .status-options { 112 + display: flex; 113 + flex-direction: row; 114 + flex-wrap: wrap; 115 + gap: 8px; 116 + margin: 10px 0; 117 + } 118 + 119 + .status-option { 120 + font-size: 2rem; 121 + width: 3rem; 122 + height: 3rem; 123 + background-color: #fff; 124 + border: 1px solid var(--border-color); 125 + border-radius: 3rem; 126 + text-align: center; 127 + box-shadow: 0 1px 4px #0001; 128 + cursor: pointer; 129 + } 130 + 131 + .status-option:hover { 132 + background-color: var(--gray-100); 133 + } 134 + 135 + .status-line { 136 + display: flex; 137 + flex-direction: row; 138 + align-items: center; 139 + gap: 10px; 140 + position: relative; 141 + margin-top: 15px; 142 + } 143 + 144 + .status-line:not(.no-line)::before { 145 + content: ''; 146 + position: absolute; 147 + width: 2px; 148 + background-color: var(--border-color); 149 + left: 1.45rem; 150 + bottom: calc(100% + 2px); 151 + height: 15px; 152 + } 153 + 154 + .status-line .status { 155 + font-size: 2rem; 156 + background-color: #fff; 157 + width: 3rem; 158 + height: 3rem; 159 + border-radius: 1.5rem; 160 + text-align: center; 161 + border: 1px solid var(--border-color); 162 + } 163 + 164 + .status-line .desc { 165 + color: var(--gray-500); 166 + } 167 + 168 + .status-line .author { 169 + color: var(--gray-700); 170 + font-weight: 600; 171 + text-decoration: none; 172 + } 173 + 174 + .status-line .author:hover { 175 + text-decoration: underline; 176 + }
+66
src/routes/index.ts
··· 8 8 import { login } from '#/pages/login' 9 9 import { page } from '#/view' 10 10 import { handler } from './util' 11 + import * as Status from '#/lexicon/types/com/example/status' 11 12 12 13 export const createRouter = (ctx: AppContext) => { 13 14 const router = express.Router() ··· 99 100 } 100 101 const { data: profile } = await agent.getProfile({ actor: session.did }) 101 102 return res.type('html').send(page(home({ statuses, profile }))) 103 + }) 104 + ) 105 + 106 + router.post( 107 + '/status', 108 + handler(async (req, res) => { 109 + const session = await getSession(req, res) 110 + const agent = 111 + session && 112 + (await ctx.oauthClient.restore(session.did).catch(async (err) => { 113 + ctx.logger.warn({ err }, 'oauth restore failed') 114 + await destroySession(req, res) 115 + return null 116 + })) 117 + if (!agent) { 118 + return res.status(401).json({ error: 'Session required' }) 119 + } 120 + 121 + const record = { 122 + $type: 'com.example.status', 123 + status: req.body?.status, 124 + updatedAt: new Date().toISOString(), 125 + } 126 + if (!Status.validateRecord(record).success) { 127 + return res.status(400).json({ error: 'Invalid status' }) 128 + } 129 + 130 + try { 131 + await agent.com.atproto.repo.putRecord({ 132 + repo: agent.accountDid, 133 + collection: 'com.example.status', 134 + rkey: 'self', 135 + record, 136 + validate: false, 137 + }) 138 + } catch (err) { 139 + ctx.logger.warn({ err }, 'failed to write record') 140 + return res.status(500).json({ error: 'Failed to write record' }) 141 + } 142 + 143 + try { 144 + await ctx.db 145 + .insertInto('status') 146 + .values({ 147 + authorDid: agent.accountDid, 148 + status: record.status, 149 + updatedAt: record.updatedAt, 150 + indexedAt: new Date().toISOString(), 151 + }) 152 + .onConflict((oc) => 153 + oc.column('authorDid').doUpdateSet({ 154 + status: record.status, 155 + updatedAt: record.updatedAt, 156 + indexedAt: new Date().toISOString(), 157 + }) 158 + ) 159 + .execute() 160 + } catch (err) { 161 + ctx.logger.warn( 162 + { err }, 163 + 'failed to update computed view; ignoring as it should be caught by the firehose' 164 + ) 165 + } 166 + 167 + res.status(200).json({}) 102 168 }) 103 169 ) 104 170