Compare commits
20 Commits
Author | SHA1 | Date | |
---|---|---|---|
540c93236a | |||
|
5a4795a66e | ||
65a6ca3e03 | |||
2634f64d1f | |||
53ea63b35f | |||
19fa7fc4e5 | |||
6ef34c6ab8 | |||
85d7e48e0d | |||
c94dab4a9b | |||
eaba1665ce | |||
228ad884bc | |||
743742b757 | |||
|
46ffa05e14 | ||
|
c434429ed5 | ||
6ff944f0ee | |||
|
5be9ed5197 | ||
1060ea4e41 | |||
3f580213fa | |||
6c60520aae | |||
b8450525a5 |
.dockerignore.gitignoreDockerfileLICENSEpackage.jsontsconfig.jsonyarn.lock
public
android-chrome-192x192.pngandroid-chrome-512x512.pngapple-touch-icon.pngbrowserconfig.xmlfavicon-16x16.pngfavicon-32x32.pngfavicon.icoindex.cssindex.htmlmanifest.jsonmstile-150x150.pngsafari-pinned-tab.svgsite.webmanifest
src
apollo
components
context.tshooks.tsindex.csstypes.tsutils.tsviews
1
.dockerignore
Normal file
1
.dockerignore
Normal file
@ -0,0 +1 @@
|
||||
node_modules/
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -13,6 +13,7 @@
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
@ -23,4 +24,6 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
*.local*
|
||||
*.gen*
|
||||
*.gen*
|
||||
|
||||
yarn.lock
|
7
Dockerfile
Normal file
7
Dockerfile
Normal file
@ -0,0 +1,7 @@
|
||||
FROM node:alpine
|
||||
WORKDIR /frontend
|
||||
COPY package.json /frontend/package.json
|
||||
RUN yarn
|
||||
COPY . /frontend
|
||||
RUN yarn codegen
|
||||
RUN yarn build
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Dmitriy Shishkov
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
@ -9,6 +9,7 @@
|
||||
"@types/react-dom": "^16.9.0",
|
||||
"@types/react-router-dom": "^5.1.6",
|
||||
"@types/validator": "^13.1.0",
|
||||
"generate-avatar": "^1.4.6",
|
||||
"graphql": "^15.3.0",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
@ -19,7 +20,8 @@
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"codegen": "graphql-codegen --config codegen.yml"
|
||||
"codegen": "graphql-codegen --config codegen.yml",
|
||||
"serve": "serve -s build"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
@ -38,6 +40,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "^1.17.10",
|
||||
"@graphql-codegen/typescript": "^1.17.10"
|
||||
"@graphql-codegen/typescript": "^1.17.10",
|
||||
"eslint-plugin-react-hooks": "^4.2.0",
|
||||
"serve": "^11.3.2",
|
||||
"typescript-plugin-css-modules": "^2.7.0"
|
||||
}
|
||||
}
|
||||
|
BIN
public/android-chrome-192x192.png
Normal file
BIN
public/android-chrome-192x192.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 16 KiB |
BIN
public/android-chrome-512x512.png
Normal file
BIN
public/android-chrome-512x512.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 35 KiB |
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 10 KiB |
9
public/browserconfig.xml
Normal file
9
public/browserconfig.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/mstile-150x150.png" />
|
||||
<TileColor>#363645</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
BIN
public/favicon-16x16.png
Normal file
BIN
public/favicon-16x16.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 959 B |
BIN
public/favicon-32x32.png
Normal file
BIN
public/favicon-32x32.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 1.8 KiB |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: 48px | Height: 48px | Size: 15 KiB |
45
public/index.css
Normal file
45
public/index.css
Normal file
@ -0,0 +1,45 @@
|
||||
:root {
|
||||
--backgroundColor: rgba(54, 54, 69, 0.05);
|
||||
--containerColor: rgb(54, 54, 69);
|
||||
--accentColor: rgb(109, 245, 119);
|
||||
--accentShadowColor: rgba(109, 245, 119, 0.16);
|
||||
|
||||
--onAccentFontColor: #ffffff;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--backgroundColor);
|
||||
min-height: 100vh;
|
||||
color: var(--containerColor);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
input {
|
||||
color: var(--containerColor);
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100vh;
|
||||
}
|
@ -2,39 +2,43 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="QuestionForm is an open source alternative to Google Forms"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="%PUBLIC_URL%/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="%PUBLIC_URL%/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="%PUBLIC_URL%/favicon-16x16.png"
|
||||
/>
|
||||
<link rel="manifest" href="%PUBLIC_URL%/site.webmanifest" />
|
||||
<link
|
||||
rel="mask-icon"
|
||||
href="%PUBLIC_URL%/safari-pinned-tab.svg"
|
||||
color="#6df577"
|
||||
/>
|
||||
<meta name="msapplication-TileColor" content="#6df577" />
|
||||
<meta name="theme-color" content="#363645" />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/apple-touch-icon.png" />
|
||||
|
||||
<link rel="stylesheet" href="/index.css" />
|
||||
<title>QuestionForm</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
|
@ -8,18 +8,18 @@
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"src": "android-chrome-192x192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"src": "android-chrome-512x512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
"theme_color": "#6df577",
|
||||
"background_color": "#363645"
|
||||
}
|
||||
|
BIN
public/mstile-150x150.png
Normal file
BIN
public/mstile-150x150.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 11 KiB |
34
public/safari-pinned-tab.svg
Normal file
34
public/safari-pinned-tab.svg
Normal file
@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
Created by potrace 1.11, written by Peter Selinger 2001-2013
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M2265 5108 c-44 -5 -134 -20 -200 -33 -514 -104 -948 -336 -1315
|
||||
-705 -370 -371 -602 -803 -705 -1315 -34 -172 -45 -289 -45 -495 0 -206 11
|
||||
-323 45 -495 103 -512 335 -946 705 -1315 356 -357 770 -584 1263 -694 338
|
||||
-76 753 -76 1097 0 261 58 590 191 802 325 164 104 314 225 458 369 357 356
|
||||
584 770 694 1263 56 251 73 597 41 857 -95 768 -529 1449 -1191 1868 -304 192
|
||||
-708 331 -1076 371 -115 13 -459 12 -573 -1z m690 -902 c173 -37 291 -78 450
|
||||
-156 311 -154 568 -407 724 -715 137 -272 201 -616 172 -925 -39 -400 -186
|
||||
-725 -448 -987 l-82 -83 32 -23 c126 -93 243 -139 367 -142 l85 -2 3 -148 3
|
||||
-148 -103 6 c-216 13 -391 84 -559 228 l-56 48 -29 -20 c-61 -42 -245 -129
|
||||
-344 -164 -57 -19 -160 -48 -229 -62 -117 -25 -144 -27 -371 -27 -229 -1 -253
|
||||
1 -371 26 -547 118 -982 448 -1219 925 -230 464 -222 1033 21 1503 226 437
|
||||
648 751 1155 860 163 34 219 39 454 35 195 -3 241 -7 345 -29z"/>
|
||||
<path d="M2420 3939 c-634 -61 -1129 -503 -1251 -1119 -26 -133 -35 -315 -20
|
||||
-436 12 -94 35 -212 44 -220 2 -2 28 4 58 15 80 27 224 59 341 76 136 19 434
|
||||
19 585 0 436 -56 820 -243 1257 -614 l100 -84 77 81 c175 185 296 418 346 670
|
||||
24 122 24 394 -1 517 -56 282 -178 513 -374 709 -238 237 -534 372 -889 406
|
||||
-120 11 -149 11 -273 -1z"/>
|
||||
<path d="M1715 1973 c-105 -8 -202 -27 -289 -54 -53 -16 -96 -32 -96 -35 0
|
||||
-12 36 -73 72 -122 175 -237 424 -415 717 -512 160 -54 277 -72 451 -73 251
|
||||
-1 449 48 648 159 l64 36 -34 29 c-425 370 -784 533 -1248 569 -130 10 -199
|
||||
10 -285 3z"/>
|
||||
</g>
|
||||
</svg>
|
After (image error) Size: 1.9 KiB |
19
public/site.webmanifest
Normal file
19
public/site.webmanifest
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#363645",
|
||||
"background_color": "#363645",
|
||||
"display": "standalone"
|
||||
}
|
@ -32,6 +32,22 @@ const FORM = gql`
|
||||
title
|
||||
}
|
||||
}
|
||||
submissions {
|
||||
answers {
|
||||
... on InputAnswer {
|
||||
type
|
||||
userInput
|
||||
}
|
||||
... on ChoiseAnswer {
|
||||
type
|
||||
userChoise
|
||||
}
|
||||
}
|
||||
date
|
||||
user {
|
||||
name
|
||||
}
|
||||
}
|
||||
title
|
||||
}
|
||||
}
|
||||
@ -41,12 +57,21 @@ const USER = gql`
|
||||
query User {
|
||||
user {
|
||||
email
|
||||
id
|
||||
name
|
||||
forms {
|
||||
id
|
||||
submissions {
|
||||
id
|
||||
}
|
||||
title
|
||||
}
|
||||
name
|
||||
formSubmissions {
|
||||
id
|
||||
form {
|
||||
title
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
@ -67,4 +92,12 @@ const CREATEFORM = gql`
|
||||
}
|
||||
`
|
||||
|
||||
export { LOGIN, FORM, USER, FORMSUBMIT, CREATEFORM }
|
||||
const REGISTER = gql`
|
||||
mutation Register($email: String!, $name: String!) {
|
||||
register(email: $email, name: $name) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export { LOGIN, FORM, USER, FORMSUBMIT, CREATEFORM, REGISTER }
|
||||
|
@ -1,11 +1,17 @@
|
||||
import { ApolloClient, createHttpLink, InMemoryCache } from '@apollo/client'
|
||||
|
||||
import {
|
||||
ApolloClient,
|
||||
createHttpLink,
|
||||
from,
|
||||
InMemoryCache,
|
||||
} from '@apollo/client'
|
||||
import { setContext } from '@apollo/client/link/context'
|
||||
import { onError } from '@apollo/client/link/error'
|
||||
|
||||
export * from './defs'
|
||||
|
||||
const httpLink = createHttpLink({
|
||||
uri: process.env.GRAPHQL_URL || process.env.REACT_APP_GRAPHQL_URL || undefined
|
||||
uri:
|
||||
process.env.GRAPHQL_URL || process.env.REACT_APP_GRAPHQL_URL || undefined,
|
||||
// fetchOptions: {
|
||||
// mode: 'no-cors'
|
||||
// }
|
||||
@ -17,14 +23,25 @@ const authLink = setContext((_, { headers }) => {
|
||||
return {
|
||||
headers: {
|
||||
...headers,
|
||||
authorization: token ? `Bearer ${token}` : ''
|
||||
}
|
||||
authorization: token ? `Bearer ${token}` : '',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const errorLink = onError(({ graphQLErrors, networkError }) => {
|
||||
if (graphQLErrors)
|
||||
graphQLErrors.map(({ message, locations, path }) =>
|
||||
console.log(
|
||||
`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`
|
||||
)
|
||||
)
|
||||
|
||||
if (networkError) console.log(`[Network error]: ${networkError}`)
|
||||
})
|
||||
|
||||
const client = new ApolloClient({
|
||||
link: authLink.concat(httpLink),
|
||||
cache: new InMemoryCache()
|
||||
link: from([errorLink, authLink, httpLink]),
|
||||
cache: new InMemoryCache(),
|
||||
})
|
||||
|
||||
export default client
|
||||
|
@ -15,7 +15,7 @@ type Form {
|
||||
author: User
|
||||
dateCreated: String!
|
||||
id: Int!
|
||||
questions: [Question!]!
|
||||
questions: [Question!]
|
||||
submissions: [FormSubmission!]
|
||||
title: String!
|
||||
}
|
||||
@ -42,9 +42,11 @@ type InputQuestion implements Question {
|
||||
}
|
||||
|
||||
type FormSubmission {
|
||||
user: User
|
||||
answers: [Answer!]!
|
||||
date: String!
|
||||
id: Int!
|
||||
form: Form
|
||||
}
|
||||
|
||||
interface Answer {
|
||||
@ -77,6 +79,7 @@ type User {
|
||||
forms: [Form!]
|
||||
id: Int!
|
||||
name: String!
|
||||
formSubmissions: [FormSubmission!]
|
||||
}
|
||||
|
||||
type serverAnswer {
|
||||
|
@ -3,34 +3,30 @@ import React from 'react'
|
||||
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'
|
||||
|
||||
import client from '../apollo'
|
||||
import Context from '../context'
|
||||
import { useUser } from '../hooks'
|
||||
import Authorize from './Authorize'
|
||||
import CreateForm from './CreateForm'
|
||||
import DoForm from './DoForm'
|
||||
import Login from './Login'
|
||||
import UserPage from './UserPage'
|
||||
import Authorize from '../views/Authorize'
|
||||
import CreateForm from '../views/CreateForm'
|
||||
import DoForm from '../views/DoForm'
|
||||
import Home from '../views/Home'
|
||||
import Login from '../views/Login'
|
||||
import Navbar from './Navbar'
|
||||
import Register from '../views/Register'
|
||||
|
||||
const App: React.FC = () => {
|
||||
const userContext = useUser()
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<ApolloProvider client={client}>
|
||||
<Context.Provider value={userContext}>
|
||||
<Router>
|
||||
<Switch>
|
||||
<Route path="/login" component={Login} />
|
||||
<Route path="/authorize" component={Authorize} />
|
||||
<Route path="/user" component={UserPage} />
|
||||
<Route path="/form/:id" component={DoForm} />
|
||||
<Route path="/create" component={CreateForm} />
|
||||
</Switch>
|
||||
</Router>
|
||||
</Context.Provider>
|
||||
</ApolloProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const App: React.FC = () => (
|
||||
<div className="App">
|
||||
<ApolloProvider client={client}>
|
||||
<Router>
|
||||
<Navbar />
|
||||
<Switch>
|
||||
<Route path="/login" component={Login} />
|
||||
<Route path="/authorize" component={Authorize} />
|
||||
<Route path="/form/:id" component={DoForm} />
|
||||
<Route path="/create" component={CreateForm} />
|
||||
<Route path="/register" component={Register} />
|
||||
<Route exact path="/" component={Home} />
|
||||
</Switch>
|
||||
</Router>
|
||||
</ApolloProvider>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default App
|
||||
|
23
src/components/Card/index.tsx
Normal file
23
src/components/Card/index.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import styles from './main.module.css'
|
||||
|
||||
interface ICardProps {
|
||||
title: string
|
||||
subtitle?: string
|
||||
icon?: React.Component
|
||||
iconCounter?: number
|
||||
id: number
|
||||
}
|
||||
|
||||
const Card: React.FC<ICardProps> = ({ title, subtitle, id }) => {
|
||||
return (
|
||||
<Link to={`/form/${id}`} className={styles.card}>
|
||||
<h3>{title}</h3>
|
||||
{subtitle ?? <h5>{subtitle}</h5>}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default Card
|
12
src/components/Card/main.module.css
Normal file
12
src/components/Card/main.module.css
Normal file
@ -0,0 +1,12 @@
|
||||
.card {
|
||||
display: block;
|
||||
background-color: var(--accentColor);
|
||||
padding: 1rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 1px 6px 0 var(--accentShadowColor);
|
||||
text-decoration: none;
|
||||
color: var(--onAccentFontColor);
|
||||
}
|
@ -1,156 +0,0 @@
|
||||
import { ApolloError, useMutation } from '@apollo/client'
|
||||
import { ChangeEvent, FormEvent, useState } from 'react'
|
||||
import { CREATEFORM } from '../../apollo'
|
||||
import { MutationCreateFormArgs, ServerAnswer } from '../../apollo/typeDefs.gen'
|
||||
|
||||
type FormQuestion<T extends string> = {
|
||||
title: string
|
||||
type: T
|
||||
variants: string[]
|
||||
}
|
||||
|
||||
type Form<T extends string> = {
|
||||
title: string
|
||||
questions: FormQuestion<T>[]
|
||||
}
|
||||
|
||||
export type FormatQuestionsToSubmitType = <T extends string>(
|
||||
questions: FormQuestion<T>[]
|
||||
) => string
|
||||
|
||||
interface ICreateFormMutation {
|
||||
createForm: ServerAnswer
|
||||
}
|
||||
|
||||
type FormSubmitType = (e: FormEvent<HTMLFormElement>) => void
|
||||
|
||||
type HandleFormTitleChangeType = (e: ChangeEvent<HTMLInputElement>) => void
|
||||
|
||||
type CreateQuestionType<T extends string> = (type: T) => void
|
||||
|
||||
type HandleQuestionTitleChangeType = (
|
||||
questionNumber: number,
|
||||
e: ChangeEvent<HTMLInputElement>
|
||||
) => void
|
||||
|
||||
type AddVariantType = (questionNumber: number) => void
|
||||
|
||||
type HandleAnswerVariantChangeType = (
|
||||
questionNumber: number,
|
||||
variantNumber: number,
|
||||
e: ChangeEvent<HTMLInputElement>
|
||||
) => void
|
||||
|
||||
type UseFormCreatorHookTurple<T extends string> = [
|
||||
Form<T>,
|
||||
[
|
||||
FormSubmitType,
|
||||
{
|
||||
submitData: ICreateFormMutation | null | undefined
|
||||
submitError: ApolloError | undefined
|
||||
submitLoading: boolean
|
||||
}
|
||||
],
|
||||
{
|
||||
handleFormTitleChange: HandleFormTitleChangeType
|
||||
addQuestion: CreateQuestionType<T>
|
||||
handleQuestionTitleChange: HandleQuestionTitleChangeType
|
||||
handleAnswerVariantChange: HandleAnswerVariantChangeType
|
||||
addVariant: AddVariantType
|
||||
}
|
||||
]
|
||||
|
||||
export const useFormCreator = <T extends string>(
|
||||
formatQuestionsToSubmit: FormatQuestionsToSubmitType
|
||||
): UseFormCreatorHookTurple<T> => {
|
||||
const [form, setState] = useState<Form<T>>({ title: '', questions: [] })
|
||||
|
||||
const [
|
||||
doFormCreation,
|
||||
{ error: submitError, data: submitData, loading: submitLoading },
|
||||
] = useMutation<ICreateFormMutation, MutationCreateFormArgs>(CREATEFORM, {
|
||||
variables: {
|
||||
title: form.title,
|
||||
questions: formatQuestionsToSubmit<T>(form.questions),
|
||||
},
|
||||
})
|
||||
|
||||
const formSubmit: FormSubmitType = (e) => {
|
||||
e.preventDefault()
|
||||
console.log({
|
||||
title: form.title,
|
||||
questions: formatQuestionsToSubmit<T>(form.questions),
|
||||
})
|
||||
doFormCreation()
|
||||
}
|
||||
|
||||
const handleFormTitleChange: HandleFormTitleChangeType = (e) => {
|
||||
const title = e.currentTarget.value
|
||||
setState((prev) => ({ ...prev, title }))
|
||||
}
|
||||
|
||||
const createQuestion: CreateQuestionType<T> = (type) => {
|
||||
setState(({ title, questions }) => ({
|
||||
title,
|
||||
questions: questions.concat({ title: '', type, variants: [] }),
|
||||
}))
|
||||
}
|
||||
|
||||
const handleQuestionTitleChange: HandleQuestionTitleChangeType = (
|
||||
questionNumber,
|
||||
e
|
||||
) => {
|
||||
const questionTitle = e.currentTarget.value
|
||||
setState(({ title, questions }) => ({
|
||||
title,
|
||||
questions: questions.map((el, index) =>
|
||||
index === questionNumber ? { ...el, title: questionTitle } : el
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
const handleAnswerVariantChange: HandleAnswerVariantChangeType = (
|
||||
questionNumber,
|
||||
variantNumber,
|
||||
e
|
||||
) => {
|
||||
const variantText = e.currentTarget.value
|
||||
setState(({ title, questions }) => ({
|
||||
title,
|
||||
questions: questions.map((question, questionIndex) =>
|
||||
questionIndex === questionNumber
|
||||
? {
|
||||
...question,
|
||||
variants: question.variants.map((variant, variantIndex) =>
|
||||
variantIndex === variantNumber ? variantText : variant
|
||||
),
|
||||
}
|
||||
: question
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
const addVariant: AddVariantType = (questionNumber) => {
|
||||
console.log()
|
||||
setState(({ title, questions }) => ({
|
||||
title,
|
||||
questions: questions.map((el, index) => ({
|
||||
...el,
|
||||
variants:
|
||||
index === questionNumber ? el.variants.concat('') : el.variants,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
return [
|
||||
form,
|
||||
[formSubmit, { submitData, submitError, submitLoading }],
|
||||
{
|
||||
handleFormTitleChange,
|
||||
addQuestion: createQuestion,
|
||||
handleQuestionTitleChange,
|
||||
handleAnswerVariantChange,
|
||||
addVariant,
|
||||
},
|
||||
]
|
||||
}
|
@ -1,129 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
import { FormatQuestionsToSubmitType, useFormCreator } from './hooks'
|
||||
|
||||
const creationsArray = [
|
||||
{ title: 'Check', type: 'CHECK', enabled: false },
|
||||
{ title: 'Input', type: 'INPUT', enabled: true },
|
||||
{ title: 'Choose', type: 'CHOOSE', enabled: true },
|
||||
{ title: 'Select', type: 'SELECT', enabled: true },
|
||||
] as const
|
||||
|
||||
type QuestionTypes = 'CHECK' | 'INPUT' | 'CHOOSE' | 'SELECT'
|
||||
|
||||
const formatQuestionsToSubmit: FormatQuestionsToSubmitType = (questions) => {
|
||||
return JSON.stringify(
|
||||
questions.map((question) =>
|
||||
question.type === 'INPUT'
|
||||
? { title: question.title }
|
||||
: {
|
||||
...question,
|
||||
variants: question.variants.map((variant) => ({
|
||||
text: variant,
|
||||
})),
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const CreateForm: React.FC = () => {
|
||||
const [
|
||||
form,
|
||||
[formSubmit, { submitData, submitError, submitLoading }],
|
||||
{
|
||||
addQuestion,
|
||||
handleFormTitleChange,
|
||||
handleQuestionTitleChange,
|
||||
handleAnswerVariantChange,
|
||||
addVariant,
|
||||
},
|
||||
] = useFormCreator<QuestionTypes>(formatQuestionsToSubmit)
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={formSubmit}>
|
||||
<label>
|
||||
Title:
|
||||
<input
|
||||
type="text"
|
||||
name="Title"
|
||||
value={form.title}
|
||||
onChange={handleFormTitleChange}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<fieldset>
|
||||
<legend>Content</legend>
|
||||
<ul>
|
||||
{creationsArray.flatMap((questionType, index) =>
|
||||
questionType.enabled
|
||||
? [
|
||||
<li key={index}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addQuestion(questionType.type)}
|
||||
>
|
||||
{questionType.title}
|
||||
</button>
|
||||
</li>,
|
||||
]
|
||||
: []
|
||||
)}
|
||||
</ul>
|
||||
<ul>
|
||||
{form.questions.map((quesstion, questionIndex) => (
|
||||
<li key={questionIndex}>
|
||||
<p>{quesstion.type} question:</p>
|
||||
<input
|
||||
type="text"
|
||||
name="questionTitle"
|
||||
placeholder="Title"
|
||||
value={quesstion.title}
|
||||
onChange={(e) => handleQuestionTitleChange(questionIndex, e)}
|
||||
/>
|
||||
{(quesstion.type === 'CHECK' ||
|
||||
quesstion.type === 'CHOOSE' ||
|
||||
quesstion.type === 'SELECT') && (
|
||||
<>
|
||||
<ul>
|
||||
{quesstion.variants.map((variant, variantIndex) => (
|
||||
<li key={variantIndex}>
|
||||
<input
|
||||
placeholder="Variant"
|
||||
type="text"
|
||||
value={variant}
|
||||
onChange={(e) =>
|
||||
handleAnswerVariantChange(
|
||||
questionIndex,
|
||||
variantIndex,
|
||||
e
|
||||
)
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addVariant(questionIndex)}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</fieldset>
|
||||
{submitLoading ? 'Loading...' : <input type="submit" value="Submit" />}
|
||||
</form>
|
||||
{submitData &&
|
||||
submitData.createForm &&
|
||||
submitData.createForm.success &&
|
||||
'Successfully uploaded'}
|
||||
{submitError && submitError.message}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateForm
|
@ -1,28 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { ChoiseAnswer, InputAnswer } from '../../apollo/typeDefs.gen'
|
||||
|
||||
export const useForm = (initialValue?: (InputAnswer | ChoiseAnswer)[]) => {
|
||||
console.log(initialValue, 'Inside hook')
|
||||
|
||||
const [answers, setAnswer] = useState<(InputAnswer | ChoiseAnswer)[]>(
|
||||
initialValue || []
|
||||
)
|
||||
|
||||
const answerChange = (num: number) => {
|
||||
return (value: number | string) => {
|
||||
setAnswer((prev) => {
|
||||
return prev.map((el, index) => {
|
||||
if (index === num) {
|
||||
if (el.__typename === 'ChoiseAnswer' && typeof value === 'number')
|
||||
return { ...el, userChoise: value }
|
||||
if (el.__typename === 'InputAnswer' && typeof value === 'string')
|
||||
return { ...el, userInput: value }
|
||||
}
|
||||
return el
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return [answers, answerChange]
|
||||
}
|
@ -1,171 +0,0 @@
|
||||
import { useMutation, useQuery } from '@apollo/client'
|
||||
import React, { FormEvent, useEffect, useState } from 'react'
|
||||
import { Redirect, useParams } from 'react-router-dom'
|
||||
import { FORM, FORMSUBMIT } from '../../apollo'
|
||||
import {
|
||||
ChoiseAnswer,
|
||||
ChoisesQuestion,
|
||||
Form,
|
||||
InputAnswer,
|
||||
InputQuestion,
|
||||
QueryFormArgs,
|
||||
} from '../../apollo/typeDefs.gen'
|
||||
import Lists from './Lists'
|
||||
|
||||
interface IFormQuery {
|
||||
form: Form
|
||||
}
|
||||
|
||||
const DoForm: React.FC = () => {
|
||||
const { id: idString } = useParams<{ id: string }>()
|
||||
|
||||
const id = parseInt(idString)
|
||||
|
||||
const { data, error, loading } = useQuery<IFormQuery, QueryFormArgs>(FORM, {
|
||||
variables: { id },
|
||||
skip: isNaN(id),
|
||||
})
|
||||
|
||||
const [
|
||||
doFormSubmit,
|
||||
{ error: submitError, data: submitData, loading: submitLoading },
|
||||
] = useMutation(FORMSUBMIT)
|
||||
|
||||
const [answers, setAnswer] = useState<(InputAnswer | ChoiseAnswer)[]>([])
|
||||
|
||||
const getInitialState = (data: IFormQuery) => {
|
||||
if (data && data.form) {
|
||||
return data.form.questions.flatMap<InputAnswer | ChoiseAnswer>(
|
||||
(el: InputQuestion | ChoisesQuestion) => {
|
||||
if (el.__typename === 'ChoisesQuestion')
|
||||
return [
|
||||
{ __typename: 'ChoiseAnswer', type: 'CHOISE', userChoise: -1 },
|
||||
]
|
||||
if (el.__typename === 'InputQuestion')
|
||||
return [{ __typename: 'InputAnswer', type: 'INPUT', userInput: '' }]
|
||||
return []
|
||||
}
|
||||
)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const initialState = getInitialState(data)
|
||||
setAnswer(initialState)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
useEffect(() => console.log(answers), [answers])
|
||||
|
||||
if (isNaN(id)) return <Redirect to="/" />
|
||||
|
||||
if (loading) return <div>Loading...</div>
|
||||
if (error) return <div>{error.message}</div>
|
||||
|
||||
const { form } = data!
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
console.log('Submited form:', answers)
|
||||
|
||||
answers.forEach((el) => {
|
||||
delete el.__typename
|
||||
})
|
||||
|
||||
const submitAnswers = JSON.stringify(answers)
|
||||
|
||||
console.log('Filtered answers: ', submitAnswers)
|
||||
|
||||
doFormSubmit({
|
||||
variables: {
|
||||
formId: id,
|
||||
answers: submitAnswers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const answerChange = (num: number) => {
|
||||
return (value: number | string) => {
|
||||
setAnswer((prev) => {
|
||||
return prev.map((el, index) => {
|
||||
if (index === num) {
|
||||
if (el.__typename === 'ChoiseAnswer' && typeof value === 'number')
|
||||
return { ...el, userChoise: value }
|
||||
if (el.__typename === 'InputAnswer' && typeof value === 'string')
|
||||
return { ...el, userInput: value }
|
||||
}
|
||||
return el
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{form.title}</h1>
|
||||
<p>{form.dateCreated}</p>
|
||||
<h3>{form.author?.name || 'No author'}</h3>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<ul>
|
||||
{form.questions.map((el: InputQuestion | ChoisesQuestion) => {
|
||||
if (el.__typename === 'InputQuestion')
|
||||
return (
|
||||
<li key={el.number}>
|
||||
<label>
|
||||
{el.title}
|
||||
<input
|
||||
onChange={(e) =>
|
||||
answerChange(el.number)(e.currentTarget.value)
|
||||
}
|
||||
type="text"
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
if (el.__typename === 'ChoisesQuestion')
|
||||
return (
|
||||
<li key={el.number}>
|
||||
<label>
|
||||
{el.title}
|
||||
{el.type === 'SELECT' ? (
|
||||
<select
|
||||
onChange={(e) => {
|
||||
const selectValue = el.variants.findIndex(
|
||||
(val) => val.text === e.currentTarget.value
|
||||
)
|
||||
answerChange(el.number)(selectValue)
|
||||
}}
|
||||
name={el.title}
|
||||
>
|
||||
{el.variants.map((option, index) => (
|
||||
<option key={index}>{option.text}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<Lists
|
||||
variants={el.variants}
|
||||
onChange={answerChange(el.number)}
|
||||
name={el.title}
|
||||
type={el.type}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
return <li>Unknown question type</li>
|
||||
})}
|
||||
</ul>
|
||||
{submitLoading ? <p>Uploading...</p> : <input type="submit" />}
|
||||
</form>
|
||||
{submitError && <p>{submitError.message}</p>}
|
||||
{submitData && submitData.formSubmit && submitData.formSubmit.success && (
|
||||
<p>Successfully uploaded</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DoForm
|
@ -1,18 +1,20 @@
|
||||
import React from 'react'
|
||||
|
||||
interface IProps {
|
||||
import styles from './main.module.css'
|
||||
|
||||
interface IListsProps {
|
||||
variants: { text: string }[]
|
||||
name: string
|
||||
type: 'CHECK' | 'CHOOSE'
|
||||
onChange: (num: number) => void
|
||||
}
|
||||
|
||||
const Lists: React.FC<IProps> = ({ variants, name, type, onChange }) => {
|
||||
const Lists: React.FC<IListsProps> = ({ variants, name, type, onChange }) => {
|
||||
const inputType =
|
||||
(type === 'CHECK' && 'check') || (type === 'CHOOSE' && 'radio') || undefined
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.variantsList}>
|
||||
{variants.map((el, index) => (
|
||||
<label key={index}>
|
||||
<input
|
||||
@ -25,6 +27,7 @@ const Lists: React.FC<IProps> = ({ variants, name, type, onChange }) => {
|
||||
type={inputType}
|
||||
name={name}
|
||||
value={el.text}
|
||||
className={styles.selector}
|
||||
/>
|
||||
{el.text}
|
||||
</label>
|
14
src/components/FormLists/main.module.css
Normal file
14
src/components/FormLists/main.module.css
Normal file
@ -0,0 +1,14 @@
|
||||
.selector {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.variantsList {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (orientation: portrait) {
|
||||
.variantsList {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
@ -1,35 +0,0 @@
|
||||
import { useMutation } from '@apollo/client'
|
||||
import React, { ChangeEvent, FormEvent, useState } from 'react'
|
||||
import { Redirect } from 'react-router-dom'
|
||||
|
||||
import { LOGIN } from '../../apollo'
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const [email, setEmail] = useState<string>('')
|
||||
|
||||
const [doLogin, { error, data }] = useMutation(LOGIN)
|
||||
|
||||
const handleFormSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await doLogin({ variables: { email } })
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(e.currentTarget.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleFormSubmit}>
|
||||
<input type="text" onChange={handleInputChange} />
|
||||
<input type="submit" value="Login" />
|
||||
</form>
|
||||
{error && error.message}
|
||||
{data && data.login && data.login.success && <Redirect to="/" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
15
src/components/Navbar/index.tsx
Normal file
15
src/components/Navbar/index.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import styles from './main.module.css'
|
||||
import logo from './logo.svg'
|
||||
|
||||
const Navbar: React.FC = () => (
|
||||
<nav className={styles.nav}>
|
||||
<Link to="/" className={styles.logo}>
|
||||
<img src={logo} alt="" className={styles.logo} />
|
||||
</Link>
|
||||
</nav>
|
||||
)
|
||||
|
||||
export default Navbar
|
73
src/components/Navbar/logo.svg
Normal file
73
src/components/Navbar/logo.svg
Normal file
@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="512"
|
||||
height="512"
|
||||
viewBox="0 0 135.46666 135.46666"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)"
|
||||
sodipodi:docname="logo.svg">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="0.35"
|
||||
inkscape:cx="574.79706"
|
||||
inkscape:cy="560"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="text845"
|
||||
inkscape:document-rotation="0"
|
||||
showgrid="false"
|
||||
units="px"
|
||||
inkscape:window-width="1366"
|
||||
inkscape:window-height="744"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:pagecheckerboard="true"
|
||||
borderlayer="false" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1">
|
||||
<circle
|
||||
style="fill:#6df577;fill-opacity:1;stroke:#000000;stroke-width:0.184432;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path18"
|
||||
cx="67.73333"
|
||||
cy="67.73333"
|
||||
r="67.641113" />
|
||||
<g
|
||||
aria-label="Q"
|
||||
id="text845"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:116.417px;line-height:1.25;font-family:'URW Gothic';-inkscape-font-specification:'URW Gothic, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.264583">
|
||||
<path
|
||||
d="m 112.61208,104.23006 c -0.6985,0.11642 -1.397,0.11642 -1.74625,0.11642 -3.60893,0 -6.86861,-1.28059 -11.176035,-4.423848 9.313355,-7.916356 14.319295,-19.092388 14.319295,-32.363926 0,-25.495323 -19.55806,-44.354877 -45.984719,-44.354877 -26.659494,0 -46.566801,19.092388 -46.566801,44.587711 0,24.796821 20.605809,44.47129 46.450384,44.47129 9.546194,0 18.510303,-2.56117 25.844574,-7.45068 5.937267,5.47159 10.826782,7.33427 18.859552,7.45068 z M 35.07836,85.603341 c 4.65668,-1.862672 8.032773,-2.444757 13.504372,-2.444757 14.668542,0 25.728157,4.540263 38.184776,15.949129 -5.82085,3.608927 -11.525283,5.238767 -18.859554,5.238767 -8.498442,0 -16.414798,-2.67759 -22.817733,-7.567107 -4.65668,-3.49251 -7.799939,-7.101437 -10.011861,-11.176032 z M 31.469433,78.26907 c -0.931336,-4.307429 -1.280587,-6.519352 -1.280587,-9.779028 0,-21.187894 16.298379,-37.369857 37.602691,-37.369857 21.187894,0 37.486273,15.832712 37.486273,36.438521 0,10.244696 -3.84176,18.859554 -11.758116,26.659493 -4.074596,-3.49251 -7.450688,-6.170101 -10.943198,-8.731275 -9.662611,-6.868603 -20.489392,-10.244696 -32.713178,-10.244696 -6.635768,0 -11.758117,0.814919 -18.393885,3.026842 z"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:116.417px;font-family:'URW Gothic';-inkscape-font-specification:'URW Gothic, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ffffff;stroke-width:0.264583"
|
||||
id="path1581" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After (image error) Size: 3.8 KiB |
16
src/components/Navbar/main.module.css
Normal file
16
src/components/Navbar/main.module.css
Normal file
@ -0,0 +1,16 @@
|
||||
.nav {
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
height: 4rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--containerColor);
|
||||
border-bottom-left-radius: 20px;
|
||||
border-bottom-right-radius: 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
height: 2.7rem;
|
||||
width: 2.7rem;
|
||||
}
|
33
src/components/QuestionsForm/hooks.ts
Normal file
33
src/components/QuestionsForm/hooks.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { AnswerT, QuestionT } from '../../types'
|
||||
import { getInitialState } from './utils'
|
||||
|
||||
export const useForm = () => {
|
||||
const [answers, setAnswers] = useState<AnswerT[]>([])
|
||||
|
||||
const changeAnswer = useCallback(
|
||||
(num: number) => (value: number | string) =>
|
||||
setAnswers((prev) =>
|
||||
prev.map((el, index) => {
|
||||
if (index === num) {
|
||||
if (el.__typename === 'ChoiseAnswer' && typeof value === 'number')
|
||||
return { ...el, userChoise: value }
|
||||
if (el.__typename === 'InputAnswer' && typeof value === 'string')
|
||||
return { ...el, userInput: value }
|
||||
}
|
||||
return el
|
||||
})
|
||||
),
|
||||
[setAnswers]
|
||||
)
|
||||
|
||||
const setInitialState = useCallback(
|
||||
(questions: QuestionT[]) => {
|
||||
const state = getInitialState(questions)
|
||||
setAnswers(state)
|
||||
},
|
||||
[setAnswers]
|
||||
)
|
||||
|
||||
return { answers, changeAnswer, setInitialState }
|
||||
}
|
130
src/components/QuestionsForm/index.tsx
Normal file
130
src/components/QuestionsForm/index.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import React, { FormEvent, useEffect } from 'react'
|
||||
import { useMutation } from '@apollo/client'
|
||||
|
||||
import { MutationFormSubmitArgs } from '../../apollo/typeDefs.gen'
|
||||
import { FORMSUBMIT } from '../../apollo'
|
||||
import Lists from '../FormLists'
|
||||
import { useForm } from './hooks'
|
||||
import { QuestionT } from '../../types'
|
||||
import { RefetchQuestionsFT, IFormSubmitMutation } from './types'
|
||||
import styles from './main.module.css'
|
||||
|
||||
interface IQuestionsFormProps {
|
||||
formId: number
|
||||
questions: QuestionT[]
|
||||
refetchQuestions: RefetchQuestionsFT
|
||||
}
|
||||
|
||||
const QuestionsForm: React.FC<IQuestionsFormProps> = ({
|
||||
formId,
|
||||
questions,
|
||||
refetchQuestions,
|
||||
}) => {
|
||||
const [
|
||||
doFormSubmit,
|
||||
{ error: submitError, data: submitData, loading: submitLoading },
|
||||
] = useMutation<IFormSubmitMutation, MutationFormSubmitArgs>(FORMSUBMIT)
|
||||
|
||||
const {
|
||||
answers,
|
||||
changeAnswer,
|
||||
setInitialState: setInitialFromState,
|
||||
} = useForm()
|
||||
|
||||
useEffect(() => setInitialFromState(questions), [
|
||||
questions,
|
||||
setInitialFromState,
|
||||
])
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
answers.forEach((el) => {
|
||||
delete el.__typename
|
||||
})
|
||||
|
||||
try {
|
||||
const submitAnswers = JSON.stringify(answers)
|
||||
|
||||
await doFormSubmit({
|
||||
variables: {
|
||||
formId,
|
||||
answers: submitAnswers,
|
||||
},
|
||||
})
|
||||
await refetchQuestions()
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<ul>
|
||||
{questions.map((el: QuestionT) => {
|
||||
if (el.__typename === 'InputQuestion')
|
||||
return (
|
||||
<li key={el.number} className={styles.question}>
|
||||
<label
|
||||
className={styles.questionTitle}
|
||||
htmlFor={el.title.replace(' ', '_')}
|
||||
>
|
||||
{el.title}
|
||||
</label>
|
||||
<input
|
||||
className={styles.textInput}
|
||||
placeholder="Input"
|
||||
name={el.title.replace(' ', '_')}
|
||||
onChange={(e) =>
|
||||
changeAnswer(el.number)(e.currentTarget.value)
|
||||
}
|
||||
type="text"
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
if (el.__typename === 'ChoisesQuestion')
|
||||
return (
|
||||
<li key={el.number} className={styles.question}>
|
||||
<label className={styles.questionTitle} htmlFor={el.title}>
|
||||
{el.title}
|
||||
</label>
|
||||
{el.type === 'SELECT' ? (
|
||||
<select
|
||||
className={styles.select}
|
||||
onChange={(e) => {
|
||||
const selectValue = el.variants.findIndex(
|
||||
(val) => val.text === e.currentTarget.value
|
||||
)
|
||||
changeAnswer(el.number)(selectValue)
|
||||
}}
|
||||
name={el.title.replace(' ', '_')}
|
||||
>
|
||||
{el.variants.map((option, index) => (
|
||||
<option key={index}>{option.text}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<Lists
|
||||
variants={el.variants}
|
||||
onChange={changeAnswer(el.number)}
|
||||
name={el.title.replace(' ', '_')}
|
||||
type={el.type}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
return <li className={styles.question}>Unknown question type</li>
|
||||
})}
|
||||
</ul>
|
||||
{submitLoading ? (
|
||||
<p>Uploading...</p>
|
||||
) : (
|
||||
<input className={styles.button} type="submit" />
|
||||
)}
|
||||
</form>
|
||||
{submitError && <p>{submitError.message}</p>}
|
||||
{submitData?.formSubmit.success && <p>Successfully uploaded</p>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuestionsForm
|
53
src/components/QuestionsForm/main.module.css
Normal file
53
src/components/QuestionsForm/main.module.css
Normal file
@ -0,0 +1,53 @@
|
||||
.question {
|
||||
list-style: none;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.questionTitle {
|
||||
padding-bottom: 0.5rem;
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.textInput {
|
||||
height: 2.3rem;
|
||||
border-radius: 100vh;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 1.2rem;
|
||||
padding: 0 0.7rem;
|
||||
width: 100%;
|
||||
border-bottom: 0.15rem var(--containerColor) solid;
|
||||
transition: border 0.1s;
|
||||
}
|
||||
|
||||
.textInput:focus {
|
||||
border-bottom-width: 0rem;
|
||||
border-top: 0.15rem var(--containerColor) solid;
|
||||
}
|
||||
|
||||
.select {
|
||||
padding: 0.5rem;
|
||||
background: var(--accentColor);
|
||||
border-radius: 20px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.button {
|
||||
height: 2.3rem;
|
||||
border-radius: 100vh;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 1.2rem;
|
||||
padding: 0 0.7rem;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
background-color: var(--accentColor);
|
||||
color: var(--onAccentFontColor);
|
||||
box-shadow: 0 1px 6px 0 var(--accentShadowColor);
|
||||
}
|
||||
|
||||
.button:active {
|
||||
box-shadow: none;
|
||||
}
|
14
src/components/QuestionsForm/types.ts
Normal file
14
src/components/QuestionsForm/types.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { ApolloQueryResult } from '@apollo/client'
|
||||
|
||||
import { IFormQuery, AnswerT, QuestionT } from '../../types'
|
||||
import { QueryFormArgs, ServerAnswer } from '../../apollo/typeDefs.gen'
|
||||
|
||||
export type RefetchQuestionsFT = (
|
||||
variables?: Partial<QueryFormArgs> | undefined
|
||||
) => Promise<ApolloQueryResult<IFormQuery>>
|
||||
|
||||
export interface IFormSubmitMutation {
|
||||
formSubmit: ServerAnswer
|
||||
}
|
||||
|
||||
export type GetInitialStateFT = (questions: QuestionT[]) => AnswerT[]
|
11
src/components/QuestionsForm/utils.ts
Normal file
11
src/components/QuestionsForm/utils.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { AnswerT } from '../../types'
|
||||
import { GetInitialStateFT } from './types'
|
||||
|
||||
export const getInitialState: GetInitialStateFT = (questions) =>
|
||||
questions.flatMap<AnswerT>((el) => {
|
||||
if (el.__typename === 'ChoisesQuestion')
|
||||
return [{ __typename: 'ChoiseAnswer', type: 'CHOISE', userChoise: -1 }]
|
||||
if (el.__typename === 'InputQuestion')
|
||||
return [{ __typename: 'InputAnswer', type: 'INPUT', userInput: '' }]
|
||||
return []
|
||||
})
|
25
src/components/SubmissionsList/empty.svg
Normal file
25
src/components/SubmissionsList/empty.svg
Normal file
@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Generator: Adobe Illustrator 18.1.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 32.055 32.055" xml:space="preserve">
|
||||
<g>
|
||||
<path style="fill:#363645" d="M3.968,12.061C1.775,12.061,0,13.835,0,16.027c0,2.192,1.773,3.967,3.968,3.967c2.189,0,3.966-1.772,3.966-3.967
|
||||
C7.934,13.835,6.157,12.061,3.968,12.061z M16.233,12.061c-2.188,0-3.968,1.773-3.968,3.965c0,2.192,1.778,3.967,3.968,3.967
|
||||
s3.97-1.772,3.97-3.967C20.201,13.835,18.423,12.061,16.233,12.061z M28.09,12.061c-2.192,0-3.969,1.774-3.969,3.967
|
||||
c0,2.19,1.774,3.965,3.969,3.965c2.188,0,3.965-1.772,3.965-3.965S30.278,12.061,28.09,12.061z" />
|
||||
</g>
|
||||
<g></g>
|
||||
<g></g>
|
||||
<g></g>
|
||||
<g></g>
|
||||
<g></g>
|
||||
<g></g>
|
||||
<g></g>
|
||||
<g></g>
|
||||
<g></g>
|
||||
<g></g>
|
||||
<g></g>
|
||||
<g></g>
|
||||
<g></g>
|
||||
<g></g>
|
||||
<g></g>
|
||||
</svg>
|
After (image error) Size: 969 B |
68
src/components/SubmissionsList/index.tsx
Normal file
68
src/components/SubmissionsList/index.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import React from 'react'
|
||||
|
||||
import {
|
||||
FormSubmission,
|
||||
InputAnswer,
|
||||
ChoiseAnswer,
|
||||
ChoisesQuestion,
|
||||
Question,
|
||||
} from '../../apollo/typeDefs.gen'
|
||||
import emptyIcon from './empty.svg'
|
||||
import styles from './main.module.css'
|
||||
import { getDateCreated } from '../../utils'
|
||||
|
||||
interface ISubmissionListProps {
|
||||
submissions: FormSubmission[]
|
||||
questions: Question[]
|
||||
}
|
||||
|
||||
const SubmissionList: React.FC<ISubmissionListProps> = ({
|
||||
submissions,
|
||||
questions,
|
||||
}) => {
|
||||
return submissions.length > 0 ? (
|
||||
<ul className={styles.container}>
|
||||
{submissions.map((submission, submissionIndex) => (
|
||||
<li className={styles.listItem} key={submissionIndex}>
|
||||
<h2 className={styles.itemHeader}>
|
||||
{`User ${
|
||||
submission.user ? submission.user.name : 'No submitter'
|
||||
} submitted on ${getDateCreated(submission.date)}:`}
|
||||
</h2>
|
||||
<ul>
|
||||
{submission.answers.map(
|
||||
(answer: InputAnswer | ChoiseAnswer, answerIndex) => (
|
||||
<li key={answerIndex}>
|
||||
<div className={styles.question}>
|
||||
<h3>{questions[answerIndex].title}</h3>
|
||||
{answer.__typename === 'ChoiseAnswer' && (
|
||||
<p>
|
||||
{
|
||||
(questions[answerIndex] as ChoisesQuestion).variants[
|
||||
answer.userChoise
|
||||
].text
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
{answer.__typename === 'InputAnswer' && (
|
||||
<p>{answer.userInput}</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className={styles.emptyContainer}>
|
||||
<div className={styles.emptyIconContainer}>
|
||||
<img className={styles.emptyIcon} src={emptyIcon} alt="Empty" />
|
||||
</div>
|
||||
<h3>No submissions yet :(</h3>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubmissionList
|
48
src/components/SubmissionsList/main.module.css
Normal file
48
src/components/SubmissionsList/main.module.css
Normal file
@ -0,0 +1,48 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2.3rem;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
list-style-type: none;
|
||||
border: 0.1rem var(--containerColor) solid;
|
||||
border-radius: 20px;
|
||||
padding: 2.3rem;
|
||||
}
|
||||
|
||||
.itemHeader {
|
||||
text-decoration: underline wavy;
|
||||
text-underline-offset: 0.5rem;
|
||||
padding-bottom: 2.3rem;
|
||||
}
|
||||
|
||||
.question {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.emptyContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.emptyIconContainer {
|
||||
height: 3.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
height: 7rem;
|
||||
margin-top: -1.75rem;
|
||||
}
|
||||
|
||||
@media (orientation: portrait) {
|
||||
.question {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
@ -1,41 +0,0 @@
|
||||
import { useQuery } from '@apollo/client'
|
||||
import React from 'react'
|
||||
import { USER } from '../../apollo'
|
||||
import { User } from '../../apollo/typeDefs.gen'
|
||||
|
||||
interface UserQuery {
|
||||
user: User
|
||||
}
|
||||
|
||||
const UserPage: React.FC = () => {
|
||||
const { data, error, loading } = useQuery<UserQuery>(USER)
|
||||
if (loading) return <p>Loading...</p>
|
||||
|
||||
if (error) return <p>{error.message}</p>
|
||||
|
||||
const { user } = data!
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Username: {user.name}</h1>
|
||||
<h3>Email: {user.email}</h3>
|
||||
<p>User ID: {user.id}</p>
|
||||
{user.forms && (
|
||||
<>
|
||||
<h2>Forms list</h2>
|
||||
<ul>
|
||||
{user.forms.map((form, index) => (
|
||||
<li key={index}>
|
||||
<a href={`http://localhost:3000/form/${form.id}`}>
|
||||
{form.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserPage
|
@ -1,27 +0,0 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
export type UserType = {
|
||||
id: number
|
||||
email: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type ContextType = {
|
||||
user: UserType
|
||||
setUser: (user: UserType) => void
|
||||
}
|
||||
|
||||
export const initialUser: UserType = {
|
||||
email: '',
|
||||
id: 0,
|
||||
name: ''
|
||||
}
|
||||
|
||||
const initialState: ContextType = {
|
||||
user: initialUser,
|
||||
setUser: () => {}
|
||||
}
|
||||
|
||||
const Context = createContext<ContextType>(initialState)
|
||||
|
||||
export default Context
|
12
src/hooks.ts
12
src/hooks.ts
@ -1,16 +1,4 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { ContextType, initialUser, UserType } from './context'
|
||||
|
||||
export const useUser = (): ContextType => {
|
||||
const [user, internalSerUser] = useState<UserType>(initialUser)
|
||||
|
||||
const setUser = useCallback((user: UserType): void => {
|
||||
internalSerUser(user)
|
||||
}, [])
|
||||
|
||||
return { user, setUser }
|
||||
}
|
||||
|
||||
export const useURLQuery = () => {
|
||||
return new URLSearchParams(useLocation().search)
|
||||
|
@ -1,13 +1,3 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
.App {
|
||||
height: 100vh;
|
||||
}
|
||||
|
22
src/types.ts
Normal file
22
src/types.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import {
|
||||
InputQuestion,
|
||||
ChoisesQuestion,
|
||||
InputAnswer,
|
||||
ChoiseAnswer,
|
||||
Form,
|
||||
User,
|
||||
} from './apollo/typeDefs.gen'
|
||||
|
||||
export type QuestionT = InputQuestion | ChoisesQuestion
|
||||
|
||||
export type AnswerT = InputAnswer | ChoiseAnswer
|
||||
|
||||
export interface IFormQuery {
|
||||
form: Form
|
||||
}
|
||||
|
||||
export type GetDateCreatedFT = (dateString: string) => string
|
||||
|
||||
export interface IUserQuery {
|
||||
user: User
|
||||
}
|
7
src/utils.ts
Normal file
7
src/utils.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { GetDateCreatedFT } from './types'
|
||||
|
||||
export const getDateCreated: GetDateCreatedFT = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
|
||||
return `${date.getMonth()}/${date.getDate()}/${date.getFullYear()}`
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import { Redirect } from 'react-router-dom'
|
||||
|
||||
import { useURLQuery } from '../../hooks'
|
||||
|
||||
const Authorize: React.FC = () => {
|
125
src/views/CreateForm/hooks.ts
Normal file
125
src/views/CreateForm/hooks.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import { useMutation } from '@apollo/client'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { CREATEFORM } from '../../apollo'
|
||||
import { MutationCreateFormArgs } from '../../apollo/typeDefs.gen'
|
||||
import {
|
||||
FormatQuestionsToSubmitFT,
|
||||
UseFormCreatorHookTurpleT,
|
||||
FormT,
|
||||
ICreateFormMutation,
|
||||
FormSubmitT,
|
||||
HandleFormTitleChangeFT,
|
||||
CreateQuestionFT,
|
||||
HandleQuestionTitleChangeFT,
|
||||
HandleAnswerVariantChangeFT,
|
||||
AddVariantFT,
|
||||
RemoveQuestionFT,
|
||||
} from './types'
|
||||
|
||||
const initialState = { title: '', questions: [] }
|
||||
|
||||
export const useFormCreator = <T extends string>(
|
||||
formatQuestionsToSubmit: FormatQuestionsToSubmitFT
|
||||
): UseFormCreatorHookTurpleT<T> => {
|
||||
const [form, setState] = useState<FormT<T>>(initialState)
|
||||
|
||||
const [
|
||||
doFormCreation,
|
||||
{ error: submitError, data: submitData, loading: submitLoading },
|
||||
] = useMutation<ICreateFormMutation, MutationCreateFormArgs>(CREATEFORM, {
|
||||
variables: {
|
||||
title: form.title,
|
||||
questions: formatQuestionsToSubmit<T>(form.questions),
|
||||
},
|
||||
})
|
||||
|
||||
const formSubmit: FormSubmitT = async (e) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await doFormCreation()
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
const handleFormTitleChange: HandleFormTitleChangeFT = (e) => {
|
||||
const title = e.currentTarget.value
|
||||
setState((prev) => ({ ...prev, title }))
|
||||
}
|
||||
|
||||
const createQuestion: CreateQuestionFT<T> = (type) => {
|
||||
setState(({ title, questions }) => ({
|
||||
title,
|
||||
questions: questions.concat({ title: '', type, variants: [''] }),
|
||||
}))
|
||||
}
|
||||
|
||||
const removeQuestion: RemoveQuestionFT = (number) => {
|
||||
setState(({ title, questions }) => ({
|
||||
title,
|
||||
questions: questions.filter((_, index) => index !== number),
|
||||
}))
|
||||
}
|
||||
|
||||
const handleQuestionTitleChange: HandleQuestionTitleChangeFT = (
|
||||
questionNumber,
|
||||
e
|
||||
) => {
|
||||
const questionTitle = e.currentTarget.value
|
||||
setState(({ title, questions }) => ({
|
||||
title,
|
||||
questions: questions.map((el, index) =>
|
||||
index === questionNumber ? { ...el, title: questionTitle } : el
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
const handleAnswerVariantChange: HandleAnswerVariantChangeFT = (
|
||||
questionNumber,
|
||||
variantNumber,
|
||||
e
|
||||
) => {
|
||||
const variantText = e.currentTarget.value
|
||||
setState(({ title, questions }) => ({
|
||||
title,
|
||||
questions: questions.map((question, questionIndex) =>
|
||||
questionIndex === questionNumber
|
||||
? {
|
||||
...question,
|
||||
variants: question.variants.map((variant, variantIndex) =>
|
||||
variantIndex === variantNumber ? variantText : variant
|
||||
),
|
||||
}
|
||||
: question
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
const addVariant: AddVariantFT = (questionNumber) => {
|
||||
setState(({ title, questions }) => ({
|
||||
title,
|
||||
questions: questions.map((el, index) => ({
|
||||
...el,
|
||||
variants:
|
||||
index === questionNumber ? el.variants.concat('') : el.variants,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setState(initialState)
|
||||
}
|
||||
|
||||
return [
|
||||
form,
|
||||
[formSubmit, { submitData, submitError, submitLoading }],
|
||||
{
|
||||
handleFormTitleChange,
|
||||
addQuestion: createQuestion,
|
||||
removeQuestion,
|
||||
handleQuestionTitleChange,
|
||||
handleAnswerVariantChange,
|
||||
addVariant,
|
||||
},
|
||||
resetForm,
|
||||
]
|
||||
}
|
154
src/views/CreateForm/index.tsx
Normal file
154
src/views/CreateForm/index.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
import React from 'react'
|
||||
|
||||
import { QuestionTypes } from './types'
|
||||
import { useFormCreator } from './hooks'
|
||||
import { creationsArray, formatQuestionsToSubmit } from './utils'
|
||||
import styles from './main.module.css'
|
||||
|
||||
const CreateForm: React.FC = () => {
|
||||
const [
|
||||
form,
|
||||
[formSubmit, { submitData, submitError, submitLoading }],
|
||||
{
|
||||
addQuestion,
|
||||
removeQuestion,
|
||||
handleFormTitleChange,
|
||||
handleQuestionTitleChange,
|
||||
handleAnswerVariantChange,
|
||||
addVariant,
|
||||
},
|
||||
resetForm,
|
||||
] = useFormCreator<QuestionTypes>(formatQuestionsToSubmit)
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
formSubmit(e)
|
||||
resetForm()
|
||||
}}
|
||||
>
|
||||
<div className={styles.pageHeader}>
|
||||
<h1 className={styles.pageTitle}>Create form</h1>
|
||||
<input
|
||||
className={styles.textInput}
|
||||
type="text"
|
||||
name="Title"
|
||||
placeholder="title"
|
||||
value={form.title}
|
||||
onChange={handleFormTitleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.mainFormContainer}>
|
||||
<fieldset className={styles.mainForm}>
|
||||
<div className={styles.mainFormTop} />
|
||||
<legend className={styles.fieldsetTitle}>Content</legend>
|
||||
<ul>
|
||||
{form.questions.map((quesstion, questionIndex) => (
|
||||
<li className={styles.questionToCreateLI} key={questionIndex}>
|
||||
<div className={styles.questionHeader}>
|
||||
<h3 className={styles.questionTitle}>
|
||||
{quesstion.type} question:
|
||||
</h3>
|
||||
<input
|
||||
className={[styles.textInput, styles.fullWidth].join(' ')}
|
||||
required
|
||||
type="text"
|
||||
name="questionTitle"
|
||||
placeholder="Title"
|
||||
value={quesstion.title}
|
||||
onChange={(e) =>
|
||||
handleQuestionTitleChange(questionIndex, e)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
className={styles.button}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
removeQuestion(questionIndex)
|
||||
}}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
{(quesstion.type === 'CHECK' ||
|
||||
quesstion.type === 'CHOOSE' ||
|
||||
quesstion.type === 'SELECT') && (
|
||||
<>
|
||||
<h4 className={styles.variantsHeader}>Variants</h4>
|
||||
<ul>
|
||||
{quesstion.variants.map((variant, variantIndex) => (
|
||||
<li key={variantIndex}>
|
||||
<input
|
||||
className={[
|
||||
styles.textInput,
|
||||
styles.fullWidth,
|
||||
styles.variantText,
|
||||
].join(' ')}
|
||||
required
|
||||
placeholder="Variant"
|
||||
type="text"
|
||||
value={variant}
|
||||
onChange={(e) =>
|
||||
handleAnswerVariantChange(
|
||||
questionIndex,
|
||||
variantIndex,
|
||||
e
|
||||
)
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addVariant(questionIndex)}
|
||||
className={[styles.button, styles.addVariant].join(' ')}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ul>
|
||||
{creationsArray.flatMap((questionType, index) =>
|
||||
questionType.enabled
|
||||
? [
|
||||
<li className={styles.questionCreatorLI} key={index}>
|
||||
<button
|
||||
className={styles.button}
|
||||
type="button"
|
||||
onClick={() => addQuestion(questionType.type)}
|
||||
>
|
||||
+ {questionType.title}
|
||||
</button>
|
||||
</li>,
|
||||
]
|
||||
: []
|
||||
)}
|
||||
</ul>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div className={styles.submitContainer}>
|
||||
{submitLoading ? (
|
||||
'Loading...'
|
||||
) : (
|
||||
<input
|
||||
className={[styles.button, styles.submitButton].join(' ')}
|
||||
type="submit"
|
||||
value="Submit"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
{submitData?.createForm.success && 'Successfully uploaded'}
|
||||
{submitError && submitError.message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateForm
|
150
src/views/CreateForm/main.module.css
Normal file
150
src/views/CreateForm/main.module.css
Normal file
@ -0,0 +1,150 @@
|
||||
.container {
|
||||
height: calc(100vh - 4rem);
|
||||
}
|
||||
|
||||
.container form {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 2.3rem;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
margin-top: -0.3rem;
|
||||
}
|
||||
|
||||
.textInput {
|
||||
height: 2.3rem;
|
||||
border-radius: 100vh;
|
||||
border: none;
|
||||
display: inline;
|
||||
outline: none;
|
||||
font-size: 1.2rem;
|
||||
padding: 0 0.7rem;
|
||||
border-bottom: 0.15rem var(--containerColor) solid;
|
||||
transition: border 0.1s;
|
||||
}
|
||||
|
||||
.textInput:focus {
|
||||
border-bottom-width: 0rem;
|
||||
border-top: 0.15rem var(--containerColor) solid;
|
||||
}
|
||||
|
||||
.mainFormContainer {
|
||||
margin-top: 2.3rem;
|
||||
padding: 2.3rem;
|
||||
padding-top: 0;
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.mainFormTop {
|
||||
top: -2.3rem;
|
||||
left: 0rem;
|
||||
position: absolute;
|
||||
height: 2.3rem;
|
||||
width: 100%;
|
||||
border-top-left-radius: 20px;
|
||||
border-top-right-radius: 20px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.mainForm {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.fieldsetTitle {
|
||||
font-size: 1.4rem;
|
||||
font-weight: bold;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.questionCreatorLI {
|
||||
list-style: none;
|
||||
display: inline;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
height: 2.3rem;
|
||||
border-radius: 100vh;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 1.2rem;
|
||||
padding: 0 0.7rem;
|
||||
cursor: pointer;
|
||||
background-color: var(--accentColor);
|
||||
color: var(--onAccentFontColor);
|
||||
box-shadow: 0 1px 6px 0 var(--accentShadowColor);
|
||||
}
|
||||
|
||||
.button:active {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.questionToCreateLI {
|
||||
margin-bottom: 2.3rem;
|
||||
list-style: none;
|
||||
border: 0.1rem var(--containerColor) solid;
|
||||
border-radius: 20px;
|
||||
padding: 2.3rem;
|
||||
}
|
||||
|
||||
.questionHeader {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr 2.3rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.questionTitle {
|
||||
line-height: 2.3rem;
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.variantsHeader {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.variantText {
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
.addVariant {
|
||||
width: 2.3rem;
|
||||
margin: auto;
|
||||
display: block;
|
||||
line-height: 2.3rem;
|
||||
}
|
||||
|
||||
.submitContainer {
|
||||
padding: 2.3rem;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (orientation: portrait) {
|
||||
.pageHeader {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.questionHeader {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
67
src/views/CreateForm/types.ts
Normal file
67
src/views/CreateForm/types.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { FormEvent, ChangeEvent } from 'react'
|
||||
import { ApolloError } from '@apollo/client'
|
||||
|
||||
import { ServerAnswer } from '../../apollo/typeDefs.gen'
|
||||
|
||||
export type FormQuestionT<T extends string> = {
|
||||
title: string
|
||||
type: T
|
||||
variants: string[]
|
||||
}
|
||||
|
||||
export type FormT<T extends string> = {
|
||||
title: string
|
||||
questions: FormQuestionT<T>[]
|
||||
}
|
||||
|
||||
export type FormatQuestionsToSubmitFT = <T extends string>(
|
||||
questions: FormQuestionT<T>[]
|
||||
) => string
|
||||
|
||||
export interface ICreateFormMutation {
|
||||
createForm: ServerAnswer
|
||||
}
|
||||
|
||||
export type FormSubmitT = (e: FormEvent<HTMLFormElement>) => void
|
||||
|
||||
export type HandleFormTitleChangeFT = (e: ChangeEvent<HTMLInputElement>) => void
|
||||
|
||||
export type CreateQuestionFT<T extends string> = (type: T) => void
|
||||
|
||||
export type RemoveQuestionFT = (number: number) => void
|
||||
|
||||
export type HandleQuestionTitleChangeFT = (
|
||||
questionNumber: number,
|
||||
e: ChangeEvent<HTMLInputElement>
|
||||
) => void
|
||||
|
||||
export type AddVariantFT = (questionNumber: number) => void
|
||||
|
||||
export type HandleAnswerVariantChangeFT = (
|
||||
questionNumber: number,
|
||||
variantNumber: number,
|
||||
e: ChangeEvent<HTMLInputElement>
|
||||
) => void
|
||||
|
||||
export type UseFormCreatorHookTurpleT<T extends string> = [
|
||||
FormT<T>,
|
||||
[
|
||||
FormSubmitT,
|
||||
{
|
||||
submitData: ICreateFormMutation | null | undefined
|
||||
submitError: ApolloError | undefined
|
||||
submitLoading: boolean
|
||||
}
|
||||
],
|
||||
{
|
||||
handleFormTitleChange: HandleFormTitleChangeFT
|
||||
addQuestion: CreateQuestionFT<T>
|
||||
removeQuestion: RemoveQuestionFT
|
||||
handleQuestionTitleChange: HandleQuestionTitleChangeFT
|
||||
handleAnswerVariantChange: HandleAnswerVariantChangeFT
|
||||
addVariant: AddVariantFT
|
||||
},
|
||||
() => void
|
||||
]
|
||||
|
||||
export type QuestionTypes = 'CHECK' | 'INPUT' | 'CHOOSE' | 'SELECT'
|
22
src/views/CreateForm/utils.ts
Normal file
22
src/views/CreateForm/utils.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { FormatQuestionsToSubmitFT } from './types'
|
||||
|
||||
export const creationsArray = [
|
||||
{ title: 'Check', type: 'CHECK', enabled: false },
|
||||
{ title: 'Input', type: 'INPUT', enabled: true },
|
||||
{ title: 'Choose', type: 'CHOOSE', enabled: true },
|
||||
{ title: 'Select', type: 'SELECT', enabled: true },
|
||||
] as const
|
||||
|
||||
export const formatQuestionsToSubmit: FormatQuestionsToSubmitFT = (questions) =>
|
||||
JSON.stringify(
|
||||
questions.map((question) =>
|
||||
question.type === 'INPUT'
|
||||
? { title: question.title }
|
||||
: {
|
||||
...question,
|
||||
variants: question.variants.map((variant) => ({
|
||||
text: variant,
|
||||
})),
|
||||
}
|
||||
)
|
||||
)
|
7
src/views/DoForm/hooks.ts
Normal file
7
src/views/DoForm/hooks.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
export const useId = () => {
|
||||
const { id: idString } = useParams<{ id: string }>()
|
||||
|
||||
return parseInt(idString)
|
||||
}
|
68
src/views/DoForm/index.tsx
Normal file
68
src/views/DoForm/index.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { useQuery } from '@apollo/client'
|
||||
import React from 'react'
|
||||
import { Redirect } from 'react-router-dom'
|
||||
|
||||
import { FORM } from '../../apollo'
|
||||
import { QueryFormArgs } from '../../apollo/typeDefs.gen'
|
||||
import SubmissionList from '../../components/SubmissionsList'
|
||||
import styles from './main.module.css'
|
||||
import QuestionsForm from '../../components/QuestionsForm'
|
||||
import { IFormQuery } from '../../types'
|
||||
import { useId } from './hooks'
|
||||
import { getDateCreated } from '../../utils'
|
||||
|
||||
const DoForm: React.FC = () => {
|
||||
const id = useId()
|
||||
|
||||
const { data, error, loading, refetch: refetchForm } = useQuery<
|
||||
IFormQuery,
|
||||
QueryFormArgs
|
||||
>(FORM, {
|
||||
variables: { id },
|
||||
skip: isNaN(id),
|
||||
})
|
||||
|
||||
if (isNaN(id)) return <Redirect to="/" />
|
||||
|
||||
if (loading) return <div>Loading...</div>
|
||||
if (error) return <div>{error.message}</div>
|
||||
|
||||
const { form } = data!
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<header className={styles.header}>
|
||||
<h1 className={styles.formTitle}>{form.title}</h1>
|
||||
<p className={styles.dateCreated}>
|
||||
Published on {getDateCreated(form.dateCreated)}
|
||||
</p>
|
||||
<p className={styles.author}>
|
||||
{'by ' + form.author?.name || 'No author'}
|
||||
</p>
|
||||
</header>
|
||||
<main className={styles.main}>
|
||||
<div className={styles.mainTop} />
|
||||
{form.submissions ? (
|
||||
<>
|
||||
<h1 className={styles.mainHeader}>Submissions</h1>
|
||||
<SubmissionList
|
||||
submissions={form.submissions}
|
||||
questions={form.questions!}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1 className={styles.mainHeader}>Questions</h1>
|
||||
<QuestionsForm
|
||||
formId={id}
|
||||
questions={data!.form.questions!}
|
||||
refetchQuestions={refetchForm}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DoForm
|
55
src/views/DoForm/main.module.css
Normal file
55
src/views/DoForm/main.module.css
Normal file
@ -0,0 +1,55 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - 4rem);
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 2.3rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template:
|
||||
'title title' auto
|
||||
'date author' auto / auto auto;
|
||||
}
|
||||
|
||||
.formTitle {
|
||||
text-align: center;
|
||||
grid-area: title;
|
||||
}
|
||||
|
||||
.dateCreated {
|
||||
grid-area: date;
|
||||
}
|
||||
|
||||
.author {
|
||||
text-align: right;
|
||||
grid-area: author;
|
||||
}
|
||||
|
||||
.main {
|
||||
background-color: #ffffff;
|
||||
position: relative;
|
||||
margin-top: 2.3rem;
|
||||
flex-grow: 1;
|
||||
padding: 2.3rem;
|
||||
padding-top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mainTop {
|
||||
height: 2.3rem;
|
||||
border-top-left-radius: 20px;
|
||||
border-top-right-radius: 20px;
|
||||
width: 100%;
|
||||
background-color: #ffffff;
|
||||
position: absolute;
|
||||
top: -2.3rem;
|
||||
left: 0rem;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.mainHeader {
|
||||
padding-bottom: 2.3rem;
|
||||
}
|
86
src/views/Home/index.tsx
Normal file
86
src/views/Home/index.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import React from 'react'
|
||||
import { useQuery } from '@apollo/client'
|
||||
import { generateFromString } from 'generate-avatar'
|
||||
import { Redirect, useHistory, Link } from 'react-router-dom'
|
||||
|
||||
import Card from '../../components/Card'
|
||||
import { USER } from '../../apollo'
|
||||
import { QueryUserArgs } from '../../apollo/typeDefs.gen'
|
||||
import styles from './main.module.css'
|
||||
import { IUserQuery } from '../../types'
|
||||
import { logOut } from './utils'
|
||||
|
||||
const Home: React.FC = () => {
|
||||
let { data, error, loading, refetch } = useQuery<IUserQuery, QueryUserArgs>(
|
||||
USER
|
||||
)
|
||||
|
||||
const history = useHistory()
|
||||
|
||||
if (loading) return <p>Loading...</p>
|
||||
|
||||
if (error?.message === 'Authorization required')
|
||||
return <Redirect to="/login" />
|
||||
|
||||
if (error) return <p>{error.message}</p>
|
||||
|
||||
const { user } = data!
|
||||
|
||||
const { forms, formSubmissions } = user
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.userPad}>
|
||||
<div className={styles.userCard}>
|
||||
<img
|
||||
className={styles.userPic}
|
||||
src={`data:image/svg+xml;utf8,${generateFromString(user.email)}`}
|
||||
alt="Userpic"
|
||||
/>
|
||||
<h1>{user.name}</h1>
|
||||
<button
|
||||
className={styles.button}
|
||||
onClick={() => logOut(refetch, history)}
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.leftPad}>
|
||||
<h1>My forms</h1>
|
||||
<ul className={styles.cardList}>
|
||||
{forms!.map((form) => (
|
||||
<li key={form.id}>
|
||||
<Card title={form.title} id={form.id} />
|
||||
</li>
|
||||
))}
|
||||
<Link className={styles.createNew} to="/create">
|
||||
<span>Create new</span> <h3>+</h3>
|
||||
</Link>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={styles.rightPad}>
|
||||
<h1>My submissions</h1>
|
||||
<ul className={styles.cardList}>
|
||||
{formSubmissions
|
||||
?.filter((submission) => Boolean(submission.form))
|
||||
.map((submission) => (
|
||||
<li key={submission.id}>
|
||||
<Card
|
||||
title={submission.form!.title}
|
||||
id={submission.form!.id}
|
||||
subtitle={
|
||||
submission.user ? 'by ' + submission.user.name : undefined
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Home
|
132
src/views/Home/main.module.css
Normal file
132
src/views/Home/main.module.css
Normal file
@ -0,0 +1,132 @@
|
||||
.container {
|
||||
display: grid;
|
||||
min-height: calc(100vh - 4rem);
|
||||
grid-template-columns: 5fr 3fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-areas:
|
||||
'left user'
|
||||
'left right';
|
||||
padding: 2.3rem;
|
||||
}
|
||||
|
||||
.leftPad {
|
||||
display: flex;
|
||||
grid-area: left;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2.3rem;
|
||||
padding: 0 2.3rem;
|
||||
}
|
||||
|
||||
.leftPad > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cardList {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.createNew:hover {
|
||||
border-color: var(--containerColor);
|
||||
color: var(--containerColor);
|
||||
}
|
||||
|
||||
.createNew {
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 0.1rem var(--accentColor) solid;
|
||||
text-decoration: none;
|
||||
color: var(--accentColor);
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.createNew span {
|
||||
font-weight: bold;
|
||||
line-height: 1.17em;
|
||||
}
|
||||
|
||||
.createNew h3 {
|
||||
line-height: 1.17em;
|
||||
}
|
||||
|
||||
.rightPad {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2.3rem;
|
||||
padding-top: 2.3rem;
|
||||
grid-area: right;
|
||||
}
|
||||
|
||||
.rightPad > * {
|
||||
width: 30vw;
|
||||
}
|
||||
|
||||
.userPad {
|
||||
grid-area: user;
|
||||
padding: 0 2.3rem;
|
||||
}
|
||||
|
||||
.userCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background-color: #ffffff;
|
||||
padding: 2.3rem;
|
||||
padding-bottom: 1.6rem;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.userPic {
|
||||
border-radius: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button {
|
||||
height: 2.3rem;
|
||||
border-radius: 100vh;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 1.2rem;
|
||||
padding: 0 0.7rem;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
background-color: var(--accentColor);
|
||||
color: var(--onAccentFontColor);
|
||||
box-shadow: 0 1px 6px 0 var(--accentShadowColor);
|
||||
}
|
||||
|
||||
.button:active {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media (orientation: portrait) {
|
||||
.container {
|
||||
grid-template-columns: auto;
|
||||
grid-template-areas: 'user' 'left' 'right';
|
||||
gap: 2.3rem;
|
||||
}
|
||||
|
||||
.userPad {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leftPad {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.rightPad {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.rightPad > * {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
11
src/views/Home/types.ts
Normal file
11
src/views/Home/types.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { QueryUserArgs } from '../../apollo/typeDefs.gen'
|
||||
import { ApolloQueryResult } from '@apollo/client'
|
||||
import { IUserQuery } from '../../types'
|
||||
import { History } from 'history'
|
||||
|
||||
export type LogOutFT = (
|
||||
refetch: (
|
||||
variables?: Partial<QueryUserArgs> | undefined
|
||||
) => Promise<ApolloQueryResult<IUserQuery>>,
|
||||
history: History
|
||||
) => void
|
7
src/views/Home/utils.ts
Normal file
7
src/views/Home/utils.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { LogOutFT } from './types'
|
||||
|
||||
export const logOut: LogOutFT = (refetch, history) => {
|
||||
localStorage.removeItem('token')
|
||||
refetch()
|
||||
history.push('/')
|
||||
}
|
77
src/views/Login/index.tsx
Normal file
77
src/views/Login/index.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { useMutation } from '@apollo/client'
|
||||
import React, { ChangeEvent, FormEvent, useState } from 'react'
|
||||
|
||||
import { LOGIN } from '../../apollo'
|
||||
import { MutationLoginArgs, ServerAnswer } from '../../apollo/typeDefs.gen'
|
||||
import styles from './main.module.css'
|
||||
import meme from './meme.png'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
interface ILoginMutation {
|
||||
login: ServerAnswer
|
||||
}
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const [email, setEmail] = useState<string>('')
|
||||
|
||||
const [doLogin, { error, data, loading }] = useMutation<
|
||||
ILoginMutation,
|
||||
MutationLoginArgs
|
||||
>(LOGIN)
|
||||
|
||||
const handleFormSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await doLogin({ variables: { email } })
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(e.currentTarget.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.formCard}>
|
||||
<img
|
||||
className={styles.img}
|
||||
src={meme}
|
||||
alt="You can't forget password if you don't have it"
|
||||
/>
|
||||
<form className={styles.form} onSubmit={handleFormSubmit}>
|
||||
{data?.login.success ? (
|
||||
<div>
|
||||
<h1>
|
||||
You will get <span className={styles.focus}>login link</span>{' '}
|
||||
<br /> in your <span className={styles.focus}>mailbox</span>
|
||||
</h1>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h1 className={styles.header}>
|
||||
Log In / <Link to="/register">Register</Link>
|
||||
</h1>
|
||||
<input
|
||||
required
|
||||
className={styles.input}
|
||||
name="email"
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="email"
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
{loading ? (
|
||||
'Loading...'
|
||||
) : (
|
||||
<input type="submit" value="Login" className={styles.button} />
|
||||
)}
|
||||
{error && <p className={styles.errorMsg}>{error.message}</p>}
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
94
src/views/Login/main.module.css
Normal file
94
src/views/Login/main.module.css
Normal file
@ -0,0 +1,94 @@
|
||||
.container {
|
||||
display: flex;
|
||||
min-height: calc(100% - 4rem);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2.3rem;
|
||||
}
|
||||
|
||||
.formCard {
|
||||
min-width: 50vw;
|
||||
background-color: #ffffff;
|
||||
border-radius: 20px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
max-width: 90vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.img {
|
||||
height: 70vh;
|
||||
object-fit: contain;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.form {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.form input {
|
||||
height: 2.3rem;
|
||||
border-radius: 100vh;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 1.2rem;
|
||||
padding: 0 0.7rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form label {
|
||||
padding: 0 0.7rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header a {
|
||||
color: var(--accentColor);
|
||||
}
|
||||
|
||||
.input {
|
||||
border-bottom: 0.15rem var(--containerColor) solid !important;
|
||||
transition: border 0.1s;
|
||||
}
|
||||
.input:focus {
|
||||
border-bottom-width: 0rem !important;
|
||||
border-top: 0.15rem var(--containerColor) solid !important;
|
||||
}
|
||||
|
||||
.button {
|
||||
cursor: pointer;
|
||||
background-color: var(--accentColor);
|
||||
color: var(--onAccentFontColor);
|
||||
box-shadow: 0 1px 6px 0 var(--accentShadowColor);
|
||||
}
|
||||
|
||||
.button:active {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.errorMsg {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.focus {
|
||||
color: var(--accentColor);
|
||||
}
|
||||
|
||||
@media (orientation: portrait) {
|
||||
.formCard {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
min-height: calc(100% - 4rem);
|
||||
}
|
||||
|
||||
.img {
|
||||
height: initial;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
BIN
src/views/Login/meme.png
Normal file
BIN
src/views/Login/meme.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 87 KiB |
73
src/views/Register/index.tsx
Normal file
73
src/views/Register/index.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { useMutation } from '@apollo/client'
|
||||
import React, { FormEvent } from 'react'
|
||||
import { Redirect, Link } from 'react-router-dom'
|
||||
|
||||
import { REGISTER } from '../../apollo'
|
||||
import { MutationRegisterArgs, ServerAnswer } from '../../apollo/typeDefs.gen'
|
||||
import styles from '../Login/main.module.css'
|
||||
import meme from './meme.jpg'
|
||||
|
||||
interface IRegisterMutation {
|
||||
register: ServerAnswer
|
||||
}
|
||||
|
||||
const Register: React.FC = () => {
|
||||
const [doRegister, { data, loading, error }] = useMutation<
|
||||
IRegisterMutation,
|
||||
MutationRegisterArgs
|
||||
>(REGISTER)
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
try {
|
||||
await doRegister({
|
||||
variables: {
|
||||
email: formData.get('email') as string,
|
||||
name: formData.get('name') as string,
|
||||
},
|
||||
})
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.formCard}>
|
||||
<img
|
||||
className={styles.img}
|
||||
src={meme}
|
||||
alt='Questionform says: "Is mailbox a password?"'
|
||||
/>
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
<h1 className={styles.header}>
|
||||
Register / <Link to="/login">Log In</Link>
|
||||
</h1>
|
||||
<input
|
||||
required
|
||||
className={styles.input}
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="email"
|
||||
/>
|
||||
<input
|
||||
required
|
||||
className={styles.input}
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="username"
|
||||
/>
|
||||
{loading ? (
|
||||
'Loading...'
|
||||
) : (
|
||||
<input className={styles.button} type="submit" value="Submit" />
|
||||
)}
|
||||
{error && <p className={styles.errorMsg}>{error.message}</p>}
|
||||
{data?.register.success && <Redirect to="/" />}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Register
|
BIN
src/views/Register/meme.jpg
Normal file
BIN
src/views/Register/meme.jpg
Normal file
Binary file not shown.
After ![]() (image error) Size: 61 KiB |
@ -13,7 +13,8 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react"
|
||||
"jsx": "react",
|
||||
"plugins": [{ "name": "typescript-plugin-css-modules" }]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user