Sitelet https://github.com/Evirtual/atunicorn
Skip to content

Repository files navigation

The @unicorn unicorn

@unicorn (atunicorn.io)

CI

It's a place to express your uniqueness in ways that inspire us to feel more confident in our everyday life

A small image-sharing site. You sign up with an email, pick a username, write an about page in Markdown, and post pictures. Everyone's posts land in one feed.

Live at atunicorn.io.

What it does

  • One shared feed. Every post from every unicorn, newest first, filtered client-side by username or description through ?search=.
  • Posts are an image and a description. Uploads are resized in the browser to 1280x1280 JPEG before they leave the device; GIFs are passed through untouched. Limits are 7MB for an image, 2MB for a GIF.
  • NSFW posts are covered until the reader taps to reveal them.
  • Light or dark, switched from the nav and remembered per browser. The switch is for signed-in accounts only.
  • A profile per user at /profile/<id>, with a username, an avatar and a Markdown about page.
  • Email and password accounts. The address must be verified before the session is kept - an unverified user is signed straight back out.
  • Posting is approval-gated. A new account can sign in and set up a profile, but sees "Account is pending for approval" and cannot upload until someone sets its approved flag.

How it fits together

browser (Next.js static export, TypeScript, Tailwind)
   |
   |-- Firebase Auth ............ accounts, email verification, ID tokens
   |-- Realtime Database ........ posts, users, username index (live subscriptions)
   |-- Firebase Hosting ......... serves out/ with clean-URL rewrites
   |
   `-- Cloudflare Worker ........ /upload and /delete
            |
            `-- Cloudinary ...... the actual image storage

Images do not go to Firebase Storage. The browser sends them to a Cloudflare Worker, which verifies the caller's Firebase ID token against Google's JWKS, re-checks users/<uid>/approved in the database, and only then signs the Cloudinary request. The Cloudinary API secret never reaches the browser. See docs/image-storage.md.

The front end

src/ holds everything, reached through the @/* alias:

Path What lives there
src/lib Firebase wiring, the store and its actions, routes, theme
src/components The nav, post card, feed, dialogs and UI primitives
src/screens One component per page
src/styles Tailwind entry point and the colour tokens
pages Route shells, nothing but re-exports

State is a small typed store read with useSyncExternalStore, written only by the actions in src/lib/actions.ts. Firebase subscriptions start once in _app and push straight into it.

Colour is CSS, never JavaScript. globals.css defines one set of semantic names - ground, surface, ink, line - twice, and .dark on the html element swaps which set applies. A component asks for bg-surface or text-ink and never learns which mode is active. A small script in _document sets that class before the first paint, so a stored dark theme does not flash light.

The feed renders a page of posts at a time and adds another when an IntersectionObserver sentinel scrolls into view. Everything is already in memory - the database hands over the whole posts tree in one subscription - so this is windowing rather than pagination.

Running it

yarn install
yarn dev

Open http://localhost:8080. The dev server talks to the real Firebase project, so a local login is a real login.

Script What it does
yarn dev Next dev server on port 8080
yarn build Static export into out/
yarn start Firebase Hosting emulator over out/, also on 8080
yarn static build then start - production, locally
yarn typecheck tsc --noEmit
yarn lint ESLint, with the Next and TypeScript configs
yarn test Vitest unit tests
yarn test:e2e Playwright, against the built export in out/
yarn verify typecheck, lint, unit tests, build, then Playwright
yarn deploy Build, then deploy hosting to Firebase
yarn storage:worker:dev Run the Cloudinary Worker locally with wrangler
yarn storage:worker:deploy Deploy the Worker

Other emulators are configured on their usual ports: auth 9099, database 9000.

Tests

Vitest covers the parts that are pure enough to pin down: route building, the search and post helpers, the username, email and password rules that the database also enforces, the theme hook, and the scroll hook that decides when the nav condenses.

Playwright covers what only exists in a built page, and each of its specs guards a bug that actually shipped:

  • the loading screen is in the served HTML, so a refresh does not flash an empty feed first
  • the logo keeps its 4:3 ratio instead of being squashed into a square
  • scrolling back and forth across the condense threshold does not crash the page, and the header does not change height when it condenses
  • a stored dark theme is on the html element before the first paint
  • signed-out visitors are not shown the theme switch

The e2e run serves out/ and talks to the live Firebase project, so the specs only read, and they skip rather than fail when the feed is empty.

Routes

trailingSlash is on and the export is static, so every route needs a matching HTML shell and a rewrite in firebase.json. That is also why the app stays on the Pages Router: post and profile ids are user content, and the App Router would have to enumerate them at build time.

Route Screen
/ the feed (?search= filters it)
/post/<id>/ a single post
/profile/<id>/ a profile and its posts
/profile/<id>/about/ that profile's Markdown about page
/about/ the site's own about page
/<anything>/ the feed - the shell the catch-all rewrite serves

Data

posts/<uid>/<postId>   id, userId, username, avatar, url, desc, nsfw, updated
users/<uid>            id, username, url, about, approved
usernames/<username>   uid   (uniqueness index)

Rules are in database.rules.json. Posts and users are world readable; the root is closed by default. Three things are enforced there rather than in the client:

  • An account cannot set its own approved flag.
  • Writing a post requires users/<uid>/approved === true, so approval gates the database as well as the Worker. Deleting your own post stays allowed either way.
  • A username can only be claimed when usernames/<name> is free and matches ^[a-z0-9]{3,15}$, with unicorn reserved. That index is what makes usernames unique, and claiming one also needs metadata/usernamesReady to be true.

Configuration

next.config.ts forwards only two environment variables into the client bundle, storageProvider and storageApiUrl, to point uploads at another storage back end. Both are optional: the defaults live in src/lib/config.ts. Anything else stays out of the bundle, and the Cloudinary Worker's own secrets are set through wrangler, not here. Keep local overrides in .env.local, which git ignores.

Deploying

yarn deploy builds and pushes hosting to the unicorn-ee877 project. Database rules deploy separately, on purpose, so a front-end release cannot quietly revert a rule edited in the console:

npx firebase-tools deploy --only database --project unicorn-ee877

The Worker deploys with yarn storage:worker:deploy.

GitHub Actions runs the checks on every push and pull request - typecheck, lint, unit tests, build, then Playwright against the export. It does not deploy: releases are pushed by hand from a machine that is logged in to Firebase.

Licence

AGPL-3.0. Use it, read it, change it, share it; if you run a changed version as a service for others, publish your changes too. © 2021–2026 Edgaras Neverdauskas.

About

A place to express your uniqueness: image posts and a markdown profile, shared at atunicorn.io. Next.js and react-native-web over Firebase auth, database and hosting, with images on Cloudinary behind a Cloudflare Worker that verifies the user's token so the API secret never reaches the browser.

Topics

Resources

Stars

1 star

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages