tangled
alpha
login
or
join now
slices.network
/
tools
Tools for the Atmosphere
tools.slices.network
quickslice
atproto
html
7
fork
atom
overview
issues
1
pulls
pipelines
init
chadtmiller.com
1 month ago
4e2caf91
+1068
5 changed files
expand all
collapse all
unified
split
.editorconfig
.gitignore
README.md
deploy.sh
statusphere.html
+9
.editorconfig
···
0
0
0
0
0
0
0
0
0
···
1
+
root = true
2
+
3
+
[*]
4
+
indent_style = space
5
+
indent_size = 2
6
+
end_of_line = lf
7
+
charset = utf-8
8
+
trim_trailing_whitespace = true
9
+
insert_final_newline = true
+1
.gitignore
···
0
···
1
+
.env
+7
README.md
···
0
0
0
0
0
0
0
···
1
+
# tools.slices.network
2
+
3
+
Tools for the Atmosphere, inspired by [Simon Willison's HTML tools](https://simonwillison.net/2025/Dec/10/html-tools/)
4
+
5
+
## Tools
6
+
7
+
- [statusphere](https://tools.slices.network/statusphere) - Statusphere client
+218
deploy.sh
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
#!/usr/bin/env bash
2
+
set -euo pipefail
3
+
4
+
# Bunny CDN Deploy Script for tools.slices.network
5
+
# Syncs .html files to Bunny Storage with clean URLs (no .html extension)
6
+
7
+
# Colors for output
8
+
RED='\033[0;31m'
9
+
GREEN='\033[0;32m'
10
+
YELLOW='\033[1;33m'
11
+
NC='\033[0m' # No Color
12
+
13
+
# Counters
14
+
UPLOADED=0
15
+
DELETED=0
16
+
17
+
# Parse arguments
18
+
DRY_RUN=false
19
+
VERBOSE=false
20
+
21
+
while [[ $# -gt 0 ]]; do
22
+
case $1 in
23
+
--dry-run)
24
+
DRY_RUN=true
25
+
shift
26
+
;;
27
+
--verbose|-v)
28
+
VERBOSE=true
29
+
shift
30
+
;;
31
+
*)
32
+
echo -e "${RED}Unknown option: $1${NC}"
33
+
exit 1
34
+
;;
35
+
esac
36
+
done
37
+
38
+
# Load .env file if it exists
39
+
if [ -f .env ]; then
40
+
set -a
41
+
source .env
42
+
set +a
43
+
fi
44
+
45
+
# Required environment variables
46
+
: "${BUNNY_API_KEY:?BUNNY_API_KEY environment variable is required}"
47
+
: "${BUNNY_STORAGE_PASSWORD:?BUNNY_STORAGE_PASSWORD environment variable is required}"
48
+
: "${BUNNY_STORAGE_ZONE:?BUNNY_STORAGE_ZONE environment variable is required}"
49
+
: "${BUNNY_STORAGE_HOST:?BUNNY_STORAGE_HOST environment variable is required}"
50
+
: "${BUNNY_PULLZONE_ID:?BUNNY_PULLZONE_ID environment variable is required}"
51
+
52
+
# Configuration
53
+
STORAGE_URL="https://${BUNNY_STORAGE_HOST}/${BUNNY_STORAGE_ZONE}"
54
+
55
+
echo "Bunny CDN Deploy - tools.slices.network"
56
+
echo "========================================"
57
+
echo "Storage Zone: ${BUNNY_STORAGE_ZONE}"
58
+
if [ "$DRY_RUN" = true ]; then
59
+
echo -e "${YELLOW}DRY RUN MODE - No changes will be made${NC}"
60
+
fi
61
+
echo ""
62
+
63
+
# Upload a single file (strips .html extension for clean URLs)
64
+
upload_file() {
65
+
local local_path="$1"
66
+
local filename
67
+
filename=$(basename "$local_path")
68
+
# Strip .html extension for clean URLs
69
+
local remote_name="${filename%.html}"
70
+
71
+
if [ "$VERBOSE" = true ]; then
72
+
echo " Uploading: ${filename} -> ${remote_name}"
73
+
fi
74
+
75
+
if [ "$DRY_RUN" = true ]; then
76
+
((UPLOADED++))
77
+
return 0
78
+
fi
79
+
80
+
local response
81
+
local http_code
82
+
83
+
response=$(curl -s -w "\n%{http_code}" -X PUT \
84
+
"${STORAGE_URL}/${remote_name}" \
85
+
-H "AccessKey: ${BUNNY_STORAGE_PASSWORD}" \
86
+
-H "Content-Type: text/html" \
87
+
--data-binary "@${local_path}")
88
+
89
+
http_code=$(echo "$response" | tail -n1)
90
+
91
+
if [[ "$http_code" =~ ^2 ]]; then
92
+
((UPLOADED++))
93
+
return 0
94
+
else
95
+
echo -e "${RED}Failed to upload ${filename}: HTTP ${http_code}${NC}"
96
+
echo "$response" | head -n -1
97
+
return 1
98
+
fi
99
+
}
100
+
101
+
# List all files in remote storage
102
+
list_remote_files() {
103
+
local response
104
+
response=$(curl -s -X GET "${STORAGE_URL}/" \
105
+
-H "AccessKey: ${BUNNY_STORAGE_PASSWORD}" \
106
+
-H "Accept: application/json")
107
+
108
+
echo "$response" | jq -r '.[] | select(.IsDirectory == false) | .ObjectName' 2>/dev/null
109
+
}
110
+
111
+
# Delete a single file from remote
112
+
delete_file() {
113
+
local remote_name="$1"
114
+
115
+
if [ "$VERBOSE" = true ]; then
116
+
echo " Deleting: ${remote_name}"
117
+
fi
118
+
119
+
if [ "$DRY_RUN" = true ]; then
120
+
((DELETED++))
121
+
return 0
122
+
fi
123
+
124
+
local http_code
125
+
http_code=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
126
+
"${STORAGE_URL}/${remote_name}" \
127
+
-H "AccessKey: ${BUNNY_STORAGE_PASSWORD}")
128
+
129
+
if [[ "$http_code" =~ ^2 ]]; then
130
+
((DELETED++))
131
+
return 0
132
+
else
133
+
echo -e "${RED}Failed to delete ${remote_name}: HTTP ${http_code}${NC}"
134
+
return 1
135
+
fi
136
+
}
137
+
138
+
# Purge pull zone cache
139
+
purge_cache() {
140
+
echo "Purging CDN cache..."
141
+
142
+
if [ "$DRY_RUN" = true ]; then
143
+
echo -e "${YELLOW} Would purge pull zone ${BUNNY_PULLZONE_ID}${NC}"
144
+
return 0
145
+
fi
146
+
147
+
local http_code
148
+
http_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
149
+
"https://api.bunny.net/pullzone/${BUNNY_PULLZONE_ID}/purgeCache" \
150
+
-H "AccessKey: ${BUNNY_API_KEY}" \
151
+
-H "Content-Type: application/json")
152
+
153
+
if [[ "$http_code" =~ ^2 ]]; then
154
+
echo -e "${GREEN} Cache purged successfully${NC}"
155
+
return 0
156
+
else
157
+
echo -e "${RED} Failed to purge cache: HTTP ${http_code}${NC}"
158
+
return 1
159
+
fi
160
+
}
161
+
162
+
# ============================================
163
+
# MAIN EXECUTION
164
+
# ============================================
165
+
166
+
# Find all .html files in root directory
167
+
HTML_FILES=$(find . -maxdepth 1 -name "*.html" -type f 2>/dev/null)
168
+
169
+
if [ -z "$HTML_FILES" ]; then
170
+
echo -e "${YELLOW}No .html files found in current directory${NC}"
171
+
exit 0
172
+
fi
173
+
174
+
# Build list of expected remote names (without .html extension)
175
+
LOCAL_NAMES_LIST=$(mktemp)
176
+
trap "rm -f $LOCAL_NAMES_LIST" EXIT
177
+
178
+
# Step 1: Upload all local .html files
179
+
echo "Uploading files..."
180
+
echo "$HTML_FILES" | while read -r file; do
181
+
filename=$(basename "$file")
182
+
remote_name="${filename%.html}"
183
+
echo "$remote_name" >> "$LOCAL_NAMES_LIST"
184
+
upload_file "$file"
185
+
done
186
+
187
+
echo ""
188
+
189
+
# Step 2: Delete orphaned remote files
190
+
echo "Checking for orphaned files..."
191
+
REMOTE_FILES=$(list_remote_files)
192
+
193
+
if [ -n "$REMOTE_FILES" ]; then
194
+
while IFS= read -r remote_file; do
195
+
if [ -z "$remote_file" ]; then
196
+
continue
197
+
fi
198
+
if ! grep -qxF "$remote_file" "$LOCAL_NAMES_LIST"; then
199
+
delete_file "$remote_file"
200
+
fi
201
+
done <<< "$REMOTE_FILES"
202
+
fi
203
+
204
+
echo ""
205
+
206
+
# Step 3: Purge CDN cache
207
+
purge_cache
208
+
209
+
# Summary
210
+
echo ""
211
+
echo "========================================"
212
+
echo -e "${GREEN}Deploy complete!${NC}"
213
+
echo " Uploaded: ${UPLOADED} files"
214
+
echo " Deleted: ${DELETED} files"
215
+
if [ "$DRY_RUN" = true ]; then
216
+
echo -e "${YELLOW} (DRY RUN - no actual changes made)${NC}"
217
+
fi
218
+
echo "========================================"
+833
statusphere.html
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
<!doctype html>
2
+
<html lang="en">
3
+
<head>
4
+
<meta charset="UTF-8" />
5
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+
<meta
7
+
http-equiv="Content-Security-Policy"
8
+
content="default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; connect-src 'self' https://xyzstatusphere.slices.network https://*.webcontainer.io; img-src 'self' https: data:;"
9
+
/>
10
+
<title>Statusphere</title>
11
+
<style>
12
+
/* CSS Reset */
13
+
*,
14
+
*::before,
15
+
*::after {
16
+
box-sizing: border-box;
17
+
}
18
+
* {
19
+
margin: 0;
20
+
}
21
+
body {
22
+
line-height: 1.5;
23
+
-webkit-font-smoothing: antialiased;
24
+
}
25
+
input,
26
+
button {
27
+
font: inherit;
28
+
}
29
+
30
+
/* CSS Variables */
31
+
:root {
32
+
--primary-500: #0078ff;
33
+
--primary-400: #339dff;
34
+
--primary-600: #0060cc;
35
+
--gray-100: #f5f5f5;
36
+
--gray-200: #e5e5e5;
37
+
--gray-500: #737373;
38
+
--gray-700: #404040;
39
+
--gray-900: #171717;
40
+
--border-color: #e5e5e5;
41
+
--error-bg: #fef2f2;
42
+
--error-border: #fecaca;
43
+
--error-text: #dc2626;
44
+
}
45
+
46
+
/* Layout */
47
+
body {
48
+
font-family:
49
+
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
50
+
background: var(--gray-100);
51
+
color: var(--gray-900);
52
+
min-height: 100vh;
53
+
padding: 2rem 1rem;
54
+
}
55
+
56
+
#app {
57
+
max-width: 600px;
58
+
margin: 0 auto;
59
+
}
60
+
61
+
/* Header */
62
+
header {
63
+
text-align: center;
64
+
margin-bottom: 2rem;
65
+
}
66
+
67
+
header h1 {
68
+
font-size: 2.5rem;
69
+
color: var(--primary-500);
70
+
margin-bottom: 0.25rem;
71
+
}
72
+
73
+
.tagline {
74
+
color: var(--gray-500);
75
+
font-size: 1rem;
76
+
}
77
+
78
+
/* Cards */
79
+
.card {
80
+
background: white;
81
+
border-radius: 0.5rem;
82
+
padding: 1.5rem;
83
+
margin-bottom: 1rem;
84
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
85
+
}
86
+
87
+
/* Auth Section */
88
+
.login-form {
89
+
display: flex;
90
+
flex-direction: column;
91
+
gap: 1rem;
92
+
}
93
+
94
+
.form-group {
95
+
display: flex;
96
+
flex-direction: column;
97
+
gap: 0.25rem;
98
+
}
99
+
100
+
.form-group label {
101
+
font-size: 0.875rem;
102
+
font-weight: 500;
103
+
color: var(--gray-700);
104
+
}
105
+
106
+
.form-group input {
107
+
padding: 0.75rem;
108
+
border: 1px solid var(--border-color);
109
+
border-radius: 0.375rem;
110
+
font-size: 1rem;
111
+
}
112
+
113
+
.form-group input:focus {
114
+
outline: none;
115
+
border-color: var(--primary-500);
116
+
box-shadow: 0 0 0 3px rgba(0, 120, 255, 0.1);
117
+
}
118
+
119
+
.btn {
120
+
padding: 0.75rem 1.5rem;
121
+
border: none;
122
+
border-radius: 0.375rem;
123
+
font-size: 1rem;
124
+
font-weight: 500;
125
+
cursor: pointer;
126
+
transition: background-color 0.15s;
127
+
}
128
+
129
+
.btn-primary {
130
+
background: var(--primary-500);
131
+
color: white;
132
+
}
133
+
134
+
.btn-primary:hover {
135
+
background: var(--primary-600);
136
+
}
137
+
138
+
.btn-primary:disabled {
139
+
background: var(--gray-200);
140
+
color: var(--gray-500);
141
+
cursor: not-allowed;
142
+
}
143
+
144
+
.btn-secondary {
145
+
background: var(--gray-200);
146
+
color: var(--gray-700);
147
+
}
148
+
149
+
.btn-secondary:hover {
150
+
background: var(--border-color);
151
+
}
152
+
153
+
/* User Card */
154
+
.user-card {
155
+
display: flex;
156
+
align-items: center;
157
+
justify-content: space-between;
158
+
}
159
+
160
+
.user-info {
161
+
display: flex;
162
+
align-items: center;
163
+
gap: 0.75rem;
164
+
}
165
+
166
+
.user-avatar {
167
+
width: 48px;
168
+
height: 48px;
169
+
border-radius: 50%;
170
+
background: var(--gray-200);
171
+
display: flex;
172
+
align-items: center;
173
+
justify-content: center;
174
+
font-size: 1.5rem;
175
+
}
176
+
177
+
.user-avatar img {
178
+
width: 100%;
179
+
height: 100%;
180
+
border-radius: 50%;
181
+
object-fit: cover;
182
+
}
183
+
184
+
.user-name {
185
+
font-weight: 600;
186
+
}
187
+
188
+
.user-handle {
189
+
font-size: 0.875rem;
190
+
color: var(--gray-500);
191
+
}
192
+
193
+
/* Emoji Picker */
194
+
.emoji-grid {
195
+
display: grid;
196
+
grid-template-columns: repeat(9, 1fr);
197
+
gap: 0.5rem;
198
+
}
199
+
200
+
.emoji-btn {
201
+
width: 100%;
202
+
aspect-ratio: 1;
203
+
font-size: 1.5rem;
204
+
border: 2px solid var(--border-color);
205
+
border-radius: 50%;
206
+
background: white;
207
+
cursor: pointer;
208
+
transition: all 0.15s;
209
+
display: flex;
210
+
align-items: center;
211
+
justify-content: center;
212
+
}
213
+
214
+
.emoji-btn:hover {
215
+
background: rgba(0, 120, 255, 0.1);
216
+
border-color: var(--primary-400);
217
+
}
218
+
219
+
.emoji-btn.selected {
220
+
border-color: var(--primary-500);
221
+
box-shadow: 0 0 0 3px rgba(0, 120, 255, 0.2);
222
+
}
223
+
224
+
.emoji-btn:disabled {
225
+
opacity: 0.5;
226
+
cursor: not-allowed;
227
+
}
228
+
229
+
.emoji-btn:disabled:hover {
230
+
background: white;
231
+
border-color: var(--border-color);
232
+
}
233
+
234
+
/* Status Feed */
235
+
.feed-title {
236
+
font-size: 1.125rem;
237
+
font-weight: 600;
238
+
margin-bottom: 1rem;
239
+
color: var(--gray-700);
240
+
}
241
+
242
+
.status-list {
243
+
list-style: none;
244
+
padding: 0;
245
+
}
246
+
247
+
.status-item {
248
+
position: relative;
249
+
padding-left: 2rem;
250
+
padding-bottom: 1.5rem;
251
+
}
252
+
253
+
.status-item::before {
254
+
content: "";
255
+
position: absolute;
256
+
left: 0.75rem;
257
+
top: 1.5rem;
258
+
bottom: 0;
259
+
width: 2px;
260
+
background: var(--border-color);
261
+
}
262
+
263
+
.status-item:last-child::before {
264
+
display: none;
265
+
}
266
+
267
+
.status-item:last-child {
268
+
padding-bottom: 0;
269
+
}
270
+
271
+
.status-emoji {
272
+
position: absolute;
273
+
left: 0;
274
+
top: 0;
275
+
font-size: 1.5rem;
276
+
}
277
+
278
+
.status-content {
279
+
padding-top: 0.25rem;
280
+
}
281
+
282
+
.status-author {
283
+
color: var(--primary-500);
284
+
text-decoration: none;
285
+
font-weight: 500;
286
+
}
287
+
288
+
.status-author:hover {
289
+
text-decoration: underline;
290
+
}
291
+
292
+
.status-text {
293
+
color: var(--gray-700);
294
+
}
295
+
296
+
.status-date {
297
+
font-size: 0.875rem;
298
+
color: var(--gray-500);
299
+
}
300
+
301
+
/* Error Banner */
302
+
#error-banner {
303
+
position: fixed;
304
+
top: 1rem;
305
+
left: 50%;
306
+
transform: translateX(-50%);
307
+
background: var(--error-bg);
308
+
border: 1px solid var(--error-border);
309
+
color: var(--error-text);
310
+
padding: 0.75rem 1rem;
311
+
border-radius: 0.375rem;
312
+
display: flex;
313
+
align-items: center;
314
+
gap: 0.75rem;
315
+
max-width: 90%;
316
+
z-index: 100;
317
+
}
318
+
319
+
#error-banner.hidden {
320
+
display: none;
321
+
}
322
+
323
+
#error-banner button {
324
+
background: none;
325
+
border: none;
326
+
color: var(--error-text);
327
+
cursor: pointer;
328
+
font-size: 1.25rem;
329
+
line-height: 1;
330
+
}
331
+
332
+
/* Loading State */
333
+
.loading {
334
+
text-align: center;
335
+
color: var(--gray-500);
336
+
padding: 2rem;
337
+
}
338
+
339
+
/* Responsive */
340
+
@media (max-width: 480px) {
341
+
.emoji-grid {
342
+
grid-template-columns: repeat(6, 1fr);
343
+
}
344
+
345
+
.emoji-btn {
346
+
font-size: 1.25rem;
347
+
}
348
+
}
349
+
350
+
/* Hidden utility */
351
+
.hidden {
352
+
display: none !important;
353
+
}
354
+
</style>
355
+
</head>
356
+
<body>
357
+
<div id="app">
358
+
<header>
359
+
<h1>Statusphere</h1>
360
+
<p class="tagline">Set your status on the Atmosphere</p>
361
+
</header>
362
+
<main>
363
+
<div id="auth-section"></div>
364
+
<div id="emoji-picker"></div>
365
+
<div id="status-feed"></div>
366
+
</main>
367
+
<div id="error-banner" class="hidden"></div>
368
+
</div>
369
+
370
+
<!-- Quickslice Client SDK -->
371
+
<script src="https://cdn.jsdelivr.net/gh/bigmoves/quickslice@main/quickslice-client-js/dist/quickslice-client.min.js"></script>
372
+
373
+
<script>
374
+
// =============================================================================
375
+
// CONFIGURATION
376
+
// =============================================================================
377
+
378
+
const SERVER_URL = "https://xyzstatusphere.slices.network";
379
+
const CLIENT_ID = "client_vPEnCW98y5BNr5PrYOHPXg"; // Set your OAuth client ID here
380
+
381
+
const EMOJIS = [
382
+
"👍",
383
+
"👎",
384
+
"💙",
385
+
"😧",
386
+
"😤",
387
+
"🙃",
388
+
"😉",
389
+
"😎",
390
+
"🤩",
391
+
"🥳",
392
+
"😭",
393
+
"😱",
394
+
"🥺",
395
+
"😡",
396
+
"💀",
397
+
"🤖",
398
+
"👻",
399
+
"👽",
400
+
"🎃",
401
+
"🤡",
402
+
"💩",
403
+
"🔥",
404
+
"⭐",
405
+
"🌈",
406
+
"🍕",
407
+
"🎉",
408
+
"💯",
409
+
];
410
+
411
+
// Client instance
412
+
let client;
413
+
414
+
// =============================================================================
415
+
// INITIALIZATION
416
+
// =============================================================================
417
+
418
+
async function main() {
419
+
// Check if this is an OAuth callback
420
+
if (window.location.search.includes("code=")) {
421
+
if (!CLIENT_ID) {
422
+
showError(
423
+
"OAuth callback received but CLIENT_ID is not configured.",
424
+
);
425
+
renderLoginForm();
426
+
return;
427
+
}
428
+
429
+
try {
430
+
client = await QuicksliceClient.createQuicksliceClient({
431
+
server: SERVER_URL,
432
+
clientId: CLIENT_ID,
433
+
});
434
+
await client.handleRedirectCallback();
435
+
console.log("OAuth callback handled successfully");
436
+
} catch (error) {
437
+
console.error("OAuth callback error:", error);
438
+
showError(`Authentication failed: ${error.message}`);
439
+
renderLoginForm();
440
+
renderEmojiPicker(null, false);
441
+
await loadAndRenderStatuses();
442
+
return;
443
+
}
444
+
} else if (CLIENT_ID) {
445
+
// Initialize client with configured ID
446
+
try {
447
+
client = await QuicksliceClient.createQuicksliceClient({
448
+
server: SERVER_URL,
449
+
clientId: CLIENT_ID,
450
+
});
451
+
} catch (error) {
452
+
console.error("Failed to initialize client:", error);
453
+
}
454
+
}
455
+
456
+
// Render based on auth state
457
+
await renderApp();
458
+
}
459
+
460
+
async function renderApp() {
461
+
const isLoggedIn = client && (await client.isAuthenticated());
462
+
463
+
if (isLoggedIn) {
464
+
try {
465
+
const viewer = await fetchViewer();
466
+
renderUserCard(viewer);
467
+
} catch (error) {
468
+
console.error("Failed to fetch viewer:", error);
469
+
renderUserCard(null);
470
+
}
471
+
} else {
472
+
renderLoginForm();
473
+
}
474
+
475
+
// Render emoji picker (enabled only if logged in)
476
+
renderEmojiPicker(null, isLoggedIn);
477
+
478
+
// Load statuses
479
+
await loadAndRenderStatuses();
480
+
}
481
+
482
+
// =============================================================================
483
+
// DATA FETCHING
484
+
// =============================================================================
485
+
486
+
async function fetchStatuses() {
487
+
const query = `
488
+
query GetStatuses {
489
+
xyzStatusphereStatus(
490
+
first: 20
491
+
sortBy: [{ field: "createdAt", direction: DESC }]
492
+
) {
493
+
edges {
494
+
node {
495
+
uri
496
+
did
497
+
status
498
+
createdAt
499
+
appBskyActorProfileByDid {
500
+
actorHandle
501
+
displayName
502
+
}
503
+
}
504
+
}
505
+
}
506
+
}
507
+
`;
508
+
509
+
// Use client if available, otherwise create a temporary one for public query
510
+
if (client) {
511
+
const data = await client.publicQuery(query);
512
+
return data.xyzStatusphereStatus?.edges?.map((e) => e.node) || [];
513
+
} else {
514
+
// For unauthenticated users, make a direct fetch
515
+
const response = await fetch(`${SERVER_URL}/graphql`, {
516
+
method: "POST",
517
+
headers: { "Content-Type": "application/json" },
518
+
body: JSON.stringify({ query }),
519
+
});
520
+
const result = await response.json();
521
+
return (
522
+
result.data?.xyzStatusphereStatus?.edges?.map((e) => e.node) || []
523
+
);
524
+
}
525
+
}
526
+
527
+
async function fetchViewer() {
528
+
const query = `
529
+
query {
530
+
viewer {
531
+
did
532
+
handle
533
+
appBskyActorProfileByDid {
534
+
displayName
535
+
avatar { url }
536
+
}
537
+
}
538
+
}
539
+
`;
540
+
541
+
const data = await client.query(query);
542
+
return data?.viewer;
543
+
}
544
+
545
+
async function postStatus(emoji) {
546
+
const mutation = `
547
+
mutation CreateStatus($status: String!, $createdAt: DateTime!) {
548
+
createXyzStatusphereStatus(
549
+
input: { status: $status, createdAt: $createdAt }
550
+
) {
551
+
uri
552
+
status
553
+
createdAt
554
+
}
555
+
}
556
+
`;
557
+
558
+
const variables = {
559
+
status: emoji,
560
+
createdAt: new Date().toISOString(),
561
+
};
562
+
563
+
return await client.mutate(mutation, variables);
564
+
}
565
+
566
+
async function loadAndRenderStatuses() {
567
+
renderLoading("status-feed");
568
+
try {
569
+
const statuses = await fetchStatuses();
570
+
renderStatusFeed(statuses);
571
+
} catch (error) {
572
+
console.error("Failed to fetch statuses:", error);
573
+
document.getElementById("status-feed").innerHTML = `
574
+
<div class="card">
575
+
<p class="loading" style="color: var(--error-text);">
576
+
Failed to load statuses. Is the quickslice server running at ${SERVER_URL}?
577
+
</p>
578
+
</div>
579
+
`;
580
+
}
581
+
}
582
+
583
+
// =============================================================================
584
+
// EVENT HANDLERS
585
+
// =============================================================================
586
+
587
+
async function handleLogin(event) {
588
+
event.preventDefault();
589
+
590
+
const handle = document.getElementById("handle").value.trim();
591
+
592
+
if (!handle) {
593
+
showError("Please enter your Bluesky handle");
594
+
return;
595
+
}
596
+
597
+
try {
598
+
client = await QuicksliceClient.createQuicksliceClient({
599
+
server: SERVER_URL,
600
+
clientId: CLIENT_ID,
601
+
});
602
+
603
+
await client.loginWithRedirect({ handle });
604
+
} catch (error) {
605
+
showError(`Login failed: ${error.message}`);
606
+
}
607
+
}
608
+
609
+
async function selectStatus(emoji) {
610
+
if (!client || !(await client.isAuthenticated())) {
611
+
showError("Please login to set your status");
612
+
return;
613
+
}
614
+
615
+
try {
616
+
// Disable buttons while posting
617
+
document
618
+
.querySelectorAll(".emoji-btn")
619
+
.forEach((btn) => (btn.disabled = true));
620
+
621
+
await postStatus(emoji);
622
+
623
+
// Refresh the page to show new status
624
+
window.location.reload();
625
+
} catch (error) {
626
+
showError(`Failed to post status: ${error.message}`);
627
+
// Re-enable buttons
628
+
document
629
+
.querySelectorAll(".emoji-btn")
630
+
.forEach((btn) => (btn.disabled = false));
631
+
}
632
+
}
633
+
634
+
function logout() {
635
+
if (client) {
636
+
client.logout();
637
+
} else {
638
+
window.location.reload();
639
+
}
640
+
}
641
+
642
+
// =============================================================================
643
+
// UI RENDERING
644
+
// =============================================================================
645
+
646
+
function showError(message) {
647
+
const banner = document.getElementById("error-banner");
648
+
banner.innerHTML = `
649
+
<span>${escapeHtml(message)}</span>
650
+
<button onclick="hideError()">×</button>
651
+
`;
652
+
banner.classList.remove("hidden");
653
+
}
654
+
655
+
function hideError() {
656
+
document.getElementById("error-banner").classList.add("hidden");
657
+
}
658
+
659
+
function escapeHtml(text) {
660
+
const div = document.createElement("div");
661
+
div.textContent = text;
662
+
return div.innerHTML;
663
+
}
664
+
665
+
function formatDate(dateString) {
666
+
const date = new Date(dateString);
667
+
const now = new Date();
668
+
const isToday = date.toDateString() === now.toDateString();
669
+
670
+
if (isToday) {
671
+
return "today";
672
+
}
673
+
674
+
return date.toLocaleDateString("en-US", {
675
+
month: "short",
676
+
day: "numeric",
677
+
year:
678
+
date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
679
+
});
680
+
}
681
+
682
+
function renderLoginForm() {
683
+
const container = document.getElementById("auth-section");
684
+
685
+
// Show configuration message if CLIENT_ID is not set
686
+
if (!CLIENT_ID) {
687
+
container.innerHTML = `
688
+
<div class="card">
689
+
<p style="color: var(--error-text); text-align: center; margin-bottom: 1rem;">
690
+
<strong>Configuration Required</strong>
691
+
</p>
692
+
<p style="color: var(--gray-700); text-align: center;">
693
+
Please set the <code style="background: var(--gray-100); padding: 0.125rem 0.375rem; border-radius: 0.25rem;">CLIENT_ID</code> constant in this file to your OAuth client ID.
694
+
</p>
695
+
</div>
696
+
`;
697
+
return;
698
+
}
699
+
700
+
container.innerHTML = `
701
+
<div class="card">
702
+
<form class="login-form" onsubmit="handleLogin(event)">
703
+
<div class="form-group">
704
+
<label for="handle">Bluesky Handle</label>
705
+
<input
706
+
type="text"
707
+
id="handle"
708
+
placeholder="you.bsky.social"
709
+
required
710
+
>
711
+
</div>
712
+
<button type="submit" class="btn btn-primary">Login with Bluesky</button>
713
+
</form>
714
+
<p style="margin-top: 1rem; font-size: 0.875rem; color: var(--gray-500); text-align: center;">
715
+
Don't have a Bluesky account? <a href="https://bsky.app" target="_blank">Sign up</a>
716
+
</p>
717
+
</div>
718
+
`;
719
+
}
720
+
721
+
function renderUserCard(viewer) {
722
+
const container = document.getElementById("auth-section");
723
+
const displayName =
724
+
viewer?.appBskyActorProfileByDid?.displayName || "User";
725
+
const handle = viewer?.handle || "unknown";
726
+
const avatarUrl = viewer?.appBskyActorProfileByDid?.avatar?.url;
727
+
728
+
container.innerHTML = `
729
+
<div class="card user-card">
730
+
<div class="user-info">
731
+
<div class="user-avatar">
732
+
${
733
+
avatarUrl
734
+
? `<img src="${escapeHtml(avatarUrl)}" alt="Avatar">`
735
+
: "👤"
736
+
}
737
+
</div>
738
+
<div>
739
+
<div class="user-name">Hi, ${escapeHtml(displayName)}!</div>
740
+
<div class="user-handle">@${escapeHtml(handle)}</div>
741
+
</div>
742
+
</div>
743
+
<button class="btn btn-secondary" onclick="logout()">Logout</button>
744
+
</div>
745
+
`;
746
+
}
747
+
748
+
function renderEmojiPicker(currentStatus, enabled = true) {
749
+
const container = document.getElementById("emoji-picker");
750
+
751
+
container.innerHTML = `
752
+
<div class="card">
753
+
<div class="emoji-grid">
754
+
${EMOJIS.map(
755
+
(emoji) => `
756
+
<button
757
+
class="emoji-btn ${emoji === currentStatus ? "selected" : ""}"
758
+
onclick="selectStatus('${emoji}')"
759
+
${!enabled ? "disabled" : ""}
760
+
title="${enabled ? "Set status" : "Login to set status"}"
761
+
>
762
+
${emoji}
763
+
</button>
764
+
`,
765
+
).join("")}
766
+
</div>
767
+
</div>
768
+
`;
769
+
}
770
+
771
+
function renderStatusFeed(statuses) {
772
+
const container = document.getElementById("status-feed");
773
+
774
+
if (statuses.length === 0) {
775
+
container.innerHTML = `
776
+
<div class="card">
777
+
<p class="loading">No statuses yet. Be the first to post!</p>
778
+
</div>
779
+
`;
780
+
return;
781
+
}
782
+
783
+
container.innerHTML = `
784
+
<div class="card">
785
+
<h2 class="feed-title">Recent Statuses</h2>
786
+
<ul class="status-list">
787
+
${statuses
788
+
.map((status) => {
789
+
const handle =
790
+
status.appBskyActorProfileByDid?.actorHandle || status.did;
791
+
const displayHandle = handle.startsWith("did:")
792
+
? handle.substring(0, 20) + "..."
793
+
: handle;
794
+
const profileUrl = handle.startsWith("did:")
795
+
? `https://bsky.app/profile/${status.did}`
796
+
: `https://bsky.app/profile/${handle}`;
797
+
798
+
return `
799
+
<li class="status-item">
800
+
<span class="status-emoji">${status.status}</span>
801
+
<div class="status-content">
802
+
<span class="status-text">
803
+
<a href="${profileUrl}" target="_blank" class="status-author">@${escapeHtml(
804
+
displayHandle,
805
+
)}</a>
806
+
is feeling ${status.status}
807
+
</span>
808
+
<div class="status-date">${formatDate(
809
+
status.createdAt,
810
+
)}</div>
811
+
</div>
812
+
</li>
813
+
`;
814
+
})
815
+
.join("")}
816
+
</ul>
817
+
</div>
818
+
`;
819
+
}
820
+
821
+
function renderLoading(container) {
822
+
document.getElementById(container).innerHTML = `
823
+
<div class="card">
824
+
<p class="loading">Loading...</p>
825
+
</div>
826
+
`;
827
+
}
828
+
829
+
// Run on page load
830
+
main();
831
+
</script>
832
+
</body>
833
+
</html>