Scratch space for learning atproto app development

Add basic UI and POST /status

Changed files
+361 -26
src
+124 -22
src/pages/home.ts
···
import { html } from '../view'
import { shell } from './shell'
+
const STATUS_OPTIONS = [
+
'๐Ÿ‘',
+
'๐Ÿ‘Ž',
+
'๐Ÿ’™',
+
'๐Ÿฅน',
+
'๐Ÿ˜ง',
+
'๐Ÿ˜ค',
+
'๐Ÿ™ƒ',
+
'๐Ÿ˜‰',
+
'๐Ÿ˜Ž',
+
'๐Ÿค“',
+
'๐Ÿคจ',
+
'๐Ÿฅณ',
+
'๐Ÿ˜ญ',
+
'๐Ÿ˜ค',
+
'๐Ÿคฏ',
+
'๐Ÿซก',
+
'๐Ÿ’€',
+
'โœŠ',
+
'๐Ÿค˜',
+
'๐Ÿ‘€',
+
'๐Ÿง ',
+
'๐Ÿ‘ฉโ€๐Ÿ’ป',
+
'๐Ÿง‘โ€๐Ÿ’ป',
+
'๐Ÿฅท',
+
'๐ŸงŒ',
+
'๐Ÿฆ‹',
+
'๐Ÿš€',
+
]
+
type Props = {
statuses: Status[]
profile?: { displayName?: string; handle: string }
···
function content({ statuses, profile }: Props) {
return html`<div id="root">
-
<h1>Welcome to the Atmosphere</h1>
-
${profile
-
? html`<form action="/logout" method="post">
-
<p>
-
Hi, <b>${profile.displayName || profile.handle}</b>. It's pretty
-
special here.
-
<button type="submit">Log out.</button>
-
</p>
-
</form>`
-
: html`<p>
-
It's pretty special here.
-
<a href="/login">Log in.</a>
-
</p>`}
-
<ul>
-
${statuses.map((status) => {
-
return html`<li>
-
${status.status}
-
<a href="${toBskyLink(status.authorDid)}" target="_blank"
-
>${status.authorDid}</a
-
>
-
</li>`
+
<div class="error"></div>
+
<div id="header">
+
<h1>Statusphere</h1>
+
<p>Set your status on the Atmosphere.</p>
+
</div>
+
<div class="container">
+
<div class="card">
+
${profile
+
? html`<form action="/logout" method="post" class="session-form">
+
<div>
+
Hi, <strong>${profile.displayName || profile.handle}</strong>.
+
what's your status today?
+
</div>
+
<div>
+
<button type="submit">Log out</button>
+
</div>
+
</form>`
+
: html`<p><a href="/login">Log in</a> to set your status!</p>`}
+
</div>
+
<div class="">
+
<div class="status-options">
+
${STATUS_OPTIONS.map(
+
(status) =>
+
html`<div class="status-option" data-value="${status}">
+
${status}
+
</div>`
+
)}
+
</div>
+
</div>
+
<div class="status-line no-line">
+
<div class="status">๐Ÿ‘</div>
+
<div class="desc">
+
<a class="author" href="/">@pfrazee.com</a>
+
is feeling ๐Ÿ‘ on Aug 12, 2024
+
</div>
+
</div>
+
<div class="status-line">
+
<div class="status">๐Ÿ‘</div>
+
<div class="desc">
+
<a class="author" href="/">@pfrazee.com</a>
+
is feeling ๐Ÿ‘ on Aug 12, 2024
+
</div>
+
</div>
+
<div class="status-line">
+
<div class="status">๐Ÿ‘</div>
+
<div class="desc">
+
<a class="author" href="/">@pfrazee.com</a>
+
is feeling ๐Ÿ‘ on Aug 12, 2024
+
</div>
+
</div>
+
<div class="status-line">
+
<div class="status">๐Ÿ‘</div>
+
<div class="desc">
+
<a class="author" href="/">@pfrazee.com</a>
+
is feeling ๐Ÿ‘ on Aug 12, 2024
+
</div>
+
</div>
+
<div class="status-line">
+
<div class="status">๐Ÿ‘</div>
+
<div class="desc">
+
<a class="author" href="/">@pfrazee.com</a>
+
is feeling ๐Ÿ‘ on Aug 12, 2024
+
</div>
+
</div>
+
<div class="status-line">
+
<div class="status">๐Ÿ‘</div>
+
<div class="desc">
+
<a class="author" href="/">@pfrazee.com</a>
+
is feeling ๐Ÿ‘ on Aug 12, 2024
+
</div>
+
</div>
+
${statuses.map((status, i) => {
+
return html`
+
<div class=${i === 0 ? 'status-line no-line' : 'status-line'}>
+
<div>
+
<div class="status">${status.status}</div>
+
</div>
+
<div class="desc">
+
<a class="author" href=${toBskyLink(status.authorDid)}
+
>@${status.authorDid}</a
+
>
+
is feeling ${status.status} on ${ts(status)}
+
</div>
+
</div>
+
`
})}
-
</ul>
+
</div>
+
<script src="/public/home.js"></script>
</div>`
}
function toBskyLink(did: string) {
return `https://bsky.app/profile/${did}`
}
+
+
function ts(status: Status) {
+
const indexedAt = new Date(status.indexedAt)
+
const updatedAt = new Date(status.updatedAt)
+
if (updatedAt > indexedAt) return updatedAt.toDateString()
+
return indexedAt.toDateString()
+
}
+1 -1
src/pages/shell.ts
···
return html`<html>
<head>
<title>${title}</title>
-
<link rel="stylesheet" href="/public/styles.css">
+
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body>
${content}
+26
src/public/home.js
···
+
Array.from(document.querySelectorAll('.status-option'), (el) => {
+
el.addEventListener('click', async (ev) => {
+
setError('')
+
const res = await fetch('/status', {
+
method: 'POST',
+
headers: { 'content-type': 'application/json' },
+
body: JSON.stringify({ status: el.dataset.value }),
+
})
+
const body = await res.json()
+
if (body?.error) {
+
setError(body.error)
+
} else {
+
location.reload()
+
}
+
})
+
})
+
+
function setError(str) {
+
const errMsg = document.querySelector('.error')
+
if (str) {
+
errMsg.classList.add('visible')
+
errMsg.textContent = str
+
} else {
+
errMsg.classList.remove('visible')
+
}
+
}
+144 -3
src/public/styles.css
···
body {
font-family: Arial, Helvetica, sans-serif;
-
}
-
#root {
-
padding: 20px;
+
--border-color: #ddd;
+
--gray-100: #fafafa;
+
--gray-500: #666;
+
--gray-700: #333;
+
--primary-400:#2e8fff;
+
--primary-500: #0078ff;
+
--primary-600: #0066db;
+
--error-500: #f00;
+
--error-100: #fee;
}
/*
···
#root, #__next {
isolation: isolate;
}
+
+
/*
+
Common components
+
*/
+
button {
+
border: 0;
+
background-color: var(--primary-500);
+
border-radius: 50px;
+
color: #fff;
+
padding: 2px 10px;
+
cursor: pointer;
+
}
+
button:hover {
+
background: var(--primary-400);
+
}
+
+
/*
+
Custom components
+
*/
+
.error {
+
background-color: var(--error-100);
+
color: var(--error-500);
+
text-align: center;
+
padding: 1rem;
+
display: none;
+
}
+
.error.visible {
+
display: block;
+
}
+
+
#header {
+
background-color: #fff;
+
text-align: center;
+
padding: 0.5rem 0 1.5rem;
+
}
+
+
#header h1 {
+
font-size: 5rem;
+
}
+
+
.container {
+
display: flex;
+
flex-direction: column;
+
gap: 4px;
+
margin: 0 auto;
+
max-width: 600px;
+
padding: 20px;
+
}
+
+
.card {
+
/* border: 1px solid var(--border-color); */
+
border-radius: 6px;
+
padding: 10px 16px;
+
background-color: #fff;
+
}
+
.card > :first-child {
+
margin-top: 0;
+
}
+
.card > :last-child {
+
margin-bottom: 0;
+
}
+
+
.session-form {
+
display: flex;
+
flex-direction: row;
+
align-items: center;
+
justify-content: space-between;
+
}
+
+
.status-options {
+
display: flex;
+
flex-direction: row;
+
flex-wrap: wrap;
+
gap: 8px;
+
margin: 10px 0;
+
}
+
+
.status-option {
+
font-size: 2rem;
+
width: 3rem;
+
height: 3rem;
+
background-color: #fff;
+
border: 1px solid var(--border-color);
+
border-radius: 3rem;
+
text-align: center;
+
box-shadow: 0 1px 4px #0001;
+
cursor: pointer;
+
}
+
+
.status-option:hover {
+
background-color: var(--gray-100);
+
}
+
+
.status-line {
+
display: flex;
+
flex-direction: row;
+
align-items: center;
+
gap: 10px;
+
position: relative;
+
margin-top: 15px;
+
}
+
+
.status-line:not(.no-line)::before {
+
content: '';
+
position: absolute;
+
width: 2px;
+
background-color: var(--border-color);
+
left: 1.45rem;
+
bottom: calc(100% + 2px);
+
height: 15px;
+
}
+
+
.status-line .status {
+
font-size: 2rem;
+
background-color: #fff;
+
width: 3rem;
+
height: 3rem;
+
border-radius: 1.5rem;
+
text-align: center;
+
border: 1px solid var(--border-color);
+
}
+
+
.status-line .desc {
+
color: var(--gray-500);
+
}
+
+
.status-line .author {
+
color: var(--gray-700);
+
font-weight: 600;
+
text-decoration: none;
+
}
+
+
.status-line .author:hover {
+
text-decoration: underline;
+
}
+66
src/routes/index.ts
···
import { login } from '#/pages/login'
import { page } from '#/view'
import { handler } from './util'
+
import * as Status from '#/lexicon/types/com/example/status'
export const createRouter = (ctx: AppContext) => {
const router = express.Router()
···
}
const { data: profile } = await agent.getProfile({ actor: session.did })
return res.type('html').send(page(home({ statuses, profile })))
+
})
+
)
+
+
router.post(
+
'/status',
+
handler(async (req, res) => {
+
const session = await getSession(req, res)
+
const agent =
+
session &&
+
(await ctx.oauthClient.restore(session.did).catch(async (err) => {
+
ctx.logger.warn({ err }, 'oauth restore failed')
+
await destroySession(req, res)
+
return null
+
}))
+
if (!agent) {
+
return res.status(401).json({ error: 'Session required' })
+
}
+
+
const record = {
+
$type: 'com.example.status',
+
status: req.body?.status,
+
updatedAt: new Date().toISOString(),
+
}
+
if (!Status.validateRecord(record).success) {
+
return res.status(400).json({ error: 'Invalid status' })
+
}
+
+
try {
+
await agent.com.atproto.repo.putRecord({
+
repo: agent.accountDid,
+
collection: 'com.example.status',
+
rkey: 'self',
+
record,
+
validate: false,
+
})
+
} catch (err) {
+
ctx.logger.warn({ err }, 'failed to write record')
+
return res.status(500).json({ error: 'Failed to write record' })
+
}
+
+
try {
+
await ctx.db
+
.insertInto('status')
+
.values({
+
authorDid: agent.accountDid,
+
status: record.status,
+
updatedAt: record.updatedAt,
+
indexedAt: new Date().toISOString(),
+
})
+
.onConflict((oc) =>
+
oc.column('authorDid').doUpdateSet({
+
status: record.status,
+
updatedAt: record.updatedAt,
+
indexedAt: new Date().toISOString(),
+
})
+
)
+
.execute()
+
} catch (err) {
+
ctx.logger.warn(
+
{ err },
+
'failed to update computed view; ignoring as it should be caught by the firehose'
+
)
+
}
+
+
res.status(200).json({})
})
)