87 | >(({ className, ...props }, ref) => (
88 | [role=checkbox]]:translate-y-[2px]',
92 | className
93 | )}
94 | {...props}
95 | />
96 | ));
97 | TableCell.displayName = 'TableCell';
98 |
99 | const TableCaption = React.forwardRef<
100 | HTMLTableCaptionElement,
101 | React.HTMLAttributes
102 | >(({ className, ...props }, ref) => (
103 |
108 | ));
109 | TableCaption.displayName = 'TableCaption';
110 |
111 | export {
112 | Table,
113 | TableHeader,
114 | TableBody,
115 | TableFooter,
116 | TableHead,
117 | TableRow,
118 | TableCell,
119 | TableCaption
120 | };
121 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/lib/utils';
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | );
20 | }
21 | );
22 | Textarea.displayName = 'Textarea';
23 |
24 | export { Textarea };
25 |
--------------------------------------------------------------------------------
/src/env.ts:
--------------------------------------------------------------------------------
1 | import { createEnv } from '@t3-oss/env-nextjs';
2 | import { vercel } from '@t3-oss/env-nextjs/presets';
3 | import { z } from 'zod';
4 |
5 | export const env = createEnv({
6 | /**
7 | * Specify your server-side environment variables schema here. This way you can ensure the app
8 | * isn't built with invalid env vars.
9 | */
10 | server: {
11 | TURSO_DATABASE_URL: z.string().url(),
12 | TURSO_AUTH_TOKEN: z.string(),
13 | CRON_SECRET: z.string(),
14 | OG_HMAC_SECRET: z.string(),
15 | GITHUB_CLIENT_ID: z.string(),
16 | GITHUB_CLIENT_SECRET: z.string(),
17 | MEILISEARCH_MASTER_KEY: z.string(),
18 | UPSTASH_REDIS_REST_URL: z.string().url(),
19 | UPSTASH_REDIS_REST_TOKEN: z.string(),
20 | BLUESKY_USERNAME: z.string().optional(),
21 | BLUESKY_PASSWORD: z.string().optional(),
22 | TWITTER_CONSUMER_KEY: z.string().optional(),
23 | TWITTER_CONSUMER_SECRET: z.string().optional(),
24 | TWITTER_ACCESS_TOKEN: z.string().optional(),
25 | TWITTER_ACCESS_TOKEN_SECRET: z.string().optional(),
26 | NODE_ENV: z
27 | .enum(['development', 'test', 'production'])
28 | .default('development')
29 | },
30 | /**
31 | * Specify your client-side environment variables schema here. This way you can ensure the app
32 | * isn't built with invalid env vars. To expose them to the client, prefix them with
33 | * `NEXT_PUBLIC_`.
34 | */
35 | client: {
36 | NEXT_PUBLIC_BASE_URL: z.string().url(),
37 | NEXT_PUBLIC_MEILISEARCH_SEARCH_KEY: z.string(),
38 | NEXT_PUBLIC_MEILISEARCH_HOST: z.string().url()
39 | },
40 | /**
41 | * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
42 | * middlewares) or client-side so we need to destruct manually.
43 | */
44 | experimental__runtimeEnv: {
45 | NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
46 | NEXT_PUBLIC_MEILISEARCH_SEARCH_KEY:
47 | process.env.NEXT_PUBLIC_MEILISEARCH_SEARCH_KEY,
48 | NEXT_PUBLIC_MEILISEARCH_HOST: process.env.NEXT_PUBLIC_MEILISEARCH_HOST
49 | },
50 | /**
51 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
52 | * useful for Docker builds.
53 | */
54 | skipValidation: !!process.env.SKIP_ENV_VALIDATION,
55 | /**
56 | * Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
57 | * `SOME_VAR=''` will throw an error.
58 | */
59 | emptyStringAsUndefined: true,
60 | /**
61 | * Use the Vercel preset to automatically add Vercel's environment variables.
62 | */
63 | extends: [vercel()]
64 | });
65 |
--------------------------------------------------------------------------------
/src/hooks/useCopyToClipboard.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useCallback, useState } from 'react';
4 |
5 | export const useCopyToClipboard = (timeoutDuration = 500) => {
6 | const [status, setStatus] = useState<'idle' | 'copied' | 'error'>('idle');
7 |
8 | const copy = useCallback(
9 | async (text: string) => {
10 | try {
11 | await navigator.clipboard.writeText(text);
12 | setStatus('copied');
13 | setTimeout(() => setStatus('idle'), timeoutDuration);
14 | } catch (err) {
15 | setStatus('error');
16 | }
17 | },
18 | [timeoutDuration]
19 | );
20 |
21 | return { status, copy };
22 | };
23 |
--------------------------------------------------------------------------------
/src/lib/action.ts:
--------------------------------------------------------------------------------
1 | import { createSafeActionClient } from 'next-safe-action';
2 |
3 | import { getCurrentSession } from './auth';
4 | import { GENERIC_ERROR } from './utils';
5 |
6 | export class ActionError extends Error {
7 | constructor(message = GENERIC_ERROR) {
8 | super(message);
9 | }
10 | }
11 |
12 | export const action = createSafeActionClient({
13 | handleReturnedServerError: (err) => {
14 | return err instanceof ActionError ? err.message : GENERIC_ERROR;
15 | }
16 | });
17 |
18 | export const protectedAction = createSafeActionClient({
19 | handleReturnedServerError: (e) => {
20 | return e instanceof ActionError ? e.message : GENERIC_ERROR;
21 | },
22 | middleware: async () => {
23 | const data = await getCurrentSession();
24 | if (!data.user) {
25 | throw new ActionError('Authentication required');
26 | }
27 | return data;
28 | }
29 | });
30 |
31 | export const moderatorAction = createSafeActionClient({
32 | handleReturnedServerError: (e) => {
33 | return e instanceof ActionError ? e.message : GENERIC_ERROR;
34 | },
35 | middleware: async () => {
36 | const data = await getCurrentSession();
37 | if (!data.user) {
38 | throw new ActionError('Authentication required');
39 | }
40 | if (data.user.role !== 'moderator' && data.user.role !== 'owner') {
41 | throw new ActionError('Insufficient permissions');
42 | }
43 | return data;
44 | }
45 | });
46 |
--------------------------------------------------------------------------------
/src/lib/auth/helpers.ts:
--------------------------------------------------------------------------------
1 | import { cookies } from 'next/headers';
2 | import { cache } from 'react';
3 |
4 | import { getSessionToken, validateSessionToken } from '.';
5 |
6 | // This is in a separate file so we can wrap it with React's cache() without issues
7 | export const getCurrentSession = cache(async () => {
8 | const cookieStore = await cookies();
9 |
10 | const token = getSessionToken(cookieStore);
11 | if (!token) {
12 | return { user: null, session: null };
13 | }
14 |
15 | const validationResult = await validateSessionToken(token);
16 | return validationResult;
17 | });
18 |
--------------------------------------------------------------------------------
/src/lib/auth/index.ts:
--------------------------------------------------------------------------------
1 | import { sha256 } from '@oslojs/crypto/sha2';
2 | import {
3 | encodeBase32LowerCaseNoPadding,
4 | encodeHexLowerCase
5 | } from '@oslojs/encoding';
6 | import { GitHub } from 'arctic';
7 | import { eq } from 'drizzle-orm';
8 | import { cookies } from 'next/headers';
9 | import { cache } from 'react';
10 |
11 | import { env } from '@/env';
12 | import { db } from '@/server/db';
13 | import { sessions, users, type Session, type User } from '@/server/db/schema';
14 |
15 | export type SessionValidationResult =
16 | | { user: User; session: Session }
17 | | { user: null; session: null };
18 |
19 | export const SESSION_TTL = 1000 * 60 * 60 * 24 * 30; // ms
20 | export const SESSION_COOKIE_NAME = 'auth_session';
21 |
22 | export const github = new GitHub(
23 | env.GITHUB_CLIENT_ID,
24 | env.GITHUB_CLIENT_SECRET,
25 | null
26 | );
27 |
28 | export const getSessionToken = (cookies: {
29 | get(name: string): { value: string } | undefined;
30 | }) => cookies?.get(SESSION_COOKIE_NAME)?.value ?? null;
31 |
32 | export function generateSessionToken() {
33 | const bytes = new Uint8Array(20);
34 | crypto.getRandomValues(bytes);
35 | return encodeBase32LowerCaseNoPadding(bytes);
36 | }
37 |
38 | export async function createSession(token: string, userId: string) {
39 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
40 |
41 | const session: Session = {
42 | userId,
43 | id: sessionId,
44 | expiresAt: Date.now() + SESSION_TTL
45 | };
46 |
47 | await db.insert(sessions).values(session);
48 |
49 | return session;
50 | }
51 |
52 | export async function validateSessionToken(
53 | token: string
54 | ): Promise {
55 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
56 |
57 | const result = await db
58 | .select({ user: users, session: sessions })
59 | .from(sessions)
60 | .innerJoin(users, eq(sessions.userId, users.id))
61 | .where(eq(sessions.id, sessionId));
62 |
63 | if (result.length < 1) {
64 | return { user: null, session: null };
65 | }
66 |
67 | const { user, session } = result[0]!;
68 |
69 | if (Date.now() >= session.expiresAt) {
70 | await db.delete(sessions).where(eq(sessions.id, session.id));
71 | return { user: null, session: null };
72 | }
73 |
74 | // Extend expiration when the session is at least halfway through its TTL
75 | if (Date.now() >= session.expiresAt - SESSION_TTL / 2) {
76 | session.expiresAt = Date.now() + SESSION_TTL;
77 | await db
78 | .update(sessions)
79 | .set({ expiresAt: session.expiresAt })
80 | .where(eq(sessions.id, session.id));
81 | }
82 |
83 | return { user, session };
84 | }
85 |
86 | export async function invalidateSession(sessionId: string): Promise {
87 | await db.delete(sessions).where(eq(sessions.id, sessionId));
88 | }
89 |
90 | export async function setSessionCookie(token: string, expiresAt: number) {
91 | const cookieStore = await cookies();
92 | cookieStore.set(SESSION_COOKIE_NAME, token, {
93 | secure: env.NODE_ENV === 'production',
94 | expires: expiresAt,
95 | httpOnly: true,
96 | sameSite: 'lax',
97 | path: '/'
98 | });
99 | }
100 |
101 | export async function deleteSessionCookie() {
102 | const cookieStore = await cookies();
103 | cookieStore.set(SESSION_COOKIE_NAME, '', {
104 | secure: env.NODE_ENV === 'production',
105 | httpOnly: true,
106 | sameSite: 'lax',
107 | maxAge: 0,
108 | path: '/'
109 | });
110 | }
111 |
112 | export const getCurrentSession = cache(async () => {
113 | const cookieStore = await cookies();
114 |
115 | const token = getSessionToken(cookieStore);
116 | if (!token) {
117 | return { user: null, session: null };
118 | }
119 |
120 | const validationResult = await validateSessionToken(token);
121 | return validationResult;
122 | });
123 |
--------------------------------------------------------------------------------
/src/lib/definitions.ts:
--------------------------------------------------------------------------------
1 | import { createHmac } from 'crypto';
2 | import MeiliSearch from 'meilisearch';
3 |
4 | import { env } from '@/env';
5 | import type { DefinitionHit } from '@/types';
6 |
7 | export function getOpenGraphImageUrl(slug: string) {
8 | const hmac = createHmac('sha256', env.OG_HMAC_SECRET);
9 | hmac.update(JSON.stringify({ slug: slug }));
10 | return `${env.NEXT_PUBLIC_BASE_URL}/api/og/${slug}?t=${hmac.digest('hex')}`;
11 | }
12 |
13 | export async function getRandomDefinition() {
14 | const meili = new MeiliSearch({
15 | host: env.NEXT_PUBLIC_MEILISEARCH_HOST,
16 | apiKey: env.MEILISEARCH_MASTER_KEY
17 | });
18 |
19 | const index = meili.index('definitions');
20 |
21 | const { numberOfDocuments } = await index.getStats();
22 |
23 | // The maximum offset seems to be 1000 for now
24 | const maxOffset = Math.min(numberOfDocuments, 1000);
25 | const randomDocumentIdx = Math.floor(Math.random() * maxOffset);
26 |
27 | const searchResponse = await index.search(null, {
28 | offset: randomDocumentIdx,
29 | limit: 1
30 | });
31 |
32 | return searchResponse.hits[0];
33 | }
34 |
--------------------------------------------------------------------------------
/src/lib/id.ts:
--------------------------------------------------------------------------------
1 | import { generateRandomString, type RandomReader } from '@oslojs/crypto/random';
2 |
3 | const ID_ALPHABET =
4 | 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
5 |
6 | export function generateId(prefix: string) {
7 | const random: RandomReader = {
8 | read(bytes) {
9 | crypto.getRandomValues(bytes);
10 | }
11 | };
12 |
13 | const randomId = generateRandomString(random, ID_ALPHABET, 16);
14 | return `${prefix}_${randomId}`;
15 | }
16 |
--------------------------------------------------------------------------------
/src/lib/seo.ts:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next';
2 |
3 | import { env } from '@/env';
4 |
5 | export const APP_NAME = 'DevTerms';
6 | export const APP_DESCRIPTION =
7 | 'The crowdsourced dictionary for developers. Find definitions for technical terms, programming jargon, and more!';
8 |
9 | export const baseMetadata = {
10 | applicationName: APP_NAME,
11 | title: {
12 | default: APP_NAME,
13 | template: `%s | ${APP_NAME}`
14 | },
15 | metadataBase: new URL(env.NEXT_PUBLIC_BASE_URL),
16 | description: APP_DESCRIPTION,
17 | creator: 'aelew',
18 | authors: [
19 | {
20 | name: 'aelew',
21 | url: 'https://aelew.com'
22 | }
23 | ],
24 | openGraph: {
25 | type: 'website',
26 | locale: 'en_US',
27 | title: APP_NAME,
28 | siteName: APP_NAME,
29 | description: APP_DESCRIPTION,
30 | images: [
31 | {
32 | url: `${env.NEXT_PUBLIC_BASE_URL}/og.jpg`,
33 | alt: APP_NAME,
34 | width: 1200,
35 | height: 630
36 | }
37 | ]
38 | },
39 | twitter: {
40 | title: APP_NAME,
41 | creator: '@aelew_',
42 | card: 'summary_large_image',
43 | description: APP_DESCRIPTION,
44 | images: [`${env.NEXT_PUBLIC_BASE_URL}/og.jpg`]
45 | }
46 | } satisfies Metadata;
47 |
48 | export function getPageMetadata(metadata: Metadata): Metadata {
49 | const metadataTitle = metadata.title as string | null | undefined;
50 | return {
51 | ...metadata,
52 | description: metadata.description ?? baseMetadata.description,
53 | openGraph: {
54 | ...baseMetadata.openGraph,
55 | ...metadata.openGraph,
56 | title: `${metadataTitle} | ${APP_NAME}`,
57 | description: metadata.description ?? baseMetadata.openGraph?.description
58 | },
59 | twitter: {
60 | ...baseMetadata.twitter,
61 | ...metadata.twitter,
62 | title: `${metadataTitle} | ${APP_NAME}`,
63 | description: metadata.description ?? baseMetadata.twitter?.description
64 | }
65 | };
66 | }
67 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from 'clsx';
2 | import type { HookResult } from 'next-safe-action/hooks';
3 | import { twMerge } from 'tailwind-merge';
4 | import type { Schema } from 'zod';
5 |
6 | export function cn(...inputs: ClassValue[]) {
7 | return twMerge(clsx(inputs));
8 | }
9 |
10 | export function getActionErrorMessage(
11 | result: Omit, 'data'>
12 | ) {
13 | if (result.serverError) {
14 | return result.serverError;
15 | }
16 | const validationErrors = Object.values(result.validationErrors ?? []);
17 | if (validationErrors.length) {
18 | return validationErrors[0]!.toString();
19 | }
20 | return GENERIC_ERROR;
21 | }
22 |
23 | export function termToSlug(term: string) {
24 | return encodeURIComponent(
25 | term.replaceAll('-', '--').replaceAll(' ', '-').toLowerCase()
26 | );
27 | }
28 |
29 | export function slugToTerm(slug: string) {
30 | return decodeURIComponent(slug).replaceAll('-', ' ').replaceAll(' ', '-');
31 | }
32 |
33 | export function truncateString(text: string, maxLength: number) {
34 | return text.length > maxLength ? text.substring(0, maxLength) + '…' : text;
35 | }
36 |
37 | export const GENERIC_ERROR = 'Uh oh! An unexpected error occurred.';
38 |
39 | export const CATEGORIES = [
40 | 'a',
41 | 'b',
42 | 'c',
43 | 'd',
44 | 'e',
45 | 'f',
46 | 'g',
47 | 'h',
48 | 'i',
49 | 'j',
50 | 'k',
51 | 'l',
52 | 'm',
53 | 'n',
54 | 'o',
55 | 'p',
56 | 'q',
57 | 'r',
58 | 's',
59 | 't',
60 | 'u',
61 | 'v',
62 | 'w',
63 | 'x',
64 | 'y',
65 | 'z',
66 | 'new'
67 | ];
68 |
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import type { NextRequest } from 'next/server';
3 |
4 | import { env } from './env';
5 | import { getSessionToken, SESSION_COOKIE_NAME } from './lib/auth';
6 |
7 | // see https://lucia-auth.com/sessions/cookies/nextjs
8 | export async function middleware(req: NextRequest) {
9 | if (req.method === 'GET') {
10 | const response = NextResponse.next();
11 |
12 | const token = getSessionToken(req.cookies);
13 | if (token) {
14 | const THIRTY_DAYS_IN_SECONDS = 60 * 60 * 24 * 30;
15 |
16 | // Only extend cookie expiration on GET requests to ensure a new session wasn't set while handling the request
17 | response.cookies.set(SESSION_COOKIE_NAME, token, {
18 | secure: env.NODE_ENV === 'production',
19 | maxAge: THIRTY_DAYS_IN_SECONDS,
20 | sameSite: 'lax',
21 | httpOnly: true,
22 | path: '/'
23 | });
24 | }
25 |
26 | return response;
27 | }
28 |
29 | const originHeader = req.headers.get('Origin');
30 | const hostHeader = req.headers.get('Host');
31 |
32 | if (!originHeader || !hostHeader) {
33 | return NextResponse.json(
34 | { success: false, error: 'forbidden' },
35 | { status: 403 }
36 | );
37 | }
38 |
39 | let origin;
40 | try {
41 | origin = new URL(originHeader);
42 | } catch {
43 | return NextResponse.json(
44 | { success: false, error: 'forbidden' },
45 | { status: 403 }
46 | );
47 | }
48 |
49 | if (origin.host !== hostHeader) {
50 | return NextResponse.json(
51 | { success: false, error: 'forbidden' },
52 | { status: 403 }
53 | );
54 | }
55 |
56 | return NextResponse.next();
57 | }
58 |
--------------------------------------------------------------------------------
/src/server/db/index.ts:
--------------------------------------------------------------------------------
1 | import { createClient } from '@libsql/client/web';
2 | import { drizzle } from 'drizzle-orm/libsql';
3 |
4 | import { env } from '@/env';
5 | import * as schema from './schema';
6 |
7 | const client = createClient({
8 | url: env.TURSO_DATABASE_URL,
9 | authToken: env.TURSO_AUTH_TOKEN
10 | });
11 |
12 | export const db = drizzle(client, {
13 | casing: 'snake_case',
14 | schema
15 | });
16 |
--------------------------------------------------------------------------------
/src/server/db/schema.ts:
--------------------------------------------------------------------------------
1 | import { relations, sql } from 'drizzle-orm';
2 | import type { InferSelectModel } from 'drizzle-orm';
3 | import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
4 |
5 | import { generateId } from '@/lib/id';
6 |
7 | export const users = sqliteTable(
8 | 'users',
9 | {
10 | id: text({ length: 21 }).primaryKey(),
11 | name: text({ length: 32 }).notNull(),
12 | role: text({ enum: ['user', 'bot', 'moderator', 'owner'] })
13 | .default('user')
14 | .notNull(),
15 | email: text({ length: 255 }).unique().notNull(),
16 | avatar: text({ length: 255 }).notNull(),
17 | githubId: integer().unique().notNull(),
18 | createdAt: integer()
19 | .default(sql`CURRENT_TIMESTAMP`)
20 | .notNull()
21 | },
22 | (table) => {
23 | return {
24 | nameIdx: index('users_name_idx').on(table.name),
25 | roleIdx: index('users_role_idx').on(table.role)
26 | };
27 | }
28 | );
29 |
30 | export const usersRelations = relations(users, ({ many }) => ({
31 | sessions: many(sessions),
32 | definitions: many(definitions)
33 | }));
34 |
35 | export type User = InferSelectModel;
36 |
37 | export const sessions = sqliteTable('sessions', {
38 | id: text({ length: 255 }).primaryKey(),
39 | userId: text({ length: 21 }).notNull(),
40 | expiresAt: integer().notNull()
41 | });
42 |
43 | export const sessionsRelations = relations(sessions, ({ one }) => ({
44 | user: one(users, {
45 | fields: [sessions.userId],
46 | references: [users.id]
47 | })
48 | }));
49 |
50 | export type Session = InferSelectModel;
51 |
52 | export const definitions = sqliteTable(
53 | 'definitions',
54 | {
55 | id: text('id', { length: 20 })
56 | .primaryKey()
57 | .$defaultFn(() => generateId('def')),
58 | userId: text({ length: 21 }).notNull(),
59 | status: text({ enum: ['pending', 'approved', 'rejected'] })
60 | .default('pending')
61 | .notNull(),
62 | term: text({ length: 255 }).notNull(),
63 | definition: text().notNull(),
64 | example: text().notNull(),
65 | upvotes: integer().default(0).notNull(),
66 | downvotes: integer().default(0).notNull(),
67 | createdAt: integer()
68 | .default(sql`CURRENT_TIMESTAMP`)
69 | .notNull()
70 | },
71 | (table) => {
72 | return {
73 | status: index('definitions_status_idx').on(table.status),
74 | term: index('definitions_term_idx').on(table.term),
75 | upvotes: index('definitions_upvotes_idx').on(table.upvotes),
76 | createdAt: index('definitions_created_at_idx').on(table.createdAt)
77 | };
78 | }
79 | );
80 |
81 | export const definitionRelations = relations(definitions, ({ one }) => ({
82 | user: one(users, {
83 | fields: [definitions.userId],
84 | references: [users.id]
85 | })
86 | }));
87 |
88 | export const reports = sqliteTable(
89 | 'reports',
90 | {
91 | id: text('id', { length: 20 })
92 | .primaryKey()
93 | .$defaultFn(() => generateId('rpt')),
94 | userId: text({ length: 21 }).notNull(),
95 | definitionId: text({ length: 20 }).notNull(),
96 | read: integer({ mode: 'boolean' }).default(false).notNull(),
97 | reason: text().notNull(),
98 | createdAt: integer()
99 | .default(sql`CURRENT_TIMESTAMP`)
100 | .notNull()
101 | },
102 | (table) => {
103 | return {
104 | read: index('reports_read_idx').on(table.read),
105 | createdAt: index('reports_created_at_idx').on(table.createdAt)
106 | };
107 | }
108 | );
109 |
110 | export const reportRelations = relations(reports, ({ one }) => ({
111 | user: one(users, {
112 | fields: [reports.userId],
113 | references: [users.id]
114 | }),
115 | definition: one(definitions, {
116 | fields: [reports.definitionId],
117 | references: [definitions.id]
118 | })
119 | }));
120 |
121 | export const wotds = sqliteTable(
122 | 'wotds',
123 | {
124 | id: text({ length: 21 })
125 | .primaryKey()
126 | .$defaultFn(() => generateId('wotd')),
127 | definitionId: text({ length: 20 }).notNull(),
128 | createdAt: integer()
129 | .default(sql`CURRENT_TIMESTAMP`)
130 | .notNull()
131 | },
132 | (table) => {
133 | return {
134 | createdAt: index('wotds_created_at_idx').on(table.createdAt)
135 | };
136 | }
137 | );
138 |
139 | export const wotdRelations = relations(wotds, ({ one }) => ({
140 | definition: one(definitions, {
141 | fields: [wotds.definitionId],
142 | references: [definitions.id]
143 | })
144 | }));
145 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import type { InferSelectModel } from 'drizzle-orm';
2 |
3 | import type { definitions } from './server/db/schema';
4 |
5 | export type Timestamp = Date | number | string;
6 |
7 | export type Definition = InferSelectModel;
8 |
9 | export type ShareMedium =
10 | | 'X'
11 | | 'Reddit'
12 | | 'LinkedIn'
13 | | 'Facebook'
14 | | 'Email'
15 | | 'QR Code'
16 | | 'Direct';
17 |
18 | export type Events = {
19 | Login: never;
20 | Search: never;
21 | Share: { Medium: ShareMedium };
22 | Report: { 'Definition ID': string };
23 | 'Submit definition': never;
24 | "I'm feeling lucky": never;
25 | };
26 |
27 | export type GitHubUserResponse = {
28 | id: number;
29 | login: string;
30 | node_id: string;
31 | avatar_url: string;
32 | gravatar_id: string;
33 | name: string;
34 | company: string | null;
35 | blog: string | null;
36 | location: string | null;
37 | email: string;
38 | bio: string | null;
39 | twitter_username: string | null;
40 | public_repos: number;
41 | public_gists: number;
42 | followers: number;
43 | following: number;
44 | created_at: string;
45 | updated_at: string;
46 | };
47 |
48 | export type GitHubEmailListResponse = {
49 | email: string;
50 | primary: boolean;
51 | verified: boolean;
52 | visibility: 'public' | 'private' | null;
53 | }[];
54 |
55 | export type DefinitionHit = {
56 | id: string;
57 | term: string;
58 | definition: string;
59 | example: string;
60 | };
61 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss';
2 |
3 | const svgToDataUri = require('mini-svg-data-uri');
4 |
5 | const colors = require('tailwindcss/colors');
6 | const {
7 | default: flattenColorPalette
8 | } = require('tailwindcss/lib/util/flattenColorPalette');
9 |
10 | const config = {
11 | darkMode: ['class'],
12 | content: ['./src/app/**/*.{ts,tsx}', './src/components/**/*.{ts,tsx}'],
13 | prefix: '',
14 | theme: {
15 | container: {
16 | center: true,
17 | padding: '1rem',
18 | screens: {
19 | '2xl': '1000px'
20 | }
21 | },
22 | extend: {
23 | colors: {
24 | border: 'hsl(var(--border))',
25 | input: 'hsl(var(--input))',
26 | ring: 'hsl(var(--ring))',
27 | background: 'hsl(var(--background))',
28 | foreground: 'hsl(var(--foreground))',
29 | primary: {
30 | DEFAULT: 'hsl(var(--primary))',
31 | foreground: 'hsl(var(--primary-foreground))'
32 | },
33 | secondary: {
34 | DEFAULT: 'hsl(var(--secondary))',
35 | foreground: 'hsl(var(--secondary-foreground))'
36 | },
37 | destructive: {
38 | DEFAULT: 'hsl(var(--destructive))',
39 | foreground: 'hsl(var(--destructive-foreground))'
40 | },
41 | muted: {
42 | DEFAULT: 'hsl(var(--muted))',
43 | foreground: 'hsl(var(--muted-foreground))'
44 | },
45 | accent: {
46 | DEFAULT: 'hsl(var(--accent))',
47 | foreground: 'hsl(var(--accent-foreground))'
48 | },
49 | popover: {
50 | DEFAULT: 'hsl(var(--popover))',
51 | foreground: 'hsl(var(--popover-foreground))'
52 | },
53 | card: {
54 | DEFAULT: 'hsl(var(--card))',
55 | foreground: 'hsl(var(--card-foreground))'
56 | }
57 | },
58 | borderRadius: {
59 | lg: 'var(--radius)',
60 | md: 'calc(var(--radius) - 2px)',
61 | sm: 'calc(var(--radius) - 4px)'
62 | },
63 | keyframes: {
64 | 'accordion-down': {
65 | from: { height: '0' },
66 | to: { height: 'var(--radix-accordion-content-height)' }
67 | },
68 | 'accordion-up': {
69 | from: { height: 'var(--radix-accordion-content-height)' },
70 | to: { height: '0' }
71 | }
72 | },
73 | animation: {
74 | 'accordion-down': 'accordion-down 0.2s ease-out',
75 | 'accordion-up': 'accordion-up 0.2s ease-out'
76 | },
77 | transitionProperty: {
78 | 'color-transform':
79 | 'color, background-color, border-color, text-decoration-color, fill, stroke, transform'
80 | }
81 | }
82 | },
83 | plugins: [
84 | require('tailwindcss-animate'),
85 | function ({ matchUtilities, theme }: unknown) {
86 | matchUtilities(
87 | {
88 | 'bg-grid': (value: string) => {
89 | const dataUri = svgToDataUri(
90 | ` `
91 | );
92 | return { backgroundImage: `url("${dataUri}")` };
93 | }
94 | },
95 | { values: flattenColorPalette(theme('backgroundColor')), type: 'color' }
96 | );
97 | }
98 | ]
99 | } satisfies Config;
100 |
101 | export default config;
102 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Base Options: */
4 | "esModuleInterop": true,
5 | "skipLibCheck": true,
6 | "target": "es2022",
7 | "allowJs": true,
8 | "resolveJsonModule": true,
9 | "moduleDetection": "force",
10 | "isolatedModules": true,
11 | "verbatimModuleSyntax": true,
12 |
13 | /* Strictness */
14 | "strict": true,
15 | "noUncheckedIndexedAccess": true,
16 | "checkJs": true,
17 |
18 | /* Bundled projects */
19 | "lib": ["dom", "dom.iterable", "ES2022"],
20 | "noEmit": true,
21 | "module": "ESNext",
22 | "moduleResolution": "Bundler",
23 | "jsx": "preserve",
24 | "plugins": [{ "name": "next" }],
25 | "incremental": true,
26 |
27 | /* Path Aliases */
28 | "baseUrl": ".",
29 | "paths": {
30 | "@/*": ["./src/*"]
31 | }
32 | },
33 | "include": [
34 | ".eslintrc.cjs",
35 | "next-env.d.ts",
36 | "**/*.ts",
37 | "**/*.tsx",
38 | "**/*.cjs",
39 | "**/*.js",
40 | ".next/types/**/*.ts"
41 | ],
42 | "exclude": ["node_modules"]
43 | }
44 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://openapi.vercel.sh/vercel.json",
3 | "crons": [
4 | {
5 | "path": "/api/cron/wotd",
6 | "schedule": "30 4 * * *"
7 | },
8 | {
9 | "path": "/api/cron/meilisearch",
10 | "schedule": "0 0 * * *"
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------