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.
- 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
approvedflag.
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.
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.
yarn install
yarn devOpen 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.
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.
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 |
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
approvedflag. - 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}$, withunicornreserved. That index is what makes usernames unique, and claiming one also needsmetadata/usernamesReadyto be true.
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.
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-ee877The 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.
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.