├── .env.example ├── .eslintrc.json ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .prettierignore ├── LICENSE ├── README.md ├── components.json ├── drizzle.config.ts ├── drizzle ├── migrations │ ├── 0000_past_lady_deathstrike.sql │ └── meta │ │ ├── 0000_snapshot.json │ │ └── _journal.json └── seed.ts ├── next.config.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── prettier.config.js ├── public ├── ads.txt ├── logo.png ├── og.jpg ├── sitemap-index.xml ├── web-app-manifest-192x192.png └── web-app-manifest-512x512.png ├── renovate.json ├── src ├── app │ ├── (browse) │ │ ├── browse │ │ │ ├── [category] │ │ │ │ ├── _components │ │ │ │ │ └── result-cards.tsx │ │ │ │ └── page.tsx │ │ │ ├── page.tsx │ │ │ └── sitemap.ts │ │ ├── define │ │ │ ├── [term] │ │ │ │ ├── _components │ │ │ │ │ └── result-cards.tsx │ │ │ │ ├── not-found.tsx │ │ │ │ └── page.tsx │ │ │ └── sitemap.ts │ │ ├── layout.tsx │ │ └── u │ │ │ └── [name] │ │ │ ├── _components │ │ │ └── result-cards.tsx │ │ │ └── page.tsx │ ├── (dash) │ │ ├── definitions │ │ │ ├── (mod-only) │ │ │ │ ├── layout.tsx │ │ │ │ ├── queue │ │ │ │ │ ├── _actions.ts │ │ │ │ │ ├── columns.tsx │ │ │ │ │ ├── data-table.tsx │ │ │ │ │ └── page.tsx │ │ │ │ └── reports │ │ │ │ │ ├── _actions.ts │ │ │ │ │ ├── columns.tsx │ │ │ │ │ ├── data-table.tsx │ │ │ │ │ └── page.tsx │ │ │ └── me │ │ │ │ ├── columns.tsx │ │ │ │ ├── data-table.tsx │ │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ └── navigation.tsx │ ├── (home) │ │ ├── _components │ │ │ └── home-feed.tsx │ │ └── page.tsx │ ├── _components │ │ ├── canonical.tsx │ │ ├── header.tsx │ │ └── search-bar.tsx │ ├── about │ │ └── page.tsx │ ├── api │ │ ├── [[...slugs]] │ │ │ ├── callback.ts │ │ │ ├── cron.ts │ │ │ ├── dev.ts │ │ │ ├── public.ts │ │ │ └── route.ts │ │ └── og │ │ │ └── [slug] │ │ │ ├── assets │ │ │ ├── background.jpg │ │ │ ├── geist-medium.otf │ │ │ └── geist-semibold.otf │ │ │ └── route.tsx │ ├── apple-icon.png │ ├── d │ │ └── [shortId] │ │ │ └── route.ts │ ├── favicon.ico │ ├── globals.css │ ├── icon0.svg │ ├── icon1.png │ ├── layout.tsx │ ├── login │ │ ├── _actions.ts │ │ ├── github-button.tsx │ │ └── page.tsx │ ├── manifest.json │ ├── not-found.tsx │ ├── robots.ts │ ├── sitemap.ts │ └── submit │ │ ├── _actions.ts │ │ ├── form.tsx │ │ ├── page.tsx │ │ └── schema.ts ├── components │ ├── aside │ │ ├── _actions.ts │ │ ├── client.tsx │ │ └── index.tsx │ ├── definition-card │ │ ├── _actions.ts │ │ ├── index.tsx │ │ ├── report-button.tsx │ │ ├── schema.ts │ │ ├── share-button.tsx │ │ ├── skeleton.tsx │ │ └── vote-actions.tsx │ ├── spotlight.tsx │ ├── statistics │ │ ├── index.tsx │ │ └── skeleton.tsx │ ├── theme-switcher.tsx │ ├── time.tsx │ └── ui │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── checkbox.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── link.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── skeleton.tsx │ │ ├── table.tsx │ │ └── textarea.tsx ├── env.ts ├── hooks │ └── useCopyToClipboard.ts ├── lib │ ├── action.ts │ ├── auth │ │ ├── helpers.ts │ │ └── index.ts │ ├── definitions.ts │ ├── id.ts │ ├── seo.ts │ └── utils.ts ├── middleware.ts ├── server │ └── db │ │ ├── index.ts │ │ └── schema.ts └── types.ts ├── tailwind.config.ts ├── tsconfig.json └── vercel.json /.env.example: -------------------------------------------------------------------------------- 1 | # Since the ".env" file is gitignored, you can use the ".env.example" file to 2 | # build a new ".env" file when you clone the repo. Keep this file up-to-date 3 | # when you add new variables to `.env`. 4 | 5 | # This file will be committed to version control, so make sure not to have any 6 | # secrets in it. If you are cloning this repo, create a copy of this file named 7 | # ".env" and populate it with your secrets. 8 | 9 | # When adding additional environment variables, the schema in "/src/env.ts" 10 | # should be updated accordingly. 11 | 12 | NEXT_PUBLIC_BASE_URL=http://localhost:3000 13 | 14 | TURSO_DATABASE_URL= 15 | TURSO_AUTH_TOKEN= 16 | 17 | CRON_SECRET= 18 | OG_HMAC_SECRET= 19 | 20 | GITHUB_CLIENT_ID= 21 | GITHUB_CLIENT_SECRET= 22 | 23 | UPSTASH_REDIS_REST_URL= 24 | UPSTASH_REDIS_REST_TOKEN= 25 | 26 | MEILISEARCH_MASTER_KEY= 27 | NEXT_PUBLIC_MEILISEARCH_SEARCH_KEY= 28 | NEXT_PUBLIC_MEILISEARCH_HOST= 29 | 30 | # Optional 31 | TWITTER_CONSUMER_KEY= 32 | TWITTER_CONSUMER_SECRET= 33 | TWITTER_ACCESS_TOKEN= 34 | TWITTER_ACCESS_TOKEN_SECRET= 35 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | 28 | - OS: [e.g. iOS] 29 | - Browser [e.g. chrome, safari] 30 | - Version [e.g. 22] 31 | 32 | **Smartphone (please complete the following information):** 33 | 34 | - Device: [e.g. iPhone6] 35 | - OS: [e.g. iOS8.1] 36 | - Browser [e.g. stock browser, safari] 37 | - Version [e.g. 22] 38 | 39 | **Additional context** 40 | Add any other context about the problem here. 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # debug 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | # local env files 25 | .env 26 | .env.local 27 | 28 | # vercel 29 | .vercel 30 | 31 | # typescript 32 | *.tsbuildinfo 33 | next-env.d.ts 34 | 35 | # misc 36 | .DS_Store 37 | *.pem 38 | /drizzle/definitions.json 39 | /meilisearch 40 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .next/ 2 | pnpm-lock.yaml 3 | drizzle/migrations/ 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 aelew 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📚 DevTerms 2 | 3 | A crowdsourced dictionary website for developers to look up technical terms, programming jargon, and more. DevTerms has [X (Twitter)](https://twitter.com/devtermsio) and [Bluesky](https://bsky.app/profile/devterms.io) accounts you can follow for daily developer wisdom and to stay in the loop with the latest tech lingo! 4 | 5 | Check out DevTerms in action at https://devterms.io! 6 | 7 | ## Built with 8 | 9 | - 🌎 [Vercel](https://vercel.com): Frontend cloud platform 10 | - 🌟 [Next.js](https://nextjs.org): The React framework for the web 11 | - 🔥 [Hono](https://hono.dev): Fast web framework built on Web Standards 12 | - 💨 [Tailwind CSS](https://tailwindcss.com): A utility-first CSS framework 13 | - 🎨 [shadcn/ui](https://ui.shadcn.com): Beautifully designed components 14 | - 💾 [Turso](https://turso.tech): Fully-managed SQLite database platform 15 | - 🌀 [Upstash](https://upstash.com): Serverless Redis database and rate limiting 16 | - 🔎 [Meilisearch](https://www.meilisearch.com): Lightning-fast website search engine 17 | - 🌧️ [Drizzle ORM](https://orm.drizzle.team): Lightweight, relational TypeScript ORM 18 | - 🔒 [Lucia](https://lucia-auth.com): Guide for implementing authentication from scratch 19 | 20 | ## Development 21 | 22 | Clone the project 23 | 24 | ```bash 25 | git clone https://github.com/aelew/devterms.git 26 | ``` 27 | 28 | Go to the project directory 29 | 30 | ```bash 31 | cd devterms 32 | ``` 33 | 34 | Install dependencies 35 | 36 | ```bash 37 | pnpm i 38 | ``` 39 | 40 | Set environment variables 41 | 42 | ```bash 43 | To run this project, you need to set the required environment variables. Copy `.env.example` into a new file called `.env` and fill in the values. 44 | ``` 45 | 46 | Start the local development server on http://localhost:3000 47 | 48 | ```bash 49 | pnpm dev 50 | ``` 51 | 52 | ## License 53 | 54 | [MIT](https://choosealicense.com/licenses/mit) 55 | 56 | ## Star History 57 | 58 | 59 | 60 | 61 | 62 | Star History Chart 63 | 64 | 65 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "./src/app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import type { Config } from 'drizzle-kit'; 3 | 4 | dotenv.config({ path: ['.env', '.env.local'] }); 5 | 6 | if (!process.env.TURSO_DATABASE_URL || !process.env.TURSO_AUTH_TOKEN) { 7 | throw new Error('Turso environment variables are not set'); 8 | } 9 | 10 | export default { 11 | dialect: 'turso', 12 | casing: 'snake_case', 13 | out: 'drizzle/migrations', 14 | schema: './src/server/db/schema.ts', 15 | dbCredentials: { 16 | url: process.env.TURSO_DATABASE_URL, 17 | authToken: process.env.TURSO_AUTH_TOKEN 18 | } 19 | } satisfies Config; 20 | -------------------------------------------------------------------------------- /drizzle/migrations/0000_past_lady_deathstrike.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `definitions` ( 2 | `id` text(20) PRIMARY KEY NOT NULL, 3 | `user_id` text(21) NOT NULL, 4 | `status` text DEFAULT 'pending' NOT NULL, 5 | `term` text(255) NOT NULL, 6 | `definition` text NOT NULL, 7 | `example` text NOT NULL, 8 | `upvotes` integer DEFAULT 0 NOT NULL, 9 | `downvotes` integer DEFAULT 0 NOT NULL, 10 | `created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL 11 | ); 12 | --> statement-breakpoint 13 | CREATE TABLE `reports` ( 14 | `id` text(20) PRIMARY KEY NOT NULL, 15 | `user_id` text(21) NOT NULL, 16 | `definition_id` text(20) NOT NULL, 17 | `read` integer DEFAULT false NOT NULL, 18 | `reason` text NOT NULL, 19 | `created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL 20 | ); 21 | --> statement-breakpoint 22 | CREATE TABLE `sessions` ( 23 | `id` text(255) PRIMARY KEY NOT NULL, 24 | `user_id` text(21) NOT NULL, 25 | `expires_at` integer NOT NULL 26 | ); 27 | --> statement-breakpoint 28 | CREATE TABLE `users` ( 29 | `id` text(21) PRIMARY KEY NOT NULL, 30 | `name` text(32), 31 | `role` text DEFAULT 'user' NOT NULL, 32 | `email` text(255) NOT NULL, 33 | `avatar` text(255) NOT NULL, 34 | `github_id` integer NOT NULL, 35 | `created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL 36 | ); 37 | --> statement-breakpoint 38 | CREATE TABLE `wotds` ( 39 | `id` text(21) PRIMARY KEY NOT NULL, 40 | `definition_id` text(20) NOT NULL, 41 | `created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL 42 | ); 43 | --> statement-breakpoint 44 | CREATE INDEX `definitions_status_idx` ON `definitions` (`status`);--> statement-breakpoint 45 | CREATE INDEX `definitions_term_idx` ON `definitions` (`term`);--> statement-breakpoint 46 | CREATE INDEX `definitions_upvotes_idx` ON `definitions` (`upvotes`);--> statement-breakpoint 47 | CREATE INDEX `definitions_created_at_idx` ON `definitions` (`created_at`);--> statement-breakpoint 48 | CREATE INDEX `reports_read_idx` ON `reports` (`read`);--> statement-breakpoint 49 | CREATE INDEX `reports_created_at_idx` ON `reports` (`created_at`);--> statement-breakpoint 50 | CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint 51 | CREATE UNIQUE INDEX `users_github_id_unique` ON `users` (`github_id`);--> statement-breakpoint 52 | CREATE INDEX `users_name_idx` ON `users` (`name`);--> statement-breakpoint 53 | CREATE INDEX `users_role_idx` ON `users` (`role`);--> statement-breakpoint 54 | CREATE INDEX `wotds_created_at_idx` ON `wotds` (`created_at`); -------------------------------------------------------------------------------- /drizzle/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "5", 8 | "when": 1712885037184, 9 | "tag": "0000_past_lady_deathstrike", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /drizzle/seed.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from 'fs/promises'; 2 | import { createClient } from '@libsql/client/web'; 3 | import dotenv from 'dotenv'; 4 | import { drizzle } from 'drizzle-orm/libsql'; 5 | 6 | import * as schema from '@/server/db/schema'; 7 | 8 | dotenv.config({ path: ['.env', '.env.local'] }); 9 | 10 | if (!process.env.TURSO_DATABASE_URL || !process.env.TURSO_AUTH_TOKEN) { 11 | throw new Error('Turso environment variables are not set'); 12 | } 13 | 14 | const client = createClient({ 15 | url: process.env.TURSO_DATABASE_URL, 16 | authToken: process.env.TURSO_AUTH_TOKEN 17 | }); 18 | 19 | const db = drizzle(client, { schema }); 20 | 21 | const AUTHOR_ID = 'user_SvltYFQtlIZvYJKo'; 22 | 23 | interface SeedDefinition { 24 | term: string; 25 | definition: string; 26 | example: string; 27 | } 28 | 29 | async function main() { 30 | try { 31 | const filePath = __dirname + '/definitions.json'; 32 | const fileContent = await readFile(filePath, 'utf-8'); 33 | const seedDefinitions = JSON.parse(fileContent) as SeedDefinition[]; 34 | 35 | console.log(`Seeding database with ${seedDefinitions.length} definitions`); 36 | 37 | for (let i = 0; i < seedDefinitions.length; i++) { 38 | const definition = seedDefinitions[i]!; 39 | await db.insert(schema.definitions).values({ 40 | userId: AUTHOR_ID, 41 | status: 'approved', 42 | upvotes: 1, 43 | ...definition 44 | }); 45 | console.log( 46 | `Seed progress: ${i + 1}/${seedDefinitions.length} | ${definition.term}` 47 | ); 48 | } 49 | 50 | console.log('Database seeded!'); 51 | process.exit(0); 52 | } catch (err) { 53 | console.error('Seed failed:', err); 54 | process.exit(1); 55 | } 56 | } 57 | 58 | void main(); 59 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from 'next'; 2 | 3 | import '@/env'; 4 | 5 | const nextConfig: NextConfig = { 6 | reactStrictMode: true, 7 | serverExternalPackages: ['twitter-api-v2'], 8 | eslint: { 9 | ignoreDuringBuilds: true 10 | } 11 | }; 12 | 13 | export default nextConfig; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "devterms", 3 | "author": "aelew", 4 | "license": "MIT", 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "typecheck": "tsc --noEmit", 11 | "format": "prettier --write .", 12 | "db": "drizzle-kit", 13 | "db:studio": "drizzle-kit studio", 14 | "db:migrate": "drizzle-kit migrate", 15 | "db:seed": "tsx drizzle/seed", 16 | "db:drop": "drizzle-kit drop", 17 | "db:push": "drizzle-kit push", 18 | "db:generate": "drizzle-kit generate" 19 | }, 20 | "dependencies": { 21 | "@atproto/api": "^0.13.35", 22 | "@hono/zod-openapi": "^0.17.1", 23 | "@hono/zod-validator": "^0.4.3", 24 | "@hookform/resolvers": "^3.10.0", 25 | "@icons-pack/react-simple-icons": "^10.2.0", 26 | "@libsql/client": "^0.14.0", 27 | "@meilisearch/instant-meilisearch": "^0.18.1", 28 | "@number-flow/react": "^0.3.5", 29 | "@oslojs/crypto": "^1.0.1", 30 | "@oslojs/encoding": "^1.1.0", 31 | "@radix-ui/react-avatar": "^1.1.3", 32 | "@radix-ui/react-checkbox": "^1.1.4", 33 | "@radix-ui/react-collapsible": "^1.1.3", 34 | "@radix-ui/react-dialog": "^1.1.6", 35 | "@radix-ui/react-dropdown-menu": "^2.1.6", 36 | "@radix-ui/react-icons": "^1.3.2", 37 | "@radix-ui/react-label": "^2.1.2", 38 | "@radix-ui/react-popover": "^1.1.6", 39 | "@radix-ui/react-slot": "^1.1.2", 40 | "@radix-ui/react-visually-hidden": "^1.1.2", 41 | "@scalar/hono-api-reference": "^0.5.184", 42 | "@sinclair/typebox": "^0.33.22", 43 | "@t3-oss/env-nextjs": "^0.11.1", 44 | "@tanstack/react-table": "^8.21.2", 45 | "@upstash/ratelimit": "^2.0.5", 46 | "@upstash/redis": "^1.34.6", 47 | "arctic": "^2.3.4", 48 | "class-variance-authority": "^0.7.1", 49 | "clsx": "^2.1.1", 50 | "cmdk": "^1.1.1", 51 | "drizzle-orm": "^0.34.1", 52 | "framer-motion": "^11.18.2", 53 | "geist": "^1.3.1", 54 | "hono": "^4.7.5", 55 | "lucide-react": "^0.486.0", 56 | "meilisearch": "^0.44.1", 57 | "mini-svg-data-uri": "^1.4.4", 58 | "next": "^15.2.4", 59 | "next-plausible": "^3.12.4", 60 | "next-safe-action": "^6.2.0", 61 | "next-themes": "^0.4.6", 62 | "p-debounce": "^4.0.0", 63 | "qrcode.react": "^4.2.0", 64 | "react": "^19.1.0", 65 | "react-dom": "^19.1.0", 66 | "react-hook-form": "^7.55.0", 67 | "react-share": "^5.2.2", 68 | "sonner": "^1.7.4", 69 | "tailwind-merge": "^2.6.0", 70 | "tailwindcss-animate": "^1.0.7", 71 | "ts-pattern": "^5.7.0", 72 | "twitter-api-v2": "^1.22.0", 73 | "zod": "^3.24.2" 74 | }, 75 | "devDependencies": { 76 | "@ianvs/prettier-plugin-sort-imports": "^4.4.1", 77 | "@types/node": "^20.17.28", 78 | "@types/react": "^19.0.12", 79 | "@types/react-dom": "^19.0.4", 80 | "ajv": "^8.17.1", 81 | "autoprefixer": "^10.4.21", 82 | "dotenv": "^16.4.7", 83 | "drizzle-kit": "^0.25.0", 84 | "eslint": "^8.57.1", 85 | "eslint-config-next": "^15.2.4", 86 | "instantsearch.js": "^4.78.1", 87 | "postcss": "^8.5.3", 88 | "prettier": "^3.5.3", 89 | "prettier-plugin-tailwindcss": "^0.6.11", 90 | "tailwindcss": "^3.4.17", 91 | "tsx": "^4.19.3", 92 | "typescript": "^5.8.2" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('prettier').Config & import("@ianvs/prettier-plugin-sort-imports").PluginConfig & import('prettier-plugin-tailwindcss').options} */ 2 | const config = { 3 | plugins: [ 4 | '@ianvs/prettier-plugin-sort-imports', 5 | 'prettier-plugin-tailwindcss' 6 | ], 7 | importOrder: ['', '', '^@/', '^[../]', '^[./]'], 8 | importOrderParserPlugins: ['typescript', 'jsx', 'decorators-legacy'], 9 | trailingComma: 'none', 10 | singleQuote: true 11 | }; 12 | 13 | module.exports = config; 14 | -------------------------------------------------------------------------------- /public/ads.txt: -------------------------------------------------------------------------------- 1 | google.com, pub-8987706742778087, DIRECT, f08c47fec0942fa0 -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aelew/devterms/0061b07366d79662078d0e17da47fda462f38af0/public/logo.png -------------------------------------------------------------------------------- /public/og.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aelew/devterms/0061b07366d79662078d0e17da47fda462f38af0/public/og.jpg -------------------------------------------------------------------------------- /public/sitemap-index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://devterms.io/sitemap.xml 5 | 6 | 7 | https://devterms.io/browse/sitemap/0.xml 8 | 9 | 10 | https://devterms.io/define/sitemap/0.xml 11 | 12 | 13 | -------------------------------------------------------------------------------- /public/web-app-manifest-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aelew/devterms/0061b07366d79662078d0e17da47fda462f38af0/public/web-app-manifest-192x192.png -------------------------------------------------------------------------------- /public/web-app-manifest-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aelew/devterms/0061b07366d79662078d0e17da47fda462f38af0/public/web-app-manifest-512x512.png -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended"] 4 | } 5 | -------------------------------------------------------------------------------- /src/app/(browse)/browse/[category]/_components/result-cards.tsx: -------------------------------------------------------------------------------- 1 | import { and, desc, eq, like } from 'drizzle-orm'; 2 | import { unstable_cache } from 'next/cache'; 3 | 4 | import { DefinitionCard } from '@/components/definition-card'; 5 | import { db } from '@/server/db'; 6 | import { definitions } from '@/server/db/schema'; 7 | 8 | interface CategoryResultCardsProps { 9 | category: string; 10 | } 11 | 12 | const getCategoryDefinitions = unstable_cache( 13 | (category: string) => 14 | db.query.definitions.findMany({ 15 | ...(category === 'new' 16 | ? { 17 | orderBy: desc(definitions.createdAt), 18 | where: and(eq(definitions.status, 'approved')), 19 | limit: 10 20 | } 21 | : { 22 | orderBy: desc(definitions.term), 23 | where: and( 24 | eq(definitions.status, 'approved'), 25 | like(definitions.term, `${category}%`) 26 | ) 27 | }), 28 | with: { 29 | user: { 30 | columns: { 31 | name: true 32 | } 33 | } 34 | } 35 | }), 36 | ['category_definitions'], 37 | { revalidate: 1800 } 38 | ); 39 | 40 | export async function CategoryResultCards({ 41 | category 42 | }: CategoryResultCardsProps) { 43 | const results = await getCategoryDefinitions(category); 44 | if (!results.length) { 45 | return

