diff --git a/api/resolvers/item.js b/api/resolvers/item.js index b85e4523..e79fa29b 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -1,43 +1,71 @@ import { UserInputError, AuthenticationError } from 'apollo-server-micro' +const createItem = async (parent, { title, text, url, parentId }, { me, models }) => { + if (!me) { + throw new AuthenticationError('You must be logged in') + } + + const data = { + title, + url, + text, + user: { + connect: { + name: me.name + } + } + } + + if (parentId) { + data.parent = { + connect: { + id: parseInt(parentId) + } + } + } + + return await models.item.create({ data }) +} + export default { Query: { items: async (parent, args, { models }) => { return await models.$queryRaw(` - SELECT id, text, "userId", ltree2text("path") AS "path" + SELECT id, "created_at" as "createdAt", title, url, text, "userId", ltree2text("path") AS "path" FROM "Item" ORDER BY "path"`) } }, Mutation: { - createItem: async (parent, { text, parentId }, { me, models }) => { - if (!me) { - throw new AuthenticationError('You must be logged in') + createLink: async (parent, { title, url }, { me, models }) => { + if (!title) { + throw new UserInputError('Link must have title', { argumentName: 'title' }) } + if (!url) { + throw new UserInputError('Link must have url', { argumentName: 'url' }) + } + + return await createItem(parent, { title, url }, { me, models }) + }, + createDiscussion: async (parent, { title, text }, { me, models }) => { + if (!title) { + throw new UserInputError('Link must have title', { argumentName: 'title' }) + } + + return await createItem(parent, { title, text }, { me, models }) + }, + createComment: async (parent, { text, parentId }, { me, models }) => { if (!text) { - throw new UserInputError('Item must have text', { argumentName: 'text' }) + throw new UserInputError('Comment must have text', { argumentName: 'text' }) } - const data = { - text, - user: { - connect: { - name: me.name - } - } + if (!parentId) { + throw new UserInputError('Comment must have text', { argumentName: 'text' }) } - if (parentId) { - data.parent = { - connect: { - id: parseInt(parentId) - } - } - } - - return await models.item.create({ data }) + return await createItem(parent, { text, parentId }, { me, models }) } }, @@ -56,6 +84,8 @@ export default { WHERE id = ${item.id}` return path.split('.').length - 1 - } + }, + comments: () => 0, + sats: () => 0 } } diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 99c2dcf4..1380ad85 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -6,13 +6,20 @@ export default gql` } extend type Mutation { - createItem(text: String!, parentId: ID): Item! + createLink(title: String!, url: String): Item! + createDiscussion(title: String!, text: String): Item! + createComment(text: String!, parentId: ID!): Item! } type Item { id: ID! - text: String! + createdAt: String! + title: String + url: String + text: String user: User! depth: Int! + sats: Int! + comments: Int! } ` diff --git a/components/form.js b/components/form.js new file mode 100644 index 00000000..f02b20af --- /dev/null +++ b/components/form.js @@ -0,0 +1,74 @@ +import Button from 'react-bootstrap/Button' +import InputGroup from 'react-bootstrap/InputGroup' +import BootstrapForm from 'react-bootstrap/Form' +import Alert from 'react-bootstrap/Alert' +import { Formik, Form as FormikForm, useFormikContext, useField } from 'formik' +import { useState } from 'react' + +export function SubmitButton ({ children, variant, ...props }) { + const { isSubmitting } = useFormikContext() + return ( + + ) +} + +export function Input ({ label, prepend, append, hint, ...props }) { + const [field, meta] = useField(props) + + return ( + + {label && {label}} + + {prepend && ( + + {prepend} + + )} + + {append && ( + + {append} + + )} + + {meta.touched && meta.error} + + {hint && ( + + {hint} + + )} + + + ) +} + +export function Form ({ + initial, schema, onSubmit, children, ...props +}) { + const [error, setError] = useState() + + return ( + + onSubmit(...args).catch(e => setError(e.message))} + > + + {error && setError(undefined)} dismissible>{error}} + {children} + + + ) +} diff --git a/components/header.js b/components/header.js index 3571ea9f..92559595 100644 --- a/components/header.js +++ b/components/header.js @@ -4,6 +4,7 @@ import Nav from 'react-bootstrap/Nav' import Link from 'next/link' import styles from './header.module.css' import { useRouter } from 'next/router' +import { Container } from 'react-bootstrap' export default function Header () { const [session, loading] = useSession() @@ -30,25 +31,27 @@ export default function Header () { return ( <> - - - STACKER NEWS - - - + + + + STACKER NEWS + + + + ) diff --git a/components/item.js b/components/item.js new file mode 100644 index 00000000..abfa5dd0 --- /dev/null +++ b/components/item.js @@ -0,0 +1,47 @@ +import UpVote from '../svgs/lightning-arrow.svg' +import styles from './item.module.css' + +function timeSince (timeStamp) { + const now = new Date() + const secondsPast = (now.getTime() - timeStamp) / 1000 + if (secondsPast < 60) { + return parseInt(secondsPast) + 's' + } + if (secondsPast < 3600) { + return parseInt(secondsPast / 60) + 'm' + } + if (secondsPast <= 86400) { + return parseInt(secondsPast / 3600) + 'h' + } + if (secondsPast > 86400) { + const day = timeStamp.getDate() + const month = timeStamp.toDateString().match(/ [a-zA-Z]*/)[0].replace(' ', '') + const year = timeStamp.getFullYear() === now.getFullYear() ? '' : ' ' + timeStamp.getFullYear() + return day + ' ' + month + year + } +} + +export default function Item ({ item }) { + return ( +
+ +
+
+ + {item.title} + {item.url.replace(/(^\w+:|^)\/\//, '')} + +
+
+ {item.sats} sats + \ + {item.comments} comments + + @{item.user.name} + + {timeSince(new Date(item.createdAt))} +
+
+
+ ) +} diff --git a/components/item.module.css b/components/item.module.css new file mode 100644 index 00000000..a5516049 --- /dev/null +++ b/components/item.module.css @@ -0,0 +1,23 @@ +.upvote { + fill: grey; + margin-right: .25rem; +} + +.upvote:hover { + fill: darkgray; + cursor: pointer; +} + +.title { + font-weight: 500; +} + +.link { + font-size: 80%; + margin-left: .5rem; +} + +.other { + font-size: 70%; + color: grey; +} \ No newline at end of file diff --git a/package.json b/package.json index f4a2ed2a..d7bccc1e 100644 --- a/package.json +++ b/package.json @@ -12,13 +12,16 @@ "@prisma/client": "^2.19.0", "apollo-server-micro": "^2.21.2", "bootstrap": "^4.6.0", + "formik": "^2.2.6", "graphql": "^15.5.0", "next": "10.0.9", "next-auth": "^3.13.3", "react": "17.0.1", "react-bootstrap": "^1.5.2", "react-dom": "17.0.1", - "swr": "^0.5.4" + "sass": "^1.32.8", + "swr": "^0.5.4", + "yup": "^0.32.9" }, "standard": { "parser": "babel-eslint", diff --git a/pages/_app.js b/pages/_app.js index 5efc326e..cda7b201 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -1,5 +1,4 @@ -import 'bootstrap/dist/css/bootstrap.min.css' -import '../styles/globals.css' +import '../styles/globals.scss' import { ApolloClient, InMemoryCache, ApolloProvider } from '@apollo/client' import { Provider } from 'next-auth/client' diff --git a/pages/index.js b/pages/index.js index b06a2735..c258f312 100644 --- a/pages/index.js +++ b/pages/index.js @@ -1,103 +1,106 @@ -import { useQuery, gql, useMutation } from '@apollo/client' -import { useState } from 'react' -import { useSession } from 'next-auth/client' -import styles from '../styles/index.module.css' +import { useQuery, gql } from '@apollo/client' +// import styles from '../styles/index.module.css' import Layout from '../components/layout' +import Item from '../components/item' +import Container from 'react-bootstrap/Container' -function Users () { - const { loading, error, data } = useQuery(gql`{ users { id, name } }`) - if (error) return
Failed to load
- if (loading) return
Loading...
- const { users } = data - return ( -
- {users.map(user => ( -
{user.name}
- ))} -
- ) -} +// function Users () { +// const { loading, error, data } = useQuery(gql`{ users { id, name } }`) +// if (error) return
Failed to load
+// if (loading) return
Loading...
+// const { users } = data +// return ( +//
+// {users.map(user => ( +//
{user.name}
+// ))} +//
+// ) +// } -function NewItem ({ parentId }) { - const [session] = useSession() - const [createItem] = useMutation( - gql` - mutation CreateItem($text: String!, $parentId: ID) { - createItem(text: $text, parentId: $parentId) { - id - } - }`, { - update (cache, { data: { createItem } }) { - cache.modify({ - fields: { - items (existingItems = [], { readField }) { - const newItemRef = cache.writeFragment({ - data: createItem, - fragment: gql` - fragment NewItem on Item { - id - user { - name - } - text - depth - } - ` - }) - for (let i = 0; i < existingItems.length; i++) { - if (readField('id', existingItems[i]) === parentId) { - return [...existingItems.slice(0, i), newItemRef, ...existingItems.slice(i)] - } - } - return [newItemRef, ...existingItems] - } - } - }) - } - }) - const [open, setOpen] = useState(false) +// function NewItem ({ parentId }) { +// const [session] = useSession() +// const [createItem] = useMutation( +// gql` +// mutation CreateItem($text: String!, $parentId: ID) { +// createItem(text: $text, parentId: $parentId) { +// id +// } +// }`, { +// update (cache, { data: { createItem } }) { +// cache.modify({ +// fields: { +// items (existingItems = [], { readField }) { +// const newItemRef = cache.writeFragment({ +// data: createItem, +// fragment: gql` +// fragment NewItem on Item { +// id +// user { +// name +// } +// text +// depth +// } +// ` +// }) +// for (let i = 0; i < existingItems.length; i++) { +// if (readField('id', existingItems[i]) === parentId) { +// return [...existingItems.slice(0, i), newItemRef, ...existingItems.slice(i)] +// } +// } +// return [newItemRef, ...existingItems] +// } +// } +// }) +// } +// }) +// const [open, setOpen] = useState(false) - if (!session) return null +// if (!session) return null - if (!open) { - return ( -
setOpen(true)}> - {parentId ? 'reply' : 'submit'} -
- ) - } +// if (!open) { +// return ( +//
setOpen(true)}> +// {parentId ? 'reply' : 'submit'} +//
+// ) +// } - let text - return ( -
{ - e.preventDefault() - createItem({ variables: { text: text.value, parentId } }) - setOpen(false) - text.value = '' - }} - > -