diff --git a/app/api/auth/callback/route.ts b/app/api/auth/callback/route.ts new file mode 100644 index 0000000..bda4211 --- /dev/null +++ b/app/api/auth/callback/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +// The client you created from the Server-Side Auth instructions +import { createClient } from "@/lib/supabase/server"; + +export async function GET(request: Request) { + const { searchParams, origin } = new URL(request.url); + const code = searchParams.get("code"); + // if "next" is in param, use it as the redirect URL + let next = searchParams.get("next") ?? "/"; + if (!next.startsWith("/")) { + // if "next" is not a relative URL, use the default + next = "/"; + } + + if (code) { + const supabase = await createClient(); + const { error } = await supabase.auth.exchangeCodeForSession(code); + if (!error) { + const forwardedHost = request.headers.get("x-forwarded-host"); // original origin before load balancer + const isLocalEnv = process.env.NODE_ENV === "development"; + if (isLocalEnv) { + // we can be sure that there is no load balancer in between, so no need to watch for X-Forwarded-Host + return NextResponse.redirect(`${origin}${next}`); + } else if (forwardedHost) { + return NextResponse.redirect(`https://${forwardedHost}${next}`); + } else { + return NextResponse.redirect(`${origin}${next}`); + } + } + } + + // return the user to an error page with instructions + return NextResponse.redirect(`${origin}/auth/auth-code-error`); +} diff --git a/app/auth/callback/page.tsx b/app/auth/callback/page.tsx new file mode 100644 index 0000000..7efe23d --- /dev/null +++ b/app/auth/callback/page.tsx @@ -0,0 +1,7 @@ +import { redirect } from "next/navigation"; + +const Page = () => { + redirect("/"); +}; + +export default Page; diff --git a/app/generate/page.tsx b/app/generate/page.tsx index f9f35ec..b3c2d99 100644 --- a/app/generate/page.tsx +++ b/app/generate/page.tsx @@ -12,6 +12,7 @@ import { Upload, X, } from "lucide-react"; +import Image from "next/image"; import { useCallback, useEffect, useRef, useState } from "react"; interface UploadedFile { @@ -56,46 +57,50 @@ export default function UploadPage() { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; }; - const handleFiles = (files: FileList) => { - const file = files[0]; - if (!file) return; + const handleFiles = useCallback( + (files: FileList) => { + const file = files[0]; + if (!file) return; - if (!file.type.startsWith("image/")) { - setError("Please select an image file"); - return; - } - - // Check file size (max 5MB) - if (file.size > 5 * 1024 * 1024) { - setError("File size must be less than 5MB"); - return; - } - - // Create preview URL - const previewUrl = URL.createObjectURL(file); + if (!file.type.startsWith("image/")) { + setError("Please select an image file"); + return; + } - const newFile: UploadedFile = { - file, - name: file.name, - size: formatFileSize(file.size), - progress: 0, - preview: previewUrl, - }; + if (file.size > 5 * 1024 * 1024) { + setError("File size must be less than 5MB"); + return; + } - setUploadedFiles([newFile]); - setError(null); - setSvgResult(null); - }; + const previewUrl = URL.createObjectURL(file); + + const newFile: UploadedFile = { + file, + name: file.name, + size: formatFileSize(file.size), + progress: 0, + preview: previewUrl, + }; + + setUploadedFiles([newFile]); + setError(null); + setSvgResult(null); + }, + [setError, setUploadedFiles, setSvgResult], + ); - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - setIsDragActive(false); + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragActive(false); - const files = e.dataTransfer.files; - if (files.length > 0) { - handleFiles(files); - } - }, []); + const files = e.dataTransfer.files; + if (files.length > 0) { + handleFiles(files); + } + }, + [handleFiles], + ); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -329,7 +334,7 @@ export default function UploadPage() { {/* Image Preview */}
- {file.name} { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); + + const supabase = createClient(); + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(null); + + try { + const { error } = await supabase.auth.signInWithPassword({ + email, + password, + }); + if (error) throw error; + router.push("/"); + } catch (error: unknown) { + setError(error instanceof Error ? error.message : "An error occurred"); + } finally { + setIsLoading(false); + } + }; + + const handleOAuthLogin = async (provider: Provider) => { + setIsLoading(true); + setError(null); + + try { + const { error } = await supabase.auth.signInWithOAuth({ + provider, + options: { + redirectTo: `${process.env.SITE_URL}/auth/callback`, + }, + }); + if (error) throw error; + } catch (error: unknown) { + setError(error instanceof Error ? error.message : "An error occurred"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+ + + Welcome back + + Login with your GitHub or Google account + + + +
+ + + + + + + Or continue with + + + Email + setEmail(e.target.value)} + /> + + +
+ Password + + Forgot your password? + +
+ setPassword(e.target.value)} + /> +
+ {error &&

{error}

} + + + + Don't have an account?{" "} + Sign up + + +
+
+
+
+
+
+
+ ); +}; + +export default Login; diff --git a/app/signup/page.tsx b/app/signup/page.tsx new file mode 100644 index 0000000..595c9e6 --- /dev/null +++ b/app/signup/page.tsx @@ -0,0 +1,5 @@ +const Signup = () => { + return
Signup
; +}; + +export default Signup; diff --git a/components/NavbarComponents/AuthButtons.tsx b/components/NavbarComponents/AuthButtons.tsx new file mode 100644 index 0000000..71b714f --- /dev/null +++ b/components/NavbarComponents/AuthButtons.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { createClient } from "@/lib/supabase/client"; +import Link from "next/link"; +import { useEffect, useState } from "react"; + +const AuthButtons = () => { + const supabase = createClient(); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchUser = async () => { + const { data } = await supabase.auth.getUser(); + if (data.user) setUser(data.user.user_metadata.name); + setLoading(false); + }; + fetchUser(); + }, [supabase.auth]); + + return ( +
+ {user ? ( + + ) : ( + + Login + + )} +
+ ); +}; + +export default AuthButtons; diff --git a/components/NavbarComponents/Navbar.tsx b/components/NavbarComponents/Navbar.tsx index dc0bd57..cd0232e 100644 --- a/components/NavbarComponents/Navbar.tsx +++ b/components/NavbarComponents/Navbar.tsx @@ -4,6 +4,7 @@ import { Home, ImageUp } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import ThemeToggle from "./ThemeToggle"; +import AuthButtons from "./AuthButtons"; export default function Navbar() { const pathname = usePathname(); @@ -46,8 +47,9 @@ export default function Navbar() {
-
+
+
diff --git a/components/ui/button.tsx b/components/ui/button.tsx index 1dd187c..74c6c39 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -5,7 +5,7 @@ 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", + "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 cursor-pointer", { variants: { variant: { diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..4f88024 --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +}; diff --git a/components/ui/field.tsx b/components/ui/field.tsx new file mode 100644 index 0000000..6b8071e --- /dev/null +++ b/components/ui/field.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { useMemo } from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className, + )} + {...props} + /> + ); +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ); +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
[data-slot=field-group]]:gap-4", + className, + )} + {...props} + /> + ); +} + +const fieldVariants = cva( + "group/field flex w-full gap-3 data-[invalid=true]:text-destructive", + { + variants: { + orientation: { + vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], + horizontal: [ + "flex-row items-center", + "[&>[data-slot=field-label]]:flex-auto", + "has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + responsive: [ + "flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto", + "@md/field-group:[&>[data-slot=field-label]]:flex-auto", + "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + }, + }, + defaultVariants: { + orientation: "vertical", + }, + }, +); + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +