Started with Mongo
This commit is contained in:
23
client/.gitignore
vendored
Normal file
23
client/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
70
client/README.md
Normal file
70
client/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `yarn start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.\
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `yarn test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `yarn build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `yarn eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||
|
||||
### `yarn build` fails to minify
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
||||
16061
client/package-lock.json
generated
Normal file
16061
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
client/package.json
Normal file
43
client/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.3.6",
|
||||
"@testing-library/jest-dom": "^5.11.4",
|
||||
"@testing-library/react": "^11.1.0",
|
||||
"@testing-library/user-event": "^12.1.10",
|
||||
"graphql": "^15.4.0",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-router": "^5.2.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-scripts": "4.0.1",
|
||||
"subscriptions-transport-ws": "^0.9.18",
|
||||
"web-vitals": "^0.2.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
client/public/favicon.ico
Normal file
BIN
client/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
45
client/public/index.html
Normal file
45
client/public/index.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<!-- <link rel="stylesheet" href="https://unpkg.com/tachyons@4.12.0/css/tachyons.min.css" /> -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Web site created using create-react-app" />
|
||||
<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`.
|
||||
-->
|
||||
<title>React App</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>
|
||||
BIN
client/public/logo192.png
Normal file
BIN
client/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
client/public/logo512.png
Normal file
BIN
client/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
25
client/public/manifest.json
Normal file
25
client/public/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
3
client/public/robots.txt
Normal file
3
client/public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
64
client/src/components/App.js
Normal file
64
client/src/components/App.js
Normal file
@@ -0,0 +1,64 @@
|
||||
// import logo from './../logo.svg';
|
||||
// import './../styles/App.css';
|
||||
|
||||
import React, { Component } from 'react';
|
||||
import AppointmentList from './AppointmentList';
|
||||
import CreateLink from './CreateAppointment'
|
||||
import Header from './Header';
|
||||
import Login from './Login'
|
||||
import Search from './Search';
|
||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<div className="center w85">
|
||||
<Header />
|
||||
<div className="ph3 pv1 background-gray">
|
||||
<Switch>
|
||||
<Route exact path="/" component={AppointmentList} />
|
||||
<Route
|
||||
exact
|
||||
path="/create"
|
||||
component={CreateLink}
|
||||
/>
|
||||
<Route exact path="/login" component={Login} />
|
||||
<Route exact path="/search" component={Search} />
|
||||
<Route
|
||||
exact
|
||||
path="/new/:page"
|
||||
component={AppointmentList}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// class App extends Component {
|
||||
// render() {
|
||||
// return <CreateLink />;
|
||||
// }
|
||||
// }
|
||||
|
||||
// function App() {
|
||||
// return (
|
||||
// <div className="App">
|
||||
// <header className="App-header">
|
||||
// <img src={logo} className="App-logo" alt="logo" />
|
||||
// <p>
|
||||
// Edit <code>src/App.js</code> and save to reload.
|
||||
// </p>
|
||||
// <a
|
||||
// className="App-link"
|
||||
// href="https://reactjs.org"
|
||||
// target="_blank"
|
||||
// rel="noopener noreferrer"
|
||||
// >
|
||||
// Learn React
|
||||
// </a>
|
||||
// </header>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
export default App;
|
||||
8
client/src/components/App.test.js
Normal file
8
client/src/components/App.test.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
98
client/src/components/Appointment.js
Normal file
98
client/src/components/Appointment.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import { useMutation, gql } from '@apollo/client';
|
||||
import { AUTH_TOKEN, APPOINTMENTS_PER_PAGE } from '../constants';
|
||||
import { timeDifferenceForDate } from '../utils'
|
||||
import { FEED_QUERY } from './AppointmentList'
|
||||
|
||||
const FOLLOW_MUTATION = gql`
|
||||
mutation FollowMutation($appointmentId: ID!) {
|
||||
follow(followId: $followId) {
|
||||
id
|
||||
appointment {
|
||||
id
|
||||
follows {
|
||||
id
|
||||
user {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
user {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const take = APPOINTMENTS_PER_PAGE;
|
||||
const skip = 0;
|
||||
const orderBy = { createdAt: 'desc' };
|
||||
|
||||
const Appointment = (props) => {
|
||||
const { appointment } = props;
|
||||
const authToken = localStorage.getItem(AUTH_TOKEN);
|
||||
const take = APPOINTMENTS_PER_PAGE;
|
||||
const skip = 0;
|
||||
const orderBy = { createdAt: 'desc' };
|
||||
|
||||
const [follow] = useMutation(FOLLOW_MUTATION, {
|
||||
variables: {
|
||||
appointmentId: appointment.id
|
||||
},
|
||||
update(cache, { data: { follow } }) {
|
||||
const { feed } = cache.readQuery({
|
||||
query: FEED_QUERY
|
||||
});
|
||||
|
||||
const updatedAppointments = feed.follows.map((feedFollow) => {
|
||||
if (feedFollow.id === appointment.id) {
|
||||
return {
|
||||
...feedFollow,
|
||||
follows: [...feedFollow.follows, follow]
|
||||
};
|
||||
}
|
||||
return feedFollow;
|
||||
});
|
||||
|
||||
cache.writeQuery({
|
||||
query: FEED_QUERY,
|
||||
data: {
|
||||
feed: {
|
||||
appointments: updatedAppointments
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex mt2 items-start">
|
||||
<div className="flex items-center">
|
||||
<span className="gray">{props.index + 1}.</span>
|
||||
{authToken && (
|
||||
<div
|
||||
className="ml1 gray f11"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={follow}
|
||||
>
|
||||
▲
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml1">
|
||||
<div>
|
||||
{appointment.title} ({appointment.description})
|
||||
</div>
|
||||
{authToken && (
|
||||
<div className="f6 lh-copy gray">
|
||||
{appointment.follows.length} follows | by{' '}
|
||||
{follow.createdBy ? follow.createdBy.name : 'Unknown'}{' '}
|
||||
{timeDifferenceForDate(appointment.createdAt)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Appointment;
|
||||
176
client/src/components/AppointmentList.js
Normal file
176
client/src/components/AppointmentList.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import React from 'react';
|
||||
import Appointment from './Appointment';
|
||||
import { useHistory } from 'react-router';
|
||||
import { APPOINTMENTS_PER_PAGE } from '../constants';
|
||||
import { useQuery, gql } from '@apollo/client';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export const FEED_QUERY = gql`
|
||||
query FeedQuery(
|
||||
$take: Int
|
||||
$skip: Int
|
||||
$orderBy: AppointmentOrderByInput
|
||||
) {
|
||||
feed(take: $take, skip: $skip, orderBy: $orderBy) {
|
||||
id
|
||||
appointments {
|
||||
id
|
||||
createdAt
|
||||
title
|
||||
start
|
||||
end
|
||||
description
|
||||
createdBy {
|
||||
id
|
||||
name
|
||||
}
|
||||
follows {
|
||||
id
|
||||
user {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const NEW_APPOINTMENTS_SUBSCRIPTION = gql`
|
||||
subscription {
|
||||
newAppointment {
|
||||
id
|
||||
url
|
||||
description
|
||||
createdAt
|
||||
createdBy {
|
||||
id
|
||||
name
|
||||
}
|
||||
follows {
|
||||
id
|
||||
user {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const getQueryVariables = (isNewPage, page) => {
|
||||
const skip = isNewPage ? (page - 1) * APPOINTMENTS_PER_PAGE : 0;
|
||||
const take = isNewPage ? APPOINTMENTS_PER_PAGE : 100;
|
||||
const orderBy = { createdAt: 'desc' };
|
||||
console.log(isNewPage, page, APPOINTMENTS_PER_PAGE, skip, take, orderBy);
|
||||
return { take, skip, orderBy };
|
||||
};
|
||||
|
||||
const AppointmentList = () => {
|
||||
const history = useHistory();
|
||||
const isNewPage = history.location.pathname.includes(
|
||||
'new'
|
||||
);
|
||||
const pageIndexParams = history.location.pathname.split(
|
||||
'/'
|
||||
);
|
||||
|
||||
const page = parseInt(
|
||||
pageIndexParams[pageIndexParams.length - 1]
|
||||
);
|
||||
|
||||
console.log(pageIndexParams.length, page);
|
||||
|
||||
const pageIndex = page ? (page - 1) * APPOINTMENTS_PER_PAGE : 0;
|
||||
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
subscribeToMore
|
||||
} = useQuery(FEED_QUERY, {
|
||||
variables: getQueryVariables(isNewPage, page)
|
||||
});
|
||||
|
||||
// const { data } = useQuery(FEED_QUERY);
|
||||
|
||||
const getAppointmentsToRender = (isNewPage, data) => {
|
||||
if (isNewPage) {
|
||||
return data.feed.appointments;
|
||||
}
|
||||
const rankedAppointments = data.feed.appointments.slice();
|
||||
rankedAppointments.sort(
|
||||
(l1, l2) => l2.follows.length - l1.follows.length
|
||||
);
|
||||
return rankedAppointments;
|
||||
};
|
||||
|
||||
subscribeToMore({
|
||||
document: NEW_APPOINTMENTS_SUBSCRIPTION,
|
||||
updateQuery: (prev, { subscriptionData }) => {
|
||||
if (!subscriptionData.data) return prev;
|
||||
const newAppointment = subscriptionData.data.newAppointment;
|
||||
const exists = prev.feed.appointments.find(
|
||||
({ id }) => id === newAppointment.id
|
||||
);
|
||||
if (exists) return prev;
|
||||
|
||||
return Object.assign({}, prev, {
|
||||
feed: {
|
||||
appointments: [newAppointment, ...prev.feed.appointments],
|
||||
count: prev.feed.appointments.length + 1,
|
||||
__typename: prev.feed.__typename
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading && <p>Loading...</p>}
|
||||
{error && <pre>{JSON.stringify(error, null, 2)}</pre>}
|
||||
{data && (
|
||||
<>
|
||||
{getAppointmentsToRender(isNewPage, data).map(
|
||||
(appointment, index) => (
|
||||
<Link
|
||||
key={appointment.id}
|
||||
link={appointment}
|
||||
index={index + pageIndex}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{isNewPage && (
|
||||
<div className="flex ml4 mv3 gray">
|
||||
<div
|
||||
className="pointer mr2"
|
||||
onClick={() => {
|
||||
if (page > 1) {
|
||||
history.push(`/new/${page - 1}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Previous
|
||||
</div>
|
||||
<div
|
||||
className="pointer"
|
||||
onClick={() => {
|
||||
if (
|
||||
page <=
|
||||
data.feed.count / APPOINTMENTS_PER_PAGE
|
||||
) {
|
||||
const nextPage = page + 1;
|
||||
history.push(`/new/${nextPage}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppointmentList;
|
||||
133
client/src/components/CreateAppointment.js
Normal file
133
client/src/components/CreateAppointment.js
Normal file
@@ -0,0 +1,133 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useHistory } from 'react-router';
|
||||
import { useMutation, gql } from '@apollo/client';
|
||||
import { APPOINTMENTS_PER_PAGE } from '../constants';
|
||||
import { FEED_QUERY } from './AppointmentList';
|
||||
|
||||
const CREATE_APPOINTMENT_MUTATION = gql`
|
||||
mutation CreateAppointmentMutation(
|
||||
$description: String!
|
||||
$url: String!
|
||||
) {
|
||||
createAppointment(description: $description, url: $url) {
|
||||
id
|
||||
createdAt
|
||||
url
|
||||
description
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const CreateAppointment = () => {
|
||||
const history = useHistory();
|
||||
|
||||
const [formState, setFormState] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
start: '',
|
||||
end: ''
|
||||
});
|
||||
|
||||
const [createAppointment] = useMutation(CREATE_APPOINTMENT_MUTATION, {
|
||||
variables: {
|
||||
title: formState.title,
|
||||
description: formState.description,
|
||||
start: formState.start,
|
||||
end: formState.end
|
||||
},
|
||||
update: (cache, { data: { createAppointment } }) => {
|
||||
const take = APPOINTMENTS_PER_PAGE;
|
||||
const skip = 0;
|
||||
const orderBy = { createdAt: 'desc' };
|
||||
|
||||
const data = cache.readQuery({
|
||||
query: FEED_QUERY,
|
||||
variables: {
|
||||
take,
|
||||
skip,
|
||||
orderBy
|
||||
}
|
||||
});
|
||||
|
||||
cache.writeQuery({
|
||||
query: FEED_QUERY,
|
||||
data: {
|
||||
feed: {
|
||||
appointments: [createAppointment, ...data.feed.appointments]
|
||||
}
|
||||
},
|
||||
variables: {
|
||||
take,
|
||||
skip,
|
||||
orderBy
|
||||
}
|
||||
});
|
||||
},
|
||||
onCompleted: () => history.push('/new/1')
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
createAppointment();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-column mt3">
|
||||
<input
|
||||
className="mb2"
|
||||
value={formState.title}
|
||||
onChange={(e) =>
|
||||
setFormState({
|
||||
...formState,
|
||||
title: e.target.value
|
||||
})
|
||||
}
|
||||
type="text"
|
||||
placeholder="The title for the appointment"
|
||||
/>
|
||||
<input
|
||||
className="mb2"
|
||||
value={formState.description}
|
||||
onChange={(e) =>
|
||||
setFormState({
|
||||
...formState,
|
||||
description: e.target.value
|
||||
})
|
||||
}
|
||||
type="text"
|
||||
placeholder="A description for the appointment"
|
||||
/>
|
||||
<input
|
||||
className="mb2"
|
||||
value={formState.start}
|
||||
onChange={(e) =>
|
||||
setFormState({
|
||||
...formState,
|
||||
start: e.target.value
|
||||
})
|
||||
}
|
||||
type="text"
|
||||
placeholder="The start for the appointment"
|
||||
/>
|
||||
<input
|
||||
className="mb2"
|
||||
value={formState.end}
|
||||
onChange={(e) =>
|
||||
setFormState({
|
||||
...formState,
|
||||
end: e.target.value
|
||||
})
|
||||
}
|
||||
type="text"
|
||||
placeholder="The end for the appointment"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateAppointment;
|
||||
60
client/src/components/Header.js
Normal file
60
client/src/components/Header.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { AUTH_TOKEN } from '../constants';
|
||||
|
||||
const Header = () => {
|
||||
const history = useHistory();
|
||||
const authToken = localStorage.getItem(AUTH_TOKEN);
|
||||
return (
|
||||
<div className="flex pa1 justify-between nowrap orange">
|
||||
<div className="flex flex-fixed black">
|
||||
<div className="fw7 mr1">Hacker News</div>
|
||||
<Link to="/" className="ml1 no-underline black">
|
||||
new
|
||||
</Link>
|
||||
<div className="ml1">|</div>
|
||||
<Link to="/top" className="ml1 no-underline black">
|
||||
top
|
||||
</Link>
|
||||
<div className="ml1">|</div>
|
||||
<Link to="/search" className="ml1 no-underline black">
|
||||
search
|
||||
</Link>
|
||||
{authToken && (
|
||||
<div className="flex">
|
||||
<div className="ml1">|</div>
|
||||
<Link
|
||||
to="/create"
|
||||
className="ml1 no-underline black"
|
||||
>
|
||||
submit
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-fixed">
|
||||
{authToken ? (
|
||||
<div
|
||||
className="ml1 pointer black"
|
||||
onClick={() => {
|
||||
localStorage.removeItem(AUTH_TOKEN);
|
||||
history.push(`/`);
|
||||
}}
|
||||
>
|
||||
logout
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
className="ml1 no-underline black"
|
||||
>
|
||||
login
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
133
client/src/components/Login.js
Normal file
133
client/src/components/Login.js
Normal file
@@ -0,0 +1,133 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useHistory } from 'react-router';
|
||||
import { useMutation, gql } from '@apollo/client';
|
||||
import { AUTH_TOKEN } from '../constants';
|
||||
|
||||
const SIGNUP_MUTATION = gql`
|
||||
mutation SignupMutation(
|
||||
$email: String!
|
||||
$password: String!
|
||||
$name: String!
|
||||
) {
|
||||
signup(
|
||||
email: $email
|
||||
password: $password
|
||||
name: $name
|
||||
) {
|
||||
token
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const LOGIN_MUTATION = gql`
|
||||
mutation LoginMutation(
|
||||
$email: String!
|
||||
$password: String!
|
||||
) {
|
||||
login(email: $email, password: $password) {
|
||||
token
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Login = () => {
|
||||
const history = useHistory();
|
||||
|
||||
const [formState, setFormState] = useState({
|
||||
login: true,
|
||||
email: '',
|
||||
password: '',
|
||||
name: ''
|
||||
});
|
||||
|
||||
const [login] = useMutation(LOGIN_MUTATION, {
|
||||
variables: {
|
||||
email: formState.email,
|
||||
password: formState.password
|
||||
},
|
||||
onCompleted: ({ login }) => {
|
||||
localStorage.setItem(AUTH_TOKEN, login.token);
|
||||
history.push('/');
|
||||
}
|
||||
});
|
||||
|
||||
const [signup] = useMutation(SIGNUP_MUTATION, {
|
||||
variables: {
|
||||
name: formState.name,
|
||||
email: formState.email,
|
||||
password: formState.password
|
||||
},
|
||||
onCompleted: ({ signup }) => {
|
||||
localStorage.setItem(AUTH_TOKEN, signup.token);
|
||||
history.push('/');
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 className="mv3">
|
||||
{formState.login ? 'Login' : 'Sign Up'}
|
||||
</h4>
|
||||
<div className="flex flex-column">
|
||||
{!formState.login && (
|
||||
<input
|
||||
value={formState.name}
|
||||
onChange={(e) =>
|
||||
setFormState({
|
||||
...formState,
|
||||
name: e.target.value
|
||||
})
|
||||
}
|
||||
type="text"
|
||||
placeholder="Your name"
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
value={formState.email}
|
||||
onChange={(e) =>
|
||||
setFormState({
|
||||
...formState,
|
||||
email: e.target.value
|
||||
})
|
||||
}
|
||||
type="text"
|
||||
placeholder="Your email address"
|
||||
/>
|
||||
<input
|
||||
value={formState.password}
|
||||
onChange={(e) =>
|
||||
setFormState({
|
||||
...formState,
|
||||
password: e.target.value
|
||||
})
|
||||
}
|
||||
type="password"
|
||||
placeholder="Choose a safe password"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex mt3">
|
||||
<button
|
||||
className="pointer mr2 button"
|
||||
onClick={formState.login ? login : signup}
|
||||
>
|
||||
{formState.login ? 'login' : 'create account'}
|
||||
</button>
|
||||
<button
|
||||
className="pointer button"
|
||||
onClick={(e) =>
|
||||
setFormState({
|
||||
...formState,
|
||||
login: !formState.login
|
||||
})
|
||||
}
|
||||
>
|
||||
{formState.login
|
||||
? 'need to create an account?'
|
||||
: 'already have an account?'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
63
client/src/components/Search.js
Normal file
63
client/src/components/Search.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useMutation } from '@apollo/client';
|
||||
import { useLazyQuery } from '@apollo/client';
|
||||
import gql from 'graphql-tag';
|
||||
import Appointment from './Appointment';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const FEED_SEARCH_QUERY = gql`
|
||||
query FeedSearchQuery($filter: String!) {
|
||||
feed(filter: $filter) {
|
||||
id
|
||||
links {
|
||||
id
|
||||
url
|
||||
description
|
||||
createdAt
|
||||
createdBy {
|
||||
id
|
||||
name
|
||||
}
|
||||
follows {
|
||||
id
|
||||
user {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Search = () => {
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
const [executeSearch, { data }] = useLazyQuery(
|
||||
FEED_SEARCH_QUERY
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
Search
|
||||
<input
|
||||
type="text"
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
executeSearch({
|
||||
variables: { filter: searchFilter }
|
||||
})
|
||||
}
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
{data &&
|
||||
data.feed.appointments.map((appointment, index) => (
|
||||
<Link key={appointment.id} link={appointment} index={index} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Search;
|
||||
2
client/src/constants.js
Normal file
2
client/src/constants.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export const AUTH_TOKEN = 'auth-token';
|
||||
export const APPOINTMENTS_PER_PAGE = 3;
|
||||
92
client/src/index.js
Normal file
92
client/src/index.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { setContext } from '@apollo/client/link/context';
|
||||
import { AUTH_TOKEN } from './constants';
|
||||
|
||||
import { split } from '@apollo/client';
|
||||
import { WebSocketLink } from '@apollo/client/link/ws';
|
||||
import { getMainDefinition } from '@apollo/client/utilities';
|
||||
// import AppointmentList from './components/AppointmentList';
|
||||
|
||||
// class App extends Component {
|
||||
// render() {
|
||||
// return <AppointmentList />;
|
||||
// }
|
||||
// }
|
||||
|
||||
// export default App;
|
||||
|
||||
// import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import './styles/index.css';
|
||||
import './styles/tachyons.min.css'
|
||||
import App from './components/App';
|
||||
// // import * as serviceWorker from './serviceWorker';
|
||||
|
||||
// // 1
|
||||
import {
|
||||
ApolloProvider,
|
||||
ApolloClient,
|
||||
createHttpLink,
|
||||
InMemoryCache
|
||||
} from '@apollo/client';
|
||||
|
||||
// 2
|
||||
const httpLink = createHttpLink({
|
||||
uri: 'http://localhost:4000'
|
||||
});
|
||||
|
||||
// attach the auth_token to all requests to GraphQL server
|
||||
const authLink = setContext((_, { headers }) => {
|
||||
const token = localStorage.getItem(AUTH_TOKEN);
|
||||
return {
|
||||
headers: {
|
||||
...headers,
|
||||
authorization: token ? `Bearer ${token}` : ''
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const wsLink = new WebSocketLink({
|
||||
uri: `ws://localhost:4000/graphql`,
|
||||
options: {
|
||||
reconnect: true,
|
||||
connectionParams: {
|
||||
authToken: localStorage.getItem(AUTH_TOKEN)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const link = split(
|
||||
({ query }) => {
|
||||
const { kind, operation } = getMainDefinition(query);
|
||||
return (
|
||||
kind === 'OperationDefinition' &&
|
||||
operation === 'subscription'
|
||||
);
|
||||
},
|
||||
wsLink,
|
||||
authLink.concat(httpLink)
|
||||
);
|
||||
|
||||
// 3
|
||||
const client = new ApolloClient({
|
||||
link,
|
||||
cache: new InMemoryCache()
|
||||
});
|
||||
|
||||
// 4
|
||||
ReactDOM.render(
|
||||
<BrowserRouter>
|
||||
<ApolloProvider client={client}>
|
||||
<App />
|
||||
</ApolloProvider>
|
||||
</BrowserRouter>,
|
||||
document.getElementById('root')
|
||||
);
|
||||
// serviceWorker.unregister();
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
// reportWebVitals();
|
||||
1
client/src/logo.svg
Normal file
1
client/src/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
13
client/src/reportWebVitals.js
Normal file
13
client/src/reportWebVitals.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const reportWebVitals = onPerfEntry => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
5
client/src/setupTests.js
Normal file
5
client/src/setupTests.js
Normal file
@@ -0,0 +1,5 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
38
client/src/styles/App.css
Normal file
38
client/src/styles/App.css
Normal file
@@ -0,0 +1,38 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
57
client/src/styles/index.css
Normal file
57
client/src/styles/index.css
Normal file
@@ -0,0 +1,57 @@
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: Verdana, Geneva, sans-serif;
|
||||
}
|
||||
|
||||
input {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.gray {
|
||||
color: #828282;
|
||||
}
|
||||
|
||||
.orange {
|
||||
background-color: #ff6600;
|
||||
}
|
||||
|
||||
.background-gray {
|
||||
background-color: rgb(246, 246, 239);
|
||||
}
|
||||
|
||||
.f11 {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.w85 {
|
||||
width: 85%;
|
||||
}
|
||||
|
||||
.button {
|
||||
font-family: monospace;
|
||||
font-size: 10pt;
|
||||
color: black;
|
||||
background-color: buttonface;
|
||||
text-align: center;
|
||||
padding: 2px 6px 3px;
|
||||
border-width: 2px;
|
||||
border-style: outset;
|
||||
border-color: buttonface;
|
||||
cursor: pointer;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
} */
|
||||
3
client/src/styles/tachyons.min.css
vendored
Normal file
3
client/src/styles/tachyons.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
45
client/src/utils.js
Normal file
45
client/src/utils.js
Normal file
@@ -0,0 +1,45 @@
|
||||
function timeDifference(current, previous) {
|
||||
const milliSecondsPerMinute = 60 * 1000;
|
||||
const milliSecondsPerHour = milliSecondsPerMinute * 60;
|
||||
const milliSecondsPerDay = milliSecondsPerHour * 24;
|
||||
const milliSecondsPerMonth = milliSecondsPerDay * 30;
|
||||
const milliSecondsPerYear = milliSecondsPerDay * 365;
|
||||
|
||||
const elapsed = current - previous;
|
||||
|
||||
if (elapsed < milliSecondsPerMinute / 3) {
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
if (elapsed < milliSecondsPerMinute) {
|
||||
return 'less than 1 min ago';
|
||||
} else if (elapsed < milliSecondsPerHour) {
|
||||
return (
|
||||
Math.round(elapsed / milliSecondsPerMinute) +
|
||||
' min ago'
|
||||
);
|
||||
} else if (elapsed < milliSecondsPerDay) {
|
||||
return (
|
||||
Math.round(elapsed / milliSecondsPerHour) + ' h ago'
|
||||
);
|
||||
} else if (elapsed < milliSecondsPerMonth) {
|
||||
return (
|
||||
Math.round(elapsed / milliSecondsPerDay) + ' days ago'
|
||||
);
|
||||
} else if (elapsed < milliSecondsPerYear) {
|
||||
return (
|
||||
Math.round(elapsed / milliSecondsPerMonth) + ' mo ago'
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
Math.round(elapsed / milliSecondsPerYear) +
|
||||
' years ago'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function timeDifferenceForDate(date) {
|
||||
const now = new Date().getTime();
|
||||
const updated = new Date(date).getTime();
|
||||
return timeDifference(now, updated);
|
||||
}
|
||||
11607
client/yarn.lock
Normal file
11607
client/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
7
server/.gitignore
vendored
Normal file
7
server/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.env*
|
||||
dist
|
||||
package-lock.json
|
||||
node_modules
|
||||
.idea
|
||||
.vscode
|
||||
*.log
|
||||
3
server/README.md
Normal file
3
server/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# hackernews-graphql-js
|
||||
|
||||
This repository contains the final project for the [**GraphQL.js tutorial**](https://www.howtographql.com/graphql-js/0-introduction/) on [How to GraphQL](https://www.howtographql.com/). Note that it also serves as foundation for all frontend tutorials on the site.
|
||||
30
server/package.json
Normal file
30
server/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"apollo-server": "^2.19.0",
|
||||
"apollo-server-express": "^2.19.1",
|
||||
"bcryptjs": "2.4.3",
|
||||
"body-parser": "^1.19.0",
|
||||
"chai": "^4.2.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.17.1",
|
||||
"express-graphql": "^0.12.0",
|
||||
"graphql": "^14.7.0",
|
||||
"graphql-tools": "^7.0.2",
|
||||
"jsonwebtoken": "8.5.1",
|
||||
"migrate": "^1.7.0",
|
||||
"mocha": "^8.2.1",
|
||||
"mongodb": "^3.6.3",
|
||||
"mongoose": "^5.11.9",
|
||||
"node-migrate": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^2.0.6"
|
||||
}
|
||||
}
|
||||
99
server/src/index.js
Normal file
99
server/src/index.js
Normal file
@@ -0,0 +1,99 @@
|
||||
const { ApolloServer, PubSub } = require('apollo-server');
|
||||
var MongoClient = require('mongodb', { useUnifiedTopology: true }).MongoClient;
|
||||
// import { MongoClient } from 'mongodb'
|
||||
const Query = require('./resolvers/Query');
|
||||
const Mutation = require('./resolvers/Mutation');
|
||||
const Subscription = require('./resolvers/Subscription');
|
||||
const User = require('./resolvers/User');
|
||||
const Appointment = require('./resolvers/Appointment');
|
||||
const Follow = require('./resolvers/Follow');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getUserId } = require('./utils');
|
||||
|
||||
const pubsub = new PubSub();
|
||||
|
||||
// const mongo = new MongoClient({
|
||||
// errorFormat: 'minimal'
|
||||
// });
|
||||
|
||||
const mongo = MongoClient.connect("mongodb+srv://admin:hEbAjhvkrFDHAP3@cluster0.0hjtt.mongodb.net/Calendar?retryWrites=true&w=majority", function (err, db) {
|
||||
|
||||
if (err) throw err;
|
||||
console.log("ALL good");
|
||||
//Write databse Insert/Update/Query code here..
|
||||
|
||||
});
|
||||
|
||||
const resolvers = {
|
||||
Query,
|
||||
Mutation,
|
||||
Subscription,
|
||||
User,
|
||||
Appointment,
|
||||
Follow
|
||||
};
|
||||
|
||||
let db;
|
||||
|
||||
const server = new ApolloServer({
|
||||
typeDefs: fs.readFileSync(
|
||||
path.join(__dirname, 'schema.graphql'),
|
||||
'utf8'
|
||||
),
|
||||
resolvers,
|
||||
// context: async () => {
|
||||
// if (!db) {
|
||||
// try {
|
||||
// const dbClient = new MongoClient(
|
||||
// 'mongodb+srv://test:qwerty123@cluster0-yvwjx.mongodb.net/next-graphql?retryWrites=true&w=majority',
|
||||
// {
|
||||
// useNewUrlParser: true,
|
||||
// useUnifiedTopology: true,
|
||||
// }
|
||||
// )
|
||||
|
||||
// if (!dbClient.isConnected()) await dbClient.connect()
|
||||
// db = dbClient.db('next-graphql') // database name
|
||||
// } catch (e) {
|
||||
// console.log('--->error while connecting with graphql context (db)', e)
|
||||
// }
|
||||
// }
|
||||
|
||||
// return { db }
|
||||
// },
|
||||
context: ({ req }) => {
|
||||
return {
|
||||
...req,
|
||||
mongo,
|
||||
pubsub,
|
||||
userId:
|
||||
req && req.headers.authorization
|
||||
? getUserId(req)
|
||||
: null
|
||||
};
|
||||
},
|
||||
subscriptions: {
|
||||
onConnect: (connectionParams) => {
|
||||
if (connectionParams.authToken) {
|
||||
return {
|
||||
mongo,
|
||||
userId: getUserId(
|
||||
null,
|
||||
connectionParams.authToken
|
||||
)
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
mongo
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
server
|
||||
.listen()
|
||||
.then(({ url }) =>
|
||||
console.log(`Server is running on ${url}`)
|
||||
);
|
||||
16
server/src/resolvers/Appointment.js
Normal file
16
server/src/resolvers/Appointment.js
Normal file
@@ -0,0 +1,16 @@
|
||||
function createdBy(parent, args, context) {
|
||||
return context.mongo.appointment
|
||||
.findUnique({ where: { id: parent.id } })
|
||||
.createdBy();
|
||||
}
|
||||
|
||||
function follows(parent, args, context) {
|
||||
return context.mongo.appointment
|
||||
.findUnique({ where: { id: parent.id } })
|
||||
.follows();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createdBy,
|
||||
follows
|
||||
};
|
||||
16
server/src/resolvers/Follow.js
Normal file
16
server/src/resolvers/Follow.js
Normal file
@@ -0,0 +1,16 @@
|
||||
function appointment(parent, args, context) {
|
||||
return context.mongo.follow
|
||||
.findUnique({ where: { id: parent.id } })
|
||||
.appointment();
|
||||
}
|
||||
|
||||
function user(parent, args, context) {
|
||||
return context.mongo.follow
|
||||
.findUnique({ where: { id: parent.id } })
|
||||
.user();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
appointment,
|
||||
user
|
||||
};
|
||||
91
server/src/resolvers/Mutation.js
Normal file
91
server/src/resolvers/Mutation.js
Normal file
@@ -0,0 +1,91 @@
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { APP_SECRET } = require('../utils');
|
||||
|
||||
function createAppointment(parent, args, context, info) {
|
||||
const { userId } = context;
|
||||
|
||||
const newAppointment = context.mongo.appointment.create({
|
||||
data: {
|
||||
title: args.title,
|
||||
description: args.description,
|
||||
createdBy: { connect: { id: userId } }
|
||||
}
|
||||
});
|
||||
|
||||
return newAppointment;
|
||||
}
|
||||
|
||||
async function signup(parent, args, context, info) {
|
||||
console.log(context);
|
||||
const password = await bcrypt.hash(args.password, 10);
|
||||
const user = await context.mongo.user.create({
|
||||
data: { ...args, password }
|
||||
});
|
||||
|
||||
const token = jwt.sign({ userId: user.id }, APP_SECRET);
|
||||
|
||||
return {
|
||||
token,
|
||||
user
|
||||
};
|
||||
}
|
||||
|
||||
async function login(parent, args, context, info) {
|
||||
const user = await context.mongo.user.findUnique({
|
||||
where: { email: args.email }
|
||||
});
|
||||
if (!user) {
|
||||
throw new Error('No such user found');
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(
|
||||
args.password,
|
||||
user.password
|
||||
);
|
||||
if (!valid) {
|
||||
throw new Error('Invalid password');
|
||||
}
|
||||
|
||||
const token = jwt.sign({ userId: user.id }, APP_SECRET);
|
||||
|
||||
return {
|
||||
token,
|
||||
user
|
||||
};
|
||||
}
|
||||
|
||||
async function follow(parent, args, context, info) {
|
||||
const { userId } = context;
|
||||
const follow = await context.mongo.follow.findUnique({
|
||||
where: {
|
||||
linkId_userId: {
|
||||
linkId: Number(args.linkId),
|
||||
userId: userId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (Boolean(follow)) {
|
||||
throw new Error(
|
||||
`Already followed the appointment: ${args.linkId}`
|
||||
);
|
||||
}
|
||||
|
||||
const newFollow = context.mongo.follow.create({
|
||||
data: {
|
||||
user: { connect: { id: userId } },
|
||||
link: { connect: { id: Number(args.linkId) } }
|
||||
}
|
||||
});
|
||||
context.pubsub.publish('NEW_FOLLOW', newFollow);
|
||||
|
||||
return newFollow;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createAppointment,
|
||||
signup,
|
||||
login,
|
||||
follow
|
||||
};
|
||||
30
server/src/resolvers/Query.js
Normal file
30
server/src/resolvers/Query.js
Normal file
@@ -0,0 +1,30 @@
|
||||
async function feed(parent, args, context, info) {
|
||||
console.log(context);
|
||||
const where = args.filter
|
||||
? {
|
||||
OR: [
|
||||
{ title: { contains: args.filter } },
|
||||
{ description: { contains: args.filter } }
|
||||
]
|
||||
}
|
||||
: {};
|
||||
|
||||
const appointments = await context.mongo.appointment.findMany({
|
||||
where,
|
||||
skip: args.skip,
|
||||
take: args.take,
|
||||
orderBy: args.orderBy
|
||||
});
|
||||
|
||||
const count = await context.mongo.appointment.count({ where });
|
||||
|
||||
return {
|
||||
id: 'main-feed',
|
||||
appointments,
|
||||
count
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
feed
|
||||
};
|
||||
26
server/src/resolvers/Subscription.js
Normal file
26
server/src/resolvers/Subscription.js
Normal file
@@ -0,0 +1,26 @@
|
||||
function newLinkSubscribe(parent, args, context, info) {
|
||||
return context.pubsub.asyncIterator("NEW_LINK")
|
||||
}
|
||||
|
||||
const newAppointment = {
|
||||
subscribe: newLinkSubscribe,
|
||||
resolve: payload => {
|
||||
return payload
|
||||
},
|
||||
}
|
||||
|
||||
function newFollowSubscribe(parent, args, context, info) {
|
||||
return context.pubsub.asyncIterator("NEW_FOLLOW")
|
||||
}
|
||||
|
||||
const newFollow = {
|
||||
subscribe: newFollowSubscribe,
|
||||
resolve: payload => {
|
||||
return payload
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
newAppointment,
|
||||
newFollow
|
||||
}
|
||||
9
server/src/resolvers/User.js
Normal file
9
server/src/resolvers/User.js
Normal file
@@ -0,0 +1,9 @@
|
||||
function appointments(parent, args, context) {
|
||||
return context.mongo.user
|
||||
.findUnique({ where: { id: parent.id } })
|
||||
.appointments();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
appointments
|
||||
};
|
||||
78
server/src/schema.graphql
Normal file
78
server/src/schema.graphql
Normal file
@@ -0,0 +1,78 @@
|
||||
type Query {
|
||||
info: String!
|
||||
feed(
|
||||
filter: String
|
||||
skip: Int
|
||||
take: Int
|
||||
orderBy: AppointmentOrderByInput
|
||||
): Feed!
|
||||
}
|
||||
|
||||
type Feed {
|
||||
id: ID!
|
||||
appointments: [Appointment!]!
|
||||
count: Int!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
createAppointment(
|
||||
title: String!,
|
||||
description: String!,
|
||||
start: DateTime!,
|
||||
end: DateTime!,
|
||||
): Appointment!
|
||||
signup(
|
||||
email: String!
|
||||
password: String!
|
||||
name: String!
|
||||
): AuthPayload
|
||||
login(email: String!, password: String!): AuthPayload
|
||||
follow(appointmentId: ID!): Follow
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
newAppointment: Appointment
|
||||
newFollow: Follow
|
||||
}
|
||||
|
||||
type AuthPayload {
|
||||
token: String
|
||||
user: User
|
||||
}
|
||||
|
||||
type User {
|
||||
id: ID!
|
||||
name: String!
|
||||
email: String!
|
||||
appointments: [Appointment!]!
|
||||
}
|
||||
|
||||
type Appointment {
|
||||
id: ID!
|
||||
title: String!
|
||||
description: String!
|
||||
start: DateTime!
|
||||
end: DateTime!
|
||||
createdBy: User
|
||||
follows: [Follow!]!
|
||||
createdAt: DateTime!
|
||||
}
|
||||
|
||||
type Follow {
|
||||
id: ID!
|
||||
appointment: Appointment!
|
||||
user: User!
|
||||
}
|
||||
|
||||
input AppointmentOrderByInput {
|
||||
description: Sort
|
||||
url: Sort
|
||||
createdAt: Sort
|
||||
}
|
||||
|
||||
enum Sort {
|
||||
asc
|
||||
desc
|
||||
}
|
||||
|
||||
scalar DateTime
|
||||
30
server/src/utils.js
Normal file
30
server/src/utils.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const APP_SECRET = 'GraphQL-is-aw3some';
|
||||
|
||||
function getTokenPayload(token) {
|
||||
return jwt.verify(token, APP_SECRET);
|
||||
}
|
||||
|
||||
function getUserId(req, authToken) {
|
||||
if (req) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader) {
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
throw new Error('No token found');
|
||||
}
|
||||
const { userId } = getTokenPayload(token);
|
||||
return userId;
|
||||
}
|
||||
} else if (authToken) {
|
||||
const { userId } = getTokenPayload(authToken);
|
||||
return userId;
|
||||
}
|
||||
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
APP_SECRET,
|
||||
getUserId
|
||||
};
|
||||
4472
server/yarn.lock
Normal file
4472
server/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user