This commit is contained in:
keyan 2021-09-23 12:42:00 -05:00
parent 7e62fad69c
commit e7787e3e67
19 changed files with 367 additions and 152 deletions

View File

@ -4,6 +4,6 @@ const prisma = global.prisma || new PrismaClient({
log: ['warn', 'error'] log: ['warn', 'error']
}) })
if (process.env.NODE_ENV === 'development') global.prisma = prisma global.prisma = prisma
export default prisma export default prisma

View File

@ -21,6 +21,18 @@ async function comments (models, id) {
return nestComments(flat, id)[0] return nestComments(flat, id)[0]
} }
export async function getItem (parent, { id }, { models }) {
console.log(id)
const [item] = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE id = $1`, Number(id))
if (item) {
item.comments = comments(models, id)
}
return item
}
export default { export default {
Query: { Query: {
moreItems: async (parent, { sort, cursor, userId }, { me, models }) => { moreItems: async (parent, { sort, cursor, userId }, { me, models }) => {
@ -82,16 +94,7 @@ export default {
comments comments
} }
}, },
item: async (parent, { id }, { models }) => { item: getItem,
const [item] = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE id = $1`, Number(id))
if (item) {
item.comments = comments(models, id)
}
return item
},
userComments: async (parent, { userId }, { models }) => { userComments: async (parent, { userId }, { models }) => {
return await models.$queryRaw(` return await models.$queryRaw(`
${SELECT} ${SELECT}
@ -363,7 +366,7 @@ export default {
const namePattern = /\B@[\w_]+/gi const namePattern = /\B@[\w_]+/gi
const createMentions = async (item, models) => { export const createMentions = async (item, models) => {
// if we miss a mention, in the rare circumstance there's some kind of // if we miss a mention, in the rare circumstance there's some kind of
// failure, it's not a big deal so we don't do it transactionally // failure, it's not a big deal so we don't do it transactionally
// ideally, we probably would // ideally, we probably would
@ -456,7 +459,7 @@ function nestComments (flat, parentId) {
} }
// we have to do our own query because ltree is unsupported // we have to do our own query because ltree is unsupported
const SELECT = export const SELECT =
`SELECT "Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title, `SELECT "Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title,
"Item".text, "Item".url, "Item"."userId", "Item"."parentId", ltree2text("Item"."path") AS "path"` "Item".text, "Item".url, "Item"."userId", "Item"."parentId", ltree2text("Item"."path") AS "path"`

View File

@ -1,4 +1,21 @@
import { AuthenticationError, UserInputError } from 'apollo-server-errors' import { AuthenticationError, UserInputError } from 'apollo-server-errors'
import { createMentions, getItem, SELECT } from './item'
import serialize from './serial'
export const createBio = async (parent, { title, text }, { me, models }) => {
if (!me) {
throw new AuthenticationError('you must be logged in')
}
const [item] = await serialize(models,
models.$queryRaw(`${SELECT} FROM create_bio($1, $2, $3) AS "Item"`,
title, text, Number(me.id)))
await createMentions(item, models)
item.comments = []
return item
}
export default { export default {
Query: { Query: {
@ -32,7 +49,8 @@ export default {
} }
throw error throw error
} }
} },
createBio: createBio
}, },
User: { User: {
@ -54,6 +72,10 @@ export default {
sats: async (user, args, { models }) => { sats: async (user, args, { models }) => {
return Math.floor(user.msats / 1000) return Math.floor(user.msats / 1000)
}, },
bio: async (user, args, { models }) => {
console.log(user)
return getItem(user, { id: user.bioId }, { models })
},
hasNewNotes: async (user, args, { models }) => { hasNewNotes: async (user, args, { models }) => {
// check if any votes have been cast for them since checkedNotesAt // check if any votes have been cast for them since checkedNotesAt
const votes = await models.$queryRaw(` const votes = await models.$queryRaw(`

View File

@ -10,6 +10,7 @@ export default gql`
extend type Mutation { extend type Mutation {
setName(name: String!): Boolean setName(name: String!): Boolean
createBio(title: String!, text: String): Item!
} }
type User { type User {
@ -22,6 +23,7 @@ export default gql`
freeComments: Int! freeComments: Int!
hasNewNotes: Boolean! hasNewNotes: Boolean!
tipDefault: Int! tipDefault: Int!
bio: Item
sats: Int! sats: Int!
msats: Int! msats: Int!
} }

View File

@ -39,7 +39,10 @@ function Parent ({ item, rootText }) {
) )
} }
export default function Comment ({ item, children, replyOpen, includeParent, rootText, noComments, noReply }) { export default function Comment ({
item, children, replyOpen, includeParent,
rootText, noComments, noReply
}) {
const [reply, setReply] = useState(replyOpen) const [reply, setReply] = useState(replyOpen)
const [edit, setEdit] = useState() const [edit, setEdit] = useState()
const [collapse, setCollapse] = useState(false) const [collapse, setCollapse] = useState(false)

View File

@ -12,7 +12,11 @@ export const DiscussionSchema = Yup.object({
...AdvPostSchema ...AdvPostSchema
}) })
export function DiscussionForm ({ item, editThreshold }) { export function DiscussionForm ({
item, editThreshold, titleLabel = 'title',
textLabel = 'text', buttonText = 'post',
adv, handleSubmit
}) {
const router = useRouter() const router = useRouter()
const [createDiscussion] = useMutation( const [createDiscussion] = useMutation(
gql` gql`
@ -53,7 +57,7 @@ export function DiscussionForm ({ item, editThreshold }) {
...AdvPostInitial ...AdvPostInitial
}} }}
schema={DiscussionSchema} schema={DiscussionSchema}
onSubmit={async ({ boost, ...values }) => { onSubmit={handleSubmit || (async ({ boost, ...values }) => {
let id, error let id, error
if (item) { if (item) {
({ data: { updateDiscussion: { id } }, error } = await updateDiscussion({ variables: { ...values, id: item.id } })) ({ data: { updateDiscussion: { id } }, error } = await updateDiscussion({ variables: { ...values, id: item.id } }))
@ -63,17 +67,18 @@ export function DiscussionForm ({ item, editThreshold }) {
if (error) { if (error) {
throw new Error({ message: error.toString() }) throw new Error({ message: error.toString() })
} }
router.push(`/items/${id}`) router.push(`/items/${id}`)
}} })}
> >
<Input <Input
label='title' label={titleLabel}
name='title' name='title'
required required
autoFocus autoFocus
/> />
<MarkdownInput <MarkdownInput
label={<>text <small className='text-muted ml-2'>optional</small></>} label={<>{textLabel} <small className='text-muted ml-2'>optional</small></>}
name='text' name='text'
as={TextareaAutosize} as={TextareaAutosize}
minRows={4} minRows={4}
@ -81,9 +86,9 @@ export function DiscussionForm ({ item, editThreshold }) {
? <Countdown date={editThreshold} /> ? <Countdown date={editThreshold} />
: null} : null}
/> />
{!item && <AdvPostForm />} {!item && adv && <AdvPostForm />}
<ActionTooltip> <ActionTooltip>
<SubmitButton variant='secondary' className='mt-3'>{item ? 'save' : 'post'}</SubmitButton> <SubmitButton variant='secondary' className='mt-3'>{item ? 'save' : buttonText}</SubmitButton>
</ActionTooltip> </ActionTooltip>
</Form> </Form>
) )

View File

@ -84,12 +84,12 @@ export default function Footer ({ noLinks }) {
/> />
</div>} </div>}
<small> <small>
<a className='text-dark d-inline-flex' href='https://github.com/stackernews/stacker.news'> <a className='text-dark d-inline-block' href='https://github.com/stackernews/stacker.news'>
This is free open source software <Github width={20} height={20} className='mx-1' /> This is free open source software<Github width={20} height={20} className='mx-1' />
</a> </a>
<span className='d-inline-flex text-muted'> <span className='d-inline-block text-muted'>
made with sound love in Austin <Texas className='mx-1' width={20} height={20} /> made with sound love in Austin<Texas className='mx-1' width={20} height={20} />
by <a href='https://twitter.com/k00bideh' className='text-twitter d-inline-flex'><Twitter width={20} height={20} className='ml-1' />@k00bideh</a> by<a href='https://twitter.com/k00bideh' className='text-twitter d-inline-block'><Twitter width={20} height={20} className='ml-1' />@k00bideh</a>
</span> </span>
</small> </small>
</Container> </Container>

76
components/item-full.js Normal file
View File

@ -0,0 +1,76 @@
import Item, { ItemSkeleton } from './item'
import Reply, { ReplySkeleton } from './reply'
import Comment from './comment'
import Text from './text'
import Comments, { CommentsSkeleton } from './comments'
import { COMMENTS } from '../fragments/comments'
import { ITEM_FIELDS } from '../fragments/items'
import { gql, useQuery } from '@apollo/client'
import styles from '../styles/item.module.css'
import { NOFOLLOW_LIMIT } from '../lib/constants'
import { useRouter } from 'next/router'
export default function ItemFull ({ item: qItem, minimal }) {
const query = gql`
${ITEM_FIELDS}
${COMMENTS}
{
item(id: ${qItem.id}) {
...ItemFields
text
comments {
...CommentsRecursive
}
}
}`
const router = useRouter()
const { error, data } = useQuery(query, {
fetchPolicy: router.query.cache ? 'cache-first' : undefined
})
if (error) return <div>Failed to load!</div>
if (!data) {
return (
<div>
<ItemSkeleton>
<ReplySkeleton />
</ItemSkeleton>
<div className={styles.comments}>
<CommentsSkeleton />
</div>
</div>
)
}
const { item } = data
return (
<>
{item.parentId
? <Comment item={item} replyOpen includeParent noComments />
: (minimal
? (
<>
{item.text &&
<div className='mb-3'>
<Text nofollow={item.sats + item.boost < NOFOLLOW_LIMIT}>{item.text}</Text>
</div>}
</>)
: (
<>
<Item item={item}>
{item.text &&
<div className='mb-3'>
<Text nofollow={item.sats + item.boost < NOFOLLOW_LIMIT}>{item.text}</Text>
</div>}
<Reply parentId={item.id} />
</Item>
</>)
)}
<div className={styles.comments}>
<Comments comments={item.comments} />
</div>
</>
)
}

View File

@ -5,7 +5,7 @@ import { LightningProvider } from './lightning'
import Footer from './footer' import Footer from './footer'
import Seo from './seo' import Seo from './seo'
export default function Layout ({ noContain, noFooter, noFooterLinks, noSeo, children }) { export default function Layout ({ noContain, noFooter, noFooterLinks, containClassName, noSeo, children }) {
return ( return (
<> <>
{!noSeo && <Seo />} {!noSeo && <Seo />}
@ -17,7 +17,7 @@ export default function Layout ({ noContain, noFooter, noFooterLinks, noSeo, chi
{noContain {noContain
? children ? children
: ( : (
<Container className='px-sm-0'> <Container className={`px-sm-0 ${containClassName || ''}`}>
{children} {children}
</Container> </Container>
)} )}

View File

@ -31,7 +31,7 @@ export default function UserHeader ({ user }) {
const client = useApolloClient() const client = useApolloClient()
const [setName] = useMutation(NAME_MUTATION) const [setName] = useMutation(NAME_MUTATION)
const Satistics = () => <h1 className='ml-2'><small className='text-success'>{user.sats} sats \ {user.stacked} stacked</small></h1> const Satistics = () => <h1 className='mb-0'><small className='text-success'>{user.sats} sats \ {user.stacked} stacked</small></h1>
const UserSchema = Yup.object({ const UserSchema = Yup.object({
name: Yup.string() name: Yup.string()
@ -51,66 +51,73 @@ export default function UserHeader ({ user }) {
return ( return (
<> <>
{editting <div>
? ( {editting
<Form ? (
className='d-flex align-items-center flex-wrap' <Form
schema={UserSchema} schema={UserSchema}
initial={{ initial={{
name: user.name name: user.name
}} }}
validateImmediately className='d-flex align-items-center'
onSubmit={async ({ name }) => { validateImmediately
if (name === user.name) { onSubmit={async ({ name }) => {
setEditting(false) if (name === user.name) {
return setEditting(false)
} return
const { error } = await setName({ variables: { name } }) }
if (error) { const { error } = await setName({ variables: { name } })
throw new Error({ message: error.toString() }) if (error) {
} throw new Error({ message: error.toString() })
router.replace(`/${name}`) }
session.user.name = name router.replace(`/${name}`)
session.user.name = name
client.writeFragment({ client.writeFragment({
id: `User:${user.id}`, id: `User:${user.id}`,
fragment: gql` fragment: gql`
fragment CurUser on User { fragment CurUser on User {
name name
} }
`, `,
data: { data: {
name name
} }
}) })
setEditting(false) setEditting(false)
}} }}
> >
<Input <Input
prepend=<InputGroup.Text>@</InputGroup.Text> prepend=<InputGroup.Text>@</InputGroup.Text>
name='name' name='name'
autoFocus autoFocus
groupClassName={`mb-0 ${styles.username}`} groupClassName={`mb-0 ${styles.username}`}
showValid showValid
/> />
<Satistics user={user} /> <SubmitButton variant='link' onClick={() => setEditting(true)}>save</SubmitButton>
<SubmitButton className='ml-2' variant='info' size='sm' onClick={() => setEditting(true)}>save</SubmitButton> </Form>
</Form> )
) : (
: ( <div className='d-flex align-items-center'>
<div className='d-flex align-items-center flex-wrap'> <h2 className='mb-0'>@{user.name}</h2>
<h1>@{user.name}</h1> {session?.user?.name === user.name &&
<Satistics user={user} /> <Button variant='link' onClick={() => setEditting(true)}>edit nym</Button>}
{session && session.user && session.user.name === user.name && </div>
<Button className='ml-2' variant='boost' size='sm' onClick={() => setEditting(true)}>edit</Button>} )}
</div> <Satistics user={user} />
)} </div>
<Nav <Nav
className={styles.nav}
activeKey={router.asPath} activeKey={router.asPath}
> >
<Nav.Item> <Nav.Item>
<Link href={'/' + user.name} passHref> <Link href={'/' + user.name} passHref>
<Nav.Link>bio</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item>
<Link href={'/' + user.name + '/posts'} passHref>
<Nav.Link>{user.nitems} posts</Nav.Link> <Nav.Link>{user.nitems} posts</Nav.Link>
</Link> </Link>
</Nav.Item> </Nav.Item>
@ -121,7 +128,7 @@ export default function UserHeader ({ user }) {
</Nav.Item> </Nav.Item>
{/* <Nav.Item> {/* <Nav.Item>
<Link href={'/' + user.name + '/sativity'} passHref> <Link href={'/' + user.name + '/sativity'} passHref>
<Nav.Link>sativity</Nav.Link> <Nav.Link>satistics</Nav.Link>
</Link> </Link>
</Nav.Item> */} </Nav.Item> */}
</Nav> </Nav>

View File

@ -1,3 +1,16 @@
.username { .username {
width: 300px; width: 300px;
}
.nav {
margin-top: 1rem;
justify-content: space-between;
}
.nav div:first-child a {
padding-left: 0;
}
.nav div:last-child a {
padding-right: 0;
} }

View File

@ -1,14 +1,22 @@
import Layout from '../components/layout' import Layout from '../components/layout'
import Items from '../components/items' import { gql, useMutation } from '@apollo/client'
import { gql } from '@apollo/client'
import ApolloClient from '../api/client' import ApolloClient from '../api/client'
import UserHeader from '../components/user-header' import UserHeader from '../components/user-header'
import Seo from '../components/seo' import Seo from '../components/seo'
import { Button } from 'react-bootstrap'
import styles from '../styles/user.module.css'
import { useState } from 'react'
import { DiscussionForm } from '../components/discussion-form'
import { useSession } from 'next-auth/client'
import { ITEM_FIELDS } from '../fragments/items'
import ItemFull from '../components/item-full'
export async function getServerSideProps ({ req, params }) { export async function getServerSideProps ({ req, params }) {
const { error, data: { user } } = await (await ApolloClient(req)).query({ const { error, data: { user } } = await (await ApolloClient(req)).query({
query: query:
gql`{ gql`
${ITEM_FIELDS}
{
user(name: "${params.username}") { user(name: "${params.username}") {
id id
createdAt createdAt
@ -17,6 +25,9 @@ export async function getServerSideProps ({ req, params }) {
ncomments ncomments
stacked stacked
sats sats
bio {
...ItemFields
}
} }
}` }`
}) })
@ -34,12 +45,61 @@ export async function getServerSideProps ({ req, params }) {
} }
} }
export default function User ({ user }) { function BioForm () {
const [createBio] = useMutation(
gql`
mutation createBio($title: String!, $text: String) {
createBio(title: $title, text: $text) {
id
}
}`, {
update (cache, { data: { createBio } }) {
cache.modify({
id: `User:${createBio.userId}`,
fields: {
bio () {
return createBio
}
}
})
}
}
)
return ( return (
<Layout noSeo> <div className={styles.createFormContainer}>
<DiscussionForm
titleLabel='one line bio' textLabel='full bio' buttonText='create'
handleSubmit={async values => {
const { error } = await createBio({ variables: values })
if (error) {
throw new Error({ message: error.toString() })
}
}}
/>
</div>
)
}
export default function User ({ user }) {
const [create, setCreate] = useState(false)
const [session] = useSession()
return (
<Layout noSeo containClassName={styles.contain}>
<Seo user={user} /> <Seo user={user} />
<UserHeader user={user} /> <UserHeader user={user} />
<Items variables={{ sort: 'user', userId: user.id }} /> {user.bio
? <ItemFull item={user.bio} minimal />
: (
<div className={styles.create}>
{create
? <BioForm />
: (
session?.user?.name === user.name &&
<Button onClick={setCreate} size='md' variant='secondary'>create bio</Button>
)}
</div>)}
</Layout> </Layout>
) )
} }

45
pages/[username]/posts.js Normal file
View File

@ -0,0 +1,45 @@
import Layout from '../../components/layout'
import { gql } from '@apollo/client'
import ApolloClient from '../../api/client'
import UserHeader from '../../components/user-header'
import Seo from '../../components/seo'
import Items from '../../components/items'
export async function getServerSideProps ({ req, params }) {
const { error, data: { user } } = await (await ApolloClient(req)).query({
query:
gql`{
user(name: "${params.username}") {
id
createdAt
name
nitems
ncomments
stacked
sats
}
}`
})
if (!user || error) {
return {
notFound: true
}
}
return {
props: {
user
}
}
}
export default function UserPosts ({ user }) {
return (
<Layout noSeo>
<Seo user={user} />
<UserHeader user={user} />
<Items variables={{ sort: 'user', userId: user.id }} />
</Layout>
)
}

View File

@ -1,17 +1,9 @@
import Item, { ItemSkeleton } from '../../components/item'
import Layout from '../../components/layout' import Layout from '../../components/layout'
import Reply, { ReplySkeleton } from '../../components/reply'
import Comment from '../../components/comment'
import Text from '../../components/text'
import Comments, { CommentsSkeleton } from '../../components/comments'
import { COMMENTS } from '../../fragments/comments'
import { ITEM_FIELDS } from '../../fragments/items' import { ITEM_FIELDS } from '../../fragments/items'
import { gql, useQuery } from '@apollo/client' import { gql } from '@apollo/client'
import styles from '../../styles/item.module.css'
import Seo from '../../components/seo' import Seo from '../../components/seo'
import ApolloClient from '../../api/client' import ApolloClient from '../../api/client'
import { NOFOLLOW_LIMIT } from '../../lib/constants' import ItemFull from '../../components/item-full'
import { useRouter } from 'next/router'
// ssr the item without comments so that we can populate metatags // ssr the item without comments so that we can populate metatags
export async function getServerSideProps ({ req, params: { id } }) { export async function getServerSideProps ({ req, params: { id } }) {
@ -46,65 +38,11 @@ export async function getServerSideProps ({ req, params: { id } }) {
} }
} }
export default function FullItem ({ item }) { export default function AnItem ({ item }) {
const query = gql`
${ITEM_FIELDS}
${COMMENTS}
{
item(id: ${item.id}) {
...ItemFields
text
comments {
...CommentsRecursive
}
}
}`
return ( return (
<Layout noSeo> <Layout noSeo>
<Seo item={item} /> <Seo item={item} />
<LoadItem query={query} /> <ItemFull item={item} />
</Layout> </Layout>
) )
} }
function LoadItem ({ query }) {
const router = useRouter()
const { error, data } = useQuery(query, {
fetchPolicy: router.query.cache ? 'cache-first' : undefined
})
if (error) return <div>Failed to load!</div>
if (!data) {
return (
<div>
<ItemSkeleton>
<ReplySkeleton />
</ItemSkeleton>
<div className={styles.comments}>
<CommentsSkeleton />
</div>
</div>
)
}
const { item } = data
return (
<>
{item.parentId
? <Comment item={item} replyOpen includeParent noComments />
: (
<>
<Item item={item}>
{item.text && <div className='mb-3'><Text nofollow={item.sats + item.boost < NOFOLLOW_LIMIT}>{item.text}</Text></div>}
<Reply parentId={item.id} />
</Item>
</>
)}
<div className={styles.comments}>
<Comments comments={item.comments} />
</div>
</>
)
}

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "bioId" INTEGER;
-- AddForeignKey
ALTER TABLE "users" ADD FOREIGN KEY ("bioId") REFERENCES "Item"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,16 @@
CREATE OR REPLACE FUNCTION create_bio(title TEXT, text TEXT, user_id INTEGER)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
item "Item";
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT * INTO item FROM create_item(title, NULL, text, 0, NULL, user_id);
UPDATE users SET "bioId" = item.id WHERE id = user_id;
RETURN item;
END;
$$;

View File

@ -24,6 +24,8 @@ model User {
actions ItemAct[] actions ItemAct[]
invoices Invoice[] invoices Invoice[]
withdrawls Withdrawl[] withdrawls Withdrawl[]
bio Item? @relation(name: "Item", fields: [bioId], references: [id])
bioId Int?
msats Int @default(0) msats Int @default(0)
freeComments Int @default(5) freeComments Int @default(5)
freePosts Int @default(2) freePosts Int @default(2)
@ -65,6 +67,7 @@ model Item {
mentions Mention[] mentions Mention[]
path Unsupported("LTREE")? path Unsupported("LTREE")?
User User[] @relation("Item")
@@index([userId]) @@index([userId])
@@index([parentId]) @@index([parentId])
} }

17
styles/user.module.css Normal file
View File

@ -0,0 +1,17 @@
.contain {
display: flex;
flex-direction: column;
flex-grow: 1;
}
.create {
flex-grow: 1;
align-items: center;
justify-content: center;
display: flex;
}
.createFormContainer {
width: 100%;
max-width: 740px;
}