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
- **Storage Adapter**: localDisk
## external resources
# external resources
## APIs
https://openlibrary.org/dev/docs/api
## maybe use
https://www.reddit.com/r/nextjs/comments/1ej1y32/share_cool_shadcnstyle_components_libraries_you/
https://motion-primitives.com/docs
## UIs
https://ui.shadcn.com/
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} */
const nextConfig = {
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/payload-cloud": "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-tabs": "^1.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": {
"version": "1.1.3",
"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/payload-cloud": "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-tabs": "^1.1.4",
"@tailwindcss/cli": "^4.1.4",

View File

@ -51,7 +51,7 @@ function RepoDropdown({
<Button
disabled={isDisabled}
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" />}
<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>
{user && <small>{user.firstName}</small>}
</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">
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.

View File

@ -41,6 +41,16 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--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 {
@ -124,4 +134,4 @@
html[data-theme='dark'],
html[data-theme='light'] {
opacity: initial;
}
}

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 = {
slug: 'users',
@ -6,13 +7,21 @@ export const Users: CollectionConfig = {
useAsTitle: 'email',
},
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: [
// Email added by default
// Add more fields as needed
{
name: 'role',
type: 'select',
options: ['admin', 'user'],
options: ['admin', 'user', 'unclaimed'],
saveToJWT: true
},
{
@ -23,5 +32,9 @@ export const Users: CollectionConfig = {
name: 'lastName',
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 { BorderTrail } from 'components/motion-primitives/border-trail'
import clsx from 'clsx'
import Link from 'next/link'
const stats = [
{ name: 'Outbound Loans', stat: '13' },
@ -24,7 +25,7 @@ const UserFeed = async (props: Props) => {
const holdRequests = (await payload.find({
collection: 'holdRequests',
limit: 6,
limit: 10,
depth: 3,
select: {
copy: true,
@ -74,7 +75,7 @@ const UserFeed = async (props: Props) => {
<h2 className="text-lg font-semibold text-foreground">Inbound Activity</h2>
<div className="my-3">
<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) => {
const book = h.book as Book
const repository = h.repository as Repository
@ -85,29 +86,33 @@ const UserFeed = async (props: Props) => {
return (
<li className="inline-block" key={book.isbn}>
<Card className="w-48">
<CardHeader>
<CardTitle>{book.title}</CardTitle>
<CardDescription>{repository.abbreviation}</CardDescription>
</CardHeader>
<CardContent>
<Image
alt="book cover"
className="mx-auto w-full"
width={120}
height={180}
src={
book.isbn
? `https://covers.openlibrary.org/b/isbn/${book.isbn}-M.jpg`
: '/images/book-48.svg'
}
/>
</CardContent>
<CardFooter className="text-muted-foreground text-sm block">
<span className="block w-full">Requested:</span>
<span className="block w-full">{formatedDateRequested}</span>
</CardFooter>
</Card>
<Link className="block hover:scale-105 transition-all" href={`/books/${book.id}`}>
<Card className="w-48 pt-4 pb-2">
<CardHeader className="-mb-4">
<CardTitle className="text-overflow-ellipsis line-clamp-2">
{book.title}
</CardTitle>
<CardDescription>{repository.abbreviation}</CardDescription>
</CardHeader>
<CardContent>
<Image
alt="book cover"
className="mx-auto w-full"
width={120}
height={180}
src={
book.isbn
? `https://covers.openlibrary.org/b/isbn/${book.isbn}-M.jpg`
: '/images/book-48.svg'
}
/>
</CardContent>
<CardFooter className="text-muted-foreground text-sm block -mt-5">
<span className="block w-full text-xs">Requested On</span>
<span className="block w-full">{formatedDateRequested}</span>
</CardFooter>
</Card>
</Link>
</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 (
<div
data-slot="card"
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className,
"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'>) {
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-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className,
"@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
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
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(
'leading-none font-semibold text-ellipsis overflow-hidden line-clamp-1',
className,
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)}
data-slot="card-content"
className={cn("px-6", 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'>) {
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)}
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...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 { PayloadRequest } from "payload"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
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 {
id: number;
role?: ('admin' | 'user') | null;
role?: ('admin' | 'user' | 'unclaimed') | null;
firstName?: string | null;
lastName?: string | null;
isOwnershipClaimed?: boolean | null;
updatedAt: string;
createdAt: string;
email: string;
@ -462,6 +463,7 @@ export interface UsersSelect<T extends boolean = true> {
role?: T;
firstName?: T;
lastName?: T;
isOwnershipClaimed?: T;
updatedAt?: T;
createdAt?: T;
email?: T;