├── .gitignore ├── README.md ├── components ├── articleList.tsx ├── badgeList.tsx ├── generateArticleList.tsx ├── generateInputField.tsx ├── itemDelete.tsx ├── itemEdit.tsx ├── itemLike.tsx ├── itemList.tsx ├── layout.tsx ├── nav.tsx ├── newEditItem.tsx ├── notifyError.tsx ├── notifyLoading.tsx ├── oneArticle.tsx ├── oneBadge.tsx ├── oneListItem.tsx ├── profilePic.tsx ├── searchItems.tsx └── svg.tsx ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages ├── _app.tsx ├── api │ ├── auth │ │ └── [...auth0].ts │ ├── cors.ts │ └── graphql.ts ├── bundle │ └── [id].tsx ├── bundles.tsx ├── feed │ └── [id].tsx ├── feeds.tsx ├── index.tsx └── saved-articles.tsx ├── postcss.config.js ├── prisma ├── migrations │ ├── 20201221211333_ │ │ └── migration.sql │ ├── 20201222203039_ │ │ └── migration.sql │ └── 20210115023100_ │ │ └── migration.sql └── schema.prisma ├── public ├── blank.png ├── end-to-end-react.jpg └── logo.png ├── styles └── index.css ├── tailwind.config.js ├── tsconfig.json ├── utils ├── api │ ├── context.ts │ ├── graphql │ │ ├── fragments.ts │ │ ├── mutations.ts │ │ └── queries.ts │ ├── log.ts │ ├── permissions.ts │ ├── resolvers.ts │ ├── typeDefs.ts │ └── verifyOwnership.ts ├── apolloClient.ts ├── optimisticCache.ts ├── prepareUpdateObj.ts ├── types.ts └── update.ts └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | .env -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # End to End React with Prisma 2 2 | 3 | ![NewsPrism Logo](./public/end-to-end-react.jpg) 4 | 5 | ## Learn the fundamentals for building a full-fledged fullstack React application 6 | 7 | This is the repo for the course by [Codemochi](https://codemochi.com) called [End to End React with Prisma 2](https://courses.codemochi.com/end-to-end-react-with-prisma-2). We will cover all of the techniques needed to build a fully fledged app- user login, permissions, database management, backend creation. The works! 8 | 9 | We will build an entire social RSS reader full stack application from scratch over 10 hours and learn all of the fundamentals of building a professional grade app. 10 | 11 | ### Overview 12 | 13 | Check out the `master` branch to see a step by step guide for building this application from the ground up. Each step is a commit which makes it easy to tell exactly what changed from step to step. 14 | 15 | If you just want the finished product, you can clone this repo and the `mater` branch will have the finished version if you pull the latest. 16 | 17 | ### How to use this Project 18 | 19 | If you just want to run the app, check out the latest on the `master` branch and then create a `.env` file in the root of your file. This `.env` has changed with the latest version of nextjs-auth0 package, so I include the lower block so you can see how the variable names changed with the latest version of nextjs-auth0- you only need the top block of variables. 20 | 21 | _.env_ 22 | 23 | ``` 24 | # NEW ENVIRONMENTAL VARIABLES 25 | DATABASE_URL="postgresql://postgres:postgres@localhost:5432/prisma?schema=public" 26 | AUTH0_CLIENT_ID=xxxx 27 | AUTH0_ISSUER_BASE_URL='https://yyyy.us.auth0.com' 28 | AUTH0_CLIENT_SECRET=zzzzz 29 | AUTH0_SCOPE='openid profile' 30 | AUTH0_SECRET='some-really-long-string-has-to-be-at-least-40-characters' 31 | AUTH0_BASE_URL=http://localhost:3000 32 | 33 | # OLD ENVIRONMENTAL VARIABLES- DO NOT USE WITH STEP 33 OR LATER 34 | #DATABASE_URL="postgresql://postgres:postgres@localhost:5432/prisma?schema=public" 35 | #AUTH0_CLIENTID=xxxx 36 | #AUTH0_DOMAIN=yyyy.us.auth0.com 37 | #AUTH0_CLIENT_SECRET=zzzzz 38 | #AUTH0_SCOPE='openid profile' 39 | #AUTH0_COOKIE='some-really-long-string-has-to-be-at-least-40-characters' 40 | #BACKEND_ADDRESS=http://localhost:3000 41 | ``` 42 | 43 | You can get the Auth0 credentials by following the video in step 3. The database will get set up in step 2 when we configure Prisma 2. 44 | 45 | You can start the app locally by running `npm run dev`. 46 | 47 | ### Steps 48 | 49 | Create the Backend 50 | 51 | 1. Create Next.js base 52 | 2. Configure Prisma 2 schema 53 | 3. Configure Auth0 54 | 4. Add graphQL server 55 | 5. Add Context and Middleware 56 | 6. Add Feed queries and mutations 57 | 7. Add Bundle queries and mutations 58 | 8. Add Nested Author information 59 | 9. Add FeedTag and BundleTag relations 60 | 10. Add LikeBundle and LikeFeed Mutations 61 | 11. Add Find queries 62 | 12. Add Update mutations 63 | 13. Add Create Saved Article operations 64 | 14. Add Delete mutations 65 | 15. Add queries, mutations and fragments 66 | 67 | Create the Frontend 68 | 69 | 16. Add Tailwindcss 70 | 17. Add Layout and Navbar 71 | 18. Add ItemList component 72 | 19. Add OneListItem component 73 | 20. Add Badges 74 | 21. Create Items and Item Detail pages 75 | 22. Start the NewEditItem component 76 | 23. Add SearchItems component 77 | 24. Finish create item functionality 78 | 25. Add update existing item functionality 79 | 26. Add delete button 80 | 27. Add like button 81 | 28. Create the generate article list component 82 | 29. Add saved article list component 83 | 30. Add one article component 84 | 31. Add saved articles page 85 | 32. Tidy it all up 86 | -------------------------------------------------------------------------------- /components/articleList.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import Pagination from 'react-js-pagination'; 3 | import { OneArticle } from './oneArticle'; 4 | 5 | export const ArticleList = ({ articleList }) => { 6 | const [currentPagination, setPagination] = useState({ 7 | currentPage: 1, 8 | articlesPerPage: 8, 9 | }); 10 | 11 | const { currentPage, articlesPerPage } = currentPagination; 12 | const indexOfLastArticle = currentPage * articlesPerPage; 13 | const indexOfFirstArticle = indexOfLastArticle - articlesPerPage; 14 | const currentArticles = articleList.slice( 15 | indexOfFirstArticle, 16 | indexOfLastArticle, 17 | ); 18 | 19 | return ( 20 | <> 21 |

Articles

