feat: client side login

This commit is contained in:
Yehoshua Sandler 2025-04-25 21:59:49 -05:00
parent d24b554d5e
commit 7a7b85f8c5
19 changed files with 437 additions and 67 deletions

View File

@ -6,10 +6,12 @@
- **Database**: postgres - **Database**: postgres
- **Storage Adapter**: localDisk - **Storage Adapter**: localDisk
## external resources # external resources
## APIs
https://openlibrary.org/dev/docs/api https://openlibrary.org/dev/docs/api
## maybe use ## UIs
https://www.reddit.com/r/nextjs/comments/1ej1y32/share_cool_shadcnstyle_components_libraries_you/ https://ui.shadcn.com/
https://motion-primitives.com/docs https://magicui.design/
https://motion-primitives.com/

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View File

@ -3,7 +3,7 @@ import { withPayload } from '@payloadcms/next/withPayload'
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
images: { images: {
domains: ['covers.openlibrary.org'], domains: ['covers.openlibrary.org', 'cdn.beitzah.net'],
}, },
} }

47
package-lock.json generated
View File

@ -15,6 +15,7 @@
"@payloadcms/next": "3.31.0", "@payloadcms/next": "3.31.0",
"@payloadcms/payload-cloud": "3.31.0", "@payloadcms/payload-cloud": "3.31.0",
"@payloadcms/richtext-lexical": "3.31.0", "@payloadcms/richtext-lexical": "3.31.0",
"@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-tabs": "^1.1.4", "@radix-ui/react-tabs": "^1.1.4",
"@tailwindcss/cli": "^4.1.4", "@tailwindcss/cli": "^4.1.4",
@ -4134,6 +4135,52 @@
} }
} }
}, },
"node_modules/@radix-ui/react-label": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.4.tgz",
"integrity": "sha512-wy3dqizZnZVV4ja0FNnUhIWNwWdoldXrneEyUcVtLYDAt8ovGS4ridtMAOGgXBBIfggL4BOveVWsjXDORdGEQg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.0.tgz",
"integrity": "sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": { "node_modules/@radix-ui/react-presence": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.3.tgz",

View File

@ -22,6 +22,7 @@
"@payloadcms/next": "3.31.0", "@payloadcms/next": "3.31.0",
"@payloadcms/payload-cloud": "3.31.0", "@payloadcms/payload-cloud": "3.31.0",
"@payloadcms/richtext-lexical": "3.31.0", "@payloadcms/richtext-lexical": "3.31.0",
"@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-tabs": "^1.1.4", "@radix-ui/react-tabs": "^1.1.4",
"@tailwindcss/cli": "^4.1.4", "@tailwindcss/cli": "^4.1.4",

View File

@ -51,7 +51,7 @@ function RepoDropdown({
<Button <Button
disabled={isDisabled} disabled={isDisabled}
onClick={onClickRequest} onClick={onClickRequest}
className="hover:scale-105 bg-emerald-500 text-foreground hover:text-black cursor-pointer" className="hover:scale-105 bg-emerald-500 text-foreground hover:text-background cursor-pointer"
> >
{isRequesting && <Loader2 className="animate-spin" />} {isRequesting && <Loader2 className="animate-spin" />}
<span>Request Copy</span> <span>Request Copy</span>

View File

View File

@ -0,0 +1,40 @@
import { headers as nextHeaders } from 'next/headers'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { redirect } from 'next/navigation'
import { GalleryVerticalEnd } from 'lucide-react'
import { LoginForm } from '@/components/login-form'
import Image from 'next/image'
type Props = {}
const LoginPage = async (props: Props) => {
const payload = await getPayload({ config: configPromise })
const headers = await nextHeaders()
const userResult = await payload.auth({ headers })
if (Boolean(userResult.user)) redirect('/profile')
return (
<div className="flex min-h-svh flex-col items-center justify-center gap-6 bg-muted p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<a href="https://beitzah.net?ref=midrashim" className="flex items-center gap-2 self-center font-medium">
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-primary text-primary-foreground">
<Image
src="https://cdn.beitzah.net/egg-highlight-white.svg"
className="h-full"
height={46}
width={20}
alt="beitzah logo"
/>
</div>
<div>
<span className="block leading-3.5">Developed by</span>
<span className="block leading-3.5">Beitzah.ts</span>
</div>
</a>
<LoginForm />
</div>
</div>
)
}
export default LoginPage

View File

@ -82,7 +82,7 @@ export default async function HomePage() {
</TextShimmer> </TextShimmer>
{user && <small>{user.firstName}</small>} {user && <small>{user.firstName}</small>}
</h2> </h2>
<div className="mt-8 text-lg font-light text-pretty text-foreground text-shadow-lg shadow-background sm:text-xl/8"> <div className="mt-8 text-lg font-normal text-pretty text-foreground text-shadow-lg text-shadow-background sm:text-xl/8">
<p className="indent-5 italic"> <p className="indent-5 italic">
Never refuse to lend books to anyone who cannot afford to purchase them, but lend Never refuse to lend books to anyone who cannot afford to purchase them, but lend
books only to those who can be trusted to return them. books only to those who can be trusted to return them.

View File

@ -41,6 +41,16 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--animate-rippling: rippling var(--duration) ease-out;
@keyframes rippling {
0% {
opacity: 1;
}
100% {
transform: scale(2);
opacity: 0;
}
}
} }
:root { :root {

View File

@ -1,4 +1,5 @@
import type { CollectionConfig } from 'payload' import { defaultAccess } from '@/lib/utils'
import type { CollectionConfig, PayloadRequest } from 'payload'
export const Users: CollectionConfig = { export const Users: CollectionConfig = {
slug: 'users', slug: 'users',
@ -6,13 +7,21 @@ export const Users: CollectionConfig = {
useAsTitle: 'email', useAsTitle: 'email',
}, },
auth: true, auth: true,
access: {
...defaultAccess,
update: ({ req, data }) => {
if (req.user?.role === 'admin') return true
else if (data?.user.id === req.user?.id) return true
else return false
}
},
fields: [ fields: [
// Email added by default // Email added by default
// Add more fields as needed // Add more fields as needed
{ {
name: 'role', name: 'role',
type: 'select', type: 'select',
options: ['admin', 'user'], options: ['admin', 'user', 'unclaimed'],
saveToJWT: true saveToJWT: true
}, },
{ {
@ -23,5 +32,9 @@ export const Users: CollectionConfig = {
name: 'lastName', name: 'lastName',
type: 'text', type: 'text',
}, },
{
name: 'isOwnershipClaimed',
type: 'checkbox',
}
], ],
} }

View File

@ -5,6 +5,7 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import Image from 'next/image' import Image from 'next/image'
import { BorderTrail } from 'components/motion-primitives/border-trail' import { BorderTrail } from 'components/motion-primitives/border-trail'
import clsx from 'clsx' import clsx from 'clsx'
import Link from 'next/link'
const stats = [ const stats = [
{ name: 'Outbound Loans', stat: '13' }, { name: 'Outbound Loans', stat: '13' },
@ -24,7 +25,7 @@ const UserFeed = async (props: Props) => {
const holdRequests = (await payload.find({ const holdRequests = (await payload.find({
collection: 'holdRequests', collection: 'holdRequests',
limit: 6, limit: 10,
depth: 3, depth: 3,
select: { select: {
copy: true, copy: true,
@ -74,7 +75,7 @@ const UserFeed = async (props: Props) => {
<h2 className="text-lg font-semibold text-foreground">Inbound Activity</h2> <h2 className="text-lg font-semibold text-foreground">Inbound Activity</h2>
<div className="my-3"> <div className="my-3">
<h3 className="text-base font-semibold text-muted-foreground">Your Holds</h3> <h3 className="text-base font-semibold text-muted-foreground">Your Holds</h3>
<ul className="flex gap-2"> <ul className="flex flex-wrap justify gap-4 last-child-adjustment">
{holdRequests.docs?.map((h) => { {holdRequests.docs?.map((h) => {
const book = h.book as Book const book = h.book as Book
const repository = h.repository as Repository const repository = h.repository as Repository
@ -85,9 +86,12 @@ const UserFeed = async (props: Props) => {
return ( return (
<li className="inline-block" key={book.isbn}> <li className="inline-block" key={book.isbn}>
<Card className="w-48"> <Link className="block hover:scale-105 transition-all" href={`/books/${book.id}`}>
<CardHeader> <Card className="w-48 pt-4 pb-2">
<CardTitle>{book.title}</CardTitle> <CardHeader className="-mb-4">
<CardTitle className="text-overflow-ellipsis line-clamp-2">
{book.title}
</CardTitle>
<CardDescription>{repository.abbreviation}</CardDescription> <CardDescription>{repository.abbreviation}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -103,11 +107,12 @@ const UserFeed = async (props: Props) => {
} }
/> />
</CardContent> </CardContent>
<CardFooter className="text-muted-foreground text-sm block"> <CardFooter className="text-muted-foreground text-sm block -mt-5">
<span className="block w-full">Requested:</span> <span className="block w-full text-xs">Requested On</span>
<span className="block w-full">{formatedDateRequested}</span> <span className="block w-full">{formatedDateRequested}</span>
</CardFooter> </CardFooter>
</Card> </Card>
</Link>
</li> </li>
) )
})} })}

View File

@ -0,0 +1,103 @@
'use client'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
export function LoginForm({ className, ...props }: React.ComponentProps<'div'>) {
const router = useRouter()
const [isLoading, setIsLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (!isLoading) {
const formData = new FormData(e.currentTarget)
const email = String(formData.get('email'))
const password = String(formData.get('password'))
if (!email || !password) return
setIsLoading(true)
try {
const loginReq = await fetch('/api/users/login', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
password,
}),
})
const user = await loginReq.json()
if (user.token) router.push('/')
} catch (error) {
console.error('Login failed:', error)
} finally {
setIsLoading(false)
}
}
}
return (
<div className={cn('flex flex-col gap-6', className)} {...props}>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Welcome back</CardTitle>
<CardDescription>Login to access your account</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit}>
<div className="grid gap-6">
<div className="grid gap-6">
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="m@example.com"
required
/>
</div>
<div className="grid gap-3">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
<a
href="/forgotPassword"
className="ml-auto text-sm underline-offset-4 hover:underline"
>
Forgot your password?
</a>
</div>
<Input id="password" name="password" type="password" required />
</div>
<Button type="submit" className="w-full">
Login
</Button>
</div>
<div className="text-center text-sm">
Don&apos;t have an account?{' '}
<a href="/requestAccess" className="underline underline-offset-4">
Request Access
</a>
</div>
</div>
</form>
</CardContent>
</Card>
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
By clicking continue, you agree to our <a href="#">Terms of Service</a> and{' '}
<a href="#">Privacy Policy</a>.
</div>
</div>
)
}

View File

@ -1,78 +1,92 @@
import * as React from 'react' import * as React from "react"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<'div'>) { function Card({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm', "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className, className
)} )}
{...props} {...props}
/> />
) )
} }
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6', "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className, className
)} )}
{...props} {...props}
/> />
) )
} }
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) { function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-title" 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( className={cn(
'leading-none font-semibold text-ellipsis overflow-hidden line-clamp-1', "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className, className
)} )}
{...props} {...props}
/> />
) )
} }
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) { function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-description" data-slot="card-content"
className={cn('text-muted-foreground text-sm', className)} className={cn("px-6", className)}
{...props} {...props}
/> />
) )
} }
function CardAction({ className, ...props }: React.ComponentProps<'div'>) { function CardFooter({ 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 ( return (
<div <div
data-slot="card-footer" data-slot="card-footer"
className={cn('flex items-center px-6 [.border-t]:pt-6', className)} className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props} {...props}
/> />
) )
} }
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent } 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 flex 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,80 @@
'use client'
import { cn } from '@/lib/utils'
import React, { MouseEvent, useEffect, useState } from 'react'
interface RippleButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
rippleColor?: string
duration?: string
}
export const RippleButton = React.forwardRef<HTMLButtonElement, RippleButtonProps>(
(
{ className, children, rippleColor = '#ffffff', duration = '600ms', onClick, ...props },
ref,
) => {
const [buttonRipples, setButtonRipples] = useState<
Array<{ x: number; y: number; size: number; key: number }>
>([])
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
createRipple(event)
onClick?.(event)
}
const createRipple = (event: MouseEvent<HTMLButtonElement>) => {
const button = event.currentTarget
const rect = button.getBoundingClientRect()
const size = Math.max(rect.width, rect.height)
const x = event.clientX - rect.left - size / 2
const y = event.clientY - rect.top - size / 2
const newRipple = { x, y, size, key: Date.now() }
setButtonRipples((prevRipples) => [...prevRipples, newRipple])
}
useEffect(() => {
if (buttonRipples.length > 0) {
const lastRipple = buttonRipples[buttonRipples.length - 1]
const timeout = setTimeout(() => {
setButtonRipples((prevRipples) =>
prevRipples.filter((ripple) => ripple.key !== lastRipple.key),
)
}, parseInt(duration))
return () => clearTimeout(timeout)
}
}, [buttonRipples, duration])
return (
<button
className={cn(
'relative flex cursor-pointer items-center justify-center overflow-hidden rounded-lg border-2 bg-background px-4 py-2 text-center text-primary',
className,
)}
onClick={handleClick}
ref={ref}
{...props}
>
<div className="relative z-10">{children}</div>
<span className="pointer-events-none absolute inset-0">
{buttonRipples.map((ripple) => (
<span
className="absolute animate-rippling rounded-full bg-background opacity-30"
key={ripple.key}
style={{
width: `${ripple.size}px`,
height: `${ripple.size}px`,
top: `${ripple.y}px`,
left: `${ripple.x}px`,
backgroundColor: rippleColor,
transform: `scale(0)`,
}}
/>
))}
</span>
</button>
)
},
)
RippleButton.displayName = 'RippleButton'

View File

@ -1,6 +1,14 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx"
import { PayloadRequest } from "payload"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
export const defaultAccess = {
admin: ({ req }: { req: PayloadRequest }) => {
if (req.user?.role === 'admin') return true
else return false
}
}

View File

@ -155,9 +155,10 @@ export interface UserAuthOperations {
*/ */
export interface User { export interface User {
id: number; id: number;
role?: ('admin' | 'user') | null; role?: ('admin' | 'user' | 'unclaimed') | null;
firstName?: string | null; firstName?: string | null;
lastName?: string | null; lastName?: string | null;
isOwnershipClaimed?: boolean | null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
email: string; email: string;
@ -462,6 +463,7 @@ export interface UsersSelect<T extends boolean = true> {
role?: T; role?: T;
firstName?: T; firstName?: T;
lastName?: T; lastName?: T;
isOwnershipClaimed?: T;
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
email?: T; email?: T;