This commit is contained in:
2026-06-24 15:34:09 +02:00
commit 65fb6816ca
442 changed files with 27246 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
'use client'
import { CloseIcon } from '@chakra-ui/icons'
import { Flex, Text } from '@chakra-ui/react'
interface Props {
message: string
}
export const FormErrorMessage: React.FC<Props> = (props) => {
const { message } = props
return (
<Flex alignItems='center' color='red' mt={4} fontSize='0.875rem' >
<CloseIcon color='red' mr={2.5} fontSize='0.725rem'/>
<Text color='red'>{message}</Text>
</Flex>
)
}
@@ -0,0 +1,17 @@
'use client'
import { CheckIcon } from '@chakra-ui/icons'
import { Flex, Text } from '@chakra-ui/react'
interface Props {
message: string
}
export const FormSuccessMessage: React.FC<Props> = (props) => {
const { message } = props
return (
<Flex alignItems='center' color='green-500' mt={4} fontSize='0.875rem'>
<CheckIcon color='green.700' fontSize='0.875rem' mr={2} />
<Text color='green'>{message}</Text>
</Flex>
)
}
+18
View File
@@ -0,0 +1,18 @@
'use client'
import { FormControl, FormErrorMessage, FormLabel, Input } from '@chakra-ui/react'
import { useField } from 'formik'
type Props = React.InputHTMLAttributes<HTMLInputElement> & { name: string, label: string, isTextArea?: boolean }
export const InputField: React.FC<Props> = ({size: _, isTextArea=false, ...props}) => {
const [field, { error }] = useField(props)
const { label, placeholder } = props
return (
<FormControl isInvalid={!!error}>
<FormLabel htmlFor={field.name}>{label}</FormLabel>
<Input {...field} {...props} id={field.name} placeholder={placeholder} />
{error ? <FormErrorMessage>{error}</FormErrorMessage> : null}
</FormControl>
)
}
+60
View File
@@ -0,0 +1,60 @@
'use client'
import { FormErrorMessage, FormSuccessMessage, InputField, Wrapper } from '@/components'
import { MeDocument, ResetPasswordDocument } from '@/generated/graphql/graphql'
import { errorMapper } from '@/utils'
import { useMutation, useQuery } from '@apollo/client/react'
import { Button } from '@chakra-ui/react'
import { Form, Formik } from 'formik'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
interface Props {
token: string
}
export const PasswordResetForm: React.FC<Props> = ({ token }) => {
const router = useRouter()
const [resetPassword] = useMutation(ResetPasswordDocument)
const { refetch } = useQuery(MeDocument)
const [message, setMessage] = useState('')
const [messageType, setMessageType] = useState<'success' | 'error' | ''>('')
return (
<Wrapper variant='small'>
<Formik
initialValues={{ newPassword: '' }}
onSubmit={async ({ newPassword }, { setErrors }) => {
const response = await resetPassword({ variables: { newPassword, token } })
const errors = response.data?.resetPassword.errors
if (errors) {
setErrors(errorMapper(errors))
}
else if (response.data?.resetPassword.message) {
setMessage(response.data.resetPassword.message)
setMessageType(response.data.resetPassword.messageType as any)
if (response.data.resetPassword.messageType == 'success') {
await refetch()
setTimeout(() => { router.push('/login') }, 1500)
}
}
}}
>
{({ isSubmitting }) => (
<Form>
<InputField name='newPassword' label='Enter your new password:' placeholder='password' type='password' />
{
(message && messageType == 'success') &&
<FormSuccessMessage message={message}/>
}
{
(message && messageType == 'error') &&
<FormErrorMessage message={message}/>
}
<Button type='submit' colorScheme='teal' mt={4} isLoading={isSubmitting}>Submit</Button>
</Form>
)}
</Formik>
</Wrapper>
)
}
+105
View File
@@ -0,0 +1,105 @@
'use client'
import { DeletePostDocument, DownvoteDocument, MeDocument, PostDocument, Post as PostType, RemoveDownvoteDocument, RemoveUpvoteDocument, UpvoteDocument } from '@/generated/graphql/graphql'
import { useMutation, useQuery } from '@apollo/client/react'
import { ArrowDownIcon, ArrowUpIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons'
import { Box, Flex, Heading, IconButton, Text } from '@chakra-ui/react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
interface Props {
post: PostType
}
export const Post: React.FC<Props> = ({ post: p }) => {
const { data } = useQuery(MeDocument)
const router = useRouter()
const { refetch } = useQuery(PostDocument, { returnPartialData: false, variables: { id: p.id } })
const [upvote, { loading: upvoting }] = useMutation(UpvoteDocument)
const [downvote, { loading: downvoting }] = useMutation(DownvoteDocument)
const [removeUpvote, { loading: removingUpvote }] = useMutation(RemoveUpvoteDocument)
const [removeDownvote, { loading: removingDownvote }] = useMutation(RemoveDownvoteDocument)
const [deletePost, { loading: deletingPost }] = useMutation(DeletePostDocument)
return (
<Flex key={p.id} p={5} borderWidth='1px'>
<Flex flexDir='column' mr='4' justifyContent='space-between' alignItems='center'>
<IconButton
aria-label='upvote-button'
icon={<ArrowUpIcon />}
color={p.upvoted ? 'green' : ''}
isLoading={upvoting || removingUpvote}
onClick={async () => {
if (!data?.me) {
router.push('/login')
}
if (p.upvoted) {
await removeUpvote({ variables: { postID: p.id } })
// Apollo Client automatically refetches one single post and merge it with the cache of previous posts and updating the result of useQuery(PostsDocument), so we don't need to refetch every post.
await refetch()
}
else {
await upvote({ variables: { postID: p.id } })
await refetch()
}
}}
/>
<Text marginY='2'>{p.points}</Text>
<IconButton
aria-label='downvote-button'
icon={<ArrowDownIcon />}
color={p.downvoted ? 'red' : ''}
isLoading={downvoting || removingDownvote}
onClick={async () => {
if (!data?.me) {
router.push('/login')
}
if (p.downvoted) {
await removeDownvote({ variables: { postID: p.id } })
await refetch()
}
else {
await downvote({ variables: { postID: p.id } })
await refetch()
}
}}
/>
</Flex>
<Box>
<Link href={`/post/${p.id}`}>
<Heading as='h3' size='md'>{p.title}</Heading>
</Link>
<Text>Posted by {p.author.username}</Text>
<Text mt={4}>{p.snippet}</Text>
</Box>
{
p.authorID == data?.me?.id &&
<Flex flexDir='column' ml='auto' justifyContent='space-between' alignItems='center'>
<Link href={`/edit-post/${p.id}`}>
<IconButton
aria-label='edit-button'
icon={<EditIcon />}
/>
</Link>
<IconButton
aria-label='delete-button'
icon={<DeleteIcon />}
colorScheme='red'
isLoading={deletingPost}
onClick={async () => {
await deletePost({
variables: { id: p.id },
// https://stackoverflow.com/questions/63192774/apollo-client-delete-item-from-cache
// https://www.apollographql.com/docs/react/caching/garbage-collection/#cacheevict
update: cache => {
const normalizedId = cache.identify({ id: p.id, __typename: 'Post' })
cache.evict({ id: normalizedId })
cache.gc()
}
})
}}
/>
</Flex>
}
</Flex>
)
}
+10
View File
@@ -0,0 +1,10 @@
'use client'
import { Button, PropsOf } from '@chakra-ui/react'
import { useRouter } from 'next/navigation'
export const RefetchButton: React.FC<PropsOf<typeof Button>> = (props) => {
const router = useRouter()
return (
<Button onClick={() => { router.refresh() }} {...props}>Refetch</Button>
)
}
+36
View File
@@ -0,0 +1,36 @@
'use client'
import { FormControl, FormErrorMessage, FormLabel, Textarea } from '@chakra-ui/react'
import autosize from 'autosize'
import { useField } from 'formik'
import { useRef, useEffect } from 'react'
type Props = React.TextareaHTMLAttributes<HTMLTextAreaElement> & { name: string, label: string }
export const TextareaField: React.FC<Props> = (props) => {
const [field, { error }] = useField(props)
const { label, placeholder } = props
// https://github.com/chakra-ui/chakra-ui/issues/670
const ref: any = useRef(null)
useEffect(() => {
const current = ref.current
autosize(current)
return () => {
autosize.destroy(current)
}
}, [])
return (
<FormControl isInvalid={!!error}>
<FormLabel htmlFor={field.name}>{label}</FormLabel>
<Textarea
{...field}
{...props}
ref={ref}
id={field.name}
placeholder={placeholder}
/>
{error ? <FormErrorMessage>{error}</FormErrorMessage> : null}
</FormControl>
)
}
+14
View File
@@ -0,0 +1,14 @@
import { Box } from '@chakra-ui/react'
interface Props {
children: React.ReactNode
variant?: 'small' | 'regular'
}
export const Wrapper: React.FC<Props> = ({ children, variant = 'regular' }) => {
return (
<Box w='100%' maxW={variant == 'small' ? 400 : 800} mt={8} mx='auto'>
{children}
</Box>
)
}
+8
View File
@@ -0,0 +1,8 @@
export * from './Wrapper'
export * from './InputField'
export * from './PasswordResetForm'
export * from './TextareaField'
export * from './RefetchButton'
export * from './Post'
export * from './FormSuccessMessage'
export * from './FormErrorMessage'