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