Repository: Nutlope/smartpdfs
Branch: main
Commit: 5021dd353c33
Files: 44
Total size: 154.3 KB
Directory structure:
gitextract_c9h6c21g/
├── .eslintrc.json
├── .example.env
├── .gitignore
├── .node-version
├── .prettierrc
├── LICENSE
├── README.md
├── components.json
├── next.config.ts
├── package.json
├── postcss.config.mjs
├── prisma/
│ └── schema.prisma
├── src/
│ ├── app/
│ │ ├── actions.ts
│ │ ├── api/
│ │ │ ├── image/
│ │ │ │ └── route.ts
│ │ │ ├── s3-upload/
│ │ │ │ └── route.ts
│ │ │ └── summarize/
│ │ │ └── route.ts
│ │ ├── globals.css
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── pdf/
│ │ └── [id]/
│ │ ├── page.tsx
│ │ └── smart-pdf-viewer.tsx
│ ├── components/
│ │ ├── HomeLandingDrop.tsx
│ │ ├── icons/
│ │ │ ├── github.tsx
│ │ │ ├── sparkles.tsx
│ │ │ └── x.tsx
│ │ ├── images/
│ │ │ ├── homepage-image-1.tsx
│ │ │ └── homepage-image-2.tsx
│ │ └── ui/
│ │ ├── action-button.tsx
│ │ ├── button.tsx
│ │ ├── logo.tsx
│ │ ├── select.tsx
│ │ ├── spinner.tsx
│ │ ├── summary-content.tsx
│ │ ├── table-of-contents.tsx
│ │ ├── toast.tsx
│ │ └── toaster.tsx
│ ├── hooks/
│ │ └── use-toast.ts
│ └── lib/
│ ├── ai.ts
│ ├── prisma.ts
│ ├── s3client.ts
│ ├── summarize.ts
│ └── utils.ts
├── tailwind.config.ts
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .eslintrc.json
================================================
{
"extends": ["next/core-web-vitals", "next/typescript"]
}
================================================
FILE: .example.env
================================================
TOGETHER_API_KEY=
DATABASE_URL=
S3_UPLOAD_KEY=
S3_UPLOAD_SECRET=
S3_UPLOAD_BUCKET=
S3_UPLOAD_REGION=
================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.env*.local
================================================
FILE: .node-version
================================================
20.12.1
================================================
FILE: .prettierrc
================================================
{
"plugins": ["prettier-plugin-tailwindcss"]
}
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2025 Hassan El Mghari
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
SmartPDF
Instantly summarize and section your PDFs with AI. Powered by Llama 3.3 on Together AI.
## Tech stack
- [Together AI](https://togetherai.link) for inference
- [Llama 3.3](https://togetherai.link/llama-3.3) for the LLM
- Next.js with Tailwind & TypeScript
- Prisma ORM with Neon (Postgres)
- Helicone for observability
- Plausible for analytics
- S3 for PDF storage
## Cloning & running
1. Clone the repo: `git clone https://github.com/Nutlope/smartpdfs`
2. Create a `.env` file and add your environment variables (see `.example.env`):
- `TOGETHER_API_KEY=`
- `DATABASE_URL=`
- `S3_UPLOAD_KEY=`
- `S3_UPLOAD_SECRET=`
- `S3_UPLOAD_BUCKET=`
- `S3_UPLOAD_REGION=us-east-1`
- `HELICONE_API_KEY=` (optional, for observability)
3. Run `pnpm install` to install dependencies
4. Run `pnpm prisma generate` to generate the Prisma client
5. Run `pnpm dev` to start the development server
## Roadmap
- [ ] Add some rate limiting by IP address
- [ ] Integrate OCR for image parsing in PDFs
- [ ] Add a bit more polish (make the link icon nicer) & add a "powered by Together" sign
- [ ] Implement additional revision steps for improved summaries
- [ ] Add a demo PDF for new users to be able to see it in action
- [ ] Add feedback system with thumbs up/down feature
================================================
FILE: components.json
================================================
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
================================================
FILE: next.config.ts
================================================
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "napkinsdev.s3.us-east-1.amazonaws.com",
},
{
protocol: "https",
hostname: "api.together.ai",
},
],
},
};
export default nextConfig;
================================================
FILE: package.json
================================================
{
"name": "pdf-summary",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"postinstall": "prisma generate",
"start": "next start",
"lint": "next lint",
"update:all": "pnpm update --interactive --latest"
},
"dependencies": {
"@ai-sdk/togetherai": "^2.0.37",
"@aws-sdk/client-s3": "^3.797.0",
"@neondatabase/serverless": "^1.0.0",
"@prisma/adapter-neon": "^6.6.0",
"@prisma/client": "^6.6.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.2",
"@tailwindcss/typography": "^0.5.16",
"ai": "^6.0.108",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"dedent": "^1.5.3",
"lucide-react": "^0.503.0",
"nanoid": "^5.1.5",
"next": "16.1.6",
"next-plausible": "^3.12.5",
"next-s3-upload": "^0.3.4",
"pdfjs-dist": "^4.8.69",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-dropzone": "^14.3.5",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"together-ai": "^0.37.0",
"ws": "^8.18.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^22.15.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/ws": "^8.5.13",
"eslint": "9.25.1",
"eslint-config-next": "15.3.1",
"postcss": "^8",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.8",
"prisma": "^6.6.0",
"tailwindcss": "^3.4.1",
"typescript": "^5"
},
"engines": {
"node": "22.x"
},
"pnpm": {
"onlyBuiltDependencies": [
"@prisma/client"
]
}
}
================================================
FILE: postcss.config.mjs
================================================
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
================================================
FILE: prisma/schema.prisma
================================================
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model SmartPDF {
id String @id @default(nanoid(5))
createdAt DateTime @default(now())
imageUrl String
pdfUrl String
pdfName String
sections Section[]
}
model Section {
id String @id @default(nanoid(5))
type String
title String
summary String
position Int
SmartPDF SmartPDF @relation(fields: [smartPDFId], references: [id])
smartPDFId String
}
================================================
FILE: src/app/actions.ts
================================================
"use server";
import { nanoid } from "nanoid";
import client from "@/lib/prisma";
import { redirect } from "next/navigation";
const slugify = (text: string) => {
return text
.toLowerCase()
.replace(/ /g, "-")
.replace(/[^\w-]+/g, "")
.replace(/--+/g, "-")
.replace(/^-+/, "")
.replace(/-+$/, "")
.slice(0, 20);
};
export async function sharePdf({
pdfName,
pdfUrl,
imageUrl,
sections,
}: {
pdfName: string;
pdfUrl: string;
imageUrl: string;
sections: {
type: string;
title: string;
summary: string;
position: number;
}[];
}) {
const smartPdf = await client.smartPDF.create({
data: {
id: `${slugify(sections[0].title)}-${nanoid(4)}`,
pdfName,
pdfUrl,
imageUrl,
sections: {
createMany: {
data: sections,
},
},
},
});
redirect(`/pdf/${smartPdf.id}`);
}
================================================
FILE: src/app/api/image/route.ts
================================================
import dedent from "dedent";
import { togetheraiBaseClient, togetheraiClient } from "@/lib/ai";
import { ImageGenerationResponse } from "@/lib/summarize";
import { awsS3Client } from "@/lib/s3client";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { generateText } from "ai";
export async function POST(req: Request) {
const json = await req.json();
const text = "text" in json ? json.text : "";
const start = new Date();
const truncatedText = text.slice(0, 2000);
const { text: visualDescription } = await generateText({
model: togetheraiClient(
"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8",
),
prompt: dedent`
Based on the following content, describe a single visual scene that represents its essence.
The scene should be suitable for a painting or illustration.
Do NOT include any text, words, or writing in your description.
Just describe what you would see: objects, colors, atmosphere, lighting, mood.
Keep it to 2-3 sentences.
Content: ${truncatedText}
Visual scene description:
`,
});
const prompt = dedent`
${visualDescription}
Oil painting, fine art, museum quality, artistic brushstrokes.
No text, no words, no letters, no writing, no documents, no signs.
Pure visual illustration only.
`;
const generatedImage = await togetheraiBaseClient.images.generate({
model: "black-forest-labs/FLUX.2-dev",
width: 1280,
height: 720,
prompt: prompt,
});
const end = new Date();
console.log(`Image generation took ${end.getTime() - start.getTime()}ms`);
const imageData = generatedImage.data[0];
if (!imageData) throw new Error("No image data generated");
if (imageData.url === undefined)
throw new Error("Expected URL response format");
const imageUrl = imageData.url;
if (!imageUrl) throw new Error("No image URL returned");
const imageFetch = await fetch(imageUrl);
const imageBlob = await imageFetch.blob();
const imageBuffer = Buffer.from(await imageBlob.arrayBuffer());
const coverImageKey = `pdf-cover-${generatedImage.id}.jpg`;
const uploadedFile = await awsS3Client.send(
new PutObjectCommand({
Bucket: process.env.S3_UPLOAD_BUCKET || "",
Key: coverImageKey,
Body: imageBuffer,
ContentType: "image/jpeg",
}),
);
if (!uploadedFile) {
throw new Error("Failed to upload enhanced image to S3");
}
return Response.json({
url: `https://${process.env.S3_UPLOAD_BUCKET}.s3.${
process.env.S3_UPLOAD_REGION || "us-east-1"
}.amazonaws.com/${coverImageKey}`,
} as ImageGenerationResponse);
}
export const runtime = "edge";
================================================
FILE: src/app/api/s3-upload/route.ts
================================================
export { POST } from "next-s3-upload/route";
================================================
FILE: src/app/api/summarize/route.ts
================================================
import { togetheraiBaseClient } from "@/lib/ai";
import assert from "assert";
import dedent from "dedent";
import { z } from "zod";
export async function POST(req: Request) {
const { text, language } = await req.json();
assert.ok(typeof text === "string");
assert.ok(typeof language === "string");
const systemPrompt = dedent`
You are an expert at summarizing text.
Your task:
1. Read the document excerpt I will provide
2. Create a concise summary in ${language}
3. Generate a short, descriptive title in ${language}
Guidelines for the summary:
- Format the summary in HTML
- Use tags for paragraphs (2-3 sentences each)
- Use
and - tags for bullet points
- Use
tags for subheadings when needed but don't repeat the initial title in the first paragraph
- Ensure proper spacing with appropriate HTML tags
The summary should be well-structured and easy to scan, while maintaining accuracy and completeness.
Please analyze the text thoroughly before starting the summary.
IMPORTANT: Output ONLY valid JSON matching the schema. Output ONLY valid HTML without any markdown or plain text line breaks.
`;
const summarySchema = z.object({
title: z.string().describe("A title for the summary"),
summary: z
.string()
.describe(
"The actual summary of the text containing new lines breaks between paragraphs or phrases for better readability.",
),
});
const jsonSchema = z.toJSONSchema(summarySchema, {
target: "openapi-3.0",
io: "output",
});
const summaryResponse = await togetheraiBaseClient.chat.completions.create({
model: "meta-llama/Llama-3.3-70B-Instruct-Turbo",
messages: [
{
role: "system",
content: systemPrompt,
},
{
role: "user",
content: text,
},
],
response_format: {
type: "json_schema",
json_schema: {
name: "summary",
schema: jsonSchema,
},
} as any,
});
const content = summaryResponse.choices[0]?.message?.content;
if (!content) {
console.log("Content was blank", JSON.stringify(summaryResponse, null, 2));
return Response.json({ error: "No content generated" }, { status: 500 });
}
const parsed = summarySchema.parse(JSON.parse(content));
return Response.json(parsed);
}
export const runtime = "edge";
================================================
FILE: src/app/globals.css
================================================
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: Arial, Helvetica, sans-serif;
}
@layer base {
:root {
--background: 37 27% 94%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 2.44% 24.12%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
================================================
FILE: src/app/layout.tsx
================================================
import GithubIcon from "@/components/icons/github";
import XIcon from "@/components/icons/x";
import Logo from "@/components/ui/logo";
import type { Metadata } from "next";
import { Plus_Jakarta_Sans } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/toaster";
import Link from "next/link";
import PlausibleProvider from "next-plausible";
const font = Plus_Jakarta_Sans({
subsets: ["latin"],
display: "swap",
variable: "--font-plus-jakarta-sans",
});
export const metadata: Metadata = {
title: "Smart PDFs | Summarize PDFs in seconds",
description:
"Upload a PDF to get a quick, clear, and shareable summary with AI for free!",
openGraph: {
images: "https://smartpdfs.vercel.app/og.jpg",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
{children}
);
}
================================================
FILE: src/app/page.tsx
================================================
"use client";
import { useS3Upload } from "next-s3-upload";
import { Button } from "@/components/ui/button";
import {
Chunk,
chunkPdf,
generateImage,
generateQuickSummary,
summarizeStream,
} from "@/lib/summarize";
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
import { FormEvent, useState } from "react";
import "pdfjs-dist/legacy/build/pdf.worker.mjs";
import { MenuIcon, SquareArrowOutUpRight } from "lucide-react";
import { sharePdf } from "@/app/actions";
import ActionButton from "@/components/ui/action-button";
import Link from "next/link";
import { useToast } from "@/hooks/use-toast";
import { HomeLandingDrop } from "@/components/HomeLandingDrop";
import SummaryContent from "@/components/ui/summary-content";
import TableOfContents from "@/components/ui/table-of-contents";
export type StatusApp = "idle" | "parsing" | "generating";
export default function Home() {
const [status, setStatus] = useState("idle");
const [file, setFile] = useState();
const [fileUrl, setFileUrl] = useState("");
const [chunks, setChunks] = useState([]);
const [activeChunkIndex, setActiveChunkIndex] = useState<
number | "quick-summary" | null
>(null);
const [quickSummary, setQuickSummary] = useState<{
title: string;
summary: string;
}>();
const [image, setImage] = useState();
const [showMobileContents, setShowMobileContents] = useState(true);
const { uploadToS3 } = useS3Upload();
const { toast } = useToast();
async function handleSubmit(e: FormEvent) {
e.preventDefault();
const language = new FormData(e.currentTarget).get("language");
if (!file || typeof language !== "string") return;
setStatus("parsing");
const uploadedPdfPromise = uploadToS3(file);
const arrayBuffer = await file.arrayBuffer();
const pdf = await getDocument({ data: arrayBuffer }).promise;
if (pdf.numPages > 500) {
toast({
variant: "destructive",
title: "PDF too large (500 pages max)",
description: "That PDF has too many pages. Please use a smaller PDF.",
});
setStatus("idle");
return;
}
const localChunks = await chunkPdf(pdf);
const totalText = localChunks.reduce(
(acc, chunk) => acc + chunk.text.length,
0,
);
if (totalText < 500) {
toast({
variant: "destructive",
title: "Unable to process PDF",
description:
"The PDF appears to be a scanned document or contains too little text to process. Please ensure the PDF contains searchable text.",
});
setFile(undefined);
setStatus("idle");
return;
}
setChunks(localChunks);
setStatus("generating");
const summarizedChunks: Chunk[] = [];
const writeStream = new WritableStream({
write(chunk) {
summarizedChunks.push(chunk);
setChunks((chunks) => {
return chunks.map((c) =>
c.text === chunk.text ? { ...c, ...chunk } : c,
);
});
},
});
const streamPromise = summarizeStream(localChunks, language);
const imagePromise = generateImage(localChunks[0]?.text ?? "");
const [stream] = await Promise.all([streamPromise]);
const controller = new AbortController();
await stream.pipeTo(writeStream, { signal: controller.signal });
const quickSummary = await generateQuickSummary(summarizedChunks, language);
const imageUrl = await imagePromise;
setQuickSummary(quickSummary);
setImage(imageUrl);
const uploadedPdf = await uploadedPdfPromise;
setFileUrl(uploadedPdf.url);
setActiveChunkIndex((activeChunkIndex) =>
activeChunkIndex === null ? "quick-summary" : activeChunkIndex,
);
await sharePdf({
pdfName: file.name,
pdfUrl: uploadedPdf.url,
imageUrl: imageUrl,
sections: [
{
type: "quick-summary",
title: quickSummary.title,
summary: quickSummary.summary,
position: 0,
},
...summarizedChunks.map((chunk, index) => ({
type: "summary",
title: chunk?.title ?? "",
summary: chunk?.summary ?? "",
position: index + 1,
})),
],
});
}
return (
{status === "idle" || status === "parsing" ? (
file && setFile(file)}
handleSubmit={handleSubmit}
/>
) : (
{fileUrl && (
Original PDF
)}
{showMobileContents && (
)}
{activeChunkIndex === "quick-summary" ? (
) : activeChunkIndex !== null ? (
) : (
Generating your Smart PDF…
)}
)}
);
}
================================================
FILE: src/app/pdf/[id]/page.tsx
================================================
import SmartPDFViewer from "@/app/pdf/[id]/smart-pdf-viewer";
import client from "@/lib/prisma";
import { notFound } from "next/navigation";
import { unstable_cache } from "next/cache";
import { Metadata, ResolvingMetadata } from "next";
const getSmartPDF = unstable_cache(
async (id: string) => {
return client.smartPDF.findUnique({
where: { id },
include: { sections: true },
});
},
["smart-pdf-query"],
{ revalidate: false },
);
export async function generateMetadata(
{
params,
}: {
params: Promise<{ id: string }>;
},
parent: ResolvingMetadata,
): Promise {
// read route params
const { id } = await params;
const parentData = await parent;
// fetch data
const smartPdf = await getSmartPDF(id);
if (!smartPdf) notFound();
return {
title: `${smartPdf.sections[0].title.slice(0, 60)} | ${parentData.title?.absolute}`,
description: `${smartPdf.sections[0].summary
.replace(/<[^>]*>/g, "")
.slice(0, 160)}...`,
openGraph: {
images: [smartPdf.imageUrl],
},
};
}
export default async function Home({
params,
}: {
params: Promise<{ id: string }>;
}) {
const id = (await params).id;
const smartPdf = await getSmartPDF(id);
if (!smartPdf) notFound();
return ;
}
================================================
FILE: src/app/pdf/[id]/smart-pdf-viewer.tsx
================================================
"use client";
import { Button } from "@/components/ui/button";
import { Section, SmartPDF } from "@prisma/client";
import { LinkIcon, MenuIcon, SquareArrowOutUpRight } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import TableOfContents from "@/components/ui/table-of-contents";
import SummaryContent from "@/components/ui/summary-content";
import ActionButton from "@/components/ui/action-button";
import { useToast } from "@/hooks/use-toast";
export default function SmartPDFViewer({
smartPdf,
}: {
smartPdf: SmartPDF & { sections: Section[] };
}) {
const { toast } = useToast();
const [showMobileContents, setShowMobileContents] = useState(true);
const [activeSection, setActiveSection] = useState(
"quick-summary",
);
const handleShare = () => {
toast({
title: "Copied to Clipboard 📋",
description:
"Share link has been copied. Ready to share your PDF summary! 🔗",
});
navigator.clipboard.writeText(window.location.href);
};
const quickSummary = smartPdf.sections[0];
const pdfSections = smartPdf.sections.slice(1);
return (
{showMobileContents && (
idx !== null && setActiveSection(idx)
}
quickSummary={smartPdf.sections[0]}
chunks={smartPdf.sections.slice(1).map((section) => ({
...section,
text: section.summary,
}))}
/>
)}
{activeSection === "quick-summary" ? (
) : activeSection !== null ? (
) : (
Generating your Smart PDF…
)}
idx !== null && setActiveSection(idx)
}
quickSummary={smartPdf.sections[0]}
chunks={smartPdf.sections.slice(1).map((section) => ({
...section,
text: section.summary,
}))}
/>
);
}
================================================
FILE: src/components/HomeLandingDrop.tsx
================================================
"use client";
import { SparklesIcon } from "lucide-react";
import { Button } from "./ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./ui/select";
import Dropzone from "react-dropzone";
import HomepageImage1 from "./images/homepage-image-1";
import HomepageImage2 from "./images/homepage-image-2";
import { StatusApp } from "@/app/page";
import { useToast } from "@/hooks/use-toast";
export const HomeLandingDrop = ({
status,
file,
setFile,
handleSubmit,
}: {
status: StatusApp;
file?: File | null;
setFile: (file: File | null) => void;
handleSubmit: (e: React.FormEvent) => void;
}) => {
const { toast } = useToast();
return (
Summarize PDFs
in seconds
Upload a PDF to get a quick, clear, and shareable
summary.
);
};
================================================
FILE: src/components/icons/github.tsx
================================================
import { ComponentProps } from "react";
export default function GithubIcon(props: ComponentProps<"svg">) {
return (
);
}
================================================
FILE: src/components/icons/sparkles.tsx
================================================
import { ComponentProps } from "react";
export default function SparklesIcon(props: ComponentProps<"svg">) {
return (
);
}
================================================
FILE: src/components/icons/x.tsx
================================================
import { ComponentProps } from "react";
export default function XIcon(props: ComponentProps<"svg">) {
return (
);
}
================================================
FILE: src/components/images/homepage-image-1.tsx
================================================
import { ComponentProps } from "react";
export default function HomepageImage1(props: ComponentProps<"svg">) {
return (
);
}
================================================
FILE: src/components/images/homepage-image-2.tsx
================================================
import { ComponentProps } from "react";
export default function HomepageImage2(props: ComponentProps<"svg">) {
return (
);
}
================================================
FILE: src/components/ui/action-button.tsx
================================================
"use client";
import { Button } from "@/components/ui/button";
import Spinner from "@/components/ui/spinner";
import { ComponentProps } from "react";
import { useFormStatus } from "react-dom";
export default function ActionButton({
children,
className,
...rest
}: ComponentProps) {
const { pending } = useFormStatus();
return (
);
}
================================================
FILE: src/components/ui/button.tsx
================================================
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border text-base border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
"outline-active":
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes,
VariantProps {
asChild?: boolean;
}
const Button = React.forwardRef(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
================================================
FILE: src/components/ui/logo.tsx
================================================
export default function Logo(props: React.ComponentProps<"svg">) {
return (
);
}
================================================
FILE: src/components/ui/select.tsx
================================================
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, children, ...props }, ref) => (
span]:line-clamp-1",
className
)}
{...props}
>
{children}
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, children, position = "popper", ...props }, ref) => (
{children}
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, children, ...props }, ref) => (
{children}
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
================================================
FILE: src/components/ui/spinner.tsx
================================================
import { ReactNode } from "react";
export default function Spinner({
loading = true,
children,
className = "",
}: {
loading?: boolean;
children?: ReactNode;
className?: string;
}) {
if (!loading) return children;
const spinner = (
<>
{Array.from(Array(8).keys()).map((i) => (
))}
>
);
if (!children) return spinner;
return (
{children}
{spinner}
);
}
================================================
FILE: src/components/ui/summary-content.tsx
================================================
"use client";
import Image from "next/image";
interface SummaryContentProps {
title?: string;
summary?: string;
imageUrl?: string;
}
export default function SummaryContent({
title,
summary,
imageUrl,
}: SummaryContentProps) {
return (
{imageUrl && (
<>
>
)}
{title}
);
}
================================================
FILE: src/components/ui/table-of-contents.tsx
================================================
"use client";
import { Button } from "@/components/ui/button";
import Spinner from "@/components/ui/spinner";
import { Chunk } from "@/lib/summarize";
interface TableOfContentsProps {
activeChunkIndex: number | "quick-summary" | null;
setActiveChunkIndex: (index: number | "quick-summary" | null) => void;
quickSummary: { title: string; summary: string } | undefined;
chunks: Chunk[];
}
export default function TableOfContents({
activeChunkIndex,
setActiveChunkIndex,
quickSummary,
chunks,
}: TableOfContentsProps) {
return (
{chunks.map((chunk, i) => (
))}
);
}
================================================
FILE: src/components/ui/toast.tsx
================================================
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef &
VariantProps
>(({ className, variant, ...props }, ref) => {
return (
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef
type ToastActionElement = React.ReactElement
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}
================================================
FILE: src/components/ui/toaster.tsx
================================================
"use client"
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
{title && {title}}
{description && (
{description}
)}
{action}
)
})}
)
}
================================================
FILE: src/hooks/use-toast.ts
================================================
"use client";
// Inspired by react-hot-toast library
import * as React from "react";
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
type ActionTypes = {
ADD_TOAST: "ADD_TOAST";
UPDATE_TOAST: "UPDATE_TOAST";
DISMISS_TOAST: "DISMISS_TOAST";
REMOVE_TOAST: "REMOVE_TOAST";
};
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type Action =
| {
type: ActionTypes["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionTypes["UPDATE_TOAST"];
toast: Partial;
}
| {
type: ActionTypes["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionTypes["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
};
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast };
================================================
FILE: src/lib/ai.ts
================================================
import { createTogetherAI } from "@ai-sdk/togetherai";
import Together from "together-ai";
export const togetheraiClient = createTogetherAI({
apiKey: process.env.TOGETHER_API_KEY ?? "",
baseURL: "https://together.helicone.ai/v1",
headers: {
"Helicone-Auth": `Bearer ${process.env.HELICONE_API_KEY}`,
"Helicone-Property-AppName": "SmartPDF",
},
});
export const togetheraiBaseClient = new Together({
apiKey: process.env.TOGETHER_API_KEY ?? "",
});
================================================
FILE: src/lib/prisma.ts
================================================
import { PrismaClient } from "@prisma/client";
declare global {
// eslint-disable-next-line no-var
var prisma: PrismaClient | undefined;
}
const client = globalThis.prisma || new PrismaClient();
if (process.env.NODE_ENV !== "production") globalThis.prisma = client;
export default client;
================================================
FILE: src/lib/s3client.ts
================================================
import { S3Client } from "@aws-sdk/client-s3";
export const awsS3Client = new S3Client({
region: process.env.S3_UPLOAD_REGION || "us-east-1",
credentials: {
accessKeyId: process.env.S3_UPLOAD_KEY || "",
secretAccessKey: process.env.S3_UPLOAD_SECRET || "",
},
});
================================================
FILE: src/lib/summarize.ts
================================================
import { PDFDocumentProxy } from "pdfjs-dist";
import assert from "assert";
export type Chunk = {
text: string;
summary?: string;
title?: string;
};
export async function getPdfText(pdf: PDFDocumentProxy) {
const numPages = pdf.numPages;
let fullText = "";
for (let pageNum = 1; pageNum <= numPages; pageNum++) {
const page = await pdf.getPage(pageNum);
const textContent = await page.getTextContent();
let lastY = null;
let pageText = "";
// Process each text item
for (const item of textContent.items) {
if ("str" in item) {
// Check for new line based on Y position
if (lastY !== null && lastY !== item.transform[5]) {
pageText += "\n";
// Add extra line break if there's significant vertical space
if (lastY - item.transform[5] > 12) {
// Adjust this threshold as needed
pageText += "\n";
}
}
pageText += item.str;
lastY = item.transform[5];
}
}
fullText += pageText + "\n\n"; // Add double newline between pages
}
return fullText;
}
export async function chunkPdf(pdf: PDFDocumentProxy) {
// const chunkCharSize = 6000; // 100k
// const chunkCharSize = 100_000;
const maxChunkSize = 50_000;
// ideally have at least 4 chunks
// chunk size = total chars / 4 OR 100k, whichever is smaller
const fullText = await getPdfText(pdf);
const chunks: Chunk[] = [];
const chunkCharSize = Math.min(maxChunkSize, Math.ceil(fullText.length / 4));
for (let i = 0; i < fullText.length; i += chunkCharSize) {
const text = fullText.slice(i, i + chunkCharSize);
chunks.push({ text });
}
return chunks;
}
export async function summarizeStream(chunks: Chunk[], language: string) {
let reading = true;
const stream = new ReadableStream({
async start(controller) {
const promises = chunks.map(async (chunk) => {
const text = chunk.text;
const response = await fetch("/api/summarize", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ text, language }),
});
let data;
try {
data = await response.json();
if (reading) {
controller.enqueue({
...chunk,
summary: data.summary,
title: data.title,
});
}
} catch (e) {
console.log(e);
}
});
await Promise.all(promises);
controller.close();
},
cancel() {
console.log("read stream canceled");
reading = false;
},
});
return stream;
}
export async function generateQuickSummary(chunks: Chunk[], language: string) {
const allSummaries = chunks.map((chunk) => chunk.summary).join("\n\n");
const response = await fetch("/api/summarize", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ text: allSummaries, language }),
});
const { title, summary } = await response.json();
console.log("title", title);
assert.ok(typeof title === "string");
assert.ok(typeof summary === "string");
return { title, summary };
}
export type ImageGenerationResponse = {
url: string;
};
export async function generateImage(text: string) {
const response = await fetch("/api/image", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ text }),
});
const data: ImageGenerationResponse = await response.json();
return data.url;
}
================================================
FILE: src/lib/utils.ts
================================================
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
================================================
FILE: tailwind.config.ts
================================================
import type { Config } from "tailwindcss";
import tailwindcssAnimate from "tailwindcss-animate";
export default {
darkMode: ["class"],
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
colors: {
gray: {
"100": "#F4F1EC",
"200": "#FAF8F5",
"250": "#E1E1E1",
"300": "#B7B7B7",
"500": "#9A9A9A",
"900": "#3F3C3C",
},
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
chart: {
"1": "hsl(var(--chart-1))",
"2": "hsl(var(--chart-2))",
"3": "hsl(var(--chart-3))",
"4": "hsl(var(--chart-4))",
"5": "hsl(var(--chart-5))",
},
},
},
},
plugins: [tailwindcssAnimate, require("@tailwindcss/typography")],
} satisfies Config;
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}