diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..f8fcb10 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,110 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +jobs: + backend-lint-test: + name: Backend — lint + test + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: pdf_accessibility_test + POSTGRES_USER: pdf_accessibility + POSTGRES_PASSWORD: testpassword + ports: + - 5432:5432 + options: --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 5 + redis: + image: redis:7-alpine + ports: + - 6379:6379 + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + run: pip install uv + + - name: Install dependencies + working-directory: backend + run: uv sync + + - name: Run tests + working-directory: backend + env: + DB_HOST: localhost + DB_NAME: pdf_accessibility_test + DB_USER: pdf_accessibility + DB_PASSWORD: testpassword + REDIS_URL: redis://localhost:6379/0 + STORAGE_ENDPOINT: http://localhost:9000 + ANTHROPIC_API_KEY: test-key + SUPABASE_JWT_SECRET: test-secret + ENVIRONMENT: development + run: uv run pytest tests/ -v --tb=short + + frontend-lint: + name: Frontend — lint + typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + - name: Install deps + working-directory: frontend + run: npm ci + - name: Typecheck + working-directory: frontend + run: npx tsc --noEmit + + build-and-push: + name: Build + push Docker images + runs-on: ubuntu-latest + needs: [backend-lint-test, frontend-lint] + if: github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + + - name: Login to registry + uses: docker/login-action@v3 + with: + registry: registry.ai-impress.com + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Build + push API + uses: docker/build-push-action@v6 + with: + context: . + file: backend/Dockerfile + push: true + tags: registry.ai-impress.com/pdf-accessibility/api:latest + + - name: Build + push Frontend + uses: docker/build-push-action@v6 + with: + context: frontend + file: frontend/Dockerfile + push: true + tags: registry.ai-impress.com/pdf-accessibility/frontend:latest + + - name: Deploy to homelab + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_KEY }} + script: | + cd /opt/pdf-accessibility + docker compose -f docker-compose.prod.yml pull + docker compose -f docker-compose.prod.yml up -d + docker system prune -f diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..b107488 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,14 @@ +pdfaccess.ai-impress.com { + # Next.js app + handle /api/* { + reverse_proxy api:8000 + } + handle { + reverse_proxy nextjs:3000 + } + encode gzip + log { + output stderr + format json + } +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 72a8019..0ef68c6 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,26 +1,99 @@ -# Production Docker Compose — PostgreSQL only -# Apache/Nginx on host serves PHP + frontend files natively -# PDF processing handled by Cloud Run (no local worker) -# PostgreSQL on 1221 to avoid host conflicts - services: postgres: image: postgres:16-alpine - ports: - - "127.0.0.1:1221:5432" - volumes: - - pg-data:/var/lib/postgresql/data - - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql + restart: always environment: - POSTGRES_DB: ${DB_NAME:-pdf_checker} - POSTGRES_USER: ${DB_USER:-pdf_checker} - POSTGRES_PASSWORD: ${DB_PASSWORD:-dev_password} + POSTGRES_DB: ${DB_NAME:-pdf_accessibility} + POSTGRES_USER: ${DB_USER:-pdf_accessibility} + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-pdf_checker}"] + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-pdf_accessibility}"] interval: 10s - timeout: 3s - retries: 3 - restart: unless-stopped + timeout: 5s + retries: 5 + labels: + - "com.centurylinklabs.watchtower.enable=false" + + redis: + image: redis:7-alpine + restart: always + command: redis-server --appendonly yes + volumes: + - redis_data:/data + labels: + - "com.centurylinklabs.watchtower.enable=false" + + minio: + image: minio/minio:latest + restart: always + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${STORAGE_ACCESS_KEY} + MINIO_ROOT_PASSWORD: ${STORAGE_SECRET_KEY} + volumes: + - minio_data:/data + labels: + - "com.centurylinklabs.watchtower.enable=false" + + api: + image: registry.ai-impress.com/pdf-accessibility/api:latest + restart: always + env_file: .env + environment: + - DB_HOST=postgres + - REDIS_URL=redis://redis:6379/0 + - STORAGE_ENDPOINT=http://minio:9000 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + labels: + - "com.centurylinklabs.watchtower.enable=false" + + celery: + image: registry.ai-impress.com/pdf-accessibility/api:latest + command: uv run celery -A app.services.queue.celery_app worker --loglevel=info -c 2 + restart: always + env_file: .env + environment: + - DB_HOST=postgres + - REDIS_URL=redis://redis:6379/0 + - STORAGE_ENDPOINT=http://minio:9000 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + labels: + - "com.centurylinklabs.watchtower.enable=false" + + nextjs: + image: registry.ai-impress.com/pdf-accessibility/frontend:latest + restart: always + env_file: frontend/.env.local + labels: + - "com.centurylinklabs.watchtower.enable=false" + + caddy: + image: caddy:2-alpine + restart: always + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + depends_on: + - api + - nextjs volumes: - pg-data: + postgres_data: + redis_data: + minio_data: + caddy_data: + caddy_config: diff --git a/frontend/.env.local.example b/frontend/.env.local.example new file mode 100644 index 0000000..9abc824 --- /dev/null +++ b/frontend/.env.local.example @@ -0,0 +1,5 @@ +NEXT_PUBLIC_SUPABASE_URL=https://YOUR_PROJECT.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_ANON_KEY +NEXT_PUBLIC_API_URL=http://localhost:8000 +NEXT_PUBLIC_STRIPE_PRICE_PRO=price_YOUR_PRO_PRICE_ID +NEXT_PUBLIC_STRIPE_PRICE_BUSINESS=price_YOUR_BUSINESS_PRICE_ID diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..aa78ae9 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-alpine AS builder +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/frontend/app/(app)/dashboard/page.tsx b/frontend/app/(app)/dashboard/page.tsx new file mode 100644 index 0000000..163f2ca --- /dev/null +++ b/frontend/app/(app)/dashboard/page.tsx @@ -0,0 +1,106 @@ +"use client"; +import { useState, useCallback } from "react"; +import { useDropzone } from "react-dropzone"; +import { useRouter } from "next/navigation"; + +export default function DashboardPage() { + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + const router = useRouter(); + + const onDrop = useCallback(async (acceptedFiles: File[]) => { + const file = acceptedFiles[0]; + if (!file) return; + if (file.type !== "application/pdf") { + setError("Only PDF files are accepted"); + return; + } + + setUploading(true); + setError(null); + + const formData = new FormData(); + formData.append("file", file); + + try { + const res = await fetch("/api/v1/jobs", { + method: "POST", + body: formData, + credentials: "include", + }); + + if (res.status === 402) { + const data = await res.json(); + setError(data.detail + " — upgrade your plan to continue."); + return; + } + if (!res.ok) { + const data = await res.json(); + setError(data.detail || "Upload failed"); + return; + } + + const { id } = await res.json(); + router.push(`/jobs/${id}`); + } catch { + setError("Network error — please try again"); + } finally { + setUploading(false); + } + }, [router]); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + accept: { "application/pdf": [".pdf"] }, + multiple: false, + disabled: uploading, + }); + + return ( +
+

