notifications done

This commit is contained in:
keyan 2021-06-24 18:56:01 -05:00
parent f7b4618b4a
commit 01922e4b88
24 changed files with 391 additions and 100 deletions

View File

@ -37,18 +37,21 @@ function nextCursorEncoded (cursor) {
export default {
Query: {
moreItems: async (parent, { sort, cursor, userId }, { models }) => {
moreItems: async (parent, { sort, cursor, userId }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
const items = userId
? await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "userId" = $1 AND "parentId" IS NULL AND created_at <= $2
ORDER BY created_at DESC
OFFSET $3
LIMIT ${LIMIT}`, Number(userId), decodedCursor.time, decodedCursor.offset)
: sort === 'hot'
? await models.$queryRaw(`
let items
switch (sort) {
case 'user':
items = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "userId" = $1 AND "parentId" IS NULL AND created_at <= $2
ORDER BY created_at DESC
OFFSET $3
LIMIT ${LIMIT}`, Number(userId), decodedCursor.time, decodedCursor.offset)
break
case 'hot':
items = await models.$queryRaw(`
${SELECT}
FROM "Item"
${timedLeftJoinSats(1)}
@ -56,18 +59,66 @@ export default {
${timedOrderBySats(1)}
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
: await models.$queryRaw(`
break
default:
items = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "parentId" IS NULL AND created_at <= $1
ORDER BY created_at DESC
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
break
}
return {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
items
}
},
moreFlatComments: async (parent, { cursor, userId }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
let comments
if (userId) {
comments = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "userId" = $1 AND "parentId" IS NOT NULL
AND created_at <= $2
ORDER BY created_at DESC
OFFSET $3
LIMIT ${LIMIT}`, Number(userId), decodedCursor.time, decodedCursor.offset)
} else {
if (!me) {
throw new AuthenticationError('you must be logged in')
}
const user = await models.user.findUnique({ where: { name: me.name } })
comments = await models.$queryRaw(`
${SELECT}
From "Item"
JOIN "Item" p ON "Item"."parentId" = p.id AND p."userId" = $1
AND "Item"."userId" <> $1 AND "Item".created_at <= $2
ORDER BY "Item".created_at DESC
OFFSET $3
LIMIT ${LIMIT}`, user.id, decodedCursor.time, decodedCursor.offset)
}
return {
cursor: comments.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
comments
}
},
notifications: async (parent, args, { me, models }) => {
if (!me) {
throw new AuthenticationError('you must be logged in')
}
const user = await models.user.findUnique({ where: { name: me.name } })
return await models.$queryRaw(`
${SELECT}
From "Item"
JOIN "Item" p ON "Item"."parentId" = p.id AND p."userId" = $1
AND "Item"."userId" <> $1
ORDER BY "Item".created_at DESC`, user.id)
},
item: async (parent, { id }, { models }) => {
const [item] = await models.$queryRaw(`
${SELECT}
@ -104,8 +155,6 @@ export default {
throw new UserInputError('link must have url', { argumentName: 'url' })
}
console.log(ensureProtocol(url))
return await createItem(parent, { title, url: ensureProtocol(url) }, { me, models })
},
createDiscussion: async (parent, { title, text }, { me, models }) => {

View File

@ -15,6 +15,25 @@ export default {
}
return me.name === name || !(await models.user.findUnique({ where: { name } }))
},
recentlyStacked: async (parent, args, { models, me }) => {
if (!me) {
throw new AuthenticationError('you must be logged in')
}
const user = await models.user.findUnique({ where: { name: me.name } })
const [{ sum }] = await models.$queryRaw(`
SELECT sum("Vote".sats)
FROM "Item"
LEFT JOIN "Vote" on "Vote"."itemId" = "Item".id
AND "Vote"."userId" <> $1
AND ("Vote".created_at > $2 OR $2 IS NULL)
AND "Vote".boost = false
WHERE "Item"."userId" = $1`, user.id, user.checkedNotesAt)
await models.user.update({ where: { name: me.name }, data: { checkedNotesAt: new Date() } })
return sum || 0
}
},
@ -46,12 +65,36 @@ export default {
const [{ sum }] = await models.$queryRaw`
SELECT sum("Vote".sats)
FROM "Item"
LEFT JOIN "Vote" on "Vote"."itemId" = "Item".id AND "Vote"."userId" <> ${user.id}
LEFT JOIN "Vote" on "Vote"."itemId" = "Item".id AND "Vote"."userId" <> ${user.id} AND boost = false
WHERE "Item"."userId" = ${user.id}`
return sum || 0
},
sats: async (user, args, { models }) => {
return Math.floor(user.msats / 1000)
},
hasNewNotes: async (user, args, { models }) => {
// check if any votes have been cast for them since checkedNotesAt
const votes = await models.$queryRaw(`
SELECT "Vote".id, "Vote".created_at
FROM "Vote"
LEFT JOIN "Item" on "Vote"."itemId" = "Item".id
AND "Vote"."userId" <> $1
AND ("Vote".created_at > $2 OR $2 IS NULL)
AND "Vote".boost = false
WHERE "Item"."userId" = $1
LIMIT 1`, user.id, user.checkedNotesAt)
if (votes.length > 0) {
return true
}
// check if they have any replies since checkedNotesAt
const newReplies = await models.$queryRaw(`
SELECT "Item".id, "Item".created_at
From "Item"
JOIN "Item" p ON "Item"."parentId" = p.id AND p."userId" = $1
AND ("Item".created_at > $2 OR $2 IS NULL) AND "Item"."userId" <> $1
LIMIT 1`, user.id, user.checkedNotesAt)
return !!newReplies.length
}
}
}

View File

@ -3,6 +3,8 @@ import { gql } from 'apollo-server-micro'
export default gql`
extend type Query {
moreItems(sort: String!, cursor: String, userId: ID): Items
moreFlatComments(cursor: String, userId: ID): Comments
notifications: [Item!]!
item(id: ID!): Item
userComments(userId: ID!): [Item!]
root(id: ID!): Item
@ -20,6 +22,11 @@ export default gql`
items: [Item!]!
}
type Comments {
cursor: String
comments: [Item!]!
}
type Item {
id: ID!
createdAt: String!

View File

@ -6,6 +6,7 @@ export default gql`
user(name: String!): User
users: [User!]
nameAvailable(name: String!): Boolean!
recentlyStacked: Int!
}
extend type Mutation {
@ -20,6 +21,7 @@ export default gql`
stacked: Int!
freePosts: Int!
freeComments: Int!
hasNewNotes: Boolean!
sats: Int!
msats: Int!
}

View File

@ -3,12 +3,13 @@ import styles from './comment.module.css'
import Text from './text'
import Link from 'next/link'
import Reply from './reply'
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { gql, useQuery } from '@apollo/client'
import { timeSince } from '../lib/time'
import UpVote from './upvote'
import Eye from '../svgs/eye-fill.svg'
import EyeClose from '../svgs/eye-close-line.svg'
import { useRouter } from 'next/router'
function Parent ({ item }) {
const { data } = useQuery(
@ -24,7 +25,7 @@ function Parent ({ item }) {
<>
<span> \ </span>
<Link href={`/items/${item.parentId}`} passHref>
<a className='text-reset'>parent</a>
<a onClick={e => e.stopPropagation()} className='text-reset'>parent</a>
</Link>
</>
)
@ -38,18 +39,33 @@ function Parent ({ item }) {
{data.root.id !== item.parentId && <ParentFrag />}
<span> \ </span>
<Link href={`/items/${data.root.id}`} passHref>
<a className='text-reset'>{data.root.title}</a>
<a onClick={e => e.stopPropagation()} className='text-reset'>root: {data.root.title}</a>
</Link>
</>
)
}
export default function Comment ({ item, children, replyOpen, includeParent, cacheId, noComments, noReply }) {
export default function Comment ({ item, children, replyOpen, includeParent, cacheId, noComments, noReply, clickToContext }) {
const [reply, setReply] = useState(replyOpen)
const [collapse, setCollapse] = useState(false)
const ref = useRef(null)
const router = useRouter()
useEffect(() => {
if (Number(router.query.commentId) === Number(item.id)) {
ref.current.scrollIntoView()
// ref.current.classList.add('flash-it')
}
}, [item])
return (
<div className={includeParent ? '' : `${styles.comment} ${collapse ? styles.collapsed : ''}`}>
<div
ref={ref} onClick={() => {
if (clickToContext) {
router.push(`/items/${item.parentId}?commentId=${item.id}`, `/items/${item.parentId}`)
}
}} className={includeParent ? `${clickToContext ? styles.clickToContext : ''}` : `${styles.comment} ${collapse ? styles.collapsed : ''}`}
>
<div className={`${itemStyles.item} ${styles.item}`}>
<UpVote itemId={item.id} meSats={item.meSats} className={styles.upvote} />
<div className={`${itemStyles.hunk} ${styles.hunk}`}>
@ -60,11 +76,11 @@ export default function Comment ({ item, children, replyOpen, includeParent, cac
<span>{item.boost} boost</span>
<span> \ </span>
<Link href={`/items/${item.id}`} passHref>
<a className='text-reset'>{item.ncomments} replies</a>
<a onClick={e => e.stopPropagation()} className='text-reset'>{item.ncomments} replies</a>
</Link>
<span> \ </span>
<Link href={`/${item.user.name}`} passHref>
<a>@{item.user.name}</a>
<a onClick={e => e.stopPropagation()}>@{item.user.name}</a>
</Link>
<span> </span>
<span>{timeSince(new Date(item.createdAt))}</span>

View File

@ -80,6 +80,15 @@
padding-left: .2rem;
}
.clickToContext {
border-radius: .4rem;
padding: .2rem 0;
}
.clickToContext:hover {
background-color: rgba(0, 0, 0, 0.03);
}
.comment:not(:last-child) {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
@ -89,4 +98,9 @@
padding-top: .25rem;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.clickToContext {
scroll-behavior: smooth;
cursor: pointer;
}

View File

@ -0,0 +1,70 @@
import { useQuery } from '@apollo/client'
import Button from 'react-bootstrap/Button'
import { MORE_FLAT_COMMENTS } from '../fragments/comments'
import { useState } from 'react'
import Comment, { CommentSkeleton } from './comment'
export default function CommentsFlat ({ variables, ...props }) {
const { loading, error, data, fetchMore } = useQuery(MORE_FLAT_COMMENTS, {
variables
})
if (error) return <div>Failed to load!</div>
if (loading) {
return <CommentsFlatSkeleton />
}
const { moreFlatComments: { comments, cursor } } = data
return (
<>
{comments.map(item => (
<Comment key={item.id} item={item} {...props} />
))}
<MoreFooter cursor={cursor} fetchMore={fetchMore} />
</>
)
}
function CommentsFlatSkeleton () {
const comments = new Array(21).fill(null)
return (
<div>{comments.map((_, i) => (
<CommentSkeleton key={i} skeletonChildren={0} />
))}
</div>
)
}
function MoreFooter ({ cursor, fetchMore }) {
const [loading, setLoading] = useState(false)
if (loading) {
return <div><CommentsFlatSkeleton /></div>
}
let Footer
if (cursor) {
Footer = () => (
<Button
variant='primary'
size='md'
onClick={async () => {
setLoading(true)
await fetchMore({
variables: {
cursor
}
})
setLoading(false)
}}
>more
</Button>
)
} else {
Footer = () => (
<div className='text-muted' style={{ fontFamily: 'lightning', fontSize: '2rem', opacity: '0.6' }}>GENISIS</div>
)
}
return <div className='d-flex justify-content-center mt-4 mb-2'><Footer /></div>
}

View File

@ -1,7 +1,16 @@
import { useQuery } from '@apollo/client'
import { useEffect } from 'react'
import Comment, { CommentSkeleton } from './comment'
export default function Comments ({ comments, ...props }) {
useEffect(() => {
// Your code here
const hash = window.location.hash
if (hash) {
document.querySelector(hash).scrollIntoView({ behavior: 'smooth' })
}
}, [])
return comments.map(item => (
<Comment key={item.id} item={item} {...props} />
))

View File

@ -7,11 +7,9 @@ import { useRouter } from 'next/router'
import { Button, Container, NavDropdown } from 'react-bootstrap'
import Price from './price'
import { useMe } from './me'
import { useApolloClient } from '@apollo/client'
function WalletSummary () {
const me = useMe()
if (!me) return null
function WalletSummary ({ me }) {
return `[${me.stacked},${me.sats}]`
}
@ -19,6 +17,8 @@ export default function Header () {
const [session, loading] = useSession()
const router = useRouter()
const path = router.asPath.split('?')[0]
const me = useMe()
const client = useApolloClient()
const Corner = () => {
if (loading) {
@ -28,35 +28,56 @@ export default function Header () {
if (session) {
return (
<div className='d-flex align-items-center'>
<NavDropdown className='pl-0' title={`@${session.user.name}`} alignRight>
<Link href={'/' + session.user.name} passHref>
<NavDropdown.Item>profile</NavDropdown.Item>
</Link>
<Link href='/wallet' passHref>
<NavDropdown.Item>wallet</NavDropdown.Item>
</Link>
<div>
<NavDropdown.Divider />
<Link href='/recent' passHref>
<NavDropdown.Item>recent</NavDropdown.Item>
<div className='position-relative'>
<NavDropdown className='pl-0' title={`@${session.user.name}`} alignRight>
<Link href={'/' + session.user.name} passHref>
<NavDropdown.Item>profile</NavDropdown.Item>
</Link>
{session
? (
<Link href='/post' passHref>
<NavDropdown.Item>post</NavDropdown.Item>
</Link>
)
: <NavDropdown.Item onClick={signIn}>post</NavDropdown.Item>}
<NavDropdown.Item href='https://bitcoinerjobs.co' target='_blank'>jobs</NavDropdown.Item>
</div>
<NavDropdown.Divider />
<NavDropdown.Item onClick={signOut}>logout</NavDropdown.Item>
</NavDropdown>
<Nav.Item>
<Link href='/wallet' passHref>
<Nav.Link className='text-success px-0'><WalletSummary /></Nav.Link>
</Link>
</Nav.Item>
<Link href='/notifications' passHref>
<NavDropdown.Item onClick={() => {
// when it's a fresh click evict old notification cache
client.cache.evict({ id: 'ROOT_QUERY', fieldName: 'moreFlatComments:{}' })
client.cache.evict({ id: 'ROOT_QUERY', fieldName: 'recentlyStacked' })
}}
>
notifications
{me && me.hasNewNotes &&
<div className='p-1 d-inline-block bg-danger rounded-circle ml-1'>
<span className='invisible'>{' '}</span>
</div>}
</NavDropdown.Item>
</Link>
<Link href='/wallet' passHref>
<NavDropdown.Item>wallet</NavDropdown.Item>
</Link>
<div>
<NavDropdown.Divider />
<Link href='/recent' passHref>
<NavDropdown.Item>recent</NavDropdown.Item>
</Link>
{session
? (
<Link href='/post' passHref>
<NavDropdown.Item>post</NavDropdown.Item>
</Link>
)
: <NavDropdown.Item onClick={signIn}>post</NavDropdown.Item>}
<NavDropdown.Item href='https://bitcoinerjobs.co' target='_blank'>jobs</NavDropdown.Item>
</div>
<NavDropdown.Divider />
<NavDropdown.Item onClick={signOut}>logout</NavDropdown.Item>
</NavDropdown>
{me && me.hasNewNotes &&
<span className='position-absolute p-1 bg-danger rounded-circle' style={{ top: '5px', right: '0px' }}>
<span className='invisible'>{' '}</span>
</span>}
</div>
{me &&
<Nav.Item>
<Link href='/wallet' passHref>
<Nav.Link className='text-success px-0'><WalletSummary me={me} /></Nav.Link>
</Link>
</Nav.Item>}
</div>
)
} else {

View File

@ -50,8 +50,8 @@ function MoreFooter ({ cursor, fetchMore, offset }) {
if (cursor) {
Footer = () => (
<Button
variant='secondary'
size='sm'
variant='primary'
size='md'
onClick={async () => {
setLoading(true)
await fetchMore({
@ -61,7 +61,7 @@ function MoreFooter ({ cursor, fetchMore, offset }) {
})
setLoading(false)
}}
>Load more
>more
</Button>
)
} else {

View File

@ -14,6 +14,7 @@ export function MeProvider ({ children }) {
stacked
freePosts
freeComments
hasNewNotes
}
}`
const { data } = useQuery(query, { pollInterval: 1000 })

View File

@ -45,7 +45,9 @@ export default function UpVote ({ itemId, meSats, className }) {
}
onClick={
session
? async () => {
? async (e) => {
e.stopPropagation()
strike()
if (!itemId) return
try {
await vote({ variables: { id: itemId, sats: 1 } })
@ -56,8 +58,6 @@ export default function UpVote ({ itemId, meSats, className }) {
}
throw new Error({ message: error.toString() })
}
strike()
}
: signIn
}

View File

@ -14,6 +14,8 @@
}
.upvote.stimi {
fill: #993DF5;
filter: drop-shadow(0 0 7px #C28BF9);
/* fill: #993DF5;
filter: drop-shadow(0 0 7px #C28BF9); */
fill: #F6911D;
filter: drop-shadow(0 0 7px #F6911D);
}

View File

@ -16,6 +16,19 @@ export const COMMENT_FIELDS = gql`
}
`
export const MORE_FLAT_COMMENTS = gql`
${COMMENT_FIELDS}
query MoreFlatComments($cursor: String, $userId: ID) {
moreFlatComments(cursor: $cursor, userId: $userId) {
cursor
comments {
...CommentFields
}
}
}
`
export const COMMENTS = gql`
${COMMENT_FIELDS}

14
package-lock.json generated
View File

@ -656,9 +656,9 @@
}
},
"@prisma/engines": {
"version": "2.23.0-36.adf5e8cba3daf12d456d911d72b6e9418681b28b",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-2.23.0-36.adf5e8cba3daf12d456d911d72b6e9418681b28b.tgz",
"integrity": "sha512-Tgk3kggO5B9IT6mimJAw6HSxbFoDAuDKL3sHHSS41EnQm76j/nf4uhGZFPzOQwZWOLeT5ZLO2khr4/FCA9Nkhw=="
"version": "2.25.0-36.c838e79f39885bc8e1611849b1eb28b5bb5bc922",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-2.25.0-36.c838e79f39885bc8e1611849b1eb28b5bb5bc922.tgz",
"integrity": "sha512-vjLCk8AFRZu3D8h/SMcWDzTo0xkMuUDyXQzXekn8gzAGjb47B6LQXGR6rDoZ3/uPM13JNTLPvF62mtVaY6fVeQ=="
},
"@prisma/engines-version": {
"version": "2.23.0-36.adf5e8cba3daf12d456d911d72b6e9418681b28b",
@ -5285,11 +5285,11 @@
"integrity": "sha1-v77VbV6ad2ZF9LH/eqGjrE+jw4U="
},
"prisma": {
"version": "2.23.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-2.23.0.tgz",
"integrity": "sha512-3c/lmDy8nsPcEsfCufvCTJUEuwmAcTPbeGg9fL1qjlvS314duLUA/k2nm3n1rq4ImKqzeC5uaKfvI2IoAfwrJA==",
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-2.25.0.tgz",
"integrity": "sha512-AdAlP+PShvugljIx62Omu+eLKu6Cozz06dehmClIHSb0/yFiVnyBtrRVV4LZus+QX6Ayg7CTDvtzroACAWl+Zw==",
"requires": {
"@prisma/engines": "2.23.0-36.adf5e8cba3daf12d456d911d72b6e9418681b28b"
"@prisma/engines": "2.25.0-36.c838e79f39885bc8e1611849b1eb28b5bb5bc922"
}
},
"process": {

View File

@ -22,7 +22,7 @@
"next": "10.0.9",
"next-auth": "^3.13.3",
"next-seo": "^4.24.0",
"prisma": "^2.23.0",
"prisma": "^2.25.0",
"qrcode.react": "^1.0.1",
"react": "17.0.1",
"react-bootstrap": "^1.5.2",
@ -52,4 +52,4 @@
"eslint-plugin-compat": "^3.9.0",
"standard": "^16.0.3"
}
}
}

View File

@ -37,7 +37,7 @@ export default function User ({ user }) {
return (
<Layout>
<UserHeader user={user} />
<Items variables={{ sort: 'recent', userId: user.id }} />
<Items variables={{ sort: 'user', userId: user.id }} />
</Layout>
)
}

View File

@ -1,9 +1,8 @@
import Layout from '../../components/layout'
import { CommentsQuery } from '../../components/comments'
import { COMMENT_FIELDS } from '../../fragments/comments'
import { gql } from '@apollo/client'
import ApolloClient from '../../api/client'
import UserHeader from '../../components/user-header'
import CommentsFlat from '../../components/comments-flat'
export async function getServerSideProps ({ req, params }) {
const { error, data: { user } } = await (await ApolloClient(req)).query({
@ -34,19 +33,11 @@ export async function getServerSideProps ({ req, params }) {
}
}
export default function User ({ user }) {
const query = gql`
${COMMENT_FIELDS}
{
comments: userComments(userId: ${user.id}) {
...CommentFields
}
}
`
export default function UserComments ({ user }) {
return (
<Layout>
<UserHeader user={user} />
<CommentsQuery query={query} includeParent noReply />
<CommentsFlat variables={{ userId: user.id }} includeParent noReply clickToContext />
</Layout>
)
}

View File

@ -28,6 +28,25 @@ const client = new ApolloClient({
}
}
}
},
moreFlatComments: {
keyArgs: ['userId'],
merge (existing, incoming, { readField }) {
const comments = existing ? existing.comments : []
return {
cursor: incoming.cursor,
comments: [...comments, ...incoming.comments]
}
},
read (existing) {
if (existing) {
return {
cursor: existing.cursor,
comments: existing.comments
}
}
}
}
}
}

28
pages/notifications.js Normal file
View File

@ -0,0 +1,28 @@
import { gql, useQuery } from '@apollo/client'
import CommentsFlat from '../components/comments-flat'
import Layout from '../components/layout'
export function RecentlyStacked () {
const query = gql`
{
recentlyStacked
}`
const { data } = useQuery(query)
if (!data || !data.recentlyStacked) return null
return (
<h2 className='visible text-success text-center py-3'>
you stacked <span className='text-monospace'>{data.recentlyStacked}</span> sats
</h2>
)
}
export default function Notifications ({ user }) {
return (
<Layout>
<RecentlyStacked />
<h6 className='text-muted'>replies</h6>
<CommentsFlat noReply includeParent clickToContext />
</Layout>
)
}

View File

@ -123,7 +123,6 @@ export function WithdrawlForm () {
initialError={error ? error.toString() : undefined}
schema={WithdrawlSchema}
onSubmit={async ({ invoice, maxFee }) => {
console.log('calling')
const { data } = await createWithdrawl({ variables: { invoice, maxFee: Number(maxFee) } })
router.push(`/withdrawls/${data.createWithdrawl.id}`)
}}

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "checkedNotesAt" TIMESTAMP(3);

View File

@ -11,21 +11,22 @@ generator client {
}
model User {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at")
name String? @unique
email String? @unique
emailVerified DateTime? @map(name: "email_verified")
image String?
items Item[]
messages Message[]
votes Vote[]
invoices Invoice[]
withdrawls Withdrawl[]
msats Int @default(0)
freeComments Int @default(5)
freePosts Int @default(2)
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at")
name String? @unique
email String? @unique
emailVerified DateTime? @map(name: "email_verified")
image String?
items Item[]
messages Message[]
votes Vote[]
invoices Invoice[]
withdrawls Withdrawl[]
msats Int @default(0)
freeComments Int @default(5)
freePosts Int @default(2)
checkedNotesAt DateTime?
@@map(name: "users")
}

View File

@ -160,6 +160,10 @@ footer {
opacity: .2;
}
.flash-it {
animation: flash 2s linear 2;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(359deg); }