-33
.github/workflows/ci.yml
-33
.github/workflows/ci.yml
···
1
-
name: CI
2
-
3
-
on:
4
-
push:
5
-
branches: [ main ]
6
-
pull_request:
7
-
branches: [ main ]
8
-
9
-
env:
10
-
CARGO_TERM_COLOR: always
11
-
12
-
jobs:
13
-
test:
14
-
name: Test
15
-
runs-on: ubuntu-latest
16
-
steps:
17
-
- uses: actions/checkout@v4
18
-
- uses: dtolnay/rust-toolchain@stable
19
-
with:
20
-
components: rustfmt, clippy
21
-
- uses: Swatinem/rust-cache@v2
22
-
23
-
- name: Check formatting
24
-
run: cargo fmt -- --check
25
-
26
-
- name: Build
27
-
run: cargo build --verbose
28
-
29
-
- name: Run clippy
30
-
run: cargo clippy -- -D warnings
31
-
32
-
- name: Run tests
33
-
run: cargo test --verbose
-52
.github/workflows/fly-review.yml
-52
.github/workflows/fly-review.yml
···
1
-
name: Deploy Review App
2
-
on:
3
-
# Run this workflow on every PR event. Existing review apps will be updated when the PR is updated.
4
-
pull_request:
5
-
types: [opened, reopened, synchronize, closed]
6
-
paths:
7
-
- 'src/**'
8
-
- 'templates/**'
9
-
- 'static/**'
10
-
- 'Cargo.toml'
11
-
- 'Cargo.lock'
12
-
- 'Dockerfile'
13
-
- 'fly.toml'
14
-
- 'fly.review.toml'
15
-
- 'build.rs'
16
-
- 'sqlx-data.json'
17
-
env:
18
-
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
19
-
# Set these to your Fly.io organization and preferred region.
20
-
FLY_REGION: ewr
21
-
FLY_ORG: personal
22
-
23
-
jobs:
24
-
review_app:
25
-
runs-on: ubuntu-latest
26
-
outputs:
27
-
url: ${{ steps.deploy.outputs.url }}
28
-
# Only run one deployment at a time per PR.
29
-
concurrency:
30
-
group: pr-${{ github.event.number }}
31
-
32
-
# Deploying apps with this "review" environment allows the URL for the app to be displayed in the PR UI.
33
-
environment:
34
-
name: review
35
-
# The script in the `deploy` sets the URL output for each review app.
36
-
url: ${{ steps.deploy.outputs.url }}
37
-
steps:
38
-
- name: Get code
39
-
uses: actions/checkout@v4
40
-
41
-
- name: Deploy PR app to Fly.io
42
-
id: deploy
43
-
uses: superfly/fly-pr-review-apps@1.2.1
44
-
with:
45
-
name: zzstoatzz-status-pr-${{ github.event.number }}
46
-
config: fly.review.toml
47
-
# Use smaller resources for review apps
48
-
vmsize: shared-cpu-1x
49
-
memory: 256
50
-
# Set OAUTH_REDIRECT_BASE dynamically for OAuth redirects
51
-
secrets: |
52
-
OAUTH_REDIRECT_BASE=https://zzstoatzz-status-pr-${{ github.event.number }}.fly.dev
-16
.github/workflows/fly.yml
-16
.github/workflows/fly.yml
···
1
-
name: Fly Deploy
2
-
on:
3
-
push:
4
-
branches:
5
-
- main
6
-
jobs:
7
-
deploy:
8
-
name: Deploy app
9
-
runs-on: ubuntu-latest
10
-
concurrency: deploy-group # ensure only one action runs at a time
11
-
steps:
12
-
- uses: actions/checkout@v4
13
-
- uses: superfly/flyctl-actions/setup-flyctl@master
14
-
- run: flyctl deploy --remote-only
15
-
env:
16
-
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
+5
-4
.gitignore
+5
-4
.gitignore
+59
-40
Dockerfile
+59
-40
Dockerfile
···
1
-
# Build stage
2
-
FROM rustlang/rust:nightly-slim AS builder
1
+
ARG GLEAM_VERSION=v1.13.0
3
2
4
-
# Install build dependencies
5
-
RUN apt-get update && apt-get install -y \
6
-
pkg-config \
7
-
libssl-dev \
8
-
&& rm -rf /var/lib/apt/lists/*
3
+
# Build stage - compile the application
4
+
FROM ghcr.io/gleam-lang/gleam:${GLEAM_VERSION}-erlang-alpine AS builder
9
5
10
-
WORKDIR /app
6
+
# Install build dependencies (including PostgreSQL client for multi-database support)
7
+
RUN apk add --no-cache \
8
+
bash \
9
+
git \
10
+
nodejs \
11
+
npm \
12
+
build-base \
13
+
sqlite-dev \
14
+
postgresql-dev
11
15
12
-
# Copy manifests
13
-
COPY Cargo.toml Cargo.lock ./
16
+
# Configure git for non-interactive use
17
+
ENV GIT_TERMINAL_PROMPT=0
14
18
15
-
# Copy source code
16
-
COPY src ./src
17
-
COPY templates ./templates
18
-
COPY lexicons ./lexicons
19
-
COPY static ./static
19
+
# Clone quickslice at the v0.17.3 tag (includes sub claim fix)
20
+
RUN git clone --depth 1 --branch v0.17.3 https://github.com/bigmoves/quickslice.git /build
21
+
22
+
# Install dependencies for all projects
23
+
RUN cd /build/client && gleam deps download
24
+
RUN cd /build/lexicon_graphql && gleam deps download
25
+
RUN cd /build/server && gleam deps download
26
+
27
+
# Apply patches to dependencies
28
+
RUN cd /build && patch -p1 < patches/mist-websocket-protocol.patch
29
+
30
+
# Install JavaScript dependencies for client
31
+
RUN cd /build/client && npm install
20
32
21
-
# Build for release
22
-
RUN cargo build --release
33
+
# Compile the client code and output to server's static directory
34
+
RUN cd /build/client \
35
+
&& gleam add --dev lustre_dev_tools \
36
+
&& gleam run -m lustre/dev build quickslice_client --minify --outdir=/build/server/priv/static
37
+
38
+
# Compile the server code
39
+
RUN cd /build/server \
40
+
&& gleam export erlang-shipment
23
41
24
-
# Runtime stage
25
-
FROM debian:bookworm-slim
42
+
# Runtime stage - slim image with only what's needed to run
43
+
FROM ghcr.io/gleam-lang/gleam:${GLEAM_VERSION}-erlang-alpine
26
44
27
-
# Install runtime dependencies
28
-
RUN apt-get update && apt-get install -y \
29
-
ca-certificates \
30
-
libssl3 \
31
-
&& rm -rf /var/lib/apt/lists/*
45
+
# Install runtime dependencies and dbmate for migrations
46
+
ARG TARGETARCH
47
+
RUN apk add --no-cache sqlite-libs sqlite libpq curl \
48
+
&& DBMATE_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "arm64" || echo "amd64") \
49
+
&& curl -fsSL -o /usr/local/bin/dbmate https://github.com/amacneil/dbmate/releases/latest/download/dbmate-linux-${DBMATE_ARCH} \
50
+
&& chmod +x /usr/local/bin/dbmate
32
51
33
-
WORKDIR /app
52
+
# Copy the compiled server code from the builder stage
53
+
COPY --from=builder /build/server/build/erlang-shipment /app
34
54
35
-
# Copy the built binary
36
-
COPY --from=builder /app/target/release/nate-status /app/nate-status
55
+
# Copy database migrations and config
56
+
COPY --from=builder /build/server/db /app/db
57
+
COPY --from=builder /build/server/.dbmate.yml /app/.dbmate.yml
58
+
COPY --from=builder /build/server/docker-entrypoint.sh /app/docker-entrypoint.sh
37
59
38
-
# Copy templates and lexicons
39
-
COPY templates ./templates
40
-
COPY lexicons ./lexicons
41
-
# Copy static files
42
-
COPY static ./static
60
+
# Set up the entrypoint
61
+
WORKDIR /app
43
62
44
-
# Create directory for SQLite database
45
-
RUN mkdir -p /data
63
+
# Create the data directory for the SQLite database and Fly.io volume mount
64
+
RUN mkdir -p /data && chmod 755 /data
46
65
47
66
# Set environment variables
48
-
ENV DB_PATH=/data/status.db
49
-
ENV ENABLE_FIREHOSE=true
67
+
ENV HOST=0.0.0.0
68
+
ENV PORT=8080
50
69
51
-
# Expose port
52
-
EXPOSE 8080
70
+
# Expose the port the server will run on
71
+
EXPOSE $PORT
53
72
54
-
# Run the binary
55
-
CMD ["./nate-status"]
73
+
# Run the server
74
+
CMD ["/app/docker-entrypoint.sh", "run"]
+45
-65
README.md
+45
-65
README.md
···
1
-
# status
1
+
# quickslice-status
2
2
3
-
a personal status tracker built on at protocol, where i can post my current status (like slack status) decoupled from any specific platform.
3
+
a status app for bluesky, built with [quickslice](https://github.com/bigmoves/quickslice).
4
4
5
-
live at: [status.zzstoatzz.io](https://status.zzstoatzz.io)
6
-
7
-
## about
5
+
**live:** https://quickslice-status.pages.dev
8
6
9
-
this is my personal status url - think of it like a service health page, but for a person. i can update my status with an emoji and optional text, and it's stored permanently in my at protocol repository.
7
+
## architecture
10
8
11
-
## credits
9
+
- **backend**: [quickslice](https://github.com/bigmoves/quickslice) on fly.io - handles oauth, graphql api, jetstream ingestion
10
+
- **frontend**: static site on cloudflare pages - vanilla js spa
12
11
13
-
this app is based on [bailey townsend's rusty statusphere](https://github.com/fatfingers23/rusty_statusphere_example_app), which is an excellent rust implementation of the at protocol quick start guide. bailey did all the heavy lifting with the atproto integration and the overall architecture. i've adapted it for my personal use case.
12
+
## deployment
14
13
15
-
major thanks to:
16
-
- [bailey townsend (@baileytownsend.dev)](https://bsky.app/profile/baileytownsend.dev) for the rusty statusphere boilerplate
17
-
- the atrium-rs maintainers for the rust at protocol libraries
18
-
- the rocketman maintainers for the jetstream consumer
14
+
### backend (fly.io)
19
15
20
-
## development
16
+
builds quickslice from source at v0.17.3 tag.
21
17
22
18
```bash
23
-
cp .env.template .env
24
-
cargo run
25
-
# navigate to http://127.0.0.1:8080
19
+
fly deploy
26
20
```
27
21
28
-
### custom emojis (no redeploys)
29
-
30
-
Emojis are now served from a runtime directory configured by `EMOJI_DIR` (defaults to `static/emojis` locally; set to `/data/emojis` on Fly.io). On startup, if the runtime emoji directory is empty, it will be seeded from the bundled `static/emojis`.
31
-
32
-
- Local dev: add image files to `static/emojis/` (or set `EMOJI_DIR` in `.env`).
33
-
- Production (Fly.io): upload files directly into the mounted volume at `/data/emojis` — no rebuild or redeploy needed.
34
-
35
-
Examples with Fly CLI:
36
-
22
+
required secrets:
37
23
```bash
38
-
# Open an SSH console to the machine
39
-
fly ssh console -a zzstoatzz-status
40
-
41
-
# Inside the VM, copy or fetch files into /data/emojis
42
-
mkdir -p /data/emojis
43
-
curl -L -o /data/emojis/my_new_emoji.png https://example.com/my_new_emoji.png
24
+
fly secrets set SECRET_KEY_BASE="$(openssl rand -base64 64 | tr -d '\n')"
25
+
fly secrets set OAUTH_SIGNING_KEY="$(goat key generate -t p256 | tail -1)"
44
26
```
45
27
46
-
Or from your machine using SFTP:
28
+
### frontend (cloudflare pages)
47
29
48
30
```bash
49
-
fly ssh sftp -a zzstoatzz-status
50
-
sftp> put ./static/emojis/my_new_emoji.png /data/emojis/
31
+
cd site
32
+
npx wrangler pages deploy . --project-name=quickslice-status
51
33
```
52
34
53
-
The app serves them at `/emojis/<filename>` and lists them via `/api/custom-emojis`.
35
+
## oauth client registration
54
36
55
-
### admin upload endpoint
37
+
register an oauth client in the quickslice admin ui at `https://zzstoatzz-quickslice-status.fly.dev/`
56
38
57
-
When logged in as the admin DID, you can upload PNG or GIF emojis without SSH via a simple endpoint:
39
+
redirect uri: `https://quickslice-status.pages.dev/callback`
58
40
59
-
- Endpoint: `POST /admin/upload-emoji`
60
-
- Auth: session-based; only the admin DID is allowed
61
-
- Form fields (multipart/form-data):
62
-
- `file`: the image file (PNG or GIF), max 5MB
63
-
- `name` (optional): base filename (letters, numbers, `-`, `_`) without extension
41
+
## lexicon
64
42
65
-
Example with curl:
43
+
uses `io.zzstoatzz.status` lexicon for user statuses.
66
44
67
-
```bash
68
-
curl -i -X POST \
69
-
-F "file=@./static/emojis/sample.png" \
70
-
-F "name=my_sample" \
71
-
http://localhost:8080/admin/upload-emoji
45
+
```json
46
+
{
47
+
"lexicon": 1,
48
+
"id": "io.zzstoatzz.status",
49
+
"defs": {
50
+
"main": {
51
+
"type": "record",
52
+
"key": "self",
53
+
"record": {
54
+
"type": "object",
55
+
"required": ["status", "createdAt"],
56
+
"properties": {
57
+
"status": { "type": "string", "maxLength": 128 },
58
+
"createdAt": { "type": "string", "format": "datetime" }
59
+
}
60
+
}
61
+
}
62
+
}
63
+
}
72
64
```
73
65
74
-
Response will include the public URL (e.g., `/emojis/my_sample.png`).
75
-
76
-
### available commands
66
+
## local development
77
67
78
-
we use [just](https://github.com/casey/just) for common tasks:
79
-
68
+
serve the frontend locally:
80
69
```bash
81
-
just watch # run with hot-reloading
82
-
just deploy # deploy to fly.io
83
-
just lint # run clippy
84
-
just fmt # format code
85
-
just clean # clean build artifacts
70
+
cd site
71
+
python -m http.server 8000
86
72
```
87
73
88
-
## tech stack
89
-
90
-
- [rust](https://www.rust-lang.org/) with [actix web](https://actix.rs/)
91
-
- [at protocol](https://atproto.com/) (via [atrium-rs](https://github.com/sugyan/atrium))
92
-
- [sqlite](https://www.sqlite.org/) for local storage
93
-
- [jetstream](https://github.com/bluesky-social/jetstream) for firehose consumption
94
-
- [fly.io](https://fly.io/) for hosting
74
+
for oauth to work locally, you'd need to register a separate oauth client with `http://localhost:8000/callback` as the redirect uri and update `CONFIG.clientId` in `app.js`.
+20
-21
fly.toml
+20
-21
fly.toml
···
1
-
app = "zzstoatzz-status"
2
-
primary_region = "ewr"
1
+
# fly.toml app configuration file generated for zzstoatzz-quickslice-status on 2025-12-13T16:42:55-06:00
2
+
#
3
+
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
4
+
#
5
+
6
+
app = 'zzstoatzz-quickslice-status'
7
+
primary_region = 'ewr'
3
8
4
9
[build]
5
-
dockerfile = "Dockerfile"
10
+
dockerfile = 'Dockerfile'
6
11
7
12
[env]
8
-
SERVER_PORT = "8080"
9
-
SERVER_HOST = "0.0.0.0"
10
-
DATABASE_URL = "sqlite:///data/status.db"
11
-
ENABLE_FIREHOSE = "true"
12
-
OAUTH_REDIRECT_BASE = "https://status.zzstoatzz.io"
13
-
EMOJI_DIR = "/data/emojis"
13
+
DATABASE_URL = 'sqlite:/data/quickslice.db'
14
+
HOST = '0.0.0.0'
15
+
PORT = '8080'
16
+
EXTERNAL_BASE_URL = 'https://zzstoatzz-quickslice-status.fly.dev'
17
+
18
+
[[mounts]]
19
+
source = 'quickslice_data'
20
+
destination = '/data'
14
21
15
22
[http_service]
16
23
internal_port = 8080
17
24
force_https = true
18
-
auto_stop_machines = true
25
+
auto_stop_machines = 'stop'
19
26
auto_start_machines = true
20
27
min_machines_running = 1
21
-
22
-
[http_service.concurrency]
23
-
type = "requests"
24
-
hard_limit = 250
25
-
soft_limit = 200
26
-
27
-
[[mounts]]
28
-
source = "status_data"
29
-
destination = "/data"
30
28
31
29
[[vm]]
32
-
cpu_kind = "shared"
30
+
memory = '1gb'
31
+
cpu_kind = 'shared'
33
32
cpus = 1
34
-
memory_mb = 512
33
+
memory_mb = 1024
+30
lexicons/preferences.json
+30
lexicons/preferences.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "io.zzstoatzz.status.preferences",
4
+
"defs": {
5
+
"main": {
6
+
"type": "record",
7
+
"key": "literal:self",
8
+
"record": {
9
+
"type": "object",
10
+
"properties": {
11
+
"accentColor": {
12
+
"type": "string",
13
+
"description": "Hex color for accent/highlight color (e.g. #4a9eff)",
14
+
"maxLength": 7
15
+
},
16
+
"font": {
17
+
"type": "string",
18
+
"description": "Font family preference",
19
+
"maxLength": 64
20
+
},
21
+
"theme": {
22
+
"type": "string",
23
+
"description": "Theme preference: light, dark, or system",
24
+
"enum": ["light", "dark", "system"]
25
+
}
26
+
}
27
+
}
28
+
}
29
+
}
30
+
}
+2
-3
lexicons/status.json
+2
-3
lexicons/status.json
···
11
11
"properties": {
12
12
"emoji": {
13
13
"type": "string",
14
-
"description": "Status emoji",
14
+
"description": "Status emoji or custom emoji slug (e.g. custom:bufo-stab)",
15
15
"minLength": 1,
16
-
"maxGraphemes": 1,
17
-
"maxLength": 32
16
+
"maxLength": 64
18
17
},
19
18
"text": {
20
19
"type": "string",
+1220
site/app.js
+1220
site/app.js
···
1
+
// Configuration
2
+
const CONFIG = {
3
+
server: 'https://zzstoatzz-quickslice-status.fly.dev',
4
+
clientId: 'client_2mP9AwgVHkg1vaSpcWSsKw',
5
+
};
6
+
7
+
// Base path for routing (empty for root domain, '/subpath' for subdirectory)
8
+
const BASE_PATH = '';
9
+
10
+
let client = null;
11
+
let userPreferences = null;
12
+
13
+
// Default preferences
14
+
const DEFAULT_PREFERENCES = {
15
+
accentColor: '#4a9eff',
16
+
font: 'mono',
17
+
theme: 'dark'
18
+
};
19
+
20
+
// Available fonts - use simple keys, map to actual CSS in applyPreferences
21
+
const FONTS = [
22
+
{ value: 'system', label: 'system' },
23
+
{ value: 'mono', label: 'mono' },
24
+
{ value: 'serif', label: 'serif' },
25
+
{ value: 'comic', label: 'comic' },
26
+
];
27
+
28
+
const FONT_CSS = {
29
+
'system': 'system-ui, -apple-system, sans-serif',
30
+
'mono': 'ui-monospace, SF Mono, Monaco, monospace',
31
+
'serif': 'ui-serif, Georgia, serif',
32
+
'comic': 'Comic Sans MS, Comic Sans, cursive',
33
+
};
34
+
35
+
// Preset accent colors
36
+
const ACCENT_COLORS = [
37
+
'#4a9eff', // blue (default)
38
+
'#10b981', // green
39
+
'#f59e0b', // amber
40
+
'#ef4444', // red
41
+
'#8b5cf6', // purple
42
+
'#ec4899', // pink
43
+
'#06b6d4', // cyan
44
+
'#f97316', // orange
45
+
];
46
+
47
+
// Apply preferences to the page
48
+
function applyPreferences(prefs) {
49
+
const { accentColor, font, theme } = { ...DEFAULT_PREFERENCES, ...prefs };
50
+
51
+
document.documentElement.style.setProperty('--accent', accentColor);
52
+
// Map simple font key to actual CSS font-family
53
+
const fontCSS = FONT_CSS[font] || FONT_CSS['mono'];
54
+
document.documentElement.style.setProperty('--font-family', fontCSS);
55
+
document.documentElement.setAttribute('data-theme', theme);
56
+
57
+
localStorage.setItem('theme', theme);
58
+
}
59
+
60
+
// Load preferences from server
61
+
async function loadPreferences() {
62
+
if (!client) return DEFAULT_PREFERENCES;
63
+
64
+
try {
65
+
const user = client.getUser();
66
+
if (!user) return DEFAULT_PREFERENCES;
67
+
68
+
const res = await fetch(`${CONFIG.server}/graphql`, {
69
+
method: 'POST',
70
+
headers: { 'Content-Type': 'application/json' },
71
+
body: JSON.stringify({
72
+
query: `
73
+
query GetPreferences($did: String!) {
74
+
ioZzstoatzzStatusPreferences(
75
+
where: { did: { eq: $did } }
76
+
first: 1
77
+
) {
78
+
edges { node { accentColor font theme } }
79
+
}
80
+
}
81
+
`,
82
+
variables: { did: user.did }
83
+
})
84
+
});
85
+
const json = await res.json();
86
+
const edges = json.data?.ioZzstoatzzStatusPreferences?.edges || [];
87
+
88
+
if (edges.length > 0) {
89
+
userPreferences = edges[0].node;
90
+
return userPreferences;
91
+
}
92
+
return DEFAULT_PREFERENCES;
93
+
} catch (e) {
94
+
console.error('Failed to load preferences:', e);
95
+
return DEFAULT_PREFERENCES;
96
+
}
97
+
}
98
+
99
+
// Save preferences to server
100
+
async function savePreferences(prefs) {
101
+
if (!client) return;
102
+
103
+
try {
104
+
const user = client.getUser();
105
+
if (!user) return;
106
+
107
+
// First, delete any existing preferences records for this user
108
+
const res = await fetch(`${CONFIG.server}/graphql`, {
109
+
method: 'POST',
110
+
headers: { 'Content-Type': 'application/json' },
111
+
body: JSON.stringify({
112
+
query: `
113
+
query GetExistingPrefs($did: String!) {
114
+
ioZzstoatzzStatusPreferences(where: { did: { eq: $did } }, first: 50) {
115
+
edges { node { uri } }
116
+
}
117
+
}
118
+
`,
119
+
variables: { did: user.did }
120
+
})
121
+
});
122
+
const json = await res.json();
123
+
const existing = json.data?.ioZzstoatzzStatusPreferences?.edges || [];
124
+
125
+
// Delete all existing preference records
126
+
for (const edge of existing) {
127
+
const rkey = edge.node.uri.split('/').pop();
128
+
try {
129
+
await client.mutate(`
130
+
mutation DeletePref($rkey: String!) {
131
+
deleteIoZzstoatzzStatusPreferences(rkey: $rkey) { uri }
132
+
}
133
+
`, { rkey });
134
+
} catch (e) {
135
+
console.warn('Failed to delete old pref:', e);
136
+
}
137
+
}
138
+
139
+
// Create new preferences record
140
+
await client.mutate(`
141
+
mutation SavePreferences($input: CreateIoZzstoatzzStatusPreferencesInput!) {
142
+
createIoZzstoatzzStatusPreferences(input: $input) { uri }
143
+
}
144
+
`, {
145
+
input: {
146
+
accentColor: prefs.accentColor,
147
+
font: prefs.font,
148
+
theme: prefs.theme
149
+
}
150
+
});
151
+
152
+
userPreferences = prefs;
153
+
applyPreferences(prefs);
154
+
} catch (e) {
155
+
console.error('Failed to save preferences:', e);
156
+
alert('Failed to save preferences: ' + e.message);
157
+
}
158
+
}
159
+
160
+
// Create settings modal
161
+
function createSettingsModal() {
162
+
const overlay = document.createElement('div');
163
+
overlay.className = 'settings-overlay hidden';
164
+
overlay.innerHTML = `
165
+
<div class="settings-modal">
166
+
<div class="settings-header">
167
+
<h3>settings</h3>
168
+
<button class="settings-close" aria-label="close">✕</button>
169
+
</div>
170
+
<div class="settings-content">
171
+
<div class="setting-group">
172
+
<label>accent color</label>
173
+
<div class="color-picker">
174
+
${ACCENT_COLORS.map(c => `
175
+
<button class="color-btn" data-color="${c}" style="background: ${c}" title="${c}"></button>
176
+
`).join('')}
177
+
<input type="color" id="custom-color" class="custom-color-input" title="custom color">
178
+
</div>
179
+
</div>
180
+
<div class="setting-group">
181
+
<label>font</label>
182
+
<select id="font-select">
183
+
${FONTS.map(f => `<option value="${f.value}">${f.label}</option>`).join('')}
184
+
</select>
185
+
</div>
186
+
<div class="setting-group">
187
+
<label>theme</label>
188
+
<select id="theme-select">
189
+
<option value="dark">dark</option>
190
+
<option value="light">light</option>
191
+
<option value="system">system</option>
192
+
</select>
193
+
</div>
194
+
</div>
195
+
<div class="settings-footer">
196
+
<button id="save-settings" class="save-btn">save</button>
197
+
</div>
198
+
</div>
199
+
`;
200
+
201
+
const modal = overlay.querySelector('.settings-modal');
202
+
const closeBtn = overlay.querySelector('.settings-close');
203
+
const colorBtns = overlay.querySelectorAll('.color-btn');
204
+
const customColor = overlay.querySelector('#custom-color');
205
+
const fontSelect = overlay.querySelector('#font-select');
206
+
const themeSelect = overlay.querySelector('#theme-select');
207
+
const saveBtn = overlay.querySelector('#save-settings');
208
+
209
+
let currentPrefs = { ...DEFAULT_PREFERENCES };
210
+
211
+
function updateColorSelection(color) {
212
+
colorBtns.forEach(btn => btn.classList.toggle('active', btn.dataset.color === color));
213
+
customColor.value = color;
214
+
currentPrefs.accentColor = color;
215
+
}
216
+
217
+
function open(prefs) {
218
+
currentPrefs = { ...DEFAULT_PREFERENCES, ...prefs };
219
+
updateColorSelection(currentPrefs.accentColor);
220
+
fontSelect.value = currentPrefs.font;
221
+
themeSelect.value = currentPrefs.theme;
222
+
overlay.classList.remove('hidden');
223
+
}
224
+
225
+
function close() {
226
+
overlay.classList.add('hidden');
227
+
}
228
+
229
+
overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
230
+
closeBtn.addEventListener('click', close);
231
+
232
+
colorBtns.forEach(btn => {
233
+
btn.addEventListener('click', () => updateColorSelection(btn.dataset.color));
234
+
});
235
+
236
+
customColor.addEventListener('input', () => {
237
+
updateColorSelection(customColor.value);
238
+
});
239
+
240
+
fontSelect.addEventListener('change', () => {
241
+
currentPrefs.font = fontSelect.value;
242
+
});
243
+
244
+
themeSelect.addEventListener('change', () => {
245
+
currentPrefs.theme = themeSelect.value;
246
+
});
247
+
248
+
saveBtn.addEventListener('click', async () => {
249
+
saveBtn.disabled = true;
250
+
saveBtn.textContent = 'saving...';
251
+
await savePreferences(currentPrefs);
252
+
saveBtn.disabled = false;
253
+
saveBtn.textContent = 'save';
254
+
close();
255
+
});
256
+
257
+
document.body.appendChild(overlay);
258
+
return { open, close };
259
+
}
260
+
261
+
// Theme (fallback for non-logged-in users)
262
+
function initTheme() {
263
+
const saved = localStorage.getItem('theme') || 'dark';
264
+
document.documentElement.setAttribute('data-theme', saved);
265
+
}
266
+
267
+
function toggleTheme() {
268
+
const current = document.documentElement.getAttribute('data-theme');
269
+
const next = current === 'dark' ? 'light' : 'dark';
270
+
document.documentElement.setAttribute('data-theme', next);
271
+
localStorage.setItem('theme', next);
272
+
273
+
// If logged in, also update preferences
274
+
if (userPreferences) {
275
+
userPreferences.theme = next;
276
+
savePreferences(userPreferences);
277
+
}
278
+
}
279
+
280
+
// Timestamp formatting (ported from original status app)
281
+
const TimestampFormatter = {
282
+
formatRelative(date, now = new Date()) {
283
+
const diffMs = now - date;
284
+
const diffMins = Math.floor(diffMs / 60000);
285
+
const diffHours = Math.floor(diffMs / 3600000);
286
+
const diffDays = Math.floor(diffMs / 86400000);
287
+
288
+
if (diffMs < 30000) return 'just now';
289
+
if (diffMins < 60) return `${diffMins}m ago`;
290
+
if (diffHours < 24) {
291
+
const remainingMins = diffMins % 60;
292
+
return remainingMins === 0 ? `${diffHours}h ago` : `${diffHours}h ${remainingMins}m ago`;
293
+
}
294
+
if (diffDays < 7) {
295
+
const remainingHours = diffHours % 24;
296
+
return remainingHours === 0 ? `${diffDays}d ago` : `${diffDays}d ${remainingHours}h ago`;
297
+
}
298
+
299
+
const timeStr = date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase();
300
+
if (date.getFullYear() === now.getFullYear()) {
301
+
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + timeStr;
302
+
}
303
+
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + ', ' + timeStr;
304
+
},
305
+
306
+
formatCompact(date, now = new Date()) {
307
+
const diffMs = now - date;
308
+
const diffDays = Math.floor(diffMs / 86400000);
309
+
310
+
if (date.toDateString() === now.toDateString()) {
311
+
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase();
312
+
}
313
+
const yesterday = new Date(now);
314
+
yesterday.setDate(yesterday.getDate() - 1);
315
+
if (date.toDateString() === yesterday.toDateString()) {
316
+
return 'yesterday, ' + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase();
317
+
}
318
+
if (diffDays < 7) {
319
+
const dayName = date.toLocaleDateString('en-US', { weekday: 'short' }).toLowerCase();
320
+
const time = date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase();
321
+
return `${dayName}, ${time}`;
322
+
}
323
+
if (date.getFullYear() === now.getFullYear()) {
324
+
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase();
325
+
}
326
+
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase();
327
+
},
328
+
329
+
getFullTimestamp(date) {
330
+
const dayName = date.toLocaleDateString('en-US', { weekday: 'long' });
331
+
const monthDay = date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
332
+
const time = date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', second: '2-digit', hour12: true });
333
+
const tzAbbr = date.toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop();
334
+
return `${dayName}, ${monthDay} at ${time} ${tzAbbr}`;
335
+
}
336
+
};
337
+
338
+
function relativeTime(dateStr, format = 'relative') {
339
+
const date = new Date(dateStr);
340
+
return format === 'compact'
341
+
? TimestampFormatter.formatCompact(date)
342
+
: TimestampFormatter.formatRelative(date);
343
+
}
344
+
345
+
function relativeTimeFuture(dateStr) {
346
+
const date = new Date(dateStr);
347
+
const now = new Date();
348
+
const diffMs = date - now;
349
+
350
+
if (diffMs <= 0) return 'now';
351
+
352
+
const diffMins = Math.floor(diffMs / 60000);
353
+
const diffHours = Math.floor(diffMs / 3600000);
354
+
const diffDays = Math.floor(diffMs / 86400000);
355
+
356
+
if (diffMins < 1) return 'in less than a minute';
357
+
if (diffMins < 60) return `in ${diffMins}m`;
358
+
if (diffHours < 24) {
359
+
const remainingMins = diffMins % 60;
360
+
return remainingMins === 0 ? `in ${diffHours}h` : `in ${diffHours}h ${remainingMins}m`;
361
+
}
362
+
if (diffDays < 7) {
363
+
const remainingHours = diffHours % 24;
364
+
return remainingHours === 0 ? `in ${diffDays}d` : `in ${diffDays}d ${remainingHours}h`;
365
+
}
366
+
367
+
// For longer times, show the date
368
+
const timeStr = date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase();
369
+
if (date.getFullYear() === now.getFullYear()) {
370
+
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + timeStr;
371
+
}
372
+
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + ', ' + timeStr;
373
+
}
374
+
375
+
function fullTimestamp(dateStr) {
376
+
return TimestampFormatter.getFullTimestamp(new Date(dateStr));
377
+
}
378
+
379
+
// Emoji picker
380
+
let emojiData = null;
381
+
let bufoList = null;
382
+
let userFrequentEmojis = null;
383
+
const DEFAULT_FREQUENT_EMOJIS = ['😊', '👍', '❤️', '😂', '🎉', '🔥', '✨', '💯', '🚀', '💪', '🙏', '👏', '😴', '🤔', '👀', '💻'];
384
+
385
+
async function loadUserFrequentEmojis() {
386
+
if (userFrequentEmojis) return userFrequentEmojis;
387
+
if (!client) return DEFAULT_FREQUENT_EMOJIS;
388
+
389
+
try {
390
+
const user = client.getUser();
391
+
if (!user) return DEFAULT_FREQUENT_EMOJIS;
392
+
393
+
// Fetch user's status history to count emoji usage
394
+
const res = await fetch(`${CONFIG.server}/graphql`, {
395
+
method: 'POST',
396
+
headers: { 'Content-Type': 'application/json' },
397
+
body: JSON.stringify({
398
+
query: `
399
+
query GetUserEmojis($did: String!) {
400
+
ioZzstoatzzStatusRecord(
401
+
first: 100
402
+
where: { did: { eq: $did } }
403
+
) {
404
+
edges { node { emoji } }
405
+
}
406
+
}
407
+
`,
408
+
variables: { did: user.did }
409
+
})
410
+
});
411
+
const json = await res.json();
412
+
const emojis = json.data?.ioZzstoatzzStatusRecord?.edges?.map(e => e.node.emoji) || [];
413
+
414
+
if (emojis.length === 0) return DEFAULT_FREQUENT_EMOJIS;
415
+
416
+
// Count emoji frequency
417
+
const counts = {};
418
+
emojis.forEach(e => { counts[e] = (counts[e] || 0) + 1; });
419
+
420
+
// Sort by frequency and take top 16
421
+
const sorted = Object.entries(counts)
422
+
.sort((a, b) => b[1] - a[1])
423
+
.slice(0, 16)
424
+
.map(([emoji]) => emoji);
425
+
426
+
userFrequentEmojis = sorted.length > 0 ? sorted : DEFAULT_FREQUENT_EMOJIS;
427
+
return userFrequentEmojis;
428
+
} catch (e) {
429
+
console.error('Failed to load frequent emojis:', e);
430
+
return DEFAULT_FREQUENT_EMOJIS;
431
+
}
432
+
}
433
+
434
+
async function loadBufoList() {
435
+
if (bufoList) return bufoList;
436
+
const res = await fetch('/bufos.json');
437
+
if (!res.ok) throw new Error('Failed to load bufos');
438
+
bufoList = await res.json();
439
+
return bufoList;
440
+
}
441
+
442
+
async function loadEmojiData() {
443
+
if (emojiData) return emojiData;
444
+
try {
445
+
const response = await fetch('https://cdn.jsdelivr.net/npm/emoji-datasource@15.1.0/emoji.json');
446
+
if (!response.ok) throw new Error('Failed to fetch');
447
+
const data = await response.json();
448
+
449
+
const emojis = {};
450
+
const categories = { frequent: DEFAULT_FREQUENT_EMOJIS, people: [], nature: [], food: [], activity: [], travel: [], objects: [], symbols: [], flags: [] };
451
+
const categoryMap = {
452
+
'Smileys & Emotion': 'people', 'People & Body': 'people', 'Animals & Nature': 'nature',
453
+
'Food & Drink': 'food', 'Activities': 'activity', 'Travel & Places': 'travel',
454
+
'Objects': 'objects', 'Symbols': 'symbols', 'Flags': 'flags'
455
+
};
456
+
457
+
data.forEach(emoji => {
458
+
const char = emoji.unified.split('-').map(u => String.fromCodePoint(parseInt(u, 16))).join('');
459
+
const keywords = [...(emoji.short_names || []), ...(emoji.name ? emoji.name.toLowerCase().split(/[\s_-]+/) : [])];
460
+
emojis[char] = keywords;
461
+
const cat = categoryMap[emoji.category];
462
+
if (cat && categories[cat]) categories[cat].push(char);
463
+
});
464
+
465
+
emojiData = { emojis, categories };
466
+
return emojiData;
467
+
} catch (e) {
468
+
console.error('Failed to load emoji data:', e);
469
+
return { emojis: {}, categories: { frequent: DEFAULT_FREQUENT_EMOJIS, people: [], nature: [], food: [], activity: [], travel: [], objects: [], symbols: [], flags: [] } };
470
+
}
471
+
}
472
+
473
+
function searchEmojis(query, data) {
474
+
if (!query) return [];
475
+
const q = query.toLowerCase();
476
+
return Object.entries(data.emojis)
477
+
.filter(([char, keywords]) => keywords.some(k => k.includes(q)))
478
+
.map(([char]) => char)
479
+
.slice(0, 50);
480
+
}
481
+
482
+
function createEmojiPicker(onSelect) {
483
+
const overlay = document.createElement('div');
484
+
overlay.className = 'emoji-picker-overlay hidden';
485
+
overlay.innerHTML = `
486
+
<div class="emoji-picker">
487
+
<div class="emoji-picker-header">
488
+
<h3>pick an emoji</h3>
489
+
<button class="emoji-picker-close" aria-label="close">✕</button>
490
+
</div>
491
+
<input type="text" class="emoji-search" placeholder="search emojis...">
492
+
<div class="emoji-categories">
493
+
<button class="category-btn active" data-category="frequent">⭐</button>
494
+
<button class="category-btn" data-category="custom">🐸</button>
495
+
<button class="category-btn" data-category="people">😊</button>
496
+
<button class="category-btn" data-category="nature">🌿</button>
497
+
<button class="category-btn" data-category="food">🍔</button>
498
+
<button class="category-btn" data-category="activity">⚽</button>
499
+
<button class="category-btn" data-category="travel">✈️</button>
500
+
<button class="category-btn" data-category="objects">💡</button>
501
+
<button class="category-btn" data-category="symbols">💕</button>
502
+
<button class="category-btn" data-category="flags">🏁</button>
503
+
</div>
504
+
<div class="emoji-grid"></div>
505
+
<div class="bufo-helper hidden"><a href="https://find-bufo.fly.dev/" target="_blank">need help finding a bufo?</a></div>
506
+
</div>
507
+
`;
508
+
509
+
const picker = overlay.querySelector('.emoji-picker');
510
+
const grid = overlay.querySelector('.emoji-grid');
511
+
const search = overlay.querySelector('.emoji-search');
512
+
const closeBtn = overlay.querySelector('.emoji-picker-close');
513
+
const categoryBtns = overlay.querySelectorAll('.category-btn');
514
+
const bufoHelper = overlay.querySelector('.bufo-helper');
515
+
516
+
let currentCategory = 'frequent';
517
+
let data = null;
518
+
519
+
async function renderCategory(cat) {
520
+
currentCategory = cat;
521
+
categoryBtns.forEach(b => b.classList.toggle('active', b.dataset.category === cat));
522
+
bufoHelper.classList.toggle('hidden', cat !== 'custom');
523
+
524
+
if (cat === 'custom') {
525
+
grid.classList.add('bufo-grid');
526
+
grid.innerHTML = '<div class="loading">loading bufos...</div>';
527
+
try {
528
+
const bufos = await loadBufoList();
529
+
grid.innerHTML = bufos.map(name => `
530
+
<button class="emoji-btn bufo-btn" data-emoji="custom:${name}" title="${name}">
531
+
<img src="https://all-the.bufo.zone/${name}.png" alt="${name}" loading="lazy" onerror="this.src='https://all-the.bufo.zone/${name}.gif'">
532
+
</button>
533
+
`).join('');
534
+
} catch (e) {
535
+
grid.innerHTML = '<div class="no-results">failed to load bufos</div>';
536
+
}
537
+
return;
538
+
}
539
+
540
+
grid.classList.remove('bufo-grid');
541
+
542
+
// Load user's frequent emojis for the frequent category
543
+
if (cat === 'frequent') {
544
+
grid.innerHTML = '<div class="loading">loading...</div>';
545
+
const frequentEmojis = await loadUserFrequentEmojis();
546
+
grid.innerHTML = frequentEmojis.map(e => {
547
+
if (e.startsWith('custom:')) {
548
+
const name = e.replace('custom:', '');
549
+
return `<button class="emoji-btn bufo-btn" data-emoji="${e}" title="${name}">
550
+
<img src="https://all-the.bufo.zone/${name}.png" alt="${name}" onerror="this.src='https://all-the.bufo.zone/${name}.gif'">
551
+
</button>`;
552
+
}
553
+
return `<button class="emoji-btn" data-emoji="${e}">${e}</button>`;
554
+
}).join('');
555
+
return;
556
+
}
557
+
558
+
if (!data) data = await loadEmojiData();
559
+
const emojis = data.categories[cat] || [];
560
+
grid.innerHTML = emojis.map(e => `<button class="emoji-btn" data-emoji="${e}">${e}</button>`).join('');
561
+
}
562
+
563
+
function close() {
564
+
overlay.classList.add('hidden');
565
+
search.value = '';
566
+
}
567
+
568
+
function open() {
569
+
overlay.classList.remove('hidden');
570
+
renderCategory('frequent');
571
+
search.focus();
572
+
}
573
+
574
+
overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
575
+
closeBtn.addEventListener('click', close);
576
+
categoryBtns.forEach(btn => btn.addEventListener('click', () => renderCategory(btn.dataset.category)));
577
+
578
+
grid.addEventListener('click', e => {
579
+
const btn = e.target.closest('.emoji-btn');
580
+
if (btn) {
581
+
onSelect(btn.dataset.emoji);
582
+
close();
583
+
}
584
+
});
585
+
586
+
search.addEventListener('input', async () => {
587
+
const q = search.value.trim();
588
+
if (!q) { renderCategory(currentCategory); return; }
589
+
590
+
// Search both emojis and bufos
591
+
if (!data) data = await loadEmojiData();
592
+
const emojiResults = searchEmojis(q, data);
593
+
594
+
// Search bufos by name
595
+
let bufoResults = [];
596
+
try {
597
+
const bufos = await loadBufoList();
598
+
const qLower = q.toLowerCase();
599
+
bufoResults = bufos.filter(name => name.toLowerCase().includes(qLower)).slice(0, 30);
600
+
} catch (e) { /* ignore */ }
601
+
602
+
grid.classList.remove('bufo-grid');
603
+
bufoHelper.classList.add('hidden');
604
+
605
+
if (emojiResults.length === 0 && bufoResults.length === 0) {
606
+
grid.innerHTML = '<div class="no-results">no emojis found</div>';
607
+
return;
608
+
}
609
+
610
+
let html = '';
611
+
// Show emoji results first
612
+
html += emojiResults.map(e => `<button class="emoji-btn" data-emoji="${e}">${e}</button>`).join('');
613
+
// Then bufo results
614
+
html += bufoResults.map(name => `
615
+
<button class="emoji-btn bufo-btn" data-emoji="custom:${name}" title="${name}">
616
+
<img src="https://all-the.bufo.zone/${name}.png" alt="${name}" onerror="this.src='https://all-the.bufo.zone/${name}.gif'">
617
+
</button>
618
+
`).join('');
619
+
620
+
grid.innerHTML = html;
621
+
});
622
+
623
+
document.body.appendChild(overlay);
624
+
return { open, close };
625
+
}
626
+
627
+
// Render emoji (handles custom:name format)
628
+
function renderEmoji(emoji) {
629
+
if (emoji && emoji.startsWith('custom:')) {
630
+
const name = emoji.slice(7);
631
+
return `<img src="https://all-the.bufo.zone/${name}.png" alt="${name}" title="${name}" onerror="this.src='https://all-the.bufo.zone/${name}.gif'">`;
632
+
}
633
+
return emoji || '-';
634
+
}
635
+
636
+
function escapeHtml(str) {
637
+
if (!str) return '';
638
+
const div = document.createElement('div');
639
+
div.textContent = str;
640
+
return div.innerHTML;
641
+
}
642
+
643
+
// Parse markdown links [text](url) and return HTML
644
+
function parseLinks(text) {
645
+
if (!text) return '';
646
+
// First escape HTML, then parse markdown links
647
+
const escaped = escapeHtml(text);
648
+
// Match [text](url) pattern
649
+
return escaped.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, linkText, url) => {
650
+
// Validate URL (basic check)
651
+
if (url.startsWith('http://') || url.startsWith('https://')) {
652
+
return `<a href="${url}" target="_blank" rel="noopener">${linkText}</a>`;
653
+
}
654
+
return match;
655
+
});
656
+
}
657
+
658
+
// Resolve handle to DID
659
+
async function resolveHandle(handle) {
660
+
const res = await fetch(`https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`);
661
+
if (!res.ok) return null;
662
+
const data = await res.json();
663
+
return data.did;
664
+
}
665
+
666
+
// Resolve DID to handle
667
+
async function resolveDidToHandle(did) {
668
+
const res = await fetch(`https://plc.directory/${did}`);
669
+
if (!res.ok) return null;
670
+
const data = await res.json();
671
+
// alsoKnownAs is like ["at://handle"]
672
+
if (data.alsoKnownAs && data.alsoKnownAs.length > 0) {
673
+
return data.alsoKnownAs[0].replace('at://', '');
674
+
}
675
+
return null;
676
+
}
677
+
678
+
// Router
679
+
function getRoute() {
680
+
const path = window.location.pathname;
681
+
if (path === '/' || path === '/index.html') return { page: 'home' };
682
+
if (path === '/feed' || path === '/feed.html') return { page: 'feed' };
683
+
if (path.startsWith('/@')) {
684
+
const handle = path.slice(2);
685
+
return { page: 'profile', handle };
686
+
}
687
+
return { page: '404' };
688
+
}
689
+
690
+
// Render home page
691
+
async function renderHome() {
692
+
const main = document.getElementById('main-content');
693
+
document.getElementById('page-title').textContent = 'status';
694
+
695
+
if (typeof QuicksliceClient === 'undefined') {
696
+
main.innerHTML = '<div class="center">failed to load. check console.</div>';
697
+
return;
698
+
}
699
+
700
+
try {
701
+
client = await QuicksliceClient.createQuicksliceClient({
702
+
server: CONFIG.server,
703
+
clientId: CONFIG.clientId,
704
+
redirectUri: window.location.origin + '/',
705
+
});
706
+
console.log('Client created with server:', CONFIG.server, 'clientId:', CONFIG.clientId);
707
+
708
+
if (window.location.search.includes('code=')) {
709
+
console.log('Got OAuth callback with code, handling...');
710
+
try {
711
+
const result = await client.handleRedirectCallback();
712
+
console.log('handleRedirectCallback result:', result);
713
+
} catch (err) {
714
+
console.error('handleRedirectCallback error:', err);
715
+
}
716
+
window.history.replaceState({}, document.title, '/');
717
+
}
718
+
719
+
const isAuthed = await client.isAuthenticated();
720
+
721
+
if (!isAuthed) {
722
+
main.innerHTML = `
723
+
<div class="center">
724
+
<p>share your status on the atproto network</p>
725
+
<form id="login-form">
726
+
<input type="text" id="handle-input" placeholder="your.handle" required>
727
+
<button type="submit">log in</button>
728
+
</form>
729
+
</div>
730
+
`;
731
+
document.getElementById('login-form').addEventListener('submit', async (e) => {
732
+
e.preventDefault();
733
+
const handle = document.getElementById('handle-input').value.trim();
734
+
if (handle && client) {
735
+
await client.loginWithRedirect({ handle });
736
+
}
737
+
});
738
+
} else {
739
+
const user = client.getUser();
740
+
if (!user) {
741
+
// Token might be invalid, log out
742
+
await client.logout();
743
+
window.location.reload();
744
+
return;
745
+
}
746
+
const handle = await resolveDidToHandle(user.did) || user.did;
747
+
748
+
// Load and apply preferences, set up settings/logout buttons
749
+
const prefs = await loadPreferences();
750
+
applyPreferences(prefs);
751
+
752
+
// Show settings button and set up modal
753
+
const settingsBtn = document.getElementById('settings-btn');
754
+
settingsBtn.classList.remove('hidden');
755
+
const settingsModal = createSettingsModal();
756
+
settingsBtn.addEventListener('click', () => settingsModal.open(userPreferences || prefs));
757
+
758
+
// Add logout button to header nav (if not already there)
759
+
if (!document.getElementById('logout-btn')) {
760
+
const nav = document.querySelector('header nav');
761
+
const logoutBtn = document.createElement('button');
762
+
logoutBtn.id = 'logout-btn';
763
+
logoutBtn.className = 'nav-btn';
764
+
logoutBtn.setAttribute('aria-label', 'log out');
765
+
logoutBtn.setAttribute('title', 'log out');
766
+
logoutBtn.innerHTML = `
767
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
768
+
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
769
+
<polyline points="16 17 21 12 16 7"></polyline>
770
+
<line x1="21" y1="12" x2="9" y2="12"></line>
771
+
</svg>
772
+
`;
773
+
logoutBtn.addEventListener('click', async () => {
774
+
await client.logout();
775
+
window.location.href = '/';
776
+
});
777
+
nav.appendChild(logoutBtn);
778
+
}
779
+
780
+
// Set page title with Bluesky profile link
781
+
document.getElementById('page-title').innerHTML = `<a href="https://bsky.app/profile/${handle}" target="_blank">@${handle}</a>`;
782
+
783
+
// Load user's statuses (full history)
784
+
const res = await fetch(`${CONFIG.server}/graphql`, {
785
+
method: 'POST',
786
+
headers: { 'Content-Type': 'application/json' },
787
+
body: JSON.stringify({
788
+
query: `
789
+
query GetUserStatuses($did: String!) {
790
+
ioZzstoatzzStatusRecord(
791
+
first: 100
792
+
where: { did: { eq: $did } }
793
+
sortBy: [{ field: "createdAt", direction: DESC }]
794
+
) {
795
+
edges { node { uri did emoji text createdAt expires } }
796
+
}
797
+
}
798
+
`,
799
+
variables: { did: user.did }
800
+
})
801
+
});
802
+
const json = await res.json();
803
+
const statuses = json.data.ioZzstoatzzStatusRecord.edges.map(e => e.node);
804
+
805
+
let currentHtml = '<span class="big-emoji">-</span>';
806
+
let historyHtml = '';
807
+
808
+
if (statuses.length > 0) {
809
+
const current = statuses[0];
810
+
const expiresHtml = current.expires ? ` • clears ${relativeTimeFuture(current.expires)}` : '';
811
+
currentHtml = `
812
+
<span class="big-emoji">${renderEmoji(current.emoji)}</span>
813
+
<div class="status-info">
814
+
${current.text ? `<span id="current-text">${parseLinks(current.text)}</span>` : ''}
815
+
<span class="meta">since ${relativeTime(current.createdAt)}${expiresHtml}</span>
816
+
</div>
817
+
`;
818
+
if (statuses.length > 1) {
819
+
historyHtml = '<section class="history"><h2>history</h2><div id="history-list">';
820
+
statuses.slice(1).forEach(s => {
821
+
// Extract rkey from URI (at://did/collection/rkey)
822
+
const rkey = s.uri.split('/').pop();
823
+
historyHtml += `
824
+
<div class="status-item">
825
+
<span class="emoji">${renderEmoji(s.emoji)}</span>
826
+
<div class="content">
827
+
<div>${s.text ? `<span class="text">${parseLinks(s.text)}</span>` : ''}</div>
828
+
<span class="time">${relativeTime(s.createdAt)}</span>
829
+
</div>
830
+
<button class="delete-btn" data-rkey="${escapeHtml(rkey)}" title="delete">
831
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
832
+
<line x1="18" y1="6" x2="6" y2="18"></line>
833
+
<line x1="6" y1="6" x2="18" y2="18"></line>
834
+
</svg>
835
+
</button>
836
+
</div>
837
+
`;
838
+
});
839
+
historyHtml += '</div></section>';
840
+
}
841
+
}
842
+
843
+
const currentEmoji = statuses.length > 0 ? statuses[0].emoji : '😊';
844
+
845
+
main.innerHTML = `
846
+
<div class="profile-card">
847
+
<div class="current-status">${currentHtml}</div>
848
+
</div>
849
+
<form id="status-form" class="status-form">
850
+
<div class="emoji-input-row">
851
+
<button type="button" id="emoji-trigger" class="emoji-trigger">
852
+
<span id="selected-emoji">${renderEmoji(currentEmoji)}</span>
853
+
</button>
854
+
<input type="hidden" id="emoji-input" value="${escapeHtml(currentEmoji)}">
855
+
<input type="text" id="text-input" placeholder="what's happening?" maxlength="256">
856
+
</div>
857
+
<div class="form-actions">
858
+
<select id="expires-select">
859
+
<option value="">don't clear</option>
860
+
<option value="30">30 min</option>
861
+
<option value="60">1 hour</option>
862
+
<option value="120">2 hours</option>
863
+
<option value="240">4 hours</option>
864
+
<option value="480">8 hours</option>
865
+
<option value="1440">1 day</option>
866
+
<option value="10080">1 week</option>
867
+
<option value="custom">custom...</option>
868
+
</select>
869
+
<input type="datetime-local" id="custom-datetime" class="custom-datetime hidden">
870
+
<button type="submit">set status</button>
871
+
</div>
872
+
</form>
873
+
${historyHtml}
874
+
`;
875
+
876
+
// Set up emoji picker
877
+
const emojiInput = document.getElementById('emoji-input');
878
+
const selectedEmojiEl = document.getElementById('selected-emoji');
879
+
const emojiPicker = createEmojiPicker((emoji) => {
880
+
emojiInput.value = emoji;
881
+
selectedEmojiEl.innerHTML = renderEmoji(emoji);
882
+
});
883
+
document.getElementById('emoji-trigger').addEventListener('click', () => emojiPicker.open());
884
+
885
+
// Custom datetime toggle
886
+
const expiresSelect = document.getElementById('expires-select');
887
+
const customDatetime = document.getElementById('custom-datetime');
888
+
889
+
// Helper to format date for datetime-local input (local timezone)
890
+
function toLocalDatetimeString(date) {
891
+
const offset = date.getTimezoneOffset();
892
+
const local = new Date(date.getTime() - offset * 60 * 1000);
893
+
return local.toISOString().slice(0, 16);
894
+
}
895
+
896
+
expiresSelect.addEventListener('change', () => {
897
+
if (expiresSelect.value === 'custom') {
898
+
customDatetime.classList.remove('hidden');
899
+
// Set min to now (prevent past dates)
900
+
const now = new Date();
901
+
customDatetime.min = toLocalDatetimeString(now);
902
+
// Default to 1 hour from now
903
+
const defaultTime = new Date(Date.now() + 60 * 60 * 1000);
904
+
customDatetime.value = toLocalDatetimeString(defaultTime);
905
+
} else {
906
+
customDatetime.classList.add('hidden');
907
+
}
908
+
});
909
+
910
+
document.getElementById('status-form').addEventListener('submit', async (e) => {
911
+
e.preventDefault();
912
+
const emoji = document.getElementById('emoji-input').value.trim();
913
+
const text = document.getElementById('text-input').value.trim();
914
+
const expiresVal = document.getElementById('expires-select').value;
915
+
const customDt = document.getElementById('custom-datetime').value;
916
+
917
+
if (!emoji) return;
918
+
919
+
const input = { emoji, createdAt: new Date().toISOString() };
920
+
if (text) input.text = text;
921
+
if (expiresVal === 'custom' && customDt) {
922
+
input.expires = new Date(customDt).toISOString();
923
+
} else if (expiresVal && expiresVal !== 'custom') {
924
+
input.expires = new Date(Date.now() + parseInt(expiresVal) * 60 * 1000).toISOString();
925
+
}
926
+
927
+
try {
928
+
await client.mutate(`
929
+
mutation CreateStatus($input: CreateIoZzstoatzzStatusRecordInput!) {
930
+
createIoZzstoatzzStatusRecord(input: $input) { uri }
931
+
}
932
+
`, { input });
933
+
window.location.reload();
934
+
} catch (err) {
935
+
console.error('Failed to create status:', err);
936
+
alert('Failed to set status: ' + err.message);
937
+
}
938
+
});
939
+
940
+
// Delete buttons
941
+
document.querySelectorAll('.delete-btn').forEach(btn => {
942
+
btn.addEventListener('click', async () => {
943
+
const rkey = btn.dataset.rkey;
944
+
if (!confirm('Delete this status?')) return;
945
+
946
+
try {
947
+
await client.mutate(`
948
+
mutation DeleteStatus($rkey: String!) {
949
+
deleteIoZzstoatzzStatusRecord(rkey: $rkey) { uri }
950
+
}
951
+
`, { rkey });
952
+
window.location.reload();
953
+
} catch (err) {
954
+
console.error('Failed to delete status:', err);
955
+
alert('Failed to delete: ' + err.message);
956
+
}
957
+
});
958
+
});
959
+
}
960
+
} catch (e) {
961
+
console.error('Failed to init:', e);
962
+
main.innerHTML = '<div class="center">failed to initialize. check console.</div>';
963
+
}
964
+
}
965
+
966
+
// Render feed page
967
+
let feedCursor = null;
968
+
let feedHasMore = true;
969
+
970
+
async function renderFeed(append = false) {
971
+
const main = document.getElementById('main-content');
972
+
document.getElementById('page-title').textContent = 'global feed';
973
+
974
+
if (!append) {
975
+
// Initialize auth UI for header elements
976
+
await initAuthUI();
977
+
main.innerHTML = '<div id="feed-list" class="feed-list"><div class="center">loading...</div></div><div id="load-more" class="center hidden"><button id="load-more-btn">load more</button></div><div id="end-of-feed" class="center hidden"><span class="meta">you\'ve reached the end</span></div>';
978
+
}
979
+
980
+
const feedList = document.getElementById('feed-list');
981
+
982
+
try {
983
+
const res = await fetch(`${CONFIG.server}/graphql`, {
984
+
method: 'POST',
985
+
headers: { 'Content-Type': 'application/json' },
986
+
body: JSON.stringify({
987
+
query: `
988
+
query GetFeed($after: String) {
989
+
ioZzstoatzzStatusRecord(first: 20, after: $after, sortBy: [{ field: "createdAt", direction: DESC }]) {
990
+
edges { node { uri did emoji text createdAt } cursor }
991
+
pageInfo { hasNextPage endCursor }
992
+
}
993
+
}
994
+
`,
995
+
variables: { after: append ? feedCursor : null }
996
+
})
997
+
});
998
+
999
+
const json = await res.json();
1000
+
const data = json.data.ioZzstoatzzStatusRecord;
1001
+
const statuses = data.edges.map(e => e.node);
1002
+
feedCursor = data.pageInfo.endCursor;
1003
+
feedHasMore = data.pageInfo.hasNextPage;
1004
+
1005
+
// Resolve all handles in parallel
1006
+
const handlePromises = statuses.map(s => resolveDidToHandle(s.did));
1007
+
const handles = await Promise.all(handlePromises);
1008
+
1009
+
if (!append) {
1010
+
feedList.innerHTML = '';
1011
+
}
1012
+
1013
+
statuses.forEach((status, i) => {
1014
+
const handle = handles[i] || status.did.slice(8, 28);
1015
+
const div = document.createElement('div');
1016
+
div.className = 'status-item';
1017
+
div.innerHTML = `
1018
+
<span class="emoji">${renderEmoji(status.emoji)}</span>
1019
+
<div class="content">
1020
+
<div>
1021
+
<a href="/@${handle}" class="author">@${handle}</a>
1022
+
${status.text ? `<span class="text">${parseLinks(status.text)}</span>` : ''}
1023
+
</div>
1024
+
<span class="time">${relativeTime(status.createdAt)}</span>
1025
+
</div>
1026
+
`;
1027
+
feedList.appendChild(div);
1028
+
});
1029
+
1030
+
const loadMore = document.getElementById('load-more');
1031
+
const endOfFeed = document.getElementById('end-of-feed');
1032
+
if (feedHasMore) {
1033
+
loadMore.classList.remove('hidden');
1034
+
endOfFeed.classList.add('hidden');
1035
+
} else {
1036
+
loadMore.classList.add('hidden');
1037
+
endOfFeed.classList.remove('hidden');
1038
+
}
1039
+
1040
+
// Attach load more handler
1041
+
const btn = document.getElementById('load-more-btn');
1042
+
if (btn && !btn.dataset.bound) {
1043
+
btn.dataset.bound = 'true';
1044
+
btn.addEventListener('click', () => renderFeed(true));
1045
+
}
1046
+
} catch (e) {
1047
+
console.error('Failed to load feed:', e);
1048
+
if (!append) {
1049
+
feedList.innerHTML = '<div class="center">failed to load feed</div>';
1050
+
}
1051
+
}
1052
+
}
1053
+
1054
+
// Render profile page
1055
+
async function renderProfile(handle) {
1056
+
const main = document.getElementById('main-content');
1057
+
const pageTitle = document.getElementById('page-title');
1058
+
1059
+
// Initialize auth UI for header elements
1060
+
await initAuthUI();
1061
+
1062
+
pageTitle.innerHTML = `<a href="https://bsky.app/profile/${handle}" target="_blank">@${handle}</a>`;
1063
+
1064
+
main.innerHTML = '<div class="center">loading...</div>';
1065
+
1066
+
try {
1067
+
// Resolve handle to DID
1068
+
const did = await resolveHandle(handle);
1069
+
if (!did) {
1070
+
main.innerHTML = '<div class="center">user not found</div>';
1071
+
return;
1072
+
}
1073
+
1074
+
const res = await fetch(`${CONFIG.server}/graphql`, {
1075
+
method: 'POST',
1076
+
headers: { 'Content-Type': 'application/json' },
1077
+
body: JSON.stringify({
1078
+
query: `
1079
+
query GetUserStatuses($did: String!) {
1080
+
ioZzstoatzzStatusRecord(first: 20, where: { did: { eq: $did } }, sortBy: [{ field: "createdAt", direction: DESC }]) {
1081
+
edges { node { uri did emoji text createdAt expires } }
1082
+
}
1083
+
}
1084
+
`,
1085
+
variables: { did }
1086
+
})
1087
+
});
1088
+
1089
+
const json = await res.json();
1090
+
const statuses = json.data.ioZzstoatzzStatusRecord.edges.map(e => e.node);
1091
+
1092
+
if (statuses.length === 0) {
1093
+
main.innerHTML = '<div class="center">no statuses yet</div>';
1094
+
return;
1095
+
}
1096
+
1097
+
const current = statuses[0];
1098
+
const expiresHtml = current.expires ? ` • clears ${relativeTimeFuture(current.expires)}` : '';
1099
+
let html = `
1100
+
<div class="profile-card">
1101
+
<div class="current-status">
1102
+
<span class="big-emoji">${renderEmoji(current.emoji)}</span>
1103
+
<div class="status-info">
1104
+
${current.text ? `<span id="current-text">${parseLinks(current.text)}</span>` : ''}
1105
+
<span class="meta">${relativeTime(current.createdAt)}${expiresHtml}</span>
1106
+
</div>
1107
+
</div>
1108
+
</div>
1109
+
`;
1110
+
1111
+
if (statuses.length > 1) {
1112
+
html += '<section class="history"><h2>history</h2><div class="feed-list">';
1113
+
statuses.slice(1).forEach(status => {
1114
+
html += `
1115
+
<div class="status-item">
1116
+
<span class="emoji">${renderEmoji(status.emoji)}</span>
1117
+
<div class="content">
1118
+
<div>${status.text ? `<span class="text">${parseLinks(status.text)}</span>` : ''}</div>
1119
+
<span class="time">${relativeTime(status.createdAt)}</span>
1120
+
</div>
1121
+
</div>
1122
+
`;
1123
+
});
1124
+
html += '</div></section>';
1125
+
}
1126
+
1127
+
main.innerHTML = html;
1128
+
} catch (e) {
1129
+
console.error('Failed to load profile:', e);
1130
+
main.innerHTML = '<div class="center">failed to load profile</div>';
1131
+
}
1132
+
}
1133
+
1134
+
// Update nav active state - hide current page icon, show the other
1135
+
function updateNavActive(page) {
1136
+
const navHome = document.getElementById('nav-home');
1137
+
const navFeed = document.getElementById('nav-feed');
1138
+
// Hide the nav icon for the current page, show the other
1139
+
if (navHome) navHome.classList.toggle('hidden', page === 'home');
1140
+
if (navFeed) navFeed.classList.toggle('hidden', page === 'feed');
1141
+
}
1142
+
1143
+
// Initialize auth state for header (settings, logout) - used by all pages
1144
+
async function initAuthUI() {
1145
+
if (typeof QuicksliceClient === 'undefined') return;
1146
+
1147
+
try {
1148
+
client = await QuicksliceClient.createQuicksliceClient({
1149
+
server: CONFIG.server,
1150
+
clientId: CONFIG.clientId,
1151
+
redirectUri: window.location.origin + '/',
1152
+
});
1153
+
1154
+
const isAuthed = await client.isAuthenticated();
1155
+
if (!isAuthed) return;
1156
+
1157
+
const user = client.getUser();
1158
+
if (!user) return;
1159
+
1160
+
// Load and apply preferences
1161
+
const prefs = await loadPreferences();
1162
+
applyPreferences(prefs);
1163
+
1164
+
// Show settings button and set up modal
1165
+
const settingsBtn = document.getElementById('settings-btn');
1166
+
settingsBtn.classList.remove('hidden');
1167
+
const settingsModal = createSettingsModal();
1168
+
settingsBtn.addEventListener('click', () => settingsModal.open(userPreferences || prefs));
1169
+
1170
+
// Add logout button to header nav (if not already there)
1171
+
if (!document.getElementById('logout-btn')) {
1172
+
const nav = document.querySelector('header nav');
1173
+
const logoutBtn = document.createElement('button');
1174
+
logoutBtn.id = 'logout-btn';
1175
+
logoutBtn.className = 'nav-btn';
1176
+
logoutBtn.setAttribute('aria-label', 'log out');
1177
+
logoutBtn.setAttribute('title', 'log out');
1178
+
logoutBtn.innerHTML = `
1179
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1180
+
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
1181
+
<polyline points="16 17 21 12 16 7"></polyline>
1182
+
<line x1="21" y1="12" x2="9" y2="12"></line>
1183
+
</svg>
1184
+
`;
1185
+
logoutBtn.addEventListener('click', async () => {
1186
+
await client.logout();
1187
+
window.location.href = '/';
1188
+
});
1189
+
nav.appendChild(logoutBtn);
1190
+
}
1191
+
1192
+
return { user, prefs };
1193
+
} catch (e) {
1194
+
console.error('Failed to init auth UI:', e);
1195
+
return null;
1196
+
}
1197
+
}
1198
+
1199
+
// Init
1200
+
document.addEventListener('DOMContentLoaded', () => {
1201
+
initTheme();
1202
+
1203
+
const themeBtn = document.getElementById('theme-toggle');
1204
+
if (themeBtn) {
1205
+
themeBtn.addEventListener('click', toggleTheme);
1206
+
}
1207
+
1208
+
const route = getRoute();
1209
+
updateNavActive(route.page);
1210
+
1211
+
if (route.page === 'home') {
1212
+
renderHome();
1213
+
} else if (route.page === 'feed') {
1214
+
renderFeed();
1215
+
} else if (route.page === 'profile') {
1216
+
renderProfile(route.handle);
1217
+
} else {
1218
+
document.getElementById('main-content').innerHTML = '<div class="center">page not found</div>';
1219
+
}
1220
+
});
+1614
site/bufos.json
+1614
site/bufos.json
···
1
+
[
2
+
"according-to-all-known-laws-of-aviation-there-is-no-way-a-bufo-should-be-able-to-fly",
3
+
"add-bufo",
4
+
"all-the-bufo",
5
+
"angry-karen-bufo-would-like-to-speak-with-your-manager",
6
+
"australian-bufo",
7
+
"awesomebufo",
8
+
"be-the-bufo-you-want-to-see",
9
+
"bigbufo_0_0",
10
+
"bigbufo_0_1",
11
+
"bigbufo_0_2",
12
+
"bigbufo_0_3",
13
+
"bigbufo_1_0",
14
+
"bigbufo_1_1",
15
+
"bigbufo_1_2",
16
+
"bigbufo_1_3",
17
+
"bigbufo_2_0",
18
+
"bigbufo_2_1",
19
+
"bigbufo_2_2",
20
+
"bigbufo_2_3",
21
+
"bigbufo_3_0",
22
+
"bigbufo_3_1",
23
+
"bigbufo_3_2",
24
+
"bigbufo_3_3",
25
+
"blockheads-bufo",
26
+
"breaking-bufo",
27
+
"bronze-bufo",
28
+
"buff-bufo",
29
+
"bufo",
30
+
"bufo_wants_his_money",
31
+
"bufo-0-10",
32
+
"bufo-10",
33
+
"bufo-10-4",
34
+
"bufo-2022",
35
+
"bufo-achieving-coding-flow",
36
+
"bufo-ack",
37
+
"bufo-actually",
38
+
"bufo-adding-bugs-to-the-code",
39
+
"bufo-adidas",
40
+
"bufo-ages-rapidly-in-the-void",
41
+
"bufo-aight-imma-head-out",
42
+
"bufo-airpods",
43
+
"bufo-alarma",
44
+
"bufo-all-good",
45
+
"bufo-all-warm-and-fuzzy-inside",
46
+
"bufo-am-i",
47
+
"bufo-amaze",
48
+
"bufo-ambiently-existing",
49
+
"bufo-american-football",
50
+
"bufo-android",
51
+
"bufo-angel",
52
+
"bufo-angrily-gives-you-a-birthday-gift",
53
+
"bufo-angrily-gives-you-white-elephant-gift",
54
+
"bufo-angry",
55
+
"bufo-angry-at-fly",
56
+
"bufo-angry-bullfrog-screech",
57
+
"bufo-angryandfrozen",
58
+
"bufo-anime-glasses",
59
+
"bufo-appears",
60
+
"bufo-apple",
61
+
"bufo-appreciates-jwst-pillars-of-creation",
62
+
"bufo-approve",
63
+
"bufo-arabicus",
64
+
"bufo-are-you-seeing-this",
65
+
"bufo-arr",
66
+
"bufo-arrr",
67
+
"bufo-arrrrrr",
68
+
"bufo-arrrrrrr",
69
+
"bufo-arrrrrrrrr",
70
+
"bufo-arrrrrrrrrrrrrrr",
71
+
"bufo-artist",
72
+
"bufo-asks-politely-to-stop",
73
+
"bufo-assists-with-the-landing",
74
+
"bufo-atc",
75
+
"bufo-away",
76
+
"bufo-awkward-smile",
77
+
"bufo-awkward-smile-nod",
78
+
"bufo-ayy",
79
+
"bufo-baby",
80
+
"bufo-babysits-an-urgent-ticket",
81
+
"bufo-back-pat",
82
+
"bufo-backpack",
83
+
"bufo-backpat",
84
+
"bufo-bag-of-bufos",
85
+
"bufo-bait",
86
+
"bufo-baker",
87
+
"bufo-baller",
88
+
"bufo-bandana",
89
+
"bufo-banging-head-against-the-wall",
90
+
"bufo-barbie",
91
+
"bufo-barney",
92
+
"bufo-barrister",
93
+
"bufo-baseball",
94
+
"bufo-basketball",
95
+
"bufo-batman",
96
+
"bufo-be-my-valentine",
97
+
"bufo-became-a-stranger-whose-laugh-you-can-recognize-anywhere",
98
+
"bufo-bee",
99
+
"bufo-bee-leaf",
100
+
"bufo-bee-sad",
101
+
"bufo-beer",
102
+
"bufo-begrudgingly-offers-you-a-plus",
103
+
"bufo-begs-for-ethernet-cable",
104
+
"bufo-behind-bars",
105
+
"bufo-bell-pepper",
106
+
"bufo-betray",
107
+
"bufo-betray-but-its-a-hotdog",
108
+
"bufo-big-eyes-stare",
109
+
"bufo-bigfoot",
110
+
"bufo-bill-pay",
111
+
"bufo-bird",
112
+
"bufo-birthday-but-not-particularly-happy",
113
+
"bufo-black-history",
114
+
"bufo-black-tea",
115
+
"bufo-blank-stare",
116
+
"bufo-blank-stare_0_0",
117
+
"bufo-blank-stare_0_1",
118
+
"bufo-blank-stare_1_0",
119
+
"bufo-blank-stare_1_1",
120
+
"bufo-blanket",
121
+
"bufo-blem",
122
+
"bufo-blep",
123
+
"bufo-bless",
124
+
"bufo-bless-back",
125
+
"bufo-blesses-this-pr",
126
+
"bufo-block",
127
+
"bufo-blogging",
128
+
"bufo-bloody-mary",
129
+
"bufo-blows-the-magic-conch",
130
+
"bufo-blue",
131
+
"bufo-blueberries",
132
+
"bufo-blush",
133
+
"bufo-boba",
134
+
"bufo-boba-army",
135
+
"bufo-boi",
136
+
"bufo-boiii",
137
+
"bufo-bongo",
138
+
"bufo-bonk",
139
+
"bufo-bops-you-on-the-head-with-a-baguette",
140
+
"bufo-bops-you-on-the-head-with-a-rolled-up-newspaper",
141
+
"bufo-bouge",
142
+
"bufo-bouncer-says-its-time-to-go-now",
143
+
"bufo-bouquet",
144
+
"bufo-bourgeoisie",
145
+
"bufo-bowser",
146
+
"bufo-box-of-chocolates",
147
+
"bufo-brain",
148
+
"bufo-brain-damage",
149
+
"bufo-brain-damage-escalates-to-new-heights",
150
+
"bufo-brain-damage-intensifies",
151
+
"bufo-brain-damage-intesifies-more",
152
+
"bufo-brain-exploding",
153
+
"bufo-breakdown",
154
+
"bufo-breaks-tech-bros-heart",
155
+
"bufo-breaks-up-with-you",
156
+
"bufo-breaks-your-heart",
157
+
"bufo-brick",
158
+
"bufo-brings-a-new-meaning-to-brain-freeze-by-bopping-you-on-the-head-with-a-popsicle",
159
+
"bufo-brings-a-new-meaning-to-gaveled-by-slamming-the-hammer-very-loud",
160
+
"bufo-brings-magic-to-the-riot",
161
+
"bufo-broccoli",
162
+
"bufo-broke",
163
+
"bufo-broke-his-toe-and-isn't-sure-what-to-do-about-the-12k-he-signed-up-for",
164
+
"bufo-broom",
165
+
"bufo-brought-a-taco",
166
+
"bufo-bufo",
167
+
"bufo-but-anatomically-correct",
168
+
"bufo-but-instead-of-green-its-hotdogs",
169
+
"bufo-but-instead-of-green-its-pizza",
170
+
"bufo-but-you-can-feel-the-electro-house-music-in-the-gif-and-oh-yea-theres-also-a-dapper-chicken",
171
+
"bufo-but-you-can-see-the-bufo-in-bufos-eyes",
172
+
"bufo-but-you-can-see-the-hotdog-in-their-eyes",
173
+
"bufo-buy-high-sell-low",
174
+
"bufo-buy-low-sell-high",
175
+
"bufo-cache-buddy",
176
+
"bufo-cackle",
177
+
"bufo-call-for-help",
178
+
"bufo-came-into-the-office-just-to-use-the-printer",
179
+
"bufo-can't-believe-heartbreak-feels-good-in-a-place-like-this",
180
+
"bufo-can't-help-but-wonder-who-watches-the-watchmen",
181
+
"bufo-canada",
182
+
"bufo-cant-believe-your-audacity",
183
+
"bufo-cant-find-a-pull-request",
184
+
"bufo-cant-find-an-issue",
185
+
"bufo-cant-stop-thinking-about-usher-killing-it-on-roller-skates",
186
+
"bufo-cant-take-it-anymore",
187
+
"bufo-cantelope",
188
+
"bufo-capri-sun",
189
+
"bufo-captain-obvious",
190
+
"bufo-caribou",
191
+
"bufo-carnage",
192
+
"bufo-carrot",
193
+
"bufo-cash-money",
194
+
"bufo-cash-squint",
195
+
"bufo-casts-a-spell-on-you",
196
+
"bufo-catch",
197
+
"bufo-caught-a-radioactive-bufo",
198
+
"bufo-caught-a-small-bufo",
199
+
"bufo-caused-an-incident",
200
+
"bufo-celebrate",
201
+
"bufo-censored",
202
+
"bufo-chappell-roan",
203
+
"bufo-chatting",
204
+
"bufo-check",
205
+
"bufo-checks-out-the-vibe",
206
+
"bufo-cheese",
207
+
"bufo-chef",
208
+
"bufo-chefkiss",
209
+
"bufo-chefkiss-with-hat",
210
+
"bufo-cherries",
211
+
"bufo-chicken",
212
+
"bufo-chomp",
213
+
"bufo-christmas",
214
+
"bufo-chungus",
215
+
"bufo-churns-the-butter",
216
+
"bufo-clap",
217
+
"bufo-clap-hd",
218
+
"bufo-claus",
219
+
"bufo-clown",
220
+
"bufo-coconut",
221
+
"bufo-code-freeze",
222
+
"bufo-coding",
223
+
"bufo-coffee-happy",
224
+
"bufo-coin",
225
+
"bufo-come-to-the-dark-side",
226
+
"bufo-comfy",
227
+
"bufo-commits-digital-piracy",
228
+
"bufo-competes-in-the-bufo-bracket",
229
+
"bufo-complies-with-the-chinese-government",
230
+
"bufo-concerned",
231
+
"bufo-cone-of-shame",
232
+
"bufo-confetti",
233
+
"bufo-confused",
234
+
"bufo-congrats",
235
+
"bufo-cookie",
236
+
"bufo-cool-glasses",
237
+
"bufo-corn",
238
+
"bufo-cornucopia",
239
+
"bufo-covid",
240
+
"bufo-cowboy",
241
+
"bufo-cozy-blanky",
242
+
"bufo-crewmate-blue",
243
+
"bufo-crewmate-blue-bounce",
244
+
"bufo-crewmate-cyan",
245
+
"bufo-crewmate-cyan-bounce",
246
+
"bufo-crewmate-green",
247
+
"bufo-crewmate-green-bounce",
248
+
"bufo-crewmate-lime",
249
+
"bufo-crewmate-lime-bounce",
250
+
"bufo-crewmate-orange",
251
+
"bufo-crewmate-orange-bounce",
252
+
"bufo-crewmate-pink",
253
+
"bufo-crewmate-pink-bounce",
254
+
"bufo-crewmate-purple",
255
+
"bufo-crewmate-purple-bounce",
256
+
"bufo-crewmate-red",
257
+
"bufo-crewmate-red-bounce",
258
+
"bufo-crewmate-yellow",
259
+
"bufo-crewmate-yellow-bounce",
260
+
"bufo-crewmates",
261
+
"bufo-cries-into-his-beer",
262
+
"bufo-crikey",
263
+
"bufo-croptop",
264
+
"bufo-crumbs",
265
+
"bufo-crustacean",
266
+
"bufo-cry",
267
+
"bufo-cry-pray",
268
+
"bufo-crying",
269
+
"bufo-crying-in-the-rain",
270
+
"bufo-crying-jail",
271
+
"bufo-crying-stop",
272
+
"bufo-crying-tears-of-crying-tears-of-joy",
273
+
"bufo-crying-why",
274
+
"bufo-cubo",
275
+
"bufo-cucumber",
276
+
"bufo-cuddle",
277
+
"bufo-cupcake",
278
+
"bufo-cuppa",
279
+
"bufo-cute",
280
+
"bufo-cute-dance",
281
+
"bufo-dab",
282
+
"bufo-dancing",
283
+
"bufo-dapper",
284
+
"bufo-dbz",
285
+
"bufo-deal-with-it",
286
+
"bufo-declines-your-suppository-offer",
287
+
"bufo-deep-hmm",
288
+
"bufo-defend",
289
+
"bufo-delurk",
290
+
"bufo-demands-more-nom-noms",
291
+
"bufo-demure",
292
+
"bufo-desperately-needs-mavis-beacon",
293
+
"bufo-detective",
294
+
"bufo-develops-clairvoyance-while-trapped-in-the-void",
295
+
"bufo-devil",
296
+
"bufo-devouring-his-son",
297
+
"bufo-di-beppo",
298
+
"bufo-did-not-make-it-through-the-heatwave",
299
+
"bufo-didnt-get-any-sleep",
300
+
"bufo-didnt-listen-to-willy-wonka",
301
+
"bufo-disappointed",
302
+
"bufo-disco",
303
+
"bufo-discombobulated",
304
+
"bufo-disguise",
305
+
"bufo-ditto",
306
+
"bufo-dizzy",
307
+
"bufo-do-not-panic",
308
+
"bufo-dodge",
309
+
"bufo-doesnt-believe-you",
310
+
"bufo-doesnt-understand-how-this-meeting-isnt-an-email",
311
+
"bufo-doesnt-wanna-get-out-of-the-bath-yet",
312
+
"bufo-dog",
313
+
"bufo-domo",
314
+
"bufo-done-check",
315
+
"bufo-dont",
316
+
"bufo-dont-even-see-the-code-anymore",
317
+
"bufo-dont-trust-whats-over-there",
318
+
"bufo-double-chin",
319
+
"bufo-double-vaccinated",
320
+
"bufo-doubt",
321
+
"bufo-dough",
322
+
"bufo-downvote",
323
+
"bufo-dr-depper",
324
+
"bufo-dragon",
325
+
"bufo-drags-knee",
326
+
"bufo-drake-no",
327
+
"bufo-drake-yes",
328
+
"bufo-drifts-through-the-void",
329
+
"bufo-drinking-baja-blast",
330
+
"bufo-drinking-boba",
331
+
"bufo-drinking-coffee",
332
+
"bufo-drinking-coke",
333
+
"bufo-drinking-pepsi",
334
+
"bufo-drinking-pumpkin-spice-latte",
335
+
"bufo-drinks-from-the-fire-hose",
336
+
"bufo-drops-everything-now",
337
+
"bufo-drowning-in-leeks",
338
+
"bufo-drowns-in-memories-of-ocean",
339
+
"bufo-drowns-in-tickets-but-ok",
340
+
"bufo-drumroll",
341
+
"bufo-easter-bunny",
342
+
"bufo-eating-hotdog",
343
+
"bufo-eating-lollipop",
344
+
"bufo-eats-a-bufo-taco",
345
+
"bufo-eats-all-your-honey",
346
+
"bufo-eats-bufo-taco",
347
+
"bufo-egg",
348
+
"bufo-elite",
349
+
"bufo-emo",
350
+
"bufo-ends-the-holy-war-by-offering-the-objectively-best-programming-language",
351
+
"bufo-enjoys-life",
352
+
"bufo-enjoys-life-in-the-windows-xp-background",
353
+
"bufo-enraged",
354
+
"bufo-enter",
355
+
"bufo-enters-the-void",
356
+
"bufo-entrance",
357
+
"bufo-ethereum",
358
+
"bufo-everything-is-on-fire",
359
+
"bufo-evil",
360
+
"bufo-excited",
361
+
"bufo-excited-but-sad",
362
+
"bufo-existential-dread-sets-in",
363
+
"bufo-exit",
364
+
"bufo-experiences-euneirophrenia",
365
+
"bufo-extra-cool",
366
+
"bufo-eye-twitch",
367
+
"bufo-eyeballs",
368
+
"bufo-eyeballs-bloodshot",
369
+
"bufo-eyes",
370
+
"bufo-fab",
371
+
"bufo-facepalm",
372
+
"bufo-failed-the-load-test",
373
+
"bufo-fails-the-vibe-check",
374
+
"bufo-fancy-tea",
375
+
"bufo-farmer",
376
+
"bufo-fastest-rubber-stamp-in-the-west",
377
+
"bufo-fedora",
378
+
"bufo-feel-better",
379
+
"bufo-feeling-pretty-might-delete-later",
380
+
"bufo-feels-appreciated",
381
+
"bufo-feels-nothing",
382
+
"bufo-fell-asleep",
383
+
"bufo-fellow-kids",
384
+
"bufo-fieri",
385
+
"bufo-fight",
386
+
"bufo-fine-art",
387
+
"bufo-fingerguns",
388
+
"bufo-fingerguns-back",
389
+
"bufo-fire",
390
+
"bufo-fire-engine",
391
+
"bufo-firefighter",
392
+
"bufo-fish",
393
+
"bufo-fish-bulb",
394
+
"bufo-fistbump",
395
+
"bufo-flex",
396
+
"bufo-flipoff",
397
+
"bufo-flips-table",
398
+
"bufo-folder",
399
+
"bufo-fomo",
400
+
"bufo-food-please",
401
+
"bufo-football",
402
+
"bufo-for-dummies",
403
+
"bufo-forgot-how-to-type",
404
+
"bufo-forgot-that-you-existed-it-isnt-love-it-isnt-hate-its-just-indifference",
405
+
"bufo-found-some-more-leeks",
406
+
"bufo-found-the-leeks",
407
+
"bufo-found-yet-another-juicebox",
408
+
"bufo-french",
409
+
"bufo-friends",
410
+
"bufo-frustrated-with-flower",
411
+
"bufo-fu%C3%9Fball",
412
+
"bufo-fun-is-over",
413
+
"bufo-furiously-tries-to-write-python",
414
+
"bufo-furiously-writes-an-epic-update",
415
+
"bufo-furiously-writes-you-a-peer-review",
416
+
"bufo-futbol",
417
+
"bufo-gamer",
418
+
"bufo-gaming",
419
+
"bufo-gandalf",
420
+
"bufo-gandalf-has-seen-things",
421
+
"bufo-gandalf-wat",
422
+
"bufo-gardener",
423
+
"bufo-garlic",
424
+
"bufo-gavel",
425
+
"bufo-gavel-dual-wield",
426
+
"bufo-gen-z",
427
+
"bufo-gentleman",
428
+
"bufo-germany",
429
+
"bufo-get-in-loser-were-going-shopping",
430
+
"bufo-gets-downloaded-from-the-cloud",
431
+
"bufo-gets-hit-in-the-face-with-an-egg",
432
+
"bufo-gets-uploaded-to-the-cloud",
433
+
"bufo-gets-whiplash",
434
+
"bufo-ghost",
435
+
"bufo-ghost-costume",
436
+
"bufo-giggling-in-a-cat-onesie",
437
+
"bufo-give",
438
+
"bufo-give-money",
439
+
"bufo-give-pack-of-ice",
440
+
"bufo-gives-a-fake-moustache",
441
+
"bufo-gives-a-magic-number",
442
+
"bufo-gives-an-idea",
443
+
"bufo-gives-approval",
444
+
"bufo-gives-can-of-worms",
445
+
"bufo-gives-databricks",
446
+
"bufo-gives-j",
447
+
"bufo-gives-star",
448
+
"bufo-gives-you-a-feature-flag",
449
+
"bufo-gives-you-a-hotdog",
450
+
"bufo-gives-you-some-extra-brain",
451
+
"bufo-gives-you-some-rice",
452
+
"bufo-glasses",
453
+
"bufo-glitch",
454
+
"bufo-goal",
455
+
"bufo-goes-super-saiyan",
456
+
"bufo-goes-to-space",
457
+
"bufo-goggles-are-too-tight",
458
+
"bufo-good-morning",
459
+
"bufo-good-vibe",
460
+
"bufo-goose-hat-happy-dance",
461
+
"bufo-got-a-tan",
462
+
"bufo-got-zapped",
463
+
"bufo-grapes",
464
+
"bufo-grasping-at-straws",
465
+
"bufo-grenade",
466
+
"bufo-grimaces-with-eyebrows",
467
+
"bufo-guitar",
468
+
"bufo-ha-ha",
469
+
"bufo-hacker",
470
+
"bufo-hackerman",
471
+
"bufo-haha-yes-haha-yes",
472
+
"bufo-hahabusiness",
473
+
"bufo-halloween",
474
+
"bufo-halloween-pumpkin",
475
+
"bufo-hands",
476
+
"bufo-hands-on-hips-annoyed",
477
+
"bufo-hangs-ten",
478
+
"bufo-hangs-up",
479
+
"bufo-hannibal-lecter",
480
+
"bufo-hanson",
481
+
"bufo-happy",
482
+
"bufo-happy-hour",
483
+
"bufo-happy-new-year",
484
+
"bufo-hardhat",
485
+
"bufo-has-a-5-dollar-footlong",
486
+
"bufo-has-a-banana",
487
+
"bufo-has-a-bbq",
488
+
"bufo-has-a-big-wrench",
489
+
"bufo-has-a-blue-wrench",
490
+
"bufo-has-a-crush",
491
+
"bufo-has-a-dr-pepper",
492
+
"bufo-has-a-fresh-slice",
493
+
"bufo-has-a-headache",
494
+
"bufo-has-a-hot-take",
495
+
"bufo-has-a-question",
496
+
"bufo-has-a-sandwich",
497
+
"bufo-has-a-spoon",
498
+
"bufo-has-a-timtam",
499
+
"bufo-has-accepted-its-horrible-fate",
500
+
"bufo-has-activated",
501
+
"bufo-has-another-sandwich",
502
+
"bufo-has-been-cleaning",
503
+
"bufo-has-gotta-poop-but-hes-stuck-in-a-long-meeting",
504
+
"bufo-has-infiltrated-your-secure-system",
505
+
"bufo-has-midas-touch",
506
+
"bufo-has-read-enough-documentation-for-today",
507
+
"bufo-has-some-ketchup",
508
+
"bufo-has-thread-for-guts",
509
+
"bufo-hasnt-worked-a-full-week-so-far-this-year",
510
+
"bufo-hat",
511
+
"bufo-hazmat",
512
+
"bufo-headbang",
513
+
"bufo-headphones",
514
+
"bufo-heart",
515
+
"bufo-heart-but-its-anatomically-correct",
516
+
"bufo-hearts",
517
+
"bufo-hehe",
518
+
"bufo-hell",
519
+
"bufo-hello",
520
+
"bufo-heralds-an-incident",
521
+
"bufo-heralds-taco-taking",
522
+
"bufo-heralds-your-success",
523
+
"bufo-here-to-make-a-dill-for-more-pickles",
524
+
"bufo-hides",
525
+
"bufo-high-speed-train",
526
+
"bufo-highfive-1",
527
+
"bufo-highfive-2",
528
+
"bufo-hipster",
529
+
"bufo-hmm",
530
+
"bufo-hmm-no",
531
+
"bufo-hmm-yes",
532
+
"bufo-holding-space-for-defying-gravity",
533
+
"bufo-holds-pumpkin",
534
+
"bufo-homologates",
535
+
"bufo-hop-in-we're-going-to-flavortown",
536
+
"bufo-hopes-you-also-are-having-a-good-day",
537
+
"bufo-hopes-you-are-having-a-good-day",
538
+
"bufo-hot-pocket",
539
+
"bufo-hotdog-rocket",
540
+
"bufo-howdy",
541
+
"bufo-hug",
542
+
"bufo-hugs-moo-deng",
543
+
"bufo-hype",
544
+
"bufo-i-just-love-it-so-much",
545
+
"bufo-ice-cream",
546
+
"bufo-idk",
547
+
"bufo-idk-but-okay-i-guess-so",
548
+
"bufo-im-in-danger",
549
+
"bufo-imposter",
550
+
"bufo-in-a-pear-tree",
551
+
"bufo-in-his-cozy-bed-hoping-he-never-gets-capitated",
552
+
"bufo-in-rome",
553
+
"bufo-inception",
554
+
"bufo-increases-his-dimensionality-while-trapped-in-the-void",
555
+
"bufo-innocent",
556
+
"bufo-inspecting",
557
+
"bufo-inspired",
558
+
"bufo-instigates-a-dramatic-turn-of-events",
559
+
"bufo-intensifies",
560
+
"bufo-intern",
561
+
"bufo-investigates",
562
+
"bufo-iphone",
563
+
"bufo-irl",
564
+
"bufo-iron-throne",
565
+
"bufo-ironside",
566
+
"bufo-is-a-little-worried-but-still-trying-to-be-supportive",
567
+
"bufo-is-a-part-of-gen-z",
568
+
"bufo-is-about-to-zap-you",
569
+
"bufo-is-all-ears",
570
+
"bufo-is-angry-at-the-water-cooler-bottle-company-for-missing-yet-another-delivery",
571
+
"bufo-is-at-his-wits-end",
572
+
"bufo-is-at-the-dentist",
573
+
"bufo-is-better-known-for-the-things-he-does-on-the-mattress",
574
+
"bufo-is-exhausted-rooting-for-the-antihero",
575
+
"bufo-is-flying-and-is-the-plane",
576
+
"bufo-is-getting-abducted",
577
+
"bufo-is-getting-paged-now",
578
+
"bufo-is-glad-the-british-were-kicked-out",
579
+
"bufo-is-happy-youre-happy",
580
+
"bufo-is-having-a-really-bad-time",
581
+
"bufo-is-in-a-never-ending-meeting",
582
+
"bufo-is-in-on-the-joke",
583
+
"bufo-is-inhaling-this-popcorn",
584
+
"bufo-is-it-done",
585
+
"bufo-is-jealous-its-your-birthday",
586
+
"bufo-is-jean-baptise-emanuel-zorg",
587
+
"bufo-is-keeping-his-eye-on-you",
588
+
"bufo-is-lonely",
589
+
"bufo-is-lost",
590
+
"bufo-is-lost-in-the-void",
591
+
"bufo-is-omniscient",
592
+
"bufo-is-on-a-sled",
593
+
"bufo-is-panicking",
594
+
"bufo-is-petting-your-cat",
595
+
"bufo-is-petting-your-dog",
596
+
"bufo-is-proud-of-you",
597
+
"bufo-is-ready-for-xmas",
598
+
"bufo-is-ready-to-build-when-you-are",
599
+
"bufo-is-ready-to-burn-down-the-mta-because-their-train-skipped-their-station-again",
600
+
"bufo-is-ready-to-consume-his-daily-sodium-intake-in-one-sitting",
601
+
"bufo-is-ready-to-eat",
602
+
"bufo-is-ready-to-riot",
603
+
"bufo-is-ready-to-slay-the-dragon",
604
+
"bufo-is-romantic",
605
+
"bufo-is-sad-no-one-complimented-their-agent-47-cosplay",
606
+
"bufo-is-safe-behind-bars",
607
+
"bufo-is-so-happy-youre-here",
608
+
"bufo-is-the-perfect-human-form",
609
+
"bufo-is-trapped-in-a-cameron-winter-phase",
610
+
"bufo-is-unconcerned",
611
+
"bufo-is-up-to-something",
612
+
"bufo-is-very-upset-now",
613
+
"bufo-is-watching-you",
614
+
"bufo-is-working-through-the-tears",
615
+
"bufo-is-working-too-much",
616
+
"bufo-isitdone",
617
+
"bufo-isnt-angry-just-disappointed",
618
+
"bufo-isnt-going-to-rewind-the-vhs-before-returning-it",
619
+
"bufo-isnt-reading-all-that",
620
+
"bufo-it-bar",
621
+
"bufo-italian",
622
+
"bufo-its-over-9000",
623
+
"bufo-its-too-early-for-this",
624
+
"bufo-jam",
625
+
"bufo-jammies",
626
+
"bufo-jammin",
627
+
"bufo-jealous",
628
+
"bufo-jedi",
629
+
"bufo-jomo",
630
+
"bufo-judge",
631
+
"bufo-judges",
632
+
"bufo-juice",
633
+
"bufo-juicebox",
634
+
"bufo-juicy",
635
+
"bufo-just-a-little-sad",
636
+
"bufo-just-a-little-salty",
637
+
"bufo-just-checking",
638
+
"bufo-just-finished-a-workout",
639
+
"bufo-just-got-back-from-the-dentist",
640
+
"bufo-just-ice",
641
+
"bufo-just-walked-into-an-awkward-conversation-and-is-now-trying-to-figure-out-how-to-leave",
642
+
"bufo-just-wanted-you-to-know-this-is-him-trying",
643
+
"bufo-justice",
644
+
"bufo-karen",
645
+
"bufo-keeps-his-password-written-on-a-post-it-note-stuck-to-his-monitor",
646
+
"bufo-keyboard",
647
+
"bufo-kills-you-with-kindness",
648
+
"bufo-king",
649
+
"bufo-kiwi",
650
+
"bufo-knife",
651
+
"bufo-knife-cries-right",
652
+
"bufo-knife-crying",
653
+
"bufo-knife-crying-left",
654
+
"bufo-knife-crying-right",
655
+
"bufo-knows-age-is-just-a-number",
656
+
"bufo-knows-his-customers",
657
+
"bufo-knows-this-is-a-total-bop",
658
+
"bufo-knuckle-sandwich",
659
+
"bufo-knuckles",
660
+
"bufo-koi",
661
+
"bufo-kudo",
662
+
"bufo-kuzco",
663
+
"bufo-kuzco-has-not-learned-his-lesson-yet",
664
+
"bufo-laser-eyes",
665
+
"bufo-late-to-the-convo",
666
+
"bufo-laugh-xd",
667
+
"bufo-laughing-popcorn",
668
+
"bufo-laughs-to-mask-the-pain",
669
+
"bufo-leads-the-way-to-better-docs",
670
+
"bufo-leaves-you-on-seen",
671
+
"bufo-left-a-comment",
672
+
"bufo-left-multiple-comments",
673
+
"bufo-legal-entities",
674
+
"bufo-lemon",
675
+
"bufo-leprechaun",
676
+
"bufo-let-them-eat-cake",
677
+
"bufo-lgtm",
678
+
"bufo-liberty",
679
+
"bufo-liberty-forgot-her-torch",
680
+
"bufo-librarian",
681
+
"bufo-lick",
682
+
"bufo-licks-his-hway-out-of-prison",
683
+
"bufo-lies-awake-in-panic",
684
+
"bufo-life-saver",
685
+
"bufo-likes-that-idea",
686
+
"bufo-link",
687
+
"bufo-listens-to-his-conscience",
688
+
"bufo-lit",
689
+
"bufo-littlefoot-is-upset",
690
+
"bufo-loading",
691
+
"bufo-lol",
692
+
"bufo-lol-cry",
693
+
"bufo-lolsob",
694
+
"bufo-long",
695
+
"bufo-lookin-dope",
696
+
"bufo-looking-very-much",
697
+
"bufo-looks-a-little-closer",
698
+
"bufo-looks-for-a-pull-request",
699
+
"bufo-looks-for-an-issue",
700
+
"bufo-looks-like-hes-listening-but-hes-not",
701
+
"bufo-looks-out-of-the-window",
702
+
"bufo-loves-blobs",
703
+
"bufo-loves-disco",
704
+
"bufo-loves-doges",
705
+
"bufo-loves-pho",
706
+
"bufo-loves-rice-and-beans",
707
+
"bufo-loves-ruby",
708
+
"bufo-loves-this-song",
709
+
"bufo-luigi",
710
+
"bufo-lunch",
711
+
"bufo-lurk",
712
+
"bufo-lurk-delurk",
713
+
"bufo-macbook",
714
+
"bufo-made-salad",
715
+
"bufo-made-you-a-burrito",
716
+
"bufo-magician",
717
+
"bufo-make-it-rain",
718
+
"bufo-makes-it-rain",
719
+
"bufo-makes-the-dream-work",
720
+
"bufo-mama-mia-thatsa-one-spicy-a-meatball",
721
+
"bufo-marine",
722
+
"bufo-mario",
723
+
"bufo-mask",
724
+
"bufo-matrix",
725
+
"bufo-medal",
726
+
"bufo-meltdown",
727
+
"bufo-melting",
728
+
"bufo-micdrop",
729
+
"bufo-midsommar",
730
+
"bufo-midwest-princess",
731
+
"bufo-mild-panic",
732
+
"bufo-mildly-aggravated",
733
+
"bufo-milk",
734
+
"bufo-mindblown",
735
+
"bufo-minecraft-attack",
736
+
"bufo-minecraft-defend",
737
+
"bufo-mischievous",
738
+
"bufo-mitosis",
739
+
"bufo-mittens",
740
+
"bufo-modern-art",
741
+
"bufo-monocle",
742
+
"bufo-monstera",
743
+
"bufo-morning",
744
+
"bufo-morning-starbucks",
745
+
"bufo-morning-sun",
746
+
"bufo-mrtayto",
747
+
"bufo-mushroom",
748
+
"bufo-mustache",
749
+
"bufo-my-pho",
750
+
"bufo-nah",
751
+
"bufo-naked",
752
+
"bufo-naptime",
753
+
"bufo-needs-some-hot-tea-to-process-this-news",
754
+
"bufo-needs-to-vent",
755
+
"bufo-nefarious",
756
+
"bufo-nervous",
757
+
"bufo-nervous-but-cute",
758
+
"bufo-night",
759
+
"bufo-ninja",
760
+
"bufo-no",
761
+
"bufo-no-capes",
762
+
"bufo-no-more-today-thank-you",
763
+
"bufo-no-prob",
764
+
"bufo-no-problem",
765
+
"bufo-no-ragrets",
766
+
"bufo-no-sleep",
767
+
"bufo-no-u",
768
+
"bufo-nod",
769
+
"bufo-noodles",
770
+
"bufo-nope",
771
+
"bufo-nosy",
772
+
"bufo-not-bad-by-dalle",
773
+
"bufo-not-my-problem",
774
+
"bufo-not-respecting-your-personal-space",
775
+
"bufo-notice-me-senpai",
776
+
"bufo-notification",
777
+
"bufo-np",
778
+
"bufo-nun",
779
+
"bufo-nyc",
780
+
"bufo-oatly",
781
+
"bufo-oblivious-and-innocent",
782
+
"bufo-of-liberty",
783
+
"bufo-offering-bufo-offering-bufo-offering-bufo",
784
+
"bufo-offers-1",
785
+
"bufo-offers-13",
786
+
"bufo-offers-2",
787
+
"bufo-offers-200",
788
+
"bufo-offers-21",
789
+
"bufo-offers-3",
790
+
"bufo-offers-5",
791
+
"bufo-offers-8",
792
+
"bufo-offers-a-bagel",
793
+
"bufo-offers-a-ball-of-mud",
794
+
"bufo-offers-a-banana-in-these-trying-times",
795
+
"bufo-offers-a-beer",
796
+
"bufo-offers-a-bicycle",
797
+
"bufo-offers-a-bolillo-para-el-susto",
798
+
"bufo-offers-a-book",
799
+
"bufo-offers-a-brain",
800
+
"bufo-offers-a-bufo-egg-in-this-trying-time",
801
+
"bufo-offers-a-burger",
802
+
"bufo-offers-a-cake",
803
+
"bufo-offers-a-clover",
804
+
"bufo-offers-a-comment",
805
+
"bufo-offers-a-cookie",
806
+
"bufo-offers-a-deploy-lock",
807
+
"bufo-offers-a-factory",
808
+
"bufo-offers-a-flan",
809
+
"bufo-offers-a-flowchart-to-help-you-navigate-this-workflow",
810
+
"bufo-offers-a-focaccia",
811
+
"bufo-offers-a-furby",
812
+
"bufo-offers-a-gavel",
813
+
"bufo-offers-a-generator",
814
+
"bufo-offers-a-hario-scale",
815
+
"bufo-offers-a-hot-take",
816
+
"bufo-offers-a-jetpack-zebra",
817
+
"bufo-offers-a-kakapo",
818
+
"bufo-offers-a-like",
819
+
"bufo-offers-a-little-band-aid-for-a-big-problem",
820
+
"bufo-offers-a-llama",
821
+
"bufo-offers-a-loading-spinner",
822
+
"bufo-offers-a-loading-spinner-spinning",
823
+
"bufo-offers-a-lock",
824
+
"bufo-offers-a-mac-m1-chip",
825
+
"bufo-offers-a-pager",
826
+
"bufo-offers-a-piece-of-cake",
827
+
"bufo-offers-a-pr",
828
+
"bufo-offers-a-pull-request",
829
+
"bufo-offers-a-rock",
830
+
"bufo-offers-a-roomba",
831
+
"bufo-offers-a-ruby",
832
+
"bufo-offers-a-sandbox",
833
+
"bufo-offers-a-shocked-pikachu",
834
+
"bufo-offers-a-speedy-recovery",
835
+
"bufo-offers-a-status",
836
+
"bufo-offers-a-taco",
837
+
"bufo-offers-a-telescope",
838
+
"bufo-offers-a-tiny-wood-stove",
839
+
"bufo-offers-a-torta-ahogada",
840
+
"bufo-offers-a-webhook",
841
+
"bufo-offers-a-webhook-but-the-logo-is-canonically-correct",
842
+
"bufo-offers-a-wednesday",
843
+
"bufo-offers-a11y",
844
+
"bufo-offers-ai",
845
+
"bufo-offers-airwrap",
846
+
"bufo-offers-an-airpod-pro",
847
+
"bufo-offers-an-easter-egg",
848
+
"bufo-offers-an-eclair",
849
+
"bufo-offers-an-egg-in-this-trying-time",
850
+
"bufo-offers-an-ethernet-cable",
851
+
"bufo-offers-an-export-of-your-data",
852
+
"bufo-offers-an-extinguisher",
853
+
"bufo-offers-an-idea",
854
+
"bufo-offers-an-incident",
855
+
"bufo-offers-an-issue",
856
+
"bufo-offers-an-outage",
857
+
"bufo-offers-approval",
858
+
"bufo-offers-avocado",
859
+
"bufo-offers-bento",
860
+
"bufo-offers-big-band-aid-for-a-little-problem",
861
+
"bufo-offers-bitcoin",
862
+
"bufo-offers-boba",
863
+
"bufo-offers-boss-coffee",
864
+
"bufo-offers-box",
865
+
"bufo-offers-bufo",
866
+
"bufo-offers-bufo-cubo",
867
+
"bufo-offers-bufo-offers",
868
+
"bufo-offers-bufomelon",
869
+
"bufo-offers-calculated-decision-to-leave-tech-debt-for-now-and-clean-it-up-later",
870
+
"bufo-offers-caribufo",
871
+
"bufo-offers-chart-with-upwards-trend",
872
+
"bufo-offers-chatgpt",
873
+
"bufo-offers-chrome",
874
+
"bufo-offers-coffee",
875
+
"bufo-offers-copilot",
876
+
"bufo-offers-corn",
877
+
"bufo-offers-corporate-red-tape",
878
+
"bufo-offers-covid",
879
+
"bufo-offers-csharp",
880
+
"bufo-offers-d20",
881
+
"bufo-offers-datadog",
882
+
"bufo-offers-discord",
883
+
"bufo-offers-dnd",
884
+
"bufo-offers-empty-wallet",
885
+
"bufo-offers-f5",
886
+
"bufo-offers-factorio",
887
+
"bufo-offers-falafel",
888
+
"bufo-offers-fart-cloud",
889
+
"bufo-offers-firefox",
890
+
"bufo-offers-flatbread",
891
+
"bufo-offers-footsie",
892
+
"bufo-offers-friday",
893
+
"bufo-offers-fud",
894
+
"bufo-offers-gatorade",
895
+
"bufo-offers-git-mailing-list",
896
+
"bufo-offers-golden-handcuffs",
897
+
"bufo-offers-google-doc",
898
+
"bufo-offers-google-drive",
899
+
"bufo-offers-google-sheets",
900
+
"bufo-offers-hello-kitty",
901
+
"bufo-offers-help",
902
+
"bufo-offers-hotdog",
903
+
"bufo-offers-jira",
904
+
"bufo-offers-ldap",
905
+
"bufo-offers-lego",
906
+
"bufo-offers-model-1857-12-pounder-napoleon-cannon",
907
+
"bufo-offers-moneybag",
908
+
"bufo-offers-new-jira",
909
+
"bufo-offers-nothing",
910
+
"bufo-offers-notion",
911
+
"bufo-offers-oatmilk",
912
+
"bufo-offers-openai",
913
+
"bufo-offers-pancakes",
914
+
"bufo-offers-peanuts",
915
+
"bufo-offers-pineapple",
916
+
"bufo-offers-power",
917
+
"bufo-offers-prescription-strength-painkillers",
918
+
"bufo-offers-python",
919
+
"bufo-offers-securifriend",
920
+
"bufo-offers-solar-eclipse",
921
+
"bufo-offers-spam",
922
+
"bufo-offers-stash-of-tea-from-the-office-for-the-weekend",
923
+
"bufo-offers-tayto",
924
+
"bufo-offers-terraform",
925
+
"bufo-offers-the-cloud",
926
+
"bufo-offers-the-power",
927
+
"bufo-offers-the-weeknd",
928
+
"bufo-offers-thoughts-and-prayers",
929
+
"bufo-offers-thread",
930
+
"bufo-offers-thundercats",
931
+
"bufo-offers-tim-tams",
932
+
"bufo-offers-tree",
933
+
"bufo-offers-turkish-delights",
934
+
"bufo-offers-ube",
935
+
"bufo-offers-watermelon",
936
+
"bufo-offers-you-a-comically-oversized-waffle",
937
+
"bufo-offers-you-a-db-for-your-customer-data",
938
+
"bufo-offers-you-a-gdpr-compliant-cookie",
939
+
"bufo-offers-you-a-kfc-16-piece-family-size-bucket-of-fried-chicken",
940
+
"bufo-offers-you-a-monster-early-in-the-morning",
941
+
"bufo-offers-you-a-pint-m8",
942
+
"bufo-offers-you-a-red-bull-early-in-the-morning",
943
+
"bufo-offers-you-a-suspiciously-not-urgent-ticket",
944
+
"bufo-offers-you-an-urgent-ticket",
945
+
"bufo-offers-you-dangerously-high-rate-limits",
946
+
"bufo-offers-you-his-crypto-before-he-pumps-and-dumps-it",
947
+
"bufo-offers-you-logs",
948
+
"bufo-offers-you-money-in-this-trying-time",
949
+
"bufo-offers-you-the-best-emoji-culture-ever",
950
+
"bufo-offers-you-the-moon",
951
+
"bufo-offers-you-the-world",
952
+
"bufo-offers-yubikey",
953
+
"bufo-office",
954
+
"bufo-oh-hai",
955
+
"bufo-oh-no",
956
+
"bufo-oh-yeah",
957
+
"bufo-ok",
958
+
"bufo-okay-pretty-salty-now",
959
+
"bufo-old",
960
+
"bufo-olives",
961
+
"bufo-omg",
962
+
"bufo-on-fire-but-still-excited",
963
+
"bufo-on-the-ceiling",
964
+
"bufo-oncall-secondary",
965
+
"bufo-onion",
966
+
"bufo-open-mic",
967
+
"bufo-opens-a-haberdashery",
968
+
"bufo-orange",
969
+
"bufo-oreilly",
970
+
"bufo-pager-duty",
971
+
"bufo-pajama-party",
972
+
"bufo-palpatine",
973
+
"bufo-panic",
974
+
"bufo-parrot",
975
+
"bufo-party",
976
+
"bufo-party-birthday",
977
+
"bufo-party-conga-line",
978
+
"bufo-passed-the-load-test",
979
+
"bufo-passes-the-vibe-check",
980
+
"bufo-pat",
981
+
"bufo-peaks-on-you-from-above",
982
+
"bufo-peaky-blinder",
983
+
"bufo-pear",
984
+
"bufo-pearly-whites",
985
+
"bufo-peek",
986
+
"bufo-peek-wall",
987
+
"bufo-peeking",
988
+
"bufo-pensivity-turned-discomfort-upon-realization-of-reality",
989
+
"bufo-phew",
990
+
"bufo-phonecall",
991
+
"bufo-photographer",
992
+
"bufo-picked-you-a-flower",
993
+
"bufo-pikmin",
994
+
"bufo-pilgrim",
995
+
"bufo-pilot",
996
+
"bufo-pinch-hitter",
997
+
"bufo-pineapple",
998
+
"bufo-ping",
999
+
"bufo-pirate",
1000
+
"bufo-pitchfork",
1001
+
"bufo-pitchforks",
1002
+
"bufo-pizza-hut",
1003
+
"bufo-placeholder",
1004
+
"bufo-platformizes",
1005
+
"bufo-plays-some-smooth-jazz",
1006
+
"bufo-plays-some-smooth-jazz-intensity-1",
1007
+
"bufo-pleading",
1008
+
"bufo-pleading-1",
1009
+
"bufo-please",
1010
+
"bufo-pog",
1011
+
"bufo-pog-surprise",
1012
+
"bufo-pointing-down-there",
1013
+
"bufo-pointing-over-there",
1014
+
"bufo-pointing-right-there",
1015
+
"bufo-pointing-up-there",
1016
+
"bufo-police",
1017
+
"bufo-poliwhirl",
1018
+
"bufo-ponders",
1019
+
"bufo-ponders-2",
1020
+
"bufo-ponders-3",
1021
+
"bufo-poo",
1022
+
"bufo-poof",
1023
+
"bufo-popcorn",
1024
+
"bufo-popping-out-of-the-coffee",
1025
+
"bufo-popping-out-of-the-coffee-upsidedown",
1026
+
"bufo-popping-out-of-the-toilet",
1027
+
"bufo-pops-by",
1028
+
"bufo-pops-out-for-a-quick-bite-to-eat",
1029
+
"bufo-possessed",
1030
+
"bufo-potato",
1031
+
"bufo-pours-one-out",
1032
+
"bufo-praise",
1033
+
"bufo-pray",
1034
+
"bufo-pray-partying",
1035
+
"bufo-praying-his-qa-is-on-point",
1036
+
"bufo-prays-for-this-to-be-over-already",
1037
+
"bufo-prays-for-this-to-be-over-already-intensifies",
1038
+
"bufo-prays-to-azure",
1039
+
"bufo-prays-to-nvidia",
1040
+
"bufo-prays-to-pagerduty",
1041
+
"bufo-preach",
1042
+
"bufo-presents-to-the-bufos",
1043
+
"bufo-pretends-to-have-authority",
1044
+
"bufo-pretty-dang-sad",
1045
+
"bufo-pride",
1046
+
"bufo-psychic",
1047
+
"bufo-pumpkin",
1048
+
"bufo-pumpkin-head",
1049
+
"bufo-pushes-to-prod",
1050
+
"bufo-put-on-active-noise-cancelling-headphones-but-can-still-hear-you",
1051
+
"bufo-quadruple-vaccinated",
1052
+
"bufo-question",
1053
+
"bufo-rad",
1054
+
"bufo-rainbow",
1055
+
"bufo-rainbow-moustache",
1056
+
"bufo-raised-hand",
1057
+
"bufo-ramen",
1058
+
"bufo-reading",
1059
+
"bufo-reads-and-analyzes-doc",
1060
+
"bufo-reads-and-analyzes-doc-intensifies",
1061
+
"bufo-red-flags",
1062
+
"bufo-redacted",
1063
+
"bufo-regret",
1064
+
"bufo-remains-perturbed-from-the-void",
1065
+
"bufo-remembers-bad-time",
1066
+
"bufo-returns-to-the-void",
1067
+
"bufo-retweet",
1068
+
"bufo-reverse",
1069
+
"bufo-review",
1070
+
"bufo-revokes-his-approval",
1071
+
"bufo-rich",
1072
+
"bufo-rick",
1073
+
"bufo-rides-in-style",
1074
+
"bufo-riding-goose",
1075
+
"bufo-riot",
1076
+
"bufo-rip",
1077
+
"bufo-roasted",
1078
+
"bufo-robs-you",
1079
+
"bufo-rocket",
1080
+
"bufo-rofl",
1081
+
"bufo-roll",
1082
+
"bufo-roll-fast",
1083
+
"bufo-roll-safe",
1084
+
"bufo-roll-the-dice",
1085
+
"bufo-rolling-out",
1086
+
"bufo-rose",
1087
+
"bufo-ross",
1088
+
"bufo-royalty",
1089
+
"bufo-royalty-sparkle",
1090
+
"bufo-rude",
1091
+
"bufo-rudolph",
1092
+
"bufo-run",
1093
+
"bufo-run-right",
1094
+
"bufo-rush",
1095
+
"bufo-sad",
1096
+
"bufo-sad-baguette",
1097
+
"bufo-sad-but-ok",
1098
+
"bufo-sad-rain",
1099
+
"bufo-sad-swinging",
1100
+
"bufo-sad-vibe",
1101
+
"bufo-sailor-moon",
1102
+
"bufo-salad",
1103
+
"bufo-salivating",
1104
+
"bufo-salty",
1105
+
"bufo-salute",
1106
+
"bufo-same",
1107
+
"bufo-santa",
1108
+
"bufo-saves-hyrule",
1109
+
"bufo-says-good-morning-to-test-the-waters",
1110
+
"bufo-scheduled",
1111
+
"bufo-science",
1112
+
"bufo-science-intensifies",
1113
+
"bufo-scientist",
1114
+
"bufo-scientist-intensifies",
1115
+
"bufo-screams-into-the-ambient-void",
1116
+
"bufo-security-jacket",
1117
+
"bufo-sees-what-you-did-there",
1118
+
"bufo-segway",
1119
+
"bufo-sends-a-demand-signal",
1120
+
"bufo-sends-to-print",
1121
+
"bufo-sends-you-to-the-shadow-realm",
1122
+
"bufo-shakes-up-your-etch-a-sketch",
1123
+
"bufo-shaking-eyes",
1124
+
"bufo-shaking-head",
1125
+
"bufo-shame",
1126
+
"bufo-shares-his-banana",
1127
+
"bufo-sheesh",
1128
+
"bufo-shh",
1129
+
"bufo-shh-barking-puppy",
1130
+
"bufo-shifty",
1131
+
"bufo-ship",
1132
+
"bufo-shipit",
1133
+
"bufo-shipping",
1134
+
"bufo-shower",
1135
+
"bufo-showing-off-baby",
1136
+
"bufo-showing-off-babypilot",
1137
+
"bufo-shredding",
1138
+
"bufo-shrek",
1139
+
"bufo-shrek-but-canonically-correct",
1140
+
"bufo-shrooms",
1141
+
"bufo-shrug",
1142
+
"bufo-shy",
1143
+
"bufo-sigh",
1144
+
"bufo-silly",
1145
+
"bufo-silly-goose-dance",
1146
+
"bufo-simba",
1147
+
"bufo-single-tear",
1148
+
"bufo-sinks",
1149
+
"bufo-sip",
1150
+
"bufo-sipping-on-juice",
1151
+
"bufo-sips-coffee",
1152
+
"bufo-siren",
1153
+
"bufo-sit",
1154
+
"bufo-sith",
1155
+
"bufo-skeledance",
1156
+
"bufo-skellington",
1157
+
"bufo-skellington-1",
1158
+
"bufo-skiing",
1159
+
"bufo-slay",
1160
+
"bufo-sleep",
1161
+
"bufo-slinging-bagels",
1162
+
"bufo-slowly-heads-out",
1163
+
"bufo-slowly-lurks-in",
1164
+
"bufo-smile",
1165
+
"bufo-smirk",
1166
+
"bufo-smol",
1167
+
"bufo-smug",
1168
+
"bufo-smugo",
1169
+
"bufo-snail",
1170
+
"bufo-snaps-a-pic",
1171
+
"bufo-snore",
1172
+
"bufo-snow",
1173
+
"bufo-sobbing",
1174
+
"bufo-soccer",
1175
+
"bufo-softball",
1176
+
"bufo-sombrero",
1177
+
"bufo-speaking-math",
1178
+
"bufo-spider",
1179
+
"bufo-spit",
1180
+
"bufo-spooky-szn",
1181
+
"bufo-sports",
1182
+
"bufo-squad",
1183
+
"bufo-squash",
1184
+
"bufo-sriracha",
1185
+
"bufo-stab",
1186
+
"bufo-stab-murder",
1187
+
"bufo-stab-reverse",
1188
+
"bufo-stamp",
1189
+
"bufo-standing",
1190
+
"bufo-stare",
1191
+
"bufo-stargazing",
1192
+
"bufo-stars-in-a-old-timey-talkie",
1193
+
"bufo-starstruck",
1194
+
"bufo-stay-puft-marshmallow",
1195
+
"bufo-steals-your-thunder",
1196
+
"bufo-stick",
1197
+
"bufo-stick-reverse",
1198
+
"bufo-stole-caribufos-antler",
1199
+
"bufo-stole-your-crunchwrap-before-you-could-finish-it",
1200
+
"bufo-stoned",
1201
+
"bufo-stonks",
1202
+
"bufo-stonks2",
1203
+
"bufo-stop",
1204
+
"bufo-stopsign",
1205
+
"bufo-strains-his-neck",
1206
+
"bufo-strange",
1207
+
"bufo-strawberry",
1208
+
"bufo-strikes-a-deal",
1209
+
"bufo-strikes-the-match-he's-ready-for-inferno",
1210
+
"bufo-stripe",
1211
+
"bufo-stuffed",
1212
+
"bufo-style",
1213
+
"bufo-sun-bless",
1214
+
"bufo-sunny-side-up",
1215
+
"bufo-surf",
1216
+
"bufo-sus",
1217
+
"bufo-sushi",
1218
+
"bufo-sussy-eyebrows",
1219
+
"bufo-sweat",
1220
+
"bufo-sweep",
1221
+
"bufo-sweet-dreams",
1222
+
"bufo-sweet-potato",
1223
+
"bufo-swims",
1224
+
"bufo-sword",
1225
+
"bufo-taco",
1226
+
"bufo-tada",
1227
+
"bufo-take-my-money",
1228
+
"bufo-takes-a-bath",
1229
+
"bufo-takes-bufo-give",
1230
+
"bufo-takes-five-corndogs-to-the-movies-by-himself-as-his-me-time",
1231
+
"bufo-takes-hotdog",
1232
+
"bufo-takes-slack",
1233
+
"bufo-takes-spam",
1234
+
"bufo-takes-your-approval",
1235
+
"bufo-takes-your-boba",
1236
+
"bufo-takes-your-bufo-taco",
1237
+
"bufo-takes-your-burrito",
1238
+
"bufo-takes-your-copilot",
1239
+
"bufo-takes-your-fud-away",
1240
+
"bufo-takes-your-golden-handcuffs",
1241
+
"bufo-takes-your-incident",
1242
+
"bufo-takes-your-nose",
1243
+
"bufo-takes-your-pizza",
1244
+
"bufo-takes-yubikey",
1245
+
"bufo-takes-zoom",
1246
+
"bufo-talks-to-brick-wall",
1247
+
"bufo-tapioca-pearl",
1248
+
"bufo-tea",
1249
+
"bufo-teal",
1250
+
"bufo-tears-of-joy",
1251
+
"bufo-tense",
1252
+
"bufo-tequila",
1253
+
"bufo-thanks",
1254
+
"bufo-thanks-bufo-for-thanking-bufo",
1255
+
"bufo-thanks-the-sr-bufo-for-their-wisdom",
1256
+
"bufo-thanks-you-for-the-approval",
1257
+
"bufo-thanks-you-for-the-bufo",
1258
+
"bufo-thanks-you-for-the-comment",
1259
+
"bufo-thanks-you-for-the-new-bufo",
1260
+
"bufo-thanks-you-for-your-issue",
1261
+
"bufo-thanks-you-for-your-pr",
1262
+
"bufo-thanks-you-for-your-service",
1263
+
"bufo-thanksgiving",
1264
+
"bufo-thanos",
1265
+
"bufo-thats-a-knee-slapper",
1266
+
"bufo-the-builder",
1267
+
"bufo-the-crying-osha-compliant-builder",
1268
+
"bufo-the-osha-compliant-builder",
1269
+
"bufo-think",
1270
+
"bufo-thinking",
1271
+
"bufo-thinking-about-holidays",
1272
+
"bufo-thinks-about-a11y",
1273
+
"bufo-thinks-about-azure",
1274
+
"bufo-thinks-about-azure-front-door",
1275
+
"bufo-thinks-about-azure-front-door-intensifies",
1276
+
"bufo-thinks-about-cheeky-nandos",
1277
+
"bufo-thinks-about-chocolate",
1278
+
"bufo-thinks-about-climbing",
1279
+
"bufo-thinks-about-docs",
1280
+
"bufo-thinks-about-fishsticks",
1281
+
"bufo-thinks-about-mountains",
1282
+
"bufo-thinks-about-omelette",
1283
+
"bufo-thinks-about-pancakes",
1284
+
"bufo-thinks-about-quarter",
1285
+
"bufo-thinks-about-redis",
1286
+
"bufo-thinks-about-rubberduck",
1287
+
"bufo-thinks-about-steak",
1288
+
"bufo-thinks-about-steakholder",
1289
+
"bufo-thinks-about-teams",
1290
+
"bufo-thinks-about-telemetry",
1291
+
"bufo-thinks-about-terraform",
1292
+
"bufo-thinks-about-ufo",
1293
+
"bufo-thinks-about-vacation",
1294
+
"bufo-thinks-he-gets-paid-too-much-to-work-here",
1295
+
"bufo-thinks-of-shamenun",
1296
+
"bufo-thinks-this-is-a-total-bop",
1297
+
"bufo-this",
1298
+
"bufo-this-is-fine",
1299
+
"bufo-this2",
1300
+
"bufo-thonk",
1301
+
"bufo-thonks-from-the-void",
1302
+
"bufo-threatens-to-hit-you-with-the-chancla-and-he-means-it",
1303
+
"bufo-threatens-to-thwack-you-with-a-slipper-and-he-means-it",
1304
+
"bufo-throws-brick",
1305
+
"bufo-thumbsup",
1306
+
"bufo-thunk",
1307
+
"bufo-thwack",
1308
+
"bufo-timeout",
1309
+
"bufo-tin-foil-hat",
1310
+
"bufo-tin-foil-hat2",
1311
+
"bufo-tips-hat",
1312
+
"bufo-tired",
1313
+
"bufo-tired-of-rooting-for-the-anti-hero",
1314
+
"bufo-tired-yes",
1315
+
"bufo-toad",
1316
+
"bufo-tofu",
1317
+
"bufo-toilet-rocket",
1318
+
"bufo-tomato",
1319
+
"bufo-tongue",
1320
+
"bufo-too-many-pings",
1321
+
"bufo-took-too-much",
1322
+
"bufo-tooth",
1323
+
"bufo-tophat",
1324
+
"bufo-tortoise",
1325
+
"bufo-torus",
1326
+
"bufo-trailhead",
1327
+
"bufo-train",
1328
+
"bufo-transfixed",
1329
+
"bufo-transmutes-reality",
1330
+
"bufo-trash-can",
1331
+
"bufo-travels",
1332
+
"bufo-tries-some-yummy-yummy-crossplane",
1333
+
"bufo-tries-to-fight-you-but-his-arms-are-too-short-so-count-yourself-lucky",
1334
+
"bufo-tries-to-hug-you-back-but-his-arms-are-too-short",
1335
+
"bufo-tries-to-hug-you-but-his-arms-are-too-short",
1336
+
"bufo-triple-vaccinated",
1337
+
"bufo-tripping",
1338
+
"bufo-trying-to-relax-while-procrastinating-but-its-not-working",
1339
+
"bufo-turns-the-tables",
1340
+
"bufo-tux",
1341
+
"bufo-typing",
1342
+
"bufo-u-dead",
1343
+
"bufo-ufo",
1344
+
"bufo-ugh",
1345
+
"bufo-uh-okay-i-guess-so",
1346
+
"bufo-uhhh",
1347
+
"bufo-underpaid-postage-at-usps-and-now-they're-coming-after-him-for-the-money-he-owes",
1348
+
"bufo-unicorn",
1349
+
"bufo-universe",
1350
+
"bufo-unlocked-transdimensional-travel-while-in-the-void",
1351
+
"bufo-uno",
1352
+
"bufo-upvote",
1353
+
"bufo-uses-100-percent-of-his-brain",
1354
+
"bufo-uwu",
1355
+
"bufo-vaccinated",
1356
+
"bufo-vaccinates-you",
1357
+
"bufo-vampire",
1358
+
"bufo-venom",
1359
+
"bufo-ventilator",
1360
+
"bufo-very-angry",
1361
+
"bufo-vibe",
1362
+
"bufo-vibe-dance",
1363
+
"bufo-vomit",
1364
+
"bufo-voted",
1365
+
"bufo-waddle",
1366
+
"bufo-waiting-for-aws-to-deep-archive-our-data",
1367
+
"bufo-waiting-for-azure",
1368
+
"bufo-waits-in-queue",
1369
+
"bufo-waldo",
1370
+
"bufo-walk-away",
1371
+
"bufo-wallop",
1372
+
"bufo-wants-a-refund",
1373
+
"bufo-wants-to-have-a-calm-and-civilized-conversation-with-you",
1374
+
"bufo-wants-to-know-your-spaghetti-policy-at-the-movies",
1375
+
"bufo-wants-to-return-his-vacuum-that-he-bought-at-costco-four-years-ago-for-a-full-refund",
1376
+
"bufo-wants-you-to-buy-his-crypto",
1377
+
"bufo-wards-off-the-evil-spirits",
1378
+
"bufo-warhol",
1379
+
"bufo-was-eavesdropping-and-got-offended-by-your-convo-but-now-has-to-pretend-he-didnt-hear-you",
1380
+
"bufo-was-in-paris",
1381
+
"bufo-wat",
1382
+
"bufo-watches-from-a-distance",
1383
+
"bufo-watches-the-rain",
1384
+
"bufo-watching-the-clock",
1385
+
"bufo-watermelon",
1386
+
"bufo-wave",
1387
+
"bufo-waves-hello-from-the-void",
1388
+
"bufo-wears-a-paper-crown",
1389
+
"bufo-wears-the-cone-of-shame",
1390
+
"bufo-wedding",
1391
+
"bufo-welcome",
1392
+
"bufo-welp",
1393
+
"bufo-whack",
1394
+
"bufo-what-are-you-doing-with-that",
1395
+
"bufo-what-did-you-just-say",
1396
+
"bufo-what-have-i-done",
1397
+
"bufo-what-have-you-done",
1398
+
"bufo-what-if",
1399
+
"bufo-whatever",
1400
+
"bufo-whew",
1401
+
"bufo-whisky",
1402
+
"bufo-who-me",
1403
+
"bufo-wholesome",
1404
+
"bufo-why-must-it-all-be-this-way",
1405
+
"bufo-why-must-it-be-this-way",
1406
+
"bufo-wicked",
1407
+
"bufo-wide",
1408
+
"bufo-wider-01",
1409
+
"bufo-wider-02",
1410
+
"bufo-wider-03",
1411
+
"bufo-wider-04",
1412
+
"bufo-wields-mjolnir",
1413
+
"bufo-wields-the-hylian-shield",
1414
+
"bufo-will-miss-you",
1415
+
"bufo-will-never-walk-cornelia-street-again",
1416
+
"bufo-will-not-be-going-to-space-today",
1417
+
"bufo-wine",
1418
+
"bufo-wink",
1419
+
"bufo-wishes-you-a-happy-valentines-day",
1420
+
"bufo-with-a-drive-by-hot-take",
1421
+
"bufo-with-a-fresh-do",
1422
+
"bufo-with-a-pearl-earring",
1423
+
"bufo-wizard",
1424
+
"bufo-wizard-magic-charge",
1425
+
"bufo-wonders-if-deliciousness-of-this-cheese-is-worth-the-pain-his-lactose-intolerance-will-cause",
1426
+
"bufo-workin-up-a-sweat-after-eating-a-wendys-double-loaded-double-baked-baked-potato-during-summer",
1427
+
"bufo-worldstar",
1428
+
"bufo-worried",
1429
+
"bufo-worry",
1430
+
"bufo-worry-coffee",
1431
+
"bufo-would-like-a-bite-of-your-cookie",
1432
+
"bufo-writes-a-doc",
1433
+
"bufo-wtf",
1434
+
"bufo-wut",
1435
+
"bufo-yah",
1436
+
"bufo-yay",
1437
+
"bufo-yay-awkward-eyes",
1438
+
"bufo-yay-confetti",
1439
+
"bufo-yay-judge",
1440
+
"bufo-yayy",
1441
+
"bufo-yeehaw",
1442
+
"bufo-yells-at-old-bufo",
1443
+
"bufo-yes",
1444
+
"bufo-yismail",
1445
+
"bufo-you-sure-about-that",
1446
+
"bufo-yugioh",
1447
+
"bufo-yummy",
1448
+
"bufo-zoom",
1449
+
"bufo-zoom-right",
1450
+
"bufo's-a-gamer-girl-but-specifically-nyt-games",
1451
+
"bufo+1",
1452
+
"bufobot",
1453
+
"bufochu",
1454
+
"bufocopter",
1455
+
"bufoda",
1456
+
"bufodile",
1457
+
"bufofoop",
1458
+
"bufoheimer",
1459
+
"bufohub",
1460
+
"bufolatro",
1461
+
"bufoling",
1462
+
"bufolo",
1463
+
"bufolta",
1464
+
"bufonana",
1465
+
"bufone",
1466
+
"bufonomical",
1467
+
"bufopilot",
1468
+
"bufopoof",
1469
+
"buforang",
1470
+
"buforce-be-with-you",
1471
+
"buforead",
1472
+
"buforever",
1473
+
"bufos-got-your-back",
1474
+
"bufos-in-love",
1475
+
"bufos-jumping-on-the-bed",
1476
+
"bufos-lips-are-sealed",
1477
+
"bufovacado",
1478
+
"bufowhirl",
1479
+
"bufrogu",
1480
+
"but-wait-theres-bufo",
1481
+
"child-bufo-only-has-deku-sticks-to-save-hyrule",
1482
+
"chonky-bufo-wants-to-be-held",
1483
+
"christmas-bufo-on-a-goose",
1484
+
"circle-of-bufo",
1485
+
"confused-math-bufo",
1486
+
"constipated-bufo-is-trying-his-hardest",
1487
+
"copper-bufo",
1488
+
"corrupted-bufo",
1489
+
"count-bufo",
1490
+
"daily-dose-of-bufo-vitamins",
1491
+
"dalmatian-bufo",
1492
+
"death-by-a-thousand-bufo-stabs",
1493
+
"doctor-bufo",
1494
+
"dont-make-bufo-tap-the-sign",
1495
+
"double-bufo-sideeye",
1496
+
"egg-bufo",
1497
+
"eggplant-bufo",
1498
+
"et-tu-bufo",
1499
+
"everybody-loves-bufo",
1500
+
"existential-bufo",
1501
+
"feelsgoodbufo",
1502
+
"fix-it-bufo",
1503
+
"friendly-neighborhood-bufo",
1504
+
"future-bufos",
1505
+
"get-in-lets-bufo",
1506
+
"get-out-of-bufos-swamp",
1507
+
"ghost-bufo-of-future-past-is-disappointed-in-your-lack-of-foresight",
1508
+
"gold-bufo",
1509
+
"good-news-bufo-offers-suppository",
1510
+
"google-sheet-bufo",
1511
+
"great-white-bufo",
1512
+
"happy-bufo-brings-you-a-deescalation-coffee",
1513
+
"happy-bufo-brings-you-a-deescalation-tea",
1514
+
"heavy-is-the-bufo-that-wears-the-crown",
1515
+
"holiday-bufo-offers-you-a-candy-cane",
1516
+
"house-of-bufo",
1517
+
"i-dont-trust-bufo",
1518
+
"i-heart-bufo",
1519
+
"i-think-you-should-leave-with-bufo",
1520
+
"if-bufo-fits-bufo-sits",
1521
+
"interdimensional-bufo-rests-atop-the-terrarium-of-existence",
1522
+
"it-takes-a-bufo-to-know-a-bufo",
1523
+
"its-been-such-a-long-day-that-bufo-doesnt-really-care-anymore",
1524
+
"just-a-bunch-of-bufos",
1525
+
"just-hear-bufo-out-for-a-sec",
1526
+
"kermit-the-bufo",
1527
+
"king-bufo",
1528
+
"kirbufo",
1529
+
"le-bufo",
1530
+
"live-laugh-bufo",
1531
+
"loch-ness-bufo",
1532
+
"looks-good-to-bufo",
1533
+
"low-fidelity-bufo-cant-believe-youve-done-this",
1534
+
"low-fidelity-bufo-concerned",
1535
+
"low-fidelity-bufo-excited",
1536
+
"low-fidelity-bufo-gets-whiplash",
1537
+
"m-bufo",
1538
+
"maam-this-is-a-bufo",
1539
+
"many-bufos",
1540
+
"maybe-a-bufo-bigfoot",
1541
+
"mega-bufo",
1542
+
"mrs-bufo",
1543
+
"my-name-is-buford-and-i-am-bufo's-father",
1544
+
"nobufo",
1545
+
"not-bufo",
1546
+
"nothing-inauthentic-bout-this-bufo-yeah-hes-the-real-thing-baby",
1547
+
"old-bufo-yells-at-cloud",
1548
+
"old-bufo-yells-at-hubble",
1549
+
"old-man-yells-at-bufo",
1550
+
"old-man-yells-at-old-bufo",
1551
+
"one-of-101-bufos",
1552
+
"our-bufo-is-in-another-castle",
1553
+
"paper-bufo",
1554
+
"party-bufo",
1555
+
"pixel-bufo",
1556
+
"planet-bufo",
1557
+
"please-converse-using-only-bufo",
1558
+
"poison-dart-bufo",
1559
+
"pour-one-out-for-bufo",
1560
+
"press-x-to-bufo",
1561
+
"princebufo",
1562
+
"proud-bufo-is-excited",
1563
+
"radioactive-bufo",
1564
+
"sad-bufo",
1565
+
"safe-driver-bufo",
1566
+
"se%C3%B1or-bufo",
1567
+
"sen%CC%83or-bufo",
1568
+
"shiny-bufo",
1569
+
"shut-up-and-take-my-bufo",
1570
+
"silver-bufo",
1571
+
"sir-bufo-esquire",
1572
+
"sir-this-is-a-bufo",
1573
+
"sleepy-bufo",
1574
+
"smol-bufo-feels-blessed",
1575
+
"smol-bufo-has-a-smol-pull-request-that-needs-reviews-and-he-promises-it-will-only-take-a-minute",
1576
+
"so-bufoful",
1577
+
"spider-bufo",
1578
+
"spotify-wrapped-reminded-bufo-his-listening-patterns-are-a-little-unhinged",
1579
+
"super-bufo",
1580
+
"super-bufo-bros",
1581
+
"tabufo",
1582
+
"teamwork-makes-the-bufo-work",
1583
+
"ted-bufo",
1584
+
"the_bufo_formerly_know_as_froge",
1585
+
"the-bufo-nightmare-before-christmas",
1586
+
"the-bufo-we-deserve",
1587
+
"the-bufos-new-groove",
1588
+
"the-creation-of-bufo",
1589
+
"the-more-you-bufo",
1590
+
"the-pinkest-bufo-there-ever-was",
1591
+
"theres-a-bufo-for-that",
1592
+
"this-8-dollar-starbucks-drink-isnt-helping-bufo-feel-any-better",
1593
+
"this-is-bufo",
1594
+
"this-will-be-bufos-little-secret",
1595
+
"triumphant-bufo",
1596
+
"tsa-bufo-gropes-you",
1597
+
"two-bufos-beefin",
1598
+
"up-and-to-the-bufo",
1599
+
"vin-bufo",
1600
+
"vintage-bufo",
1601
+
"whatever-youre-doing-its-attracting-the-bufos",
1602
+
"when-bufo-falls-in-love",
1603
+
"whenlifegetsatbufo",
1604
+
"with-friends-like-this-bufo-doesnt-need-enemies",
1605
+
"wreck-it-bufo",
1606
+
"wrong-frog",
1607
+
"yay-bufo-1",
1608
+
"yay-bufo-2",
1609
+
"yay-bufo-3",
1610
+
"yay-bufo-4",
1611
+
"you-have-awoken-the-bufo",
1612
+
"you-have-exquisite-taste-in-bufo",
1613
+
"you-left-your-typewriter-at-bufos-apartment"
1614
+
]
+8
site/favicon.svg
+8
site/favicon.svg
···
1
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2
+
<!-- Outer ring -->
3
+
<circle cx="16" cy="16" r="14" fill="none" stroke="#4a9eff" stroke-width="2"/>
4
+
<!-- Inner status dot -->
5
+
<circle cx="16" cy="16" r="8" fill="#4a9eff"/>
6
+
<!-- Small highlight to give it depth -->
7
+
<circle cx="18" cy="14" r="3" fill="#6bb2ff" opacity="0.7"/>
8
+
</svg>
+49
site/index.html
+49
site/index.html
···
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
+
<title>status</title>
7
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
8
+
<link rel="stylesheet" href="/styles.css">
9
+
<script src="https://cdn.jsdelivr.net/gh/bigmoves/quickslice@v0.17.3/quickslice-client-js/dist/quickslice-client.min.js"></script>
10
+
</head>
11
+
<body>
12
+
<div id="app">
13
+
<header>
14
+
<h1 id="page-title">status</h1>
15
+
<nav>
16
+
<a href="/" id="nav-home" class="nav-btn" aria-label="home" title="home">
17
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
18
+
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
19
+
<polyline points="9 22 9 12 15 12 15 22"></polyline>
20
+
</svg>
21
+
</a>
22
+
<a href="/feed" id="nav-feed" class="nav-btn" aria-label="global feed" title="global feed">
23
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
24
+
<circle cx="12" cy="12" r="10"></circle>
25
+
<line x1="2" y1="12" x2="22" y2="12"></line>
26
+
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
27
+
</svg>
28
+
</a>
29
+
<button id="settings-btn" class="nav-btn hidden" aria-label="settings" title="settings">
30
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
31
+
<circle cx="12" cy="12" r="3"></circle>
32
+
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
33
+
</svg>
34
+
</button>
35
+
<button id="theme-toggle" aria-label="toggle theme">
36
+
<span class="sun">☀</span>
37
+
<span class="moon">☾</span>
38
+
</button>
39
+
</nav>
40
+
</header>
41
+
42
+
<main id="main-content">
43
+
<div class="center">loading...</div>
44
+
</main>
45
+
</div>
46
+
47
+
<script src="/app.js"></script>
48
+
</body>
49
+
</html>
+791
site/styles.css
+791
site/styles.css
···
1
+
:root {
2
+
--bg: #0a0a0a;
3
+
--bg-card: #1a1a1a;
4
+
--text: #ffffff;
5
+
--text-secondary: #888;
6
+
--accent: #4a9eff;
7
+
--border: #2a2a2a;
8
+
--radius: 12px;
9
+
--font-family: ui-monospace, "SF Mono", Monaco, monospace;
10
+
}
11
+
12
+
[data-theme="light"] {
13
+
--bg: #ffffff;
14
+
--bg-card: #f5f5f5;
15
+
--text: #1a1a1a;
16
+
--text-secondary: #666;
17
+
--border: #e0e0e0;
18
+
}
19
+
20
+
* {
21
+
margin: 0;
22
+
padding: 0;
23
+
box-sizing: border-box;
24
+
}
25
+
26
+
/* Theme-aware scrollbars */
27
+
::-webkit-scrollbar {
28
+
width: 8px;
29
+
height: 8px;
30
+
}
31
+
32
+
::-webkit-scrollbar-track {
33
+
background: var(--bg);
34
+
}
35
+
36
+
::-webkit-scrollbar-thumb {
37
+
background: var(--border);
38
+
border-radius: 4px;
39
+
}
40
+
41
+
::-webkit-scrollbar-thumb:hover {
42
+
background: var(--text-secondary);
43
+
}
44
+
45
+
/* Firefox */
46
+
* {
47
+
scrollbar-width: thin;
48
+
scrollbar-color: var(--border) var(--bg);
49
+
}
50
+
51
+
body {
52
+
font-family: var(--font-family);
53
+
background: var(--bg);
54
+
color: var(--text);
55
+
line-height: 1.6;
56
+
min-height: 100vh;
57
+
}
58
+
59
+
#app {
60
+
max-width: 600px;
61
+
margin: 0 auto;
62
+
padding: 2rem 1rem;
63
+
}
64
+
65
+
header {
66
+
display: flex;
67
+
justify-content: space-between;
68
+
align-items: center;
69
+
margin-bottom: 2rem;
70
+
padding-bottom: 1rem;
71
+
border-bottom: 1px solid var(--border);
72
+
}
73
+
74
+
header h1 {
75
+
font-size: 1.5rem;
76
+
font-weight: 600;
77
+
}
78
+
79
+
nav {
80
+
display: flex;
81
+
gap: 1rem;
82
+
align-items: center;
83
+
}
84
+
85
+
nav a {
86
+
color: var(--text-secondary);
87
+
text-decoration: none;
88
+
}
89
+
90
+
nav a:hover {
91
+
color: var(--accent);
92
+
}
93
+
94
+
.nav-btn {
95
+
display: flex;
96
+
align-items: center;
97
+
justify-content: center;
98
+
padding: 0.5rem;
99
+
border-radius: 8px;
100
+
transition: background 0.15s, color 0.15s;
101
+
color: var(--text-secondary);
102
+
background: none;
103
+
border: none;
104
+
cursor: pointer;
105
+
}
106
+
107
+
.nav-btn:hover {
108
+
background: var(--bg-card);
109
+
color: var(--accent);
110
+
}
111
+
112
+
.nav-btn.active {
113
+
color: var(--accent);
114
+
}
115
+
116
+
.nav-btn svg {
117
+
display: block;
118
+
}
119
+
120
+
#theme-toggle {
121
+
background: none;
122
+
border: 1px solid var(--border);
123
+
border-radius: 8px;
124
+
padding: 0.5rem;
125
+
cursor: pointer;
126
+
font-size: 1rem;
127
+
}
128
+
129
+
#theme-toggle .sun { display: none; }
130
+
#theme-toggle .moon { display: inline; color: var(--text); }
131
+
[data-theme="light"] #theme-toggle .sun { display: inline; color: var(--text); }
132
+
[data-theme="light"] #theme-toggle .moon { display: none; }
133
+
134
+
.hidden { display: none !important; }
135
+
.center { text-align: center; padding: 2rem; }
136
+
137
+
/* Login form */
138
+
#login-form {
139
+
display: flex;
140
+
gap: 0.5rem;
141
+
margin-top: 1rem;
142
+
justify-content: center;
143
+
}
144
+
145
+
#login-form input {
146
+
padding: 0.75rem 1rem;
147
+
border: 1px solid var(--border);
148
+
border-radius: var(--radius);
149
+
background: var(--bg-card);
150
+
color: var(--text);
151
+
font-family: inherit;
152
+
font-size: 1rem;
153
+
width: 200px;
154
+
}
155
+
156
+
#login-form button, button[type="submit"] {
157
+
padding: 0.75rem 1.5rem;
158
+
background: var(--accent);
159
+
color: white;
160
+
border: none;
161
+
border-radius: var(--radius);
162
+
cursor: pointer;
163
+
font-family: inherit;
164
+
font-size: 1rem;
165
+
}
166
+
167
+
#login-form button:hover, button[type="submit"]:hover {
168
+
opacity: 0.9;
169
+
}
170
+
171
+
/* Profile card */
172
+
.profile-card {
173
+
background: var(--bg-card);
174
+
border: 1px solid var(--border);
175
+
border-radius: var(--radius);
176
+
padding: 2rem;
177
+
margin-bottom: 1.5rem;
178
+
}
179
+
180
+
.current-status {
181
+
display: flex;
182
+
flex-direction: column;
183
+
align-items: center;
184
+
gap: 1rem;
185
+
text-align: center;
186
+
}
187
+
188
+
.big-emoji {
189
+
font-size: 4rem;
190
+
line-height: 1;
191
+
}
192
+
193
+
.big-emoji img {
194
+
width: 4rem;
195
+
height: 4rem;
196
+
object-fit: contain;
197
+
}
198
+
199
+
.status-info {
200
+
display: flex;
201
+
flex-direction: column;
202
+
gap: 0.25rem;
203
+
}
204
+
205
+
#current-text {
206
+
font-size: 1.25rem;
207
+
}
208
+
209
+
.meta {
210
+
color: var(--text-secondary);
211
+
font-size: 0.875rem;
212
+
}
213
+
214
+
/* Status form */
215
+
.status-form {
216
+
background: var(--bg-card);
217
+
border: 1px solid var(--border);
218
+
border-radius: var(--radius);
219
+
padding: 1rem;
220
+
margin-bottom: 1.5rem;
221
+
}
222
+
223
+
.emoji-input-row {
224
+
display: flex;
225
+
gap: 0.5rem;
226
+
margin-bottom: 0.75rem;
227
+
}
228
+
229
+
.emoji-input-row input {
230
+
flex: 1;
231
+
padding: 0.75rem;
232
+
border: 1px solid var(--border);
233
+
border-radius: 8px;
234
+
background: var(--bg);
235
+
color: var(--text);
236
+
font-family: inherit;
237
+
font-size: 1rem;
238
+
}
239
+
240
+
#emoji-input {
241
+
max-width: 150px;
242
+
}
243
+
244
+
.form-actions {
245
+
display: flex;
246
+
gap: 0.5rem;
247
+
justify-content: flex-end;
248
+
}
249
+
250
+
.form-actions select {
251
+
padding: 0.75rem;
252
+
border: 1px solid var(--border);
253
+
border-radius: 8px;
254
+
background: var(--bg);
255
+
color: var(--text);
256
+
font-family: inherit;
257
+
}
258
+
259
+
.custom-datetime {
260
+
padding: 0.75rem;
261
+
border: 1px solid var(--border);
262
+
border-radius: 8px;
263
+
background: var(--bg);
264
+
color: var(--text);
265
+
font-family: inherit;
266
+
}
267
+
268
+
/* History */
269
+
.history {
270
+
margin-bottom: 2rem;
271
+
}
272
+
273
+
.history h2 {
274
+
font-size: 0.875rem;
275
+
text-transform: uppercase;
276
+
letter-spacing: 0.05em;
277
+
color: var(--text-secondary);
278
+
margin-bottom: 1rem;
279
+
}
280
+
281
+
#history-list {
282
+
display: flex;
283
+
flex-direction: column;
284
+
gap: 0.75rem;
285
+
}
286
+
287
+
/* Feed list */
288
+
.feed-list {
289
+
display: flex;
290
+
flex-direction: column;
291
+
gap: 1rem;
292
+
}
293
+
294
+
/* Status item (used in both history and feed) */
295
+
.status-item {
296
+
display: flex;
297
+
gap: 1rem;
298
+
padding: 1rem;
299
+
background: var(--bg-card);
300
+
border: 1px solid var(--border);
301
+
border-radius: var(--radius);
302
+
align-items: flex-start;
303
+
}
304
+
305
+
.status-item:hover {
306
+
border-color: var(--accent);
307
+
}
308
+
309
+
.status-item .emoji {
310
+
font-size: 1.5rem;
311
+
line-height: 1;
312
+
flex-shrink: 0;
313
+
}
314
+
315
+
.status-item .emoji img {
316
+
width: 1.5rem;
317
+
height: 1.5rem;
318
+
object-fit: contain;
319
+
}
320
+
321
+
.status-item .content {
322
+
flex: 1;
323
+
min-width: 0;
324
+
}
325
+
326
+
.status-item .author {
327
+
color: var(--text-secondary);
328
+
font-weight: 600;
329
+
text-decoration: none;
330
+
}
331
+
332
+
.status-item .author:hover {
333
+
color: var(--accent);
334
+
}
335
+
336
+
.status-item .text {
337
+
margin-left: 0.5rem;
338
+
}
339
+
340
+
.status-item .time {
341
+
display: block;
342
+
font-size: 0.875rem;
343
+
color: var(--text-secondary);
344
+
margin-top: 0.25rem;
345
+
}
346
+
347
+
.delete-btn {
348
+
background: transparent;
349
+
border: none;
350
+
color: var(--text-secondary);
351
+
cursor: pointer;
352
+
padding: 0.25rem;
353
+
border-radius: 4px;
354
+
opacity: 0;
355
+
transition: opacity 0.15s, color 0.15s;
356
+
flex-shrink: 0;
357
+
}
358
+
359
+
.status-item:hover .delete-btn {
360
+
opacity: 1;
361
+
}
362
+
363
+
.delete-btn:hover {
364
+
color: #e74c3c;
365
+
}
366
+
367
+
/* Logout */
368
+
.logout-btn {
369
+
display: block;
370
+
margin: 0 auto;
371
+
padding: 0.5rem 1rem;
372
+
background: none;
373
+
border: 1px solid var(--border);
374
+
border-radius: 8px;
375
+
color: var(--text-secondary);
376
+
cursor: pointer;
377
+
font-family: inherit;
378
+
}
379
+
380
+
.logout-btn:hover {
381
+
border-color: var(--text);
382
+
color: var(--text);
383
+
}
384
+
385
+
/* Load more */
386
+
#load-more-btn {
387
+
padding: 0.75rem 1.5rem;
388
+
background: var(--bg-card);
389
+
border: 1px solid var(--border);
390
+
border-radius: var(--radius);
391
+
color: var(--text);
392
+
cursor: pointer;
393
+
font-family: inherit;
394
+
}
395
+
396
+
#load-more-btn:hover {
397
+
border-color: var(--accent);
398
+
}
399
+
400
+
/* Emoji trigger button */
401
+
.emoji-trigger {
402
+
width: 3rem;
403
+
height: 3rem;
404
+
border: none;
405
+
border-radius: 8px;
406
+
background: transparent;
407
+
cursor: pointer;
408
+
display: flex;
409
+
align-items: center;
410
+
justify-content: center;
411
+
font-size: 1.75rem;
412
+
flex-shrink: 0;
413
+
}
414
+
415
+
.emoji-trigger:hover {
416
+
background: var(--bg-card);
417
+
}
418
+
419
+
.emoji-trigger img {
420
+
width: 2.5rem;
421
+
height: 2.5rem;
422
+
object-fit: contain;
423
+
}
424
+
425
+
/* Emoji picker overlay */
426
+
.emoji-picker-overlay {
427
+
position: fixed;
428
+
inset: 0;
429
+
background: rgba(0, 0, 0, 0.7);
430
+
display: flex;
431
+
align-items: center;
432
+
justify-content: center;
433
+
z-index: 1000;
434
+
padding: 1rem;
435
+
}
436
+
437
+
.emoji-picker {
438
+
background: var(--bg-card);
439
+
border: 1px solid var(--border);
440
+
border-radius: var(--radius);
441
+
width: 100%;
442
+
max-width: 600px;
443
+
height: 90vh;
444
+
max-height: 700px;
445
+
display: flex;
446
+
flex-direction: column;
447
+
overflow: hidden;
448
+
}
449
+
450
+
.emoji-picker-header {
451
+
display: flex;
452
+
justify-content: space-between;
453
+
align-items: center;
454
+
padding: 1rem;
455
+
border-bottom: 1px solid var(--border);
456
+
}
457
+
458
+
.emoji-picker-header h3 {
459
+
font-size: 1rem;
460
+
font-weight: 600;
461
+
}
462
+
463
+
.emoji-picker-close {
464
+
background: none;
465
+
border: none;
466
+
color: var(--text-secondary);
467
+
cursor: pointer;
468
+
font-size: 1.25rem;
469
+
padding: 0.25rem;
470
+
}
471
+
472
+
.emoji-picker-close:hover {
473
+
color: var(--text);
474
+
}
475
+
476
+
.emoji-search {
477
+
margin: 0.75rem;
478
+
padding: 0.5rem 0.75rem;
479
+
border: 1px solid var(--border);
480
+
border-radius: 8px;
481
+
background: var(--bg);
482
+
color: var(--text);
483
+
font-family: inherit;
484
+
font-size: 0.875rem;
485
+
}
486
+
487
+
.emoji-categories {
488
+
display: flex;
489
+
gap: 0.25rem;
490
+
padding: 0 0.75rem;
491
+
overflow-x: auto;
492
+
flex-shrink: 0;
493
+
}
494
+
495
+
.category-btn {
496
+
padding: 0.5rem;
497
+
border: none;
498
+
background: none;
499
+
cursor: pointer;
500
+
font-size: 1.25rem;
501
+
border-radius: 8px;
502
+
opacity: 0.5;
503
+
transition: opacity 0.15s;
504
+
}
505
+
506
+
.category-btn:hover, .category-btn.active {
507
+
opacity: 1;
508
+
background: var(--bg);
509
+
}
510
+
511
+
.emoji-grid {
512
+
padding: 0.75rem;
513
+
display: grid;
514
+
grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
515
+
gap: 0.25rem;
516
+
overflow-y: auto;
517
+
flex: 1;
518
+
min-height: 200px;
519
+
align-content: start;
520
+
}
521
+
522
+
.emoji-grid.bufo-grid {
523
+
grid-template-columns: repeat(auto-fill, minmax(64px, 1fr));
524
+
gap: 0.5rem;
525
+
}
526
+
527
+
.emoji-btn {
528
+
padding: 0.5rem;
529
+
border: none;
530
+
background: none;
531
+
cursor: pointer;
532
+
font-size: 1.5rem;
533
+
border-radius: 8px;
534
+
transition: background 0.15s;
535
+
}
536
+
537
+
.emoji-btn:hover {
538
+
background: var(--bg);
539
+
}
540
+
541
+
/* Consistent sizing for mixed emoji/bufo grids (frequent tab) */
542
+
.emoji-grid .emoji-btn {
543
+
width: 48px;
544
+
height: 48px;
545
+
display: flex;
546
+
align-items: center;
547
+
justify-content: center;
548
+
font-size: 1.75rem;
549
+
}
550
+
551
+
.bufo-btn {
552
+
padding: 0.25rem;
553
+
}
554
+
555
+
.bufo-grid .bufo-btn {
556
+
width: 64px;
557
+
height: 64px;
558
+
}
559
+
560
+
.bufo-btn img {
561
+
width: 100%;
562
+
height: 100%;
563
+
max-width: 48px;
564
+
max-height: 48px;
565
+
object-fit: contain;
566
+
}
567
+
568
+
.loading {
569
+
grid-column: 1 / -1;
570
+
text-align: center;
571
+
color: var(--text-secondary);
572
+
padding: 2rem;
573
+
}
574
+
575
+
.no-results {
576
+
grid-column: 1 / -1;
577
+
text-align: center;
578
+
color: var(--text-secondary);
579
+
padding: 2rem;
580
+
}
581
+
582
+
/* Custom emoji input */
583
+
.custom-emoji-input {
584
+
grid-column: 1 / -1;
585
+
display: flex;
586
+
gap: 0.5rem;
587
+
margin-bottom: 1rem;
588
+
}
589
+
590
+
.custom-emoji-input input {
591
+
flex: 1;
592
+
padding: 0.5rem 0.75rem;
593
+
border: 1px solid var(--border);
594
+
border-radius: 8px;
595
+
background: var(--bg);
596
+
color: var(--text);
597
+
font-family: inherit;
598
+
}
599
+
600
+
.custom-emoji-input button {
601
+
padding: 0.5rem 1rem;
602
+
background: var(--accent);
603
+
color: white;
604
+
border: none;
605
+
border-radius: 8px;
606
+
cursor: pointer;
607
+
font-family: inherit;
608
+
}
609
+
610
+
.custom-emoji-preview {
611
+
grid-column: 1 / -1;
612
+
display: flex;
613
+
justify-content: center;
614
+
min-height: 80px;
615
+
align-items: center;
616
+
}
617
+
618
+
.bufo-helper {
619
+
padding: 0.75rem;
620
+
text-align: center;
621
+
border-top: 1px solid var(--border);
622
+
}
623
+
624
+
.bufo-helper a {
625
+
color: var(--accent);
626
+
font-size: 0.875rem;
627
+
}
628
+
629
+
/* Settings Modal */
630
+
.settings-overlay {
631
+
position: fixed;
632
+
top: 0;
633
+
left: 0;
634
+
right: 0;
635
+
bottom: 0;
636
+
background: rgba(0, 0, 0, 0.7);
637
+
display: flex;
638
+
align-items: center;
639
+
justify-content: center;
640
+
z-index: 1000;
641
+
padding: 1rem;
642
+
}
643
+
644
+
.settings-modal {
645
+
background: var(--bg-card);
646
+
border: 1px solid var(--border);
647
+
border-radius: var(--radius);
648
+
width: 100%;
649
+
max-width: 400px;
650
+
display: flex;
651
+
flex-direction: column;
652
+
}
653
+
654
+
.settings-header {
655
+
display: flex;
656
+
justify-content: space-between;
657
+
align-items: center;
658
+
padding: 1rem;
659
+
border-bottom: 1px solid var(--border);
660
+
}
661
+
662
+
.settings-header h3 {
663
+
font-size: 1.1rem;
664
+
font-weight: 500;
665
+
}
666
+
667
+
.settings-close {
668
+
background: none;
669
+
border: none;
670
+
color: var(--text-secondary);
671
+
cursor: pointer;
672
+
font-size: 1.25rem;
673
+
padding: 0.25rem;
674
+
}
675
+
676
+
.settings-close:hover {
677
+
color: var(--text);
678
+
}
679
+
680
+
.settings-content {
681
+
padding: 1rem;
682
+
display: flex;
683
+
flex-direction: column;
684
+
gap: 1.25rem;
685
+
}
686
+
687
+
.setting-group {
688
+
display: flex;
689
+
flex-direction: column;
690
+
gap: 0.5rem;
691
+
}
692
+
693
+
.setting-group label {
694
+
font-size: 0.875rem;
695
+
color: var(--text-secondary);
696
+
}
697
+
698
+
.setting-group select {
699
+
padding: 0.75rem;
700
+
border: 1px solid var(--border);
701
+
border-radius: 8px;
702
+
background: var(--bg);
703
+
color: var(--text);
704
+
font-family: inherit;
705
+
font-size: 1rem;
706
+
}
707
+
708
+
.color-picker {
709
+
display: flex;
710
+
flex-wrap: wrap;
711
+
gap: 0.5rem;
712
+
align-items: center;
713
+
}
714
+
715
+
.color-btn {
716
+
width: 32px;
717
+
height: 32px;
718
+
border-radius: 50%;
719
+
border: 2px solid transparent;
720
+
cursor: pointer;
721
+
transition: border-color 0.15s, transform 0.15s;
722
+
}
723
+
724
+
.color-btn:hover {
725
+
transform: scale(1.1);
726
+
}
727
+
728
+
.color-btn.active {
729
+
border-color: var(--text);
730
+
}
731
+
732
+
.custom-color-input {
733
+
width: 32px;
734
+
height: 32px;
735
+
border: none;
736
+
border-radius: 50%;
737
+
cursor: pointer;
738
+
background: none;
739
+
padding: 0;
740
+
}
741
+
742
+
.custom-color-input::-webkit-color-swatch-wrapper {
743
+
padding: 0;
744
+
}
745
+
746
+
.custom-color-input::-webkit-color-swatch {
747
+
border: 2px solid var(--border);
748
+
border-radius: 50%;
749
+
}
750
+
751
+
.settings-footer {
752
+
padding: 1rem;
753
+
border-top: 1px solid var(--border);
754
+
display: flex;
755
+
justify-content: flex-end;
756
+
}
757
+
758
+
.settings-footer .save-btn {
759
+
padding: 0.75rem 1.5rem;
760
+
background: var(--accent);
761
+
color: white;
762
+
border: none;
763
+
border-radius: 8px;
764
+
cursor: pointer;
765
+
font-family: inherit;
766
+
font-size: 1rem;
767
+
}
768
+
769
+
.settings-footer .save-btn:hover {
770
+
opacity: 0.9;
771
+
}
772
+
773
+
.settings-footer .save-btn:disabled {
774
+
opacity: 0.5;
775
+
cursor: not-allowed;
776
+
}
777
+
778
+
/* Mobile */
779
+
@media (max-width: 480px) {
780
+
.emoji-input-row {
781
+
flex-direction: row;
782
+
}
783
+
784
+
.form-actions {
785
+
flex-direction: column;
786
+
}
787
+
788
+
.emoji-grid {
789
+
grid-template-columns: repeat(6, 1fr);
790
+
}
791
+
}