Check PDF Accessibility

+

Upload a PDF to run 30+ WCAG 2.1 AA checks

+ +
+ + {uploading ? ( +
+
+

Uploading and queuing check...

+
+ ) : isDragActive ? ( +
+
📄
+

Drop your PDF here

+
+ ) : ( +
+
📤
+

Drop your PDF here, or click to browse

+

PDF files only · Max 50 MB

+
+ )} +
+ + {error && ( +
+ {error} + {error.includes("upgrade") && ( + + Upgrade now → + + )} +
+ )} + +
+ What gets checked: Alt text, color contrast, reading order, headings, tables, forms, language, bookmarks, PDF/UA-1 (Matterhorn Protocol), and more. +
+
+ ); +} diff --git a/frontend/app/(app)/jobs/page.tsx b/frontend/app/(app)/jobs/page.tsx new file mode 100644 index 0000000..55e4183 --- /dev/null +++ b/frontend/app/(app)/jobs/page.tsx @@ -0,0 +1,91 @@ +"use client"; +import { useEffect, useState } from "react"; +import Link from "next/link"; + +interface Job { + id: string; + filename: string; + status: string; + accessibility_score: number | null; + created_at: string; +} + +function ScoreBadge({ score }: { score: number | null }) { + if (score === null) return ; + const color = score >= 80 ? "bg-green-100 text-green-800" : score >= 60 ? "bg-yellow-100 text-yellow-800" : "bg-red-100 text-red-800"; + return {score}; +} + +function StatusBadge({ status }: { status: string }) { + const styles: Record = { + completed: "bg-green-100 text-green-700", + processing: "bg-blue-100 text-blue-700", + pending: "bg-gray-100 text-gray-700", + failed: "bg-red-100 text-red-700", + }; + return ( + + {status} + + ); +} + +export default function JobsPage() { + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch("/api/v1/jobs", { credentials: "include" }) + .then((r) => r.json()) + .then((d) => { setJobs(d.jobs || []); setLoading(false); }) + .catch(() => setLoading(false)); + }, []); + + if (loading) return
Loading history...
; + + return ( +
+
+

History

+ + + New check + +
+ + {jobs.length === 0 ? ( +
+
📄
+

No PDFs checked yet — upload your first one!

+ Upload PDF → +
+ ) : ( +
+ + + + + + + + + + + + {jobs.map((job) => ( + + + + + + + + ))} + +
FilenameStatusScoreDate
{job.filename}{new Date(job.created_at).toLocaleDateString()} + View report → +
+
+ )} +
+ ); +} diff --git a/frontend/app/(app)/layout.tsx b/frontend/app/(app)/layout.tsx new file mode 100644 index 0000000..f30cd04 --- /dev/null +++ b/frontend/app/(app)/layout.tsx @@ -0,0 +1,41 @@ +import { redirect } from "next/navigation"; +import Link from "next/link"; +import { createClient } from "@/lib/supabase/server"; + +export default async function AppLayout({ children }: { children: React.ReactNode }) { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) redirect("/login"); + + return ( +
+ {/* Sidebar */} + + + {/* Main content */} +
{children}
+
+ ); +} diff --git a/frontend/app/(app)/settings/billing/page.tsx b/frontend/app/(app)/settings/billing/page.tsx new file mode 100644 index 0000000..b264700 --- /dev/null +++ b/frontend/app/(app)/settings/billing/page.tsx @@ -0,0 +1,86 @@ +"use client"; +import { useEffect, useState } from "react"; + +interface Subscription { + plan_tier: string; + monthly_quota: number; +} + +const PLAN_LABELS: Record = { + free: "Free", + pro: "Pro", + business: "Business", +}; + +export default function BillingPage() { + const [sub, setSub] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch("/api/v1/billing/subscription", { credentials: "include" }) + .then((r) => r.json()) + .then((d) => { setSub(d); setLoading(false); }) + .catch(() => setLoading(false)); + }, []); + + async function handleUpgrade(plan: "pro" | "business") { + const priceEnv = plan === "pro" + ? process.env.NEXT_PUBLIC_STRIPE_PRICE_PRO + : process.env.NEXT_PUBLIC_STRIPE_PRICE_BUSINESS; + + const res = await fetch(`/api/v1/billing/checkout?price_id=${priceEnv}`, { + method: "POST", + credentials: "include", + }); + const { checkout_url } = await res.json(); + window.location.href = checkout_url; + } + + async function handleManage() { + const res = await fetch("/api/v1/billing/portal", { method: "POST", credentials: "include" }); + const { portal_url } = await res.json(); + window.location.href = portal_url; + } + + if (loading) return
Loading billing...
; + + return ( +
+

Billing

+ +
+
+
+

Current plan

+

{PLAN_LABELS[sub?.plan_tier || "free"]}

+
+ + {sub?.monthly_quota === 999999 ? "Unlimited" : `${sub?.monthly_quota} PDFs/month`} + +
+ + {sub?.plan_tier !== "free" && ( + + )} +
+ + {sub?.plan_tier === "free" && ( +
+ {(["pro", "business"] as const).map((plan) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx new file mode 100644 index 0000000..209c5fe --- /dev/null +++ b/frontend/app/(auth)/login/page.tsx @@ -0,0 +1,69 @@ +"use client"; +import { useState } from "react"; +import { createClient } from "@/lib/supabase/client"; +import Link from "next/link"; + +export default function LoginPage() { + const [email, setEmail] = useState(""); + const [sent, setSent] = useState(false); + const [loading, setLoading] = useState(false); + + async function handleMagicLink(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + const supabase = createClient(); + await supabase.auth.signInWithOtp({ + email, + options: { emailRedirectTo: `${window.location.origin}/dashboard` }, + }); + setSent(true); + setLoading(false); + } + + return ( +
+
+
+ Aimpress PDF +

Sign in to your account

+

We'll send you a magic link — no password needed

+
+ + {sent ? ( +
+
✉️
+

Check your email

+

We sent a magic link to {email}

+
+ ) : ( +
+
+ + setEmail(e.target.value)} + placeholder="you@example.com" + className="w-full border border-gray-300 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent" + /> +
+ +
+ )} + +

+ No account?{" "} + Sign up free +

+
+
+ ); +} diff --git a/frontend/app/(auth)/signup/page.tsx b/frontend/app/(auth)/signup/page.tsx new file mode 100644 index 0000000..d74413e --- /dev/null +++ b/frontend/app/(auth)/signup/page.tsx @@ -0,0 +1,72 @@ +"use client"; +import { useState } from "react"; +import { createClient } from "@/lib/supabase/client"; +import Link from "next/link"; + +export default function SignupPage() { + const [email, setEmail] = useState(""); + const [sent, setSent] = useState(false); + const [loading, setLoading] = useState(false); + + async function handleSignup(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + const supabase = createClient(); + await supabase.auth.signInWithOtp({ + email, + options: { emailRedirectTo: `${window.location.origin}/dashboard`, shouldCreateUser: true }, + }); + setSent(true); + setLoading(false); + } + + return ( +
+
+
+ Aimpress PDF +

Create your free account

+

5 PDFs free · No credit card required

+
+ + {sent ? ( +
+
✉️
+

Almost there!

+

We sent a confirmation link to {email}

+
+ ) : ( +
+
+ + setEmail(e.target.value)} + placeholder="you@company.com" + className="w-full border border-gray-300 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent" + /> +
+ +
+ )} + +

+ Already have an account?{" "} + Sign in +

+

+ By signing up you agree to our Terms of Service and Privacy Policy. +

+
+
+ ); +} diff --git a/frontend/app/(marketing)/page.tsx b/frontend/app/(marketing)/page.tsx new file mode 100644 index 0000000..277f6f1 --- /dev/null +++ b/frontend/app/(marketing)/page.tsx @@ -0,0 +1,94 @@ +import Link from "next/link"; + +export default function LandingPage() { + return ( +
+ {/* Nav */} + + + {/* Hero */} +
+
+ EU Accessibility Act — June 2025 +
+

+ WCAG-compliant PDFs
+ in 60 seconds +

+

+ AI-powered PDF accessibility checker. 30+ WCAG 2.1 AA checks, auto-remediation, and detailed reports. + No desktop software — upload and get results instantly. +

+
+ + Check your PDF for free + + + See how it works → + +
+

5 PDFs free · No credit card required

+
+ + {/* Features */} +
+
+

Everything you need for PDF accessibility

+
+ {[ + { icon: "🔍", title: "30+ WCAG 2.1 checks", desc: "Alt text, color contrast, reading order, headings, tables, forms, language, bookmarks — all automated." }, + { icon: "🤖", title: "AI-powered analysis", desc: "Claude AI validates image descriptions, detects text-in-images, and classifies decorative vs informational graphics." }, + { icon: "⚡", title: "Auto-remediation", desc: "Fix title, language, tags and bookmarks automatically. Download the corrected PDF in one click." }, + { icon: "📊", title: "Visual Page Inspector", desc: "See exactly where issues are with SVG overlays on your PDF pages. Click any issue to jump to it." }, + { icon: "🌍", title: "50+ languages", desc: "Multi-language audit reports and auto-translation of accessibility metadata." }, + { icon: "👥", title: "Team workspaces", desc: "Share results with your team, track history, and collaborate on remediation tasks." }, + ].map((f) => ( +
+
{f.icon}
+

{f.title}

+

{f.desc}

+
+ ))} +
+
+
+ + {/* Social proof */} +
+

Who needs this?

+
+ {["Banks & Financial Services", "E-commerce", "E-learning Platforms", "Government & Public Sector", "Healthcare", "Legal Firms"].map((tag) => ( + {tag} + ))} +
+

+ The EU Accessibility Act (effective June 2025) requires these sectors to provide accessible digital documents. + Non-compliance can result in fines up to €100,000. +

+
+ + {/* CTA */} +
+

Ready to make your PDFs accessible?

+

Start free — 5 PDFs per month, no credit card required.

+ + Get started for free + +
+ + {/* Footer */} +
+

© {new Date().getFullYear()} Aimpress Ltd · Company No. 16417799 · London, UK

+
+
+ ); +} diff --git a/frontend/app/(marketing)/pricing/page.tsx b/frontend/app/(marketing)/pricing/page.tsx new file mode 100644 index 0000000..d2c07d8 --- /dev/null +++ b/frontend/app/(marketing)/pricing/page.tsx @@ -0,0 +1,95 @@ +import Link from "next/link"; + +const plans = [ + { + name: "Free", + price: "$0", + period: "forever", + quota: "5 PDFs / month", + features: ["30+ WCAG 2.1 AA checks", "HTML + JSON report", "Visual Page Inspector", "Basic issue list"], + cta: "Get started", + href: "/signup", + highlighted: false, + }, + { + name: "Pro", + price: "$29", + period: "/ month", + quota: "100 PDFs / month", + features: ["Everything in Free", "Auto-remediation (fix PDF)", "PDF report export", "API access (coming soon)", "Email support"], + cta: "Start Pro", + href: "/signup?plan=pro", + highlighted: true, + }, + { + name: "Business", + price: "$149", + period: "/ month", + quota: "Unlimited PDFs", + features: ["Everything in Pro", "Team workspace (up to 10 seats)", "Priority support", "Custom branding on reports", "Audit log", "SLA 99.9%"], + cta: "Start Business", + href: "/signup?plan=business", + highlighted: false, + }, +]; + +export default function PricingPage() { + return ( +
+
+
+

Simple, transparent pricing

+

Start free. Upgrade when you need more.

+
+ +
+ {plans.map((plan) => ( +
+
+
+ {plan.name} +
+
+ {plan.price} + {plan.period} +
+
{plan.quota}
+
+ +
    + {plan.features.map((f) => ( +
  • + + {f} +
  • + ))} +
+ + + {plan.cta} + +
+ ))} +
+ +

+ All plans include WCAG 2.1 AA + PDF/UA-1 checking · VAT may apply · Cancel anytime +

+
+
+ ); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..664dcbf --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,9 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap'); +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --brand: #6366f1; + --brand-dark: #4f46e5; +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..c290efc --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "Aimpress PDF Accessibility — WCAG 2.1 AA Compliance Checker", + description: "AI-powered PDF accessibility checker. WCAG 2.1 AA / PDF/UA-1 compliance in seconds. EU Accessibility Act ready.", + keywords: ["PDF accessibility", "WCAG 2.1", "PDF/UA", "accessibility checker", "EU Accessibility Act"], +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/frontend/lib/supabase/client.ts b/frontend/lib/supabase/client.ts new file mode 100644 index 0000000..9f2891b --- /dev/null +++ b/frontend/lib/supabase/client.ts @@ -0,0 +1,8 @@ +import { createBrowserClient } from "@supabase/ssr"; + +export function createClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ); +} diff --git a/frontend/lib/supabase/server.ts b/frontend/lib/supabase/server.ts new file mode 100644 index 0000000..b12ec08 --- /dev/null +++ b/frontend/lib/supabase/server.ts @@ -0,0 +1,20 @@ +import { createServerClient } from "@supabase/ssr"; +import { cookies } from "next/headers"; + +export async function createClient() { + const cookieStore = await cookies(); + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll: () => cookieStore.getAll(), + setAll: (cookiesToSet) => { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options) + ); + }, + }, + } + ); +} diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 0000000..9873e47 --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,14 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + async rewrites() { + return [ + { + source: "/api/:path*", + destination: `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/api/:path*`, + }, + ]; + }, +}; + +export default nextConfig; diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..de42bf6 --- /dev/null +++ b/frontend/tailwind.config.ts @@ -0,0 +1,22 @@ +import type { Config } from "tailwindcss"; + +export default { + content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"], + theme: { + extend: { + colors: { + brand: { + 50: "#eef2ff", + 100: "#e0e7ff", + 500: "#6366f1", + 600: "#4f46e5", + 700: "#4338ca", + }, + }, + fontFamily: { + sans: ["Inter", "system-ui", "sans-serif"], + }, + }, + }, + plugins: [require("@tailwindcss/typography")], +} satisfies Config;