slack status without the slack status.zzstoatzz.io/
quickslice

Merge pull request #72 from zzstoatzz/quickslice-rewrite

rewrite: quickslice backend + cloudflare pages frontend

authored by nate nowack and committed by GitHub 6660acb8 5e0dad48

-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
··· 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
··· 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
··· 1 - /target 2 - .idea 3 - .env 4 - statusphere.sqlite3 1 + # wrangler/cloudflare 2 + .wrangler/ 3 + 4 + # notes 5 + oauth-experience.md
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 + }