84 | >(({ className, ...props }, ref) => (
85 | [role=checkbox]]:translate-y-[2px]",
89 | className
90 | )}
91 | {...props}
92 | />
93 | ))
94 | TableCell.displayName = "TableCell"
95 |
96 | const TableCaption = React.forwardRef<
97 | HTMLTableCaptionElement,
98 | React.HTMLAttributes
99 | >(({ className, ...props }, ref) => (
100 |
105 | ))
106 | TableCaption.displayName = "TableCaption"
107 |
108 | export {
109 | Table,
110 | TableHeader,
111 | TableBody,
112 | TableFooter,
113 | TableHead,
114 | TableRow,
115 | TableCell,
116 | TableCaption,
117 | }
118 |
--------------------------------------------------------------------------------
/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TabsPrimitive from "@radix-ui/react-tabs"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Tabs = TabsPrimitive.Root
9 |
10 | const TabsList = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ))
23 | TabsList.displayName = TabsPrimitive.List.displayName
24 |
25 | const TabsTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
37 | ))
38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
39 |
40 | const TabsContent = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
52 | ))
53 | TabsContent.displayName = TabsPrimitive.Content.displayName
54 |
55 | export { Tabs, TabsList, TabsTrigger, TabsContent }
56 |
--------------------------------------------------------------------------------
/lib/auth-options.ts:
--------------------------------------------------------------------------------
1 | import { PrismaAdapter } from "@next-auth/prisma-adapter";
2 | import NextAuth, { NextAuthOptions } from "next-auth";
3 | import FacebookProvider from "next-auth/providers/facebook";
4 | import CredentialsProvider from "next-auth/providers/credentials";
5 | import { prisma } from "@/lib/db";
6 | import * as bcrypt from "bcrypt";
7 |
8 | import type { DefaultUser } from "next-auth";
9 |
10 | declare module "next-auth" {
11 | interface Session {
12 | user?: DefaultUser & { id: string };
13 | }
14 | }
15 |
16 | export const authOptions: NextAuthOptions = {
17 | session: {
18 | strategy: "jwt",
19 | },
20 | callbacks: {
21 | async jwt({ token, user }) {
22 | if (user) {
23 | token.user = { ...user };
24 | }
25 | return token;
26 | },
27 | session({ session, token }) {
28 | if (token?.user) {
29 | //@ts-ignore
30 | session.user = token.user;
31 | }
32 | return session;
33 | },
34 | },
35 | adapter: PrismaAdapter(prisma),
36 | pages: {
37 | signIn: "/login",
38 | signOut: "/logout",
39 | },
40 | providers: [
41 | FacebookProvider({
42 | clientId: process.env.FACEBOOK_CLIENT_ID!,
43 | clientSecret: process.env.FACEBOOK_CLIENT_SECRET!,
44 | }),
45 | CredentialsProvider({
46 | name: "Credentials",
47 | credentials: {
48 | email: { label: "Email", type: "text", placeholder: "jane@gmail.com" },
49 | password: { label: "Password", type: "password" },
50 | },
51 | async authorize(credentials, req) {
52 | if (!credentials?.email || !credentials?.password) return null;
53 |
54 | const { email, password } = credentials;
55 | const user = await prisma.user.findUnique({
56 | where: { email },
57 | select: {
58 | id: true,
59 | name: true,
60 | email: true,
61 | password: true,
62 | image: true,
63 | },
64 | });
65 | if (!user || !user.password) return null;
66 | const passwordChecked = await bcrypt.compare(password, user?.password);
67 |
68 | if (user && passwordChecked) {
69 | user.password = null;
70 | return user;
71 | }
72 | return null;
73 | },
74 | }),
75 | ],
76 | };
77 |
--------------------------------------------------------------------------------
/lib/cache.ts:
--------------------------------------------------------------------------------
1 | import { Redis } from "@upstash/redis";
2 |
3 | if (!process.env.KV_URL || !process.env.KV_TOKEN) throw "env not found";
4 |
5 | declare global {
6 | // eslint-disable-next-line no-var
7 | var kv: Redis | undefined;
8 | }
9 |
10 | export const kv =
11 | global.kv ||
12 | new Redis({
13 | url: process.env.KV_URL,
14 | token: process.env.KV_TOKEN,
15 | });
16 |
17 | if (process.env.NODE_ENV !== "production") {
18 | global.kv = kv;
19 | }
20 |
--------------------------------------------------------------------------------
/lib/db.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 |
3 | declare global {
4 | // eslint-disable-next-line no-var
5 | var prisma: PrismaClient | undefined;
6 | }
7 |
8 | export const prisma =
9 | global.prisma ||
10 | new PrismaClient({
11 | log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
12 | });
13 |
14 | if (process.env.NODE_ENV !== "production") {
15 | global.prisma = prisma;
16 | }
17 |
--------------------------------------------------------------------------------
/lib/fb-types.ts:
--------------------------------------------------------------------------------
1 | export interface MetricsResponse {
2 | data: Metric[];
3 | }
4 |
5 | export interface Metric {
6 | name: string;
7 | values: MetricValue[];
8 | period: string;
9 | description: string;
10 | title: string;
11 | id: string;
12 | }
13 |
14 | export interface MetricValue {
15 | value: number;
16 | end_time?: string;
17 | }
18 |
19 | export interface ResponseError {
20 | error: {
21 | message: string;
22 | type: string;
23 | code: number;
24 | fbtrace_id: string;
25 | };
26 | }
27 |
--------------------------------------------------------------------------------
/lib/password.ts:
--------------------------------------------------------------------------------
1 | import * as bcrypt from "bcrypt";
2 |
3 | const saltRounds = 10;
4 |
5 | export const hashPassword = (string: string) => {
6 | return bcrypt.hash(string, saltRounds);
7 | };
8 |
--------------------------------------------------------------------------------
/lib/scrape.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { unstable_after } from "next/server";
4 | import { InstagramImage } from "./utils";
5 | import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
6 |
7 | import { Redis } from "@upstash/redis";
8 |
9 | if (!process.env.KV_URL || !process.env.KV_TOKEN) throw "env not found";
10 |
11 | const kv = new Redis({
12 | url: process.env.KV_URL,
13 | token: process.env.KV_TOKEN,
14 | });
15 |
16 | const s3Client = new S3Client({
17 | credentials: {
18 | accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
19 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
20 | },
21 | region: "us-east-1",
22 | });
23 |
24 | function sortInstagramImages(images: InstagramImage[]) {
25 | return images.sort((a, b) => {
26 | if (a.takenAt > b.takenAt) return -1;
27 | if (a.takenAt < b.takenAt) return 1;
28 | return 0;
29 | });
30 | }
31 |
32 | export async function scrape(account: string) {
33 | // Get cached results (these are always stored)
34 | const cachedResults: InstagramImage[] = sortInstagramImages(
35 | await kv.lrange(account, 0, 12)
36 | );
37 |
38 | // Process and upload new images
39 | unstable_after(async () => {
40 | const expirationKey = `${account}:last_update`;
41 | const isExpired = !(await kv.exists(expirationKey));
42 |
43 | if (!isExpired) {
44 | console.log(`${account} using cache`);
45 | return;
46 | }
47 |
48 | console.log(`Scraping ${account}...`);
49 | let responseRequest = await fetch(
50 | `https://api.scrape.do/?token=${process.env.SCRAPE_API}&url=https://www.instagram.com/api/v1/users/web_profile_info/?username=${account}`
51 | );
52 |
53 | if (!responseRequest.ok) {
54 | if (cachedResults.length > 0) {
55 | return cachedResults;
56 | }
57 | return new Response("Error", { status: 500 });
58 | }
59 |
60 | let response = await responseRequest.json();
61 | let images: InstagramImage[] = sortInstagramImages(
62 | response.data.user.edge_owner_to_timeline_media.edges.map(
63 | ({ node }: any) =>
64 | ({
65 | slug: node.shortcode,
66 | type: extractNodeType(node),
67 | image: node.display_url,
68 | description: node.edge_media_to_caption?.edges[0]?.node?.text,
69 | takenAt: new Date(node.taken_at_timestamp * 1000).toISOString(),
70 | pinned: node.pinned_for_users && node.pinned_for_users.length > 0,
71 | video: node.is_video ? node.video_url : undefined,
72 | } as InstagramImage)
73 | )
74 | );
75 |
76 | console.log(`Uploading ${account}...`);
77 | let finalImageList: InstagramImage[] = images.filter((i) =>
78 | cachedResults.find((c) => c.slug === i.slug)
79 | );
80 |
81 | // Only process new media
82 | let newMedia = images.filter(
83 | (i) => !cachedResults.find((c) => c.slug === i.slug)
84 | );
85 |
86 | if (newMedia.length > 0) {
87 | await Promise.allSettled(
88 | newMedia.map((i) => uploadFile(account, i, finalImageList))
89 | );
90 | console.log(`Ended uploading ${account}`);
91 | } else {
92 | console.log(`No new media to upload for ${account}`);
93 | }
94 |
95 | // Set expiration key with 1 hour TTL (3600 seconds)
96 | await kv.set(expirationKey, new Date().toISOString(), { ex: 3600 });
97 | });
98 |
99 | return cachedResults;
100 |
101 | function extractNodeType(node: any): "carousel_album" | "video" | "image" {
102 | if (node.edge_sidecar_to_children) {
103 | return "carousel_album";
104 | }
105 | if (node.is_video) {
106 | return "video";
107 | }
108 | return "image";
109 | }
110 | }
111 |
112 | async function uploadFile(
113 | account: string,
114 | image: InstagramImage,
115 | finalImageList: InstagramImage[]
116 | ) {
117 | try {
118 | let filename = `${account}/${image.slug}.jpeg`;
119 | let imageRequest = await fetch(image.image);
120 | let buffer = await imageRequest.arrayBuffer();
121 | await s3Client.send(
122 | new PutObjectCommand({
123 | Bucket: "instagram-feed-api",
124 | Key: filename,
125 | Body: Buffer.from(buffer),
126 | CacheControl: "max-age=31536000",
127 | })
128 | );
129 | filename = `${process.env.CDN_URL}/${filename}`;
130 | let video = undefined;
131 | if (image.video) {
132 | let videoFilename = `${account}/${image.slug}.mp4`;
133 | let videoRequest = await fetch(image.video);
134 | let videoBuffer = await videoRequest.arrayBuffer();
135 | await s3Client.send(
136 | new PutObjectCommand({
137 | Bucket: "instagram-feed-api",
138 | Key: videoFilename,
139 | Body: Buffer.from(videoBuffer),
140 | CacheControl: "max-age=31536000",
141 | })
142 | );
143 | video = `${process.env.CDN_URL}/${videoFilename}`;
144 | }
145 | finalImageList.push({ ...image, image: filename, video });
146 | kv.lpush(account, { ...image, image: filename, video });
147 | } catch (e) {
148 | console.error(e);
149 | }
150 | }
151 |
--------------------------------------------------------------------------------
/lib/swagger.ts:
--------------------------------------------------------------------------------
1 | import { createSwaggerSpec } from "next-swagger-doc";
2 |
3 | export const getApiDocs = async () => {
4 | const spec = createSwaggerSpec({
5 | apiFolder: "app/api", // define api folder under app folder
6 | definition: {
7 | openapi: "3.0.0",
8 | info: {
9 | title: "Instagram Swagger API",
10 | version: "1.0",
11 | },
12 | components: {
13 | schemas: {
14 | Media: {
15 | type: "object",
16 | properties: {
17 | id: {
18 | type: "string",
19 | examples: ["17906810171818562"],
20 | },
21 | caption: {
22 | type: "string",
23 | description:
24 | "Caption. Excludes album children. The @ symbol is excluded, unless the app user can perform admin-equivalent tasks on the Facebook Page connected to the Instagram account used to create the caption.",
25 | examples: ["Media caption"],
26 | },
27 | mediaProductType: {
28 | type: "string",
29 | description: "Surface where the media is published",
30 | enum: ["AD", "FEED", "STORY", "REELS"],
31 | },
32 | mediaType: {
33 | type: "string",
34 | description: "Media type",
35 | enum: ["CAROUSEL_ALBUM", "IMAGE", "VIDEO"],
36 | },
37 | thumbnailUrl: {
38 | type: "string",
39 | description:
40 | "Media thumbnail URL. Only available on VIDEO media.",
41 | },
42 | mediaUrl: {
43 | type: "string",
44 | description: "The URL for the media.",
45 | },
46 | shortcode: {
47 | type: "string",
48 | description: "Shortcode to the media.",
49 | examples: ["CwzvMwlO77A"],
50 | },
51 | timestamp: {
52 | type: "string",
53 | description:
54 | "ISO 8601-formatted creation date in UTC (default is UTC ±00:00).",
55 | format: "date-time",
56 | },
57 | username: {
58 | type: "string",
59 | description: "Username of user who created the media.",
60 | },
61 | },
62 | },
63 | LoginURL: {
64 | type: "object",
65 | properties: {
66 | url: {
67 | type: "string",
68 | },
69 | },
70 | },
71 | Pagination: {
72 | type: "object",
73 | properties: {
74 | isFirstPage: {
75 | type: "boolean",
76 | },
77 | isLastPage: {
78 | type: "boolean",
79 | },
80 | currentPage: {
81 | type: "integer",
82 | format: "int64",
83 | },
84 | previousPage: {
85 | type: "integer",
86 | format: "int64",
87 | },
88 | nextPage: {
89 | type: "integer",
90 | format: "int64",
91 | },
92 | pageCount: {
93 | type: "integer",
94 | format: "int64",
95 | },
96 | totalCount: {
97 | type: "integer",
98 | format: "int64",
99 | },
100 | limit: {
101 | type: "integer",
102 | format: "int64",
103 | },
104 | },
105 | },
106 | Error: {
107 | type: "object",
108 | properties: {
109 | error: {
110 | type: "string",
111 | },
112 | },
113 | },
114 | },
115 | securitySchemes: {
116 | BearerAuth: {
117 | type: "http",
118 | scheme: "bearer",
119 | bearerFormat: "JWT",
120 | },
121 | },
122 | },
123 | security: [],
124 | },
125 | });
126 | return spec;
127 | };
128 |
--------------------------------------------------------------------------------
/lib/temporal.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Connection,
3 | ConnectionOptions,
4 | WorkflowClient,
5 | } from "@temporalio/client";
6 |
7 | import Long from "long";
8 | import protobufjs from "protobufjs";
9 |
10 | protobufjs.util.Long = Long;
11 | protobufjs.configure();
12 |
13 | export async function getConnection() {
14 | let connectionParams: ConnectionOptions = {
15 | address: process.env.TEMPORAL_URL,
16 | };
17 | if (process.env.NODE_ENV === "production")
18 | connectionParams = {
19 | ...connectionParams,
20 | tls: {
21 | clientCertPair: {
22 | crt: Buffer.from(process.env.TEMPORAL_CERT_KEY || "", "utf8"),
23 | key: Buffer.from(process.env.TEMPORAL_CERT || "", "utf8"),
24 | },
25 | },
26 | };
27 |
28 | return Connection.connect(connectionParams);
29 | }
30 |
31 | export async function getWorkflowClient() {
32 | let connection = await getConnection();
33 |
34 | return new WorkflowClient({
35 | connection,
36 | namespace: process.env.TEMPORAL_NAMESPACE,
37 | });
38 | }
39 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 | export type InstagramImage = {
8 | slug: string;
9 | type: "video" | "image" | "carousel_album";
10 | image: string;
11 | description: string;
12 | takenAt: string;
13 | pinned: boolean;
14 | video?: string;
15 | };
16 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | experimental: {
4 | after: true,
5 | },
6 | serverExternalPackages: ["prisma", "@temporalio/client"],
7 | images: {
8 | remotePatterns: [
9 | {
10 | protocol: "https",
11 | hostname: "d2b8b46ja6xujp.cloudfront.net",
12 | },
13 | {
14 | protocol: "https",
15 | hostname: "**.fbcdn.net",
16 | },
17 | ],
18 | },
19 | };
20 |
21 | module.exports = nextConfig;
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "instagram-feed-api",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "postinstall": "prisma generate",
9 | "start": "next start",
10 | "lint": "next lint"
11 | },
12 | "dependencies": {
13 | "@aws-sdk/client-s3": "^3.328.0",
14 | "@next-auth/prisma-adapter": "^1.0.7",
15 | "@planetscale/database": "^1.11.0",
16 | "@prisma/client": "5.2.0",
17 | "@radix-ui/react-alert-dialog": "^1.0.5",
18 | "@radix-ui/react-avatar": "^1.0.3",
19 | "@radix-ui/react-dialog": "^1.0.5",
20 | "@radix-ui/react-dropdown-menu": "^2.0.5",
21 | "@radix-ui/react-icons": "^1.3.0",
22 | "@radix-ui/react-label": "^2.0.2",
23 | "@radix-ui/react-slot": "^1.0.2",
24 | "@radix-ui/react-tabs": "^1.0.4",
25 | "@temporalio/client": "^1.8.6",
26 | "@tremor/react": "^3.11.1",
27 | "@types/node": "20.1.0",
28 | "@types/react": "^18.2.21",
29 | "@types/react-dom": "^18.2.7",
30 | "@types/uuid": "^9.0.2",
31 | "@upstash/ratelimit": "^0.4.3",
32 | "@upstash/redis": "^1.22.0",
33 | "@vercel/analytics": "^1.0.1",
34 | "@vercel/functions": "^1.5.1",
35 | "@vercel/kv": "^0.1.2",
36 | "autoprefixer": "10.4.14",
37 | "bcrypt": "^5.1.1",
38 | "class-variance-authority": "^0.7.0",
39 | "clsx": "^2.0.0",
40 | "lucide-react": "^0.216.0",
41 | "next": "^15.0.3",
42 | "next-auth": "^4.24.10",
43 | "next-swagger-doc": "^0.4.0",
44 | "postcss": "8.4.23",
45 | "react": "18.2.0",
46 | "react-dom": "18.2.0",
47 | "react-dropzone": "^14.2.3",
48 | "shiki": "^1.24.0",
49 | "swagger-ui-react": "5.4.2",
50 | "tailwind-merge": "^1.14.0",
51 | "tailwindcss": "3.3.2",
52 | "tailwindcss-animate": "^1.0.7",
53 | "typescript": "^5.2.2",
54 | "uuid": "^9.0.0"
55 | },
56 | "devDependencies": {
57 | "@types/bcrypt": "^5.0.2",
58 | "@types/swagger-ui-react": "4.18.0",
59 | "prisma": "^5.2.0"
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | generator client {
5 | provider = "prisma-client-js"
6 | }
7 |
8 | datasource db {
9 | provider = "mysql"
10 | url = env("DATABASE_URL")
11 | relationMode = "prisma"
12 | }
13 |
14 | model Account {
15 | id String @id @default(cuid())
16 | userId String
17 | type String
18 | provider String
19 | providerAccountId String
20 | refresh_token String? @db.Text
21 | access_token String? @db.Text
22 | expires_at Int?
23 | token_type String?
24 | scope String?
25 | id_token String? @db.Text
26 | session_state String?
27 |
28 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
29 |
30 | @@unique([provider, providerAccountId])
31 | @@index([userId])
32 | }
33 |
34 | model Session {
35 | id String @id @default(cuid())
36 | sessionToken String @unique
37 | userId String
38 | expires DateTime
39 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
40 |
41 | @@index([userId])
42 | }
43 |
44 | model User {
45 | id String @id @default(cuid())
46 | name String?
47 | email String? @unique
48 | password String?
49 | emailVerified DateTime?
50 | image String? @db.Text
51 | createdAt DateTime @default(now())
52 | updatedAt DateTime @default(now()) @updatedAt
53 | accounts Account[]
54 | sessions Session[]
55 | ApiToken ApiToken[]
56 | }
57 |
58 | model VerificationToken {
59 | identifier String
60 | token String @unique
61 | expires DateTime
62 |
63 | @@unique([identifier, token])
64 | }
65 |
66 | model InstagramAccount {
67 | username String @id
68 | id String?
69 | accessToken String? @db.Text()
70 | // instagramAccessTokenExpiry DateTime
71 | // apiToken ApiToken @relation(fields: [apiTokenId], references: [id])
72 | // apiTokenId String
73 | apiTokens ApiToken[]
74 | updatedAt DateTime @updatedAt
75 | createdAt DateTime @default(now())
76 | // Relations
77 | // Publications Publication[]
78 | // Shop Shop[]
79 | // StoryMedia StoryMedia[]
80 | // ApiToken ApiToken? @relation(fields: [apiTokenId], references: [id])
81 | // apiTokenId String?
82 |
83 | // @@unique([username, apiTokenId])
84 | // @@index([apiTokenId])
85 | media Media[]
86 | }
87 |
88 | model ApiToken {
89 | id String @id
90 | description String?
91 | userId String
92 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
93 |
94 | accounts InstagramAccount[]
95 |
96 | @@index([userId])
97 | }
98 |
99 | enum MediaProductType {
100 | AD
101 | FEED
102 | STORY
103 | REELS
104 | }
105 |
106 | enum MediaType {
107 | CAROUSEL_ALBUM
108 | IMAGE
109 | VIDEO
110 | }
111 |
112 | model Media {
113 | id String @id
114 | caption String? @db.Text()
115 | mediaProductType MediaProductType?
116 | mediaType MediaType
117 | thumbnailUrl String? @db.Text()
118 | mediaUrl String @db.Text()
119 | shortcode String
120 | timestamp DateTime?
121 | username String
122 | user InstagramAccount @relation(fields: [username], references: [username], onDelete: Cascade)
123 |
124 | children Media[] @relation("SubMedias")
125 | parent Media? @relation("SubMedias", fields: [parentId], references: [id], onDelete: NoAction, onUpdate: NoAction)
126 | parentId String?
127 |
128 | sizes Json?
129 |
130 | @@index([username])
131 | @@index([parentId])
132 | }
133 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: [
5 | "./pages/**/*.{ts,tsx}",
6 | "./components/**/*.{ts,tsx}",
7 | "./app/**/*.{ts,tsx}",
8 | "./src/**/*.{ts,tsx}",
9 | "./node_modules/@tremor/**/*.{js,ts,jsx,tsx}", // Tremor module
10 | ],
11 | theme: {
12 | transparent: "transparent",
13 | current: "currentColor",
14 | container: {
15 | center: true,
16 | padding: "2rem",
17 | screens: {
18 | "2xl": "1400px",
19 | },
20 | },
21 | extend: {
22 | colors: {
23 | border: "hsl(var(--border))",
24 | input: "hsl(var(--input))",
25 | ring: "hsl(var(--ring))",
26 | background: "hsl(var(--background))",
27 | foreground: "hsl(var(--foreground))",
28 | primary: {
29 | DEFAULT: "hsl(var(--primary))",
30 | foreground: "hsl(var(--primary-foreground))",
31 | },
32 | secondary: {
33 | DEFAULT: "hsl(var(--secondary))",
34 | foreground: "hsl(var(--secondary-foreground))",
35 | },
36 | destructive: {
37 | DEFAULT: "hsl(var(--destructive))",
38 | foreground: "hsl(var(--destructive-foreground))",
39 | },
40 | muted: {
41 | DEFAULT: "hsl(var(--muted))",
42 | foreground: "hsl(var(--muted-foreground))",
43 | },
44 | accent: {
45 | DEFAULT: "hsl(var(--accent))",
46 | foreground: "hsl(var(--accent-foreground))",
47 | },
48 | popover: {
49 | DEFAULT: "hsl(var(--popover))",
50 | foreground: "hsl(var(--popover-foreground))",
51 | },
52 | card: {
53 | DEFAULT: "hsl(var(--card))",
54 | foreground: "hsl(var(--card-foreground))",
55 | },
56 |
57 | // light mode
58 | tremor: {
59 | brand: {
60 | faint: "#eff6ff", // blue-50
61 | muted: "#bfdbfe", // blue-200
62 | subtle: "#60a5fa", // blue-400
63 | DEFAULT: "#3b82f6", // blue-500
64 | emphasis: "#1d4ed8", // blue-700
65 | inverted: "#ffffff", // white
66 | },
67 | background: {
68 | muted: "#f9fafb", // gray-50
69 | subtle: "#f3f4f6", // gray-100
70 | DEFAULT: "#ffffff", // white
71 | emphasis: "#374151", // gray-700
72 | },
73 | border: {
74 | DEFAULT: "#e5e7eb", // gray-200
75 | },
76 | ring: {
77 | DEFAULT: "#e5e7eb", // gray-200
78 | },
79 | content: {
80 | subtle: "#9ca3af", // gray-400
81 | DEFAULT: "#6b7280", // gray-500
82 | emphasis: "#374151", // gray-700
83 | strong: "#111827", // gray-900
84 | inverted: "#ffffff", // white
85 | },
86 | },
87 | // dark mode
88 | "dark-tremor": {
89 | brand: {
90 | faint: "#0B1229", // custom
91 | muted: "#172554", // blue-950
92 | subtle: "#1e40af", // blue-800
93 | DEFAULT: "#3b82f6", // blue-500
94 | emphasis: "#60a5fa", // blue-400
95 | inverted: "#030712", // gray-950
96 | },
97 | background: {
98 | muted: "#131A2B", // custom
99 | subtle: "#1f2937", // gray-800
100 | DEFAULT: "#111827", // gray-900
101 | emphasis: "#d1d5db", // gray-300
102 | },
103 | border: {
104 | DEFAULT: "#1f2937", // gray-800
105 | },
106 | ring: {
107 | DEFAULT: "#1f2937", // gray-800
108 | },
109 | content: {
110 | subtle: "#4b5563", // gray-600
111 | DEFAULT: "#6b7280", // gray-500
112 | emphasis: "#e5e7eb", // gray-200
113 | strong: "#f9fafb", // gray-50
114 | inverted: "#000000", // black
115 | },
116 | },
117 | },
118 | boxShadow: {
119 | // light
120 | "tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)",
121 | "tremor-card":
122 | "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
123 | "tremor-dropdown":
124 | "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
125 | // dark
126 | "dark-tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)",
127 | "dark-tremor-card":
128 | "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
129 | "dark-tremor-dropdown":
130 | "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
131 | },
132 | borderRadius: {
133 | lg: "var(--radius)",
134 | md: "calc(var(--radius) - 2px)",
135 | sm: "calc(var(--radius) - 4px)",
136 | "tremor-small": "0.375rem",
137 | "tremor-default": "0.5rem",
138 | "tremor-full": "9999px",
139 | },
140 | fontSize: {
141 | "tremor-label": ["0.75rem"],
142 | "tremor-default": ["0.875rem", { lineHeight: "1.25rem" }],
143 | "tremor-title": ["1.125rem", { lineHeight: "1.75rem" }],
144 | "tremor-metric": ["1.875rem", { lineHeight: "2.25rem" }],
145 | },
146 | keyframes: {
147 | "accordion-down": {
148 | from: { height: 0 },
149 | to: { height: "var(--radix-accordion-content-height)" },
150 | },
151 | "accordion-up": {
152 | from: { height: "var(--radix-accordion-content-height)" },
153 | to: { height: 0 },
154 | },
155 | },
156 | animation: {
157 | "accordion-down": "accordion-down 0.2s ease-out",
158 | "accordion-up": "accordion-up 0.2s ease-out",
159 | },
160 | },
161 | },
162 | safelist: [
163 | {
164 | pattern:
165 | /^(bg-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
166 | variants: ["hover", "ui-selected"],
167 | },
168 | {
169 | pattern:
170 | /^(text-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
171 | variants: ["hover", "ui-selected"],
172 | },
173 | {
174 | pattern:
175 | /^(border-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
176 | variants: ["hover", "ui-selected"],
177 | },
178 | {
179 | pattern:
180 | /^(ring-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
181 | },
182 | {
183 | pattern:
184 | /^(stroke-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
185 | },
186 | {
187 | pattern:
188 | /^(fill-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
189 | },
190 | ],
191 | plugins: [require("tailwindcss-animate")],
192 | };
193 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "paths": {
23 | "@/*": ["./*"]
24 | }
25 | },
26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------