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
|
- **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/
|
||||||
|
|||||||
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} */
|
/** @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
47
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
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>
|
</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.
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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',
|
||||||
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,29 +86,33 @@ 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">
|
||||||
<CardDescription>{repository.abbreviation}</CardDescription>
|
<CardTitle className="text-overflow-ellipsis line-clamp-2">
|
||||||
</CardHeader>
|
{book.title}
|
||||||
<CardContent>
|
</CardTitle>
|
||||||
<Image
|
<CardDescription>{repository.abbreviation}</CardDescription>
|
||||||
alt="book cover"
|
</CardHeader>
|
||||||
className="mx-auto w-full"
|
<CardContent>
|
||||||
width={120}
|
<Image
|
||||||
height={180}
|
alt="book cover"
|
||||||
src={
|
className="mx-auto w-full"
|
||||||
book.isbn
|
width={120}
|
||||||
? `https://covers.openlibrary.org/b/isbn/${book.isbn}-M.jpg`
|
height={180}
|
||||||
: '/images/book-48.svg'
|
src={
|
||||||
}
|
book.isbn
|
||||||
/>
|
? `https://covers.openlibrary.org/b/isbn/${book.isbn}-M.jpg`
|
||||||
</CardContent>
|
: '/images/book-48.svg'
|
||||||
<CardFooter className="text-muted-foreground text-sm block">
|
}
|
||||||
<span className="block w-full">Requested:</span>
|
/>
|
||||||
<span className="block w-full">{formatedDateRequested}</span>
|
</CardContent>
|
||||||
</CardFooter>
|
<CardFooter className="text-muted-foreground text-sm block -mt-5">
|
||||||
</Card>
|
<span className="block w-full text-xs">Requested On</span>
|
||||||
|
<span className="block w-full">{formatedDateRequested}</span>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
</li>
|
</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 (
|
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,
|
||||||
|
}
|
||||||
|
|||||||
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 { 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user