No definitions in this category

; 46 | } 47 | return ( 48 | <> 49 | {results.map((definition) => ( 50 | 55 | ))} 56 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/app/(browse)/browse/[category]/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from 'next/navigation'; 2 | import { Suspense } from 'react'; 3 | 4 | import { DefinitionCardSkeleton } from '@/components/definition-card/skeleton'; 5 | import { getPageMetadata } from '@/lib/seo'; 6 | import { CATEGORIES } from '@/lib/utils'; 7 | import { CategoryResultCards } from './_components/result-cards'; 8 | 9 | interface BrowseCategoryPageProps { 10 | params: Promise<{ category: string }>; 11 | } 12 | 13 | export async function generateMetadata(props: BrowseCategoryPageProps) { 14 | const params = await props.params; 15 | return getPageMetadata({ 16 | title: `Browse ${decodeURIComponent(params.category).toUpperCase()} definitions` 17 | }); 18 | } 19 | 20 | export default async function BrowseCategoryPage( 21 | props: BrowseCategoryPageProps 22 | ) { 23 | const params = await props.params; 24 | 25 | const category = decodeURIComponent(params.category); 26 | if (!CATEGORIES.includes(category)) { 27 | notFound(); 28 | } 29 | 30 | return ( 31 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | } 41 | > 42 | 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/app/(browse)/browse/page.tsx: -------------------------------------------------------------------------------- 1 | import { GeistMono } from 'geist/font/mono'; 2 | 3 | import { buttonVariants } from '@/components/ui/button'; 4 | import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 5 | import { Link } from '@/components/ui/link'; 6 | import { getPageMetadata } from '@/lib/seo'; 7 | import { CATEGORIES, cn } from '@/lib/utils'; 8 | 9 | export const metadata = getPageMetadata({ title: 'Browse definitions' }); 10 | 11 | export default function BrowsePage() { 12 | return ( 13 | 14 | 15 | 16 |

Browse definitions

17 |
18 |
19 | 25 | {CATEGORIES.map((category) => ( 26 | 35 | {category} 36 | 37 | ))} 38 | 39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/app/(browse)/browse/sitemap.ts: -------------------------------------------------------------------------------- 1 | import type { MetadataRoute } from 'next'; 2 | 3 | import { env } from '@/env'; 4 | import { CATEGORIES } from '@/lib/utils'; 5 | 6 | export function generateSitemaps() { 7 | return [{ id: 0 }]; 8 | } 9 | 10 | export default function sitemap(): MetadataRoute.Sitemap { 11 | return CATEGORIES.map((c) => `/browse/${c}`).map((path) => ({ 12 | url: env.NEXT_PUBLIC_BASE_URL + path, 13 | lastModified: new Date(), 14 | changeFrequency: 'daily', 15 | priority: 0.8 16 | })); 17 | } 18 | -------------------------------------------------------------------------------- /src/app/(browse)/define/[term]/_components/result-cards.tsx: -------------------------------------------------------------------------------- 1 | import { and, desc, eq, sql } from 'drizzle-orm'; 2 | import { unstable_cache } from 'next/cache'; 3 | import { notFound } from 'next/navigation'; 4 | import { cache } from 'react'; 5 | 6 | import { DefinitionCard } from '@/components/definition-card'; 7 | import { termToSlug } from '@/lib/utils'; 8 | import { db } from '@/server/db'; 9 | import { definitions } from '@/server/db/schema'; 10 | 11 | interface DefineResultCardsProps { 12 | term: string; 13 | } 14 | 15 | export const getDefinitions = cache((term: string) => 16 | unstable_cache( 17 | (term: string) => 18 | db.query.definitions.findMany({ 19 | orderBy: desc(definitions.upvotes), 20 | where: and( 21 | eq(definitions.status, 'approved'), 22 | eq(definitions.term, sql`${term} COLLATE NOCASE`) 23 | ), 24 | with: { 25 | user: { 26 | columns: { 27 | name: true 28 | } 29 | } 30 | } 31 | }), 32 | ['definitions'], 33 | { tags: [`definitions:${termToSlug(term)}`], revalidate: 1800 } 34 | )(term) 35 | ); 36 | 37 | export async function DefineResultCards({ term }: DefineResultCardsProps) { 38 | const results = await getDefinitions(term); 39 | if (!results.length) { 40 | notFound(); 41 | } 42 | return ( 43 | <> 44 | {results.map((definition) => ( 45 | 50 | ))} 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/app/(browse)/define/[term]/not-found.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useParams } from 'next/navigation'; 4 | 5 | import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 6 | import { Link } from '@/components/ui/link'; 7 | import { slugToTerm, termToSlug } from '@/lib/utils'; 8 | 9 | export default function NotFound() { 10 | const params = useParams(); 11 | const term = slugToTerm(params.term as string); 12 | return ( 13 | 14 | 15 | {term} 16 | 17 | 18 |

19 | There are no definitions for {term} yet. 20 |

21 |

22 | Know what it means?{' '} 23 | 27 | Submit a definition here! 28 | 29 |

30 |
31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/app/(browse)/define/[term]/page.tsx: -------------------------------------------------------------------------------- 1 | import { createHmac } from 'crypto'; 2 | import { Suspense } from 'react'; 3 | 4 | import { DefinitionCardSkeleton } from '@/components/definition-card/skeleton'; 5 | import { env } from '@/env'; 6 | import { getOpenGraphImageUrl } from '@/lib/definitions'; 7 | import { getPageMetadata } from '@/lib/seo'; 8 | import { slugToTerm } from '@/lib/utils'; 9 | import { DefineResultCards, getDefinitions } from './_components/result-cards'; 10 | 11 | interface DefinitionPageProps { 12 | params: Promise<{ term: string }>; 13 | } 14 | 15 | export async function generateMetadata(props: DefinitionPageProps) { 16 | const params = await props.params; 17 | const term = slugToTerm(params.term); 18 | 19 | const results = await getDefinitions(term); 20 | if (!results.length) { 21 | return getPageMetadata({ title: `What is ${term}?` }); 22 | } 23 | 24 | const firstResult = results[0]!; 25 | const ogUrl = getOpenGraphImageUrl(params.term); 26 | 27 | return getPageMetadata({ 28 | title: `What is ${firstResult.term}?`, 29 | description: firstResult.definition, 30 | twitter: { images: [ogUrl] }, 31 | openGraph: { 32 | images: [ 33 | { 34 | url: ogUrl, 35 | width: 1200, 36 | height: 630, 37 | alt: firstResult.term 38 | } 39 | ] 40 | } 41 | }); 42 | } 43 | 44 | export default async function DefinitionPage(props: DefinitionPageProps) { 45 | const params = await props.params; 46 | const term = slugToTerm(params.term); 47 | return ( 48 | }> 49 | 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/app/(browse)/define/sitemap.ts: -------------------------------------------------------------------------------- 1 | import { eq } from 'drizzle-orm'; 2 | import type { MetadataRoute } from 'next'; 3 | 4 | import { env } from '@/env'; 5 | import { termToSlug } from '@/lib/utils'; 6 | import { db } from '@/server/db'; 7 | import { definitions } from '@/server/db/schema'; 8 | 9 | export function generateSitemaps() { 10 | return [{ id: 0 }]; 11 | } 12 | 13 | export default async function sitemap(): Promise { 14 | const allDefinitions = await db.query.definitions.findMany({ 15 | where: eq(definitions.status, 'approved'), 16 | columns: { term: true } 17 | }); 18 | return allDefinitions.map((def) => ({ 19 | url: `${env.NEXT_PUBLIC_BASE_URL}/define/${termToSlug(def.term)}`, 20 | lastModified: new Date(), 21 | changeFrequency: 'daily', 22 | priority: 0.7 23 | })); 24 | } 25 | -------------------------------------------------------------------------------- /src/app/(browse)/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from 'react'; 2 | 3 | import { Aside } from '@/components/aside'; 4 | 5 | export default function BrowseLayout({ children }: PropsWithChildren) { 6 | return ( 7 |
8 |
{children}
9 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/app/(browse)/u/[name]/_components/result-cards.tsx: -------------------------------------------------------------------------------- 1 | import { SiGithub } from '@icons-pack/react-simple-icons'; 2 | import { desc, eq, sql } from 'drizzle-orm'; 3 | import { unstable_cache } from 'next/cache'; 4 | import Image from 'next/image'; 5 | import { notFound } from 'next/navigation'; 6 | import { cache } from 'react'; 7 | 8 | import { DefinitionCard } from '@/components/definition-card'; 9 | import { Time } from '@/components/time'; 10 | import { Badge } from '@/components/ui/badge'; 11 | import { Card, CardDescription } from '@/components/ui/card'; 12 | import { Link } from '@/components/ui/link'; 13 | import { db } from '@/server/db'; 14 | import { definitions, users } from '@/server/db/schema'; 15 | 16 | interface UserResultCardsProps { 17 | name: string; 18 | } 19 | 20 | export const getUser = cache( 21 | unstable_cache( 22 | (name: string) => 23 | db.query.users.findFirst({ 24 | where: eq(users.name, sql`${name} COLLATE NOCASE`), 25 | columns: { 26 | name: true, 27 | role: true, 28 | avatar: true, 29 | createdAt: true 30 | }, 31 | with: { 32 | definitions: { 33 | where: eq(definitions.status, 'approved'), 34 | orderBy: desc(definitions.createdAt), 35 | limit: 5 36 | } 37 | } 38 | }), 39 | ['user_definitions'], 40 | { revalidate: 1800 } 41 | ) 42 | ); 43 | 44 | export async function UserResultCards({ name }: UserResultCardsProps) { 45 | const user = await getUser(name); 46 | if (!user) { 47 | notFound(); 48 | } 49 | return ( 50 | <> 51 | 52 |
53 | {`Avatar 61 |
62 |
63 |
64 |

65 | {user.name} 66 |

67 | {user.role} 68 |
69 | 70 | User since{' '} 71 | 72 | 74 | 75 |
76 | 81 | 82 | View on GitHub 83 | 84 |
85 |
86 |
87 | {user.definitions.map((definition) => ( 88 | 95 | ))} 96 | 97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /src/app/(browse)/u/[name]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | 3 | import { DefinitionCardSkeleton } from '@/components/definition-card/skeleton'; 4 | import { getPageMetadata } from '@/lib/seo'; 5 | import { getUser, UserResultCards } from './_components/result-cards'; 6 | 7 | interface ProfilePageProps { 8 | params: Promise<{ name: string }>; 9 | } 10 | 11 | export async function generateMetadata(props: ProfilePageProps) { 12 | const params = await props.params; 13 | const user = await getUser(params.name); 14 | if (!user) { 15 | return getPageMetadata({ 16 | title: `@${params.name}'s Profile` 17 | }); 18 | } 19 | return getPageMetadata({ 20 | title: `@${user.name}'s Profile`, 21 | description: `Check out ${user.name}'s definitions on DevTerms!` 22 | }); 23 | } 24 | 25 | export default async function ProfilePage(props: ProfilePageProps) { 26 | const params = await props.params; 27 | return ( 28 | 31 | 32 | 33 | 34 | } 35 | > 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/app/(dash)/definitions/(mod-only)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { notFound, redirect } from 'next/navigation'; 2 | import type { PropsWithChildren } from 'react'; 3 | 4 | import { getCurrentSession } from '@/lib/auth'; 5 | 6 | export default async function ModOnlyDashboardLayout({ 7 | children 8 | }: PropsWithChildren) { 9 | const { user } = await getCurrentSession(); 10 | if (!user) { 11 | redirect('/login'); 12 | } 13 | if (user.role !== 'moderator' && user.role !== 'owner') { 14 | notFound(); 15 | } 16 | return <>{children}; 17 | } 18 | -------------------------------------------------------------------------------- /src/app/(dash)/definitions/(mod-only)/queue/_actions.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { eq } from 'drizzle-orm'; 4 | import { z } from 'zod'; 5 | 6 | import { moderatorAction } from '@/lib/action'; 7 | import { db } from '@/server/db'; 8 | import { definitions } from '@/server/db/schema'; 9 | 10 | export const updatePendingDefinition = moderatorAction( 11 | z.object({ 12 | definitionId: z.string(), 13 | action: z.enum(['approve', 'reject', 'delete']) 14 | }), 15 | async ({ definitionId, action }) => { 16 | if (action === 'delete') { 17 | await db.delete(definitions).where(eq(definitions.id, definitionId)); 18 | } else { 19 | await db 20 | .update(definitions) 21 | .set({ status: action === 'approve' ? 'approved' : 'rejected' }) 22 | .where(eq(definitions.id, definitionId)); 23 | } 24 | } 25 | ); 26 | -------------------------------------------------------------------------------- /src/app/(dash)/definitions/(mod-only)/queue/columns.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import type { ColumnDef } from '@tanstack/react-table'; 4 | import { ArrowUpDownIcon, CheckIcon, TrashIcon, XIcon } from 'lucide-react'; 5 | import { toast } from 'sonner'; 6 | 7 | import { Time } from '@/components/time'; 8 | import { Button } from '@/components/ui/button'; 9 | import { Checkbox } from '@/components/ui/checkbox'; 10 | import type { Timestamp } from '@/types'; 11 | import { updatePendingDefinition } from './_actions'; 12 | 13 | export type Definition = { 14 | id: string; 15 | term: string; 16 | definition: string; 17 | example: string; 18 | createdAt: Timestamp; 19 | }; 20 | 21 | export const columns: ColumnDef[] = [ 22 | { 23 | id: 'select', 24 | header: ({ table }) => ( 25 | table.toggleAllPageRowsSelected(!!value)} 27 | aria-label="Select all" 28 | className="mr-2" 29 | checked={ 30 | table.getIsAllPageRowsSelected() || 31 | (table.getIsSomePageRowsSelected() && 'indeterminate') 32 | } 33 | /> 34 | ), 35 | cell: ({ row }) => ( 36 | row.toggleSelected(!!value)} 39 | aria-label="Select row" 40 | /> 41 | ), 42 | enableSorting: false, 43 | enableHiding: false 44 | }, 45 | { 46 | header: 'Term', 47 | accessorKey: 'term' 48 | }, 49 | { 50 | header: 'Definition', 51 | accessorKey: 'definition' 52 | }, 53 | { 54 | header: 'Example', 55 | accessorKey: 'example' 56 | }, 57 | { 58 | accessorKey: 'createdAt', 59 | sortingFn: (a, b) => 60 | new Date(b.original.createdAt).getTime() - 61 | new Date(a.original.createdAt).getTime(), 62 | header: ({ column }) => { 63 | return ( 64 | 72 | ); 73 | }, 74 | cell: ({ getValue }) => ( 75 |
76 |
78 | ) 79 | }, 80 | { 81 | id: 'actions', 82 | header: 'Actions', 83 | cell: ({ row, table }) => ( 84 |
85 | 100 | 116 | 132 |
133 | ) 134 | } 135 | ]; 136 | -------------------------------------------------------------------------------- /src/app/(dash)/definitions/(mod-only)/queue/page.tsx: -------------------------------------------------------------------------------- 1 | import { eq } from 'drizzle-orm'; 2 | 3 | import { getPageMetadata } from '@/lib/seo'; 4 | import { db } from '@/server/db'; 5 | import { definitions } from '@/server/db/schema'; 6 | import { columns } from './columns'; 7 | import { DataTable } from './data-table'; 8 | 9 | export const metadata = getPageMetadata({ title: 'Pending definitions' }); 10 | 11 | export default async function PendingDefinitionsPage() { 12 | const data = await db.query.definitions.findMany({ 13 | where: eq(definitions.status, 'pending'), 14 | columns: { 15 | id: true, 16 | term: true, 17 | definition: true, 18 | example: true, 19 | createdAt: true 20 | } 21 | }); 22 | return ; 23 | } 24 | -------------------------------------------------------------------------------- /src/app/(dash)/definitions/(mod-only)/reports/_actions.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { eq } from 'drizzle-orm'; 4 | import { z } from 'zod'; 5 | 6 | import { moderatorAction } from '@/lib/action'; 7 | import { db } from '@/server/db'; 8 | import { definitions, reports } from '@/server/db/schema'; 9 | 10 | export const acknowledgeReport = moderatorAction( 11 | z.object({ reportId: z.string() }), 12 | async ({ reportId }) => { 13 | await db 14 | .update(reports) 15 | .set({ read: true }) 16 | .where(eq(reports.id, reportId)); 17 | } 18 | ); 19 | -------------------------------------------------------------------------------- /src/app/(dash)/definitions/(mod-only)/reports/columns.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import type { ColumnDef } from '@tanstack/react-table'; 4 | import { ArrowUpDownIcon, CheckIcon, XIcon } from 'lucide-react'; 5 | import { toast } from 'sonner'; 6 | 7 | import { Time } from '@/components/time'; 8 | import { Button } from '@/components/ui/button'; 9 | import { Checkbox } from '@/components/ui/checkbox'; 10 | import type { Timestamp } from '@/types'; 11 | import { acknowledgeReport } from './_actions'; 12 | 13 | export type Report = { 14 | id: string; 15 | reason: string; 16 | user: { 17 | name: string | null; 18 | }; 19 | definition: { 20 | term: string; 21 | definition: string; 22 | example: string; 23 | }; 24 | createdAt: Timestamp; 25 | }; 26 | 27 | export const columns: ColumnDef[] = [ 28 | { 29 | id: 'select', 30 | header: ({ table }) => ( 31 | table.toggleAllPageRowsSelected(!!value)} 33 | aria-label="Select all" 34 | className="mr-2" 35 | checked={ 36 | table.getIsAllPageRowsSelected() || 37 | (table.getIsSomePageRowsSelected() && 'indeterminate') 38 | } 39 | /> 40 | ), 41 | cell: ({ row }) => ( 42 | row.toggleSelected(!!value)} 45 | aria-label="Select row" 46 | /> 47 | ), 48 | enableSorting: false, 49 | enableHiding: false 50 | }, 51 | { 52 | header: 'User', 53 | cell: ({ row }) => row.original.user.name ?? 'Deleted User' 54 | }, 55 | { 56 | header: 'Term', 57 | accessorFn: (row) => row.definition?.term 58 | }, 59 | { 60 | header: 'Definition', 61 | accessorFn: (row) => row.definition?.definition 62 | }, 63 | { 64 | header: 'Example', 65 | accessorFn: (row) => row.definition?.example 66 | }, 67 | { 68 | header: 'Reason', 69 | accessorKey: 'reason' 70 | }, 71 | { 72 | accessorKey: 'createdAt', 73 | sortingFn: (a, b) => 74 | new Date(b.original.createdAt).getTime() - 75 | new Date(a.original.createdAt).getTime(), 76 | header: ({ column }) => { 77 | return ( 78 | 86 | ); 87 | }, 88 | cell: ({ getValue }) => ( 89 |
90 |
92 | ) 93 | }, 94 | { 95 | id: 'actions', 96 | header: 'Actions', 97 | cell: ({ row, table }) => ( 98 |
99 | 111 |
112 | ) 113 | } 114 | ]; 115 | -------------------------------------------------------------------------------- /src/app/(dash)/definitions/(mod-only)/reports/data-table.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | flexRender, 5 | getCoreRowModel, 6 | getSortedRowModel, 7 | useReactTable, 8 | type ColumnDef, 9 | type SortingState 10 | } from '@tanstack/react-table'; 11 | import { CheckIcon, CircleEllipsisIcon } from 'lucide-react'; 12 | import { useState } from 'react'; 13 | import { toast } from 'sonner'; 14 | 15 | import { Button } from '@/components/ui/button'; 16 | import { 17 | DropdownMenu, 18 | DropdownMenuContent, 19 | DropdownMenuItem, 20 | DropdownMenuTrigger 21 | } from '@/components/ui/dropdown-menu'; 22 | import { 23 | Table, 24 | TableBody, 25 | TableCell, 26 | TableHead, 27 | TableHeader, 28 | TableRow 29 | } from '@/components/ui/table'; 30 | import { acknowledgeReport } from './_actions'; 31 | import type { Report } from './columns'; 32 | 33 | interface DataTableProps { 34 | columns: ColumnDef[]; 35 | data: TData[]; 36 | } 37 | 38 | export function DataTable({ 39 | columns, 40 | data 41 | }: DataTableProps) { 42 | const [hiddenRowIndices, setHiddenRowIndices] = useState([]); 43 | const [sorting, setSorting] = useState([]); 44 | const [rowSelection, setRowSelection] = useState({}); 45 | const table = useReactTable({ 46 | data, 47 | columns, 48 | state: { sorting, rowSelection }, 49 | onSortingChange: setSorting, 50 | onRowSelectionChange: setRowSelection, 51 | getCoreRowModel: getCoreRowModel(), 52 | getSortedRowModel: getSortedRowModel(), 53 | meta: { 54 | removeRow: (index: number) => { 55 | setHiddenRowIndices((prev) => [...prev, index]); 56 | } 57 | } 58 | }); 59 | return ( 60 |
61 | 62 | 63 | 67 | 68 | 69 | { 72 | table 73 | .getFilteredSelectedRowModel() 74 | .rows.filter((row) => !hiddenRowIndices.includes(row.index)) 75 | .forEach((row) => { 76 | acknowledgeReport({ 77 | reportId: (row.original as Report).id 78 | }); 79 | // @ts-expect-error removeRow 80 | table.options.meta.removeRow(row.index); 81 | }); 82 | toast.success('Reports acknowledged!'); 83 | }} 84 | > 85 | Acknowledge selected 86 | 87 | 88 | 89 |
90 | 91 | 92 | {table.getHeaderGroups().map((headerGroup) => ( 93 | 94 | {headerGroup.headers.map((header) => { 95 | return ( 96 | 97 | {header.isPlaceholder 98 | ? null 99 | : (flexRender( 100 | header.column.columnDef.header, 101 | header.getContext() 102 | ) as React.ReactNode)} 103 | 104 | ); 105 | })} 106 | 107 | ))} 108 | 109 | 110 | {table.getRowModel().rows?.length ? ( 111 | table 112 | .getRowModel() 113 | .rows.filter((_, i) => !hiddenRowIndices.includes(i)) 114 | .map((row) => ( 115 | 120 | {row.getVisibleCells().map((cell) => ( 121 | 122 | { 123 | flexRender( 124 | cell.column.columnDef.cell, 125 | cell.getContext() 126 | ) as React.ReactNode 127 | } 128 | 129 | ))} 130 | 131 | )) 132 | ) : ( 133 | 134 | 138 | No data to display. 139 | 140 | 141 | )} 142 | 143 |
144 |
145 |
146 | { 147 | table 148 | .getFilteredSelectedRowModel() 149 | .rows.filter((row) => !hiddenRowIndices.includes(row.index)).length 150 | }{' '} 151 | of{' '} 152 | { 153 | table 154 | .getFilteredRowModel() 155 | .rows.filter((row) => !hiddenRowIndices.includes(row.index)).length 156 | }{' '} 157 | row(s) selected. 158 |
159 |
160 | ); 161 | } 162 | -------------------------------------------------------------------------------- /src/app/(dash)/definitions/(mod-only)/reports/page.tsx: -------------------------------------------------------------------------------- 1 | import { eq } from 'drizzle-orm'; 2 | 3 | import { getPageMetadata } from '@/lib/seo'; 4 | import { db } from '@/server/db'; 5 | import { reports } from '@/server/db/schema'; 6 | import { columns } from './columns'; 7 | import { DataTable } from './data-table'; 8 | 9 | export const metadata = getPageMetadata({ title: 'Reported definitions' }); 10 | 11 | export default async function ReportedDefinitionsPage() { 12 | const data = await db.query.reports.findMany({ 13 | where: eq(reports.read, false), 14 | columns: { 15 | id: true, 16 | reason: true, 17 | createdAt: true 18 | }, 19 | with: { 20 | user: { 21 | columns: { 22 | name: true 23 | } 24 | }, 25 | definition: { 26 | columns: { 27 | term: true, 28 | definition: true, 29 | example: true 30 | } 31 | } 32 | } 33 | }); 34 | return ; 35 | } 36 | -------------------------------------------------------------------------------- /src/app/(dash)/definitions/me/columns.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import type { ColumnDef } from '@tanstack/react-table'; 4 | import { ArrowUpDownIcon } from 'lucide-react'; 5 | import { match } from 'ts-pattern'; 6 | 7 | import { Time } from '@/components/time'; 8 | import { Badge } from '@/components/ui/badge'; 9 | import { Button } from '@/components/ui/button'; 10 | import { Link } from '@/components/ui/link'; 11 | import { termToSlug } from '@/lib/utils'; 12 | import type { Timestamp } from '@/types'; 13 | 14 | export type Definition = { 15 | id: string; 16 | status: 'pending' | 'approved' | 'rejected'; 17 | term: string; 18 | upvotes: number; 19 | downvotes: number; 20 | createdAt: Timestamp; 21 | }; 22 | 23 | export const columns: ColumnDef[] = [ 24 | { 25 | header: 'Term', 26 | cell: ({ row }) => { 27 | return ( 28 | 32 | {row.original.term} 33 | 34 | ); 35 | } 36 | }, 37 | { 38 | header: 'Status', 39 | cell: ({ row }) => { 40 | const status = row.original.status; 41 | return ( 42 | 'success' as const) 45 | .with('pending', () => 'secondary' as const) 46 | .with('rejected', () => 'destructive' as const) 47 | .exhaustive()} 48 | > 49 | {status} 50 | 51 | ); 52 | } 53 | }, 54 | { 55 | header: 'Upvotes', 56 | accessorKey: 'upvotes' 57 | }, 58 | { 59 | header: 'Downvotes', 60 | accessorKey: 'downvotes' 61 | }, 62 | { 63 | accessorKey: 'createdAt', 64 | sortingFn: (a, b) => 65 | new Date(b.original.createdAt).getTime() - 66 | new Date(a.original.createdAt).getTime(), 67 | header: ({ column }) => { 68 | return ( 69 | 77 | ); 78 | }, 79 | cell: ({ getValue }) => { 80 | return ( 81 |
82 |
84 | ); 85 | } 86 | } 87 | ]; 88 | -------------------------------------------------------------------------------- /src/app/(dash)/definitions/me/data-table.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | flexRender, 5 | getCoreRowModel, 6 | getSortedRowModel, 7 | useReactTable, 8 | type ColumnDef, 9 | type SortingState 10 | } from '@tanstack/react-table'; 11 | import { useState } from 'react'; 12 | 13 | import { 14 | Table, 15 | TableBody, 16 | TableCell, 17 | TableHead, 18 | TableHeader, 19 | TableRow 20 | } from '@/components/ui/table'; 21 | 22 | interface DataTableProps { 23 | columns: ColumnDef[]; 24 | data: TData[]; 25 | } 26 | 27 | export function DataTable({ 28 | columns, 29 | data 30 | }: DataTableProps) { 31 | const [sorting, setSorting] = useState([]); 32 | const table = useReactTable({ 33 | data, 34 | columns, 35 | state: { sorting }, 36 | onSortingChange: setSorting, 37 | getCoreRowModel: getCoreRowModel(), 38 | getSortedRowModel: getSortedRowModel() 39 | }); 40 | return ( 41 |
42 | 43 | 44 | {table.getHeaderGroups().map((headerGroup) => ( 45 | 46 | {headerGroup.headers.map((header) => { 47 | return ( 48 | 49 | {header.isPlaceholder 50 | ? null 51 | : (flexRender( 52 | header.column.columnDef.header, 53 | header.getContext() 54 | ) as React.ReactNode)} 55 | 56 | ); 57 | })} 58 | 59 | ))} 60 | 61 | 62 | {table.getRowModel().rows?.length ? ( 63 | table.getRowModel().rows.map((row) => ( 64 | 68 | {row.getVisibleCells().map((cell) => ( 69 | 70 | { 71 | flexRender( 72 | cell.column.columnDef.cell, 73 | cell.getContext() 74 | ) as React.ReactNode 75 | } 76 | 77 | ))} 78 | 79 | )) 80 | ) : ( 81 | 82 | 83 | No data to display. 84 | 85 | 86 | )} 87 | 88 |
89 |
90 | ); 91 | } 92 | -------------------------------------------------------------------------------- /src/app/(dash)/definitions/me/page.tsx: -------------------------------------------------------------------------------- 1 | import { eq } from 'drizzle-orm'; 2 | import { notFound } from 'next/navigation'; 3 | 4 | import { getCurrentSession } from '@/lib/auth'; 5 | import { getPageMetadata } from '@/lib/seo'; 6 | import { db } from '@/server/db'; 7 | import { definitions } from '@/server/db/schema'; 8 | import { columns } from './columns'; 9 | import { DataTable } from './data-table'; 10 | 11 | export const metadata = getPageMetadata({ title: 'My definitions' }); 12 | 13 | export default async function MyDefinitionsPage() { 14 | const { user } = await getCurrentSession(); 15 | if (!user) { 16 | notFound(); 17 | } 18 | const data = await db.query.definitions.findMany({ 19 | where: eq(definitions.userId, user.id), 20 | columns: { 21 | id: true, 22 | status: true, 23 | term: true, 24 | upvotes: true, 25 | downvotes: true, 26 | createdAt: true 27 | } 28 | }); 29 | return ; 30 | } 31 | -------------------------------------------------------------------------------- /src/app/(dash)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | import type { PropsWithChildren } from 'react'; 3 | 4 | import { getCurrentSession } from '@/lib/auth'; 5 | import { DashboardNavigation } from './navigation'; 6 | 7 | export default async function DashboardLayout({ children }: PropsWithChildren) { 8 | const { user } = await getCurrentSession(); 9 | if (!user) { 10 | redirect('/login'); 11 | } 12 | const isModerator = user.role === 'moderator' || user.role === 'owner'; 13 | return ( 14 |
15 | 16 |
{children}
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/app/(dash)/navigation.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { BookMarkedIcon, ClockIcon, FlagIcon } from 'lucide-react'; 4 | import { usePathname } from 'next/navigation'; 5 | 6 | import { buttonVariants } from '@/components/ui/button'; 7 | import { Link } from '@/components/ui/link'; 8 | import { cn } from '@/lib/utils'; 9 | 10 | interface DashboardNavigationProps { 11 | isModerator: boolean; 12 | } 13 | 14 | export function DashboardNavigation({ isModerator }: DashboardNavigationProps) { 15 | const links = [ 16 | { 17 | icon: BookMarkedIcon, 18 | color: 'text-green-600', 19 | label: 'My definitions', 20 | href: '/definitions/me' 21 | }, 22 | ...(isModerator 23 | ? [ 24 | { 25 | icon: ClockIcon, 26 | color: 'text-amber-600', 27 | label: 'Pending definitions', 28 | href: '/definitions/queue' 29 | }, 30 | { 31 | icon: FlagIcon, 32 | color: 'text-destructive', 33 | label: 'Reported definitions', 34 | href: '/definitions/reports' 35 | } 36 | ] 37 | : []) 38 | ]; 39 | const pathname = usePathname(); 40 | return ( 41 |
42 |
43 | {links.map((link) => ( 44 | 52 | {link.label} 53 | 54 | ))} 55 |
56 |
57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/app/(home)/_components/home-feed.tsx: -------------------------------------------------------------------------------- 1 | import { count, desc, eq } from 'drizzle-orm'; 2 | import type { SQLiteSelect } from 'drizzle-orm/sqlite-core'; 3 | import { unstable_cache } from 'next/cache'; 4 | 5 | import { DefinitionCard } from '@/components/definition-card'; 6 | import { Card, CardHeader, CardTitle } from '@/components/ui/card'; 7 | import { 8 | Pagination, 9 | PaginationContent, 10 | PaginationEllipsis, 11 | PaginationItem, 12 | PaginationLink, 13 | PaginationNext, 14 | PaginationPrevious 15 | } from '@/components/ui/pagination'; 16 | import { db } from '@/server/db'; 17 | import { definitions, users, wotds } from '@/server/db/schema'; 18 | 19 | interface HomeFeedProps { 20 | page: number; 21 | } 22 | 23 | const PAGE_SIZE = 5; 24 | 25 | function withPagination(qb: T, pageIndex: number) { 26 | return qb.limit(PAGE_SIZE).offset(pageIndex * PAGE_SIZE); 27 | } 28 | 29 | const getHomeFeed = unstable_cache( 30 | (page: number) => { 31 | const query = db 32 | .select({ 33 | definition: definitions, 34 | user: { name: users.name } 35 | }) 36 | .from(wotds) 37 | .orderBy(desc(wotds.createdAt)) 38 | .leftJoin(definitions, eq(definitions.id, wotds.definitionId)) 39 | .leftJoin(users, eq(users.id, definitions.userId)); 40 | const dynamicQuery = query.$dynamic(); 41 | return withPagination(dynamicQuery, page - 1); 42 | }, 43 | ['home_feed'], 44 | { tags: ['home_feed'], revalidate: 1800 } 45 | ); 46 | 47 | const getTotalPages = unstable_cache( 48 | async () => { 49 | const result = await db.select({ rows: count() }).from(wotds); 50 | const wotdCount = result[0]?.rows ?? 0; 51 | return Math.ceil(wotdCount / PAGE_SIZE); 52 | }, 53 | ['total_pages'], 54 | { tags: ['total_pages'], revalidate: 1800 } 55 | ); 56 | 57 | export async function HomeFeed({ page }: HomeFeedProps) { 58 | const [homeFeed, totalPages] = await Promise.all([ 59 | getHomeFeed(page), 60 | getTotalPages() 61 | ]); 62 | return ( 63 | <> 64 | {homeFeed.length ? ( 65 | homeFeed.map((wotd, i) => { 66 | if (!wotd.definition) { 67 | return null; 68 | } 69 | const definition = { 70 | ...wotd.definition, 71 | user: { name: wotd.user?.name ?? null } 72 | }; 73 | 74 | if (i === 0 && page === 1) { 75 | return ( 76 | 82 | ); 83 | } 84 | return ( 85 | 86 | ); 87 | }) 88 | ) : ( 89 | 90 | 91 | 92 | There's nothing here yet. Check back later! 93 | 94 | 95 | 96 | )} 97 | 98 | 99 | {page > 1 && ( 100 | 101 | 102 | 103 | )} 104 | {[...Array(Math.min(totalPages, 4))].map((_, i) => ( 105 | 106 | {i + 1} 107 | 108 | ))} 109 | 110 | 111 | 112 | {page < totalPages && ( 113 | 114 | 115 | 116 | )} 117 | 118 | 119 | 120 | ); 121 | } 122 | -------------------------------------------------------------------------------- /src/app/(home)/page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | 3 | import { Aside } from '@/components/aside'; 4 | import { DefinitionCardSkeleton } from '@/components/definition-card/skeleton'; 5 | import { getPageMetadata } from '@/lib/seo'; 6 | import { HomeFeed } from './_components/home-feed'; 7 | 8 | interface HomePageProps { 9 | searchParams: Promise<{ page?: string }>; 10 | } 11 | 12 | export const metadata = getPageMetadata({ title: 'The Developer Dictionary' }); 13 | 14 | export default async function HomePage(props: HomePageProps) { 15 | const searchParams = await props.searchParams; 16 | const page = Number(searchParams.page) > 1 ? Number(searchParams.page) : 1; 17 | 18 | return ( 19 |
20 |
21 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | } 31 | > 32 | 33 | 34 |
35 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/app/_components/canonical.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { usePathname } from 'next/navigation'; 4 | import { useEffect, useState } from 'react'; 5 | 6 | import { env } from '@/env'; 7 | 8 | export function Canonical() { 9 | const pathname = usePathname(); 10 | const [url, setURL] = useState(env.NEXT_PUBLIC_BASE_URL + pathname); 11 | 12 | useEffect(() => { 13 | setURL(env.NEXT_PUBLIC_BASE_URL + pathname); 14 | }, [pathname]); 15 | 16 | return ( 17 | <> 18 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/app/_components/header.tsx: -------------------------------------------------------------------------------- 1 | import { SiGithub } from '@icons-pack/react-simple-icons'; 2 | import { 3 | ArrowRightIcon, 4 | LayoutDashboardIcon, 5 | LogOutIcon, 6 | UserIcon 7 | } from 'lucide-react'; 8 | import Image from 'next/image'; 9 | 10 | import { ThemeSwitcher } from '@/components/theme-switcher'; 11 | import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; 12 | import { buttonVariants } from '@/components/ui/button'; 13 | import { 14 | DropdownMenu, 15 | DropdownMenuContent, 16 | DropdownMenuItem, 17 | DropdownMenuLabel, 18 | DropdownMenuSeparator, 19 | DropdownMenuTrigger 20 | } from '@/components/ui/dropdown-menu'; 21 | import { Link } from '@/components/ui/link'; 22 | import { 23 | deleteSessionCookie, 24 | getCurrentSession, 25 | invalidateSession 26 | } from '@/lib/auth'; 27 | import { SearchBar } from './search-bar'; 28 | 29 | export async function Header() { 30 | const { user } = await getCurrentSession(); 31 | 32 | const navLinkClassName = 33 | 'block transition-color-transform hover:text-muted-foreground/80 active:scale-95'; 34 | 35 | async function signOut() { 36 | 'use server'; 37 | 38 | const { user, session } = await getCurrentSession(); 39 | if (!user) { 40 | throw new Error('Authentication required'); 41 | } 42 | 43 | await invalidateSession(session.id); 44 | await deleteSessionCookie(); 45 | } 46 | 47 | return ( 48 |
49 |
50 |
51 | 55 | Logo 56 | 57 | DevTerms 58 | 59 | 60 |
    61 | {[ 62 | { label: 'Home', href: '/' }, 63 | { label: 'Browse', href: '/browse' }, 64 | { label: 'Submit', href: '/submit' } 65 | ].map((item) => ( 66 |
  • 67 | 68 | {item.label} 69 | 70 |
  • 71 | ))} 72 |
  • 73 | 79 | API 80 | 81 |
  • 82 |
83 |
84 |
85 | 91 | 92 | 93 | 94 | {user ? ( 95 | 96 | 100 | 101 | 102 | 103 | {user.name.slice(0, 2).toUpperCase()} 104 | 105 | 106 | 107 | 108 | 109 |

{user.name}

110 |

111 | {user.email} 112 |

113 |
114 | 115 | 116 | 117 | 118 | View profile 119 | 120 | 121 | 122 | 123 | 124 | Dashboard 125 | 126 | 127 |
128 | 129 | 132 | 133 |
134 |
135 |
136 | ) : ( 137 | 141 | Sign in 142 | 143 | 144 | )} 145 |
146 |
147 | 148 |
149 | ); 150 | } 151 | -------------------------------------------------------------------------------- /src/app/_components/search-bar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { instantMeiliSearch } from '@meilisearch/instant-meilisearch'; 4 | import { Command as CommandPrimitive } from 'cmdk'; 5 | import { CheckIcon, SearchIcon } from 'lucide-react'; 6 | import type { SearchResponse } from 'meilisearch'; 7 | import { usePlausible } from 'next-plausible'; 8 | import { usePathname, useRouter } from 'next/navigation'; 9 | import pDebounce from 'p-debounce'; 10 | import { 11 | useCallback, 12 | useEffect, 13 | useRef, 14 | useState, 15 | type KeyboardEvent 16 | } from 'react'; 17 | 18 | import { 19 | CommandGroup, 20 | CommandItem, 21 | CommandList 22 | } from '@/components/ui/command'; 23 | import { Input } from '@/components/ui/input'; 24 | import { env } from '@/env'; 25 | import { termToSlug } from '@/lib/utils'; 26 | import type { Events } from '@/types'; 27 | 28 | const { searchClient } = instantMeiliSearch( 29 | env.NEXT_PUBLIC_MEILISEARCH_HOST, 30 | env.NEXT_PUBLIC_MEILISEARCH_SEARCH_KEY, 31 | { 32 | placeholderSearch: false, 33 | meiliSearchParams: { 34 | attributesToSearchOn: ['term', 'definition', 'example'] 35 | } 36 | } 37 | ); 38 | 39 | type Hit = { 40 | id: string; 41 | term: string; 42 | definition: string; 43 | }; 44 | 45 | export function SearchBar() { 46 | const [selectedHit, setSelectedHit] = useState(null); 47 | const [inputValue, setInputValue] = useState(''); 48 | const [open, setOpen] = useState(false); 49 | 50 | const [hits, setHits] = useState([]); 51 | 52 | const inputRef = useRef(null); 53 | const plausible = usePlausible(); 54 | const pathname = usePathname(); 55 | const router = useRouter(); 56 | 57 | const handleKeyDown = useCallback( 58 | (event: KeyboardEvent) => { 59 | const input = inputRef.current; 60 | if (!input) { 61 | return; 62 | } 63 | 64 | // Keep hits displayed when typing 65 | if (!open) { 66 | setOpen(true); 67 | } 68 | 69 | if (event.key === 'Enter' && input.value.trim() !== '') { 70 | const hitToSelect = 71 | selectedHit || hits.find((hit) => hit.term === input.value.trim()); 72 | if (hitToSelect) { 73 | setSelectedHit(hitToSelect); 74 | } else { 75 | router.push(`/define/${termToSlug(input.value.trim())}`); 76 | plausible('Search'); 77 | } 78 | } 79 | 80 | if (event.key === 'Escape') { 81 | input.blur(); 82 | } 83 | }, 84 | [router, hits, open, selectedHit, plausible] 85 | ); 86 | 87 | const handleBlur = useCallback(() => { 88 | setOpen(false); 89 | if (selectedHit) { 90 | setInputValue(selectedHit.term); 91 | } 92 | }, [selectedHit]); 93 | 94 | const updateSearchResults = pDebounce( 95 | async (query: string) => { 96 | const { results } = await searchClient.search([ 97 | { 98 | indexName: 'definitions', 99 | params: { 100 | query, 101 | hitsPerPage: 4, 102 | synonyms: true, 103 | distinct: true, 104 | attributesToHighlight: [], 105 | attributesToRetrieve: ['id', 'term', 'definition'] 106 | } 107 | } 108 | ]); 109 | setHits((results[0] as unknown as SearchResponse).hits); 110 | }, 111 | -1, 112 | { before: true } 113 | ); 114 | 115 | const handleValueChange = useCallback( 116 | async (value: string) => { 117 | setInputValue(value); 118 | if (value.trim() === '') { 119 | setHits([]); 120 | } else { 121 | updateSearchResults(value); 122 | } 123 | }, 124 | [setInputValue, updateSearchResults] 125 | ); 126 | 127 | const handleHitSelect = useCallback( 128 | (hit: Hit) => { 129 | setSelectedHit(hit); 130 | setInputValue(''); 131 | setOpen(false); 132 | inputRef.current?.blur(); 133 | router.push(`/define/${termToSlug(hit.term)}`); 134 | plausible('Search'); 135 | }, 136 | [router, plausible] 137 | ); 138 | 139 | useEffect(() => { 140 | setInputValue(''); 141 | }, [pathname]); 142 | 143 | return ( 144 | 1}> 145 |
146 | 147 | 152 | setOpen(true)} 157 | onValueChange={handleValueChange} 158 | /> 159 | 160 |
161 |
162 | 163 | {open && hits.length > 0 && ( 164 | 165 | {hits.map((hit) => { 166 | const isSelected = selectedHit?.id === hit.id; 167 | return ( 168 | handleHitSelect(hit)} 171 | value={hit.term} 172 | key={hit.id} 173 | onMouseDown={(e) => { 174 | e.preventDefault(); 175 | e.stopPropagation(); 176 | }} 177 | > 178 | {isSelected ? ( 179 | 180 | ) : ( 181 | 182 | )} 183 |

{hit.term}

184 |

185 | {hit.definition} 186 |

187 |
188 | ); 189 | })} 190 |
191 | )} 192 |
193 |
194 |
195 | ); 196 | } 197 | -------------------------------------------------------------------------------- /src/app/about/page.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardDescription, CardHeader } from '@/components/ui/card'; 2 | import { Link } from '@/components/ui/link'; 3 | import { getPageMetadata } from '@/lib/seo'; 4 | 5 | export const metadata = getPageMetadata({ title: 'About' }); 6 | 7 | export default function AboutPage() { 8 | return ( 9 |
10 | 11 | 12 |

13 | About 14 |

15 | 16 | DevTerms is an community-driven dictionary of programming terms. The 17 | source code can be found on{' '} 18 | 23 | GitHub 24 | 25 | . 26 | 27 |
28 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/app/api/[[...slugs]]/callback.ts: -------------------------------------------------------------------------------- 1 | import { zValidator } from '@hono/zod-validator'; 2 | import { OAuth2RequestError } from 'arctic'; 3 | import { eq } from 'drizzle-orm'; 4 | import { Hono } from 'hono'; 5 | import { z } from 'zod'; 6 | 7 | import { 8 | createSession, 9 | generateSessionToken, 10 | github, 11 | setSessionCookie 12 | } from '@/lib/auth'; 13 | import { generateId } from '@/lib/id'; 14 | import { db } from '@/server/db'; 15 | import { users } from '@/server/db/schema'; 16 | import type { GitHubEmailListResponse, GitHubUserResponse } from '@/types'; 17 | 18 | export const callbackRoutes = new Hono().get( 19 | '/github', 20 | zValidator('query', z.object({ code: z.string(), state: z.string() })), 21 | zValidator('cookie', z.object({ oauth_state: z.string() })), 22 | async (c) => { 23 | const query = c.req.valid('query'); 24 | const cookie = c.req.valid('cookie'); 25 | 26 | if (query.state !== cookie.oauth_state) { 27 | return c.json( 28 | { 29 | success: false, 30 | error: 'bad_request' 31 | }, 32 | 400 33 | ); 34 | } 35 | 36 | try { 37 | const tokens = await github.validateAuthorizationCode(query.code); 38 | 39 | const githubUserResponse = await fetch('https://api.github.com/user', { 40 | headers: { 41 | Authorization: `Bearer ${tokens.accessToken()}` 42 | } 43 | }); 44 | 45 | const githubUser: GitHubUserResponse = await githubUserResponse.json(); 46 | 47 | const existingUser = await db.query.users.findFirst({ 48 | where: eq(users.githubId, githubUser.id) 49 | }); 50 | 51 | if (existingUser) { 52 | const token = generateSessionToken(); 53 | 54 | const session = await createSession(token, existingUser.id); 55 | await setSessionCookie(token, session.expiresAt); 56 | 57 | return c.redirect('/'); 58 | } 59 | 60 | let userEmail: string | null = githubUser.email; 61 | 62 | // Special case for GitHub accounts with private emails 63 | if (!userEmail) { 64 | const githubEmailListResponse = await fetch( 65 | 'https://api.github.com/user/emails', 66 | { 67 | headers: { 68 | Authorization: `Bearer ${tokens.accessToken}` 69 | } 70 | } 71 | ); 72 | 73 | const githubEmails: GitHubEmailListResponse = 74 | await githubEmailListResponse.json(); 75 | 76 | if (!Array.isArray(githubEmails) || githubEmails.length < 1) { 77 | return c.json( 78 | { 79 | success: false, 80 | error: 'invalid_github_email_list_response' 81 | }, 82 | 500 83 | ); 84 | } 85 | 86 | userEmail = 87 | githubEmails.find((email) => email.primary)?.email ?? 88 | githubEmails.find((email) => email.verified)?.email ?? 89 | null; 90 | 91 | if (!userEmail) { 92 | return c.json( 93 | { 94 | success: false, 95 | error: 'missing_verified_github_email' 96 | }, 97 | 400 98 | ); 99 | } 100 | } 101 | 102 | const userId = generateId('user'); 103 | 104 | await db.insert(users).values({ 105 | id: userId, 106 | email: userEmail, 107 | name: githubUser.login, 108 | githubId: githubUser.id, 109 | avatar: githubUser.avatar_url 110 | }); 111 | 112 | const token = generateSessionToken(); 113 | 114 | const session = await createSession(token, userId); 115 | await setSessionCookie(token, session.expiresAt); 116 | 117 | return c.redirect('/'); 118 | } catch (err) { 119 | if (err instanceof OAuth2RequestError) { 120 | return c.json( 121 | { 122 | success: false, 123 | error: 'invalid_code' 124 | }, 125 | 400 126 | ); 127 | } 128 | 129 | console.error('GitHub OAuth2 error:', err); 130 | 131 | return c.json( 132 | { 133 | success: false, 134 | error: 'internal_server_error' 135 | }, 136 | 500 137 | ); 138 | } 139 | } 140 | ); 141 | -------------------------------------------------------------------------------- /src/app/api/[[...slugs]]/cron.ts: -------------------------------------------------------------------------------- 1 | import AtpAgent, { CredentialSession, RichText } from '@atproto/api'; 2 | import { eq } from 'drizzle-orm'; 3 | import { Hono } from 'hono'; 4 | import MeiliSearch from 'meilisearch'; 5 | import { revalidatePath, revalidateTag } from 'next/cache'; 6 | import { TwitterApi } from 'twitter-api-v2'; 7 | 8 | import { env } from '@/env'; 9 | import { getOpenGraphImageUrl, getRandomDefinition } from '@/lib/definitions'; 10 | import { termToSlug, truncateString } from '@/lib/utils'; 11 | import { db } from '@/server/db'; 12 | import { definitions, wotds } from '@/server/db/schema'; 13 | 14 | const DEFINITION_PLACEHOLDER = '%DEFINITION_PLACEHOLDER%'; 15 | const X_CHARACTER_LIMIT = 280; 16 | const BSKY_CHARACTER_LIMIT = 300; 17 | 18 | export const cronRoutes = new Hono() 19 | .use(async (c, next) => { 20 | const authorizationHeader = c.req.header('Authorization'); 21 | if (authorizationHeader !== `Bearer ${env.CRON_SECRET}`) { 22 | return c.json( 23 | { 24 | success: false, 25 | error: 'unauthorized' 26 | }, 27 | 401 28 | ); 29 | } 30 | await next(); 31 | }) 32 | .get('/meilisearch', async (c) => { 33 | const meili = new MeiliSearch({ 34 | host: env.NEXT_PUBLIC_MEILISEARCH_HOST, 35 | apiKey: env.MEILISEARCH_MASTER_KEY 36 | }); 37 | 38 | const data = await db.query.definitions.findMany({ 39 | where: eq(definitions.status, 'approved'), 40 | columns: { 41 | id: true, 42 | term: true, 43 | definition: true, 44 | example: true 45 | } 46 | }); 47 | 48 | const index = meili.index('definitions'); 49 | 50 | await index.updateSortableAttributes(['term']); 51 | await index.updateDocuments(data, { primaryKey: 'id' }); 52 | 53 | return c.json({ success: true }); 54 | }) 55 | .get('/wotd', async (c) => { 56 | const definition = await getRandomDefinition(); 57 | if (!definition) { 58 | return c.json( 59 | { 60 | success: false, 61 | error: 'no_definitions_available' 62 | }, 63 | 404 64 | ); 65 | } 66 | 67 | await db.insert(wotds).values({ definitionId: definition.id }); 68 | 69 | revalidateTag(`definitions:${termToSlug(definition.term)}`); 70 | revalidateTag('home_feed'); 71 | revalidatePath('/'); 72 | 73 | // Post on X (Twitter) 74 | if ( 75 | env.TWITTER_CONSUMER_KEY && 76 | env.TWITTER_CONSUMER_SECRET && 77 | env.TWITTER_ACCESS_TOKEN && 78 | env.TWITTER_ACCESS_TOKEN_SECRET 79 | ) { 80 | const twitter = new TwitterApi({ 81 | appKey: env.TWITTER_CONSUMER_KEY, 82 | appSecret: env.TWITTER_CONSUMER_SECRET, 83 | accessToken: env.TWITTER_ACCESS_TOKEN, 84 | accessSecret: env.TWITTER_ACCESS_TOKEN_SECRET 85 | }); 86 | 87 | const statusTemplate = 88 | `💡 Today's word of the day is ${definition.term}!\n\n` + 89 | `${DEFINITION_PLACEHOLDER}\n\n` + 90 | `${env.NEXT_PUBLIC_BASE_URL}/define/${termToSlug(definition.term)}\n\n` + 91 | '#buildinpublic #developers'; 92 | 93 | const truncatedDefinition = truncateString( 94 | definition.definition, 95 | X_CHARACTER_LIMIT - 96 | statusTemplate.length - 97 | DEFINITION_PLACEHOLDER.length 98 | ); 99 | 100 | const status = statusTemplate.replace( 101 | DEFINITION_PLACEHOLDER, 102 | truncatedDefinition 103 | ); 104 | 105 | try { 106 | await twitter.v2.tweet(status); 107 | } catch (err) { 108 | console.error('Twitter error:', err); 109 | 110 | return c.json( 111 | { 112 | success: false, 113 | error: 'internal_server_error' 114 | }, 115 | 500 116 | ); 117 | } 118 | } 119 | 120 | // Post on Bluesky 121 | if (env.BLUESKY_USERNAME && env.BLUESKY_PASSWORD) { 122 | const messageTemplate = 123 | `💡 Today's word of the day is ${definition.term}!\n\n` + 124 | `${DEFINITION_PLACEHOLDER}\n\n` + 125 | '#coding #developers #buildinpublic'; 126 | 127 | const truncatedDefinition = truncateString( 128 | definition.definition, 129 | BSKY_CHARACTER_LIMIT - 130 | messageTemplate.length - 131 | DEFINITION_PLACEHOLDER.length 132 | ); 133 | 134 | const message = messageTemplate.replace( 135 | DEFINITION_PLACEHOLDER, 136 | truncatedDefinition 137 | ); 138 | 139 | const session = new CredentialSession(new URL('https://bsky.social')); 140 | const agent = new AtpAgent(session); 141 | 142 | const rt = new RichText({ text: message }); 143 | await rt.detectFacets(agent); 144 | 145 | try { 146 | await agent.login({ 147 | identifier: env.BLUESKY_USERNAME, 148 | password: env.BLUESKY_PASSWORD 149 | }); 150 | 151 | const ogUrl = getOpenGraphImageUrl(termToSlug(definition.term)); 152 | const ogResponse = await fetch(ogUrl); 153 | const ogBlob = await ogResponse.blob(); 154 | 155 | const { data } = await agent.uploadBlob(ogBlob); 156 | 157 | await agent.post({ 158 | text: rt.text, 159 | facets: rt.facets, 160 | embed: { 161 | $type: 'app.bsky.embed.external', 162 | external: { 163 | uri: `${env.NEXT_PUBLIC_BASE_URL}/define/${termToSlug(definition.term)}`, 164 | title: `What is ${definition.term}?`, 165 | description: definition.definition, 166 | thumb: data.blob 167 | } 168 | } 169 | }); 170 | } catch (err) { 171 | console.error('Bluesky error:', err); 172 | 173 | return c.json( 174 | { 175 | success: false, 176 | error: 'internal_server_error' 177 | }, 178 | 500 179 | ); 180 | } 181 | } 182 | 183 | return c.json({ success: true }); 184 | }); 185 | -------------------------------------------------------------------------------- /src/app/api/[[...slugs]]/dev.ts: -------------------------------------------------------------------------------- 1 | import { env } from 'process'; 2 | import { zValidator } from '@hono/zod-validator'; 3 | import { Hono } from 'hono'; 4 | import { setCookie } from 'hono/cookie'; 5 | import type { CookieOptions } from 'hono/utils/cookie'; 6 | import { TwitterApi } from 'twitter-api-v2'; 7 | import { z } from 'zod'; 8 | 9 | const twitterRoutes = new Hono() 10 | .use(async (c, next) => { 11 | if (!env.TWITTER_CONSUMER_KEY || !env.TWITTER_CONSUMER_SECRET) { 12 | return c.json( 13 | { 14 | success: false, 15 | error: 'missing_credentials' 16 | }, 17 | 500 18 | ); 19 | } 20 | await next(); 21 | }) 22 | .get('/login', async (c) => { 23 | const twitter = new TwitterApi({ 24 | appKey: env.TWITTER_CONSUMER_KEY!, 25 | appSecret: env.TWITTER_CONSUMER_SECRET! 26 | }); 27 | 28 | const authLink = await twitter.generateAuthLink( 29 | `${env.NEXT_PUBLIC_BASE_URL}/api/dev/twitter/collect`, 30 | { linkMode: 'authorize' } 31 | ); 32 | 33 | const opts = { 34 | sameSite: 'lax', 35 | httpOnly: true, 36 | secure: false, 37 | maxAge: 600, 38 | path: '/' 39 | } satisfies CookieOptions; 40 | 41 | setCookie(c, 'dev__twitter_oauth_token', authLink.oauth_token, opts); 42 | setCookie(c, 'dev__twitter_oauth_token_secret', authLink.oauth_token, opts); 43 | 44 | return c.redirect(authLink.url); 45 | }) 46 | .get( 47 | '/collect', 48 | zValidator( 49 | 'query', 50 | z.object({ 51 | oauth_token: z.string(), 52 | oauth_verifier: z.string() 53 | }) 54 | ), 55 | zValidator( 56 | 'cookie', 57 | z.object({ 58 | dev__twitter_oauth_token: z.string(), 59 | dev__twitter_oauth_token_secret: z.string() 60 | }) 61 | ), 62 | async (c) => { 63 | const query = c.req.valid('query'); 64 | const cookie = c.req.valid('cookie'); 65 | 66 | const twitter = new TwitterApi({ 67 | appKey: env.TWITTER_CONSUMER_KEY!, 68 | appSecret: env.TWITTER_CONSUMER_SECRET!, 69 | accessToken: cookie.dev__twitter_oauth_token, 70 | accessSecret: cookie.dev__twitter_oauth_token_secret 71 | }); 72 | try { 73 | const { accessToken, accessSecret } = await twitter.login( 74 | query.oauth_verifier 75 | ); 76 | 77 | return c.json({ accessToken, accessSecret }); 78 | } catch (err) { 79 | console.error('Twitter OAuth2 error:', err); 80 | 81 | return c.json( 82 | { 83 | success: false, 84 | error: 'internal_server_error' 85 | }, 86 | 500 87 | ); 88 | } 89 | } 90 | ); 91 | 92 | export const devRoutes = new Hono() 93 | .use(async (c, next) => { 94 | if (env.NODE_ENV !== 'development') { 95 | return c.json( 96 | { 97 | success: false, 98 | error: 'restricted_endpoint' 99 | }, 100 | 403 101 | ); 102 | } 103 | await next(); 104 | }) 105 | .route('/twitter', twitterRoutes); 106 | -------------------------------------------------------------------------------- /src/app/api/[[...slugs]]/public.ts: -------------------------------------------------------------------------------- 1 | import { createRoute, OpenAPIHono } from '@hono/zod-openapi'; 2 | import { and, desc, eq, sql } from 'drizzle-orm'; 3 | import MeiliSearch from 'meilisearch'; 4 | import { z } from 'zod'; 5 | 6 | import { env } from '@/env'; 7 | import { getRandomDefinition } from '@/lib/definitions'; 8 | import { termToSlug } from '@/lib/utils'; 9 | import { db } from '@/server/db'; 10 | import { definitions } from '@/server/db/schema'; 11 | import type { DefinitionHit } from '@/types'; 12 | 13 | export const publicRoutes = new OpenAPIHono() 14 | .openapi( 15 | createRoute({ 16 | method: 'get', 17 | path: '/health', 18 | responses: { 19 | 200: { 20 | description: 21 | 'Simple health check endpoint for uptime monitoring services.', 22 | content: { 23 | 'application/json': { 24 | schema: z.object({ 25 | status: z.literal('ok') 26 | }) 27 | } 28 | } 29 | } 30 | } 31 | }), 32 | (c) => c.json({ status: 'ok' } as const) 33 | ) 34 | .openapi( 35 | createRoute({ 36 | method: 'get', 37 | path: '/random', 38 | responses: { 39 | 200: { 40 | description: 'Returns a random definition from DevTerms.', 41 | content: { 42 | 'application/json': { 43 | schema: z.object({ 44 | id: z.string(), 45 | term: z.string(), 46 | definition: z.string(), 47 | example: z.string(), 48 | url: z.string().url() 49 | }) 50 | } 51 | } 52 | }, 53 | 404: { 54 | description: 'Not found', 55 | content: { 56 | 'application/json': { 57 | schema: z.object({ 58 | success: z.literal(false), 59 | error: z.string() 60 | }) 61 | } 62 | } 63 | } 64 | } 65 | }), 66 | async (c) => { 67 | const definition = await getRandomDefinition(); 68 | if (!definition) { 69 | return c.json( 70 | { 71 | success: false, 72 | error: 'no_definitions_available' 73 | } as const, 74 | 404 75 | ); 76 | } 77 | return c.json( 78 | { 79 | ...definition, 80 | url: `${env.NEXT_PUBLIC_BASE_URL}/define/${termToSlug(definition.term)}` 81 | }, 82 | 200 83 | ); 84 | } 85 | ) 86 | .openapi( 87 | createRoute({ 88 | method: 'get', 89 | path: '/search', 90 | request: { 91 | query: z.object({ 92 | q: z.string(), 93 | page: z.number().default(1).optional() 94 | }) 95 | }, 96 | responses: { 97 | 200: { 98 | description: 99 | 'Uses fuzzy string matching to search for definitions. This should produce equivalent results to the search bar on the website. Returns a list of hits and other metadata.', 100 | content: { 101 | 'application/json': { 102 | schema: z.object({ 103 | query: z.string(), 104 | page: z.number(), 105 | hitsPerPage: z.number(), 106 | totalPages: z.number(), 107 | totalHits: z.number(), 108 | processingTimeMs: z.number(), 109 | hits: z.array( 110 | z.object({ 111 | id: z.string(), 112 | term: z.string(), 113 | definition: z.string(), 114 | example: z.string(), 115 | url: z.string().url() 116 | }) 117 | ) 118 | }) 119 | } 120 | } 121 | } 122 | } 123 | }), 124 | async (c) => { 125 | const { q: query, page } = c.req.valid('query'); 126 | 127 | const meili = new MeiliSearch({ 128 | host: env.NEXT_PUBLIC_MEILISEARCH_HOST, 129 | apiKey: env.MEILISEARCH_MASTER_KEY 130 | }); 131 | 132 | const index = meili.index('definitions'); 133 | 134 | const response = await index.search(query, { 135 | attributesToSearchOn: ['term', 'definition', 'example'], 136 | hitsPerPage: 4, 137 | page 138 | }); 139 | 140 | return c.json({ 141 | ...response, 142 | hits: response.hits.map((hit) => ({ 143 | ...hit, 144 | url: `${env.NEXT_PUBLIC_BASE_URL}/define/${termToSlug(hit.term)}` 145 | })) 146 | }); 147 | } 148 | ) 149 | .openapi( 150 | createRoute({ 151 | method: 'get', 152 | path: '/define', 153 | request: { 154 | query: z 155 | .object({ 156 | id: z.string(), 157 | term: z.string() 158 | }) 159 | .partial() 160 | }, 161 | responses: { 162 | 200: { 163 | description: 164 | 'Returns a single definition by its ID or definition(s) by exact term. Use the `/search` endpoint for fuzzy matching.', 165 | content: { 166 | 'application/json': { 167 | schema: z.object({ 168 | results: z.array( 169 | z.object({ 170 | id: z.string(), 171 | term: z.string(), 172 | definition: z.string(), 173 | upvotes: z.number(), 174 | downvotes: z.number(), 175 | createdAt: z.number(), 176 | url: z.string().url() 177 | }) 178 | ) 179 | }) 180 | } 181 | } 182 | }, 183 | 400: { 184 | description: 'Bad request', 185 | content: { 186 | 'application/json': { 187 | schema: z.object({ 188 | success: z.literal(false), 189 | error: z.string() 190 | }) 191 | } 192 | } 193 | } 194 | } 195 | }), 196 | async (c) => { 197 | const query = c.req.valid('query'); 198 | if (!query.id && !query.term) { 199 | return c.json( 200 | { 201 | success: false, 202 | error: 'invalid_query' 203 | } as const, 204 | 400 205 | ); 206 | } 207 | 208 | const results = await db.query.definitions.findMany({ 209 | orderBy: desc(definitions.upvotes), 210 | where: and( 211 | eq(definitions.status, 'approved'), 212 | query.id 213 | ? eq(definitions.id, query.id) 214 | : eq(definitions.term, sql`${query.term} COLLATE NOCASE`) 215 | ), 216 | columns: { 217 | id: true, 218 | term: true, 219 | definition: true, 220 | upvotes: true, 221 | downvotes: true, 222 | createdAt: true 223 | } 224 | }); 225 | 226 | return c.json( 227 | { 228 | results: results.map((result) => ({ 229 | ...result, 230 | url: `${env.NEXT_PUBLIC_BASE_URL}/define/${termToSlug(result.term)}` 231 | })) 232 | }, 233 | 200 234 | ); 235 | } 236 | ); 237 | -------------------------------------------------------------------------------- /src/app/api/[[...slugs]]/route.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIHono } from '@hono/zod-openapi'; 2 | import { apiReference } from '@scalar/hono-api-reference'; 3 | import { handle } from 'hono/vercel'; 4 | 5 | import { env } from '@/env'; 6 | import { APP_DESCRIPTION, APP_NAME } from '@/lib/seo'; 7 | import { callbackRoutes } from './callback'; 8 | import { cronRoutes } from './cron'; 9 | import { devRoutes } from './dev'; 10 | import { publicRoutes } from './public'; 11 | 12 | const app = new OpenAPIHono().basePath('/api'); 13 | 14 | app.notFound((c) => c.json({ success: false, error: 'not_found' }, 404)); 15 | 16 | app.onError((err, c) => { 17 | console.error(err); 18 | return c.json( 19 | { 20 | success: false, 21 | error: 'internal_server_error' 22 | }, 23 | 500 24 | ); 25 | }); 26 | 27 | app.route('/cron', cronRoutes); 28 | app.route('/callback', callbackRoutes); 29 | 30 | if (env.NODE_ENV === 'development') { 31 | app.route('/dev', devRoutes); 32 | } 33 | 34 | app 35 | .route('/v1', publicRoutes) 36 | .doc('/v1/openapi.json', { 37 | openapi: '3.1.0', 38 | info: { 39 | version: '1.1.0', 40 | title: `${APP_NAME} API`, 41 | description: APP_DESCRIPTION 42 | } 43 | }) 44 | .get( 45 | '/docs', 46 | apiReference({ 47 | theme: 'deepSpace', 48 | favicon: '/logo.png', 49 | spec: { url: '/api/v1/openapi.json' }, 50 | customCss: '.download-button { opacity: 0.75; }' 51 | }) 52 | ); 53 | 54 | const handler = handle(app); 55 | 56 | export { handler as GET, handler as POST }; 57 | -------------------------------------------------------------------------------- /src/app/api/og/[slug]/assets/background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aelew/devterms/0061b07366d79662078d0e17da47fda462f38af0/src/app/api/og/[slug]/assets/background.jpg -------------------------------------------------------------------------------- /src/app/api/og/[slug]/assets/geist-medium.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aelew/devterms/0061b07366d79662078d0e17da47fda462f38af0/src/app/api/og/[slug]/assets/geist-medium.otf -------------------------------------------------------------------------------- /src/app/api/og/[slug]/assets/geist-semibold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aelew/devterms/0061b07366d79662078d0e17da47fda462f38af0/src/app/api/og/[slug]/assets/geist-semibold.otf -------------------------------------------------------------------------------- /src/app/api/og/[slug]/route.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | import { and, desc, eq, sql } from 'drizzle-orm'; 3 | import { ImageResponse } from 'next/og'; 4 | import { NextResponse, type NextRequest } from 'next/server'; 5 | 6 | import { env } from '@/env'; 7 | import { slugToTerm } from '@/lib/utils'; 8 | import { db } from '@/server/db'; 9 | import { definitions } from '@/server/db/schema'; 10 | 11 | interface OpenGraphDefinitionProps { 12 | params: Promise<{ slug: string }>; 13 | } 14 | 15 | export const runtime = 'edge'; 16 | 17 | const key = crypto.subtle.importKey( 18 | 'raw', 19 | new TextEncoder().encode(env.OG_HMAC_SECRET), 20 | { name: 'HMAC', hash: { name: 'SHA-256' } }, 21 | false, 22 | ['sign'] 23 | ); 24 | 25 | function toHex(arrayBuffer: ArrayBuffer) { 26 | return Array.prototype.map 27 | .call(new Uint8Array(arrayBuffer), (n) => n.toString(16).padStart(2, '0')) 28 | .join(''); 29 | } 30 | 31 | export async function GET(req: NextRequest, props: OpenGraphDefinitionProps) { 32 | const params = await props.params; 33 | 34 | const term = slugToTerm(params.slug); 35 | 36 | const searchParams = req.nextUrl.searchParams; 37 | const token = searchParams.get('t'); 38 | 39 | const verifyToken = toHex( 40 | await crypto.subtle.sign( 41 | 'HMAC', 42 | await key, 43 | new TextEncoder().encode(JSON.stringify({ slug: params.slug })) 44 | ) 45 | ); 46 | 47 | if (token !== verifyToken) { 48 | return NextResponse.json( 49 | { success: false, error: 'invalid_token' }, 50 | { status: 401 } 51 | ); 52 | } 53 | 54 | // locate definition 55 | const result = await db.query.definitions.findFirst({ 56 | orderBy: desc(definitions.upvotes), 57 | where: and( 58 | eq(definitions.status, 'approved'), 59 | eq(definitions.term, sql`${term} COLLATE NOCASE`) 60 | ), 61 | with: { 62 | user: { 63 | columns: { 64 | name: true 65 | } 66 | } 67 | } 68 | }); 69 | if (!result) { 70 | return NextResponse.json( 71 | { success: false, error: 'definition_not_found' }, 72 | { status: 404 } 73 | ); 74 | } 75 | 76 | // load resources 77 | const mediumFontData = await fetch( 78 | new URL('./assets/geist-medium.otf', import.meta.url) 79 | ).then((res) => res.arrayBuffer()); 80 | 81 | const semiBoldFontData = await fetch( 82 | new URL('./assets/geist-semibold.otf', import.meta.url) 83 | ).then((res) => res.arrayBuffer()); 84 | 85 | const logoImage = await fetch( 86 | new URL('../../../../../public/logo.png', import.meta.url) 87 | ).then((res) => res.arrayBuffer()); 88 | 89 | const backgroundImage = await fetch( 90 | new URL('./assets/background.jpg', import.meta.url) 91 | ).then((res) => res.arrayBuffer()); 92 | 93 | return new ImageResponse( 94 | ( 95 |
99 | 107 | 115 |

122 | {result.term} 123 |

124 |

{result.definition}

125 |

126 | "{result.example}" 127 |

128 |

129 | — @{result.user.name}{' '} 130 | · 131 | {' ' + 132 | new Date(result.createdAt).toLocaleDateString(undefined, { 133 | year: 'numeric', 134 | month: 'long', 135 | day: 'numeric' 136 | })} 137 |

138 |
139 | ), 140 | { 141 | width: 1200, 142 | height: 630, 143 | fonts: [ 144 | { 145 | data: mediumFontData, 146 | name: 'Geist Sans', 147 | style: 'normal', 148 | weight: 500 149 | }, 150 | { 151 | data: semiBoldFontData, 152 | name: 'Geist Sans', 153 | style: 'normal', 154 | weight: 600 155 | } 156 | ] 157 | } 158 | ); 159 | } 160 | -------------------------------------------------------------------------------- /src/app/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aelew/devterms/0061b07366d79662078d0e17da47fda462f38af0/src/app/apple-icon.png -------------------------------------------------------------------------------- /src/app/d/[shortId]/route.ts: -------------------------------------------------------------------------------- 1 | import { and, eq } from 'drizzle-orm'; 2 | import { NextResponse, type NextRequest } from 'next/server'; 3 | 4 | import { termToSlug } from '@/lib/utils'; 5 | import { db } from '@/server/db'; 6 | import { definitions } from '@/server/db/schema'; 7 | 8 | interface Params { 9 | shortId: string; 10 | } 11 | 12 | export async function GET( 13 | req: NextRequest, 14 | props: { params: Promise } 15 | ) { 16 | const params = await props.params; 17 | const definition = await db.query.definitions.findFirst({ 18 | columns: { id: true, term: true }, 19 | where: and( 20 | eq(definitions.id, `def_${params.shortId}`), 21 | eq(definitions.status, 'approved') 22 | ) 23 | }); 24 | if (!definition) { 25 | return NextResponse.json( 26 | { error: 'Definition not found' }, 27 | { status: 404 } 28 | ); 29 | } 30 | return NextResponse.redirect( 31 | new URL(`/define/${termToSlug(definition.term)}#${definition.id}`, req.url), 32 | { status: 308 } 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aelew/devterms/0061b07366d79662078d0e17da47fda462f38af0/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 240 10% 3.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 240 10% 3.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 240 10% 3.9%; 15 | 16 | --primary: 240 5.9% 10%; 17 | --primary-foreground: 0 0% 98%; 18 | 19 | --secondary: 240 4.8% 95.9%; 20 | --secondary-foreground: 240 5.9% 10%; 21 | 22 | --muted: 240 4.8% 95.9%; 23 | --muted-foreground: 240 3.8% 46.1%; 24 | 25 | --accent: 240 4.8% 95.9%; 26 | --accent-foreground: 240 5.9% 10%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 0 0% 98%; 30 | 31 | --border: 240 5.9% 90%; 32 | --input: 240 5.9% 90%; 33 | --ring: 240 10% 3.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 240 10% 3.9%; 40 | --foreground: 0 0% 98%; 41 | 42 | --card: 240 10% 6%; 43 | --card-foreground: 0 0% 98%; 44 | 45 | --popover: 240 10% 3.9%; 46 | --popover-foreground: 0 0% 98%; 47 | 48 | --primary: 0 0% 98%; 49 | --primary-foreground: 240 5.9% 10%; 50 | 51 | --secondary: 240 3.7% 15.9%; 52 | --secondary-foreground: 0 0% 98%; 53 | 54 | --muted: 240 3.7% 15.9%; 55 | --muted-foreground: 240 5% 64.9%; 56 | 57 | --accent: 240 3.7% 15.9%; 58 | --accent-foreground: 0 0% 98%; 59 | 60 | --destructive: 0 84.2% 60.2%; 61 | --destructive-foreground: 0 0% 98%; 62 | 63 | --border: 240 3.7% 15.9%; 64 | --input: 240 3.7% 15.9%; 65 | --ring: 240 4.9% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | html, 77 | body { 78 | @apply scroll-smooth; 79 | } 80 | strong { 81 | @apply font-semibold; 82 | } 83 | } 84 | 85 | @layer components { 86 | .dark .text-gradient { 87 | background: linear-gradient(to bottom, #fff 30%, hsla(0, 0%, 100%, 0.5)); 88 | -webkit-text-fill-color: transparent; 89 | -webkit-background-clip: text; 90 | background-clip: text; 91 | display: inline; 92 | color: unset; 93 | } 94 | .dark .text-gradient::selection { 95 | @apply bg-blue-400/40 text-foreground; 96 | } 97 | } 98 | 99 | @supports (-webkit-touch-callout: none) { 100 | input, 101 | select, 102 | textarea { 103 | font-size: 16px !important; 104 | } 105 | } 106 | 107 | .react-share__ShareButton { 108 | @apply transition-color-transform hover:opacity-80 active:scale-90; 109 | } 110 | -------------------------------------------------------------------------------- /src/app/icon1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aelew/devterms/0061b07366d79662078d0e17da47fda462f38af0/src/app/icon1.png -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { GeistSans } from 'geist/font/sans'; 2 | import PlausibleProvider from 'next-plausible'; 3 | import { ThemeProvider } from 'next-themes'; 4 | import type { PropsWithChildren } from 'react'; 5 | import { Toaster } from 'sonner'; 6 | 7 | import { Spotlight } from '@/components/spotlight'; 8 | import { baseMetadata } from '@/lib/seo'; 9 | import { cn } from '@/lib/utils'; 10 | import { Canonical } from './_components/canonical'; 11 | import { Header } from './_components/header'; 12 | 13 | import './globals.css'; 14 | 15 | export const metadata = baseMetadata; 16 | 17 | export default function RootLayout({ children }: PropsWithChildren) { 18 | return ( 19 | 20 | 21 | 22 | 23 | 30 | 31 | 38 | 44 | {/* Grid background */} 45 |
46 |
47 |
48 |
49 |
{children}
50 | 51 | 55 | 56 | 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/app/login/_actions.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { generateState } from 'arctic'; 4 | import { cookies } from 'next/headers'; 5 | import { redirect } from 'next/navigation'; 6 | 7 | import { env } from '@/env'; 8 | import { github } from '@/lib/auth'; 9 | 10 | export async function login() { 11 | 'use server'; 12 | 13 | const state = generateState(); 14 | const url = await github.createAuthorizationURL(state, ['user:email']); 15 | 16 | const cookieStore = await cookies(); 17 | 18 | cookieStore.set('oauth_state', state, { 19 | secure: env.NODE_ENV === 'production', 20 | maxAge: 60 * 10, 21 | httpOnly: true, 22 | sameSite: 'lax', 23 | path: '/' 24 | }); 25 | 26 | redirect(url.toString()); 27 | } 28 | -------------------------------------------------------------------------------- /src/app/login/github-button.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { SiGithub } from '@icons-pack/react-simple-icons'; 4 | import { usePlausible } from 'next-plausible'; 5 | 6 | import { Button } from '@/components/ui/button'; 7 | import type { Events } from '@/types'; 8 | import { login } from './_actions'; 9 | 10 | export function GitHubButton() { 11 | const plausible = usePlausible(); 12 | return ( 13 |
14 | 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/app/login/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | 3 | import { 4 | Card, 5 | CardContent, 6 | CardDescription, 7 | CardHeader, 8 | CardTitle 9 | } from '@/components/ui/card'; 10 | import { Link } from '@/components/ui/link'; 11 | import { getPageMetadata } from '@/lib/seo'; 12 | import { GitHubButton } from './github-button'; 13 | 14 | export const metadata = getPageMetadata({ 15 | title: 'Login', 16 | description: 17 | 'Sign in with your GitHub account to contribute to DevTerms, a crowdsourced dictionary for developers!' 18 | }); 19 | 20 | export default function SignInPage() { 21 | return ( 22 | 23 | 24 | 25 | Logo 26 | 27 | 28 |

Sign in to DevTerms

29 |
30 | The developer dictionary 31 |
32 | 33 | 34 | 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/app/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "DevTerms", 3 | "short_name": "DevTerms", 4 | "icons": [ 5 | { 6 | "src": "/web-app-manifest-192x192.png?v=3", 7 | "sizes": "192x192", 8 | "type": "image/png", 9 | "purpose": "maskable" 10 | }, 11 | { 12 | "src": "/web-app-manifest-512x512.png?v=3", 13 | "sizes": "512x512", 14 | "type": "image/png", 15 | "purpose": "maskable" 16 | } 17 | ], 18 | "theme_color": "#000000", 19 | "background_color": "#000000", 20 | "display": "standalone" 21 | } 22 | -------------------------------------------------------------------------------- /src/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowLeftIcon } from 'lucide-react'; 2 | 3 | import { buttonVariants } from '@/components/ui/button'; 4 | import { Link } from '@/components/ui/link'; 5 | 6 | export default function NotFound() { 7 | return ( 8 |
9 |

10 | 404 Not Found 11 |

12 |

13 | Uh oh! We couldn't find the resource you were looking for. Please 14 | check your URL and try again. 15 |

16 | 17 | Return home 18 | 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/app/robots.ts: -------------------------------------------------------------------------------- 1 | import type { MetadataRoute } from 'next'; 2 | 3 | import { env } from '@/env'; 4 | 5 | export default function robots(): MetadataRoute.Robots { 6 | return { 7 | sitemap: env.NEXT_PUBLIC_BASE_URL + '/sitemap-index.xml', 8 | host: env.NEXT_PUBLIC_BASE_URL, 9 | rules: { 10 | userAgent: '*', 11 | allow: '/' 12 | } 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /src/app/sitemap.ts: -------------------------------------------------------------------------------- 1 | import type { MetadataRoute } from 'next'; 2 | 3 | import { env } from '@/env'; 4 | 5 | export default function sitemap(): MetadataRoute.Sitemap { 6 | return ['/', '/browse', '/submit', '/api/docs', '/login', '/about'].map( 7 | (path) => ({ 8 | url: env.NEXT_PUBLIC_BASE_URL + path, 9 | priority: path === '/' ? 1 : 0.9, 10 | changeFrequency: 'daily', 11 | lastModified: new Date() 12 | }) 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/app/submit/_actions.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { protectedAction } from '@/lib/action'; 4 | import { db } from '@/server/db'; 5 | import { definitions } from '@/server/db/schema'; 6 | import { formSchema } from './schema'; 7 | 8 | // TODO: Implement CF Turnstile captcha 9 | export const submitDefinition = protectedAction( 10 | formSchema, 11 | async ({ term, definition, example }, { user }) => { 12 | await db 13 | .insert(definitions) 14 | .values({ userId: user.id, term, definition, example }); 15 | return { message: 'Your definition has been submitted for review!' }; 16 | } 17 | ); 18 | -------------------------------------------------------------------------------- /src/app/submit/form.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { zodResolver } from '@hookform/resolvers/zod'; 4 | import { usePlausible } from 'next-plausible'; 5 | import { useAction } from 'next-safe-action/hooks'; 6 | import { useSearchParams } from 'next/navigation'; 7 | import { useEffect } from 'react'; 8 | import { useForm } from 'react-hook-form'; 9 | import { toast } from 'sonner'; 10 | import { z } from 'zod'; 11 | 12 | import { Button, buttonVariants } from '@/components/ui/button'; 13 | import { 14 | Form, 15 | FormControl, 16 | FormDescription, 17 | FormField, 18 | FormItem, 19 | FormLabel, 20 | FormMessage 21 | } from '@/components/ui/form'; 22 | import { Input } from '@/components/ui/input'; 23 | import { Link } from '@/components/ui/link'; 24 | import { Textarea } from '@/components/ui/textarea'; 25 | import { getActionErrorMessage, slugToTerm } from '@/lib/utils'; 26 | import type { Events } from '@/types'; 27 | import { submitDefinition } from './_actions'; 28 | import { formSchema } from './schema'; 29 | 30 | interface SubmitDefinitionFormProps { 31 | isAuthenticated: boolean; 32 | } 33 | 34 | export function SubmitDefinitionForm({ 35 | isAuthenticated 36 | }: SubmitDefinitionFormProps) { 37 | const plausible = usePlausible(); 38 | const searchParams = useSearchParams(); 39 | 40 | const { execute, status } = useAction(submitDefinition, { 41 | onSuccess: (data) => { 42 | form.reset(); 43 | toast.success(data.message); 44 | plausible('Submit definition'); 45 | }, 46 | onError: (result) => { 47 | const message = getActionErrorMessage(result); 48 | toast.error(message); 49 | } 50 | }); 51 | 52 | const form = useForm>({ 53 | resolver: zodResolver(formSchema), 54 | defaultValues: { 55 | term: '', 56 | definition: '', 57 | example: '' 58 | } 59 | }); 60 | 61 | const onSubmit = (values: z.infer) => { 62 | execute(values); 63 | }; 64 | 65 | useEffect(() => { 66 | const termSlugParam = searchParams.get('term'); 67 | if (termSlugParam) { 68 | form.setValue('term', slugToTerm(termSlugParam)); 69 | } 70 | }, [form, searchParams]); 71 | 72 | return ( 73 |
74 | 78 | ( 82 | 83 | Term 84 | 85 | 90 | 91 | 92 | The word or phrase you want to define. 93 | 94 | 95 | 96 | )} 97 | /> 98 | ( 102 | 103 | Definition 104 | 105 |