feat: Implement PDF to Markdown conversion with a new UI built using Shadcn/UI and updated styling.

This commit is contained in:
2025-12-08 09:51:42 -05:00
parent ac074abe45
commit 781c4581ed
19 changed files with 927 additions and 110 deletions

25
src/app/actions.ts Normal file
View File

@@ -0,0 +1,25 @@
'use server';
import { extractTextFromPdf } from '~/lib/pdf';
export async function convertPdf(formData: FormData): Promise<{ success: boolean; data?: string; error?: string }> {
try {
const file = formData.get('file') as File;
if (!file) {
return { success: false, error: 'No file uploaded' };
}
if (file.type !== 'application/pdf') {
return { success: false, error: 'Invalid file type. Please upload a PDF.' };
}
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const markdown = await extractTextFromPdf(buffer);
return { success: true, data: markdown };
} catch (error) {
console.error('PDF Conversion failed:', error);
return { success: false, error: 'Failed to process PDF' };
}
}

View File

@@ -4,8 +4,8 @@ import { type Metadata } from "next";
import { Geist } from "next/font/google";
export const metadata: Metadata = {
title: "Create T3 App",
description: "Generated by create-t3-app",
title: "PDF2MD",
description: "Convert PDF documents to clean Markdown format in seconds.",
icons: [{ rel: "icon", url: "/favicon.ico" }],
};

View File

@@ -1,37 +1,31 @@
import Link from "next/link";
import { UploadForm } from "~/components/upload-form";
import { Navbar } from "~/components/navbar";
export default function HomePage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c] text-white">
<div className="container flex flex-col items-center justify-center gap-12 px-4 py-16">
<h1 className="text-5xl font-extrabold tracking-tight text-white sm:text-[5rem]">
Create <span className="text-[hsl(280,100%,70%)]">T3</span> App
</h1>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:gap-8">
<Link
className="flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 text-white hover:bg-white/20"
href="https://create.t3.gg/en/usage/first-steps"
target="_blank"
>
<h3 className="text-2xl font-bold">First Steps </h3>
<div className="text-lg">
Just the basics - Everything you need to know to set up your
database and authentication.
</div>
</Link>
<Link
className="flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 text-white hover:bg-white/20"
href="https://create.t3.gg/en/introduction"
target="_blank"
>
<h3 className="text-2xl font-bold">Documentation </h3>
<div className="text-lg">
Learn more about Create T3 App, the libraries it uses, and how to
deploy it.
</div>
</Link>
<main className="relative min-h-screen w-full bg-background selection:bg-primary/10">
{/* Background Pattern */}
<div className="absolute inset-0 -z-10 h-full w-full bg-white dark:bg-black bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] [background-size:16px_16px] dark:bg-[radial-gradient(#ffffff33_1px,transparent_1px)]" />
<Navbar />
<div className="container mx-auto px-4 pt-32 pb-12">
<div className="flex flex-col items-center justify-center gap-12">
<div className="text-center space-y-6 max-w-3xl">
<h1 className="text-5xl font-extrabold tracking-tight lg:text-7xl bg-clip-text text-transparent bg-gradient-to-b from-neutral-800 to-neutral-500 dark:from-neutral-100 dark:to-neutral-400">
PDF to Markdown
</h1>
<p className="text-xl text-muted-foreground leading-relaxed">
Start extracting content from your documents in seconds. <br className="hidden sm:inline" />
Simply upload your PDF and get clean, formatted Markdown instantly.
</p>
</div>
<div className="w-full max-w-4xl mt-8">
<UploadForm />
</div>
</div>
</div>
</main>
);
}
}

24
src/components/navbar.tsx Normal file
View File

@@ -0,0 +1,24 @@
import Link from "next/link";
import { FileText, Github } from "lucide-react";
import { Button } from "./ui/button";
export function Navbar() {
return (
<div className="fixed top-4 left-0 right-0 z-50 flex justify-center px-4">
<nav className="flex items-center justify-between w-full max-w-4xl px-4 py-2 bg-background/80 backdrop-blur-md border border-border/50 rounded-full shadow-lg">
<Link href="/" className="flex items-center gap-2 px-2 hover:opacity-80 transition-opacity">
<FileText className="w-5 h-5 text-primary" />
<span className="font-semibold tracking-tight">PDF2MD</span>
</Link>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" asChild className="rounded-full">
<Link href="https://github.com/soconnor0919/pdf2md" target="_blank" rel="noopener noreferrer">
<Github className="w-5 h-5" />
<span className="sr-only">GitHub</span>
</Link>
</Button>
</div>
</nav>
</div>
);
}