22 |
23 | {currentArticles.map(({ feed, ...oneArticle }) => ( 24 | 25 | ))} 26 | { 34 | setPagination((currState) => ({ 35 | ...currState, 36 | currentPage: parseInt(clickedNumber), 37 | })); 38 | }} 39 | /> 40 |
41 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /components/badgeList.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction } from 'react'; 2 | import { 3 | ActionType, 4 | BadgeFieldName, 5 | BundleObject, 6 | FeedObject, 7 | } from '../utils/types'; 8 | import { OneBadge } from './oneBadge'; 9 | 10 | export const BadgeList = ({ 11 | fieldName, 12 | action, 13 | setItem, 14 | item, 15 | setSearch, 16 | }: { 17 | fieldName: BadgeFieldName; 18 | action: ActionType; 19 | item: FeedObject | BundleObject; 20 | setItem?: Dispatch>; 21 | setSearch?: Dispatch>; 22 | }) => { 23 | return item[fieldName] && item[fieldName].length > 0 ? ( 24 | <> 25 | {item[fieldName].map((oneBadge) => ( 26 | 35 | ))} 36 | 37 | ) : ( 38 |

None found

39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /components/generateArticleList.tsx: -------------------------------------------------------------------------------- 1 | import { Feed } from '@prisma/client'; 2 | import { useEffect, useState } from 'react'; 3 | import * as _ from 'lodash'; 4 | import { NotifyLoading } from './notifyLoading'; 5 | import { NotifyError } from './notifyError'; 6 | import { ArticleList } from './articleList'; 7 | const Parser = require('rss-parser'); 8 | const parser = new Parser(); 9 | import getConfig from 'next/config'; 10 | 11 | const { publicRuntimeConfig } = getConfig(); 12 | const { CORS_URL } = publicRuntimeConfig; 13 | 14 | const CORS_PROXY = `${CORS_URL}?`; 15 | 16 | export const GenerateArticleList = ({ feeds }: { feeds: Feed[] }) => { 17 | const [{ loading, error, data }, setGet] = useState({ 18 | error: false, 19 | loading: false, 20 | data: [], 21 | }); 22 | useEffect(() => { 23 | (async () => { 24 | try { 25 | setGet((o) => ({ ...o, error: false, loading: true })); 26 | const fetchedItems = _.reduce( 27 | await Promise.all( 28 | feeds.map(async (oneFeed) => { 29 | const { items } = await parser.parseURL(CORS_PROXY + oneFeed.url); 30 | return items.map((o) => ({ ...o, feed: oneFeed })); 31 | }), 32 | ), 33 | (sum, n) => [...sum, ...n], 34 | ); 35 | setGet((o) => ({ ...o, data: fetchedItems, loading: false })); 36 | } catch (error) { 37 | setGet((o) => ({ ...o, error, loading: false })); 38 | } 39 | })(); 40 | }, [feeds]); 41 | 42 | if (loading) { 43 | return ; 44 | } 45 | 46 | if (error) { 47 | return ; 48 | } 49 | 50 | return ; 51 | }; 52 | -------------------------------------------------------------------------------- /components/generateInputField.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction } from 'react'; 2 | import { BundleObject, FeedObject } from '../utils/types'; 3 | 4 | export const GenerateInputField = ({ 5 | currentItem, 6 | name, 7 | changeHandler, 8 | }: { 9 | name: string; 10 | currentItem: FeedObject | BundleObject; 11 | changeHandler: Dispatch>; 12 | }) => ( 13 |
14 | 17 | { 21 | e.persist(); 22 | changeHandler((curr) => ({ ...curr, [name]: e.target.value })); 23 | }} 24 | /> 25 |
26 | ); 27 | -------------------------------------------------------------------------------- /components/itemDelete.tsx: -------------------------------------------------------------------------------- 1 | import { useMutation } from '@apollo/client'; 2 | import { useState } from 'react'; 3 | import { 4 | DELETE_BUNDLE_MUTATION, 5 | DELETE_FEED_MUTATION, 6 | } from '../utils/api/graphql/mutations'; 7 | import { BUNDLES_QUERY, FEEDS_QUERY } from '../utils/api/graphql/queries'; 8 | import { BundleObject, FeedObject, ItemType } from '../utils/types'; 9 | import { useUser } from '@auth0/nextjs-auth0'; 10 | import { Spin, Delete } from './svg'; 11 | 12 | export const ItemDelete = ({ 13 | item, 14 | type, 15 | }: { 16 | item: FeedObject | BundleObject; 17 | type: ItemType; 18 | }) => { 19 | const isFeed = type === ItemType.FeedType; 20 | const __typename = isFeed ? 'Feed' : 'Bundle'; 21 | const [modalVisibility, setVisibility] = useState(false); 22 | const [deleteItemMutation, { loading: deleteItemLoading }] = useMutation( 23 | isFeed ? DELETE_FEED_MUTATION : DELETE_BUNDLE_MUTATION, 24 | ); 25 | const { user, error, isLoading } = useUser(); 26 | 27 | return ( 28 | <> 29 | {modalVisibility ? ( 30 |
31 |
32 |
33 |
34 | 35 |
41 |
42 |

43 | Are you sure you want to delete this{' '} 44 | {isFeed ? 'feed' : 'bundle'}? 45 |

46 |
47 |
48 | 49 | 99 | 100 | 101 | 111 | 112 |
113 |
114 |
115 |
116 |
117 | ) : null} 118 |
{ 120 | e.preventDefault(); 121 | setVisibility(true); 122 | }} 123 | className="flex col-span-1 py-2 px-1 z-10" 124 | > 125 | {deleteItemLoading || isLoading || !user ? ( 126 | 127 | ) : ( 128 | 129 | )} 130 |
131 | 132 | ); 133 | }; 134 | -------------------------------------------------------------------------------- /components/itemEdit.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction } from 'react'; 2 | import { 3 | BundleObject, 4 | FeedObject, 5 | ItemType, 6 | SelectedFeedState, 7 | } from '../utils/types'; 8 | import { EditPencil } from './svg'; 9 | 10 | export const ItemEdit = ({ 11 | item, 12 | type, 13 | selected, 14 | setSelected, 15 | }: { 16 | item: FeedObject | BundleObject; 17 | type: ItemType; 18 | selected?: SelectedFeedState; 19 | setSelected?: Dispatch>; 20 | }) => { 21 | const isFeed = type === ItemType.FeedType; 22 | 23 | return ( 24 |
{ 26 | e.stopPropagation(); 27 | setSelected((currState) => ({ 28 | id: item.id, 29 | feeds: isFeed ? [item] : item['feeds'], 30 | editMode: 31 | !selected.editMode || currState.id !== item.id ? true : false, 32 | newMode: false, 33 | })); 34 | }} 35 | className="flex py-2 mx-1 z-10" 36 | > 37 | 46 |
47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /components/itemLike.tsx: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery } from '@apollo/client'; 2 | import * as _ from 'lodash'; 3 | import { 4 | LIKE_BUNDLE_MUTATION, 5 | LIKE_FEED_MUTATION, 6 | } from '../utils/api/graphql/mutations'; 7 | import { ME_QUERY } from '../utils/api/graphql/queries'; 8 | import { BundleObject, FeedObject, ItemType } from '../utils/types'; 9 | import { useUser } from '@auth0/nextjs-auth0'; 10 | import { HeartOutline } from './svg'; 11 | 12 | export const ItemLike = ({ 13 | item, 14 | type, 15 | }: { 16 | item: FeedObject | BundleObject; 17 | type: ItemType; 18 | }) => { 19 | const isFeed = type === ItemType.FeedType; 20 | 21 | const [likeItemMutation, { loading: likeItemLoading }] = useMutation( 22 | isFeed ? LIKE_FEED_MUTATION : LIKE_BUNDLE_MUTATION, 23 | ); 24 | const { data: meData, loading: userLoadingQuery } = useQuery(ME_QUERY); 25 | 26 | const { user, error, isLoading: fetchUserLoading } = useUser(); 27 | const likeMatches = item.likes.filter( 28 | (oneLike) => oneLike.auth0 === (user ? user.sub : ''), 29 | ); 30 | const hasMatch = likeMatches.length > 0 ? true : false; 31 | 32 | const loading = fetchUserLoading || likeItemLoading || userLoadingQuery; 33 | const me = _.get(meData, 'me'); 34 | return ( 35 |
{ 37 | e.stopPropagation(); 38 | if (user && !loading) { 39 | const idObj = isFeed ? { feedId: item.id } : { bundleId: item.id }; 40 | likeItemMutation({ 41 | variables: { 42 | data: { 43 | ...idObj, 44 | likeState: hasMatch ? false : true, 45 | }, 46 | }, 47 | optimisticResponse: () => { 48 | const likes = item.likes.filter((item) => 49 | item.id === me ? me.id : '', 50 | ); 51 | return { 52 | __typename: 'Mutation', 53 | [`like${isFeed ? 'Feed' : 'Bundle'}`]: { 54 | ...item, 55 | ...(hasMatch 56 | ? { 57 | likes, 58 | } 59 | : { 60 | likes: [...likes, me], 61 | }), 62 | }, 63 | }; 64 | }, 65 | }); 66 | } 67 | }} 68 | className="flex py-2 mx-1 z-10" 69 | > 70 |

{item.likes.length}

71 | 77 |
78 | ); 79 | }; 80 | -------------------------------------------------------------------------------- /components/itemList.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@apollo/client'; 2 | import { BUNDLES_QUERY, FEEDS_QUERY } from '../utils/api/graphql/queries'; 3 | import { 4 | BundleObject, 5 | FeedObject, 6 | ItemType, 7 | SelectedFeedState, 8 | } from '../utils/types'; 9 | import { NotifyError } from './notifyError'; 10 | import { NotifyLoading } from './notifyLoading'; 11 | import { OneListItem } from './oneListItem'; 12 | import { Dispatch, SetStateAction, useEffect } from 'react'; 13 | 14 | export const ItemList = ({ 15 | type, 16 | selected, 17 | setSelected, 18 | useSelected = false, 19 | allowEdits = false, 20 | }: { 21 | type: ItemType; 22 | selected?: SelectedFeedState; 23 | setSelected?: Dispatch>; 24 | useSelected?: boolean; 25 | allowEdits?: boolean; 26 | }) => { 27 | const isFeed = type === ItemType.FeedType; 28 | const { loading, error, data } = useQuery( 29 | isFeed ? FEEDS_QUERY : BUNDLES_QUERY, 30 | ); 31 | const { feeds, bundles } = data || {}; 32 | const itemList = isFeed ? feeds : bundles; 33 | 34 | useEffect(() => { 35 | (async () => { 36 | if ( 37 | useSelected && 38 | itemList && 39 | itemList.length > 0 && 40 | selected.id === null 41 | ) { 42 | const firstItem = itemList[0]; 43 | await setSelected({ 44 | id: firstItem.id, 45 | feeds: isFeed ? [firstItem] : firstItem['feeds'], 46 | editMode: false, 47 | newMode: false, 48 | }); 49 | } 50 | })(); 51 | }); 52 | 53 | if (loading) { 54 | return ; 55 | } 56 | 57 | if (error || !itemList) { 58 | return ; 59 | } 60 | 61 | return ( 62 | <> 63 |
64 | {itemList && itemList.length > 0 ? ( 65 | itemList.map((item: FeedObject | BundleObject) => ( 66 | 75 | )) 76 | ) : ( 77 |

None are present. Why not add one?

78 | )} 79 |
80 | 81 | ); 82 | }; 83 | -------------------------------------------------------------------------------- /components/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Nav } from './nav'; 2 | 3 | export const Layout = ({ children }) => { 4 | return ( 5 |
6 |
9 | ); 10 | }; 11 | -------------------------------------------------------------------------------- /components/nav.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { useUser } from '@auth0/nextjs-auth0'; 3 | 4 | export const Nav = () => { 5 | const { user, error, isLoading } = useUser(); 6 | 7 | return ( 8 | 66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /components/newEditItem.tsx: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery } from '@apollo/client'; 2 | import { Dispatch, SetStateAction, useEffect, useState } from 'react'; 3 | import { 4 | CREATE_BUNDLE_MUTATION, 5 | CREATE_FEED_MUTATION, 6 | UPDATE_BUNDLE_MUTATION, 7 | UPDATE_FEED_MUTATION, 8 | } from '../utils/api/graphql/mutations'; 9 | import { 10 | BUNDLE_QUERY, 11 | FEED_QUERY, 12 | FIND_BUNDLE_TAGS_QUERY, 13 | FIND_FEEDS_QUERY, 14 | FIND_FEED_TAGS_QUERY, 15 | ME_QUERY, 16 | } from '../utils/api/graphql/queries'; 17 | import { optimisticCache } from '../utils/optimisticCache'; 18 | import { prepareNewUpdateObj } from '../utils/prepareUpdateObj'; 19 | import { 20 | ActionType, 21 | BadgeFieldName, 22 | BundleObject, 23 | FeedObject, 24 | ItemType, 25 | NewItemState, 26 | SearchQueryName, 27 | SelectedFeedState, 28 | } from '../utils/types'; 29 | import { updateCache } from '../utils/update'; 30 | import { BadgeList } from './badgeList'; 31 | import { GenerateInputField } from './generateInputField'; 32 | import { SearchItems } from './searchItems'; 33 | import { ErrorSign, WaitingClock } from './svg'; 34 | 35 | export const NewEditItem = ({ 36 | type, 37 | selected, 38 | setSelected, 39 | }: { 40 | type: ItemType; 41 | selected: SelectedFeedState; 42 | setSelected: Dispatch>; 43 | }) => { 44 | const isFeed = type === ItemType.FeedType; 45 | const initialFeed: FeedObject = { name: '', url: '', tags: [] }; 46 | const initialBundle: BundleObject = { 47 | name: '', 48 | description: '', 49 | tags: [], 50 | feeds: [], 51 | }; 52 | const initialState: NewItemState = isFeed ? initialFeed : initialBundle; 53 | 54 | const [currentItem, setItem] = useState(initialState); 55 | const inputFields = isFeed ? ['name', 'url'] : ['name', 'description']; 56 | 57 | const [ 58 | createItemMutation, 59 | { loading: createLoading, error: createError }, 60 | ] = useMutation(isFeed ? CREATE_FEED_MUTATION : CREATE_BUNDLE_MUTATION); 61 | const [ 62 | updateItemMutation, 63 | { loading: updateLoading, error: updateError }, 64 | ] = useMutation(isFeed ? UPDATE_FEED_MUTATION : UPDATE_BUNDLE_MUTATION); 65 | 66 | const variables = { data: { id: selected.id ? selected.id : '' } }; 67 | const { 68 | loading: itemQueryLoading, 69 | error: itemQueryError, 70 | data: itemQueryData, 71 | } = useQuery(isFeed ? FEED_QUERY : BUNDLE_QUERY, { variables }); 72 | 73 | const { data: meData, loading: meLoading, error: meError } = useQuery( 74 | ME_QUERY, 75 | ); 76 | 77 | const { bundle, feed } = itemQueryData || {}; 78 | const item = isFeed ? feed : bundle; 79 | 80 | useEffect(() => { 81 | (async () => { 82 | if (item && selected.editMode) { 83 | const { __typename, likes, author, ...cleanedItem } = item; 84 | setItem({ ...cleanedItem }); 85 | } else { 86 | setItem(initialState); 87 | } 88 | })(); 89 | }, [itemQueryData]); 90 | 91 | if (createLoading || updateLoading || itemQueryLoading || meLoading) { 92 | return ; 93 | } 94 | if (createError || updateError || itemQueryError || meError) { 95 | return ; 96 | } 97 | 98 | return ( 99 | <> 100 |
{ 102 | e.preventDefault(); 103 | const data = prepareNewUpdateObj( 104 | item, 105 | currentItem, 106 | isFeed, 107 | selected.editMode, 108 | ); 109 | 110 | selected.editMode 111 | ? updateItemMutation({ 112 | variables: { data }, 113 | optimisticResponse: optimisticCache( 114 | isFeed, 115 | 'update', 116 | data, 117 | currentItem, 118 | meData, 119 | ), 120 | }) 121 | : createItemMutation({ 122 | variables: { data }, 123 | update: updateCache(isFeed, 'create'), 124 | optimisticResponse: optimisticCache( 125 | isFeed, 126 | 'create', 127 | data, 128 | currentItem, 129 | meData, 130 | ), 131 | }); 132 | await setItem(initialState); 133 | setSelected((currState) => ({ 134 | ...currState, 135 | editMode: false, 136 | newMode: false, 137 | })); 138 | }} 139 | > 140 |
141 |

142 | {`${selected.editMode ? 'Edit ' : 'New '}${ 143 | isFeed ? 'Feed' : 'Bundle' 144 | }`} 145 |

146 | 147 |
148 | {inputFields.map((name) => ( 149 | 155 | ))} 156 |
157 | 165 |
166 |
167 |
168 |
169 | 170 |
171 | 177 |
178 |
179 |
180 | 181 | 192 |
193 | {isFeed ? null : ( 194 | <> 195 |
196 | 197 |
198 | 204 |
205 |
206 |
207 | 208 | 215 |
216 | 217 | )} 218 |
219 |
220 |
221 | 222 | ); 223 | }; 224 | -------------------------------------------------------------------------------- /components/notifyError.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorSign } from './svg'; 2 | 3 | export const NotifyError = () => ( 4 |
5 | 6 |
7 | ); 8 | -------------------------------------------------------------------------------- /components/notifyLoading.tsx: -------------------------------------------------------------------------------- 1 | import { WaitingClock } from './svg'; 2 | 3 | export const NotifyLoading = () => ( 4 |
5 | 6 |
7 | ); 8 | -------------------------------------------------------------------------------- /components/oneArticle.tsx: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery } from '@apollo/client'; 2 | import { Feed } from '@prisma/client'; 3 | import * as _ from 'lodash'; 4 | import stripHTML from 'string-strip-html'; 5 | import { 6 | CREATE_SAVED_ARTICLE_MUTATION, 7 | DELETE_SAVED_ARTICLE_MUTATION, 8 | } from '../utils/api/graphql/mutations'; 9 | import { ME_QUERY, SAVED_ARTICLE_QUERY } from '../utils/api/graphql/queries'; 10 | import { useUser } from '@auth0/nextjs-auth0'; 11 | import { HeartOutline, SingleArrowRight } from './svg'; 12 | import { updateSavedArticleCache } from '../utils/update'; 13 | 14 | export const OneArticle = ({ article, feed }: { article; feed: Feed }) => { 15 | const cleanedContent = stripHTML(article.content); 16 | 17 | const variables = { data: { url: article.link } }; 18 | const { 19 | loading: savedArticleLoading, 20 | error, 21 | data, 22 | } = useQuery(SAVED_ARTICLE_QUERY, { variables }); 23 | 24 | const { user, isLoading: userLoading } = useUser(); 25 | const { data: meData, loading: userLoadingQuery } = useQuery(ME_QUERY); 26 | const [ 27 | createSavedArticleMutation, 28 | { loading: createSavedArticleLoading }, 29 | ] = useMutation(CREATE_SAVED_ARTICLE_MUTATION); 30 | const [ 31 | deleteSavedArticleMutation, 32 | { loading: deleteSavedArticleLoading }, 33 | ] = useMutation(DELETE_SAVED_ARTICLE_MUTATION); 34 | 35 | const loading = 36 | createSavedArticleLoading || 37 | deleteSavedArticleLoading || 38 | savedArticleLoading || 39 | userLoading || 40 | userLoadingQuery; 41 | 42 | const savedArticle = _.get(data, 'savedArticle'); 43 | return ( 44 |
45 |
{ 47 | e.stopPropagation(); 48 | if (user && !loading) { 49 | if (savedArticle) { 50 | const deleteSavedArticle = { data: { id: savedArticle.id } }; 51 | deleteSavedArticleMutation({ 52 | variables: deleteSavedArticle, 53 | update: updateSavedArticleCache('delete'), 54 | optimisticResponse: () => { 55 | return { 56 | __typename: 'Mutation', 57 | ['deleteSavedArticle']: { 58 | ...deleteSavedArticle.data, 59 | __typename: 'SavedArticle', 60 | }, 61 | }; 62 | }, 63 | }); 64 | } else { 65 | const newSavedArticle = { 66 | data: { 67 | url: article.link, 68 | content: article, 69 | feed: { 70 | connect: { 71 | id: feed.id, 72 | }, 73 | }, 74 | }, 75 | }; 76 | createSavedArticleMutation({ 77 | variables: newSavedArticle, 78 | update: updateSavedArticleCache('create'), 79 | optimisticResponse: () => { 80 | const user = _.get(meData, 'me'); 81 | 82 | return { 83 | __typename: 'Mutation', 84 | ['createSavedArticle']: { 85 | id: `${user.id}-${newSavedArticle.data.url}`, 86 | ...newSavedArticle.data, 87 | user, 88 | feed, 89 | __typename: 'SavedArticle', 90 | }, 91 | }; 92 | }, 93 | }); 94 | } 95 | } 96 | }} 97 | className="col-span-1 flex items-center justify-center z-10 cursor-pointer" 98 | > 99 | 104 |
105 |
106 |

{article.title}

107 | {article.creator ? ( 108 |

{article.creator}

109 | ) : null} 110 |

{cleanedContent.result}

111 |
112 |
113 | 114 | 115 | 116 |
117 |
118 | ); 119 | }; 120 | -------------------------------------------------------------------------------- /components/oneBadge.tsx: -------------------------------------------------------------------------------- 1 | import { Bundle, BundleTag, FeedTag } from '@prisma/client'; 2 | import { Dispatch, SetStateAction } from 'react'; 3 | import { 4 | ActionType, 5 | BadgeFieldName, 6 | BundleObject, 7 | FeedObject, 8 | } from '../utils/types'; 9 | import { Minus, Plus } from './svg'; 10 | 11 | export const OneBadge = ({ 12 | item, 13 | action, 14 | currentItem, 15 | fieldName, 16 | setItem, 17 | setSearch, 18 | }: { 19 | item: FeedTag | BundleTag | FeedObject; 20 | action: ActionType; 21 | currentItem?: FeedObject | BundleObject; 22 | fieldName: BadgeFieldName; 23 | setItem?: Dispatch>; 24 | setSearch?: Dispatch>; 25 | }) => { 26 | const color = 27 | fieldName === BadgeFieldName.tags 28 | ? 'blue' 29 | : fieldName === BadgeFieldName.feeds 30 | ? 'green' 31 | : 'purple'; 32 | 33 | return ( 34 |
35 | 38 | {action === ActionType.ADD ? ( 39 |
{ 41 | setItem((currState) => ({ 42 | ...currState, 43 | [fieldName]: [...currState[fieldName], { ...item }], 44 | })); 45 | setSearch(''); 46 | }} 47 | > 48 | 49 |
50 | ) : null} 51 | {action === ActionType.CREATE ? ( 52 |
{ 54 | setItem((currState) => ({ 55 | ...currState, 56 | [fieldName]: currState[fieldName].filter( 57 | (o) => item.name !== o.name, 58 | ), 59 | })); 60 | }} 61 | > 62 | 63 |
64 | ) : null} 65 |

{item.name}

66 |
67 |
68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /components/oneListItem.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { Dispatch, SetStateAction } from 'react'; 3 | import * as _ from 'lodash'; 4 | import { 5 | ActionType, 6 | BadgeFieldName, 7 | BundleObject, 8 | FeedObject, 9 | ItemType, 10 | SelectedFeedState, 11 | } from '../utils/types'; 12 | import { useUser } from '@auth0/nextjs-auth0'; 13 | import { BadgeList } from './badgeList'; 14 | import { ItemEdit } from './itemEdit'; 15 | import { ItemDelete } from './itemDelete'; 16 | 17 | import { ProfilePic } from './profilePic'; 18 | import { DoubleArrowDown, DoubleArrowRight, WaitingClock } from './svg'; 19 | import { ItemLike } from './itemLike'; 20 | 21 | export const OneListItem = ({ 22 | item, 23 | type, 24 | selected, 25 | setSelected, 26 | useSelected = false, 27 | allowEdits = false, 28 | }: { 29 | item: FeedObject | BundleObject; 30 | type: ItemType; 31 | selected?: SelectedFeedState; 32 | setSelected?: Dispatch>; 33 | useSelected?: boolean; 34 | allowEdits?: boolean; 35 | }) => { 36 | const isFeed = type === ItemType.FeedType; 37 | const isSelected = useSelected && selected && selected.id === item.id; 38 | const { user, error, isLoading } = useUser(); 39 | 40 | if (isLoading) { 41 | return ; 42 | } 43 | 44 | const canManipulate = 45 | !isLoading && 46 | user && 47 | _.get(item, 'author.auth0') === user.sub && 48 | allowEdits && 49 | useSelected; 50 | 51 | return ( 52 | 53 |
54 |
66 |
67 |

{item.name}

68 | {!isFeed ?

{item['description']}

: null} 69 |
70 |
71 | 72 | {canManipulate ? ( 73 | 79 | ) : null} 80 | {canManipulate ? : null} 81 |
82 | 83 |
84 | {item.author ? : null} 85 |
86 | 87 |
88 |

Tags

89 |
90 | 95 |
96 |
97 |
98 |

{isFeed ? 'Bundles' : 'Feeds'}

99 |
100 | 107 |
108 |
109 |
110 | {useSelected ? ( 111 | <> 112 | {isSelected ? ( 113 |

{ 115 | e.preventDefault(); 116 | }} 117 | className={`flex rounded-lg rounded-t-none align-middle 118 | ${isSelected ? `bg-${isFeed ? 'green' : 'purple'}-400` : `bg-gray-300`} 119 | p-4 z-10 text-white cursor-pointer 120 | `} 121 | > 122 | 123 | {` Hide ${isFeed ? `Feed` : `Bundle`} Articles`} 124 |

125 | ) : ( 126 |

{ 128 | e.preventDefault(); 129 | setSelected({ 130 | id: item.id, 131 | feeds: isFeed ? [item] : item['feeds'], 132 | editMode: false, 133 | newMode: false, 134 | }); 135 | }} 136 | className={`flex rounded-lg rounded-t-none align-middle 137 | ${isSelected ? `bg-${isFeed ? 'green' : 'purple'}-400` : `bg-gray-300`} 138 | p-4 z-10 text-white cursor-pointer 139 | `} 140 | > 141 | 142 | {` Show ${isFeed ? `Feed` : `Bundle`} Articles`} 143 |

144 | )} 145 | 146 | ) : null} 147 |
148 | 149 | ); 150 | }; 151 | -------------------------------------------------------------------------------- /components/profilePic.tsx: -------------------------------------------------------------------------------- 1 | import { User } from '@prisma/client'; 2 | import { Question } from './svg'; 3 | 4 | export const ProfilePic = ({ author }: { author: User }) => ( 5 | <> 6 | {author.picture ? ( 7 |
8 | 12 |
13 | ) : ( 14 | 15 | )} 16 |

{author.nickname}

17 | 18 | ); 19 | -------------------------------------------------------------------------------- /components/searchItems.tsx: -------------------------------------------------------------------------------- 1 | import { useLazyQuery } from '@apollo/client'; 2 | import { DocumentNode } from 'graphql'; 3 | import { Dispatch, SetStateAction, useState } from 'react'; 4 | import { 5 | ActionType, 6 | BadgeFieldName, 7 | BundleObject, 8 | FeedObject, 9 | SearchQueryName, 10 | } from '../utils/types'; 11 | import { BadgeList } from './badgeList'; 12 | import { Search, Spin } from './svg'; 13 | import * as _ from 'lodash'; 14 | 15 | export const SearchItems = ({ 16 | currentItem, 17 | setItem, 18 | queryName, 19 | query, 20 | fieldName, 21 | }: { 22 | currentItem: FeedObject | BundleObject; 23 | setItem: Dispatch>; 24 | queryName: SearchQueryName; 25 | query: DocumentNode; 26 | fieldName: BadgeFieldName; 27 | }) => { 28 | const [search, setSearch] = useState(''); 29 | const [findItemsQuery, { loading, data, called }] = useLazyQuery(query, { 30 | fetchPolicy: 'network-only', 31 | }); 32 | 33 | const fetchedItems = _.get(data, queryName); 34 | const filtFindItems = fetchedItems 35 | ? fetchedItems.filter( 36 | (oneItem) => 37 | !currentItem[fieldName].map((o) => o.name).includes(oneItem.name), 38 | ) 39 | : []; 40 | 41 | const matchCurrent = filtFindItems.filter((o) => o.name === search); 42 | const matchList = currentItem[fieldName].filter((o) => o.name === search); 43 | const filtFindItemsWithAdd = 44 | matchCurrent.length === 0 && 45 | matchList.length === 0 && 46 | queryName !== 'findFeeds' 47 | ? [...filtFindItems, { name: search }] 48 | : filtFindItems; 49 | 50 | const dummyNewItem = { 51 | ...currentItem, 52 | [fieldName]: filtFindItemsWithAdd, 53 | }; 54 | 55 | return ( 56 |
57 |
58 | {loading ? ( 59 | 60 | ) : ( 61 | 62 | )} 63 | { 67 | if (e.key === 'Enter') { 68 | e.preventDefault(); 69 | setItem((currState) => ({ 70 | ...currState, 71 | [fieldName]: [ 72 | ...currState[fieldName], 73 | { ...dummyNewItem[fieldName][0] }, 74 | ], 75 | })); 76 | setSearch(() => ''); 77 | } 78 | }} 79 | onChange={(e) => { 80 | e.persist(); 81 | if (e.target.value !== search) { 82 | setSearch(() => e.target.value); 83 | findItemsQuery({ 84 | variables: { data: { search: e.target.value } }, 85 | }); 86 | } 87 | }} 88 | /> 89 |
90 |
91 | {search !== '' ? ( 92 | 99 | ) : called ? ( 100 |

No matches

101 | ) : null} 102 |
103 |
104 | ); 105 | }; 106 | -------------------------------------------------------------------------------- /components/svg.tsx: -------------------------------------------------------------------------------- 1 | export const WaitingClock = ({ className }) => ( 2 | 9 | 15 | 16 | ); 17 | 18 | export const ErrorSign = ({ className }) => ( 19 | 26 | 32 | 33 | ); 34 | 35 | export const DoubleArrowDown = ({ className }) => ( 36 | 43 | 49 | 50 | ); 51 | 52 | export const DoubleArrowRight = ({ className }) => ( 53 | 60 | 66 | 67 | ); 68 | 69 | export const Plus = ({ className }) => ( 70 | 77 | 83 | 84 | ); 85 | 86 | export const Minus = ({ className }) => ( 87 | 94 | 100 | 101 | ); 102 | 103 | export const Spin = ({ className }) => ( 104 | 111 | 117 | 118 | ); 119 | 120 | export const Search = ({ className }) => ( 121 | 128 | 134 | 135 | ); 136 | 137 | export const Question = ({ className }) => ( 138 | 145 | 151 | 152 | ); 153 | 154 | export const EditPencil = ({ className }) => ( 155 | 162 | 168 | 169 | ); 170 | 171 | export const Delete = ({ className }) => ( 172 | 179 | 185 | 186 | ); 187 | 188 | export const HeartOutline = ({ className }) => ( 189 | 196 | 202 | 203 | ); 204 | 205 | export const SingleArrowRight = ({ className }) => ( 206 | 213 | 219 | 220 | ); 221 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | const { AUTH0_BASE_URL } = process.env; 4 | 5 | module.exports = { 6 | publicRuntimeConfig: { 7 | BACKEND_URL: `${AUTH0_BASE_URL}/api/graphql`, 8 | CORS_URL: `${AUTH0_BASE_URL}/api/cors`, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "newsprism", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "next", 8 | "build": "next build", 9 | "start": "next start", 10 | "prisma:init": "prisma init", 11 | "prisma:migrate": "prisma migrate dev --preview-feature", 12 | "prisma:generate": "prisma generate" 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "ISC", 17 | "dependencies": { 18 | "@apollo/client": "^3.3.6", 19 | "@auth0/nextjs-auth0": "^1.4.0", 20 | "@prisma/client": "^2.24.1", 21 | "apollo-server-micro": "^2.19.0", 22 | "autoprefixer": "^10.1.0", 23 | "dotenv": "^8.2.0", 24 | "graphql": "^15.4.0", 25 | "graphql-middleware": "^6.0.0", 26 | "graphql-shield": "^7.4.4", 27 | "graphql-tools": "^7.0.2", 28 | "lodash": "^4.17.20", 29 | "micro-cors": "^0.1.1", 30 | "next": "^10.0.3", 31 | "next-http-proxy-middleware": "^1.0.9", 32 | "postcss": "^8.1.7", 33 | "react": "^17.0.1", 34 | "react-dom": "^17.0.1", 35 | "react-js-pagination": "^3.0.3", 36 | "rss-parser": "^3.10.0", 37 | "string-strip-html": "^7.0.3", 38 | "tailwindcss": "^2.0.2", 39 | "uuid": "^8.3.2" 40 | }, 41 | "devDependencies": { 42 | "@types/node": "^14.14.14", 43 | "@types/react": "^17.0.0", 44 | "prisma": "^2.24.1", 45 | "typescript": "^4.1.3" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { ApolloProvider } from '@apollo/client'; 2 | import { useApollo } from '../utils/apolloClient'; 3 | import { UserProvider } from '@auth0/nextjs-auth0'; 4 | import 'tailwindcss/tailwind.css'; 5 | import '../styles/index.css'; 6 | 7 | export default function App({ Component, pageProps }) { 8 | const apolloClient = useApollo(pageProps.initialApolloState); 9 | const { user } = pageProps; 10 | 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /pages/api/auth/[...auth0].ts: -------------------------------------------------------------------------------- 1 | import { handleAuth } from '@auth0/nextjs-auth0'; 2 | 3 | export default handleAuth(); 4 | -------------------------------------------------------------------------------- /pages/api/cors.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import httpProxyMiddleware from 'next-http-proxy-middleware'; 3 | 4 | export default (req: NextApiRequest, res: NextApiResponse): Promise => { 5 | res.setHeader('Expires', '-1'); 6 | res.setHeader('Cache-Control', 'no-store, must-revalidate'); 7 | 8 | return new Promise(() => 9 | httpProxyMiddleware(req, res, { 10 | target: req.url.replace('/api/cors?', ''), 11 | pathRewrite: { 12 | '^/api/cors': '', 13 | }, 14 | }) 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /pages/api/graphql.ts: -------------------------------------------------------------------------------- 1 | import { ApolloServer } from 'apollo-server-micro'; 2 | import { makeExecutableSchema } from 'graphql-tools'; 3 | import Cors from 'micro-cors'; 4 | import { typeDefs } from '../../utils/api/typeDefs'; 5 | import { resolvers } from '../../utils/api/resolvers'; 6 | import { applyMiddleware } from 'graphql-middleware'; 7 | import { log } from '../../utils/api/log'; 8 | import { permissions } from '../../utils/api/permissions'; 9 | import { context } from '../../utils/api/context'; 10 | 11 | const cors = Cors(); 12 | 13 | const schema = applyMiddleware( 14 | makeExecutableSchema({ typeDefs, resolvers }), 15 | log, 16 | permissions, 17 | ); 18 | 19 | export const config = { 20 | api: { 21 | bodyParser: false, 22 | }, 23 | }; 24 | 25 | const handler = new ApolloServer({ 26 | schema, 27 | context, 28 | }).createHandler({ 29 | path: '/api/graphql', 30 | }); 31 | 32 | export default cors((req, res) => { 33 | if (req.method === 'OPTIONS') { 34 | return res.status(200).send(); 35 | } 36 | return handler(req, res); 37 | }); 38 | -------------------------------------------------------------------------------- /pages/bundle/[id].tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@apollo/client'; 2 | import { GenerateArticleList } from '../../components/generateArticleList'; 3 | import { Layout } from '../../components/layout'; 4 | import { NotifyError } from '../../components/notifyError'; 5 | import { NotifyLoading } from '../../components/notifyLoading'; 6 | import { OneListItem } from '../../components/oneListItem'; 7 | import { BUNDLE_QUERY } from '../../utils/api/graphql/queries'; 8 | import { FeedObject, ItemType } from '../../utils/types'; 9 | 10 | const Bundle = ({ id }) => { 11 | const { loading, error, data } = useQuery(BUNDLE_QUERY, { 12 | variables: { data: { id } }, 13 | }); 14 | 15 | console.log(loading, error, data); 16 | 17 | if (loading) { 18 | return ( 19 | 20 | 21 | 22 | ); 23 | } 24 | 25 | if (error) { 26 | return ( 27 | 28 | 29 | 30 | ); 31 | } 32 | 33 | const { bundle } = data || {}; 34 | 35 | return ( 36 | 37 |

{bundle.name}

38 |

{bundle.description}

39 |

Feeds

40 |
41 | {bundle.feeds.length > 0 ? ( 42 | bundle.feeds.map((item: FeedObject) => ( 43 | 44 | )) 45 | ) : ( 46 |

None are present. Why not add one?

47 | )} 48 |
49 | 50 |
51 | ); 52 | }; 53 | 54 | Bundle.getInitialProps = ({ query }) => { 55 | const { id } = query; 56 | return { id }; 57 | }; 58 | 59 | export default Bundle; 60 | -------------------------------------------------------------------------------- /pages/bundles.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { GenerateArticleList } from '../components/generateArticleList'; 3 | import { ItemList } from '../components/itemList'; 4 | import { Layout } from '../components/layout'; 5 | import { NewEditItem } from '../components/newEditItem'; 6 | import { Minus, Plus } from '../components/svg'; 7 | import { ItemType, SelectedFeedState } from '../utils/types'; 8 | import { useUser } from '@auth0/nextjs-auth0'; 9 | 10 | const BundlesPage = () => { 11 | const { user, error, isLoading } = useUser(); 12 | const initialSelected: SelectedFeedState = { 13 | id: null, 14 | feeds: [], 15 | editMode: false, 16 | newMode: false, 17 | }; 18 | const [selected, setSelected] = useState(initialSelected); 19 | 20 | return ( 21 | 22 |
23 |

24 | Bundles Page 25 |

26 | {user ? ( 27 |
{ 29 | e.persist(); 30 | setSelected((currState) => ({ 31 | ...currState, 32 | newMode: !currState.newMode, 33 | editMode: false, 34 | })); 35 | }} 36 | className="flex grid-cols-1 justify-end cursor-pointer" 37 | > 38 | {selected.newMode ? ( 39 | 44 | ) : ( 45 | 50 | )} 51 |

56 | New Bundle 57 |

58 |
59 | ) : null} 60 |
61 | {(selected.editMode || selected.newMode) && user ? ( 62 | 67 | ) : null} 68 | 75 | {selected.feeds.length > 0 ? ( 76 | 77 | ) : ( 78 |

No Bundle Selected

79 | )} 80 |
81 | ); 82 | }; 83 | 84 | export default BundlesPage; 85 | -------------------------------------------------------------------------------- /pages/feed/[id].tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@apollo/client'; 2 | import { GenerateArticleList } from '../../components/generateArticleList'; 3 | import { Layout } from '../../components/layout'; 4 | import { NotifyError } from '../../components/notifyError'; 5 | import { NotifyLoading } from '../../components/notifyLoading'; 6 | import { OneListItem } from '../../components/oneListItem'; 7 | import { FEED_QUERY } from '../../utils/api/graphql/queries'; 8 | import { FeedObject, ItemType } from '../../utils/types'; 9 | 10 | const Feed = ({ id }) => { 11 | const { loading, error, data } = useQuery(FEED_QUERY, { 12 | variables: { data: { id } }, 13 | }); 14 | 15 | console.log(loading, error, data); 16 | 17 | if (loading) { 18 | return ( 19 | 20 | 21 | 22 | ); 23 | } 24 | 25 | if (error) { 26 | return ( 27 | 28 | 29 | 30 | ); 31 | } 32 | 33 | const { feed } = data || {}; 34 | 35 | return ( 36 | 37 |

{feed.name}

38 |

{feed.url}

39 |

Bundles

40 |
41 | {feed.bundles.length > 0 ? ( 42 | feed.bundles.map((item: FeedObject) => ( 43 | 44 | )) 45 | ) : ( 46 |

None are present. Why not add one?

47 | )} 48 |
49 | 50 |
51 | ); 52 | }; 53 | 54 | Feed.getInitialProps = ({ query }) => { 55 | const { id } = query; 56 | return { id }; 57 | }; 58 | 59 | export default Feed; 60 | -------------------------------------------------------------------------------- /pages/feeds.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { GenerateArticleList } from '../components/generateArticleList'; 3 | import { ItemList } from '../components/itemList'; 4 | import { Layout } from '../components/layout'; 5 | import { NewEditItem } from '../components/newEditItem'; 6 | import { Minus, Plus } from '../components/svg'; 7 | import { ItemType, SelectedFeedState } from '../utils/types'; 8 | import { useUser } from '@auth0/nextjs-auth0'; 9 | 10 | const FeedsPage = () => { 11 | const { user, error, isLoading } = useUser(); 12 | const initialSelected: SelectedFeedState = { 13 | id: null, 14 | feeds: [], 15 | editMode: false, 16 | newMode: false, 17 | }; 18 | const [selected, setSelected] = useState(initialSelected); 19 | 20 | return ( 21 | 22 |
23 |

24 | Feeds Page 25 |

26 | {user ? ( 27 |
{ 29 | e.persist(); 30 | setSelected((currState) => ({ 31 | ...currState, 32 | newMode: !currState.newMode, 33 | editMode: false, 34 | })); 35 | }} 36 | className="flex grid-cols-1 justify-end cursor-pointer" 37 | > 38 | {selected.newMode ? ( 39 | 44 | ) : ( 45 | 50 | )} 51 |

56 | New Feed 57 |

58 |
59 | ) : null} 60 |
61 | {(selected.editMode || selected.newMode) && user ? ( 62 | 67 | ) : null} 68 | 75 | {selected.feeds.length > 0 ? ( 76 | 77 | ) : ( 78 |

No Bundle Selected

79 | )} 80 |
81 | ); 82 | }; 83 | 84 | export default FeedsPage; 85 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { GenerateArticleList } from '../components/generateArticleList'; 3 | import { ItemList } from '../components/itemList'; 4 | import { Layout } from '../components/layout'; 5 | import { ItemType, SelectedFeedState } from '../utils/types'; 6 | 7 | const IndexPage = () => { 8 | const initialSelected: SelectedFeedState = { 9 | id: null, 10 | feeds: [], 11 | editMode: false, 12 | newMode: false, 13 | }; 14 | const [selected, setSelected] = useState(initialSelected); 15 | 16 | return ( 17 | 18 |

Home Page

19 | 25 | {selected.feeds.length > 0 ? ( 26 | 27 | ) : ( 28 |

No Bundle Selected

29 | )} 30 |
31 | ); 32 | }; 33 | 34 | export default IndexPage; 35 | -------------------------------------------------------------------------------- /pages/saved-articles.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@apollo/client'; 2 | import { ArticleList } from '../components/articleList'; 3 | import { Layout } from '../components/layout'; 4 | import { NotifyError } from '../components/notifyError'; 5 | import { NotifyLoading } from '../components/notifyLoading'; 6 | import { SAVED_ARTICLES_QUERY } from '../utils/api/graphql/queries'; 7 | 8 | const SavedArticles = () => { 9 | const { loading, error, data } = useQuery(SAVED_ARTICLES_QUERY); 10 | 11 | if (loading) { 12 | return ( 13 | 14 | 15 | 16 | ); 17 | } 18 | 19 | const { savedArticles } = data || {}; 20 | 21 | if (error || !savedArticles) { 22 | return ( 23 | 24 | 25 | 26 | ); 27 | } 28 | 29 | const articleList = savedArticles.map(({ content, feed }) => ({ 30 | ...content, 31 | ...feed, 32 | })); 33 | 34 | return ( 35 | 36 | 37 | 38 | ); 39 | }; 40 | 41 | export default SavedArticles; 42 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /prisma/migrations/20201221211333_/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Feed" ( 3 | "id" TEXT NOT NULL, 4 | "name" TEXT NOT NULL, 5 | "url" TEXT NOT NULL, 6 | "authorId" TEXT, 7 | 8 | PRIMARY KEY ("id") 9 | ); 10 | 11 | -- CreateTable 12 | CREATE TABLE "Bundle" ( 13 | "id" TEXT NOT NULL, 14 | "name" TEXT NOT NULL, 15 | "description" TEXT NOT NULL DEFAULT E'', 16 | "authorId" TEXT, 17 | 18 | PRIMARY KEY ("id") 19 | ); 20 | 21 | -- CreateTable 22 | CREATE TABLE "User" ( 23 | "id" TEXT NOT NULL, 24 | "auth0" TEXT NOT NULL, 25 | "nickname" TEXT, 26 | "picture" TEXT, 27 | 28 | PRIMARY KEY ("id") 29 | ); 30 | 31 | -- CreateTable 32 | CREATE TABLE "SavedArticle" ( 33 | "id" TEXT NOT NULL, 34 | "content" JSONB NOT NULL, 35 | "feedId" TEXT, 36 | "authorId" TEXT, 37 | 38 | PRIMARY KEY ("id") 39 | ); 40 | 41 | -- CreateTable 42 | CREATE TABLE "BundleTag" ( 43 | "id" TEXT NOT NULL, 44 | "name" TEXT NOT NULL, 45 | 46 | PRIMARY KEY ("id") 47 | ); 48 | 49 | -- CreateTable 50 | CREATE TABLE "FeedTag" ( 51 | "id" TEXT NOT NULL, 52 | "name" TEXT NOT NULL, 53 | 54 | PRIMARY KEY ("id") 55 | ); 56 | 57 | -- CreateTable 58 | CREATE TABLE "_BundleToFeed" ( 59 | "A" TEXT NOT NULL, 60 | "B" TEXT NOT NULL 61 | ); 62 | 63 | -- CreateTable 64 | CREATE TABLE "_FeedToFeedTag" ( 65 | "A" TEXT NOT NULL, 66 | "B" TEXT NOT NULL 67 | ); 68 | 69 | -- CreateTable 70 | CREATE TABLE "_FeedUserLikes" ( 71 | "A" TEXT NOT NULL, 72 | "B" TEXT NOT NULL 73 | ); 74 | 75 | -- CreateTable 76 | CREATE TABLE "_BundleToBundleTag" ( 77 | "A" TEXT NOT NULL, 78 | "B" TEXT NOT NULL 79 | ); 80 | 81 | -- CreateTable 82 | CREATE TABLE "_BundleUserLikes" ( 83 | "A" TEXT NOT NULL, 84 | "B" TEXT NOT NULL 85 | ); 86 | 87 | -- CreateIndex 88 | CREATE UNIQUE INDEX "Feed.url_unique" ON "Feed"("url"); 89 | 90 | -- CreateIndex 91 | CREATE UNIQUE INDEX "User.auth0_unique" ON "User"("auth0"); 92 | 93 | -- CreateIndex 94 | CREATE UNIQUE INDEX "BundleTag.name_unique" ON "BundleTag"("name"); 95 | 96 | -- CreateIndex 97 | CREATE UNIQUE INDEX "FeedTag.name_unique" ON "FeedTag"("name"); 98 | 99 | -- CreateIndex 100 | CREATE UNIQUE INDEX "_BundleToFeed_AB_unique" ON "_BundleToFeed"("A", "B"); 101 | 102 | -- CreateIndex 103 | CREATE INDEX "_BundleToFeed_B_index" ON "_BundleToFeed"("B"); 104 | 105 | -- CreateIndex 106 | CREATE UNIQUE INDEX "_FeedToFeedTag_AB_unique" ON "_FeedToFeedTag"("A", "B"); 107 | 108 | -- CreateIndex 109 | CREATE INDEX "_FeedToFeedTag_B_index" ON "_FeedToFeedTag"("B"); 110 | 111 | -- CreateIndex 112 | CREATE UNIQUE INDEX "_FeedUserLikes_AB_unique" ON "_FeedUserLikes"("A", "B"); 113 | 114 | -- CreateIndex 115 | CREATE INDEX "_FeedUserLikes_B_index" ON "_FeedUserLikes"("B"); 116 | 117 | -- CreateIndex 118 | CREATE UNIQUE INDEX "_BundleToBundleTag_AB_unique" ON "_BundleToBundleTag"("A", "B"); 119 | 120 | -- CreateIndex 121 | CREATE INDEX "_BundleToBundleTag_B_index" ON "_BundleToBundleTag"("B"); 122 | 123 | -- CreateIndex 124 | CREATE UNIQUE INDEX "_BundleUserLikes_AB_unique" ON "_BundleUserLikes"("A", "B"); 125 | 126 | -- CreateIndex 127 | CREATE INDEX "_BundleUserLikes_B_index" ON "_BundleUserLikes"("B"); 128 | 129 | -- AddForeignKey 130 | ALTER TABLE "Feed" ADD FOREIGN KEY("authorId")REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; 131 | 132 | -- AddForeignKey 133 | ALTER TABLE "Bundle" ADD FOREIGN KEY("authorId")REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; 134 | 135 | -- AddForeignKey 136 | ALTER TABLE "SavedArticle" ADD FOREIGN KEY("feedId")REFERENCES "Feed"("id") ON DELETE SET NULL ON UPDATE CASCADE; 137 | 138 | -- AddForeignKey 139 | ALTER TABLE "SavedArticle" ADD FOREIGN KEY("authorId")REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; 140 | 141 | -- AddForeignKey 142 | ALTER TABLE "_BundleToFeed" ADD FOREIGN KEY("A")REFERENCES "Bundle"("id") ON DELETE CASCADE ON UPDATE CASCADE; 143 | 144 | -- AddForeignKey 145 | ALTER TABLE "_BundleToFeed" ADD FOREIGN KEY("B")REFERENCES "Feed"("id") ON DELETE CASCADE ON UPDATE CASCADE; 146 | 147 | -- AddForeignKey 148 | ALTER TABLE "_FeedToFeedTag" ADD FOREIGN KEY("A")REFERENCES "Feed"("id") ON DELETE CASCADE ON UPDATE CASCADE; 149 | 150 | -- AddForeignKey 151 | ALTER TABLE "_FeedToFeedTag" ADD FOREIGN KEY("B")REFERENCES "FeedTag"("id") ON DELETE CASCADE ON UPDATE CASCADE; 152 | 153 | -- AddForeignKey 154 | ALTER TABLE "_FeedUserLikes" ADD FOREIGN KEY("A")REFERENCES "Feed"("id") ON DELETE CASCADE ON UPDATE CASCADE; 155 | 156 | -- AddForeignKey 157 | ALTER TABLE "_FeedUserLikes" ADD FOREIGN KEY("B")REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 158 | 159 | -- AddForeignKey 160 | ALTER TABLE "_BundleToBundleTag" ADD FOREIGN KEY("A")REFERENCES "Bundle"("id") ON DELETE CASCADE ON UPDATE CASCADE; 161 | 162 | -- AddForeignKey 163 | ALTER TABLE "_BundleToBundleTag" ADD FOREIGN KEY("B")REFERENCES "BundleTag"("id") ON DELETE CASCADE ON UPDATE CASCADE; 164 | 165 | -- AddForeignKey 166 | ALTER TABLE "_BundleUserLikes" ADD FOREIGN KEY("A")REFERENCES "Bundle"("id") ON DELETE CASCADE ON UPDATE CASCADE; 167 | 168 | -- AddForeignKey 169 | ALTER TABLE "_BundleUserLikes" ADD FOREIGN KEY("B")REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 170 | -------------------------------------------------------------------------------- /prisma/migrations/20201222203039_/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `url` to the `SavedArticle` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "SavedArticle" ADD COLUMN "url" TEXT NOT NULL; 9 | -------------------------------------------------------------------------------- /prisma/migrations/20210115023100_/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateIndex 2 | CREATE INDEX "SavedArticle.authorId_url_index" ON "SavedArticle"("authorId", "url"); 3 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | datasource db { 5 | provider = "postgresql" 6 | url = env("DATABASE_URL") 7 | } 8 | 9 | generator client { 10 | provider = "prisma-client-js" 11 | } 12 | 13 | model Feed { 14 | id String @id 15 | name String 16 | url String @unique 17 | author User? @relation("FeedAuthor", fields: [authorId], references: [id]) 18 | authorId String? 19 | savedArticles SavedArticle[] 20 | bundles Bundle[] 21 | tags FeedTag[] 22 | likes User[] @relation("FeedUserLikes") 23 | } 24 | 25 | model Bundle { 26 | id String @id 27 | name String 28 | description String @default("") 29 | author User? @relation("BundleAuthor", fields: [authorId], references: [id]) 30 | authorId String? 31 | tags BundleTag[] 32 | feeds Feed[] 33 | likes User[] @relation("BundleUserLikes") 34 | 35 | } 36 | 37 | model User { 38 | id String @id 39 | auth0 String @unique 40 | nickname String? 41 | picture String? 42 | bundles Bundle[] @relation("BundleAuthor") 43 | feeds Feed[] @relation("FeedAuthor") 44 | bundleLikes Bundle[] @relation("BundleUserLikes") 45 | feedLikes Feed[] @relation("FeedUserLikes") 46 | savedArticles SavedArticle[] 47 | } 48 | 49 | model SavedArticle { 50 | id String @id 51 | content Json 52 | feed Feed? @relation(fields: [feedId], references: [id]) 53 | feedId String? 54 | author User? @relation(fields: [authorId], references: [id]) 55 | authorId String? 56 | url String 57 | 58 | @@index([authorId, url]) 59 | } 60 | 61 | model BundleTag { 62 | id String @id 63 | name String @unique 64 | bundles Bundle[] 65 | } 66 | 67 | model FeedTag { 68 | id String @id 69 | name String @unique 70 | feeds Feed[] 71 | } 72 | -------------------------------------------------------------------------------- /public/blank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaptainChemist/newsprism/c0765b7ac71c2c139b7b6fa9cea5bca642168ae3/public/blank.png -------------------------------------------------------------------------------- /public/end-to-end-react.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaptainChemist/newsprism/c0765b7ac71c2c139b7b6fa9cea5bca642168ae3/public/end-to-end-react.jpg -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaptainChemist/newsprism/c0765b7ac71c2c139b7b6fa9cea5bca642168ae3/public/logo.png -------------------------------------------------------------------------------- /styles/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: [], 3 | theme: { 4 | extend: {}, 5 | }, 6 | varients: {}, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": false, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve" 20 | }, 21 | "include": [ 22 | "next-env.d.ts", 23 | "**/*.ts", 24 | "**/*.tsx" 25 | ], 26 | "exclude": [ 27 | "node_modules" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /utils/api/context.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient, User } from '@prisma/client'; 2 | import { getSession } from '@auth0/nextjs-auth0'; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | 5 | let prisma: PrismaClient; 6 | 7 | if (process.env.NODE_ENV === 'production') { 8 | prisma = new PrismaClient(); 9 | } else { 10 | // Ensure the prisma instance is re-used during hot-reloading 11 | // Otherwise, a new client will be created on every reload 12 | globalThis['prisma'] = globalThis['prisma'] || new PrismaClient(); 13 | prisma = globalThis['prisma']; 14 | // Many thanks to kripod for this fix: 15 | // https://github.com/blitz-js/blitz/blob/canary/examples/tailwind/db/index.ts 16 | } 17 | 18 | export const context = async ({ req, res }) => { 19 | try { 20 | const { user: auth0User } = await getSession(req, res); 21 | 22 | let user = await prisma.user.findUnique({ 23 | where: { auth0: auth0User.sub }, 24 | }); 25 | if (!user) { 26 | const { picture, nickname, sub } = auth0User; 27 | user = await prisma.user.create({ 28 | data: { 29 | id: uuidv4(), 30 | auth0: sub, 31 | picture, 32 | nickname, 33 | }, 34 | }); 35 | } 36 | return { user, prisma }; 37 | } catch (e) { 38 | return { user: {}, prisma }; 39 | } 40 | }; 41 | 42 | export interface Context { 43 | prisma: PrismaClient; 44 | user: User; 45 | } 46 | -------------------------------------------------------------------------------- /utils/api/graphql/fragments.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | export const FEED_TAG_FRAGMENT = gql` 4 | fragment FeedTagFragment on FeedTag { 5 | id 6 | name 7 | } 8 | `; 9 | 10 | export const BUNDLE_TAG_FRAGMENT = gql` 11 | fragment BundleTagFragment on BundleTag { 12 | id 13 | name 14 | } 15 | `; 16 | 17 | export const AUTHOR_FRAGMENT = gql` 18 | fragment AuthorFragment on User { 19 | id 20 | auth0 21 | picture 22 | nickname 23 | } 24 | `; 25 | 26 | export const LIKE_FRAGMENT = gql` 27 | fragment LikeFragment on Like { 28 | id 29 | name 30 | } 31 | `; 32 | 33 | export const FEED_FRAGMENT = gql` 34 | fragment FeedFragment on Feed { 35 | id 36 | name 37 | url 38 | likes { 39 | ...AuthorFragment 40 | } 41 | tags { 42 | ...FeedTagFragment 43 | } 44 | author { 45 | ...AuthorFragment 46 | } 47 | } 48 | ${FEED_TAG_FRAGMENT} 49 | ${AUTHOR_FRAGMENT} 50 | `; 51 | 52 | export const BUNDLE_FRAGMENT = gql` 53 | fragment BundleFragment on Bundle { 54 | id 55 | name 56 | description 57 | tags { 58 | ...BundleTagFragment 59 | } 60 | author { 61 | ...AuthorFragment 62 | } 63 | likes { 64 | ...AuthorFragment 65 | } 66 | } 67 | ${BUNDLE_TAG_FRAGMENT} 68 | ${AUTHOR_FRAGMENT} 69 | `; 70 | 71 | export const SAVED_ARTICLE_FRAGMENT = gql` 72 | fragment SavedArticleFragment on SavedArticle { 73 | id 74 | content 75 | url 76 | author { 77 | ...AuthorFragment 78 | } 79 | feed { 80 | ...FeedFragment 81 | } 82 | } 83 | ${AUTHOR_FRAGMENT} 84 | ${FEED_FRAGMENT} 85 | `; 86 | -------------------------------------------------------------------------------- /utils/api/graphql/mutations.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | import { BUNDLE_FRAGMENT, FEED_FRAGMENT, SAVED_ARTICLE_FRAGMENT } from './fragments'; 3 | 4 | export const LIKE_BUNDLE_MUTATION = gql` 5 | mutation likeBundleMutation($data: LikeBundleInput) { 6 | likeBundle(data: $data) { 7 | ...BundleFragment 8 | feeds { 9 | ...FeedFragment 10 | } 11 | } 12 | } 13 | ${BUNDLE_FRAGMENT} 14 | ${FEED_FRAGMENT} 15 | `; 16 | 17 | export const LIKE_FEED_MUTATION = gql` 18 | mutation likeFeedMutation($data: LikeFeedInput) { 19 | likeFeed(data: $data) { 20 | ...FeedFragment 21 | bundles { 22 | ...BundleFragment 23 | } 24 | } 25 | } 26 | ${BUNDLE_FRAGMENT} 27 | ${FEED_FRAGMENT} 28 | `; 29 | 30 | export const CREATE_BUNDLE_MUTATION = gql` 31 | mutation createBundleMutation($data: BundleCreateInput) { 32 | createBundle(data: $data) { 33 | ...BundleFragment 34 | feeds { 35 | ...FeedFragment 36 | bundles { 37 | ...BundleFragment 38 | } 39 | } 40 | } 41 | } 42 | ${FEED_FRAGMENT} 43 | ${BUNDLE_FRAGMENT} 44 | `; 45 | export const UPDATE_BUNDLE_MUTATION = gql` 46 | mutation updateBundleMutation($data: BundleUpdateInput) { 47 | updateBundle(data: $data) { 48 | ...BundleFragment 49 | feeds { 50 | ...FeedFragment 51 | bundles { 52 | ...BundleFragment 53 | } 54 | } 55 | } 56 | } 57 | ${FEED_FRAGMENT} 58 | ${BUNDLE_FRAGMENT} 59 | `; 60 | 61 | export const CREATE_FEED_MUTATION = gql` 62 | mutation createFeedMutation($data: FeedCreateInput) { 63 | createFeed(data: $data) { 64 | ...FeedFragment 65 | bundles { 66 | ...BundleFragment 67 | feeds { 68 | ...FeedFragment 69 | } 70 | } 71 | } 72 | } 73 | ${FEED_FRAGMENT} 74 | ${BUNDLE_FRAGMENT} 75 | `; 76 | 77 | export const UPDATE_FEED_MUTATION = gql` 78 | mutation updateFeedMutation($data: FeedUpdateInput) { 79 | updateFeed(data: $data) { 80 | ...FeedFragment 81 | bundles { 82 | ...BundleFragment 83 | feeds { 84 | ...FeedFragment 85 | } 86 | } 87 | } 88 | } 89 | ${FEED_FRAGMENT} 90 | ${BUNDLE_FRAGMENT} 91 | `; 92 | 93 | export const CREATE_SAVED_ARTICLE_MUTATION = gql` 94 | mutation createSavedArticleMutation($data: SavedArticleCreateInput) { 95 | createSavedArticle(data: $data) { 96 | ...SavedArticleFragment 97 | } 98 | } 99 | ${SAVED_ARTICLE_FRAGMENT} 100 | `; 101 | 102 | export const DELETE_BUNDLE_MUTATION = gql` 103 | mutation deleteBundleMutation($data: BundleInput) { 104 | deleteBundle(data: $data) { 105 | id 106 | } 107 | } 108 | `; 109 | 110 | export const DELETE_FEED_MUTATION = gql` 111 | mutation deleteFeedMutation($data: FeedInput) { 112 | deleteFeed(data: $data) { 113 | id 114 | } 115 | } 116 | `; 117 | 118 | export const DELETE_SAVED_ARTICLE_MUTATION = gql` 119 | mutation deleteSavedArticleMutation($data: DeleteSavedArticleInput) { 120 | deleteSavedArticle(data: $data) { 121 | id 122 | url 123 | } 124 | } 125 | `; 126 | -------------------------------------------------------------------------------- /utils/api/graphql/queries.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | import { 3 | AUTHOR_FRAGMENT, 4 | BUNDLE_FRAGMENT, 5 | BUNDLE_TAG_FRAGMENT, 6 | FEED_FRAGMENT, 7 | FEED_TAG_FRAGMENT, 8 | SAVED_ARTICLE_FRAGMENT, 9 | } from './fragments'; 10 | 11 | export const BUNDLES_QUERY = gql` 12 | query { 13 | bundles { 14 | ...BundleFragment 15 | feeds { 16 | ...FeedFragment 17 | } 18 | } 19 | } 20 | ${BUNDLE_FRAGMENT} 21 | ${FEED_FRAGMENT} 22 | `; 23 | 24 | export const FEEDS_QUERY = gql` 25 | query { 26 | feeds { 27 | ...FeedFragment 28 | bundles { 29 | ...BundleFragment 30 | } 31 | } 32 | } 33 | ${FEED_FRAGMENT} 34 | ${BUNDLE_FRAGMENT} 35 | `; 36 | 37 | export const FIND_FEEDS_QUERY = gql` 38 | query findFeedsQuery($data: FindFeedsInput) { 39 | findFeeds(data: $data) { 40 | ...FeedFragment 41 | bundles { 42 | ...BundleFragment 43 | } 44 | } 45 | } 46 | ${FEED_FRAGMENT} 47 | ${BUNDLE_FRAGMENT} 48 | `; 49 | 50 | export const FEED_QUERY = gql` 51 | query feedQuery($data: FeedInput) { 52 | feed(data: $data) { 53 | ...FeedFragment 54 | bundles { 55 | ...BundleFragment 56 | feeds { 57 | ...FeedFragment 58 | } 59 | } 60 | } 61 | } 62 | ${FEED_FRAGMENT} 63 | ${BUNDLE_FRAGMENT} 64 | `; 65 | 66 | export const BUNDLE_QUERY = gql` 67 | query bundleQuery($data: BundleInput) { 68 | bundle(data: $data) { 69 | ...BundleFragment 70 | feeds { 71 | ...FeedFragment 72 | bundles { 73 | ...BundleFragment 74 | } 75 | } 76 | } 77 | } 78 | ${FEED_FRAGMENT} 79 | ${BUNDLE_FRAGMENT} 80 | `; 81 | 82 | export const FIND_FEED_TAGS_QUERY = gql` 83 | query findFeedTagsQuery($data: FindFeedTagsInput) { 84 | findFeedTags(data: $data) { 85 | ...FeedTagFragment 86 | } 87 | } 88 | ${FEED_TAG_FRAGMENT} 89 | `; 90 | 91 | export const FIND_BUNDLE_TAGS_QUERY = gql` 92 | query findBundleTagsQuery($data: FindBundleTagsInput) { 93 | findBundleTags(data: $data) { 94 | ...BundleTagFragment 95 | } 96 | } 97 | ${BUNDLE_TAG_FRAGMENT} 98 | `; 99 | 100 | export const SAVED_ARTICLES_QUERY = gql` 101 | query savedArticlesQuery { 102 | savedArticles { 103 | ...SavedArticleFragment 104 | } 105 | } 106 | ${SAVED_ARTICLE_FRAGMENT} 107 | `; 108 | 109 | export const SAVED_ARTICLE_QUERY = gql` 110 | query savedArticleQuery($data: SavedArticleInput) { 111 | savedArticle(data: $data) { 112 | ...SavedArticleFragment 113 | } 114 | } 115 | ${SAVED_ARTICLE_FRAGMENT} 116 | `; 117 | 118 | export const ME_QUERY = gql` 119 | query meQuery { 120 | me { 121 | ...AuthorFragment 122 | } 123 | } 124 | ${AUTHOR_FRAGMENT} 125 | `; 126 | -------------------------------------------------------------------------------- /utils/api/log.ts: -------------------------------------------------------------------------------- 1 | export const log = async (resolve, parent, args, ctx, info) => { 2 | try { 3 | const res = await resolve(); 4 | return res; 5 | } catch (e) { 6 | console.log(e); 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /utils/api/permissions.ts: -------------------------------------------------------------------------------- 1 | import { rule, shield } from 'graphql-shield'; 2 | import * as _ from 'lodash'; 3 | 4 | const rules = { 5 | isAuthenticated: rule()((_parent, _args, context) => { 6 | return _.isEmpty(context.user) ? false : true; 7 | }), 8 | }; 9 | 10 | export const permissions = shield({ 11 | Query: { 12 | savedArticle: rules.isAuthenticated, 13 | savedArticles: rules.isAuthenticated, 14 | }, 15 | Mutation: { 16 | createFeed: rules.isAuthenticated, 17 | createBundle: rules.isAuthenticated, 18 | likeFeed: rules.isAuthenticated, 19 | updateFeed: rules.isAuthenticated, 20 | updateBundle: rules.isAuthenticated, 21 | createSavedArticle: rules.isAuthenticated, 22 | deleteBundle: rules.isAuthenticated, 23 | deleteFeed: rules.isAuthenticated, 24 | deleteSavedArticle: rules.isAuthenticated, 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /utils/api/resolvers.ts: -------------------------------------------------------------------------------- 1 | import { Context } from './context'; 2 | import { verifyOwnership } from './verifyOwnership'; 3 | 4 | const createFieldResolver = (modelName, parName) => ({ 5 | [parName]: async ({ id }, args, { prisma }: Context) => { 6 | const modelResponse = await prisma[modelName].findUnique({ 7 | where: { id }, 8 | select: { [parName]: true }, 9 | }); 10 | return modelResponse[parName]; 11 | }, 12 | }); 13 | 14 | export const resolvers = { 15 | Feed: { 16 | ...createFieldResolver('feed', 'author'), 17 | ...createFieldResolver('feed', 'tags'), 18 | ...createFieldResolver('feed', 'bundles'), 19 | ...createFieldResolver('feed', 'likes'), 20 | }, 21 | Bundle: { 22 | ...createFieldResolver('bundle', 'author'), 23 | ...createFieldResolver('bundle', 'tags'), 24 | ...createFieldResolver('bundle', 'feeds'), 25 | ...createFieldResolver('bundle', 'likes'), 26 | }, 27 | BundleTag: { 28 | ...createFieldResolver('bundleTag', 'bundles'), 29 | }, 30 | FeedTag: { 31 | ...createFieldResolver('feedTag', 'feeds'), 32 | }, 33 | SavedArticle: { 34 | ...createFieldResolver('savedArticle', 'author'), 35 | ...createFieldResolver('savedArticle', 'feed'), 36 | }, 37 | User: { 38 | ...createFieldResolver('user', 'bundles'), 39 | ...createFieldResolver('user', 'feeds'), 40 | ...createFieldResolver('user', 'feedLikes'), 41 | ...createFieldResolver('user', 'bundleLikes'), 42 | }, 43 | Query: { 44 | hello: (parent, args, context: Context) => 'hi!', 45 | feed: (parent, { data: { id } }, { prisma }: Context) => 46 | prisma.feed.findUnique({ where: { id } }), 47 | feeds: (parent, args, { prisma }: Context) => prisma.feed.findMany(), 48 | bundle: (parent, { data: { id } }, { prisma }: Context) => 49 | prisma.bundle.findUnique({ where: { id } }), 50 | bundles: (parent, args, { prisma }: Context) => prisma.bundle.findMany(), 51 | findFeedTags: (parent, { data }, { prisma }: Context) => 52 | prisma.feedTag.findMany({ 53 | where: { name: { contains: data.search, mode: 'insensitive' } }, 54 | }), 55 | findBundleTags: (parent, { data }, { prisma }: Context) => 56 | prisma.bundleTag.findMany({ 57 | where: { name: { contains: data.search, mode: 'insensitive' } }, 58 | }), 59 | findFeeds: (parent, { data }, { prisma }: Context) => 60 | prisma.feed.findMany({ 61 | where: { name: { contains: data.search, mode: 'insensitive' } }, 62 | }), 63 | savedArticle: ( 64 | parent, 65 | { data: { url } }, 66 | { prisma, user: { id: authorId } }: Context, 67 | ) => 68 | prisma.savedArticle.findUnique({ 69 | where: { id: `${authorId}-${url}` }, 70 | }), 71 | savedArticles: ( 72 | parent, 73 | args, 74 | { prisma, user: { id: authorId } }: Context, 75 | ) => 76 | prisma.savedArticle.findMany({ 77 | where: { authorId: authorId ? authorId : null }, 78 | }), 79 | me: (parent, args, { prisma, user: { id } }: Context) => 80 | prisma.user.findUnique({ where: { id } }), 81 | }, 82 | Mutation: { 83 | createFeed: (parent, { data }, { prisma, user }: Context) => 84 | prisma.feed.create({ 85 | data: { ...data, author: { connect: { id: user.id } } }, 86 | }), 87 | createBundle: (parent, { data }, { prisma, user }: Context) => 88 | prisma.bundle.create({ 89 | data: { ...data, author: { connect: { id: user.id } } }, 90 | }), 91 | likeBundle: (parent, { data }, { prisma, user }: Context) => { 92 | const { bundleId, likeState } = data; 93 | const connectState = likeState ? 'connect' : 'disconnect'; 94 | return prisma.bundle.update({ 95 | where: { id: bundleId }, 96 | data: { likes: { [connectState]: { id: user.id } } }, 97 | }); 98 | }, 99 | likeFeed: (parent, { data }, { prisma, user }: Context) => { 100 | const { feedId, likeState } = data; 101 | const connectState = likeState ? 'connect' : 'disconnect'; 102 | return prisma.feed.update({ 103 | where: { id: feedId }, 104 | data: { likes: { [connectState]: { id: user.id } } }, 105 | }); 106 | }, 107 | updateFeed: async ( 108 | parent, 109 | { data: { id, ...feedUpdate } }, 110 | { prisma, user }: Context, 111 | ) => { 112 | const feed = await prisma.feed.findUnique({ 113 | where: { id }, 114 | include: { author: true }, 115 | }); 116 | await verifyOwnership(feed, user); 117 | return prisma.feed.update({ where: { id }, data: { ...feedUpdate } }); 118 | }, 119 | updateBundle: async ( 120 | parent, 121 | { data: { id, ...bundleUpdate } }, 122 | { prisma, user }: Context, 123 | ) => { 124 | const bundle = await prisma.bundle.findUnique({ 125 | where: { id }, 126 | include: { author: true }, 127 | }); 128 | await verifyOwnership(bundle, user); 129 | return prisma.bundle.update({ where: { id }, data: { ...bundleUpdate } }); 130 | }, 131 | createSavedArticle: async (parent, { data }, { prisma, user }: Context) => 132 | prisma.savedArticle.create({ 133 | data: { 134 | ...data, 135 | id: `${user.id}-${data.url}`, 136 | author: { connect: { id: user.id } }, 137 | }, 138 | }), 139 | deleteBundle: async ( 140 | parent, 141 | { data: { id } }, 142 | { prisma, user }: Context, 143 | ) => { 144 | const bundle = await prisma.bundle.findUnique({ 145 | where: { id }, 146 | include: { author: true }, 147 | }); 148 | await verifyOwnership(bundle, user); 149 | await prisma.bundle.delete({ where: { id: bundle.id } }); 150 | return bundle; 151 | }, 152 | deleteFeed: async (parent, { data: { id } }, { prisma, user }: Context) => { 153 | const feed = await prisma.feed.findUnique({ 154 | where: { id }, 155 | include: { author: true }, 156 | }); 157 | await verifyOwnership(feed, user); 158 | await prisma.feed.delete({ where: { id: feed.id } }); 159 | return feed; 160 | }, 161 | deleteSavedArticle: async ( 162 | parent, 163 | { data: { id } }, 164 | { prisma, user }: Context, 165 | ) => { 166 | const savedArticle = await prisma.savedArticle.findUnique({ 167 | where: { id }, 168 | include: { author: true }, 169 | }); 170 | await verifyOwnership(savedArticle, user); 171 | await prisma.savedArticle.delete({ where: { id: savedArticle.id } }); 172 | return savedArticle; 173 | }, 174 | }, 175 | }; 176 | -------------------------------------------------------------------------------- /utils/api/typeDefs.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'apollo-server-micro'; 2 | 3 | export const typeDefs = gql` 4 | type Feed { 5 | id: String 6 | name: String 7 | url: String 8 | author: User 9 | tags: [FeedTag] 10 | bundles: [Bundle] 11 | likes: [User] 12 | savedArticles: [SavedArticle] 13 | } 14 | 15 | type Bundle { 16 | id: String 17 | name: String 18 | description: String 19 | author: User 20 | tags: [BundleTag] 21 | feeds: [Feed] 22 | likes: [User] 23 | } 24 | 25 | type User { 26 | id: String 27 | auth0: String 28 | nickname: String 29 | picture: String 30 | bundles: [Bundle] 31 | feeds: [Feed] 32 | feedLikes: [Feed] 33 | bundleLikes: [Bundle] 34 | } 35 | 36 | type FeedTag { 37 | id: String 38 | name: String 39 | feeds: [Feed] 40 | } 41 | 42 | type BundleTag { 43 | id: String 44 | name: String 45 | bundles: [Bundle] 46 | } 47 | 48 | input FeedInput { 49 | id: String 50 | } 51 | input BundleInput { 52 | id: String 53 | } 54 | 55 | input FeedCreateInput { 56 | id: String 57 | url: String 58 | name: String 59 | tags: NestedFeedTagCreateInput 60 | } 61 | input NestedFeedTagCreateInput { 62 | create: [FeedTagCreateInput] 63 | connect: [FeedTagWhereUniqueInput] 64 | } 65 | 66 | input FeedTagCreateInput { 67 | id: String 68 | name: String 69 | } 70 | input FeedTagWhereUniqueInput { 71 | id: String 72 | name: String 73 | } 74 | 75 | input BundleCreateInput { 76 | id: String 77 | name: String 78 | description: String 79 | tags: NestedBundleTagCreateInput 80 | feeds: NestedBundleFeedCreateInput 81 | } 82 | input NestedBundleTagCreateInput { 83 | create: [BundleTagCreateInput] 84 | connect: [BundleTagWhereUniqueInput] 85 | } 86 | 87 | input BundleTagCreateInput { 88 | id: String 89 | name: String 90 | } 91 | 92 | input BundleTagWhereUniqueInput { 93 | id: String 94 | name: String 95 | } 96 | 97 | input NestedBundleFeedCreateInput { 98 | create: [FeedCreateInput] 99 | connect: [FeedWhereUniqueInput] 100 | } 101 | 102 | input FeedWhereUniqueInput { 103 | id: String 104 | url: String 105 | } 106 | 107 | input LikeBundleInput { 108 | bundleId: String 109 | likeState: Boolean 110 | } 111 | 112 | input LikeFeedInput { 113 | feedId: String 114 | likeState: Boolean 115 | } 116 | 117 | input FindFeedTagsInput { 118 | search: String 119 | } 120 | 121 | input FindBundleTagsInput { 122 | search: String 123 | } 124 | 125 | input FindFeedsInput { 126 | search: String 127 | } 128 | 129 | input FeedUpdateInput { 130 | id: String 131 | url: String 132 | name: String 133 | tags: NestedFeedTagUpdateInput 134 | } 135 | 136 | input NestedFeedTagUpdateInput { 137 | create: [FeedTagCreateInput] 138 | connect: [FeedTagWhereUniqueInput] 139 | disconnect: [FeedTagWhereUniqueInput] 140 | } 141 | 142 | input BundleUpdateInput { 143 | id: String 144 | name: String 145 | description: String 146 | tags: NestedBundleTagUpdateInput 147 | feeds: NestedBundleFeedUpdateInput 148 | } 149 | 150 | input NestedBundleTagUpdateInput { 151 | create: [BundleTagCreateInput] 152 | connect: [BundleTagWhereUniqueInput] 153 | disconnect: [BundleTagWhereUniqueInput] 154 | } 155 | 156 | input NestedBundleFeedUpdateInput { 157 | create: [FeedCreateInput] 158 | connect: [FeedWhereUniqueInput] 159 | disconnect: [FeedWhereUniqueInput] 160 | } 161 | 162 | scalar JSON 163 | 164 | type SavedArticle { 165 | id: String 166 | author: User 167 | url: String 168 | content: JSON 169 | feed: Feed 170 | } 171 | 172 | input SavedArticleInput { 173 | url: String 174 | } 175 | 176 | input SavedArticleCreateInput { 177 | feed: NestedFeedCreateInput 178 | content: JSON 179 | url: String 180 | } 181 | 182 | input NestedFeedCreateInput { 183 | connect: FeedWhereUniqueInput 184 | } 185 | 186 | input DeleteSavedArticleInput { 187 | id: String 188 | } 189 | 190 | type Query { 191 | hello: String 192 | feed(data: FeedInput): Feed 193 | bundle(data: BundleInput): Bundle 194 | feeds: [Feed] 195 | bundles: [Bundle] 196 | findFeedTags(data: FindFeedTagsInput): [FeedTag] 197 | findBundleTags(data: FindBundleTagsInput): [BundleTag] 198 | findFeeds(data: FindFeedsInput): [Feed] 199 | savedArticle(data: SavedArticleInput): SavedArticle 200 | savedArticles: [SavedArticle] 201 | me: User 202 | } 203 | type Mutation { 204 | createFeed(data: FeedCreateInput): Feed 205 | createBundle(data: BundleCreateInput): Bundle 206 | likeBundle(data: LikeBundleInput): Bundle 207 | likeFeed(data: LikeFeedInput): Feed 208 | updateBundle(data: BundleUpdateInput): Bundle 209 | updateFeed(data: FeedUpdateInput): Feed 210 | createSavedArticle(data: SavedArticleCreateInput): SavedArticle 211 | deleteBundle(data: BundleInput): Bundle 212 | deleteFeed(data: FeedInput): Feed 213 | deleteSavedArticle(data: DeleteSavedArticleInput): SavedArticle 214 | } 215 | `; 216 | -------------------------------------------------------------------------------- /utils/api/verifyOwnership.ts: -------------------------------------------------------------------------------- 1 | export const verifyOwnership = (item, user) => { 2 | const { author } = item; 3 | if (author.auth0 !== user.auth0) { 4 | throw new Error('Access denied, user does not own this item.'); 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /utils/apolloClient.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'; 3 | import getConfig from 'next/config'; 4 | 5 | const { publicRuntimeConfig } = getConfig(); 6 | const { BACKEND_URL } = publicRuntimeConfig; 7 | 8 | let apolloClient; 9 | 10 | function createApolloClient() { 11 | return new ApolloClient({ 12 | ssrMode: typeof window === 'undefined', 13 | link: new HttpLink({ 14 | uri: BACKEND_URL, // Server URL (must be absolute) 15 | credentials: 'same-origin', // Additional fetch() options like `credentials` or `headers` 16 | }), 17 | cache: new InMemoryCache({}), 18 | }); 19 | } 20 | 21 | export function initializeApollo(initialState = null) { 22 | const _apolloClient = apolloClient ?? createApolloClient(); 23 | 24 | // If your page has Next.js data fetching methods that use Apollo Client, the initial state 25 | // gets hydrated here 26 | if (initialState) { 27 | // Get existing cache, loaded during client side data fetching 28 | const existingCache = _apolloClient.extract(); 29 | 30 | _apolloClient.cache.restore({ ...existingCache, ...initialState }); 31 | } 32 | // For SSG and SSR always create a new Apollo Client 33 | if (typeof window === 'undefined') return _apolloClient; 34 | // Create the Apollo Client once in the client 35 | if (!apolloClient) apolloClient = _apolloClient; 36 | 37 | return _apolloClient; 38 | } 39 | 40 | export function useApollo(initialState) { 41 | const store = useMemo(() => initializeApollo(initialState), [initialState]); 42 | return store; 43 | } 44 | -------------------------------------------------------------------------------- /utils/optimisticCache.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | 3 | export const optimisticCache = (isFeed, action, data, currentItem, meData) => { 4 | const __typename = isFeed ? 'Feed' : 'Bundle'; 5 | const { id } = data; 6 | const { me } = meData; 7 | 8 | const response = { 9 | id, 10 | ...currentItem, 11 | [isFeed ? 'bundles' : 'feeds']: [], 12 | likes: [], 13 | tags: [ 14 | ...currentItem.tags.filter((tag) => _.has(tag, 'id')), 15 | ..._.get(data, 'tags.create', []).map((tag) => ({ 16 | __typename: isFeed ? 'FeedTag' : 'BundleTag', 17 | ...tag, 18 | })), 19 | ], 20 | ...(isFeed 21 | ? {} 22 | : { 23 | feeds: currentItem.feeds, 24 | }), 25 | author: me, 26 | }; 27 | 28 | return { 29 | __typename: 'Mutation', 30 | [action + __typename]: { 31 | __typename, 32 | ...response, 33 | }, 34 | }; 35 | }; 36 | -------------------------------------------------------------------------------- /utils/prepareUpdateObj.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | import * as _ from 'lodash'; 3 | 4 | const genNestedItems = (currentItem) => { 5 | const tags = 6 | 'tags' in currentItem 7 | ? { 8 | tags: { 9 | connect: currentItem.tags 10 | .map(({ id }) => ({ id })) 11 | .filter(({ id }) => id !== undefined), 12 | 13 | create: currentItem.tags 14 | .filter(({ id }) => id === undefined) 15 | .map((o) => ({ ...o, id: uuidv4() })), 16 | }, 17 | } 18 | : {}; 19 | 20 | const feeds = 21 | 'feeds' in currentItem 22 | ? { 23 | feeds: { 24 | connect: currentItem.feeds 25 | .map(({ id }) => ({ id })) 26 | .filter(({ id }) => id !== undefined), 27 | }, 28 | } 29 | : {}; 30 | 31 | const { __typename, likes, author, bundles, ...cleanedItem } = currentItem; 32 | 33 | return { ...cleanedItem, ...tags, ...feeds }; 34 | }; 35 | 36 | const cleanOps = (currentData, items) => { 37 | items.map((oneItem) => { 38 | ['connect', 'disconnect', 'create'].map((operation) => { 39 | if (operation in currentData[oneItem]) { 40 | currentData[oneItem][operation].length === 0 41 | ? delete currentData[oneItem][operation] 42 | : null; 43 | } 44 | }); 45 | 46 | if (_.isEmpty(currentData[oneItem])) { 47 | delete currentData[oneItem]; 48 | } 49 | }); 50 | 51 | return currentData; 52 | }; 53 | 54 | export const prepareNewUpdateObj = ( 55 | queriedItem, 56 | currentItem, 57 | isFeed, 58 | isEditing, 59 | ) => { 60 | const currentData = genNestedItems(currentItem); 61 | 62 | if (!isEditing) { 63 | return { ...currentData, id: uuidv4() }; 64 | } 65 | 66 | const queriedData = genNestedItems(queriedItem); 67 | 68 | const disconnectedTags = _.differenceWith( 69 | queriedData.tags.connect, 70 | currentData.tags.connect, 71 | _.isEqual, 72 | ); 73 | const connectedTags = _.differenceWith( 74 | currentData.tags.connect, 75 | queriedData.tags.connect, 76 | _.isEqual, 77 | ); 78 | 79 | if (!isFeed) { 80 | const disconnectedFeeds = _.differenceWith( 81 | queriedData.feeds.connect, 82 | currentData.feeds.connect, 83 | _.isEqual, 84 | ); 85 | const connectedFeeds = _.differenceWith( 86 | currentData.feeds.connect, 87 | queriedData.feeds.connect, 88 | _.isEqual, 89 | ); 90 | 91 | return cleanOps( 92 | { 93 | ...currentData, 94 | tags: { 95 | connect: connectedTags, 96 | disconnect: disconnectedTags, 97 | create: _.get(currentData, 'tags.create', []), 98 | }, 99 | feeds: { 100 | connect: connectedFeeds, 101 | disconnect: disconnectedFeeds, 102 | }, 103 | }, 104 | ['tags', 'feeds'], 105 | ); 106 | } else { 107 | return cleanOps( 108 | { 109 | ...currentData, 110 | tags: { 111 | connect: connectedTags, 112 | disconnect: disconnectedTags, 113 | create: _.get(currentData, 'tags.create', []), 114 | }, 115 | }, 116 | ['tags'], 117 | ); 118 | } 119 | }; 120 | -------------------------------------------------------------------------------- /utils/types.ts: -------------------------------------------------------------------------------- 1 | import { BundleTag, Feed, FeedTag, User } from '@prisma/client'; 2 | 3 | export enum ItemType { 4 | BundleType = 'BundleType', 5 | FeedType = 'FeedType', 6 | } 7 | 8 | export type FeedObject = { 9 | id?: string; 10 | name: string; 11 | url: string; 12 | tags: FeedTag[]; 13 | bundles?: BundleObject[]; 14 | author?: User; 15 | likes?: User[]; 16 | }; 17 | 18 | export type BundleObject = { 19 | id?: string; 20 | name: string; 21 | description: string; 22 | tags: BundleTag[]; 23 | feeds: FeedObject[]; 24 | author?: User; 25 | likes?: User[]; 26 | }; 27 | 28 | export type SelectedFeedState = { 29 | id: string; 30 | feeds: Feed[]; 31 | editMode: boolean; 32 | newMode: boolean; 33 | }; 34 | 35 | export enum BadgeFieldName { 36 | tags = 'tags', 37 | feeds = 'feeds', 38 | bundles = 'bundles', 39 | } 40 | 41 | export enum ActionType { 42 | ADD = 'ADD', 43 | CREATE = 'CREATE', 44 | NONE = 'NONE', 45 | } 46 | 47 | export type NewItemState = FeedObject | BundleObject; 48 | 49 | export enum SearchQueryName { 50 | findFeedTags = 'findFeedTags', 51 | findBundleTags = 'findBundleTags', 52 | findFeeds = 'findFeeds', 53 | } 54 | -------------------------------------------------------------------------------- /utils/update.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BUNDLES_QUERY, 3 | BUNDLE_QUERY, 4 | FEEDS_QUERY, 5 | FEED_QUERY, 6 | SAVED_ARTICLES_QUERY, 7 | SAVED_ARTICLE_QUERY, 8 | } from './api/graphql/queries'; 9 | import * as _ from 'lodash'; 10 | 11 | export const updateCache = (isFeed, action) => (store, { data }) => { 12 | const item = data[`${action}${isFeed ? 'Feed' : 'Bundle'}`]; 13 | 14 | try { 15 | store.writeQuery({ 16 | query: isFeed ? FEED_QUERY : BUNDLE_QUERY, 17 | variables: { data: { id: _.get(item, 'id') } }, 18 | data: { [isFeed ? 'feed' : 'bundle']: item }, 19 | }); 20 | } catch (e) {} 21 | 22 | try { 23 | const { feeds, bundles } = store.readQuery({ 24 | query: isFeed ? FEEDS_QUERY : BUNDLES_QUERY, 25 | }); 26 | const currentItems = isFeed ? feeds : bundles; 27 | 28 | store.writeQuery({ 29 | query: isFeed ? FEEDS_QUERY : BUNDLES_QUERY, 30 | data: { 31 | [isFeed ? 'feeds' : 'bundles']: [ 32 | ...currentItems.filter((o) => o.id !== item.id), 33 | item, 34 | ], 35 | }, 36 | }); 37 | } catch (e) {} 38 | }; 39 | 40 | export const updateSavedArticleCache = (action) => (store, { data }) => { 41 | const item = data[`${action}SavedArticle`]; 42 | 43 | try { 44 | store.writeQuery({ 45 | query: SAVED_ARTICLE_QUERY, 46 | variables: { data: { url: _.get(item, 'url') } }, 47 | data: { savedArticle: action === 'delete' ? null : item }, 48 | }); 49 | } catch (e) {} 50 | 51 | try { 52 | const { savedArticles } = store.readQuery({ 53 | query: SAVED_ARTICLES_QUERY, 54 | }); 55 | 56 | store.writeQuery({ 57 | query: SAVED_ARTICLES_QUERY, 58 | data: { 59 | savedArticles: 60 | action === 'delete' 61 | ? savedArticles.filter((o) => o.id !== item.id) 62 | : [...savedArticles, item], 63 | }, 64 | }); 65 | } catch (e) {} 66 | }; 67 | --------------------------------------------------------------------------------