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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/app/(marketing)/layout.tsx:
--------------------------------------------------------------------------------
1 | import "../globals.css";
2 | import { Inter } from "next/font/google";
3 | import { Analytics } from "@vercel/analytics/react";
4 | import Footer from "../Footer";
5 | import Signin from "../Signin";
6 | import { UserNav } from "../dashboard/components/user-nav";
7 |
8 | const inter = Inter({ subsets: ["latin"] });
9 |
10 | export const metadata = {
11 | title: "Create Next App",
12 | description: "Generated by create next app",
13 | };
14 |
15 | const navigation = [
16 | { name: "Product", href: "/" },
17 | { name: "Faq", href: "/faq" },
18 | { name: "Pricing", href: "/pricing" },
19 | ];
20 |
21 | export default function RootLayout({
22 | children,
23 | }: {
24 | children: React.ReactNode;
25 | }) {
26 | return (
27 |
28 |
29 | {/*
30 |
34 |
39 |
50 |
51 |
52 |
53 |
54 | */}
55 |
56 |
57 |
61 |
62 |
70 |
71 |
72 |
73 |
74 |
78 |
79 |
85 |
86 |
98 | {children}
99 |
100 |
101 |
102 |
103 |
104 | );
105 | }
106 |
--------------------------------------------------------------------------------
/app/(marketing)/page.tsx:
--------------------------------------------------------------------------------
1 | import { GenerateFeed } from "../_actions";
2 | import type { Metadata } from "next";
3 | import { CodeExamples } from "./components/CodeExamples";
4 |
5 | export const metadata: Metadata = {
6 | title: "Instagram Feed API",
7 | description: "Generate an API for your Instagram Feed in seconds",
8 | };
9 |
10 | const faqs = [
11 | {
12 | id: 1,
13 | question: "Do I need a credit card to start using?",
14 | answer: "No, our service is free until you reach our limits",
15 | },
16 | {
17 | id: 2,
18 | question: "Whats the limitation of the free plan?",
19 | answer: "On the free plan you are limited to 5 requests per 10 seconds",
20 | },
21 | {
22 | id: 3,
23 | question: "What happens if I need more?",
24 | answer: "Please contact us to upgrade your plan at contact@scribo.dev",
25 | },
26 | ];
27 |
28 | export default function Home() {
29 | return (
30 |
31 |
32 |
33 |
34 |
35 |
36 | Free Instagram Feed API
37 |
38 |
39 | Imagine a world where you can seamlessly integrate Instagram
40 | feeds into your applications and projects, enhancing user
41 | experience and engagement like never before. InstaConnect API is
42 | here to turn that vision into reality!
43 |
44 |
45 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | Frequently asked questions
71 |
72 |
73 | {faqs.map((faq) => (
74 |
78 |
79 | {faq.question}
80 |
81 |
82 |
83 | {faq.answer}
84 |
85 |
86 |
87 | ))}
88 |
89 |
90 |
91 |
92 |
93 | );
94 | }
95 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/app/(marketing)/pricing/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from "next";
2 |
3 | const tiers = [
4 | {
5 | name: "Personal",
6 | id: "tier-personal",
7 | href: "/",
8 | priceMonthly: "Free",
9 | description: "Start using without a credit card",
10 | features: ["Realtime updates", "Super fast", "Up to 1,000 requests"],
11 | featured: true,
12 | },
13 | {
14 | name: "Team",
15 | id: "tier-team",
16 | href: "/",
17 | priceMonthly: "$9",
18 | description: "A plan that scales with your rapidly growing business.",
19 | features: ["Unlimited Requests"],
20 | featured: false,
21 | },
22 | ];
23 |
24 | export const metadata: Metadata = {
25 | title: "Pricing | Instagram Feed API",
26 | description: "Start using your Instagram Feed API for free",
27 | };
28 |
29 | function classNames(...classes: any) {
30 | return classes.filter(Boolean).join(" ");
31 | }
32 |
33 | export default async function Page() {
34 | return (
35 |
36 |
40 |
41 |
42 | Pricing
43 |
44 |
45 | The right price for you, whoever you are
46 |
47 |
48 |
49 | Start Free
50 |
51 |
52 | {tiers.map((tier, tierIdx) => (
53 |
67 |
71 | {tier.name}
72 |
73 |
74 |
75 | {tier.priceMonthly}
76 |
77 | /month
78 |
79 |
80 | {tier.description}
81 |
82 |
86 | {tier.features.map((feature) => (
87 |
88 | {/* */}
92 | {feature}
93 |
94 | ))}
95 |
96 |
106 | Get started today
107 |
108 |
109 | ))}
110 |
111 |
112 | );
113 | }
114 |
--------------------------------------------------------------------------------
/components/ui/Dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { Cross2Icon } from "@radix-ui/react-icons"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = DialogPrimitive.Portal
14 |
15 | const DialogClose = DialogPrimitive.Close
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ))
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ))
54 | DialogContent.displayName = DialogPrimitive.Content.displayName
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | )
68 | DialogHeader.displayName = "DialogHeader"
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | )
82 | DialogFooter.displayName = "DialogFooter"
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ))
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogTrigger,
116 | DialogClose,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | }
123 |
--------------------------------------------------------------------------------
/app/dashboard/[account]/metrics/page.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import { getMetrics } from "./actions";
3 | import {
4 | Accordion,
5 | AccordionBody,
6 | AccordionHeader,
7 | AreaChart,
8 | BadgeDelta,
9 | Callout,
10 | Card,
11 | SparkAreaChart,
12 | Text,
13 | Title,
14 | } from "@tremor/react";
15 | import { MetricsDateRangePicker } from "./DatePicker";
16 | import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
17 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
18 |
19 | export default async function Page(
20 | props: {
21 | params: Promise<{ account: string }>;
22 | searchParams: Promise;
23 | }
24 | ) {
25 | const searchParams = await props.searchParams;
26 | const params = await props.params;
27 | let dates: string[] = [];
28 | if (searchParams.from && searchParams.to)
29 | dates = [searchParams.from, searchParams.to];
30 |
31 | const { profile, metrics, error } = await getMetrics(params.account, dates);
32 |
33 | return (
34 |
35 |
36 | {profile && (
37 |
38 |
39 |
46 |
47 |
{params.account}
48 |
49 |
50 |
Metrics
51 |
55 |
56 | {error && (
57 |
58 |
59 |
60 | Error
61 | {error.error.message}
62 |
63 |
64 | )}
65 | {metrics &&
66 | metrics.map((m) => {
67 | const delta =
68 | (m.metrics?.at(-1)?.metric ?? 0) -
69 | (m.metrics?.at(0)?.metric ?? 0);
70 | return (
71 |
72 |
73 |
74 |
75 |
{m.title}
76 |
77 | {m.metrics && (
78 |
85 | )}
86 |
87 |
88 |
89 | 0 ? "increase" : "decrease"}
91 | >
92 | {`${delta}`}
93 |
94 |
95 |
96 |
97 | {m.metrics && (
98 |
107 | )}
108 |
109 |
110 | );
111 | })}
112 |
113 |
114 | )}
115 |
116 |
117 | );
118 | }
119 |
--------------------------------------------------------------------------------
/app/cors.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Multi purpose CORS lib.
3 | * Note: Based on the `cors` package in npm but using only
4 | * web APIs. Feel free to use it in your own projects.
5 | */
6 |
7 | type StaticOrigin = boolean | string | RegExp | (boolean | string | RegExp)[];
8 |
9 | type OriginFn = (
10 | origin: string | undefined,
11 | req: Request
12 | ) => StaticOrigin | Promise;
13 |
14 | interface CorsOptions {
15 | origin?: StaticOrigin | OriginFn;
16 | methods?: string | string[];
17 | allowedHeaders?: string | string[];
18 | exposedHeaders?: string | string[];
19 | credentials?: boolean;
20 | maxAge?: number;
21 | preflightContinue?: boolean;
22 | optionsSuccessStatus?: number;
23 | }
24 |
25 | const defaultOptions: CorsOptions = {
26 | origin: "*",
27 | methods: "GET,HEAD,PUT,PATCH,POST,DELETE",
28 | preflightContinue: false,
29 | optionsSuccessStatus: 204,
30 | };
31 |
32 | function isOriginAllowed(origin: string, allowed: StaticOrigin): boolean {
33 | return Array.isArray(allowed)
34 | ? allowed.some((o) => isOriginAllowed(origin, o))
35 | : typeof allowed === "string"
36 | ? origin === allowed
37 | : allowed instanceof RegExp
38 | ? allowed.test(origin)
39 | : !!allowed;
40 | }
41 |
42 | function getOriginHeaders(reqOrigin: string | undefined, origin: StaticOrigin) {
43 | const headers = new Headers();
44 |
45 | if (origin === "*") {
46 | // Allow any origin
47 | headers.set("Access-Control-Allow-Origin", "*");
48 | } else if (typeof origin === "string") {
49 | // Fixed origin
50 | headers.set("Access-Control-Allow-Origin", origin);
51 | headers.append("Vary", "Origin");
52 | } else {
53 | const allowed = isOriginAllowed(reqOrigin ?? "", origin);
54 |
55 | if (allowed && reqOrigin) {
56 | headers.set("Access-Control-Allow-Origin", reqOrigin);
57 | }
58 | headers.append("Vary", "Origin");
59 | }
60 |
61 | return headers;
62 | }
63 |
64 | // originHeadersFromReq
65 |
66 | async function originHeadersFromReq(
67 | req: Request,
68 | origin: StaticOrigin | OriginFn
69 | ) {
70 | const reqOrigin = req.headers.get("Origin") || undefined;
71 | const value =
72 | typeof origin === "function" ? await origin(reqOrigin, req) : origin;
73 |
74 | if (!value) return;
75 | return getOriginHeaders(reqOrigin, value);
76 | }
77 |
78 | function getAllowedHeaders(req: Request, allowed?: string | string[]) {
79 | const headers = new Headers();
80 |
81 | if (!allowed) {
82 | allowed = req.headers.get("Access-Control-Request-Headers")!;
83 | headers.append("Vary", "Access-Control-Request-Headers");
84 | } else if (Array.isArray(allowed)) {
85 | // If the allowed headers is an array, turn it into a string
86 | allowed = allowed.join(",");
87 | }
88 | if (allowed) {
89 | headers.set("Access-Control-Allow-Headers", allowed);
90 | }
91 |
92 | return headers;
93 | }
94 |
95 | export default async function cors(
96 | req: Request,
97 | res: Response,
98 | options?: CorsOptions
99 | ) {
100 | const opts = { ...defaultOptions, ...options };
101 | const { headers } = res;
102 | const originHeaders = await originHeadersFromReq(req, opts.origin ?? false);
103 | const mergeHeaders = (v: string, k: string) => {
104 | if (k === "Vary") headers.append(k, v);
105 | else headers.set(k, v);
106 | };
107 |
108 | // If there's no origin we won't touch the response
109 | if (!originHeaders) return res;
110 |
111 | originHeaders.forEach(mergeHeaders);
112 |
113 | if (opts.credentials) {
114 | headers.set("Access-Control-Allow-Credentials", "true");
115 | }
116 |
117 | const exposed = Array.isArray(opts.exposedHeaders)
118 | ? opts.exposedHeaders.join(",")
119 | : opts.exposedHeaders;
120 |
121 | if (exposed) {
122 | headers.set("Access-Control-Expose-Headers", exposed);
123 | }
124 |
125 | // Handle the preflight request
126 | if (req.method === "OPTIONS") {
127 | if (opts.methods) {
128 | const methods = Array.isArray(opts.methods)
129 | ? opts.methods.join(",")
130 | : opts.methods;
131 |
132 | headers.set("Access-Control-Allow-Methods", methods);
133 | }
134 |
135 | getAllowedHeaders(req, opts.allowedHeaders).forEach(mergeHeaders);
136 |
137 | if (typeof opts.maxAge === "number") {
138 | headers.set("Access-Control-Max-Age", String(opts.maxAge));
139 | }
140 |
141 | if (opts.preflightContinue) return res;
142 |
143 | headers.set("Content-Length", "0");
144 | return new Response(null, { status: opts.optionsSuccessStatus, headers });
145 | }
146 |
147 | // If we got here, it's a normal request
148 | return res;
149 | }
150 |
151 | export function initCors(options?: CorsOptions) {
152 | return (req: Request, res: Response) => cors(req, res, options);
153 | }
154 |
--------------------------------------------------------------------------------
/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SheetPrimitive from "@radix-ui/react-dialog"
5 | import { Cross2Icon } from "@radix-ui/react-icons"
6 | import { cva, type VariantProps } from "class-variance-authority"
7 |
8 | import { cn } from "@/lib/utils"
9 |
10 | const Sheet = SheetPrimitive.Root
11 |
12 | const SheetTrigger = SheetPrimitive.Trigger
13 |
14 | const SheetClose = SheetPrimitive.Close
15 |
16 | const SheetPortal = SheetPrimitive.Portal
17 |
18 | const SheetOverlay = React.forwardRef<
19 | React.ElementRef,
20 | React.ComponentPropsWithoutRef
21 | >(({ className, ...props }, ref) => (
22 |
30 | ))
31 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
32 |
33 | const sheetVariants = cva(
34 | "fixed z-50 gap-4 bg-white p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500 dark:bg-slate-950",
35 | {
36 | variants: {
37 | side: {
38 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
39 | bottom:
40 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
41 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
42 | right:
43 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
44 | },
45 | },
46 | defaultVariants: {
47 | side: "right",
48 | },
49 | }
50 | )
51 |
52 | interface SheetContentProps
53 | extends React.ComponentPropsWithoutRef,
54 | VariantProps {}
55 |
56 | const SheetContent = React.forwardRef<
57 | React.ElementRef,
58 | SheetContentProps
59 | >(({ side = "right", className, children, ...props }, ref) => (
60 |
61 |
62 |
67 | {children}
68 |
69 |
70 | Close
71 |
72 |
73 |
74 | ))
75 | SheetContent.displayName = SheetPrimitive.Content.displayName
76 |
77 | const SheetHeader = ({
78 | className,
79 | ...props
80 | }: React.HTMLAttributes) => (
81 |
88 | )
89 | SheetHeader.displayName = "SheetHeader"
90 |
91 | const SheetFooter = ({
92 | className,
93 | ...props
94 | }: React.HTMLAttributes) => (
95 |
102 | )
103 | SheetFooter.displayName = "SheetFooter"
104 |
105 | const SheetTitle = React.forwardRef<
106 | React.ElementRef,
107 | React.ComponentPropsWithoutRef
108 | >(({ className, ...props }, ref) => (
109 |
114 | ))
115 | SheetTitle.displayName = SheetPrimitive.Title.displayName
116 |
117 | const SheetDescription = React.forwardRef<
118 | React.ElementRef,
119 | React.ComponentPropsWithoutRef
120 | >(({ className, ...props }, ref) => (
121 |
126 | ))
127 | SheetDescription.displayName = SheetPrimitive.Description.displayName
128 |
129 | export {
130 | Sheet,
131 | SheetPortal,
132 | SheetOverlay,
133 | SheetTrigger,
134 | SheetClose,
135 | SheetContent,
136 | SheetHeader,
137 | SheetFooter,
138 | SheetTitle,
139 | SheetDescription,
140 | }
141 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
5 |
6 | import { cn } from "@/lib/utils"
7 | import { buttonVariants } from "@/components/ui/button"
8 |
9 | const AlertDialog = AlertDialogPrimitive.Root
10 |
11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger
12 |
13 | const AlertDialogPortal = AlertDialogPrimitive.Portal
14 |
15 | const AlertDialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ))
28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
29 |
30 | const AlertDialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, ...props }, ref) => (
34 |
35 |
36 |
44 |
45 | ))
46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
47 |
48 | const AlertDialogHeader = ({
49 | className,
50 | ...props
51 | }: React.HTMLAttributes) => (
52 |
59 | )
60 | AlertDialogHeader.displayName = "AlertDialogHeader"
61 |
62 | const AlertDialogFooter = ({
63 | className,
64 | ...props
65 | }: React.HTMLAttributes) => (
66 |
73 | )
74 | AlertDialogFooter.displayName = "AlertDialogFooter"
75 |
76 | const AlertDialogTitle = React.forwardRef<
77 | React.ElementRef,
78 | React.ComponentPropsWithoutRef
79 | >(({ className, ...props }, ref) => (
80 |
85 | ))
86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
87 |
88 | const AlertDialogDescription = React.forwardRef<
89 | React.ElementRef,
90 | React.ComponentPropsWithoutRef
91 | >(({ className, ...props }, ref) => (
92 |
97 | ))
98 | AlertDialogDescription.displayName =
99 | AlertDialogPrimitive.Description.displayName
100 |
101 | const AlertDialogAction = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ))
111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
112 |
113 | const AlertDialogCancel = React.forwardRef<
114 | React.ElementRef,
115 | React.ComponentPropsWithoutRef
116 | >(({ className, ...props }, ref) => (
117 |
126 | ))
127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
128 |
129 | export {
130 | AlertDialog,
131 | AlertDialogPortal,
132 | AlertDialogOverlay,
133 | AlertDialogTrigger,
134 | AlertDialogContent,
135 | AlertDialogHeader,
136 | AlertDialogFooter,
137 | AlertDialogTitle,
138 | AlertDialogDescription,
139 | AlertDialogAction,
140 | AlertDialogCancel,
141 | }
142 |
--------------------------------------------------------------------------------
/app/dashboard/[account]/page.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import type { Metadata } from "next";
3 | import { prisma } from "@/lib/db";
4 | import { Media } from "@prisma/client";
5 | import { getServerSession } from "next-auth";
6 |
7 | import Link from "next/link";
8 | import { authOptions } from "@/lib/auth-options";
9 | import { LineChart, RefreshCcw } from "lucide-react";
10 | import { Button } from "@/components/ui/button";
11 |
12 | type PageProps = {
13 | params: Promise<{ account: string }>;
14 | };
15 |
16 | export async function generateMetadata(props: PageProps): Promise {
17 | const params = await props.params;
18 | return {
19 | title: `@${params.account} Instagram Feed API`,
20 | description: `Feed API from @${params.account}`,
21 | };
22 | }
23 |
24 | async function getData(account: string) {
25 | const session = await getServerSession(authOptions);
26 |
27 | const tokens = await prisma?.apiToken.findMany({
28 | where: { userId: session?.user?.id },
29 | include: {
30 | accounts: true,
31 | },
32 | });
33 | const selectedToken = tokens && tokens[0];
34 | const images = (
35 | await (
36 | await fetch(
37 | `${process.env.APP_URL}/api/v2/${account}?media-product-type=FEED,REELS`,
38 | {
39 | headers: { Authorization: `Bearer ${selectedToken.id}` },
40 | }
41 | )
42 | ).json()
43 | ).data as Media[];
44 |
45 | const stories = (
46 | await (
47 | await fetch(
48 | `${process.env.APP_URL}/api/v2/${account}?media-product-type=STORY`,
49 | {
50 | headers: { Authorization: `Bearer ${selectedToken.id}` },
51 | }
52 | )
53 | ).json()
54 | ).data as Media[];
55 | return { images, stories };
56 | }
57 |
58 | export default async function Page(props: PageProps) {
59 | const params = await props.params;
60 | let { images, stories } = await getData(params.account);
61 |
62 | return (
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | {params.account}
71 |
72 |
73 |
74 |
75 |
76 | Metrics
77 |
78 |
79 |
80 |
81 | {!images || images.length === 0 ? (
82 |
83 |
84 | Syncing...
85 |
86 | Please retry in a couple of minutes
87 |
88 |
89 |
93 | Refresh
94 |
95 |
96 |
97 | ) : null}
98 |
99 | {stories?.reverse().map((i) => (
100 |
106 |
113 |
114 | ))}
115 |
116 |
117 | {images?.map((i) => (
118 |
124 |
131 |
132 | ))}
133 |
134 |
135 |
136 |
137 |
138 |
139 | );
140 | }
141 |
--------------------------------------------------------------------------------
/app/(marketing)/account-selector/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from "next";
2 | import { prisma } from "@/lib/db";
3 | import { usePathname, useRouter, useSearchParams } from "next/navigation";
4 | import { revalidatePath } from "next/cache";
5 | import { Button } from "@/components/ui/button";
6 | import { CheckIcon, PlusCircleIcon } from "lucide-react";
7 |
8 | export const dynamic = "force-dynamic";
9 |
10 | type FacebookResponse = {
11 | data: FacebookAccount[];
12 | };
13 |
14 | type FacebookAccount = {
15 | id: string;
16 | name: string;
17 | access_token: string;
18 | instagram_business_account: {
19 | id: string;
20 | name: string;
21 | username: string;
22 | };
23 | };
24 |
25 | type FacebookTokenResponse = {
26 | app_id: string;
27 | type: string;
28 | application: string;
29 | data_access_expires_at: number;
30 | expires_at: number;
31 | is_valid: boolean;
32 | scopes: string[];
33 | user_id: string;
34 | };
35 |
36 | async function getData(searchParams: {
37 | [key: string]: string | string[] | undefined;
38 | }) {
39 | const res = await fetch(
40 | `https://graph.facebook.com/v18.0/me/accounts?fields=id%2Cname%2Caccess_token%2Cinstagram_business_account{id,name,username}&access_token=${
41 | searchParams["long_lived_token"] || searchParams["access_token"]
42 | }`,
43 | { cache: "no-store" }
44 | );
45 |
46 | if (!res.ok) {
47 | console.error(res);
48 | // This will activate the closest `error.js` Error Boundary
49 | throw new Error("Failed to fetch data");
50 | }
51 |
52 | return (await res.json()) as FacebookResponse;
53 | }
54 |
55 | async function getTokenInfo(searchParams: {
56 | [key: string]: string | string[] | undefined;
57 | }) {
58 | const res = await fetch(
59 | `https://graph.facebook.com/debug_token?&input_token=${searchParams["access_token"]}&access_token=${process.env.FACEBOOK_CLIENT_ID}|${process.env.FACEBOOK_CLIENT_SECRET}`,
60 | { cache: "no-store" }
61 | );
62 |
63 | if (!res.ok) {
64 | console.error(res);
65 | // This will activate the closest `error.js` Error Boundary
66 | throw new Error("Failed to fetch data");
67 | }
68 |
69 | return (await res.json()) as { data: FacebookTokenResponse };
70 | }
71 |
72 | export default async function Page(
73 | props: {
74 | params: Promise<{ slug: string }>;
75 | searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
76 | }
77 | ) {
78 | const searchParams = await props.searchParams;
79 | const data = await getData(searchParams);
80 | const tokenInfo = await getTokenInfo(searchParams);
81 |
82 | const alreadyConnected = await prisma.instagramAccount.findMany({
83 | select: {
84 | id: true,
85 | username: true,
86 | },
87 | where: {
88 | username: {
89 | in: data?.data
90 | ?.filter((a) => a.instagram_business_account)
91 | .map((a) => a.instagram_business_account.username),
92 | },
93 | },
94 | });
95 |
96 | async function connect(formData: FormData) {
97 | "use server";
98 | const id = formData.get("id") as string;
99 | const instagramUsername = formData.get("username") as string;
100 | const authToken = formData.get("token") as string;
101 | const userId = formData.get("userId") as string;
102 |
103 | const pageTokenRequest = await fetch(
104 | `https://graph.facebook.com/oauth/access_token?grant_type=fb_exchange_token&client_id=${process.env.FACEBOOK_CLIENT_ID}&client_secret=${process.env.FACEBOOK_CLIENT_SECRET}&fb_exchange_token=${authToken}`
105 | );
106 |
107 | const pageTokenResponse = await pageTokenRequest.json();
108 |
109 | const pageLongTokenRequest = await fetch(
110 | `https://graph.facebook.com/v17.0/${userId}/accounts?access_token=${pageTokenResponse.access_token}`
111 | );
112 | const pageLongTokenResponse = await pageLongTokenRequest.json();
113 |
114 | if (!pageLongTokenResponse.data || pageLongTokenResponse.data.length === 0)
115 | return;
116 |
117 | const longLivedToken = pageLongTokenResponse.data[0].access_token;
118 |
119 | await prisma.instagramAccount.upsert({
120 | where: {
121 | username: instagramUsername,
122 | },
123 | create: {
124 | id: id,
125 | username: instagramUsername,
126 | accessToken: longLivedToken,
127 | },
128 | update: { id: id, accessToken: longLivedToken, updatedAt: new Date() },
129 | });
130 | revalidatePath("/account-selector");
131 | // mutate data
132 | // revalidate cache
133 | }
134 |
135 | return (
136 |
137 |
138 |
139 | Accounts
140 |
141 |
142 | {data?.data
143 | ?.filter((a) => a.instagram_business_account)
144 | .map((account) => (
145 |
185 | ))}
186 |
187 |
188 |
189 | );
190 | }
191 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/app/dashboard/account-selector/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from "next";
2 | import { prisma } from "@/lib/db";
3 | import {
4 | redirect,
5 | usePathname,
6 | useRouter,
7 | useSearchParams,
8 | } from "next/navigation";
9 | import { revalidatePath } from "next/cache";
10 | import { Button } from "@/components/ui/button";
11 | import { CheckIcon, PlusCircleIcon } from "lucide-react";
12 | import { getServerSession } from "next-auth";
13 |
14 | import ConnectButton from "./ConnectButton";
15 | import { authOptions } from "@/lib/auth-options";
16 |
17 | export const dynamic = "force-dynamic";
18 |
19 | export const metadata: Metadata = {
20 | title: "Account selector",
21 | };
22 |
23 | type FacebookResponse = {
24 | data: FacebookAccount[];
25 | };
26 |
27 | type FacebookAccount = {
28 | id: string;
29 | name: string;
30 | access_token: string;
31 | instagram_business_account: {
32 | id: string;
33 | name: string;
34 | username: string;
35 | };
36 | };
37 |
38 | type FacebookTokenResponse = {
39 | app_id: string;
40 | type: string;
41 | application: string;
42 | data_access_expires_at: number;
43 | expires_at: number;
44 | is_valid: boolean;
45 | scopes: string[];
46 | user_id: string;
47 | };
48 |
49 | async function getData(searchParams: {
50 | [key: string]: string | string[] | undefined;
51 | }) {
52 | const res = await fetch(
53 | `https://graph.facebook.com/v18.0/me/accounts?fields=id%2Cname%2Caccess_token%2Cinstagram_business_account{id,name,username}&access_token=${
54 | searchParams["long_lived_token"] || searchParams["access_token"]
55 | }`,
56 | { cache: "no-store" }
57 | );
58 |
59 | if (!res.ok) {
60 | console.error(res);
61 | // This will activate the closest `error.js` Error Boundary
62 | throw new Error("Failed to fetch data");
63 | }
64 |
65 | return (await res.json()) as FacebookResponse;
66 | }
67 |
68 | async function getTokenInfo(searchParams: {
69 | [key: string]: string | string[] | undefined;
70 | }) {
71 | const res = await fetch(
72 | `https://graph.facebook.com/debug_token?&input_token=${searchParams["access_token"]}&access_token=${process.env.FACEBOOK_CLIENT_ID}|${process.env.FACEBOOK_CLIENT_SECRET}`,
73 | { cache: "no-store" }
74 | );
75 |
76 | if (!res.ok) {
77 | console.error(res);
78 | // This will activate the closest `error.js` Error Boundary
79 | throw new Error("Failed to fetch data");
80 | }
81 |
82 | return (await res.json()) as { data: FacebookTokenResponse };
83 | }
84 |
85 | export default async function Page(
86 | props: {
87 | params: Promise<{ slug: string }>;
88 | searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
89 | }
90 | ) {
91 | const searchParams = await props.searchParams;
92 | const data = await getData(searchParams);
93 | const tokenInfo = await getTokenInfo(searchParams);
94 | const session = await getServerSession(authOptions);
95 |
96 | const tokens = await prisma?.apiToken.findMany({
97 | where: { userId: session?.user?.id },
98 | include: {
99 | accounts: true,
100 | },
101 | });
102 | const selectedToken = tokens[0];
103 |
104 | if (!selectedToken) return null;
105 |
106 | const alreadyConnected = await prisma.instagramAccount.findMany({
107 | select: {
108 | id: true,
109 | username: true,
110 | },
111 | where: {
112 | username: {
113 | in: data?.data
114 | ?.filter((a) => a.instagram_business_account)
115 | .map((a) => a.instagram_business_account.username),
116 | },
117 | },
118 | });
119 |
120 | async function connect(formData: FormData) {
121 | "use server";
122 | const id = formData.get("id") as string;
123 | const instagramUsername = formData.get("username") as string;
124 | const authToken = formData.get("token") as string;
125 | const userId = formData.get("userId") as string;
126 |
127 | const pageTokenRequest = await fetch(
128 | `https://graph.facebook.com/oauth/access_token?grant_type=fb_exchange_token&client_id=${process.env.FACEBOOK_CLIENT_ID}&client_secret=${process.env.FACEBOOK_CLIENT_SECRET}&fb_exchange_token=${authToken}`
129 | );
130 |
131 | const pageTokenResponse = await pageTokenRequest.json();
132 |
133 | const pageLongTokenRequest = await fetch(
134 | `https://graph.facebook.com/v17.0/${userId}/accounts?access_token=${pageTokenResponse.access_token}`
135 | );
136 | const pageLongTokenResponse = await pageLongTokenRequest.json();
137 |
138 | if (!pageLongTokenResponse.data || pageLongTokenResponse.data.length === 0)
139 | return;
140 |
141 | const longLivedToken = pageLongTokenResponse.data[0].access_token;
142 |
143 | await prisma.instagramAccount.upsert({
144 | where: {
145 | username: instagramUsername,
146 | },
147 | create: {
148 | id: id,
149 | username: instagramUsername,
150 | accessToken: longLivedToken,
151 | },
152 | update: { id: id, accessToken: longLivedToken, updatedAt: new Date() },
153 | });
154 |
155 | if (!session || !session.user) return;
156 |
157 | await prisma?.apiToken.update({
158 | where: { id: selectedToken?.id },
159 | data: {
160 | userId: session.user.id,
161 | accounts: {
162 | connectOrCreate: {
163 | where: {
164 | username: instagramUsername,
165 | },
166 | create: {
167 | username: instagramUsername,
168 | },
169 | },
170 | },
171 | },
172 | });
173 |
174 | redirect("/dashboard");
175 | }
176 |
177 | return (
178 |
179 |
180 |
Select Pages
181 |
182 | Choose which accounts to connect
183 |
184 |
185 | {data?.data
186 | ?.filter((a) => a.instagram_business_account)
187 | .map((account) => (
188 |
225 | ))}
226 |
227 |
228 |
229 | );
230 | }
231 |
--------------------------------------------------------------------------------
/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5 | import {
6 | CheckIcon,
7 | ChevronRightIcon,
8 | DotFilledIcon,
9 | } from "@radix-ui/react-icons"
10 |
11 | import { cn } from "@/lib/utils"
12 |
13 | const DropdownMenu = DropdownMenuPrimitive.Root
14 |
15 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
16 |
17 | const DropdownMenuGroup = DropdownMenuPrimitive.Group
18 |
19 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal
20 |
21 | const DropdownMenuSub = DropdownMenuPrimitive.Sub
22 |
23 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
24 |
25 | const DropdownMenuSubTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef & {
28 | inset?: boolean
29 | }
30 | >(({ className, inset, children, ...props }, ref) => (
31 |
40 | {children}
41 |
42 |
43 | ))
44 | DropdownMenuSubTrigger.displayName =
45 | DropdownMenuPrimitive.SubTrigger.displayName
46 |
47 | const DropdownMenuSubContent = React.forwardRef<
48 | React.ElementRef,
49 | React.ComponentPropsWithoutRef
50 | >(({ className, ...props }, ref) => (
51 |
59 | ))
60 | DropdownMenuSubContent.displayName =
61 | DropdownMenuPrimitive.SubContent.displayName
62 |
63 | const DropdownMenuContent = React.forwardRef<
64 | React.ElementRef,
65 | React.ComponentPropsWithoutRef
66 | >(({ className, sideOffset = 4, ...props }, ref) => (
67 |
68 |
78 |
79 | ))
80 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
81 |
82 | const DropdownMenuItem = React.forwardRef<
83 | React.ElementRef,
84 | React.ComponentPropsWithoutRef & {
85 | inset?: boolean
86 | }
87 | >(({ className, inset, ...props }, ref) => (
88 |
97 | ))
98 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
99 |
100 | const DropdownMenuCheckboxItem = React.forwardRef<
101 | React.ElementRef,
102 | React.ComponentPropsWithoutRef
103 | >(({ className, children, checked, ...props }, ref) => (
104 |
113 |
114 |
115 |
116 |
117 |
118 | {children}
119 |
120 | ))
121 | DropdownMenuCheckboxItem.displayName =
122 | DropdownMenuPrimitive.CheckboxItem.displayName
123 |
124 | const DropdownMenuRadioItem = React.forwardRef<
125 | React.ElementRef,
126 | React.ComponentPropsWithoutRef
127 | >(({ className, children, ...props }, ref) => (
128 |
136 |
137 |
138 |
139 |
140 |
141 | {children}
142 |
143 | ))
144 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
145 |
146 | const DropdownMenuLabel = React.forwardRef<
147 | React.ElementRef,
148 | React.ComponentPropsWithoutRef & {
149 | inset?: boolean
150 | }
151 | >(({ className, inset, ...props }, ref) => (
152 |
161 | ))
162 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
163 |
164 | const DropdownMenuSeparator = React.forwardRef<
165 | React.ElementRef,
166 | React.ComponentPropsWithoutRef
167 | >(({ className, ...props }, ref) => (
168 |
173 | ))
174 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
175 |
176 | const DropdownMenuShortcut = ({
177 | className,
178 | ...props
179 | }: React.HTMLAttributes) => {
180 | return (
181 |
185 | )
186 | }
187 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
188 |
189 | export {
190 | DropdownMenu,
191 | DropdownMenuTrigger,
192 | DropdownMenuContent,
193 | DropdownMenuItem,
194 | DropdownMenuCheckboxItem,
195 | DropdownMenuRadioItem,
196 | DropdownMenuLabel,
197 | DropdownMenuSeparator,
198 | DropdownMenuShortcut,
199 | DropdownMenuGroup,
200 | DropdownMenuPortal,
201 | DropdownMenuSub,
202 | DropdownMenuSubContent,
203 | DropdownMenuSubTrigger,
204 | DropdownMenuRadioGroup,
205 | }
206 |
--------------------------------------------------------------------------------
/app/dashboard/page.tsx:
--------------------------------------------------------------------------------
1 | import { getServerSession } from "next-auth";
2 | import { revalidatePath } from "next/cache";
3 | import {
4 | Table,
5 | TableBody,
6 | TableCell,
7 | TableHead,
8 | TableHeader,
9 | TableRow,
10 | } from "@/components/ui/table";
11 | import { Input } from "@/components/ui/input";
12 | import { Button } from "@/components/ui/button";
13 | import { Badge } from "@/components/ui/badge";
14 | import { ExternalLink, FacebookIcon, RefreshCcw } from "lucide-react";
15 | import { prisma } from "@/lib/db";
16 | import Link from "next/link";
17 | import DisconnectButton from "./components/DisconnectButton";
18 | import AddAccountButton from "./components/AddAccountButton";
19 | import { Metadata } from "next";
20 | import { authOptions } from "@/lib/auth-options";
21 | import {
22 | AlertDialog,
23 | AlertDialogAction,
24 | AlertDialogCancel,
25 | AlertDialogContent,
26 | AlertDialogDescription,
27 | AlertDialogFooter,
28 | AlertDialogHeader,
29 | AlertDialogTitle,
30 | AlertDialogTrigger,
31 | } from "@/components/ui/alert-dialog";
32 |
33 | export const metadata: Metadata = {
34 | title: "Dashboard",
35 | };
36 |
37 | export default async function Page() {
38 | const session = await getServerSession(authOptions);
39 |
40 | const tokens = await prisma?.apiToken.findMany({
41 | where: { userId: session?.user?.id },
42 | include: {
43 | accounts: true,
44 | },
45 | });
46 | const selectedToken = tokens && tokens[0];
47 | const clientId = process.env.FACEBOOK_CLIENT_ID;
48 | const redirectUri = `${process.env.APP_URL}/dashboard/auth-integration`;
49 |
50 | async function create(formData: FormData) {
51 | "use server";
52 |
53 | if (!session?.user) throw new Error("Unauthorized");
54 |
55 | await prisma?.apiToken.create({
56 | data: {
57 | id: formData.get("value") as string,
58 | description: formData.get("description") as string,
59 | userId: session.user.id,
60 | },
61 | });
62 |
63 | revalidatePath("/dashboard");
64 | }
65 |
66 | async function addAccount(formData: FormData) {
67 | "use server";
68 | if (!session?.user) throw new Error("Unauthorized");
69 |
70 | if (!selectedToken) throw new Error("No token found");
71 |
72 | let account = (formData.get("account") as string).replace("@", "").trim();
73 | await prisma?.apiToken.update({
74 | where: { id: selectedToken?.id },
75 | data: {
76 | userId: session.user.id,
77 | accounts: {
78 | connectOrCreate: {
79 | where: {
80 | username: account,
81 | },
82 | create: {
83 | username: account,
84 | },
85 | },
86 | },
87 | },
88 | });
89 |
90 | revalidatePath("/dashboard");
91 | }
92 |
93 | async function disconnect(formData: FormData) {
94 | "use server";
95 | await prisma.apiToken.update({
96 | where: { id: selectedToken?.id },
97 | data: {
98 | accounts: {
99 | disconnect: {
100 | username: formData.get("account") as string,
101 | },
102 | },
103 | },
104 | });
105 |
106 | revalidatePath("/dashboard");
107 | }
108 |
109 | return (
110 |
111 |
112 |
113 |
Accounts
114 | {selectedToken?.id}
115 |
116 |
117 |
118 |
119 |
120 |
121 | Sync Facebook
122 |
123 |
124 |
125 |
126 | Warning
127 |
128 | Notice that if you don’t have an Instagram business account,
129 | you will have to convert your current account into a business
130 | one in order to use this application
131 |
132 |
133 |
134 | Cancel
135 |
136 |
140 | Continue
141 |
142 |
143 |
144 |
145 |
146 | or
147 |
151 |
152 |
153 |
154 | {selectedToken?.accounts?.length > 0 ? (
155 |
156 |
157 |
158 | Username
159 | Connect
160 |
161 |
162 |
163 | {selectedToken &&
164 | selectedToken?.accounts?.map((account) => (
165 |
166 |
167 |
171 | {account.username}
172 |
173 |
174 |
175 |
176 | {account.accessToken ? (
177 |
185 | ) : (
186 |
187 |
188 |
189 | Install Guide
190 |
191 |
192 | )}
193 |
194 |
195 | ))}
196 |
197 |
198 | ) : (
199 |
219 | )}
220 |
221 |
222 | );
223 | }
224 |
--------------------------------------------------------------------------------
/app/api/v2/[account]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest } from "next/server";
2 |
3 | import cors from "../../../cors";
4 |
5 | import { Redis } from "@upstash/redis";
6 | import { Ratelimit } from "@upstash/ratelimit";
7 | import { prisma } from "@/lib/db";
8 | import { getWorkflowClient } from "@/lib/temporal";
9 | import { kv } from "@/lib/cache";
10 | import { MediaProductType, Prisma } from "@prisma/client";
11 |
12 | if (!process.env.KV_URL || !process.env.KV_TOKEN) throw "env not found";
13 |
14 | const redis = new Redis({
15 | url: process.env.KV_URL,
16 | token: process.env.KV_TOKEN,
17 | });
18 |
19 | const ratelimit = {
20 | free: new Ratelimit({
21 | redis,
22 | analytics: true,
23 | prefix: "ratelimit:free",
24 | limiter: Ratelimit.fixedWindow(5, "10s"),
25 | }),
26 | paid: new Ratelimit({
27 | redis,
28 | analytics: true,
29 | prefix: "ratelimit:paid",
30 | limiter: Ratelimit.fixedWindow(1000, "10s"),
31 | }),
32 | };
33 |
34 | async function checkPermission(
35 | request: NextRequest,
36 | account: string,
37 | checkAccount: boolean = true
38 | ) {
39 | const auth = request.headers.get("Authorization")?.replace("Bearer ", "");
40 | if (!auth)
41 | return {
42 | error: "Token not found",
43 | };
44 |
45 | let storedToken = await prisma.apiToken.findUnique({
46 | where: { id: auth },
47 | include: { accounts: true },
48 | });
49 |
50 | if (!storedToken)
51 | return {
52 | error: "Token not found",
53 | };
54 |
55 | const instagramAccount = storedToken.accounts.find(
56 | (a) => a.username === account
57 | );
58 | if (checkAccount && !instagramAccount)
59 | return {
60 | error: "Invalid account",
61 | };
62 |
63 | return { account: instagramAccount, token: storedToken };
64 | }
65 |
66 | /**
67 | * @swagger
68 | * /api/v2/{account}:
69 | * get:
70 | * summary: List instagram account media
71 | * description: Returns user's instagram media
72 | * tags:
73 | * - Media
74 | * parameters:
75 | * - name: account
76 | * in: path
77 | * description: Instagram account name
78 | * required: true
79 | * schema:
80 | * type: string
81 | * - name: sizes
82 | * in: query
83 | * description: "media sizes in the format of width x height : compression separated with comma. 400x400, x360, 360x. For videos, you can specify a compression value from 0 (lower compression) to 51 (bigger compression)"
84 | * required: false
85 | * schema:
86 | * type: string
87 | * - name: media-type
88 | * in: query
89 | * description: filter by media type
90 | * required: false
91 | * schema:
92 | * type: string
93 | * enum:
94 | * - CAROUSEL_ALBUM
95 | * - IMAGE
96 | * - VIDEO
97 | * - name: media-product-type
98 | * in: query
99 | * description: filter by media product type
100 | * required: false
101 | * schema:
102 | * type: string
103 | * enum:
104 | * - AD
105 | * - FEED
106 | * - STORY
107 | * - REELS
108 | * - name: limit
109 | * in: query
110 | * description: number of media returned
111 | * required: false
112 | * schema:
113 | * type: integer
114 | * - name: page
115 | * in: query
116 | * description: page number
117 | * required: false
118 | * schema:
119 | * type: integer
120 | * - in: header
121 | * name: Cache-Control
122 | * description: Force sync execution
123 | * schema:
124 | * type: string
125 | * enum:
126 | * - no-cache
127 | * required: false
128 | * security:
129 | * - BearerAuth:
130 | * responses:
131 | * 401:
132 | * description: Unauthorized
133 | * content:
134 | * application/json:
135 | * schema:
136 | * $ref: '#/components/schemas/Error'
137 | * 200:
138 | * description: List of all media types
139 | * headers:
140 | * X-Sync-TTL:
141 | * description: TTL for next sync
142 | * schema:
143 | * type: number
144 | * X-Cache-Hit:
145 | * description: Cache Hit
146 | * schema:
147 | * type: boolean
148 | * content:
149 | * application/json:
150 | * schema:
151 | * type: object
152 | * properties:
153 | * data:
154 | * type: array
155 | * items:
156 | * $ref: '#/components/schemas/Media'
157 | * pagination:
158 | * $ref: '#/components/schemas/Pagination'
159 | */
160 | export async function GET(
161 | request: NextRequest,
162 | props: {
163 | params: Promise<{ account: string; type?: string }>;
164 | }
165 | ) {
166 | const params = await props.params;
167 | const url = new URL(request.url);
168 | const sizes = url.searchParams.get("sizes");
169 | // const compression = parseInt(url.searchParams.get("compression") || "23");
170 | const limit = parseInt(url.searchParams.get("limit") || "10");
171 | const page = parseInt(url.searchParams.get("page") || "1");
172 | const mediaType = url.searchParams.get("media-type")?.toUpperCase() as
173 | | "CAROUSEL_ALBUM"
174 | | "IMAGE"
175 | | "VIDEO"
176 | | null;
177 |
178 | const mediaProductType = url.searchParams
179 | .get("media-product-type")
180 | ?.toUpperCase() as "AD" | "FEED" | "STORY" | "REELS" | null;
181 |
182 | const { account: instagramAccount, error } = await checkPermission(
183 | request,
184 | params.account
185 | );
186 |
187 | if (error)
188 | return cors(
189 | request,
190 | new Response(JSON.stringify({ error }), {
191 | status: 401,
192 | headers: {
193 | "Content-Type": "application/json",
194 | },
195 | })
196 | );
197 |
198 | const account = params.account;
199 | let query: Prisma.MediaWhereInput = { username: account, parentId: null };
200 | if (mediaType) query = { ...query, mediaType };
201 |
202 | if (mediaProductType)
203 | query = {
204 | ...query,
205 | mediaProductType: {
206 | in: mediaProductType.split(",") as MediaProductType[],
207 | },
208 | };
209 |
210 | const images = await prisma.media.findMany({
211 | where: query,
212 | include: { children: { orderBy: { timestamp: "desc" } } },
213 | orderBy: { timestamp: "desc" },
214 | skip: (page - 1) * limit,
215 | take: limit,
216 | });
217 | const totalCount = await prisma.media.count({ where: query });
218 | const pageCount = Math.ceil(totalCount / limit);
219 | const previousPage = page > 1 ? page - 1 : null;
220 | const nextPage = page < pageCount ? page + 1 : null;
221 | const pagination = {
222 | isFirstPage: previousPage === null,
223 | isLastPage: nextPage === null,
224 | currentPage: page,
225 | previousPage,
226 | nextPage,
227 | pageCount,
228 | totalCount,
229 | limit,
230 | };
231 |
232 | let cacheHit = true;
233 | const cacheKey = `${account}-temporal-ttl`;
234 | const keyTTL = await kv.ttl(cacheKey);
235 | const forceNoCache = request.headers.get("Cache-Control") === "no-cache";
236 |
237 | if (keyTTL <= -1 || forceNoCache) {
238 | try {
239 | cacheHit = false;
240 | const client = await getWorkflowClient();
241 | await client.start("InstagramInterpreter", {
242 | args: [account, sizes],
243 | taskQueue: "instagram-interpreter",
244 | workflowId: `instagram-${account}`,
245 | });
246 |
247 | await kv.set(cacheKey, "cached");
248 | await kv.expire(cacheKey, 5 * 60);
249 | } catch (e) {
250 | console.error(e);
251 | }
252 | }
253 |
254 | return cors(
255 | request,
256 | new Response(JSON.stringify({ data: images, pagination }), {
257 | status: 200,
258 | headers: {
259 | "Content-Type": "application/json",
260 | "X-Sync-TTL": keyTTL.toString(),
261 | "X-Cache-Hit": `${cacheHit}`,
262 | },
263 | })
264 | );
265 | }
266 |
267 | /**
268 | * @swagger
269 | * /api/v2/{account}:
270 | * post:
271 | * summary: Add instagram account
272 | * description: Add instagram account
273 | * tags:
274 | * - Settings
275 | * parameters:
276 | * - name: account
277 | * in: path
278 | * description: Instagram account name
279 | * required: true
280 | * schema:
281 | * type: string
282 | * security:
283 | * - BearerAuth:
284 | * responses:
285 | * 401:
286 | * description: Unauthorized
287 | * content:
288 | * application/json:
289 | * schema:
290 | * $ref: '#/components/schemas/Error'
291 | * 200:
292 | * description: Login information that you need to send to the client
293 | * content:
294 | * application/json:
295 | * schema:
296 | * $ref: '#/components/schemas/LoginURL'
297 | */
298 | export async function POST(
299 | request: NextRequest,
300 | props: {
301 | params: Promise<{ account: string; type?: string }>;
302 | }
303 | ) {
304 | const params = await props.params;
305 | const {
306 | account: instagramAccount,
307 | token,
308 | error,
309 | } = await checkPermission(request, params.account, false);
310 |
311 | if (error || !token)
312 | return cors(request, new Response(error, { status: 401 }));
313 |
314 | await prisma.apiToken.update({
315 | where: { id: token.id },
316 | data: {
317 | accounts: {
318 | connectOrCreate: {
319 | where: { username: params.account },
320 | create: { username: params.account },
321 | },
322 | },
323 | },
324 | });
325 |
326 | return cors(
327 | request,
328 | new Response(
329 | JSON.stringify({
330 | url: `https://www.facebook.com/v17.0/dialog/oauth?client_id=${process.env.FACEBOOK_CLIENT_ID}&display=page&extras={"setup":{"channel":"IG_API_ONBOARDING"}}&redirect_uri=${process.env.APP_URL}/auth-integration&response_type=token&scope=instagram_basic,instagram_manage_insights,pages_show_list,pages_read_engagement,business_management`,
331 | }),
332 | {
333 | status: 200,
334 | headers: {
335 | "Content-Type": "application/json",
336 | },
337 | }
338 | )
339 | );
340 | }
341 |
342 | /**
343 | * @swagger
344 | * /api/v2/{account}:
345 | * delete:
346 | * summary: Remove instagram account
347 | * description: Remove instagram account
348 | * tags:
349 | * - Settings
350 | * parameters:
351 | * - name: account
352 | * in: path
353 | * description: Instagram account name
354 | * required: true
355 | * schema:
356 | * type: string
357 | * security:
358 | * - BearerAuth:
359 | * responses:
360 | * 401:
361 | * description: Unauthorized
362 | * content:
363 | * application/json:
364 | * schema:
365 | * $ref: '#/components/schemas/Error'
366 | * 200:
367 | * description: Account removed from auth token
368 | *
369 | */
370 | export async function DELETE(
371 | request: NextRequest,
372 | props: {
373 | params: Promise<{ account: string; type?: string }>;
374 | }
375 | ) {
376 | const params = await props.params;
377 | const {
378 | account: instagramAccount,
379 | token,
380 | error,
381 | } = await checkPermission(request, params.account, false);
382 |
383 | if (error || !token || !instagramAccount)
384 | return cors(request, new Response(error, { status: 401 }));
385 |
386 | const username = instagramAccount.username;
387 |
388 | await prisma.apiToken.update({
389 | where: { id: token.id },
390 | data: {
391 | accounts: {
392 | disconnect: {
393 | username,
394 | },
395 | },
396 | },
397 | });
398 |
399 | return cors(request, new Response(null, { status: 200 }));
400 | }
401 |
402 | export async function OPTIONS(request: Request) {
403 | return cors(
404 | request,
405 | new Response(null, {
406 | status: 204,
407 | })
408 | );
409 | }
410 |
--------------------------------------------------------------------------------