diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index e56b39a..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: CI - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -env: - CARGO_TERM_COLOR: always - -jobs: - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 - - - name: Check formatting - run: cargo fmt -- --check - - - name: Build - run: cargo build --verbose - - - name: Run clippy - run: cargo clippy -- -D warnings - - - name: Run tests - run: cargo test --verbose \ No newline at end of file diff --git a/.github/workflows/fly-review.yml b/.github/workflows/fly-review.yml deleted file mode 100644 index a7051f5..0000000 --- a/.github/workflows/fly-review.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Deploy Review App -on: - # Run this workflow on every PR event. Existing review apps will be updated when the PR is updated. - pull_request: - types: [opened, reopened, synchronize, closed] - paths: - - 'src/**' - - 'templates/**' - - 'static/**' - - 'Cargo.toml' - - 'Cargo.lock' - - 'Dockerfile' - - 'fly.toml' - - 'fly.review.toml' - - 'build.rs' - - 'sqlx-data.json' -env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} - # Set these to your Fly.io organization and preferred region. - FLY_REGION: ewr - FLY_ORG: personal - -jobs: - review_app: - runs-on: ubuntu-latest - outputs: - url: ${{ steps.deploy.outputs.url }} - # Only run one deployment at a time per PR. - concurrency: - group: pr-${{ github.event.number }} - - # Deploying apps with this "review" environment allows the URL for the app to be displayed in the PR UI. - environment: - name: review - # The script in the `deploy` sets the URL output for each review app. - url: ${{ steps.deploy.outputs.url }} - steps: - - name: Get code - uses: actions/checkout@v4 - - - name: Deploy PR app to Fly.io - id: deploy - uses: superfly/fly-pr-review-apps@1.2.1 - with: - name: zzstoatzz-status-pr-${{ github.event.number }} - config: fly.review.toml - # Use smaller resources for review apps - vmsize: shared-cpu-1x - memory: 256 - # Set OAUTH_REDIRECT_BASE dynamically for OAuth redirects - secrets: | - OAUTH_REDIRECT_BASE=https://zzstoatzz-status-pr-${{ github.event.number }}.fly.dev \ No newline at end of file diff --git a/.github/workflows/fly.yml b/.github/workflows/fly.yml deleted file mode 100644 index b5e71da..0000000 --- a/.github/workflows/fly.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Fly Deploy -on: - push: - branches: - - main -jobs: - deploy: - name: Deploy app - runs-on: ubuntu-latest - concurrency: deploy-group # ensure only one action runs at a time - steps: - - uses: actions/checkout@v4 - - uses: superfly/flyctl-actions/setup-flyctl@master - - run: flyctl deploy --remote-only - env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3c83b3d..89fd582 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ -/target -.idea -.env -statusphere.sqlite3 \ No newline at end of file +# wrangler/cloudflare +.wrangler/ + +# notes +oauth-experience.md diff --git a/Dockerfile b/Dockerfile index 5af4d0d..0830073 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,55 +1,74 @@ -# Build stage -FROM rustlang/rust:nightly-slim AS builder +ARG GLEAM_VERSION=v1.13.0 -# Install build dependencies -RUN apt-get update && apt-get install -y \ - pkg-config \ - libssl-dev \ - && rm -rf /var/lib/apt/lists/* +# Build stage - compile the application +FROM ghcr.io/gleam-lang/gleam:${GLEAM_VERSION}-erlang-alpine AS builder -WORKDIR /app +# Install build dependencies (including PostgreSQL client for multi-database support) +RUN apk add --no-cache \ + bash \ + git \ + nodejs \ + npm \ + build-base \ + sqlite-dev \ + postgresql-dev -# Copy manifests -COPY Cargo.toml Cargo.lock ./ +# Configure git for non-interactive use +ENV GIT_TERMINAL_PROMPT=0 -# Copy source code -COPY src ./src -COPY templates ./templates -COPY lexicons ./lexicons -COPY static ./static +# Clone quickslice at the v0.17.3 tag (includes sub claim fix) +RUN git clone --depth 1 --branch v0.17.3 https://github.com/bigmoves/quickslice.git /build -# Build for release -RUN cargo build --release +# Install dependencies for all projects +RUN cd /build/client && gleam deps download +RUN cd /build/lexicon_graphql && gleam deps download +RUN cd /build/server && gleam deps download -# Runtime stage -FROM debian:bookworm-slim +# Apply patches to dependencies +RUN cd /build && patch -p1 < patches/mist-websocket-protocol.patch -# Install runtime dependencies -RUN apt-get update && apt-get install -y \ - ca-certificates \ - libssl3 \ - && rm -rf /var/lib/apt/lists/* +# Install JavaScript dependencies for client +RUN cd /build/client && npm install -WORKDIR /app +# Compile the client code and output to server's static directory +RUN cd /build/client \ + && gleam add --dev lustre_dev_tools \ + && gleam run -m lustre/dev build quickslice_client --minify --outdir=/build/server/priv/static + +# Compile the server code +RUN cd /build/server \ + && gleam export erlang-shipment + +# Runtime stage - slim image with only what's needed to run +FROM ghcr.io/gleam-lang/gleam:${GLEAM_VERSION}-erlang-alpine -# Copy the built binary -COPY --from=builder /app/target/release/nate-status /app/nate-status +# Install runtime dependencies and dbmate for migrations +ARG TARGETARCH +RUN apk add --no-cache sqlite-libs sqlite libpq curl \ + && DBMATE_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "arm64" || echo "amd64") \ + && curl -fsSL -o /usr/local/bin/dbmate https://github.com/amacneil/dbmate/releases/latest/download/dbmate-linux-${DBMATE_ARCH} \ + && chmod +x /usr/local/bin/dbmate -# Copy templates and lexicons -COPY templates ./templates -COPY lexicons ./lexicons -# Copy static files -COPY static ./static +# Copy the compiled server code from the builder stage +COPY --from=builder /build/server/build/erlang-shipment /app + +# Copy database migrations and config +COPY --from=builder /build/server/db /app/db +COPY --from=builder /build/server/.dbmate.yml /app/.dbmate.yml +COPY --from=builder /build/server/docker-entrypoint.sh /app/docker-entrypoint.sh + +# Set up the entrypoint +WORKDIR /app -# Create directory for SQLite database -RUN mkdir -p /data +# Create the data directory for the SQLite database and Fly.io volume mount +RUN mkdir -p /data && chmod 755 /data # Set environment variables -ENV DB_PATH=/data/status.db -ENV ENABLE_FIREHOSE=true +ENV HOST=0.0.0.0 +ENV PORT=8080 -# Expose port -EXPOSE 8080 +# Expose the port the server will run on +EXPOSE $PORT -# Run the binary -CMD ["./nate-status"] \ No newline at end of file +# Run the server +CMD ["/app/docker-entrypoint.sh", "run"] diff --git a/README.md b/README.md index fc431f0..b0c90df 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,74 @@ -# status +# quickslice-status -a personal status tracker built on at protocol, where i can post my current status (like slack status) decoupled from any specific platform. +a status app for bluesky, built with [quickslice](https://github.com/bigmoves/quickslice). -live at: [status.zzstoatzz.io](https://status.zzstoatzz.io) +**live:** https://quickslice-status.pages.dev -## about +## architecture -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. +- **backend**: [quickslice](https://github.com/bigmoves/quickslice) on fly.io - handles oauth, graphql api, jetstream ingestion +- **frontend**: static site on cloudflare pages - vanilla js spa -## credits +## deployment -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. +### backend (fly.io) -major thanks to: -- [bailey townsend (@baileytownsend.dev)](https://bsky.app/profile/baileytownsend.dev) for the rusty statusphere boilerplate -- the atrium-rs maintainers for the rust at protocol libraries -- the rocketman maintainers for the jetstream consumer - -## development +builds quickslice from source at v0.17.3 tag. ```bash -cp .env.template .env -cargo run -# navigate to http://127.0.0.1:8080 +fly deploy ``` -### custom emojis (no redeploys) - -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`. - -- Local dev: add image files to `static/emojis/` (or set `EMOJI_DIR` in `.env`). -- Production (Fly.io): upload files directly into the mounted volume at `/data/emojis` — no rebuild or redeploy needed. - -Examples with Fly CLI: - +required secrets: ```bash -# Open an SSH console to the machine -fly ssh console -a zzstoatzz-status - -# Inside the VM, copy or fetch files into /data/emojis -mkdir -p /data/emojis -curl -L -o /data/emojis/my_new_emoji.png https://example.com/my_new_emoji.png +fly secrets set SECRET_KEY_BASE="$(openssl rand -base64 64 | tr -d '\n')" +fly secrets set OAUTH_SIGNING_KEY="$(goat key generate -t p256 | tail -1)" ``` -Or from your machine using SFTP: +### frontend (cloudflare pages) ```bash -fly ssh sftp -a zzstoatzz-status -sftp> put ./static/emojis/my_new_emoji.png /data/emojis/ +cd site +npx wrangler pages deploy . --project-name=quickslice-status ``` -The app serves them at `/emojis/` and lists them via `/api/custom-emojis`. - -### admin upload endpoint - -When logged in as the admin DID, you can upload PNG or GIF emojis without SSH via a simple endpoint: - -- Endpoint: `POST /admin/upload-emoji` -- Auth: session-based; only the admin DID is allowed -- Form fields (multipart/form-data): - - `file`: the image file (PNG or GIF), max 5MB - - `name` (optional): base filename (letters, numbers, `-`, `_`) without extension - -Example with curl: - -```bash -curl -i -X POST \ - -F "file=@./static/emojis/sample.png" \ - -F "name=my_sample" \ - http://localhost:8080/admin/upload-emoji +## oauth client registration + +register an oauth client in the quickslice admin ui at `https://zzstoatzz-quickslice-status.fly.dev/` + +redirect uri: `https://quickslice-status.pages.dev/callback` + +## lexicon + +uses `io.zzstoatzz.status` lexicon for user statuses. + +```json +{ + "lexicon": 1, + "id": "io.zzstoatzz.status", + "defs": { + "main": { + "type": "record", + "key": "self", + "record": { + "type": "object", + "required": ["status", "createdAt"], + "properties": { + "status": { "type": "string", "maxLength": 128 }, + "createdAt": { "type": "string", "format": "datetime" } + } + } + } + } +} ``` -Response will include the public URL (e.g., `/emojis/my_sample.png`). - -### available commands - -we use [just](https://github.com/casey/just) for common tasks: +## local development +serve the frontend locally: ```bash -just watch # run with hot-reloading -just deploy # deploy to fly.io -just lint # run clippy -just fmt # format code -just clean # clean build artifacts +cd site +python -m http.server 8000 ``` -## tech stack - -- [rust](https://www.rust-lang.org/) with [actix web](https://actix.rs/) -- [at protocol](https://atproto.com/) (via [atrium-rs](https://github.com/sugyan/atrium)) -- [sqlite](https://www.sqlite.org/) for local storage -- [jetstream](https://github.com/bluesky-social/jetstream) for firehose consumption -- [fly.io](https://fly.io/) for hosting +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`. diff --git a/fly.toml b/fly.toml index 6b8b679..50013c9 100644 --- a/fly.toml +++ b/fly.toml @@ -1,34 +1,33 @@ -app = "zzstoatzz-status" -primary_region = "ewr" +# fly.toml app configuration file generated for zzstoatzz-quickslice-status on 2025-12-13T16:42:55-06:00 +# +# See https://fly.io/docs/reference/configuration/ for information about how to use this file. +# + +app = 'zzstoatzz-quickslice-status' +primary_region = 'ewr' [build] - dockerfile = "Dockerfile" + dockerfile = 'Dockerfile' [env] - SERVER_PORT = "8080" - SERVER_HOST = "0.0.0.0" - DATABASE_URL = "sqlite:///data/status.db" - ENABLE_FIREHOSE = "true" - OAUTH_REDIRECT_BASE = "https://status.zzstoatzz.io" - EMOJI_DIR = "/data/emojis" + DATABASE_URL = 'sqlite:/data/quickslice.db' + HOST = '0.0.0.0' + PORT = '8080' + EXTERNAL_BASE_URL = 'https://zzstoatzz-quickslice-status.fly.dev' + +[[mounts]] + source = 'quickslice_data' + destination = '/data' [http_service] internal_port = 8080 force_https = true - auto_stop_machines = true + auto_stop_machines = 'stop' auto_start_machines = true min_machines_running = 1 - - [http_service.concurrency] - type = "requests" - hard_limit = 250 - soft_limit = 200 - -[[mounts]] - source = "status_data" - destination = "/data" [[vm]] - cpu_kind = "shared" + memory = '1gb' + cpu_kind = 'shared' cpus = 1 - memory_mb = 512 + memory_mb = 1024 diff --git a/lexicons/preferences.json b/lexicons/preferences.json new file mode 100644 index 0000000..d461e32 --- /dev/null +++ b/lexicons/preferences.json @@ -0,0 +1,30 @@ +{ + "lexicon": 1, + "id": "io.zzstoatzz.status.preferences", + "defs": { + "main": { + "type": "record", + "key": "literal:self", + "record": { + "type": "object", + "properties": { + "accentColor": { + "type": "string", + "description": "Hex color for accent/highlight color (e.g. #4a9eff)", + "maxLength": 7 + }, + "font": { + "type": "string", + "description": "Font family preference", + "maxLength": 64 + }, + "theme": { + "type": "string", + "description": "Theme preference: light, dark, or system", + "enum": ["light", "dark", "system"] + } + } + } + } + } +} diff --git a/lexicons/status.json b/lexicons/status.json index 9dba4b6..8f3a588 100644 --- a/lexicons/status.json +++ b/lexicons/status.json @@ -11,10 +11,9 @@ "properties": { "emoji": { "type": "string", - "description": "Status emoji", + "description": "Status emoji or custom emoji slug (e.g. custom:bufo-stab)", "minLength": 1, - "maxGraphemes": 1, - "maxLength": 32 + "maxLength": 64 }, "text": { "type": "string", diff --git a/site/app.js b/site/app.js new file mode 100644 index 0000000..16aa99b --- /dev/null +++ b/site/app.js @@ -0,0 +1,1220 @@ +// Configuration +const CONFIG = { + server: 'https://zzstoatzz-quickslice-status.fly.dev', + clientId: 'client_2mP9AwgVHkg1vaSpcWSsKw', +}; + +// Base path for routing (empty for root domain, '/subpath' for subdirectory) +const BASE_PATH = ''; + +let client = null; +let userPreferences = null; + +// Default preferences +const DEFAULT_PREFERENCES = { + accentColor: '#4a9eff', + font: 'mono', + theme: 'dark' +}; + +// Available fonts - use simple keys, map to actual CSS in applyPreferences +const FONTS = [ + { value: 'system', label: 'system' }, + { value: 'mono', label: 'mono' }, + { value: 'serif', label: 'serif' }, + { value: 'comic', label: 'comic' }, +]; + +const FONT_CSS = { + 'system': 'system-ui, -apple-system, sans-serif', + 'mono': 'ui-monospace, SF Mono, Monaco, monospace', + 'serif': 'ui-serif, Georgia, serif', + 'comic': 'Comic Sans MS, Comic Sans, cursive', +}; + +// Preset accent colors +const ACCENT_COLORS = [ + '#4a9eff', // blue (default) + '#10b981', // green + '#f59e0b', // amber + '#ef4444', // red + '#8b5cf6', // purple + '#ec4899', // pink + '#06b6d4', // cyan + '#f97316', // orange +]; + +// Apply preferences to the page +function applyPreferences(prefs) { + const { accentColor, font, theme } = { ...DEFAULT_PREFERENCES, ...prefs }; + + document.documentElement.style.setProperty('--accent', accentColor); + // Map simple font key to actual CSS font-family + const fontCSS = FONT_CSS[font] || FONT_CSS['mono']; + document.documentElement.style.setProperty('--font-family', fontCSS); + document.documentElement.setAttribute('data-theme', theme); + + localStorage.setItem('theme', theme); +} + +// Load preferences from server +async function loadPreferences() { + if (!client) return DEFAULT_PREFERENCES; + + try { + const user = client.getUser(); + if (!user) return DEFAULT_PREFERENCES; + + const res = await fetch(`${CONFIG.server}/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query GetPreferences($did: String!) { + ioZzstoatzzStatusPreferences( + where: { did: { eq: $did } } + first: 1 + ) { + edges { node { accentColor font theme } } + } + } + `, + variables: { did: user.did } + }) + }); + const json = await res.json(); + const edges = json.data?.ioZzstoatzzStatusPreferences?.edges || []; + + if (edges.length > 0) { + userPreferences = edges[0].node; + return userPreferences; + } + return DEFAULT_PREFERENCES; + } catch (e) { + console.error('Failed to load preferences:', e); + return DEFAULT_PREFERENCES; + } +} + +// Save preferences to server +async function savePreferences(prefs) { + if (!client) return; + + try { + const user = client.getUser(); + if (!user) return; + + // First, delete any existing preferences records for this user + const res = await fetch(`${CONFIG.server}/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query GetExistingPrefs($did: String!) { + ioZzstoatzzStatusPreferences(where: { did: { eq: $did } }, first: 50) { + edges { node { uri } } + } + } + `, + variables: { did: user.did } + }) + }); + const json = await res.json(); + const existing = json.data?.ioZzstoatzzStatusPreferences?.edges || []; + + // Delete all existing preference records + for (const edge of existing) { + const rkey = edge.node.uri.split('/').pop(); + try { + await client.mutate(` + mutation DeletePref($rkey: String!) { + deleteIoZzstoatzzStatusPreferences(rkey: $rkey) { uri } + } + `, { rkey }); + } catch (e) { + console.warn('Failed to delete old pref:', e); + } + } + + // Create new preferences record + await client.mutate(` + mutation SavePreferences($input: CreateIoZzstoatzzStatusPreferencesInput!) { + createIoZzstoatzzStatusPreferences(input: $input) { uri } + } + `, { + input: { + accentColor: prefs.accentColor, + font: prefs.font, + theme: prefs.theme + } + }); + + userPreferences = prefs; + applyPreferences(prefs); + } catch (e) { + console.error('Failed to save preferences:', e); + alert('Failed to save preferences: ' + e.message); + } +} + +// Create settings modal +function createSettingsModal() { + const overlay = document.createElement('div'); + overlay.className = 'settings-overlay hidden'; + overlay.innerHTML = ` +
+
+

settings

+ +
+
+
+ +
+ ${ACCENT_COLORS.map(c => ` + + `).join('')} + +
+
+
+ + +
+
+ + +
+
+ +
+ `; + + const modal = overlay.querySelector('.settings-modal'); + const closeBtn = overlay.querySelector('.settings-close'); + const colorBtns = overlay.querySelectorAll('.color-btn'); + const customColor = overlay.querySelector('#custom-color'); + const fontSelect = overlay.querySelector('#font-select'); + const themeSelect = overlay.querySelector('#theme-select'); + const saveBtn = overlay.querySelector('#save-settings'); + + let currentPrefs = { ...DEFAULT_PREFERENCES }; + + function updateColorSelection(color) { + colorBtns.forEach(btn => btn.classList.toggle('active', btn.dataset.color === color)); + customColor.value = color; + currentPrefs.accentColor = color; + } + + function open(prefs) { + currentPrefs = { ...DEFAULT_PREFERENCES, ...prefs }; + updateColorSelection(currentPrefs.accentColor); + fontSelect.value = currentPrefs.font; + themeSelect.value = currentPrefs.theme; + overlay.classList.remove('hidden'); + } + + function close() { + overlay.classList.add('hidden'); + } + + overlay.addEventListener('click', e => { if (e.target === overlay) close(); }); + closeBtn.addEventListener('click', close); + + colorBtns.forEach(btn => { + btn.addEventListener('click', () => updateColorSelection(btn.dataset.color)); + }); + + customColor.addEventListener('input', () => { + updateColorSelection(customColor.value); + }); + + fontSelect.addEventListener('change', () => { + currentPrefs.font = fontSelect.value; + }); + + themeSelect.addEventListener('change', () => { + currentPrefs.theme = themeSelect.value; + }); + + saveBtn.addEventListener('click', async () => { + saveBtn.disabled = true; + saveBtn.textContent = 'saving...'; + await savePreferences(currentPrefs); + saveBtn.disabled = false; + saveBtn.textContent = 'save'; + close(); + }); + + document.body.appendChild(overlay); + return { open, close }; +} + +// Theme (fallback for non-logged-in users) +function initTheme() { + const saved = localStorage.getItem('theme') || 'dark'; + document.documentElement.setAttribute('data-theme', saved); +} + +function toggleTheme() { + const current = document.documentElement.getAttribute('data-theme'); + const next = current === 'dark' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', next); + localStorage.setItem('theme', next); + + // If logged in, also update preferences + if (userPreferences) { + userPreferences.theme = next; + savePreferences(userPreferences); + } +} + +// Timestamp formatting (ported from original status app) +const TimestampFormatter = { + formatRelative(date, now = new Date()) { + const diffMs = now - date; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMs < 30000) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) { + const remainingMins = diffMins % 60; + return remainingMins === 0 ? `${diffHours}h ago` : `${diffHours}h ${remainingMins}m ago`; + } + if (diffDays < 7) { + const remainingHours = diffHours % 24; + return remainingHours === 0 ? `${diffDays}d ago` : `${diffDays}d ${remainingHours}h ago`; + } + + const timeStr = date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase(); + if (date.getFullYear() === now.getFullYear()) { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + timeStr; + } + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + ', ' + timeStr; + }, + + formatCompact(date, now = new Date()) { + const diffMs = now - date; + const diffDays = Math.floor(diffMs / 86400000); + + if (date.toDateString() === now.toDateString()) { + return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase(); + } + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + if (date.toDateString() === yesterday.toDateString()) { + return 'yesterday, ' + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase(); + } + if (diffDays < 7) { + const dayName = date.toLocaleDateString('en-US', { weekday: 'short' }).toLowerCase(); + const time = date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase(); + return `${dayName}, ${time}`; + } + if (date.getFullYear() === now.getFullYear()) { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase(); + } + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase(); + }, + + getFullTimestamp(date) { + const dayName = date.toLocaleDateString('en-US', { weekday: 'long' }); + const monthDay = date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); + const time = date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', second: '2-digit', hour12: true }); + const tzAbbr = date.toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop(); + return `${dayName}, ${monthDay} at ${time} ${tzAbbr}`; + } +}; + +function relativeTime(dateStr, format = 'relative') { + const date = new Date(dateStr); + return format === 'compact' + ? TimestampFormatter.formatCompact(date) + : TimestampFormatter.formatRelative(date); +} + +function relativeTimeFuture(dateStr) { + const date = new Date(dateStr); + const now = new Date(); + const diffMs = date - now; + + if (diffMs <= 0) return 'now'; + + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'in less than a minute'; + if (diffMins < 60) return `in ${diffMins}m`; + if (diffHours < 24) { + const remainingMins = diffMins % 60; + return remainingMins === 0 ? `in ${diffHours}h` : `in ${diffHours}h ${remainingMins}m`; + } + if (diffDays < 7) { + const remainingHours = diffHours % 24; + return remainingHours === 0 ? `in ${diffDays}d` : `in ${diffDays}d ${remainingHours}h`; + } + + // For longer times, show the date + const timeStr = date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase(); + if (date.getFullYear() === now.getFullYear()) { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + timeStr; + } + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + ', ' + timeStr; +} + +function fullTimestamp(dateStr) { + return TimestampFormatter.getFullTimestamp(new Date(dateStr)); +} + +// Emoji picker +let emojiData = null; +let bufoList = null; +let userFrequentEmojis = null; +const DEFAULT_FREQUENT_EMOJIS = ['😊', '👍', '❤️', '😂', '🎉', '🔥', '✨', '💯', '🚀', '💪', '🙏', '👏', '😴', '🤔', '👀', '💻']; + +async function loadUserFrequentEmojis() { + if (userFrequentEmojis) return userFrequentEmojis; + if (!client) return DEFAULT_FREQUENT_EMOJIS; + + try { + const user = client.getUser(); + if (!user) return DEFAULT_FREQUENT_EMOJIS; + + // Fetch user's status history to count emoji usage + const res = await fetch(`${CONFIG.server}/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query GetUserEmojis($did: String!) { + ioZzstoatzzStatusRecord( + first: 100 + where: { did: { eq: $did } } + ) { + edges { node { emoji } } + } + } + `, + variables: { did: user.did } + }) + }); + const json = await res.json(); + const emojis = json.data?.ioZzstoatzzStatusRecord?.edges?.map(e => e.node.emoji) || []; + + if (emojis.length === 0) return DEFAULT_FREQUENT_EMOJIS; + + // Count emoji frequency + const counts = {}; + emojis.forEach(e => { counts[e] = (counts[e] || 0) + 1; }); + + // Sort by frequency and take top 16 + const sorted = Object.entries(counts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 16) + .map(([emoji]) => emoji); + + userFrequentEmojis = sorted.length > 0 ? sorted : DEFAULT_FREQUENT_EMOJIS; + return userFrequentEmojis; + } catch (e) { + console.error('Failed to load frequent emojis:', e); + return DEFAULT_FREQUENT_EMOJIS; + } +} + +async function loadBufoList() { + if (bufoList) return bufoList; + const res = await fetch('/bufos.json'); + if (!res.ok) throw new Error('Failed to load bufos'); + bufoList = await res.json(); + return bufoList; +} + +async function loadEmojiData() { + if (emojiData) return emojiData; + try { + const response = await fetch('https://cdn.jsdelivr.net/npm/emoji-datasource@15.1.0/emoji.json'); + if (!response.ok) throw new Error('Failed to fetch'); + const data = await response.json(); + + const emojis = {}; + const categories = { frequent: DEFAULT_FREQUENT_EMOJIS, people: [], nature: [], food: [], activity: [], travel: [], objects: [], symbols: [], flags: [] }; + const categoryMap = { + 'Smileys & Emotion': 'people', 'People & Body': 'people', 'Animals & Nature': 'nature', + 'Food & Drink': 'food', 'Activities': 'activity', 'Travel & Places': 'travel', + 'Objects': 'objects', 'Symbols': 'symbols', 'Flags': 'flags' + }; + + data.forEach(emoji => { + const char = emoji.unified.split('-').map(u => String.fromCodePoint(parseInt(u, 16))).join(''); + const keywords = [...(emoji.short_names || []), ...(emoji.name ? emoji.name.toLowerCase().split(/[\s_-]+/) : [])]; + emojis[char] = keywords; + const cat = categoryMap[emoji.category]; + if (cat && categories[cat]) categories[cat].push(char); + }); + + emojiData = { emojis, categories }; + return emojiData; + } catch (e) { + console.error('Failed to load emoji data:', e); + return { emojis: {}, categories: { frequent: DEFAULT_FREQUENT_EMOJIS, people: [], nature: [], food: [], activity: [], travel: [], objects: [], symbols: [], flags: [] } }; + } +} + +function searchEmojis(query, data) { + if (!query) return []; + const q = query.toLowerCase(); + return Object.entries(data.emojis) + .filter(([char, keywords]) => keywords.some(k => k.includes(q))) + .map(([char]) => char) + .slice(0, 50); +} + +function createEmojiPicker(onSelect) { + const overlay = document.createElement('div'); + overlay.className = 'emoji-picker-overlay hidden'; + overlay.innerHTML = ` +
+
+

pick an emoji

+ +
+ +
+ + + + + + + + + + +
+
+ +
+ `; + + const picker = overlay.querySelector('.emoji-picker'); + const grid = overlay.querySelector('.emoji-grid'); + const search = overlay.querySelector('.emoji-search'); + const closeBtn = overlay.querySelector('.emoji-picker-close'); + const categoryBtns = overlay.querySelectorAll('.category-btn'); + const bufoHelper = overlay.querySelector('.bufo-helper'); + + let currentCategory = 'frequent'; + let data = null; + + async function renderCategory(cat) { + currentCategory = cat; + categoryBtns.forEach(b => b.classList.toggle('active', b.dataset.category === cat)); + bufoHelper.classList.toggle('hidden', cat !== 'custom'); + + if (cat === 'custom') { + grid.classList.add('bufo-grid'); + grid.innerHTML = '
loading bufos...
'; + try { + const bufos = await loadBufoList(); + grid.innerHTML = bufos.map(name => ` + + `).join(''); + } catch (e) { + grid.innerHTML = '
failed to load bufos
'; + } + return; + } + + grid.classList.remove('bufo-grid'); + + // Load user's frequent emojis for the frequent category + if (cat === 'frequent') { + grid.innerHTML = '
loading...
'; + const frequentEmojis = await loadUserFrequentEmojis(); + grid.innerHTML = frequentEmojis.map(e => { + if (e.startsWith('custom:')) { + const name = e.replace('custom:', ''); + return ``; + } + return ``; + }).join(''); + return; + } + + if (!data) data = await loadEmojiData(); + const emojis = data.categories[cat] || []; + grid.innerHTML = emojis.map(e => ``).join(''); + } + + function close() { + overlay.classList.add('hidden'); + search.value = ''; + } + + function open() { + overlay.classList.remove('hidden'); + renderCategory('frequent'); + search.focus(); + } + + overlay.addEventListener('click', e => { if (e.target === overlay) close(); }); + closeBtn.addEventListener('click', close); + categoryBtns.forEach(btn => btn.addEventListener('click', () => renderCategory(btn.dataset.category))); + + grid.addEventListener('click', e => { + const btn = e.target.closest('.emoji-btn'); + if (btn) { + onSelect(btn.dataset.emoji); + close(); + } + }); + + search.addEventListener('input', async () => { + const q = search.value.trim(); + if (!q) { renderCategory(currentCategory); return; } + + // Search both emojis and bufos + if (!data) data = await loadEmojiData(); + const emojiResults = searchEmojis(q, data); + + // Search bufos by name + let bufoResults = []; + try { + const bufos = await loadBufoList(); + const qLower = q.toLowerCase(); + bufoResults = bufos.filter(name => name.toLowerCase().includes(qLower)).slice(0, 30); + } catch (e) { /* ignore */ } + + grid.classList.remove('bufo-grid'); + bufoHelper.classList.add('hidden'); + + if (emojiResults.length === 0 && bufoResults.length === 0) { + grid.innerHTML = '
no emojis found
'; + return; + } + + let html = ''; + // Show emoji results first + html += emojiResults.map(e => ``).join(''); + // Then bufo results + html += bufoResults.map(name => ` + + `).join(''); + + grid.innerHTML = html; + }); + + document.body.appendChild(overlay); + return { open, close }; +} + +// Render emoji (handles custom:name format) +function renderEmoji(emoji) { + if (emoji && emoji.startsWith('custom:')) { + const name = emoji.slice(7); + return `${name}`; + } + return emoji || '-'; +} + +function escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +// Parse markdown links [text](url) and return HTML +function parseLinks(text) { + if (!text) return ''; + // First escape HTML, then parse markdown links + const escaped = escapeHtml(text); + // Match [text](url) pattern + return escaped.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, linkText, url) => { + // Validate URL (basic check) + if (url.startsWith('http://') || url.startsWith('https://')) { + return `${linkText}`; + } + return match; + }); +} + +// Resolve handle to DID +async function resolveHandle(handle) { + const res = await fetch(`https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`); + if (!res.ok) return null; + const data = await res.json(); + return data.did; +} + +// Resolve DID to handle +async function resolveDidToHandle(did) { + const res = await fetch(`https://plc.directory/${did}`); + if (!res.ok) return null; + const data = await res.json(); + // alsoKnownAs is like ["at://handle"] + if (data.alsoKnownAs && data.alsoKnownAs.length > 0) { + return data.alsoKnownAs[0].replace('at://', ''); + } + return null; +} + +// Router +function getRoute() { + const path = window.location.pathname; + if (path === '/' || path === '/index.html') return { page: 'home' }; + if (path === '/feed' || path === '/feed.html') return { page: 'feed' }; + if (path.startsWith('/@')) { + const handle = path.slice(2); + return { page: 'profile', handle }; + } + return { page: '404' }; +} + +// Render home page +async function renderHome() { + const main = document.getElementById('main-content'); + document.getElementById('page-title').textContent = 'status'; + + if (typeof QuicksliceClient === 'undefined') { + main.innerHTML = '
failed to load. check console.
'; + return; + } + + try { + client = await QuicksliceClient.createQuicksliceClient({ + server: CONFIG.server, + clientId: CONFIG.clientId, + redirectUri: window.location.origin + '/', + }); + console.log('Client created with server:', CONFIG.server, 'clientId:', CONFIG.clientId); + + if (window.location.search.includes('code=')) { + console.log('Got OAuth callback with code, handling...'); + try { + const result = await client.handleRedirectCallback(); + console.log('handleRedirectCallback result:', result); + } catch (err) { + console.error('handleRedirectCallback error:', err); + } + window.history.replaceState({}, document.title, '/'); + } + + const isAuthed = await client.isAuthenticated(); + + if (!isAuthed) { + main.innerHTML = ` +
+

share your status on the atproto network

+
+ + +
+
+ `; + document.getElementById('login-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const handle = document.getElementById('handle-input').value.trim(); + if (handle && client) { + await client.loginWithRedirect({ handle }); + } + }); + } else { + const user = client.getUser(); + if (!user) { + // Token might be invalid, log out + await client.logout(); + window.location.reload(); + return; + } + const handle = await resolveDidToHandle(user.did) || user.did; + + // Load and apply preferences, set up settings/logout buttons + const prefs = await loadPreferences(); + applyPreferences(prefs); + + // Show settings button and set up modal + const settingsBtn = document.getElementById('settings-btn'); + settingsBtn.classList.remove('hidden'); + const settingsModal = createSettingsModal(); + settingsBtn.addEventListener('click', () => settingsModal.open(userPreferences || prefs)); + + // Add logout button to header nav (if not already there) + if (!document.getElementById('logout-btn')) { + const nav = document.querySelector('header nav'); + const logoutBtn = document.createElement('button'); + logoutBtn.id = 'logout-btn'; + logoutBtn.className = 'nav-btn'; + logoutBtn.setAttribute('aria-label', 'log out'); + logoutBtn.setAttribute('title', 'log out'); + logoutBtn.innerHTML = ` + + + + + + `; + logoutBtn.addEventListener('click', async () => { + await client.logout(); + window.location.href = '/'; + }); + nav.appendChild(logoutBtn); + } + + // Set page title with Bluesky profile link + document.getElementById('page-title').innerHTML = `@${handle}`; + + // Load user's statuses (full history) + const res = await fetch(`${CONFIG.server}/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query GetUserStatuses($did: String!) { + ioZzstoatzzStatusRecord( + first: 100 + where: { did: { eq: $did } } + sortBy: [{ field: "createdAt", direction: DESC }] + ) { + edges { node { uri did emoji text createdAt expires } } + } + } + `, + variables: { did: user.did } + }) + }); + const json = await res.json(); + const statuses = json.data.ioZzstoatzzStatusRecord.edges.map(e => e.node); + + let currentHtml = '-'; + let historyHtml = ''; + + if (statuses.length > 0) { + const current = statuses[0]; + const expiresHtml = current.expires ? ` • clears ${relativeTimeFuture(current.expires)}` : ''; + currentHtml = ` + ${renderEmoji(current.emoji)} +
+ ${current.text ? `${parseLinks(current.text)}` : ''} + since ${relativeTime(current.createdAt)}${expiresHtml} +
+ `; + if (statuses.length > 1) { + historyHtml = '

history

'; + statuses.slice(1).forEach(s => { + // Extract rkey from URI (at://did/collection/rkey) + const rkey = s.uri.split('/').pop(); + historyHtml += ` +
+ ${renderEmoji(s.emoji)} +
+
${s.text ? `${parseLinks(s.text)}` : ''}
+ ${relativeTime(s.createdAt)} +
+ +
+ `; + }); + historyHtml += '
'; + } + } + + const currentEmoji = statuses.length > 0 ? statuses[0].emoji : '😊'; + + main.innerHTML = ` +
+
${currentHtml}
+
+
+
+ + + +
+
+ + + +
+
+ ${historyHtml} + `; + + // Set up emoji picker + const emojiInput = document.getElementById('emoji-input'); + const selectedEmojiEl = document.getElementById('selected-emoji'); + const emojiPicker = createEmojiPicker((emoji) => { + emojiInput.value = emoji; + selectedEmojiEl.innerHTML = renderEmoji(emoji); + }); + document.getElementById('emoji-trigger').addEventListener('click', () => emojiPicker.open()); + + // Custom datetime toggle + const expiresSelect = document.getElementById('expires-select'); + const customDatetime = document.getElementById('custom-datetime'); + + // Helper to format date for datetime-local input (local timezone) + function toLocalDatetimeString(date) { + const offset = date.getTimezoneOffset(); + const local = new Date(date.getTime() - offset * 60 * 1000); + return local.toISOString().slice(0, 16); + } + + expiresSelect.addEventListener('change', () => { + if (expiresSelect.value === 'custom') { + customDatetime.classList.remove('hidden'); + // Set min to now (prevent past dates) + const now = new Date(); + customDatetime.min = toLocalDatetimeString(now); + // Default to 1 hour from now + const defaultTime = new Date(Date.now() + 60 * 60 * 1000); + customDatetime.value = toLocalDatetimeString(defaultTime); + } else { + customDatetime.classList.add('hidden'); + } + }); + + document.getElementById('status-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const emoji = document.getElementById('emoji-input').value.trim(); + const text = document.getElementById('text-input').value.trim(); + const expiresVal = document.getElementById('expires-select').value; + const customDt = document.getElementById('custom-datetime').value; + + if (!emoji) return; + + const input = { emoji, createdAt: new Date().toISOString() }; + if (text) input.text = text; + if (expiresVal === 'custom' && customDt) { + input.expires = new Date(customDt).toISOString(); + } else if (expiresVal && expiresVal !== 'custom') { + input.expires = new Date(Date.now() + parseInt(expiresVal) * 60 * 1000).toISOString(); + } + + try { + await client.mutate(` + mutation CreateStatus($input: CreateIoZzstoatzzStatusRecordInput!) { + createIoZzstoatzzStatusRecord(input: $input) { uri } + } + `, { input }); + window.location.reload(); + } catch (err) { + console.error('Failed to create status:', err); + alert('Failed to set status: ' + err.message); + } + }); + + // Delete buttons + document.querySelectorAll('.delete-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const rkey = btn.dataset.rkey; + if (!confirm('Delete this status?')) return; + + try { + await client.mutate(` + mutation DeleteStatus($rkey: String!) { + deleteIoZzstoatzzStatusRecord(rkey: $rkey) { uri } + } + `, { rkey }); + window.location.reload(); + } catch (err) { + console.error('Failed to delete status:', err); + alert('Failed to delete: ' + err.message); + } + }); + }); + } + } catch (e) { + console.error('Failed to init:', e); + main.innerHTML = '
failed to initialize. check console.
'; + } +} + +// Render feed page +let feedCursor = null; +let feedHasMore = true; + +async function renderFeed(append = false) { + const main = document.getElementById('main-content'); + document.getElementById('page-title').textContent = 'global feed'; + + if (!append) { + // Initialize auth UI for header elements + await initAuthUI(); + main.innerHTML = '
loading...
'; + } + + const feedList = document.getElementById('feed-list'); + + try { + const res = await fetch(`${CONFIG.server}/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query GetFeed($after: String) { + ioZzstoatzzStatusRecord(first: 20, after: $after, sortBy: [{ field: "createdAt", direction: DESC }]) { + edges { node { uri did emoji text createdAt } cursor } + pageInfo { hasNextPage endCursor } + } + } + `, + variables: { after: append ? feedCursor : null } + }) + }); + + const json = await res.json(); + const data = json.data.ioZzstoatzzStatusRecord; + const statuses = data.edges.map(e => e.node); + feedCursor = data.pageInfo.endCursor; + feedHasMore = data.pageInfo.hasNextPage; + + // Resolve all handles in parallel + const handlePromises = statuses.map(s => resolveDidToHandle(s.did)); + const handles = await Promise.all(handlePromises); + + if (!append) { + feedList.innerHTML = ''; + } + + statuses.forEach((status, i) => { + const handle = handles[i] || status.did.slice(8, 28); + const div = document.createElement('div'); + div.className = 'status-item'; + div.innerHTML = ` + ${renderEmoji(status.emoji)} +
+
+ @${handle} + ${status.text ? `${parseLinks(status.text)}` : ''} +
+ ${relativeTime(status.createdAt)} +
+ `; + feedList.appendChild(div); + }); + + const loadMore = document.getElementById('load-more'); + const endOfFeed = document.getElementById('end-of-feed'); + if (feedHasMore) { + loadMore.classList.remove('hidden'); + endOfFeed.classList.add('hidden'); + } else { + loadMore.classList.add('hidden'); + endOfFeed.classList.remove('hidden'); + } + + // Attach load more handler + const btn = document.getElementById('load-more-btn'); + if (btn && !btn.dataset.bound) { + btn.dataset.bound = 'true'; + btn.addEventListener('click', () => renderFeed(true)); + } + } catch (e) { + console.error('Failed to load feed:', e); + if (!append) { + feedList.innerHTML = '
failed to load feed
'; + } + } +} + +// Render profile page +async function renderProfile(handle) { + const main = document.getElementById('main-content'); + const pageTitle = document.getElementById('page-title'); + + // Initialize auth UI for header elements + await initAuthUI(); + + pageTitle.innerHTML = `@${handle}`; + + main.innerHTML = '
loading...
'; + + try { + // Resolve handle to DID + const did = await resolveHandle(handle); + if (!did) { + main.innerHTML = '
user not found
'; + return; + } + + const res = await fetch(`${CONFIG.server}/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query GetUserStatuses($did: String!) { + ioZzstoatzzStatusRecord(first: 20, where: { did: { eq: $did } }, sortBy: [{ field: "createdAt", direction: DESC }]) { + edges { node { uri did emoji text createdAt expires } } + } + } + `, + variables: { did } + }) + }); + + const json = await res.json(); + const statuses = json.data.ioZzstoatzzStatusRecord.edges.map(e => e.node); + + if (statuses.length === 0) { + main.innerHTML = '
no statuses yet
'; + return; + } + + const current = statuses[0]; + const expiresHtml = current.expires ? ` • clears ${relativeTimeFuture(current.expires)}` : ''; + let html = ` +
+
+ ${renderEmoji(current.emoji)} +
+ ${current.text ? `${parseLinks(current.text)}` : ''} + ${relativeTime(current.createdAt)}${expiresHtml} +
+
+
+ `; + + if (statuses.length > 1) { + html += '

history

'; + statuses.slice(1).forEach(status => { + html += ` +
+ ${renderEmoji(status.emoji)} +
+
${status.text ? `${parseLinks(status.text)}` : ''}
+ ${relativeTime(status.createdAt)} +
+
+ `; + }); + html += '
'; + } + + main.innerHTML = html; + } catch (e) { + console.error('Failed to load profile:', e); + main.innerHTML = '
failed to load profile
'; + } +} + +// Update nav active state - hide current page icon, show the other +function updateNavActive(page) { + const navHome = document.getElementById('nav-home'); + const navFeed = document.getElementById('nav-feed'); + // Hide the nav icon for the current page, show the other + if (navHome) navHome.classList.toggle('hidden', page === 'home'); + if (navFeed) navFeed.classList.toggle('hidden', page === 'feed'); +} + +// Initialize auth state for header (settings, logout) - used by all pages +async function initAuthUI() { + if (typeof QuicksliceClient === 'undefined') return; + + try { + client = await QuicksliceClient.createQuicksliceClient({ + server: CONFIG.server, + clientId: CONFIG.clientId, + redirectUri: window.location.origin + '/', + }); + + const isAuthed = await client.isAuthenticated(); + if (!isAuthed) return; + + const user = client.getUser(); + if (!user) return; + + // Load and apply preferences + const prefs = await loadPreferences(); + applyPreferences(prefs); + + // Show settings button and set up modal + const settingsBtn = document.getElementById('settings-btn'); + settingsBtn.classList.remove('hidden'); + const settingsModal = createSettingsModal(); + settingsBtn.addEventListener('click', () => settingsModal.open(userPreferences || prefs)); + + // Add logout button to header nav (if not already there) + if (!document.getElementById('logout-btn')) { + const nav = document.querySelector('header nav'); + const logoutBtn = document.createElement('button'); + logoutBtn.id = 'logout-btn'; + logoutBtn.className = 'nav-btn'; + logoutBtn.setAttribute('aria-label', 'log out'); + logoutBtn.setAttribute('title', 'log out'); + logoutBtn.innerHTML = ` + + + + + + `; + logoutBtn.addEventListener('click', async () => { + await client.logout(); + window.location.href = '/'; + }); + nav.appendChild(logoutBtn); + } + + return { user, prefs }; + } catch (e) { + console.error('Failed to init auth UI:', e); + return null; + } +} + +// Init +document.addEventListener('DOMContentLoaded', () => { + initTheme(); + + const themeBtn = document.getElementById('theme-toggle'); + if (themeBtn) { + themeBtn.addEventListener('click', toggleTheme); + } + + const route = getRoute(); + updateNavActive(route.page); + + if (route.page === 'home') { + renderHome(); + } else if (route.page === 'feed') { + renderFeed(); + } else if (route.page === 'profile') { + renderProfile(route.handle); + } else { + document.getElementById('main-content').innerHTML = '
page not found
'; + } +}); diff --git a/site/bufos.json b/site/bufos.json new file mode 100644 index 0000000..d83886c --- /dev/null +++ b/site/bufos.json @@ -0,0 +1,1614 @@ +[ + "according-to-all-known-laws-of-aviation-there-is-no-way-a-bufo-should-be-able-to-fly", + "add-bufo", + "all-the-bufo", + "angry-karen-bufo-would-like-to-speak-with-your-manager", + "australian-bufo", + "awesomebufo", + "be-the-bufo-you-want-to-see", + "bigbufo_0_0", + "bigbufo_0_1", + "bigbufo_0_2", + "bigbufo_0_3", + "bigbufo_1_0", + "bigbufo_1_1", + "bigbufo_1_2", + "bigbufo_1_3", + "bigbufo_2_0", + "bigbufo_2_1", + "bigbufo_2_2", + "bigbufo_2_3", + "bigbufo_3_0", + "bigbufo_3_1", + "bigbufo_3_2", + "bigbufo_3_3", + "blockheads-bufo", + "breaking-bufo", + "bronze-bufo", + "buff-bufo", + "bufo", + "bufo_wants_his_money", + "bufo-0-10", + "bufo-10", + "bufo-10-4", + "bufo-2022", + "bufo-achieving-coding-flow", + "bufo-ack", + "bufo-actually", + "bufo-adding-bugs-to-the-code", + "bufo-adidas", + "bufo-ages-rapidly-in-the-void", + "bufo-aight-imma-head-out", + "bufo-airpods", + "bufo-alarma", + "bufo-all-good", + "bufo-all-warm-and-fuzzy-inside", + "bufo-am-i", + "bufo-amaze", + "bufo-ambiently-existing", + "bufo-american-football", + "bufo-android", + "bufo-angel", + "bufo-angrily-gives-you-a-birthday-gift", + "bufo-angrily-gives-you-white-elephant-gift", + "bufo-angry", + "bufo-angry-at-fly", + "bufo-angry-bullfrog-screech", + "bufo-angryandfrozen", + "bufo-anime-glasses", + "bufo-appears", + "bufo-apple", + "bufo-appreciates-jwst-pillars-of-creation", + "bufo-approve", + "bufo-arabicus", + "bufo-are-you-seeing-this", + "bufo-arr", + "bufo-arrr", + "bufo-arrrrrr", + "bufo-arrrrrrr", + "bufo-arrrrrrrrr", + "bufo-arrrrrrrrrrrrrrr", + "bufo-artist", + "bufo-asks-politely-to-stop", + "bufo-assists-with-the-landing", + "bufo-atc", + "bufo-away", + "bufo-awkward-smile", + "bufo-awkward-smile-nod", + "bufo-ayy", + "bufo-baby", + "bufo-babysits-an-urgent-ticket", + "bufo-back-pat", + "bufo-backpack", + "bufo-backpat", + "bufo-bag-of-bufos", + "bufo-bait", + "bufo-baker", + "bufo-baller", + "bufo-bandana", + "bufo-banging-head-against-the-wall", + "bufo-barbie", + "bufo-barney", + "bufo-barrister", + "bufo-baseball", + "bufo-basketball", + "bufo-batman", + "bufo-be-my-valentine", + "bufo-became-a-stranger-whose-laugh-you-can-recognize-anywhere", + "bufo-bee", + "bufo-bee-leaf", + "bufo-bee-sad", + "bufo-beer", + "bufo-begrudgingly-offers-you-a-plus", + "bufo-begs-for-ethernet-cable", + "bufo-behind-bars", + "bufo-bell-pepper", + "bufo-betray", + "bufo-betray-but-its-a-hotdog", + "bufo-big-eyes-stare", + "bufo-bigfoot", + "bufo-bill-pay", + "bufo-bird", + "bufo-birthday-but-not-particularly-happy", + "bufo-black-history", + "bufo-black-tea", + "bufo-blank-stare", + "bufo-blank-stare_0_0", + "bufo-blank-stare_0_1", + "bufo-blank-stare_1_0", + "bufo-blank-stare_1_1", + "bufo-blanket", + "bufo-blem", + "bufo-blep", + "bufo-bless", + "bufo-bless-back", + "bufo-blesses-this-pr", + "bufo-block", + "bufo-blogging", + "bufo-bloody-mary", + "bufo-blows-the-magic-conch", + "bufo-blue", + "bufo-blueberries", + "bufo-blush", + "bufo-boba", + "bufo-boba-army", + "bufo-boi", + "bufo-boiii", + "bufo-bongo", + "bufo-bonk", + "bufo-bops-you-on-the-head-with-a-baguette", + "bufo-bops-you-on-the-head-with-a-rolled-up-newspaper", + "bufo-bouge", + "bufo-bouncer-says-its-time-to-go-now", + "bufo-bouquet", + "bufo-bourgeoisie", + "bufo-bowser", + "bufo-box-of-chocolates", + "bufo-brain", + "bufo-brain-damage", + "bufo-brain-damage-escalates-to-new-heights", + "bufo-brain-damage-intensifies", + "bufo-brain-damage-intesifies-more", + "bufo-brain-exploding", + "bufo-breakdown", + "bufo-breaks-tech-bros-heart", + "bufo-breaks-up-with-you", + "bufo-breaks-your-heart", + "bufo-brick", + "bufo-brings-a-new-meaning-to-brain-freeze-by-bopping-you-on-the-head-with-a-popsicle", + "bufo-brings-a-new-meaning-to-gaveled-by-slamming-the-hammer-very-loud", + "bufo-brings-magic-to-the-riot", + "bufo-broccoli", + "bufo-broke", + "bufo-broke-his-toe-and-isn't-sure-what-to-do-about-the-12k-he-signed-up-for", + "bufo-broom", + "bufo-brought-a-taco", + "bufo-bufo", + "bufo-but-anatomically-correct", + "bufo-but-instead-of-green-its-hotdogs", + "bufo-but-instead-of-green-its-pizza", + "bufo-but-you-can-feel-the-electro-house-music-in-the-gif-and-oh-yea-theres-also-a-dapper-chicken", + "bufo-but-you-can-see-the-bufo-in-bufos-eyes", + "bufo-but-you-can-see-the-hotdog-in-their-eyes", + "bufo-buy-high-sell-low", + "bufo-buy-low-sell-high", + "bufo-cache-buddy", + "bufo-cackle", + "bufo-call-for-help", + "bufo-came-into-the-office-just-to-use-the-printer", + "bufo-can't-believe-heartbreak-feels-good-in-a-place-like-this", + "bufo-can't-help-but-wonder-who-watches-the-watchmen", + "bufo-canada", + "bufo-cant-believe-your-audacity", + "bufo-cant-find-a-pull-request", + "bufo-cant-find-an-issue", + "bufo-cant-stop-thinking-about-usher-killing-it-on-roller-skates", + "bufo-cant-take-it-anymore", + "bufo-cantelope", + "bufo-capri-sun", + "bufo-captain-obvious", + "bufo-caribou", + "bufo-carnage", + "bufo-carrot", + "bufo-cash-money", + "bufo-cash-squint", + "bufo-casts-a-spell-on-you", + "bufo-catch", + "bufo-caught-a-radioactive-bufo", + "bufo-caught-a-small-bufo", + "bufo-caused-an-incident", + "bufo-celebrate", + "bufo-censored", + "bufo-chappell-roan", + "bufo-chatting", + "bufo-check", + "bufo-checks-out-the-vibe", + "bufo-cheese", + "bufo-chef", + "bufo-chefkiss", + "bufo-chefkiss-with-hat", + "bufo-cherries", + "bufo-chicken", + "bufo-chomp", + "bufo-christmas", + "bufo-chungus", + "bufo-churns-the-butter", + "bufo-clap", + "bufo-clap-hd", + "bufo-claus", + "bufo-clown", + "bufo-coconut", + "bufo-code-freeze", + "bufo-coding", + "bufo-coffee-happy", + "bufo-coin", + "bufo-come-to-the-dark-side", + "bufo-comfy", + "bufo-commits-digital-piracy", + "bufo-competes-in-the-bufo-bracket", + "bufo-complies-with-the-chinese-government", + "bufo-concerned", + "bufo-cone-of-shame", + "bufo-confetti", + "bufo-confused", + "bufo-congrats", + "bufo-cookie", + "bufo-cool-glasses", + "bufo-corn", + "bufo-cornucopia", + "bufo-covid", + "bufo-cowboy", + "bufo-cozy-blanky", + "bufo-crewmate-blue", + "bufo-crewmate-blue-bounce", + "bufo-crewmate-cyan", + "bufo-crewmate-cyan-bounce", + "bufo-crewmate-green", + "bufo-crewmate-green-bounce", + "bufo-crewmate-lime", + "bufo-crewmate-lime-bounce", + "bufo-crewmate-orange", + "bufo-crewmate-orange-bounce", + "bufo-crewmate-pink", + "bufo-crewmate-pink-bounce", + "bufo-crewmate-purple", + "bufo-crewmate-purple-bounce", + "bufo-crewmate-red", + "bufo-crewmate-red-bounce", + "bufo-crewmate-yellow", + "bufo-crewmate-yellow-bounce", + "bufo-crewmates", + "bufo-cries-into-his-beer", + "bufo-crikey", + "bufo-croptop", + "bufo-crumbs", + "bufo-crustacean", + "bufo-cry", + "bufo-cry-pray", + "bufo-crying", + "bufo-crying-in-the-rain", + "bufo-crying-jail", + "bufo-crying-stop", + "bufo-crying-tears-of-crying-tears-of-joy", + "bufo-crying-why", + "bufo-cubo", + "bufo-cucumber", + "bufo-cuddle", + "bufo-cupcake", + "bufo-cuppa", + "bufo-cute", + "bufo-cute-dance", + "bufo-dab", + "bufo-dancing", + "bufo-dapper", + "bufo-dbz", + "bufo-deal-with-it", + "bufo-declines-your-suppository-offer", + "bufo-deep-hmm", + "bufo-defend", + "bufo-delurk", + "bufo-demands-more-nom-noms", + "bufo-demure", + "bufo-desperately-needs-mavis-beacon", + "bufo-detective", + "bufo-develops-clairvoyance-while-trapped-in-the-void", + "bufo-devil", + "bufo-devouring-his-son", + "bufo-di-beppo", + "bufo-did-not-make-it-through-the-heatwave", + "bufo-didnt-get-any-sleep", + "bufo-didnt-listen-to-willy-wonka", + "bufo-disappointed", + "bufo-disco", + "bufo-discombobulated", + "bufo-disguise", + "bufo-ditto", + "bufo-dizzy", + "bufo-do-not-panic", + "bufo-dodge", + "bufo-doesnt-believe-you", + "bufo-doesnt-understand-how-this-meeting-isnt-an-email", + "bufo-doesnt-wanna-get-out-of-the-bath-yet", + "bufo-dog", + "bufo-domo", + "bufo-done-check", + "bufo-dont", + "bufo-dont-even-see-the-code-anymore", + "bufo-dont-trust-whats-over-there", + "bufo-double-chin", + "bufo-double-vaccinated", + "bufo-doubt", + "bufo-dough", + "bufo-downvote", + "bufo-dr-depper", + "bufo-dragon", + "bufo-drags-knee", + "bufo-drake-no", + "bufo-drake-yes", + "bufo-drifts-through-the-void", + "bufo-drinking-baja-blast", + "bufo-drinking-boba", + "bufo-drinking-coffee", + "bufo-drinking-coke", + "bufo-drinking-pepsi", + "bufo-drinking-pumpkin-spice-latte", + "bufo-drinks-from-the-fire-hose", + "bufo-drops-everything-now", + "bufo-drowning-in-leeks", + "bufo-drowns-in-memories-of-ocean", + "bufo-drowns-in-tickets-but-ok", + "bufo-drumroll", + "bufo-easter-bunny", + "bufo-eating-hotdog", + "bufo-eating-lollipop", + "bufo-eats-a-bufo-taco", + "bufo-eats-all-your-honey", + "bufo-eats-bufo-taco", + "bufo-egg", + "bufo-elite", + "bufo-emo", + "bufo-ends-the-holy-war-by-offering-the-objectively-best-programming-language", + "bufo-enjoys-life", + "bufo-enjoys-life-in-the-windows-xp-background", + "bufo-enraged", + "bufo-enter", + "bufo-enters-the-void", + "bufo-entrance", + "bufo-ethereum", + "bufo-everything-is-on-fire", + "bufo-evil", + "bufo-excited", + "bufo-excited-but-sad", + "bufo-existential-dread-sets-in", + "bufo-exit", + "bufo-experiences-euneirophrenia", + "bufo-extra-cool", + "bufo-eye-twitch", + "bufo-eyeballs", + "bufo-eyeballs-bloodshot", + "bufo-eyes", + "bufo-fab", + "bufo-facepalm", + "bufo-failed-the-load-test", + "bufo-fails-the-vibe-check", + "bufo-fancy-tea", + "bufo-farmer", + "bufo-fastest-rubber-stamp-in-the-west", + "bufo-fedora", + "bufo-feel-better", + "bufo-feeling-pretty-might-delete-later", + "bufo-feels-appreciated", + "bufo-feels-nothing", + "bufo-fell-asleep", + "bufo-fellow-kids", + "bufo-fieri", + "bufo-fight", + "bufo-fine-art", + "bufo-fingerguns", + "bufo-fingerguns-back", + "bufo-fire", + "bufo-fire-engine", + "bufo-firefighter", + "bufo-fish", + "bufo-fish-bulb", + "bufo-fistbump", + "bufo-flex", + "bufo-flipoff", + "bufo-flips-table", + "bufo-folder", + "bufo-fomo", + "bufo-food-please", + "bufo-football", + "bufo-for-dummies", + "bufo-forgot-how-to-type", + "bufo-forgot-that-you-existed-it-isnt-love-it-isnt-hate-its-just-indifference", + "bufo-found-some-more-leeks", + "bufo-found-the-leeks", + "bufo-found-yet-another-juicebox", + "bufo-french", + "bufo-friends", + "bufo-frustrated-with-flower", + "bufo-fu%C3%9Fball", + "bufo-fun-is-over", + "bufo-furiously-tries-to-write-python", + "bufo-furiously-writes-an-epic-update", + "bufo-furiously-writes-you-a-peer-review", + "bufo-futbol", + "bufo-gamer", + "bufo-gaming", + "bufo-gandalf", + "bufo-gandalf-has-seen-things", + "bufo-gandalf-wat", + "bufo-gardener", + "bufo-garlic", + "bufo-gavel", + "bufo-gavel-dual-wield", + "bufo-gen-z", + "bufo-gentleman", + "bufo-germany", + "bufo-get-in-loser-were-going-shopping", + "bufo-gets-downloaded-from-the-cloud", + "bufo-gets-hit-in-the-face-with-an-egg", + "bufo-gets-uploaded-to-the-cloud", + "bufo-gets-whiplash", + "bufo-ghost", + "bufo-ghost-costume", + "bufo-giggling-in-a-cat-onesie", + "bufo-give", + "bufo-give-money", + "bufo-give-pack-of-ice", + "bufo-gives-a-fake-moustache", + "bufo-gives-a-magic-number", + "bufo-gives-an-idea", + "bufo-gives-approval", + "bufo-gives-can-of-worms", + "bufo-gives-databricks", + "bufo-gives-j", + "bufo-gives-star", + "bufo-gives-you-a-feature-flag", + "bufo-gives-you-a-hotdog", + "bufo-gives-you-some-extra-brain", + "bufo-gives-you-some-rice", + "bufo-glasses", + "bufo-glitch", + "bufo-goal", + "bufo-goes-super-saiyan", + "bufo-goes-to-space", + "bufo-goggles-are-too-tight", + "bufo-good-morning", + "bufo-good-vibe", + "bufo-goose-hat-happy-dance", + "bufo-got-a-tan", + "bufo-got-zapped", + "bufo-grapes", + "bufo-grasping-at-straws", + "bufo-grenade", + "bufo-grimaces-with-eyebrows", + "bufo-guitar", + "bufo-ha-ha", + "bufo-hacker", + "bufo-hackerman", + "bufo-haha-yes-haha-yes", + "bufo-hahabusiness", + "bufo-halloween", + "bufo-halloween-pumpkin", + "bufo-hands", + "bufo-hands-on-hips-annoyed", + "bufo-hangs-ten", + "bufo-hangs-up", + "bufo-hannibal-lecter", + "bufo-hanson", + "bufo-happy", + "bufo-happy-hour", + "bufo-happy-new-year", + "bufo-hardhat", + "bufo-has-a-5-dollar-footlong", + "bufo-has-a-banana", + "bufo-has-a-bbq", + "bufo-has-a-big-wrench", + "bufo-has-a-blue-wrench", + "bufo-has-a-crush", + "bufo-has-a-dr-pepper", + "bufo-has-a-fresh-slice", + "bufo-has-a-headache", + "bufo-has-a-hot-take", + "bufo-has-a-question", + "bufo-has-a-sandwich", + "bufo-has-a-spoon", + "bufo-has-a-timtam", + "bufo-has-accepted-its-horrible-fate", + "bufo-has-activated", + "bufo-has-another-sandwich", + "bufo-has-been-cleaning", + "bufo-has-gotta-poop-but-hes-stuck-in-a-long-meeting", + "bufo-has-infiltrated-your-secure-system", + "bufo-has-midas-touch", + "bufo-has-read-enough-documentation-for-today", + "bufo-has-some-ketchup", + "bufo-has-thread-for-guts", + "bufo-hasnt-worked-a-full-week-so-far-this-year", + "bufo-hat", + "bufo-hazmat", + "bufo-headbang", + "bufo-headphones", + "bufo-heart", + "bufo-heart-but-its-anatomically-correct", + "bufo-hearts", + "bufo-hehe", + "bufo-hell", + "bufo-hello", + "bufo-heralds-an-incident", + "bufo-heralds-taco-taking", + "bufo-heralds-your-success", + "bufo-here-to-make-a-dill-for-more-pickles", + "bufo-hides", + "bufo-high-speed-train", + "bufo-highfive-1", + "bufo-highfive-2", + "bufo-hipster", + "bufo-hmm", + "bufo-hmm-no", + "bufo-hmm-yes", + "bufo-holding-space-for-defying-gravity", + "bufo-holds-pumpkin", + "bufo-homologates", + "bufo-hop-in-we're-going-to-flavortown", + "bufo-hopes-you-also-are-having-a-good-day", + "bufo-hopes-you-are-having-a-good-day", + "bufo-hot-pocket", + "bufo-hotdog-rocket", + "bufo-howdy", + "bufo-hug", + "bufo-hugs-moo-deng", + "bufo-hype", + "bufo-i-just-love-it-so-much", + "bufo-ice-cream", + "bufo-idk", + "bufo-idk-but-okay-i-guess-so", + "bufo-im-in-danger", + "bufo-imposter", + "bufo-in-a-pear-tree", + "bufo-in-his-cozy-bed-hoping-he-never-gets-capitated", + "bufo-in-rome", + "bufo-inception", + "bufo-increases-his-dimensionality-while-trapped-in-the-void", + "bufo-innocent", + "bufo-inspecting", + "bufo-inspired", + "bufo-instigates-a-dramatic-turn-of-events", + "bufo-intensifies", + "bufo-intern", + "bufo-investigates", + "bufo-iphone", + "bufo-irl", + "bufo-iron-throne", + "bufo-ironside", + "bufo-is-a-little-worried-but-still-trying-to-be-supportive", + "bufo-is-a-part-of-gen-z", + "bufo-is-about-to-zap-you", + "bufo-is-all-ears", + "bufo-is-angry-at-the-water-cooler-bottle-company-for-missing-yet-another-delivery", + "bufo-is-at-his-wits-end", + "bufo-is-at-the-dentist", + "bufo-is-better-known-for-the-things-he-does-on-the-mattress", + "bufo-is-exhausted-rooting-for-the-antihero", + "bufo-is-flying-and-is-the-plane", + "bufo-is-getting-abducted", + "bufo-is-getting-paged-now", + "bufo-is-glad-the-british-were-kicked-out", + "bufo-is-happy-youre-happy", + "bufo-is-having-a-really-bad-time", + "bufo-is-in-a-never-ending-meeting", + "bufo-is-in-on-the-joke", + "bufo-is-inhaling-this-popcorn", + "bufo-is-it-done", + "bufo-is-jealous-its-your-birthday", + "bufo-is-jean-baptise-emanuel-zorg", + "bufo-is-keeping-his-eye-on-you", + "bufo-is-lonely", + "bufo-is-lost", + "bufo-is-lost-in-the-void", + "bufo-is-omniscient", + "bufo-is-on-a-sled", + "bufo-is-panicking", + "bufo-is-petting-your-cat", + "bufo-is-petting-your-dog", + "bufo-is-proud-of-you", + "bufo-is-ready-for-xmas", + "bufo-is-ready-to-build-when-you-are", + "bufo-is-ready-to-burn-down-the-mta-because-their-train-skipped-their-station-again", + "bufo-is-ready-to-consume-his-daily-sodium-intake-in-one-sitting", + "bufo-is-ready-to-eat", + "bufo-is-ready-to-riot", + "bufo-is-ready-to-slay-the-dragon", + "bufo-is-romantic", + "bufo-is-sad-no-one-complimented-their-agent-47-cosplay", + "bufo-is-safe-behind-bars", + "bufo-is-so-happy-youre-here", + "bufo-is-the-perfect-human-form", + "bufo-is-trapped-in-a-cameron-winter-phase", + "bufo-is-unconcerned", + "bufo-is-up-to-something", + "bufo-is-very-upset-now", + "bufo-is-watching-you", + "bufo-is-working-through-the-tears", + "bufo-is-working-too-much", + "bufo-isitdone", + "bufo-isnt-angry-just-disappointed", + "bufo-isnt-going-to-rewind-the-vhs-before-returning-it", + "bufo-isnt-reading-all-that", + "bufo-it-bar", + "bufo-italian", + "bufo-its-over-9000", + "bufo-its-too-early-for-this", + "bufo-jam", + "bufo-jammies", + "bufo-jammin", + "bufo-jealous", + "bufo-jedi", + "bufo-jomo", + "bufo-judge", + "bufo-judges", + "bufo-juice", + "bufo-juicebox", + "bufo-juicy", + "bufo-just-a-little-sad", + "bufo-just-a-little-salty", + "bufo-just-checking", + "bufo-just-finished-a-workout", + "bufo-just-got-back-from-the-dentist", + "bufo-just-ice", + "bufo-just-walked-into-an-awkward-conversation-and-is-now-trying-to-figure-out-how-to-leave", + "bufo-just-wanted-you-to-know-this-is-him-trying", + "bufo-justice", + "bufo-karen", + "bufo-keeps-his-password-written-on-a-post-it-note-stuck-to-his-monitor", + "bufo-keyboard", + "bufo-kills-you-with-kindness", + "bufo-king", + "bufo-kiwi", + "bufo-knife", + "bufo-knife-cries-right", + "bufo-knife-crying", + "bufo-knife-crying-left", + "bufo-knife-crying-right", + "bufo-knows-age-is-just-a-number", + "bufo-knows-his-customers", + "bufo-knows-this-is-a-total-bop", + "bufo-knuckle-sandwich", + "bufo-knuckles", + "bufo-koi", + "bufo-kudo", + "bufo-kuzco", + "bufo-kuzco-has-not-learned-his-lesson-yet", + "bufo-laser-eyes", + "bufo-late-to-the-convo", + "bufo-laugh-xd", + "bufo-laughing-popcorn", + "bufo-laughs-to-mask-the-pain", + "bufo-leads-the-way-to-better-docs", + "bufo-leaves-you-on-seen", + "bufo-left-a-comment", + "bufo-left-multiple-comments", + "bufo-legal-entities", + "bufo-lemon", + "bufo-leprechaun", + "bufo-let-them-eat-cake", + "bufo-lgtm", + "bufo-liberty", + "bufo-liberty-forgot-her-torch", + "bufo-librarian", + "bufo-lick", + "bufo-licks-his-hway-out-of-prison", + "bufo-lies-awake-in-panic", + "bufo-life-saver", + "bufo-likes-that-idea", + "bufo-link", + "bufo-listens-to-his-conscience", + "bufo-lit", + "bufo-littlefoot-is-upset", + "bufo-loading", + "bufo-lol", + "bufo-lol-cry", + "bufo-lolsob", + "bufo-long", + "bufo-lookin-dope", + "bufo-looking-very-much", + "bufo-looks-a-little-closer", + "bufo-looks-for-a-pull-request", + "bufo-looks-for-an-issue", + "bufo-looks-like-hes-listening-but-hes-not", + "bufo-looks-out-of-the-window", + "bufo-loves-blobs", + "bufo-loves-disco", + "bufo-loves-doges", + "bufo-loves-pho", + "bufo-loves-rice-and-beans", + "bufo-loves-ruby", + "bufo-loves-this-song", + "bufo-luigi", + "bufo-lunch", + "bufo-lurk", + "bufo-lurk-delurk", + "bufo-macbook", + "bufo-made-salad", + "bufo-made-you-a-burrito", + "bufo-magician", + "bufo-make-it-rain", + "bufo-makes-it-rain", + "bufo-makes-the-dream-work", + "bufo-mama-mia-thatsa-one-spicy-a-meatball", + "bufo-marine", + "bufo-mario", + "bufo-mask", + "bufo-matrix", + "bufo-medal", + "bufo-meltdown", + "bufo-melting", + "bufo-micdrop", + "bufo-midsommar", + "bufo-midwest-princess", + "bufo-mild-panic", + "bufo-mildly-aggravated", + "bufo-milk", + "bufo-mindblown", + "bufo-minecraft-attack", + "bufo-minecraft-defend", + "bufo-mischievous", + "bufo-mitosis", + "bufo-mittens", + "bufo-modern-art", + "bufo-monocle", + "bufo-monstera", + "bufo-morning", + "bufo-morning-starbucks", + "bufo-morning-sun", + "bufo-mrtayto", + "bufo-mushroom", + "bufo-mustache", + "bufo-my-pho", + "bufo-nah", + "bufo-naked", + "bufo-naptime", + "bufo-needs-some-hot-tea-to-process-this-news", + "bufo-needs-to-vent", + "bufo-nefarious", + "bufo-nervous", + "bufo-nervous-but-cute", + "bufo-night", + "bufo-ninja", + "bufo-no", + "bufo-no-capes", + "bufo-no-more-today-thank-you", + "bufo-no-prob", + "bufo-no-problem", + "bufo-no-ragrets", + "bufo-no-sleep", + "bufo-no-u", + "bufo-nod", + "bufo-noodles", + "bufo-nope", + "bufo-nosy", + "bufo-not-bad-by-dalle", + "bufo-not-my-problem", + "bufo-not-respecting-your-personal-space", + "bufo-notice-me-senpai", + "bufo-notification", + "bufo-np", + "bufo-nun", + "bufo-nyc", + "bufo-oatly", + "bufo-oblivious-and-innocent", + "bufo-of-liberty", + "bufo-offering-bufo-offering-bufo-offering-bufo", + "bufo-offers-1", + "bufo-offers-13", + "bufo-offers-2", + "bufo-offers-200", + "bufo-offers-21", + "bufo-offers-3", + "bufo-offers-5", + "bufo-offers-8", + "bufo-offers-a-bagel", + "bufo-offers-a-ball-of-mud", + "bufo-offers-a-banana-in-these-trying-times", + "bufo-offers-a-beer", + "bufo-offers-a-bicycle", + "bufo-offers-a-bolillo-para-el-susto", + "bufo-offers-a-book", + "bufo-offers-a-brain", + "bufo-offers-a-bufo-egg-in-this-trying-time", + "bufo-offers-a-burger", + "bufo-offers-a-cake", + "bufo-offers-a-clover", + "bufo-offers-a-comment", + "bufo-offers-a-cookie", + "bufo-offers-a-deploy-lock", + "bufo-offers-a-factory", + "bufo-offers-a-flan", + "bufo-offers-a-flowchart-to-help-you-navigate-this-workflow", + "bufo-offers-a-focaccia", + "bufo-offers-a-furby", + "bufo-offers-a-gavel", + "bufo-offers-a-generator", + "bufo-offers-a-hario-scale", + "bufo-offers-a-hot-take", + "bufo-offers-a-jetpack-zebra", + "bufo-offers-a-kakapo", + "bufo-offers-a-like", + "bufo-offers-a-little-band-aid-for-a-big-problem", + "bufo-offers-a-llama", + "bufo-offers-a-loading-spinner", + "bufo-offers-a-loading-spinner-spinning", + "bufo-offers-a-lock", + "bufo-offers-a-mac-m1-chip", + "bufo-offers-a-pager", + "bufo-offers-a-piece-of-cake", + "bufo-offers-a-pr", + "bufo-offers-a-pull-request", + "bufo-offers-a-rock", + "bufo-offers-a-roomba", + "bufo-offers-a-ruby", + "bufo-offers-a-sandbox", + "bufo-offers-a-shocked-pikachu", + "bufo-offers-a-speedy-recovery", + "bufo-offers-a-status", + "bufo-offers-a-taco", + "bufo-offers-a-telescope", + "bufo-offers-a-tiny-wood-stove", + "bufo-offers-a-torta-ahogada", + "bufo-offers-a-webhook", + "bufo-offers-a-webhook-but-the-logo-is-canonically-correct", + "bufo-offers-a-wednesday", + "bufo-offers-a11y", + "bufo-offers-ai", + "bufo-offers-airwrap", + "bufo-offers-an-airpod-pro", + "bufo-offers-an-easter-egg", + "bufo-offers-an-eclair", + "bufo-offers-an-egg-in-this-trying-time", + "bufo-offers-an-ethernet-cable", + "bufo-offers-an-export-of-your-data", + "bufo-offers-an-extinguisher", + "bufo-offers-an-idea", + "bufo-offers-an-incident", + "bufo-offers-an-issue", + "bufo-offers-an-outage", + "bufo-offers-approval", + "bufo-offers-avocado", + "bufo-offers-bento", + "bufo-offers-big-band-aid-for-a-little-problem", + "bufo-offers-bitcoin", + "bufo-offers-boba", + "bufo-offers-boss-coffee", + "bufo-offers-box", + "bufo-offers-bufo", + "bufo-offers-bufo-cubo", + "bufo-offers-bufo-offers", + "bufo-offers-bufomelon", + "bufo-offers-calculated-decision-to-leave-tech-debt-for-now-and-clean-it-up-later", + "bufo-offers-caribufo", + "bufo-offers-chart-with-upwards-trend", + "bufo-offers-chatgpt", + "bufo-offers-chrome", + "bufo-offers-coffee", + "bufo-offers-copilot", + "bufo-offers-corn", + "bufo-offers-corporate-red-tape", + "bufo-offers-covid", + "bufo-offers-csharp", + "bufo-offers-d20", + "bufo-offers-datadog", + "bufo-offers-discord", + "bufo-offers-dnd", + "bufo-offers-empty-wallet", + "bufo-offers-f5", + "bufo-offers-factorio", + "bufo-offers-falafel", + "bufo-offers-fart-cloud", + "bufo-offers-firefox", + "bufo-offers-flatbread", + "bufo-offers-footsie", + "bufo-offers-friday", + "bufo-offers-fud", + "bufo-offers-gatorade", + "bufo-offers-git-mailing-list", + "bufo-offers-golden-handcuffs", + "bufo-offers-google-doc", + "bufo-offers-google-drive", + "bufo-offers-google-sheets", + "bufo-offers-hello-kitty", + "bufo-offers-help", + "bufo-offers-hotdog", + "bufo-offers-jira", + "bufo-offers-ldap", + "bufo-offers-lego", + "bufo-offers-model-1857-12-pounder-napoleon-cannon", + "bufo-offers-moneybag", + "bufo-offers-new-jira", + "bufo-offers-nothing", + "bufo-offers-notion", + "bufo-offers-oatmilk", + "bufo-offers-openai", + "bufo-offers-pancakes", + "bufo-offers-peanuts", + "bufo-offers-pineapple", + "bufo-offers-power", + "bufo-offers-prescription-strength-painkillers", + "bufo-offers-python", + "bufo-offers-securifriend", + "bufo-offers-solar-eclipse", + "bufo-offers-spam", + "bufo-offers-stash-of-tea-from-the-office-for-the-weekend", + "bufo-offers-tayto", + "bufo-offers-terraform", + "bufo-offers-the-cloud", + "bufo-offers-the-power", + "bufo-offers-the-weeknd", + "bufo-offers-thoughts-and-prayers", + "bufo-offers-thread", + "bufo-offers-thundercats", + "bufo-offers-tim-tams", + "bufo-offers-tree", + "bufo-offers-turkish-delights", + "bufo-offers-ube", + "bufo-offers-watermelon", + "bufo-offers-you-a-comically-oversized-waffle", + "bufo-offers-you-a-db-for-your-customer-data", + "bufo-offers-you-a-gdpr-compliant-cookie", + "bufo-offers-you-a-kfc-16-piece-family-size-bucket-of-fried-chicken", + "bufo-offers-you-a-monster-early-in-the-morning", + "bufo-offers-you-a-pint-m8", + "bufo-offers-you-a-red-bull-early-in-the-morning", + "bufo-offers-you-a-suspiciously-not-urgent-ticket", + "bufo-offers-you-an-urgent-ticket", + "bufo-offers-you-dangerously-high-rate-limits", + "bufo-offers-you-his-crypto-before-he-pumps-and-dumps-it", + "bufo-offers-you-logs", + "bufo-offers-you-money-in-this-trying-time", + "bufo-offers-you-the-best-emoji-culture-ever", + "bufo-offers-you-the-moon", + "bufo-offers-you-the-world", + "bufo-offers-yubikey", + "bufo-office", + "bufo-oh-hai", + "bufo-oh-no", + "bufo-oh-yeah", + "bufo-ok", + "bufo-okay-pretty-salty-now", + "bufo-old", + "bufo-olives", + "bufo-omg", + "bufo-on-fire-but-still-excited", + "bufo-on-the-ceiling", + "bufo-oncall-secondary", + "bufo-onion", + "bufo-open-mic", + "bufo-opens-a-haberdashery", + "bufo-orange", + "bufo-oreilly", + "bufo-pager-duty", + "bufo-pajama-party", + "bufo-palpatine", + "bufo-panic", + "bufo-parrot", + "bufo-party", + "bufo-party-birthday", + "bufo-party-conga-line", + "bufo-passed-the-load-test", + "bufo-passes-the-vibe-check", + "bufo-pat", + "bufo-peaks-on-you-from-above", + "bufo-peaky-blinder", + "bufo-pear", + "bufo-pearly-whites", + "bufo-peek", + "bufo-peek-wall", + "bufo-peeking", + "bufo-pensivity-turned-discomfort-upon-realization-of-reality", + "bufo-phew", + "bufo-phonecall", + "bufo-photographer", + "bufo-picked-you-a-flower", + "bufo-pikmin", + "bufo-pilgrim", + "bufo-pilot", + "bufo-pinch-hitter", + "bufo-pineapple", + "bufo-ping", + "bufo-pirate", + "bufo-pitchfork", + "bufo-pitchforks", + "bufo-pizza-hut", + "bufo-placeholder", + "bufo-platformizes", + "bufo-plays-some-smooth-jazz", + "bufo-plays-some-smooth-jazz-intensity-1", + "bufo-pleading", + "bufo-pleading-1", + "bufo-please", + "bufo-pog", + "bufo-pog-surprise", + "bufo-pointing-down-there", + "bufo-pointing-over-there", + "bufo-pointing-right-there", + "bufo-pointing-up-there", + "bufo-police", + "bufo-poliwhirl", + "bufo-ponders", + "bufo-ponders-2", + "bufo-ponders-3", + "bufo-poo", + "bufo-poof", + "bufo-popcorn", + "bufo-popping-out-of-the-coffee", + "bufo-popping-out-of-the-coffee-upsidedown", + "bufo-popping-out-of-the-toilet", + "bufo-pops-by", + "bufo-pops-out-for-a-quick-bite-to-eat", + "bufo-possessed", + "bufo-potato", + "bufo-pours-one-out", + "bufo-praise", + "bufo-pray", + "bufo-pray-partying", + "bufo-praying-his-qa-is-on-point", + "bufo-prays-for-this-to-be-over-already", + "bufo-prays-for-this-to-be-over-already-intensifies", + "bufo-prays-to-azure", + "bufo-prays-to-nvidia", + "bufo-prays-to-pagerduty", + "bufo-preach", + "bufo-presents-to-the-bufos", + "bufo-pretends-to-have-authority", + "bufo-pretty-dang-sad", + "bufo-pride", + "bufo-psychic", + "bufo-pumpkin", + "bufo-pumpkin-head", + "bufo-pushes-to-prod", + "bufo-put-on-active-noise-cancelling-headphones-but-can-still-hear-you", + "bufo-quadruple-vaccinated", + "bufo-question", + "bufo-rad", + "bufo-rainbow", + "bufo-rainbow-moustache", + "bufo-raised-hand", + "bufo-ramen", + "bufo-reading", + "bufo-reads-and-analyzes-doc", + "bufo-reads-and-analyzes-doc-intensifies", + "bufo-red-flags", + "bufo-redacted", + "bufo-regret", + "bufo-remains-perturbed-from-the-void", + "bufo-remembers-bad-time", + "bufo-returns-to-the-void", + "bufo-retweet", + "bufo-reverse", + "bufo-review", + "bufo-revokes-his-approval", + "bufo-rich", + "bufo-rick", + "bufo-rides-in-style", + "bufo-riding-goose", + "bufo-riot", + "bufo-rip", + "bufo-roasted", + "bufo-robs-you", + "bufo-rocket", + "bufo-rofl", + "bufo-roll", + "bufo-roll-fast", + "bufo-roll-safe", + "bufo-roll-the-dice", + "bufo-rolling-out", + "bufo-rose", + "bufo-ross", + "bufo-royalty", + "bufo-royalty-sparkle", + "bufo-rude", + "bufo-rudolph", + "bufo-run", + "bufo-run-right", + "bufo-rush", + "bufo-sad", + "bufo-sad-baguette", + "bufo-sad-but-ok", + "bufo-sad-rain", + "bufo-sad-swinging", + "bufo-sad-vibe", + "bufo-sailor-moon", + "bufo-salad", + "bufo-salivating", + "bufo-salty", + "bufo-salute", + "bufo-same", + "bufo-santa", + "bufo-saves-hyrule", + "bufo-says-good-morning-to-test-the-waters", + "bufo-scheduled", + "bufo-science", + "bufo-science-intensifies", + "bufo-scientist", + "bufo-scientist-intensifies", + "bufo-screams-into-the-ambient-void", + "bufo-security-jacket", + "bufo-sees-what-you-did-there", + "bufo-segway", + "bufo-sends-a-demand-signal", + "bufo-sends-to-print", + "bufo-sends-you-to-the-shadow-realm", + "bufo-shakes-up-your-etch-a-sketch", + "bufo-shaking-eyes", + "bufo-shaking-head", + "bufo-shame", + "bufo-shares-his-banana", + "bufo-sheesh", + "bufo-shh", + "bufo-shh-barking-puppy", + "bufo-shifty", + "bufo-ship", + "bufo-shipit", + "bufo-shipping", + "bufo-shower", + "bufo-showing-off-baby", + "bufo-showing-off-babypilot", + "bufo-shredding", + "bufo-shrek", + "bufo-shrek-but-canonically-correct", + "bufo-shrooms", + "bufo-shrug", + "bufo-shy", + "bufo-sigh", + "bufo-silly", + "bufo-silly-goose-dance", + "bufo-simba", + "bufo-single-tear", + "bufo-sinks", + "bufo-sip", + "bufo-sipping-on-juice", + "bufo-sips-coffee", + "bufo-siren", + "bufo-sit", + "bufo-sith", + "bufo-skeledance", + "bufo-skellington", + "bufo-skellington-1", + "bufo-skiing", + "bufo-slay", + "bufo-sleep", + "bufo-slinging-bagels", + "bufo-slowly-heads-out", + "bufo-slowly-lurks-in", + "bufo-smile", + "bufo-smirk", + "bufo-smol", + "bufo-smug", + "bufo-smugo", + "bufo-snail", + "bufo-snaps-a-pic", + "bufo-snore", + "bufo-snow", + "bufo-sobbing", + "bufo-soccer", + "bufo-softball", + "bufo-sombrero", + "bufo-speaking-math", + "bufo-spider", + "bufo-spit", + "bufo-spooky-szn", + "bufo-sports", + "bufo-squad", + "bufo-squash", + "bufo-sriracha", + "bufo-stab", + "bufo-stab-murder", + "bufo-stab-reverse", + "bufo-stamp", + "bufo-standing", + "bufo-stare", + "bufo-stargazing", + "bufo-stars-in-a-old-timey-talkie", + "bufo-starstruck", + "bufo-stay-puft-marshmallow", + "bufo-steals-your-thunder", + "bufo-stick", + "bufo-stick-reverse", + "bufo-stole-caribufos-antler", + "bufo-stole-your-crunchwrap-before-you-could-finish-it", + "bufo-stoned", + "bufo-stonks", + "bufo-stonks2", + "bufo-stop", + "bufo-stopsign", + "bufo-strains-his-neck", + "bufo-strange", + "bufo-strawberry", + "bufo-strikes-a-deal", + "bufo-strikes-the-match-he's-ready-for-inferno", + "bufo-stripe", + "bufo-stuffed", + "bufo-style", + "bufo-sun-bless", + "bufo-sunny-side-up", + "bufo-surf", + "bufo-sus", + "bufo-sushi", + "bufo-sussy-eyebrows", + "bufo-sweat", + "bufo-sweep", + "bufo-sweet-dreams", + "bufo-sweet-potato", + "bufo-swims", + "bufo-sword", + "bufo-taco", + "bufo-tada", + "bufo-take-my-money", + "bufo-takes-a-bath", + "bufo-takes-bufo-give", + "bufo-takes-five-corndogs-to-the-movies-by-himself-as-his-me-time", + "bufo-takes-hotdog", + "bufo-takes-slack", + "bufo-takes-spam", + "bufo-takes-your-approval", + "bufo-takes-your-boba", + "bufo-takes-your-bufo-taco", + "bufo-takes-your-burrito", + "bufo-takes-your-copilot", + "bufo-takes-your-fud-away", + "bufo-takes-your-golden-handcuffs", + "bufo-takes-your-incident", + "bufo-takes-your-nose", + "bufo-takes-your-pizza", + "bufo-takes-yubikey", + "bufo-takes-zoom", + "bufo-talks-to-brick-wall", + "bufo-tapioca-pearl", + "bufo-tea", + "bufo-teal", + "bufo-tears-of-joy", + "bufo-tense", + "bufo-tequila", + "bufo-thanks", + "bufo-thanks-bufo-for-thanking-bufo", + "bufo-thanks-the-sr-bufo-for-their-wisdom", + "bufo-thanks-you-for-the-approval", + "bufo-thanks-you-for-the-bufo", + "bufo-thanks-you-for-the-comment", + "bufo-thanks-you-for-the-new-bufo", + "bufo-thanks-you-for-your-issue", + "bufo-thanks-you-for-your-pr", + "bufo-thanks-you-for-your-service", + "bufo-thanksgiving", + "bufo-thanos", + "bufo-thats-a-knee-slapper", + "bufo-the-builder", + "bufo-the-crying-osha-compliant-builder", + "bufo-the-osha-compliant-builder", + "bufo-think", + "bufo-thinking", + "bufo-thinking-about-holidays", + "bufo-thinks-about-a11y", + "bufo-thinks-about-azure", + "bufo-thinks-about-azure-front-door", + "bufo-thinks-about-azure-front-door-intensifies", + "bufo-thinks-about-cheeky-nandos", + "bufo-thinks-about-chocolate", + "bufo-thinks-about-climbing", + "bufo-thinks-about-docs", + "bufo-thinks-about-fishsticks", + "bufo-thinks-about-mountains", + "bufo-thinks-about-omelette", + "bufo-thinks-about-pancakes", + "bufo-thinks-about-quarter", + "bufo-thinks-about-redis", + "bufo-thinks-about-rubberduck", + "bufo-thinks-about-steak", + "bufo-thinks-about-steakholder", + "bufo-thinks-about-teams", + "bufo-thinks-about-telemetry", + "bufo-thinks-about-terraform", + "bufo-thinks-about-ufo", + "bufo-thinks-about-vacation", + "bufo-thinks-he-gets-paid-too-much-to-work-here", + "bufo-thinks-of-shamenun", + "bufo-thinks-this-is-a-total-bop", + "bufo-this", + "bufo-this-is-fine", + "bufo-this2", + "bufo-thonk", + "bufo-thonks-from-the-void", + "bufo-threatens-to-hit-you-with-the-chancla-and-he-means-it", + "bufo-threatens-to-thwack-you-with-a-slipper-and-he-means-it", + "bufo-throws-brick", + "bufo-thumbsup", + "bufo-thunk", + "bufo-thwack", + "bufo-timeout", + "bufo-tin-foil-hat", + "bufo-tin-foil-hat2", + "bufo-tips-hat", + "bufo-tired", + "bufo-tired-of-rooting-for-the-anti-hero", + "bufo-tired-yes", + "bufo-toad", + "bufo-tofu", + "bufo-toilet-rocket", + "bufo-tomato", + "bufo-tongue", + "bufo-too-many-pings", + "bufo-took-too-much", + "bufo-tooth", + "bufo-tophat", + "bufo-tortoise", + "bufo-torus", + "bufo-trailhead", + "bufo-train", + "bufo-transfixed", + "bufo-transmutes-reality", + "bufo-trash-can", + "bufo-travels", + "bufo-tries-some-yummy-yummy-crossplane", + "bufo-tries-to-fight-you-but-his-arms-are-too-short-so-count-yourself-lucky", + "bufo-tries-to-hug-you-back-but-his-arms-are-too-short", + "bufo-tries-to-hug-you-but-his-arms-are-too-short", + "bufo-triple-vaccinated", + "bufo-tripping", + "bufo-trying-to-relax-while-procrastinating-but-its-not-working", + "bufo-turns-the-tables", + "bufo-tux", + "bufo-typing", + "bufo-u-dead", + "bufo-ufo", + "bufo-ugh", + "bufo-uh-okay-i-guess-so", + "bufo-uhhh", + "bufo-underpaid-postage-at-usps-and-now-they're-coming-after-him-for-the-money-he-owes", + "bufo-unicorn", + "bufo-universe", + "bufo-unlocked-transdimensional-travel-while-in-the-void", + "bufo-uno", + "bufo-upvote", + "bufo-uses-100-percent-of-his-brain", + "bufo-uwu", + "bufo-vaccinated", + "bufo-vaccinates-you", + "bufo-vampire", + "bufo-venom", + "bufo-ventilator", + "bufo-very-angry", + "bufo-vibe", + "bufo-vibe-dance", + "bufo-vomit", + "bufo-voted", + "bufo-waddle", + "bufo-waiting-for-aws-to-deep-archive-our-data", + "bufo-waiting-for-azure", + "bufo-waits-in-queue", + "bufo-waldo", + "bufo-walk-away", + "bufo-wallop", + "bufo-wants-a-refund", + "bufo-wants-to-have-a-calm-and-civilized-conversation-with-you", + "bufo-wants-to-know-your-spaghetti-policy-at-the-movies", + "bufo-wants-to-return-his-vacuum-that-he-bought-at-costco-four-years-ago-for-a-full-refund", + "bufo-wants-you-to-buy-his-crypto", + "bufo-wards-off-the-evil-spirits", + "bufo-warhol", + "bufo-was-eavesdropping-and-got-offended-by-your-convo-but-now-has-to-pretend-he-didnt-hear-you", + "bufo-was-in-paris", + "bufo-wat", + "bufo-watches-from-a-distance", + "bufo-watches-the-rain", + "bufo-watching-the-clock", + "bufo-watermelon", + "bufo-wave", + "bufo-waves-hello-from-the-void", + "bufo-wears-a-paper-crown", + "bufo-wears-the-cone-of-shame", + "bufo-wedding", + "bufo-welcome", + "bufo-welp", + "bufo-whack", + "bufo-what-are-you-doing-with-that", + "bufo-what-did-you-just-say", + "bufo-what-have-i-done", + "bufo-what-have-you-done", + "bufo-what-if", + "bufo-whatever", + "bufo-whew", + "bufo-whisky", + "bufo-who-me", + "bufo-wholesome", + "bufo-why-must-it-all-be-this-way", + "bufo-why-must-it-be-this-way", + "bufo-wicked", + "bufo-wide", + "bufo-wider-01", + "bufo-wider-02", + "bufo-wider-03", + "bufo-wider-04", + "bufo-wields-mjolnir", + "bufo-wields-the-hylian-shield", + "bufo-will-miss-you", + "bufo-will-never-walk-cornelia-street-again", + "bufo-will-not-be-going-to-space-today", + "bufo-wine", + "bufo-wink", + "bufo-wishes-you-a-happy-valentines-day", + "bufo-with-a-drive-by-hot-take", + "bufo-with-a-fresh-do", + "bufo-with-a-pearl-earring", + "bufo-wizard", + "bufo-wizard-magic-charge", + "bufo-wonders-if-deliciousness-of-this-cheese-is-worth-the-pain-his-lactose-intolerance-will-cause", + "bufo-workin-up-a-sweat-after-eating-a-wendys-double-loaded-double-baked-baked-potato-during-summer", + "bufo-worldstar", + "bufo-worried", + "bufo-worry", + "bufo-worry-coffee", + "bufo-would-like-a-bite-of-your-cookie", + "bufo-writes-a-doc", + "bufo-wtf", + "bufo-wut", + "bufo-yah", + "bufo-yay", + "bufo-yay-awkward-eyes", + "bufo-yay-confetti", + "bufo-yay-judge", + "bufo-yayy", + "bufo-yeehaw", + "bufo-yells-at-old-bufo", + "bufo-yes", + "bufo-yismail", + "bufo-you-sure-about-that", + "bufo-yugioh", + "bufo-yummy", + "bufo-zoom", + "bufo-zoom-right", + "bufo's-a-gamer-girl-but-specifically-nyt-games", + "bufo+1", + "bufobot", + "bufochu", + "bufocopter", + "bufoda", + "bufodile", + "bufofoop", + "bufoheimer", + "bufohub", + "bufolatro", + "bufoling", + "bufolo", + "bufolta", + "bufonana", + "bufone", + "bufonomical", + "bufopilot", + "bufopoof", + "buforang", + "buforce-be-with-you", + "buforead", + "buforever", + "bufos-got-your-back", + "bufos-in-love", + "bufos-jumping-on-the-bed", + "bufos-lips-are-sealed", + "bufovacado", + "bufowhirl", + "bufrogu", + "but-wait-theres-bufo", + "child-bufo-only-has-deku-sticks-to-save-hyrule", + "chonky-bufo-wants-to-be-held", + "christmas-bufo-on-a-goose", + "circle-of-bufo", + "confused-math-bufo", + "constipated-bufo-is-trying-his-hardest", + "copper-bufo", + "corrupted-bufo", + "count-bufo", + "daily-dose-of-bufo-vitamins", + "dalmatian-bufo", + "death-by-a-thousand-bufo-stabs", + "doctor-bufo", + "dont-make-bufo-tap-the-sign", + "double-bufo-sideeye", + "egg-bufo", + "eggplant-bufo", + "et-tu-bufo", + "everybody-loves-bufo", + "existential-bufo", + "feelsgoodbufo", + "fix-it-bufo", + "friendly-neighborhood-bufo", + "future-bufos", + "get-in-lets-bufo", + "get-out-of-bufos-swamp", + "ghost-bufo-of-future-past-is-disappointed-in-your-lack-of-foresight", + "gold-bufo", + "good-news-bufo-offers-suppository", + "google-sheet-bufo", + "great-white-bufo", + "happy-bufo-brings-you-a-deescalation-coffee", + "happy-bufo-brings-you-a-deescalation-tea", + "heavy-is-the-bufo-that-wears-the-crown", + "holiday-bufo-offers-you-a-candy-cane", + "house-of-bufo", + "i-dont-trust-bufo", + "i-heart-bufo", + "i-think-you-should-leave-with-bufo", + "if-bufo-fits-bufo-sits", + "interdimensional-bufo-rests-atop-the-terrarium-of-existence", + "it-takes-a-bufo-to-know-a-bufo", + "its-been-such-a-long-day-that-bufo-doesnt-really-care-anymore", + "just-a-bunch-of-bufos", + "just-hear-bufo-out-for-a-sec", + "kermit-the-bufo", + "king-bufo", + "kirbufo", + "le-bufo", + "live-laugh-bufo", + "loch-ness-bufo", + "looks-good-to-bufo", + "low-fidelity-bufo-cant-believe-youve-done-this", + "low-fidelity-bufo-concerned", + "low-fidelity-bufo-excited", + "low-fidelity-bufo-gets-whiplash", + "m-bufo", + "maam-this-is-a-bufo", + "many-bufos", + "maybe-a-bufo-bigfoot", + "mega-bufo", + "mrs-bufo", + "my-name-is-buford-and-i-am-bufo's-father", + "nobufo", + "not-bufo", + "nothing-inauthentic-bout-this-bufo-yeah-hes-the-real-thing-baby", + "old-bufo-yells-at-cloud", + "old-bufo-yells-at-hubble", + "old-man-yells-at-bufo", + "old-man-yells-at-old-bufo", + "one-of-101-bufos", + "our-bufo-is-in-another-castle", + "paper-bufo", + "party-bufo", + "pixel-bufo", + "planet-bufo", + "please-converse-using-only-bufo", + "poison-dart-bufo", + "pour-one-out-for-bufo", + "press-x-to-bufo", + "princebufo", + "proud-bufo-is-excited", + "radioactive-bufo", + "sad-bufo", + "safe-driver-bufo", + "se%C3%B1or-bufo", + "sen%CC%83or-bufo", + "shiny-bufo", + "shut-up-and-take-my-bufo", + "silver-bufo", + "sir-bufo-esquire", + "sir-this-is-a-bufo", + "sleepy-bufo", + "smol-bufo-feels-blessed", + "smol-bufo-has-a-smol-pull-request-that-needs-reviews-and-he-promises-it-will-only-take-a-minute", + "so-bufoful", + "spider-bufo", + "spotify-wrapped-reminded-bufo-his-listening-patterns-are-a-little-unhinged", + "super-bufo", + "super-bufo-bros", + "tabufo", + "teamwork-makes-the-bufo-work", + "ted-bufo", + "the_bufo_formerly_know_as_froge", + "the-bufo-nightmare-before-christmas", + "the-bufo-we-deserve", + "the-bufos-new-groove", + "the-creation-of-bufo", + "the-more-you-bufo", + "the-pinkest-bufo-there-ever-was", + "theres-a-bufo-for-that", + "this-8-dollar-starbucks-drink-isnt-helping-bufo-feel-any-better", + "this-is-bufo", + "this-will-be-bufos-little-secret", + "triumphant-bufo", + "tsa-bufo-gropes-you", + "two-bufos-beefin", + "up-and-to-the-bufo", + "vin-bufo", + "vintage-bufo", + "whatever-youre-doing-its-attracting-the-bufos", + "when-bufo-falls-in-love", + "whenlifegetsatbufo", + "with-friends-like-this-bufo-doesnt-need-enemies", + "wreck-it-bufo", + "wrong-frog", + "yay-bufo-1", + "yay-bufo-2", + "yay-bufo-3", + "yay-bufo-4", + "you-have-awoken-the-bufo", + "you-have-exquisite-taste-in-bufo", + "you-left-your-typewriter-at-bufos-apartment" +] diff --git a/site/favicon.svg b/site/favicon.svg new file mode 100644 index 0000000..42eb403 --- /dev/null +++ b/site/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..51ab031 --- /dev/null +++ b/site/index.html @@ -0,0 +1,49 @@ + + + + + + status + + + + + +
+
+

status

+ +
+ +
+
loading...
+
+
+ + + + diff --git a/site/styles.css b/site/styles.css new file mode 100644 index 0000000..9739ea1 --- /dev/null +++ b/site/styles.css @@ -0,0 +1,791 @@ +:root { + --bg: #0a0a0a; + --bg-card: #1a1a1a; + --text: #ffffff; + --text-secondary: #888; + --accent: #4a9eff; + --border: #2a2a2a; + --radius: 12px; + --font-family: ui-monospace, "SF Mono", Monaco, monospace; +} + +[data-theme="light"] { + --bg: #ffffff; + --bg-card: #f5f5f5; + --text: #1a1a1a; + --text-secondary: #666; + --border: #e0e0e0; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +/* Theme-aware scrollbars */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg); +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* Firefox */ +* { + scrollbar-width: thin; + scrollbar-color: var(--border) var(--bg); +} + +body { + font-family: var(--font-family); + background: var(--bg); + color: var(--text); + line-height: 1.6; + min-height: 100vh; +} + +#app { + max-width: 600px; + margin: 0 auto; + padding: 2rem 1rem; +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border); +} + +header h1 { + font-size: 1.5rem; + font-weight: 600; +} + +nav { + display: flex; + gap: 1rem; + align-items: center; +} + +nav a { + color: var(--text-secondary); + text-decoration: none; +} + +nav a:hover { + color: var(--accent); +} + +.nav-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 0.5rem; + border-radius: 8px; + transition: background 0.15s, color 0.15s; + color: var(--text-secondary); + background: none; + border: none; + cursor: pointer; +} + +.nav-btn:hover { + background: var(--bg-card); + color: var(--accent); +} + +.nav-btn.active { + color: var(--accent); +} + +.nav-btn svg { + display: block; +} + +#theme-toggle { + background: none; + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.5rem; + cursor: pointer; + font-size: 1rem; +} + +#theme-toggle .sun { display: none; } +#theme-toggle .moon { display: inline; color: var(--text); } +[data-theme="light"] #theme-toggle .sun { display: inline; color: var(--text); } +[data-theme="light"] #theme-toggle .moon { display: none; } + +.hidden { display: none !important; } +.center { text-align: center; padding: 2rem; } + +/* Login form */ +#login-form { + display: flex; + gap: 0.5rem; + margin-top: 1rem; + justify-content: center; +} + +#login-form input { + padding: 0.75rem 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-card); + color: var(--text); + font-family: inherit; + font-size: 1rem; + width: 200px; +} + +#login-form button, button[type="submit"] { + padding: 0.75rem 1.5rem; + background: var(--accent); + color: white; + border: none; + border-radius: var(--radius); + cursor: pointer; + font-family: inherit; + font-size: 1rem; +} + +#login-form button:hover, button[type="submit"]:hover { + opacity: 0.9; +} + +/* Profile card */ +.profile-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2rem; + margin-bottom: 1.5rem; +} + +.current-status { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + text-align: center; +} + +.big-emoji { + font-size: 4rem; + line-height: 1; +} + +.big-emoji img { + width: 4rem; + height: 4rem; + object-fit: contain; +} + +.status-info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +#current-text { + font-size: 1.25rem; +} + +.meta { + color: var(--text-secondary); + font-size: 0.875rem; +} + +/* Status form */ +.status-form { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem; + margin-bottom: 1.5rem; +} + +.emoji-input-row { + display: flex; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.emoji-input-row input { + flex: 1; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--text); + font-family: inherit; + font-size: 1rem; +} + +#emoji-input { + max-width: 150px; +} + +.form-actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; +} + +.form-actions select { + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--text); + font-family: inherit; +} + +.custom-datetime { + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--text); + font-family: inherit; +} + +/* History */ +.history { + margin-bottom: 2rem; +} + +.history h2 { + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); + margin-bottom: 1rem; +} + +#history-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +/* Feed list */ +.feed-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +/* Status item (used in both history and feed) */ +.status-item { + display: flex; + gap: 1rem; + padding: 1rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + align-items: flex-start; +} + +.status-item:hover { + border-color: var(--accent); +} + +.status-item .emoji { + font-size: 1.5rem; + line-height: 1; + flex-shrink: 0; +} + +.status-item .emoji img { + width: 1.5rem; + height: 1.5rem; + object-fit: contain; +} + +.status-item .content { + flex: 1; + min-width: 0; +} + +.status-item .author { + color: var(--text-secondary); + font-weight: 600; + text-decoration: none; +} + +.status-item .author:hover { + color: var(--accent); +} + +.status-item .text { + margin-left: 0.5rem; +} + +.status-item .time { + display: block; + font-size: 0.875rem; + color: var(--text-secondary); + margin-top: 0.25rem; +} + +.delete-btn { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 0.25rem; + border-radius: 4px; + opacity: 0; + transition: opacity 0.15s, color 0.15s; + flex-shrink: 0; +} + +.status-item:hover .delete-btn { + opacity: 1; +} + +.delete-btn:hover { + color: #e74c3c; +} + +/* Logout */ +.logout-btn { + display: block; + margin: 0 auto; + padding: 0.5rem 1rem; + background: none; + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-secondary); + cursor: pointer; + font-family: inherit; +} + +.logout-btn:hover { + border-color: var(--text); + color: var(--text); +} + +/* Load more */ +#load-more-btn { + padding: 0.75rem 1.5rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + cursor: pointer; + font-family: inherit; +} + +#load-more-btn:hover { + border-color: var(--accent); +} + +/* Emoji trigger button */ +.emoji-trigger { + width: 3rem; + height: 3rem; + border: none; + border-radius: 8px; + background: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.75rem; + flex-shrink: 0; +} + +.emoji-trigger:hover { + background: var(--bg-card); +} + +.emoji-trigger img { + width: 2.5rem; + height: 2.5rem; + object-fit: contain; +} + +/* Emoji picker overlay */ +.emoji-picker-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.emoji-picker { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + width: 100%; + max-width: 600px; + height: 90vh; + max-height: 700px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.emoji-picker-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + border-bottom: 1px solid var(--border); +} + +.emoji-picker-header h3 { + font-size: 1rem; + font-weight: 600; +} + +.emoji-picker-close { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 1.25rem; + padding: 0.25rem; +} + +.emoji-picker-close:hover { + color: var(--text); +} + +.emoji-search { + margin: 0.75rem; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--text); + font-family: inherit; + font-size: 0.875rem; +} + +.emoji-categories { + display: flex; + gap: 0.25rem; + padding: 0 0.75rem; + overflow-x: auto; + flex-shrink: 0; +} + +.category-btn { + padding: 0.5rem; + border: none; + background: none; + cursor: pointer; + font-size: 1.25rem; + border-radius: 8px; + opacity: 0.5; + transition: opacity 0.15s; +} + +.category-btn:hover, .category-btn.active { + opacity: 1; + background: var(--bg); +} + +.emoji-grid { + padding: 0.75rem; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(48px, 1fr)); + gap: 0.25rem; + overflow-y: auto; + flex: 1; + min-height: 200px; + align-content: start; +} + +.emoji-grid.bufo-grid { + grid-template-columns: repeat(auto-fill, minmax(64px, 1fr)); + gap: 0.5rem; +} + +.emoji-btn { + padding: 0.5rem; + border: none; + background: none; + cursor: pointer; + font-size: 1.5rem; + border-radius: 8px; + transition: background 0.15s; +} + +.emoji-btn:hover { + background: var(--bg); +} + +/* Consistent sizing for mixed emoji/bufo grids (frequent tab) */ +.emoji-grid .emoji-btn { + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.75rem; +} + +.bufo-btn { + padding: 0.25rem; +} + +.bufo-grid .bufo-btn { + width: 64px; + height: 64px; +} + +.bufo-btn img { + width: 100%; + height: 100%; + max-width: 48px; + max-height: 48px; + object-fit: contain; +} + +.loading { + grid-column: 1 / -1; + text-align: center; + color: var(--text-secondary); + padding: 2rem; +} + +.no-results { + grid-column: 1 / -1; + text-align: center; + color: var(--text-secondary); + padding: 2rem; +} + +/* Custom emoji input */ +.custom-emoji-input { + grid-column: 1 / -1; + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.custom-emoji-input input { + flex: 1; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--text); + font-family: inherit; +} + +.custom-emoji-input button { + padding: 0.5rem 1rem; + background: var(--accent); + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + font-family: inherit; +} + +.custom-emoji-preview { + grid-column: 1 / -1; + display: flex; + justify-content: center; + min-height: 80px; + align-items: center; +} + +.bufo-helper { + padding: 0.75rem; + text-align: center; + border-top: 1px solid var(--border); +} + +.bufo-helper a { + color: var(--accent); + font-size: 0.875rem; +} + +/* Settings Modal */ +.settings-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.settings-modal { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + width: 100%; + max-width: 400px; + display: flex; + flex-direction: column; +} + +.settings-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + border-bottom: 1px solid var(--border); +} + +.settings-header h3 { + font-size: 1.1rem; + font-weight: 500; +} + +.settings-close { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 1.25rem; + padding: 0.25rem; +} + +.settings-close:hover { + color: var(--text); +} + +.settings-content { + padding: 1rem; + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.setting-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.setting-group label { + font-size: 0.875rem; + color: var(--text-secondary); +} + +.setting-group select { + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--text); + font-family: inherit; + font-size: 1rem; +} + +.color-picker { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.color-btn { + width: 32px; + height: 32px; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + transition: border-color 0.15s, transform 0.15s; +} + +.color-btn:hover { + transform: scale(1.1); +} + +.color-btn.active { + border-color: var(--text); +} + +.custom-color-input { + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + cursor: pointer; + background: none; + padding: 0; +} + +.custom-color-input::-webkit-color-swatch-wrapper { + padding: 0; +} + +.custom-color-input::-webkit-color-swatch { + border: 2px solid var(--border); + border-radius: 50%; +} + +.settings-footer { + padding: 1rem; + border-top: 1px solid var(--border); + display: flex; + justify-content: flex-end; +} + +.settings-footer .save-btn { + padding: 0.75rem 1.5rem; + background: var(--accent); + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + font-family: inherit; + font-size: 1rem; +} + +.settings-footer .save-btn:hover { + opacity: 0.9; +} + +.settings-footer .save-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Mobile */ +@media (max-width: 480px) { + .emoji-input-row { + flex-direction: row; + } + + .form-actions { + flex-direction: column; + } + + .emoji-grid { + grid-template-columns: repeat(6, 1fr); + } +}