feat: client side login
This commit is contained in:
parent
d24b554d5e
commit
7a7b85f8c5
10
README.md
10
README.md
@ -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/
|
||||
|
||||
BIN
assets/2025-04-25-20-29-26.png
Normal file
BIN
assets/2025-04-25-20-29-26.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
@ -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
47
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
0
src/app/(frontend)/login/page.client.tsx
Normal file
0
src/app/(frontend)/login/page.client.tsx
Normal file
40
src/app/(frontend)/login/page.tsx
Normal file
40
src/app/(frontend)/login/page.tsx
Normal 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
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
103
src/components/login-form.tsx
Normal file
103
src/components/login-form.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal 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 }
|
||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal 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 }
|
||||
80
src/components/ui/ripple-button.tsx
Normal file
80
src/components/ui/ripple-button.tsx
Normal 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'
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user