View File

@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,60 @@
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-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "~/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "~/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "~/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "~/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "~/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,173 @@
'use client';
import { useState } from 'react';
import { Button } from '~/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '~/components/ui/card';
import { Input } from '~/components/ui/input';
import { Label } from '~/components/ui/label';
import { Textarea } from '~/components/ui/textarea';
import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '~/components/ui/tabs';
import { convertPdf } from '~/app/actions';
import { Loader2, Upload, FileText, File as FileIcon, X } from 'lucide-react';
export function UploadForm() {
const [isLoading, setIsLoading] = useState(false);
const [markdown, setMarkdown] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<string>('upload');
const [fileName, setFileName] = useState<string | null>(null);
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setIsLoading(true);
setError(null);
setMarkdown('');
const formData = new FormData(event.currentTarget);
try {
const result = await convertPdf(formData);
if (result.success && result.data) {
setMarkdown(result.data);
setActiveTab('output');
} else {
setError(result.error || 'An unknown error occurred');
}
} catch (e) {
setError('Failed to communicate with the server');
} finally {
setIsLoading(false);
}
}
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
setFileName(file.name);
} else {
setFileName(null);
}
};
const clearFile = () => {
setFileName(null);
// Reset the input value if needed, but since it's uncontrolled effectively in the form submission it's tricky.
// For a controlled input we'd need a ref.
// For now just clearing visual state is enough if the user re-selects.
// Actually, to properly clear we should reset the form or input.
// Let's just keep it simple: showing the name. logic to clear requires ref.
const input = document.getElementById('file') as HTMLInputElement;
if (input) input.value = '';
};
return (
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full max-w-2xl mx-auto">
<TabsList className="grid w-full grid-cols-2 mb-8">
<TabsTrigger value="upload" className="flex items-center gap-2">
<Upload className="w-4 h-4" /> Upload
</TabsTrigger>
<TabsTrigger value="output" disabled={!markdown} className="flex items-center gap-2">
<FileText className="w-4 h-4" /> Output
</TabsTrigger>
</TabsList>
<TabsContent value="upload">
<Card>
<CardHeader>
<CardTitle>Upload PDF</CardTitle>
<CardDescription>Select a PDF file to convert to Markdown</CardDescription>
</CardHeader>
<form onSubmit={onSubmit}>
<CardContent className="grid w-full items-center gap-4 py-8">
<div className="flex flex-col space-y-4">
{!fileName ? (
<Label htmlFor="file" className="text-center cursor-pointer border-2 border-dashed border-muted-foreground/25 rounded-lg p-12 hover:bg-muted/50 transition-colors flex flex-col items-center gap-4">
<div className="p-4 rounded-full bg-primary/10 text-primary">
<Upload className="w-8 h-8" />
</div>
<div className="space-y-1 text-center">
<span className="font-medium">Click to upload</span> or drag and drop
<span className="block text-xs text-muted-foreground">PDF files only</span>
</div>
</Label>
) : (
<div className="flex items-center justify-between p-4 border rounded-lg bg-muted/20">
<div className="flex items-center gap-3">
<div className="p-2 rounded-full bg-primary/10 text-primary">
<FileIcon className="w-5 h-5" />
</div>
<span className="font-medium truncate max-w-[200px] sm:max-w-md">{fileName}</span>
</div>
<Button type="button" variant="ghost" size="icon" onClick={clearFile} aria-label="Remove file">
<X className="w-4 h-4" />
</Button>
</div>
)}
<Input
id="file"
name="file"
type="file"
accept="application/pdf"
required
className="hidden"
onChange={handleFileChange}
/>
</div>
</CardContent>
<CardFooter className="flex justify-end">
<Button type="submit" disabled={isLoading || !fileName} className="w-full sm:w-auto">
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Converting...
</>
) : (
'Convert to Markdown'
)}
</Button>
</CardFooter>
</form>
</Card>
{error && (
<Alert variant="destructive" className="mt-6">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</TabsContent>
<TabsContent value="output">
<Card className="h-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle>Markdown Output</CardTitle>
<Button variant="outline" size="sm" onClick={() => {
void navigator.clipboard.writeText(markdown);
}}>
Copy to Clipboard
</Button>
</CardHeader>
<CardContent className="pt-4">
<Textarea
className="min-h-[500px] font-mono text-sm leading-relaxed resize-none p-4"
value={markdown}
readOnly
/>
</CardContent>
<CardFooter>
<Button variant="ghost" onClick={() => {
setActiveTab('upload');
setFileName(null);
// Optional: clear file input if we verified it works above
const input = document.getElementById('file') as HTMLInputElement;
if (input) input.value = '';
}} className="w-full">
Convert another file
</Button>
</CardFooter>
</Card>
</TabsContent>
</Tabs>
);
}

