Join the Agent Deck Discord Server!
Discord에서 Agent Deck 커뮤니티 확인, 357명과 자유 음성·텍스트 채팅 이용
IKEA 인형 상어를 위한 색·크기·촉감 매칭 데이트 사이트
A warm, weird little corner of the internet for Blåhaj looking for their person. The world's first dating site for IKEA's plush sharks, matching by color, size, and squishiness.
소셜TypeScript · CSSGitHub @CuteNikki온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기The world's first dating site exclusively for Blåhaj!
저장소 README에서 가져옴
README 전체를 불러오는 중입니다.
README 전문 보기 ↗Tinderhaj is a dating site for plush sharks, named after IKEA's Blåhaj and built with Next.js. Owners create a profile for each of their sharks, a moderator checks it by hand, and once it's verified other sharks can find it in discovery and send it a heart. Two sharks that heart each other are a match.
This project combines:
The product flow is centered on creating a profile for a shark, submitting it for review, and then discovering and hearting other verified sharks.
/u/<username> with all its verified sharks, and a preview picture of them when the link is shared.
├── bot/ # The Discord bot, its own package (see Discord Bot)
├── generated/ # Prisma client, made by `prisma generate`
├── prisma/
│ ├── schema.prisma
│ ├── seed.ts
│ └── migrations/
├── src/
│ ├── app/
│ ├── components/
│ ├── constants/
│ └── lib/
├── .env
├── next.config.ts
├── package.json
├── tsconfig.json
├── components.json
└── README.md
Before running the app locally, make sure you have:
Create a .env file in the project root with values similar to:
DATABASE_URL="postgresql://username:password@localhost:5432/tinderhaj"
SMTP_URL="smtps://no-reply%40tinderhaj.com:[email protected]"
BETTER_AUTH_SECRET="a random string, e.g. from openssl rand -base64 32"
BETTER_AUTH_URL="http://localhost:3000"
STORAGE_ACCESS_KEY_ID="the key from garage key create"
STORAGE_SECRET_ACCESS_KEY="its secret"
# Optional: social sign-in. Each provider shows up once both of its values are set.
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
APPLE_CLIENT_ID=""
APPLE_CLIENT_SECRET=""
APPLE_APP_BUNDLE_IDENTIFIER=""
MICROSOFT_CLIENT_ID=""
MICROSOFT_CLIENT_SECRET=""
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
DISCORD_CLIENT_ID=""
DISCORD_CLIENT_SECRET=""
TWITTER_CLIENT_ID=""
TWITTER_CLIENT_SECRET=""
TWITCH_CLIENT_ID=""
TWITCH_CLIENT_SECRET=""
FACEBOOK_CLIENT_ID=""
FACEBOOK_CLIENT_SECRET=""
# Optional: roles on the Discord server for what people do on the site. Needs Discord sign-in above.
DISCORD_BOT_TOKEN=""
DISCORD_GUILD_ID=""
DISCORD_ROLE_LINKED=""
DISCORD_ROLE_VERIFIED=""
DISCORD_ROLE_MATCHED=""
DISCORD_ROLE_MODERATOR=""
DISCORD_ROLE_ADMIN=""
# Optional: a channel on the same server where newly verified sharks are posted. Needs DISCORD_BOT_TOKEN.
DISCORD_CHANNEL_NEW_SHARKS=""
# Optional: a channel only moderators see, where sharks waiting for review are posted. Needs DISCORD_BOT_TOKEN.
DISCORD_CHANNEL_REVIEW_QUEUE=""
# Optional: the Discord bot in bot/ (see Discord Bot below).
DISCORD_CHANNEL_JOIN_TO_CREATE=""
DISCORD_CHANNEL_BUBBLE_CONTROLS=""
DISCORD_CHANNEL_LOG=""
Notes:
DATABASE_URL is required for Prisma and the app database connection.BETTER_AUTH_SECRET signs session cookies and encrypts two-step sign-in secrets. Keep it the same across deploys, or everyone is signed out and authenticator apps stop working.BETTER_AUTH_URL is where the site runs, e.g. https://tinderhaj.com. Links in emails and sign-in provider callbacks point there, and passkeys only work on its domain. On Vercel it can be left out: production then uses the project's production domain (VERCEL_PROJECT_PRODUCTION_URL) and preview deployments their own address. Anywhere else it is required in production, where the server won't start without it; in development it defaults to http://localhost:3000.STORAGE_ACCESS_KEY_ID and STORAGE_SECRET_ACCESS_KEY are the storage key the site signs uploads and deletions with (see Pictures). Without them, uploading a picture fails. STORAGE_REGION is garage unless set.<BETTER_AUTH_URL>/api/auth/callback/<provider>, e.g. http://localhost:3000/api/auth/callback/github. The provider ids are google, apple, microsoft, github, discord, twitter (X), twitch, and facebook.
APPLE_APP_BUNDLE_IDENTIFIER is only needed for signing in from an iOS app.Linked for connecting at all, Verified Shark Owner for a verified shark, Matched for a match, Site Moderator for moderators and admins, and Site Admin for admins. They're given and taken back as those change, all of them while an account is banned, and each person can refresh theirs from Account Settings.
npm install
If the project uses Bun for the Prisma generation hook, you can also install with Bun:
bun install
Generate Prisma client and apply migrations:
npx prisma generate
npx prisma migrate dev
If you want to seed the database:
npx tsx prisma/seed.ts
Start the development server:
npm run dev
Then open:
http://localhost:3000
npm run dev # start the Next.js dev server
npm run build # production build
npm run start # start the production server
npm run lint # ESLint checks
npm run lint:classes # classes Tailwind would write another way, e.g. w-[16px] for w-4
npm run typecheck # TypeScript, after generating Next's route types
npm run format # Prettier formatting
npm run format:check # Prettier, without changing files
npm run test:e2e # Playwright end-to-end tests, see below
GitHub Actions runs the four checks, a production build and the end-to-end tests, against a fresh PostgreSQL database, on every push and pull request (.github/workflows/ci.yml).
The Playwright tests in e2e/ sign up, create a profile and send it for review, and send a heart that becomes a match. They run against a production build in Chromium.
They make accounts, so they need a database of their own in E2E_DATABASE_URL, never the one in .env. A throwaway one in Docker works:
docker run -d --rm --name tinderhaj-e2e -e POSTGRES_PASSWORD=postgres -p 55432:5432 postgres:17-alpine
DATABASE_URL=postgresql://postgres:postgres@localhost:55432/postgres npx prisma migrate deploy
npx playwright install chromium
npm run build
E2E_DATABASE_URL=postgresql://postgres:postgres@localhost:55432/postgres npm run test:e2e
The server runs on port 3100 and sends its emails to a stand-in mail server on port 3198, which the tests read them back from on port 3199, so nothing leaves your machine. Accounts the tests make have @e2e.test addresses and are removed when the run ends.
Sign-in runs on Better Auth, configured in src/lib/auth.ts and served from /api/auth/*. Sessions are kept in PostgreSQL and an HTTP-only cookie; server code reads them with getSession() and requireUser() from src/lib/session.ts, and client components call Better Auth through authClient from src/lib/auth-client.ts.
Better Auth calls the username name. It checks usernames on sign-up and on changes against the same rules as the forms. People who sign up with a provider get a username made from their handle or name there (e.g. nikki_b, or nikki_b_4821 if taken), which they can change in Account Settings.
Signing in with a provider joins an existing account with the same email only when both the provider and Tinderhaj have verified that email. Otherwise the person is asked to sign in another way and connect the provider from Account Settings. Passwords are hashed with scrypt as salt:hash (src/lib/password-hasher.ts), the same way as before Better Auth, so older accounts keep their passwords.
Emails (verification, email changes, password resets, sign-in codes, and account deletion) are sent through our mail server (see Emails) from src/lib/email.ts.
The Prisma schema defines the core models:
User: username, email, role, whether two-step sign-in is on, how matches can reach them, and any ban (reason, expiry, and who gave it)Account: ways to sign in; holds the passwordSession: active sessions, with IP address and browserVerification: email links and trusted devicesTwoFactor: authenticator app secrets and backup codesPasskey: passkeysProfile: a shark's profile content, moderation status, rejection feedback, and submission and verification timestampsHeart: a heart from one shark to another, and when its receiver saw it. A heart each way is a match.Report: a shark, or its owner's way to be reached, reported to moderators: the reason, who reported it, and who handled it.Roles are USER, MODERATOR, and ADMIN; what each can do is in src/lib/roles.ts.
Profile states include:
CREATEDPENDINGREJECTEDVERIFIEDOnly verified profiles from accounts that aren't banned show up in discovery, on user pages, and in hearts.
This app is designed for deployment on modern Node.js hosting platforms such as Vercel. For production, ensure you configure:
DATABASE_URLSMTP_URLBETTER_AUTH_SECRET, and BETTER_AUTH_URL unless Vercel's production domain is the right addressSTORAGE_ACCESS_KEY_ID and STORAGE_SECRET_ACCESS_KEYBetter Auth rate limits sign-in, sign-up, and code requests per IP address in production. It reads the address from x-forwarded-for, which Vercel sets. Behind a proxy that doesn't, every visitor shares one limit.
Avatars and banners are kept on our own server, in Garage, an S3-compatible store, at images.tinderhaj.com (UPLOAD_ORIGIN in src/constants/uploads.ts). The browser shrinks a picture, asks the site for a link to upload it to (requestUpload), and sends it there itself, so it never passes through the site. Each link lasts 5 minutes and is signed for that picture's type and size, at a new address of its own. A picture is deleted once no shark has it any more: when a shark gets a new one, or a shark or account is deleted.
Garage is the server's image storage, run from ~/services/storage on the server (container garage, with meta and data there being what to back up), where every site has a bucket of its own. The pictures are in the tinderhaj bucket under avatars/ and banners/, which images.tinderhaj.com serves. Uploading and deleting go through Garage's S3 API at s3.niso.moe, which takes the bucket in the path (BUCKET and STORAGE_ENDPOINT in src/lib/storage.ts). To set it up, with garage standing for docker exec garage /garage:
images.tinderhaj.com at the server (an A record; on Cloudflare, DNS only, so Caddy can get its certificate).garage bucket create tinderhaj
garage bucket website --allow tinderhaj
garage key create tinderhaj-site # its Key ID and Secret key are STORAGE_ACCESS_KEY_ID and STORAGE_SECRET_ACCESS_KEY
garage bucket allow --read --write tinderhaj --key tinderhaj-site
PUT with a content-type header from https://tinderhaj.com and http://localhost:3000.images.tinderhaj.com and Garage's S3 API at s3.niso.moe (the server's ~/services/caddy/Caddyfile). Reading a picture tells Garage it's tinderhaj being read, since Garage picks the bucket from the address. Uploads have an address of their own because they're signed for their path, which Caddy can't rewrite.Emails go out from our own server, through its mail server (Stalwart, at mail.niso.moe), which also handles the mail for the server's other domains and isn't part of this repo. The site logs in as [email protected], an account in Stalwart, over TLS on port 465, and sends as that address. SMTP_URL holds the login, with the @ written as %40: smtps://no-reply%40tinderhaj.com:[email protected]. EMAIL_FROM changes the name shown, e.g. "Tinderhaj <[email protected]>", the default.
Stalwart signs each email with DKIM and delivers it, retrying for a while when a server is down. Its DNS records for tinderhaj.com (MX, SPF, DKIM, DMARC) are on Cloudflare, published by Stalwart itself.
bot/ holds the Tinderhaj bot: the same bot as the role sync, new sharks feed and review queue, but running all the time, so it can see who joins voice channels and answer buttons and commands. It's built like CuteNikki/DiscordBot (a file per command, button, modal and event, loaded from their folders) on discord.js 14, and runs with Bun. It uses the site's database (DATABASE_URL) with the client generated from prisma/schema.prisma, where its own tables (Bubble, BubbleSettings) are too. It reads site data directly, but only changes its own tables: the site's data is left to the site's code, which keeps its cache, Discord roles and posts in step.
DISCORD_CHANNEL_JOIN_TO_CREATE makes a voice channel of your own in the same category and takes you there; joining again while you have one takes you back to it.
/bubble, or the button the bot keeps in DISCORD_CHANNEL_BUBBLE_CONTROLS if that's set, shows the same panel from anywhere./shark member posts someone's verified sharks (or yours) in one card, each with its avatar, details and a link to its page (the first 5, with a button to see them all on the site), /shark user the same for a Tinderhaj username (nikki or @nikki), and /shark random a random one, as a link Discord shows as its card. /shark member says the same for someone who hasn't connected Discord as for someone with no sharks, and /shark user the same for a banned or unknown username, and leaves out whose Discord account it is, so a username can't be used to find someone in the server./guidelines posts the guidelines moderators review profiles by, for everyone in the channel to see. They're a copy of the site's guidelines page, so a change to one goes in the other./warn, /timeout, /untimeout, /kick, /ban and /unban, each for whoever has the matching Discord permission (Timeout Members, Kick Members or Ban Members). Nobody can act on someone whose top role is as high as theirs. Before a warn, timeout, kick or ban, the bot DMs them what happened and why, without saying who did it. deletes up to 100 messages in a channel at once, optionally only one member's, for whoever has Manage Messages there; Discord doesn't let bots bulk delete messages older than 2 weeks, so those are left.
Setting it up:
Set SITE_URL for the bot if the site isn't at https://tinderhaj.com, e.g. http://localhost:3000 locally, so /shark links to it.
Give the bot's role Manage Channels, Manage Roles, Move Members, View Channel, Connect, Speak, Video, Send Messages, Embed Links and Read Message History in the join-to-create category (it can only give owners what it has itself), and View Channel, Send Messages and Read Message History in the bubble controls channel. For moderation and the log it also needs View Audit Log, Ban Members, Kick Members and Timeout Members, View Channel and Read Message History everywhere it should log messages from, and its role above everyone it should be able to moderate.
In the Discord Developer Portal, under Bot, turn on Server Members Intent and Message Content Intent: the log needs them for joins and leaves and for what edited and deleted messages said.
Copy the join-to-create voice channel's id into DISCORD_CHANNEL_JOIN_TO_CREATE, optionally a text channel's into DISCORD_CHANNEL_BUBBLE_CONTROLS, and the log channel's into DISCORD_CHANNEL_LOG. Keep the log channel visible to moderators only.
Apply the site's migrations, which include the bot's tables (see Database Setup).
Run it. Locally, cd bot && bun install && bun run dev reads the site's .env and uses the client the site generated. On a server, cd bot && docker compose up -d --build builds it from the repo root, generating the client from the site's schema, and reads ../.env as well. The compose file joins the external postgres_default network, so DATABASE_URL can use the Postgres container's name as its host; take that out if your Postgres isn't a container on that network. Logs go to bot/logs.
Register its commands on the server in DISCORD_GUILD_ID: bun run register in bot/, or docker compose exec tinderhaj-bot bun run register on the server. Discord keeps them, so this isn't done on start; run it again only when commands are added, removed or changed. It replaces what was there, so removed commands go too.
bun run typecheck checks the bot, and the site's checks leave bot/ out.
typecheck, lint, lint:classes, format:check, and test:e2e, which CI runs too.This project does not currently declare a license. If you plan to publish or distribute it, add an explicit license before doing so.
DISCORD_BOT_TOKENbotDISCORD_GUILD_ID and the DISCORD_ROLE_* variables. A role left empty isn't given.DISCORD_CHANNEL_NEW_SHARKS, and Discord shows it as the shark's card. A shark verified again after its owner edits it isn't posted twice. The bot needs View Channel, Send Messages and Embed Links in that channel.DISCORD_CHANNEL_REVIEW_QUEUE, with a button to the verification page. Once a moderator verifies or rejects it, the message is edited to say so and by whom; if the owner takes the shark back to edit it, or deletes it, the message is removed. Keep the channel visible to moderators only. The bot needs the same three permissions there./purgeModeration table, including ones done by hand in Discord (found in the audit log), so /history view shows everything that's happened to someone. /history remove takes out one made by mistake, and says so in the log. Nothing happens automatically: moderators decide.DISCORD_CHANNEL_LOG: joins (flagging new accounts) and leaves, name changes, boosts, edited and deleted messages, voice joins, leaves and moves, moderation, and changes to channels (including their permissions and which one was moved where), roles, members' nicknames and roles, the server's settings, emojis, stickers, invites, threads and events.