diff --git a/components/Obituary/components/Obituary.tsx b/components/Obituary/components/Obituary.tsx index 218df81..b2dc771 100644 --- a/components/Obituary/components/Obituary.tsx +++ b/components/Obituary/components/Obituary.tsx @@ -1,5 +1,13 @@ import React, { useCallback } from 'react' -import { Box, Divider, Flex, Heading, Text, VStack } from '@chakra-ui/react' +import { + Box, + Button, + Divider, + Flex, + Heading, + Text, + VStack, +} from '@chakra-ui/react' import { Link } from '../../Link' import { useTranslation } from 'next-i18next' import useModal from '../../../contexts/ModalContext' @@ -9,10 +17,14 @@ import { ObituaryRenderer } from '../ObituaryContainer' import { isMultiObituary } from 'lib/domain/isMultiObituary' import { RichText } from 'components/RichText' import { Timestamp } from '../../Timestamp' +import { useUpdateObituary } from 'hooks/reactQuery/mutations' +import { useAdminContext } from 'contexts/AdminContext' const htmlTagsRegexp = /<(?:"[^"]*"['"]*|'[^']*'['"]*|[^'">])+>/g export const Obituary: ObituaryRenderer = (props) => { + const isAdmin = useAdminContext() + const { mutate, isLoading } = useUpdateObituary() const { image = '', _id, @@ -30,6 +42,7 @@ export const Obituary: ObituaryRenderer = (props) => { surname_second, date_of_birth_second, date_of_death_second, + disabled, } = props const { t } = useTranslation() const isClicked = @@ -48,6 +61,10 @@ export const Obituary: ObituaryRenderer = (props) => { }) }, [open, props]) + const handleDisable = () => { + mutate({ _id, disabled: !disabled }) + } + return ( { borderWidth={1} borderStyle="solid" borderRadius="sm" + bg={disabled ? 'gray.100' : 'unset'} _hover={{ boxShadow: `0 20px 25px -5px rgba(${ isClicked ? '222,135,31,0.2' : '0,0,0,0.1' @@ -73,7 +91,7 @@ export const Obituary: ObituaryRenderer = (props) => { > {t(type)} - + { )} {preamble && ( - + {preamble} )} @@ -146,6 +164,7 @@ export const Obituary: ObituaryRenderer = (props) => { className="capitalize" textAlign="justify" fontSize="sm" + opacity={disabled ? 0.5 : 1} sx={{ display: '-webkit-box', WebkitLineClamp: 6, @@ -163,7 +182,20 @@ export const Obituary: ObituaryRenderer = (props) => { )} - + + {isAdmin ? ( + + ) : ( +
+ )} {children} + +export const useAdminContext = () => useContext(AdminContext) diff --git a/hooks/reactQuery/mutations.ts b/hooks/reactQuery/mutations.ts index a6076a5..2ffc19c 100644 --- a/hooks/reactQuery/mutations.ts +++ b/hooks/reactQuery/mutations.ts @@ -5,6 +5,37 @@ import { } from '@tanstack/react-query' import { IObituary } from 'lib/domain/types' +export const useUpdateObituary = () => { + const queryClient = useQueryClient() + return useMutation< + IObituary | null, + unknown, + Pick + >( + ['disableObituary'], + async ({ _id, disabled }) => { + const res = await fetch('/api/obituaries/' + _id, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + disabled, + }), + }) + if (res.ok) { + return res.json() + } + return null + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['obituariesInfinite']) + }, + } + ) +} + export const useIncrementAppreciation = () => { const queryClient = useQueryClient() diff --git a/lib/domain/getObituaries.ts b/lib/domain/getObituaries.ts index 02ef354..d54007f 100644 --- a/lib/domain/getObituaries.ts +++ b/lib/domain/getObituaries.ts @@ -5,7 +5,8 @@ import { IObituary, IObituaryQuery, ObituaryType } from './types' type IGetObituaries = ( db: Db, - query: IObituaryQuery + query: IObituaryQuery, + isAdmin?: boolean ) => Promise<{ data: IObituary[] next: string @@ -13,10 +14,11 @@ type IGetObituaries = ( const withCache = (fn: IGetObituaries): IGetObituaries => async ( db, - { next = '', search = '', category = '', limit = DEFAULT_LIST_LIMIT } + { next = '', search = '', category = '', limit = DEFAULT_LIST_LIMIT }, + isAdmin = false ) => { - if (search === '' || process.env.DISABLE_CACHE === 'true') { - return await fn(db, { next, search, category, limit }) + if (search === '' || isAdmin || process.env.DISABLE_CACHE === 'true') { + return await fn(db, { next, search, category, limit }, isAdmin) } const kvKey = [next, search, category, limit].join('&') const cached = await kv.get>(kvKey) @@ -24,7 +26,7 @@ const withCache = (fn: IGetObituaries): IGetObituaries => async ( return cached } - const res = await fn(db, { next, search, category, limit }) + const res = await fn(db, { next, search, category, limit }, isAdmin) try { await kv.set(kvKey, JSON.stringify(res), { px: 60 * 5 * 1000, @@ -37,7 +39,8 @@ const withCache = (fn: IGetObituaries): IGetObituaries => async ( const getObituaries: IGetObituaries = async ( db, - { next = '', search = '', category = '', limit = DEFAULT_LIST_LIMIT } + { next = '', search = '', category = '', limit = DEFAULT_LIST_LIMIT }, + isAdmin = false ) => { const $regex = new RegExp(search.split(/\s+/).join('|'), 'i') const obituaries: IObituary[] = JSON.parse( @@ -46,11 +49,22 @@ const getObituaries: IGetObituaries = async ( .collection>('obituaries') .find( { - ...(category && { + ...((category || !isAdmin) && { $and: [ - { - type: category as ObituaryType, - }, + ...(category + ? [ + { + type: category as ObituaryType, + }, + ] + : []), + ...(isAdmin + ? [] + : [ + { + disabled: { $ne: true }, + }, + ]), ], }), ...(search && { @@ -82,7 +96,7 @@ const getObituaries: IGetObituaries = async ( }, { limit: limit + 1, - sort: { _id: -1 }, + sort: { _id: 'desc' }, } ) .toArray() diff --git a/lib/domain/types.ts b/lib/domain/types.ts index d3d4809..afabe58 100644 --- a/lib/domain/types.ts +++ b/lib/domain/types.ts @@ -60,6 +60,7 @@ export interface IObituary { is_crawled: boolean appreciations: number symbol_image?: string | StoryblokAsset + disabled?: boolean } export interface ContactFormInput { diff --git a/pages/admin.tsx b/pages/admin.tsx new file mode 100644 index 0000000..d1900f9 --- /dev/null +++ b/pages/admin.tsx @@ -0,0 +1,89 @@ +import cookie from 'cookie' +import { ReactElement, useState } from 'react' +import { GetServerSideProps } from 'next' +import { serverSideTranslations } from 'next-i18next/serverSideTranslations' +import { useTranslation } from 'next-i18next' +import { Flex } from '@chakra-ui/react' +import { useRouter } from 'next/router' +import { ObituaryGrid } from 'components/ObituaryGrid' +import { AdminProvider } from 'contexts/AdminContext' +import { useObituaries } from 'hooks/reactQuery/queries' +import { ProgressBar } from 'components/ProgessBar' +import { Contained } from 'components/Contained/Contained' +import { SearchInput } from 'components/SearchInput' + +interface Props {} + +export default function Admin(): ReactElement { + const { t } = useTranslation() + const { query: routerQuery } = useRouter() + const [query, setQuery] = useState((routerQuery?.search as string) ?? '') + const { + isLoading, + isFetchingNextPage, + hasNextPage, + isFetching, + fetchNextPage, + data, + } = useObituaries({ query }) + + return ( + + + + + + + + page.data)} + hasMore={hasNextPage} + onLoadMore={fetchNextPage} + /> + + ) +} + +export const getServerSideProps: GetServerSideProps = async ({ + locale, + query, + req, + res, +}) => { + if ( + req.cookies?.['admin-session'] === 'true' || + query?.secret === process.env.ADMIN_SECRET + ) { + res.setHeader( + 'Set-Cookie', + cookie.serialize('admin-session', 'true', { + httpOnly: true, + secure: true, + expires: new Date(Date.now() + 1000 * 15 * 60), + path: '/', + sameSite: 'strict', + }) + ) + return { + props: { + ...(await serverSideTranslations(locale, ['common'])), + }, + } + } + return { + notFound: true, + } +} diff --git a/pages/api/logout.ts b/pages/api/logout.ts new file mode 100644 index 0000000..21fe876 --- /dev/null +++ b/pages/api/logout.ts @@ -0,0 +1,34 @@ +import cookie from 'cookie' +import { NextApiRequest, NextApiResponse } from 'next' + +export default async ( + req: NextApiRequest, + res: NextApiResponse +): Promise => { + switch (req.method) { + case 'GET': { + try { + res.setHeader( + 'Set-Cookie', + cookie.serialize('admin-session', '', { + httpOnly: true, + secure: true, + sameSite: 'strict', + path: '/', + expires: new Date(0), + }) + ) + res.status(200).end() + return + } catch (err) { + console.error(err) + res.status(400).end() + return + } + } + default: { + res.status(404).end() + return + } + } +} diff --git a/pages/api/obituaries/[id]/index.ts b/pages/api/obituaries/[id]/index.ts index f635b50..a715d9b 100644 --- a/pages/api/obituaries/[id]/index.ts +++ b/pages/api/obituaries/[id]/index.ts @@ -5,6 +5,12 @@ import { ObjectId } from 'mongodb' const router = attachMiddleware() +type UpdateProperties = 'disabled' + +const updateProperties: Extract[] = [ + 'disabled', +] + router .put( async ( @@ -13,16 +19,36 @@ router ): Promise => { try { const id = req.query.id as string - const obituaries = req.db.collection('obituaries') + const obituaries = req.db.collection>( + 'obituaries' + ) if (!req.body) { res.status(400).end() + return + } + + if ( + !( + typeof req.body === 'object' && + req.body !== null && + Object.keys(req.body).every((key) => + updateProperties.includes(key as UpdateProperties) + ) + ) + ) { + res.status(40).end() + return } + const obituary = await obituaries.findOneAndUpdate( - { _id: id }, - req.body, + { + _id: ObjectId.isValid(id) + ? ObjectId.createFromHexString(id) + : new ObjectId(id), + }, + { $set: req.body }, { returnDocument: 'after' } ) - res.setHeader('Cache-Control', 's-maxage=3600, max-age=3600') return res.status(200).json(obituary) } catch (err) { console.error(err) diff --git a/pages/api/obituaries/index.ts b/pages/api/obituaries/index.ts index 535b93f..d2105c8 100644 --- a/pages/api/obituaries/index.ts +++ b/pages/api/obituaries/index.ts @@ -6,15 +6,22 @@ const router = attachMiddleware() router.get(async (req, res) => { try { + const isAdmin = req.cookies['admin-session'] === 'true' const { data, next } = await getObituaries( req.db, - parseObituaryQuery(req.query) + parseObituaryQuery(req.query), + isAdmin ) - res.setHeader('Cache-Control', 's-maxage=3600, max-age=3600') + if (isAdmin) { + res.setHeader('Cache-Control', 'no-cache') + } else { + res.setHeader('Cache-Control', 's-maxage=3600, max-age=3600') + } res.status(200).json({ data, next, }) + return } catch (err) { console.error(err) res.status(500).end()