135
src/lib/pdf.ts Normal file
View File

@@ -0,0 +1,135 @@
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import path from 'path';
if (typeof window === 'undefined') {
// In Node.js, we need to point to the worker file on the filesystem
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
(pdfjsLib.GlobalWorkerOptions as any).workerSrc = path.join(process.cwd(), 'node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs');
}
interface TextItem {
str: string;
dir?: string;
transform: number[]; // [scaleX, skewY, skewX, scaleY, x, y]
width?: number;
height?: number;
fontName?: string;
hasEOL?: boolean;
}
export async function extractTextFromPdf(buffer: Buffer): Promise<string> {
const data = new Uint8Array(buffer);
try {
const loadingTask = pdfjsLib.getDocument({
data,
useSystemFonts: true,
verbosity: 0,
});
const pdfDocument = await loadingTask.promise;
const numPages = pdfDocument.numPages;
let fullText = '';
for (let i = 1; i <= numPages; i++) {
const page = await pdfDocument.getPage(i);
const textContent = await page.getTextContent();
// First pass: Analyze font sizes to heuristically detect body text vs headers
const heightMap = new Map<number, number>();
const items = textContent.items as unknown as TextItem[];
// Filter empty items
const contentItems = items.filter(item => item.str.trim().length > 0);
if (contentItems.length === 0) continue;
for (const item of contentItems) {
// Approximate height from transform matrix (scaleY is index 3) or height prop
// item.height might be 0 in some pdf modes, use transform[3] which is usually font point size
const height = Math.abs(item.transform[3] ?? 0);
const roundedHeight = Math.round(height * 10) / 10;
heightMap.set(roundedHeight, (heightMap.get(roundedHeight) || 0) + item.str.length);
}
// Find the most common height -> assume this is body text
let bodyHeight = 0;
let maxCount = 0;
for (const [height, count] of heightMap.entries()) {
if (count > maxCount) {
maxCount = count;
bodyHeight = height;
}
}
let lastY: number | null = null;
let pageMarkdown = '';
// Sort items by Y (descending) then X (ascending) to ensure reading order
// TextContent usually gives them in order but sometimes not
contentItems.sort((a, b) => {
const yA = a.transform[5] ?? 0;
const yB = b.transform[5] ?? 0;
if (Math.abs(yA - yB) > 5) { // Threshold for same line
return yB - yA; // Top to bottom
}
const xA = a.transform[4] ?? 0;
const xB = b.transform[4] ?? 0;
return xA - xB; // Left to right
});
for (const item of contentItems) {
const currentY = item.transform[5] ?? 0;
const currentHeight = Math.abs(item.transform[3] ?? 0);
const txt = item.str;
// Logic for new line / paragraph
if (lastY !== null) {
const diffY = lastY - currentY;
if (diffY > currentHeight * 1.5) {
// New paragraph
pageMarkdown += '\n\n';
} else if (diffY > currentHeight * 0.5) {
// potential new line in same block, but if we are converting to markdown
// we usually want text to flow.
// Check if the previous line ended with a hyphen or if it's a list.
pageMarkdown += ' ';
} else {
// Same line (or super close)
// Check gap for space
}
}
// Logic for Headers
// If significantly larger than body text
if (currentHeight > bodyHeight * 1.5) {
pageMarkdown += '\n# ' + txt; // H1
} else if (currentHeight > bodyHeight * 1.2) {
pageMarkdown += '\n## ' + txt; // H2
} else {
// Body text
// Check if it starts with bullet like characters
if (/^[•\-\*]\s/.test(txt)) {
pageMarkdown += '\n- ' + txt.replace(/^[•\-\*]\s/, '');
} else {
pageMarkdown += txt;
}
}
lastY = currentY;
}
// Clean up: collapse multiple newlines, fix hyphenation if we joined lines
// (Simple cleanup)
pageMarkdown = pageMarkdown.replace(/\n{3,}/g, '\n\n');
fullText += `\n\n${pageMarkdown}\n\n`;
}
return fullText.trim();
} catch (error) {
console.error("Internal PDF extraction error:", error);
throw error;
}
}

View File

@@ -1,53 +1,15 @@
@import "tailwindcss";
@import "tw-animate-css";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
@@ -63,6 +25,7 @@
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
@@ -71,6 +34,7 @@
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
@@ -84,11 +48,11 @@
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
@@ -96,10 +60,11 @@
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
@@ -111,8 +76,47 @@
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {