├── README.old.md
├── public
├── robots.txt
├── favicon.ico
├── manifest.json
└── index.html
├── .firebaserc
├── .storybook
├── addons.js
└── config.js
├── cors.json
├── firebase.json
├── .eslintrc.json
├── src
├── svg
│ ├── MagnifyingGlass.js
│ └── Stack.js
├── index.js
├── routes
│ ├── StaticLoader.js
│ ├── Article
│ │ ├── ArticleLoader.js
│ │ ├── ArticleLayout.js
│ │ └── Article.js
│ ├── Edit
│ │ ├── PrivateRoute.js
│ │ ├── Edit.js
│ │ └── EditLayout.js
│ ├── Home
│ │ ├── ArticleDisplay.js
│ │ ├── HomeLayout.js
│ │ └── Home.js
│ └── SignIn.js
├── components
│ ├── Loading.js
│ ├── SignOut.js
│ ├── Banner
│ │ ├── BannerLayout.js
│ │ └── Banner.js
│ ├── Article
│ │ ├── ArticleProvider.js
│ │ ├── ArticleItem.js
│ │ ├── ArticleCard.js
│ │ └── ArticleForm.js
│ ├── Tag
│ │ ├── TagProvider.js
│ │ ├── Tag.js
│ │ ├── TagHolder.js
│ │ └── TagForm.js
│ ├── ModalManager.js
│ ├── DeviceQueries.js
│ ├── Search.js
│ ├── Edit
│ │ ├── EditTag.js
│ │ ├── EditArticle.js
│ │ ├── FocusArticle.js
│ │ └── EditAssets.js
│ ├── Form
│ │ └── Form.js
│ └── Asset
│ │ └── AssetItem.js
├── stories
│ └── index.js
├── App.js
└── registerServiceWorker.js
├── LICENSE
├── package.json
└── .gitignore
/README.old.md:
--------------------------------------------------------------------------------
1 | # portfolio
2 | My portfolio
3 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow: /edit
3 | Disallow: /login
--------------------------------------------------------------------------------
/.firebaserc:
--------------------------------------------------------------------------------
1 | {
2 | "projects": {
3 | "default": "portfolio-5e3de"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Kaelinator/portfolio/master/public/favicon.ico
--------------------------------------------------------------------------------
/.storybook/addons.js:
--------------------------------------------------------------------------------
1 | import '@storybook/addon-actions/register';
2 | import '@storybook/addon-links/register';
3 |
--------------------------------------------------------------------------------
/cors.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "origin": ["*"],
4 | "method": ["GET"],
5 | "maxAgeSeconds": 3600
6 | }
7 | ]
--------------------------------------------------------------------------------
/.storybook/config.js:
--------------------------------------------------------------------------------
1 | import { configure } from '@storybook/react';
2 |
3 | function loadStories() {
4 | require('../src/stories');
5 | }
6 |
7 | configure(loadStories, module);
8 |
--------------------------------------------------------------------------------
/firebase.json:
--------------------------------------------------------------------------------
1 | {
2 | "hosting": {
3 | "public": "build",
4 | "ignore": [
5 | "firebase.json",
6 | "**/.*",
7 | "**/node_modules/**"
8 | ],
9 | "rewrites": [
10 | {
11 | "source": "**",
12 | "destination": "/index.html"
13 | }
14 | ]
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Kael Kirk",
3 | "name": "Kael Kirk Portfolio",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": "./index.html",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "airbnb",
3 | "parser": "babel-eslint",
4 | "env": {
5 | "browser": true,
6 | "node": true
7 | },
8 | "rules": {
9 | "linebreak-style": 0,
10 | "import/no-extraneous-dependencies": ["error", {"devDependencies": true}],
11 | "react/prefer-stateless-function": 0,
12 | "react/jsx-filename-extension": 0,
13 | "react/forbid-prop-types": 0
14 | }
15 | }
--------------------------------------------------------------------------------
/src/svg/MagnifyingGlass.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | const MagnifyingGlass = ({ color, thickness }) => (
5 |
17 | );
18 |
19 | MagnifyingGlass.propTypes = {
20 | color: PropTypes.string,
21 | thickness: PropTypes.number,
22 | };
23 |
24 | MagnifyingGlass.defaultProps = {
25 | color: 'black',
26 | thickness: 4,
27 | };
28 |
29 | export default MagnifyingGlass;
30 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 |
4 | import firebase from 'firebase/compat/app';
5 | import 'firebase/compat/firestore';
6 |
7 | import App from './App';
8 | // import registerServiceWorker from './registerServiceWorker';
9 |
10 | firebase.initializeApp({
11 | apiKey: 'AIzaSyCbxy9RWIUIUckXpRPea0zwlg1drezBtHs',
12 | authDomain: 'portfolio-5e3de.firebaseapp.com',
13 | databaseURL: 'https://portfolio-5e3de.firebaseio.com',
14 | projectId: 'portfolio-5e3de',
15 | storageBucket: 'portfolio-5e3de.appspot.com',
16 | messagingSenderId: '441789742914',
17 | });
18 | firebase.firestore().settings({ timestampsInSnapshots: true });
19 |
20 | ReactDOM.render(
21 | ,
22 | document.getElementById('root'),
23 | );
24 |
25 | // registerServiceWorker();
26 |
--------------------------------------------------------------------------------
/src/svg/Stack.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | const Stack = ({ colors }) => {
5 | const dy = 40 / colors.length;
6 | return (
7 |
25 | );
26 | };
27 |
28 | Stack.propTypes = {
29 | colors: PropTypes.array,
30 | };
31 |
32 | Stack.defaultProps = {
33 | colors: Array(3).fill('black'),
34 | };
35 |
36 | export default Stack;
37 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | Kael Kirk
11 |
12 |
13 |
16 |
17 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/src/routes/StaticLoader.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import 'firebase/firestore';
3 | import PropTypes from 'prop-types';
4 | import { getStorage, ref, getDownloadURL } from 'firebase/storage';
5 |
6 | export default class Resume extends Component {
7 | static propTypes = {
8 | match: PropTypes.shape({
9 | params: PropTypes.shape({
10 | load: PropTypes.string,
11 | }),
12 | }),
13 | }
14 |
15 | static defaultProps = {
16 | match: { params: { load: null } },
17 | }
18 |
19 | componentDidMount() {
20 | let { match: { params: { load } } } = this.props;
21 | if (!load) load = this.props.load;
22 | const storage = getStorage();
23 | const markdownRef = ref(storage, `static/${load}`);
24 |
25 |
26 | getDownloadURL(markdownRef)
27 | .then(url => window.location.assign(url));
28 | }
29 |
30 | render() {
31 | return <>>;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/Loading.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import styled from 'styled-components';
4 | import ModalManager from './ModalManager';
5 |
6 | const Wrapper = styled.div`
7 | text-align: center;
8 | `;
9 |
10 | const Title = styled.h1`
11 | font: 3em arial;
12 | `;
13 |
14 | const Subtitle = styled.h2`
15 | font: 1em arial;
16 | `;
17 |
18 | export default class Loading extends Component {
19 | static propTypes = {
20 | message: PropTypes.string,
21 | }
22 |
23 | static defaultProps = {
24 | message: 'Please wait',
25 | }
26 |
27 | render() {
28 | const { message } = this.props;
29 | return (
30 |
35 | Loading
36 | {message}
37 |
38 | )}
39 | />
40 | );
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/components/SignOut.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 |
3 | import firebase from 'firebase/compat/app';
4 | import 'firebase/auth';
5 |
6 | import styled from 'styled-components';
7 |
8 | const Wrapper = styled.div`
9 | grid-area: status;
10 | font-size: 1em;
11 | height: 40px;
12 | `;
13 |
14 | const Button = styled.button`
15 | font-size: 1em;
16 | border-radius: 4px;
17 | cursor: pointer;
18 | border-style: solid;
19 | border-width: 2px;
20 | `;
21 |
22 | export default class SignOut extends Component {
23 | state = {
24 | error: null,
25 | }
26 |
27 | constructor(props) {
28 | super(props);
29 |
30 | this.signOut = this.signOut.bind(this);
31 | }
32 |
33 | signOut() {
34 | firebase.auth()
35 | .signOut()
36 | .catch(err => this.setState({ error: err.message }));
37 | }
38 |
39 | render() {
40 | const { error } = this.state;
41 | return (
42 |
43 |
44 | {error && `Error signing out: ${error}`}
45 |
46 | );
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Kael Kirk
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/routes/Article/ArticleLoader.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | import 'github-markdown-css';
5 | import { ArticleContext } from '../../components/Article/ArticleProvider';
6 | import Article from './Article';
7 |
8 | const fallbackArticle = {
9 | title: 'loading',
10 | };
11 |
12 | export default class ArticleLoader extends Component {
13 | static propTypes = {
14 | match: PropTypes.shape({
15 | params: PropTypes.shape({
16 | articleUrl: PropTypes.string,
17 | }),
18 | }),
19 | }
20 |
21 | static defaultProps = {
22 | match: { params: { articleUrl: 'not-found' } },
23 | }
24 |
25 | render() {
26 | const { match: { params: { articleUrl } } } = this.props;
27 | return (
28 |
29 | {
30 | (articles) => {
31 | const article = articles.find(({ url }) => articleUrl === url) || fallbackArticle;
32 | return (
33 |
34 | );
35 | }
36 | }
37 |
38 | );
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/Banner/BannerLayout.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import styled from 'styled-components';
4 |
5 | import { Mobile, NotMobile } from '../DeviceQueries';
6 |
7 | const Container = styled.header`
8 | grid-area: head;
9 | display: grid;
10 | grid-gap: 20px;
11 | grid-template-areas:
12 | 'name'
13 | 'tags'
14 | 'srch';
15 | grid-template-rows: repeat(3, auto);
16 | grid-template-columns: minmax(0, 1fr);
17 | align-items: baseline;
18 | `;
19 |
20 | const Ribbon = styled.header`
21 | grid-area: head;
22 | grid-row-gap: 5px;
23 | display: grid;
24 | grid-template-areas:
25 | 'srch name'
26 | 'tags tags';
27 | overflow: hidden;
28 | grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
29 | `;
30 |
31 | const BannerLayout = ({ children }) => (
32 | <>
33 |
34 | {children}
35 |
36 |
37 | {children}
38 |
39 | >
40 | );
41 |
42 | BannerLayout.propTypes = {
43 | children: PropTypes.node,
44 | };
45 |
46 | BannerLayout.defaultProps = {
47 | children: [],
48 | };
49 |
50 | export default BannerLayout;
51 |
--------------------------------------------------------------------------------
/src/components/Article/ArticleProvider.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | import firebase from 'firebase/compat/app';
5 | import 'firebase/firestore';
6 |
7 | export const ArticleContext = React.createContext([]);
8 |
9 | export default class ArticleProvider extends Component {
10 | static propTypes = {
11 | children: PropTypes.node.isRequired,
12 | }
13 |
14 | state = {
15 | articleRefUnsub: null,
16 | articles: [],
17 | }
18 |
19 | componentDidMount() {
20 | const firestore = firebase.firestore();
21 |
22 | const articleRefUnsub = firestore
23 | .collection('articles')
24 | .onSnapshot((snap) => {
25 | const articles = snap.docs.map(doc => ({
26 | id: doc.id,
27 | ...doc.data(),
28 | }));
29 | this.setState({ articles });
30 | });
31 |
32 | this.setState({ articleRefUnsub });
33 | }
34 |
35 | componentWillUnmount() {
36 | const { articleRefUnsub } = this.state;
37 |
38 | articleRefUnsub();
39 | }
40 |
41 | render() {
42 | const { children } = this.props;
43 | const { articles } = this.state;
44 | return (
45 |
46 | {children}
47 |
48 | );
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/stories/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { storiesOf } from '@storybook/react';
4 | // import { action } from '@storybook/addon-actions';
5 |
6 | import Edit from '../routes/Edit/Edit';
7 | import TagForm from '../components/Tag/TagForm';
8 | import EditTag from '../components/Edit/EditTag';
9 | import EditArticle from '../components/Edit/EditArticle';
10 | import ArticleForm from '../components/Article/ArticleForm';
11 | import ArticleCard from '../components/Article/ArticleCard';
12 |
13 | storiesOf('Edit', module)
14 | .add('edit layout', () => )
15 | .add('article edit', () => )
16 | .add('tag edit', () => );
17 |
18 | storiesOf('TagForm', module)
19 | .add('tag form', () => );
20 |
21 | storiesOf('Article', module)
22 | .add('article form', () => )
23 | .add('article card', () => (
24 |
25 |
`${Math.random()}`)}
29 | />
30 |
31 | ));
32 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | BrowserRouter, Switch, Route,
4 | } from 'react-router-dom';
5 |
6 | import { ResponsiveProvider } from './components/DeviceQueries';
7 | import { TagProvider } from './components/Tag/TagProvider';
8 |
9 | import Home from './routes/Home/Home';
10 | import StaticLoader from './routes/StaticLoader';
11 | import Edit from './routes/Edit/Edit';
12 | import SignIn from './routes/SignIn';
13 | import PrivateRoute from './routes/Edit/PrivateRoute';
14 | import ArticleProvider from './components/Article/ArticleProvider';
15 | import ArticleLoader from './routes/Article/ArticleLoader';
16 |
17 | export default () => (
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | } />
26 | } />
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | );
35 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "portfolio",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "firebase": "9.6.4",
7 | "github-markdown-css": "^2.10.0",
8 | "prop-types": "^15.6.2",
9 | "react": "^16.7.0",
10 | "react-document-meta": "^3.0.0-beta.2",
11 | "react-dom": "^16.7.0",
12 | "react-dropzone": "^8.0.3",
13 | "react-markdown": "^4.0.4",
14 | "react-pose": "^4.0.4",
15 | "react-responsive": "^6.0.1",
16 | "react-router-dom": "^4.3.1",
17 | "react-scripts": "^2.1.2",
18 | "styled-components": "^4.1.3"
19 | },
20 | "scripts": {
21 | "start": "react-scripts start",
22 | "build": "react-scripts build",
23 | "test": "react-scripts test --env=jsdom",
24 | "eject": "react-scripts eject",
25 | "deploy": "react-scripts build && firebase deploy",
26 | "storybook": "start-storybook -p 9009 -s public",
27 | "build-storybook": "build-storybook -s public"
28 | },
29 | "devDependencies": {
30 | "@babel/core": "^7.2.2",
31 | "@storybook/addon-actions": "^4.1.4",
32 | "@storybook/addon-links": "^4.1.4",
33 | "@storybook/addons": "^4.1.4",
34 | "@storybook/react": "^4.1.4",
35 | "babel-loader": "^8.0.4",
36 | "eslint-config-airbnb": "^17.1.0",
37 | "eslint-plugin-import": "^2.14.0",
38 | "eslint-plugin-jsx-a11y": "^6.1.2",
39 | "eslint-plugin-react": "^7.12.3"
40 | },
41 | "browserslist": [
42 | ">0.2%",
43 | "not dead",
44 | "not ie <= 11",
45 | "not op_mini all"
46 | ]
47 | }
48 |
--------------------------------------------------------------------------------
/src/routes/Edit/PrivateRoute.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { Route, Redirect } from 'react-router-dom';
4 |
5 | // import firebase from 'firebase/compat/app';
6 | // import 'firebase/auth';
7 | import { getAuth, onAuthStateChanged } from 'firebase/auth';
8 |
9 | import Loading from '../../components/Loading';
10 |
11 | export default class PrivateRoute extends React.Component {
12 | static propTypes = {
13 | component: PropTypes.func.isRequired,
14 | }
15 |
16 | constructor(props) {
17 | super(props);
18 |
19 | this.state = {
20 | signedIn: false,
21 | loading: true,
22 | };
23 | }
24 |
25 | componentDidMount() {
26 | const auth = getAuth();
27 | const authChangeUnsub = onAuthStateChanged(auth, user => this.setState({
28 | signedIn: !!user,
29 | loading: false
30 | }));
31 |
32 | this.setState({ authChangeUnsub });
33 | }
34 |
35 |
36 | componentWillUnmount() {
37 | const { authChangeUnsub } = this.state;
38 | authChangeUnsub();
39 | }
40 |
41 | render() {
42 | const { component: Component, ...rest } = this.props;
43 | const { signedIn, loading } = this.state;
44 |
45 | if (loading) return ;
46 |
47 | return (
48 | (signedIn
51 | ?
52 | : )
53 | }
54 | />
55 | );
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 |
8 | # Runtime data
9 | pids
10 | *.pid
11 | *.seed
12 | *.pid.lock
13 |
14 | # Directory for instrumented libs generated by jscoverage/JSCover
15 | lib-cov
16 |
17 | # Coverage directory used by tools like istanbul
18 | coverage
19 |
20 | # nyc test coverage
21 | .nyc_output
22 |
23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
24 | .grunt
25 |
26 | # Bower dependency directory (https://bower.io/)
27 | bower_components
28 |
29 | # node-waf configuration
30 | .lock-wscript
31 |
32 | # Compiled binary addons (https://nodejs.org/api/addons.html)
33 | build/Release
34 |
35 | # Dependency directories
36 | node_modules/
37 | jspm_packages/
38 |
39 | # TypeScript v1 declaration files
40 | typings/
41 |
42 | # Optional npm cache directory
43 | .npm
44 |
45 | # firebase cache
46 | .firebase
47 |
48 | # Optional eslint cache
49 | .eslintcache
50 |
51 | # Optional REPL history
52 | .node_repl_history
53 |
54 | # Output of 'npm pack'
55 | *.tgz
56 |
57 | # Yarn Integrity file
58 | .yarn-integrity
59 |
60 | # dotenv environment variables file
61 | .env
62 |
63 | # next.js build output
64 | .next
65 | # See https://help.github.com/ignore-files/ for more about ignoring files.
66 |
67 | # dependencies
68 | /node_modules
69 |
70 | # testing
71 | /coverage
72 |
73 | # production
74 | /build
75 |
76 | # misc
77 | .DS_Store
78 | .env.local
79 | .env.development.local
80 | .env.test.local
81 | .env.production.local
82 |
83 | npm-debug.log*
84 | yarn-debug.log*
85 | yarn-error.log*
86 |
--------------------------------------------------------------------------------
/src/routes/Edit/Edit.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 |
3 | import EditLayout from './EditLayout';
4 | import SignOut from '../../components/SignOut';
5 | import { TagContext } from '../../components/Tag/TagProvider';
6 | import EditTag from '../../components/Edit/EditTag';
7 | import { ArticleContext } from '../../components/Article/ArticleProvider';
8 | import EditArticle from '../../components/Edit/EditArticle';
9 | import FocusArticle from '../../components/Edit/FocusArticle';
10 |
11 | export default class Edit extends Component {
12 | state ={
13 | focusId: null,
14 | }
15 |
16 | constructor(props) {
17 | super(props);
18 | this.setFocusArticle = this.setFocusArticle.bind(this);
19 | }
20 |
21 | setFocusArticle({ id }) {
22 | this.setState({ focusId: id });
23 | }
24 |
25 | render() {
26 | const { focusId } = this.state;
27 | return (
28 |
29 |
30 |
31 |
32 | { ({ tags }) => }
33 |
34 |
35 |
36 | { articles => }
37 |
38 |
39 |
40 |
41 | { (articles) => {
42 | const article = (articles.filter(({ id }) => focusId === id))[0];
43 | if (!article) return null;
44 | return ;
45 | }}
46 |
47 |
48 | );
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/components/Tag/TagProvider.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | import firebase from 'firebase/compat/app';
5 | import 'firebase/firestore';
6 |
7 | const getDataFrom = tags => id => tags.find(e => e.id === id) || {};
8 | const getColorsFrom = tags => articleTags => tags
9 | .filter(({ id }) => articleTags.includes(id))
10 | .reduce((arr, { color, accent }) => [color, ...arr, accent], []);
11 |
12 | const initializeState = tags => ({
13 | tags,
14 | dataOf: getDataFrom(tags),
15 | colorsOf: getColorsFrom(tags),
16 | });
17 |
18 | export const TagContext = React.createContext(initializeState([]));
19 |
20 | export class TagProvider extends Component {
21 | static propTypes = {
22 | children: PropTypes.node.isRequired,
23 | }
24 |
25 | state = initializeState([])
26 |
27 | componentDidMount() {
28 | const firestore = firebase.firestore();
29 |
30 | const tagRefUnsub = firestore
31 | .collection('tags')
32 | .onSnapshot((snap) => {
33 | const tags = snap.docs.map(doc => ({
34 | id: doc.id,
35 | ...doc.data(),
36 | }));
37 |
38 | this.setState({
39 | ...initializeState(tags),
40 | });
41 | });
42 |
43 | this.setState({ tagRefUnsub });
44 | }
45 |
46 | componentWillUnmount() {
47 | const { tagRefUnsub } = this.state;
48 |
49 | tagRefUnsub();
50 | }
51 |
52 | render() {
53 | const { children } = this.props;
54 | const { tags, dataOf, colorsOf } = this.state;
55 | return (
56 |
57 | {children}
58 |
59 | );
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/routes/Edit/EditLayout.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | import styled from 'styled-components';
5 |
6 | import { Desktop, Tablet, Mobile } from '../../components/DeviceQueries';
7 |
8 | const WrapperLarge = styled.div`
9 | width: 100vw;
10 | height: 100vh;
11 | display: grid;
12 | grid-template-areas:
13 | 'status status article article'
14 | 'tags articles body assets ';
15 | grid-template-columns: minmax(0, 1fr) minmax(0, 3fr) minmax(0, 2fr) minmax(0, 2fr);
16 | grid-template-rows: auto 1fr;
17 | `;
18 |
19 | const WrapperMedium = styled.div`
20 | width: 100vw;
21 | display: grid;
22 | grid-template-areas:
23 | 'status status '
24 | 'tags articles'
25 | 'article article '
26 | 'body body '
27 | 'assets assets ';
28 | grid-template-columns: 1fr 3fr;
29 | grid-template-rows: auto auto auto 500px auto;
30 | `;
31 |
32 | const WrapperSmall = styled.div`
33 | width: 100vw;
34 | display: grid;
35 | grid-template-areas:
36 | 'status'
37 | 'tags'
38 | 'articles'
39 | 'article'
40 | 'body'
41 | 'assets';
42 | grid-template-rows: auto auto auto auto 500px auto;
43 | `;
44 |
45 | const EditLayout = ({ children }) => (
46 | <>
47 |
48 | {children}
49 |
50 |
51 | {children}
52 |
53 |
54 | {children}
55 |
56 | >
57 | );
58 |
59 | EditLayout.propTypes = {
60 | children: PropTypes.node,
61 | };
62 |
63 | EditLayout.defaultProps = {
64 | children: [],
65 | };
66 |
67 | export default EditLayout;
68 |
--------------------------------------------------------------------------------
/src/components/Banner/Banner.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import styled from 'styled-components';
4 |
5 | import BannerLayout from './BannerLayout';
6 | import {
7 | Mobile, Desktop, Tablet, ResponsiveContext,
8 | } from '../DeviceQueries';
9 |
10 |
11 | const Name = styled.h1`
12 | font-family: arial;
13 | text-align: center;
14 | font-size: 7em;
15 | grid-area: name;
16 | `;
17 |
18 | const NameMedium = styled.h1`
19 | font-family: arial;
20 | text-align: center;
21 | font-size: 3em;
22 | margin: 0;
23 | padding: 0;
24 | grid-area: name;
25 | `;
26 |
27 | const NameSmall = styled.h1`
28 | font-family: arial;
29 | text-align: center;
30 | font-size: 3em;
31 | margin: 0;
32 | padding: 0;
33 | grid-area: name;
34 | justify-self: end;
35 | `;
36 |
37 | export default class Banner extends Component {
38 | static propTypes = {
39 | Search: PropTypes.func.isRequired,
40 | TagHolder: PropTypes.func.isRequired,
41 | }
42 |
43 | render() {
44 | const { Search, TagHolder } = this.props;
45 | return (
46 |
47 |
48 | Kael Kirk
49 |
50 |
51 | Kael Kirk
52 |
53 |
54 | Kael
55 |
56 |
57 |
58 |
59 |
60 |
61 | {
62 | ({ isMobile }) =>
63 | }
64 |
65 |
66 |
67 | );
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/components/Tag/Tag.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import styled from 'styled-components';
4 |
5 | import { TagContext } from './TagProvider';
6 |
7 |
8 | const Wrapper = styled.button`
9 | border-radius: 4px;
10 | padding: 2px;
11 | font-family: arial;
12 | border-style: solid;
13 | border-width: 2px;
14 | text-align: center;
15 | margin: 5px;
16 | flex-shrink: 0;
17 | `;
18 |
19 | export default class Tag extends Component {
20 | static propTypes = {
21 | id: PropTypes.string.isRequired,
22 | clickable: PropTypes.bool,
23 | active: PropTypes.bool,
24 | onClick: PropTypes.func,
25 | };
26 |
27 | static defaultProps = {
28 | active: true,
29 | clickable: false,
30 | onClick: () => {},
31 | };
32 |
33 | constructor(props) {
34 | super(props);
35 |
36 | this.handleClick = this.handleClick.bind(this);
37 | }
38 |
39 | handleClick() {
40 | const { clickable, onClick } = this.props;
41 |
42 | if (!clickable) return;
43 |
44 | onClick();
45 | }
46 |
47 | render() {
48 | const { id, clickable, active } = this.props;
49 |
50 | const cursor = { cursor: clickable ? 'pointer' : 'context-menu' };
51 |
52 | return (
53 |
54 | {({ dataOf }) => {
55 | const { color, accent, name } = dataOf(id);
56 | return (
57 |
64 | {name || }
65 |
66 | );
67 | }}
68 |
69 | );
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/components/ModalManager.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | import styled from 'styled-components';
5 | import posed, { PoseGroup } from 'react-pose';
6 |
7 | const Popup = posed(styled.div`
8 | position: absolute;
9 | background: #F9F9FA;
10 | border-radius: 10px;
11 | display: grid;
12 | box-shadow: 0px 0px 5px 0px rgba(50, 50, 50, 0.4);
13 | transform: translate(50%, 50%);
14 | `)({
15 | enter: {
16 | y: 0,
17 | opacity: 1,
18 | delay: 300,
19 | transition: {
20 | y: { type: 'spring', stiffness: 1000, damping: 15 },
21 | default: { duration: 300 },
22 | },
23 | },
24 | exit: {
25 | y: 50,
26 | opacity: 0,
27 | transition: { duration: 150 },
28 | },
29 | });
30 |
31 | const Shade = posed(styled.div`
32 | position: absolute;
33 | background: rgba(0, 0, 0, ${({ noShade }) => (noShade ? 0 : 0.8)});
34 | top: 0;
35 | left: 0;
36 | right: 0;
37 | bottom: 0;
38 | display: flex;
39 | align-items: center;
40 | justify-content: center;
41 | `)({
42 | enter: { opacity: 1 },
43 | exit: { opacity: 0 },
44 | });
45 |
46 | export default class ModalManager extends Component {
47 | static propTypes = {
48 | modal: PropTypes.object.isRequired,
49 | children: PropTypes.node,
50 | visible: PropTypes.bool,
51 | noShade: PropTypes.bool,
52 | }
53 |
54 | static defaultProps = {
55 | children: [],
56 | visible: false,
57 | noShade: false,
58 | }
59 |
60 | render() {
61 | const {
62 | children, modal, visible, noShade,
63 | } = this.props;
64 |
65 | return (
66 | <>
67 | {children}
68 |
69 | { visible && [
70 | {modal},
71 | ]}
72 |
73 | >
74 | );
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/src/routes/Home/ArticleDisplay.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import styled from 'styled-components';
4 | import posed, { PoseGroup } from 'react-pose';
5 |
6 | import ArticleCard from '../../components/Article/ArticleCard';
7 |
8 | const Item = posed.div({
9 | enter: {
10 | opacity: 1,
11 | transition: { duration: 250 },
12 | delay: ({ index }) => index * 30,
13 | },
14 | exit: {
15 | opacity: 0,
16 | transition: { duration: 250 },
17 | },
18 | flip: {
19 | transition: {
20 | default: { type: 'tween', ease: 'circOut' },
21 | },
22 | },
23 | });
24 |
25 | const List = styled.div`
26 | display: grid;
27 | grid-template-columns: 1fr;
28 | grid-gap: 20px;
29 | grid-area: rslt;
30 | `;
31 |
32 | const match = (articles, search) => {
33 | const keywords = search.toLowerCase().split(/\W/);
34 | return articles.filter(({ title, subtitle }) => {
35 | const lTitle = title.toLowerCase();
36 | const lSub = subtitle.toLowerCase();
37 | return keywords.some(word => lTitle.includes(word) || lSub.includes(word));
38 | });
39 | };
40 |
41 | const filter = (articles, tagList) => (
42 | articles.filter(({ tags }) => tags.some(tag => !tagList.includes(tag)))
43 | );
44 |
45 | const render = articles => (
46 |
47 | {
48 | articles
49 | .filter(({ visible }) => visible)
50 | .map((article, i) => (
51 | -
52 |
53 |
54 | ))
55 | }
56 |
57 | );
58 |
59 | const ArticleDisplay = ({ articles, search, tags }) => (
60 | <>
61 | {
62 | search
63 | ? {render(filter(match(articles, search), tags))}
64 | : render(filter(articles, tags))
65 | }
66 | >
67 | );
68 |
69 | ArticleDisplay.propTypes = {
70 | articles: PropTypes.array,
71 | search: PropTypes.string,
72 | tags: PropTypes.array,
73 | };
74 |
75 | ArticleDisplay.defaultProps = {
76 | articles: [],
77 | search: '',
78 | tags: [],
79 | };
80 |
81 | export default ArticleDisplay;
82 |
--------------------------------------------------------------------------------
/src/components/DeviceQueries.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import Responsive from 'react-responsive';
4 |
5 | export const DesktopLarge = props => ;
6 | export const DesktopSmall = props => ;
7 |
8 | export const Desktop = props => ;
9 | export const Tablet = props => ;
10 | export const Mobile = props => ;
11 |
12 | export const NotMobile = props => ;
13 |
14 | const DESKTOP_MIN = 992;
15 | const TABLET_MIN = 768;
16 |
17 | export const ResponsiveContext = React.createContext({
18 | /* assume an iPhone X */
19 | width: 375,
20 | height: 812,
21 | isDesktop: false,
22 | isTablet: false,
23 | isMobile: true,
24 | });
25 |
26 | export class ResponsiveProvider extends Component {
27 | static propTypes = {
28 | children: PropTypes.node.isRequired,
29 | }
30 |
31 | state = {
32 | width: window.innerWidth,
33 | height: window.innerHeight,
34 | isDesktop: window.innerWidth >= DESKTOP_MIN,
35 | isTablet: window.innerWidth < DESKTOP_MIN && window.innerWidth >= TABLET_MIN,
36 | isMobile: window.innerWidth < TABLET_MIN,
37 | }
38 |
39 | constructor(props) {
40 | super(props);
41 |
42 | this.updateWindowDimensions = this.updateWindowDimensions.bind(this);
43 | }
44 |
45 | componentDidMount() {
46 | this.updateWindowDimensions();
47 | window.addEventListener('resize', this.updateWindowDimensions);
48 | }
49 |
50 | componentWillUnmount() {
51 | window.removeEventListener('resize', this.updateWindowDimensions);
52 | }
53 |
54 | updateWindowDimensions() {
55 | const width = window.innerWidth;
56 | const height = window.innerHeight;
57 | this.setState({
58 | width,
59 | height,
60 | isDesktop: width >= DESKTOP_MIN,
61 | isTablet: width < DESKTOP_MIN && width >= TABLET_MIN,
62 | isMobile: width < TABLET_MIN,
63 | });
64 | }
65 |
66 | render() {
67 | const { children } = this.props;
68 | return (
69 |
70 | {children}
71 |
72 | );
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/components/Tag/TagHolder.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import styled from 'styled-components';
4 |
5 | import Tag from './Tag';
6 | import { TagContext } from './TagProvider';
7 |
8 | const Horizontal = styled.nav`
9 | display: flex;
10 | justify-content: space-between;
11 | margin-left: 0;
12 | margin-right: 0;
13 | overflow-x: auto;
14 | scrollbar-width: none;
15 |
16 | &::-webkit-scrollbar {
17 | display: none;
18 | }
19 |
20 | & > button:first-child {
21 | margin-left: 0;
22 | }
23 |
24 | & > button:last-child {
25 | margin-right: 0;
26 | }
27 | `;
28 |
29 |
30 | class TagHolder extends Component {
31 | static propTypes = {
32 | tags: PropTypes.array,
33 | onStatusChange: PropTypes.func,
34 | };
35 |
36 | static defaultProps = {
37 | tags: [],
38 | onStatusChange: () => {},
39 | };
40 |
41 | constructor(props) {
42 | super(props);
43 | const { tags } = this.props;
44 |
45 | this.state = {
46 | disabledTags: Array(tags.length).fill(true),
47 | };
48 |
49 | this.onStatusChange = this.onStatusChange.bind(this);
50 | }
51 |
52 | onStatusChange(tagId) {
53 | return () => {
54 | const { onStatusChange } = this.props;
55 | const { disabledTags } = this.state;
56 |
57 | const index = disabledTags.findIndex(id => id === tagId);
58 |
59 |
60 | const updatedStatuses = index === -1
61 | ? disabledTags.concat(tagId)
62 | : disabledTags.filter(id => id !== tagId);
63 |
64 | onStatusChange(updatedStatuses);
65 | this.setState({ disabledTags: updatedStatuses });
66 | };
67 | }
68 |
69 | render() {
70 | const { tags } = this.props;
71 | const { disabledTags } = this.state;
72 | return (
73 |
74 | {
75 | tags.map(({ id }) => (
76 |
83 | ))
84 | }
85 |
86 | );
87 | }
88 | }
89 |
90 | export default props => (
91 |
92 | {({ tags }) => ({ tag, id }))} {...props} />}
93 |
94 | );
95 |
--------------------------------------------------------------------------------
/src/routes/Home/HomeLayout.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | import styled from 'styled-components';
5 |
6 | import {
7 | DesktopSmall, DesktopLarge, Tablet, Mobile,
8 | } from '../../components/DeviceQueries';
9 |
10 | const Wrapper = styled.div`
11 | color: #1F1F20;
12 | height: 100vh;
13 | display: grid;
14 | grid-template-rows: 1fr auto;
15 | margin: 0 10px 0 10px;
16 | `;
17 |
18 | const Content = styled.div`
19 | display: grid;
20 | grid-template-rows: auto 1fr;
21 | `;
22 |
23 | const GridLarge = styled.div`
24 | display: grid;
25 | grid-template-areas:
26 | 'left left head head head head right right'
27 | 'left left head head head head right right'
28 | 'left left rslt rslt rslt rslt right right';
29 | grid-auto-flow: row dense;
30 | grid-auto-columns: 1fr;
31 | grid-gap: 20px;
32 | `;
33 |
34 | const Grid = styled.div`
35 | display: grid;
36 | grid-template-areas:
37 | 'left head head head right'
38 | 'left head head head right'
39 | 'left rslt rslt rslt right';
40 | grid-auto-flow: row dense;
41 | grid-auto-columns: 1fr;
42 | grid-gap: 20px;
43 | `;
44 |
45 | const GridTablet = styled.div`
46 | display: grid;
47 | grid-template-areas:
48 | 'left head head right'
49 | 'left rslt rslt right';
50 | grid-auto-flow: row dense;
51 | grid-auto-columns: 1fr;
52 | grid-gap: 20px;
53 | `;
54 |
55 | const GridMobile = styled.div`
56 | display: grid;
57 | grid-template-areas:
58 | 'head'
59 | 'rslt';
60 | grid-auto-flow: row dense;
61 | grid-auto-columns: 1fr;
62 | grid-gap: 20px;
63 | `;
64 |
65 | const HomeLayout = ({ children, footer }) => (
66 |
67 |
68 |
69 | {children}
70 |
71 |
72 |
73 | {children}
74 |
75 |
76 |
77 | {children}
78 |
79 |
80 |
81 | {children}
82 |
83 |
84 |
85 | {footer}
86 |
87 | );
88 |
89 | HomeLayout.propTypes = {
90 | children: PropTypes.node,
91 | footer: PropTypes.node,
92 | };
93 |
94 | HomeLayout.defaultProps = {
95 | children: [],
96 | footer: [],
97 | };
98 |
99 | export default HomeLayout;
100 |
--------------------------------------------------------------------------------
/src/routes/Home/Home.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 |
3 | import styled from 'styled-components';
4 |
5 | import Banner from '../../components/Banner/Banner';
6 | import HomeLayout from './HomeLayout';
7 | import Search from '../../components/Search';
8 | import TagHolder from '../../components/Tag/TagHolder';
9 | import { ArticleContext } from '../../components/Article/ArticleProvider';
10 | import Results from './ArticleDisplay';
11 |
12 | const Footer = styled.footer`
13 | display: flex;
14 | flex-flow: row wrap;
15 | justify-content: space-evenly;
16 | grid-row: -2 / -1;
17 | padding: 20px;
18 | `;
19 |
20 | const Social = styled.a`
21 | font: 1.25em arial;
22 | margin-left: 10px;
23 | margin-right: 10px;
24 | `;
25 |
26 | export default class Home extends Component {
27 | state = {
28 | search: '',
29 | tags: [],
30 | }
31 |
32 | constructor(props) {
33 | super(props);
34 |
35 | this.search = this.search.bind(this);
36 | this.filter = this.filter.bind(this);
37 |
38 | this.SearchBar = prop => ;
39 | this.TagHolder = prop => ;
40 | }
41 |
42 | search(search) {
43 | this.setState({ search });
44 | }
45 |
46 | filter(tags) {
47 | this.setState({ tags });
48 | }
49 |
50 | render() {
51 | const { search, tags } = this.state;
52 | return (
53 |
55 | Email
56 | GitHub
57 | LinkedIn
58 | Medium
59 | Twitter
60 | YouTube
61 |
62 | )}
63 | >
64 |
65 |
66 | { articles => }
67 |
68 |
69 | );
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/components/Article/ArticleItem.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | // import { Link } from 'react-router-dom';
4 |
5 | import styled from 'styled-components';
6 |
7 | const Wrapper = styled.li`
8 | list-style: none;
9 | padding: 10px 0 10px 0;
10 | border-radius: 4px;
11 | margin: 5px;
12 | box-shadow: 0px 0px 5px 0px rgba(50, 50, 50, 0.25);
13 | display: grid;
14 | grid-template-columns: minmax(0, 1fr) auto;
15 | background-color: ${props => (props.visible ? 'white' : 'grey')}
16 | color: #1F1F20;
17 | `;
18 |
19 | const Heading = styled.div`
20 | display: flex;
21 | align-items: baseline;
22 | `;
23 |
24 | const Title = styled.h3`
25 | font-family: arial;
26 | font-size: 2em;
27 | margin: 0 5px 0 10px;
28 | overflow: hidden;
29 | text-overflow: ellipsis;
30 | white-space: nowrap;
31 | `;
32 |
33 | const Small = styled.h5`
34 | margin: 0 10px 0 5px;
35 | font-size: 1em;
36 | font-family: arial;
37 | color: #3A3A3A;
38 | white-space: nowrap;
39 | `;
40 |
41 | const Control = styled.div`
42 | margin: 0 5px 0 5px;
43 | display: flex;
44 | justify-content: flex-end;
45 | align-items: stretch;
46 | align-self: stretch;
47 |
48 | & > button:first-child {
49 | border-right: 1px dashed white;
50 | border-bottom-right-radius: 0;
51 | border-top-right-radius: 0;
52 | }
53 |
54 | & > button:last-child {
55 | border-left: none;
56 | border-bottom-left-radius: 0;
57 | border-top-left-radius: 0;
58 | }
59 | `;
60 |
61 | const Button = styled.button`
62 | border-radius: 4px;
63 | cursor: pointer;
64 | border-style: solid;
65 | border-width: 2px;
66 | `;
67 |
68 | const ArticleItem = ({
69 | url, title, onEdit, onWrite, visible, ...rest
70 | }) => console.log(rest) || (
71 |
72 |
73 | {title}
74 | {`/${url}`}
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | );
83 |
84 | ArticleItem.propTypes = {
85 | history: PropTypes.object,
86 | onEdit: PropTypes.func.isRequired,
87 | onWrite: PropTypes.func.isRequired,
88 | url: PropTypes.string.isRequired,
89 | visible: PropTypes.bool,
90 | title: PropTypes.string,
91 | };
92 |
93 | ArticleItem.defaultProps = {
94 | visible: true,
95 | title: '',
96 | };
97 |
98 | export default ArticleItem;
99 |
--------------------------------------------------------------------------------
/src/components/Tag/TagForm.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import {
4 | Form, Text, Label, Submit, Color, Section, ActionRow, Header,
5 | } from '../Form/Form';
6 |
7 | export default class TagForm extends Component {
8 | static propTypes = {
9 | onSubmit: PropTypes.func.isRequired,
10 | id: PropTypes.string,
11 | color: PropTypes.string,
12 | accent: PropTypes.string,
13 | name: PropTypes.string,
14 | }
15 |
16 | static defaultProps = {
17 | id: null,
18 | color: '#999999',
19 | accent: '#444444',
20 | name: '',
21 | }
22 |
23 | constructor(props) {
24 | super(props);
25 |
26 | const {
27 | color, accent, name, id,
28 | } = this.props;
29 |
30 | this.state = {
31 | color, accent, name, id,
32 | };
33 |
34 | this.handleChange = this.handleChange.bind(this);
35 | this.submit = this.submit.bind(this);
36 | this.cancel = this.cancel.bind(this);
37 | this.delete = this.delete.bind(this);
38 | }
39 |
40 | submit() {
41 | const { onSubmit } = this.props;
42 | onSubmit(this.state);
43 | }
44 |
45 | cancel() {
46 | const { onSubmit } = this.props;
47 | onSubmit({ cancel: true });
48 | }
49 |
50 | delete() {
51 | const { onSubmit } = this.props;
52 | const { id } = this.state;
53 | onSubmit({ id, delete: true });
54 | }
55 |
56 | handleChange(event) {
57 | this.setState({ [event.target.id]: event.target.value });
58 | }
59 |
60 | render() {
61 | const { color, accent, name } = this.state;
62 | return (
63 |
86 | );
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/src/routes/SignIn.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | // import firebase from 'firebase/compat/app';
5 | // import 'firebase/auth';
6 | import { getAuth, signInWithEmailAndPassword } from 'firebase/auth';
7 |
8 | import { Redirect } from 'react-router-dom';
9 | import {
10 | Label, Text, Submit, Section, Form, Header,
11 | } from '../components/Form/Form';
12 | import ModalManager from '../components/ModalManager';
13 |
14 | export default class SignIn extends Component {
15 | static propTypes = {
16 | location: PropTypes.object,
17 | }
18 |
19 | static defaultProps = {
20 | location: { state: { from: { pathname: '/e' } } },
21 | }
22 |
23 | state = {
24 | email: '',
25 | password: '',
26 | authSuccess: false,
27 | error: null,
28 | modalVisible: false,
29 | }
30 |
31 | constructor(props) {
32 | super(props);
33 |
34 | this.onSubmit = this.onSubmit.bind(this);
35 | this.handleChange = this.handleChange.bind(this);
36 | }
37 |
38 | componentDidMount() {
39 | this.setState({ modalVisible: true });
40 | }
41 |
42 | onSubmit(event) {
43 | event.preventDefault();
44 | const { email, password } = this.state;
45 |
46 | const auth = getAuth();
47 | signInWithEmailAndPassword(auth, email, password)
48 | .then(() => this.setState({ authSuccess: true }))
49 | .catch(err => this.setState({ error: err.code }));
50 | }
51 |
52 | handleChange(event) {
53 | this.setState({ [event.target.id]: event.target.value });
54 | }
55 |
56 | render() {
57 | const { location } = this.props;
58 | const { from } = location.state;
59 | const {
60 | email, password, authSuccess, error, modalVisible,
61 | } = this.state;
62 |
63 | if (authSuccess) return ;
64 |
65 | return (
66 |
70 | { error && Failed to sign in }
71 |
72 |
73 |
74 |
78 |
79 |
83 |
84 |
85 |
86 | )}
87 | />
88 | );
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/components/Search.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | import posed from 'react-pose';
5 | import styled from 'styled-components';
6 |
7 | import MagnifyingGlass from '../svg/MagnifyingGlass';
8 |
9 | const Layout = posed(styled.div`
10 | display: flex;
11 | flex-direction: row;
12 | border-radius: inherit;
13 | border: solid gray 1px;
14 | border-radius: 3px;
15 | background: white;
16 | box-shadow: 0px 0px 5px 0px rgba(50, 50, 50, 0.25);
17 | overflow: hidden;
18 | `)({
19 | expanded: {
20 | width: '100%',
21 | },
22 | contracted: {
23 | width: 50,
24 | },
25 | });
26 |
27 | const Bar = styled.input`
28 | border: none;
29 | vertical-align: text-bottom;
30 | margin-right: 5px;
31 | padding: 5px;
32 | font-size: 1em;
33 | color: #1F1F20;
34 | width: 100%;
35 | `;
36 |
37 | const SearchIcon = styled.button`
38 | vertical-align: text-bottom;
39 | display: inline-block;
40 | background: white;
41 | padding: 0;
42 | margin: 0;
43 | border-radius: inherit;
44 | border: none;
45 | width: 50px;
46 | flex-shrink: 0;
47 | `;
48 |
49 | export default class Search extends Component {
50 | static propTypes = {
51 | contracted: PropTypes.bool,
52 | onSearch: PropTypes.func.isRequired,
53 | }
54 |
55 | static defaultProps = {
56 | contracted: true,
57 | }
58 |
59 | constructor(props) {
60 | super(props);
61 |
62 | const expanded = !props.contracted;
63 |
64 | this.state = {
65 | expanded,
66 | value: '',
67 | };
68 |
69 | this.toggleExpanded = this.toggleExpanded.bind(this);
70 | this.handleChange = this.handleChange.bind(this);
71 | }
72 |
73 | toggleExpanded() {
74 | const { onSearch } = this.props;
75 | const { expanded, value } = this.state;
76 |
77 | if (expanded) onSearch('');
78 | else onSearch(value);
79 |
80 | this.setState({ expanded: !expanded });
81 | }
82 |
83 | handleChange(event) {
84 | const { onSearch } = this.props;
85 |
86 | this.setState({ value: event.target.value });
87 |
88 | onSearch(event.target.value);
89 | }
90 |
91 | render() {
92 | const { expanded, value } = this.state;
93 | return (
94 |
95 |
96 |
97 |
98 |
99 |
100 | );
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/src/components/Article/ArticleCard.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import styled from 'styled-components';
4 | import { Link } from 'react-router-dom';
5 | import Tag from '../Tag/Tag';
6 |
7 |
8 | const Wrapper = styled.header`
9 | display: grid;
10 | width: 100%;
11 | height: 200px;
12 | border-radius: 5px;
13 | background-color: white;
14 | cursor: pointer;
15 | box-shadow: 0px 0px 5px 0px rgba(50, 50, 50, 0.25);
16 | `;
17 |
18 | const Title = styled.h2`
19 | align-self: start;
20 | font: 2em 'Open Sans', sans-serif;
21 | padding: 0 5px 0 5px;
22 | margin: 0;
23 | overflow: hidden;
24 | overflow-wrap: break-word;
25 | hyphens: auto;
26 | text-overflow: ellipsis;
27 | display: -webkit-box;
28 | line-height: 32px; /* fallback */
29 | max-height: 128px; /* fallback */
30 | `;
31 |
32 | const Subtitle = styled.p`
33 | font: 1em 'Open Sans', sans-serif;
34 | padding: 0 5px 0 5px;
35 | margin: 0;
36 | hyphens: auto;
37 | overflow: hidden;
38 | `;
39 |
40 | const Tags = styled.div`
41 | overflow: hidden;
42 | flex-flow: row wrap;
43 | display: flex;
44 | justify-content: space-evenly;
45 | height: 10px;
46 |
47 | & > button {
48 | width: auto;
49 | height: 10px;
50 | overflow: hidden;
51 | font-size: 0;
52 | margin: 0;
53 | flex-grow: 2;
54 | border-top-left-radius: 0;
55 | border-bottom-left-radius: 0;
56 | border-top-right-radius: 0;
57 | border-bottom-right-radius: 0;
58 | border-bottom: none;
59 | border-right-width: 0;
60 | border-left-width: 0;
61 | }
62 |
63 | & > button:first-child {
64 | border-top-left-radius: 5px;
65 | border-left-width: 2px;
66 | }
67 |
68 | & > button:last-child {
69 | border-top-right-radius: 5px;
70 | border-right-width: 2px;
71 | }
72 | `;
73 |
74 | const wrapIfLink = (url, content) => (url === '' ? content() : {content()});
75 |
76 | const ArticleCard = ({
77 | url, title, subtitle, tags,
78 | }) => (
79 |
80 |
81 | {wrapIfLink(url, () => (
82 |
83 |
84 | { tags.map(tag => ) }
85 |
86 | {title}
87 | {subtitle}
88 |
89 | ))}
90 |
91 |
92 | );
93 |
94 | ArticleCard.propTypes = {
95 | url: PropTypes.string,
96 | title: PropTypes.string,
97 | subtitle: PropTypes.string,
98 | tags: PropTypes.array,
99 | };
100 |
101 | ArticleCard.defaultProps = {
102 | url: '',
103 | title: '',
104 | subtitle: '',
105 | tags: [],
106 | };
107 |
108 | export default ArticleCard;
109 |
--------------------------------------------------------------------------------
/src/components/Edit/EditTag.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import styled from 'styled-components';
4 |
5 | import firebase from 'firebase/compat/app';
6 | import 'firebase/firestore';
7 |
8 | import Tag from '../Tag/Tag';
9 | import ModalManager from '../ModalManager';
10 | import TagForm from '../Tag/TagForm';
11 |
12 |
13 | const Wrapper = styled.div`
14 | display: flex;
15 | flex-direction: column;
16 | grid-area: tags;
17 | overflow-y: auto;
18 | `;
19 |
20 | const Button = styled.button`
21 | border-radius: 4px;
22 | padding: 2px;
23 | font-family: arial;
24 | border-style: solid;
25 | border-width: 2px;
26 | text-align: center;
27 | margin: 5px;
28 | cursor: pointer;
29 | `;
30 |
31 | export default class EditTag extends Component {
32 | static propTypes = {
33 | tags: PropTypes.array,
34 | }
35 |
36 | static defaultProps = {
37 | tags: [],
38 | }
39 |
40 | state = {
41 | modalVisible: false,
42 | modal: ,
43 | }
44 |
45 | constructor(props) {
46 | super(props);
47 |
48 | this.toggleModal = this.toggleModal.bind(this);
49 | this.submitTag = this.submitTag.bind(this);
50 | this.editTag = this.editTag.bind(this);
51 | }
52 |
53 |
54 | submitTag({ id, ...tag }) {
55 | if (tag.cancel) return this.toggleModal();
56 |
57 | const tags = firebase.firestore().collection('tags');
58 |
59 | if (!id) {
60 | return tags.add(tag)
61 | .then(() => this.toggleModal())
62 | .catch(err => console.log('Error creating tag', err));
63 | }
64 |
65 | const tagRef = tags.doc(id);
66 |
67 | if (tag.delete) {
68 | return tagRef.delete()
69 | .then(() => this.toggleModal())
70 | .catch(err => console.log('Error deleting tag', err));
71 | }
72 |
73 | return tagRef.set(tag)
74 | .then(() => this.toggleModal())
75 | .catch(err => console.log('Error updating tag', err));
76 | }
77 |
78 | editTag(tag) {
79 | this.setState({
80 | modal: ,
81 | modalVisible: true,
82 | });
83 | }
84 |
85 | toggleModal() {
86 | this.setState(({ modalVisible }) => ({
87 | modalVisible: !modalVisible,
88 | }));
89 | }
90 |
91 | render() {
92 | const { tags } = this.props;
93 | const { modalVisible, modal } = this.state;
94 | return (
95 |
96 |
97 | {
98 | tags.map(tag => (
99 | this.editTag(tag)} />
100 | ))
101 | }
102 |
103 |
104 |
105 | );
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/src/components/Form/Form.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const Form = styled.form`
4 | font-family: arial;
5 | font-size: 1.5em;
6 | text-align: center;
7 | display: grid;
8 | grid-gap: 10px;
9 | color: #1F1F20;
10 | `;
11 |
12 | export const ActionRow = styled.div`
13 | display: flex;
14 | justify-content: flex-end;
15 | `;
16 |
17 | export const Submit = styled.input`
18 | margin: 5px;
19 | border: none;
20 | font-family: inherit;
21 | font-size: inherit;
22 | border-radius: 5px;
23 | box-shadow: 0px 0px 5px 0px rgba(50, 50, 50, 0.4);
24 | background-color: white;
25 | padding: 5px;
26 | cursor: pointer;
27 |
28 | &.primary {
29 | background-color: #1cd45e;
30 | color: white;
31 | }
32 |
33 | &.danger {
34 | background-color: #fc4447;
35 | color: white;
36 | }
37 |
38 | &.info {
39 | background-color: #6af0ff;
40 | color: white;
41 | }
42 | `;
43 |
44 | export const Text = styled.input`
45 | margin: 5px;
46 | border: none;
47 | font-family: inherit;
48 | font-size: inherit;
49 | border-radius: 5px;
50 | background: white;
51 | box-shadow: 0px 0px 5px 0px rgba(50, 50, 50, 0.4);
52 | padding: 5px;
53 | `;
54 |
55 | export const TextArea = styled.textarea`
56 | resize: none;
57 | font: 1em arial;
58 | border-radius: 5px;
59 | background: white;
60 | box-shadow: 0px 0px 5px 0px rgba(50, 50, 50, 0.4);
61 | padding: 5px;
62 | margin: 5px;
63 | border: none;
64 | `;
65 |
66 | export const Color = styled.input`
67 | margin: 5px;
68 | border: none;
69 | font-family: inherit;
70 | font-size: inherit;
71 | border-radius: 5px;
72 | background: white;
73 | box-shadow: 0px 0px 5px 0px rgba(50, 50, 50, 0.4);
74 | padding: 5px;
75 | cursor: pointer;
76 | `;
77 |
78 | export const Checkbox = styled.input`
79 | box-shadow: 0px 0px 5px 0px rgba(50, 50, 50, 0.2);
80 | `;
81 |
82 | export const Select = styled.select`
83 | overflow-y: auto;
84 | border-radius: 5px;
85 | background: white;
86 | box-shadow: 0px 0px 5px 0px rgba(50, 50, 50, 0.4);
87 | padding: 5px;
88 | margin: 5px;
89 | border: none;
90 | `;
91 |
92 | export const Option = styled.option`
93 | overflow-y: auto;
94 |
95 | &:selected {
96 | background: red;
97 | }
98 | `;
99 |
100 | export const File = styled.div`
101 | border: 5px dashed ${({ active }) => (active ? '#6af0ff' : '#efefef')};
102 | border-radius: 5px;
103 | background: white;
104 | box-shadow: 0px 0px 5px 0px rgba(50, 50, 50, 0.4);
105 | padding: 5px;
106 | margin: 5px;
107 | `;
108 |
109 | export const Label = styled.label`
110 | margin: 5px;
111 | font-family: inherit;
112 | font-size: inherit;
113 | cursor: pointer;
114 | `;
115 |
116 | export const Section = styled.div`
117 | display: grid;
118 | grid-template-columns: 1fr auto;
119 | `;
120 |
121 | export const Header = styled.h1`
122 | font-size: 2em;
123 | font-family: arial;
124 | `;
125 |
--------------------------------------------------------------------------------
/src/components/Edit/EditArticle.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import styled from 'styled-components';
4 |
5 | import firebase from 'firebase/compat/app';
6 | import 'firebase/firestore';
7 |
8 | import ArticleItem from '../Article/ArticleItem';
9 | import ModalManager from '../ModalManager';
10 | import ArticleForm from '../Article/ArticleForm';
11 |
12 | const Wrapper = styled.ul`
13 | margin: 0;
14 | padding: 0;
15 | grid-area: articles;
16 | overflow-y: auto;
17 | `;
18 |
19 | const Button = styled.button`
20 | font-family: arial;
21 | font-size: 2em;
22 | border-radius: 4px;
23 | cursor: pointer;
24 | border-style: solid;
25 | border-width: 2px;
26 | width: 100%;
27 | `;
28 |
29 | export default class EditArticle extends Component {
30 | static propTypes = {
31 | onArticleFocus: PropTypes.func.isRequired,
32 | articles: PropTypes.array,
33 | }
34 |
35 | static defaultProps = {
36 | articles: [],
37 | }
38 |
39 | state = {
40 | modalVisible: false,
41 | modal: ,
42 | }
43 |
44 | constructor(props) {
45 | super(props);
46 |
47 | this.toggleModal = this.toggleModal.bind(this);
48 | this.submitArticle = this.submitArticle.bind(this);
49 | this.editArticle = this.editArticle.bind(this);
50 | }
51 |
52 | submitArticle({ id, ...article }) {
53 | if (article.cancel) return this.toggleModal();
54 |
55 | const articles = firebase.firestore().collection('articles');
56 |
57 | if (!id) {
58 | return articles.add(article)
59 | .then(() => this.toggleModal())
60 | .catch(err => console.log('Error creating article', err));
61 | }
62 |
63 | const articleRef = articles.doc(id);
64 |
65 | if (article.delete) {
66 | return articleRef.delete()
67 | .then(() => this.toggleModal())
68 | .catch(err => console.log('Error deleting article', err));
69 | }
70 |
71 | return articleRef.set(article, { merge: true })
72 | .then(() => this.toggleModal())
73 | .catch(err => console.log('Error updating article', err));
74 | }
75 |
76 | editArticle(article) {
77 | this.setState({
78 | modal: ,
79 | modalVisible: true,
80 | });
81 | }
82 |
83 | toggleModal() {
84 | this.setState(({ modalVisible }) => ({
85 | modalVisible: !modalVisible,
86 | }));
87 | }
88 |
89 | render() {
90 | const { articles, onArticleFocus } = this.props;
91 | const { modal, modalVisible } = this.state;
92 | return (
93 |
94 |
95 | {articles.map(article => (
96 | this.editArticle(article)}
99 | onWrite={() => onArticleFocus(article)}
100 | {...article}
101 | />
102 | ))}
103 |
104 |
105 |
106 |
107 |
108 | );
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/src/routes/Article/ArticleLayout.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { Redirect, Link } from 'react-router-dom';
4 | // import posed from 'react-pose';
5 |
6 | import styled from 'styled-components';
7 |
8 | import { Desktop, Tablet, Mobile } from '../../components/DeviceQueries';
9 |
10 | /*
11 | const Swipable = posed.div({
12 | draggable: 'x',
13 | passive: {
14 | opacity: ['x', v => 1 - Math.abs(1.5 * v) / window.innerWidth],
15 | },
16 | dragEnd: {
17 | x: 0,
18 | y: 0,
19 | transition: { type: 'spring' },
20 | },
21 | });
22 | */
23 |
24 | const WrapperLarge = styled.div`
25 | display: grid;
26 | grid-template-areas:
27 | 'left titl right'
28 | 'left tags right'
29 | 'left subt right'
30 | 'left text right'
31 | 'left foot right';
32 | grid-template-columns: 3fr 5fr 3fr;
33 | padding-bottom: 100px;
34 | `;
35 |
36 | const WrapperMedium = styled.div`
37 | display: grid;
38 | grid-template-areas:
39 | 'titl titl titl'
40 | 'tags tags tags'
41 | 'subt subt subt'
42 | 'left text right'
43 | 'left foot right';
44 | grid-template-columns: 3fr 10fr 3fr;
45 | padding: 10px;
46 | padding-bottom: 100px;
47 | `;
48 |
49 | const FillWrapper = styled.div`
50 | display: grid;
51 | grid-template-areas:
52 | 'titl'
53 | 'tags'
54 | 'subt'
55 | 'text'
56 | 'foot';
57 | padding: 10px;
58 | padding-bottom: 100px;
59 | `;
60 |
61 | const BackButton = styled.div`
62 | position: fixed;
63 | bottom: 5vw;
64 | right: 5vw;
65 | width: 20vw;
66 | height: 20vw;
67 | background-color: white;
68 | box-shadow: 0px 2px 14px -6px rgba(0,0,0,0.75);
69 | border-radius: 50%;
70 | font-family: sans-serif;
71 | font-size: 5vw;
72 | display: flex;
73 | align-items: center;
74 | justify-content: center;
75 | `;
76 |
77 | export default class ArticleLayout extends Component {
78 | static propTypes = {
79 | children: PropTypes.node.isRequired,
80 | }
81 |
82 | state = {
83 | exitted: false,
84 | oldScroll: 0,
85 | scrolledUp: false,
86 | }
87 |
88 | constructor(props) {
89 | super(props);
90 | this.handleSwipe = this.handleSwipe.bind(this);
91 | window.onscroll = this.handleScroll.bind(this);
92 | }
93 |
94 | handleScroll() {
95 | const { oldScroll } = this.state;
96 | const newScroll = document.documentElement.scrollTop;
97 |
98 | this.setState(() => ({
99 | oldScroll: newScroll,
100 | scrolledUp: oldScroll < newScroll,
101 | }));
102 | }
103 |
104 | handleSwipe({ clientX, layerX }) {
105 | if (Math.abs(clientX - layerX) > (window.innerWidth / 3)) {
106 | this.setState(() => ({
107 | exitted: true,
108 | }));
109 | }
110 | }
111 |
112 | render() {
113 | const { children } = this.props;
114 | const { exitted, scrolledUp } = this.state;
115 |
116 | if (exitted) return ;
117 |
118 | return (
119 | <>
120 |
121 | {children}
122 |
123 |
124 | {children}
125 | {!scrolledUp && Home}
126 |
127 |
128 | {children}
129 | {!scrolledUp && Home}
130 |
131 | >
132 | );
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/src/routes/Article/Article.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | import styled from 'styled-components';
5 | import Markdown from 'react-markdown';
6 | import DocumentMeta from 'react-document-meta';
7 |
8 | // import firebase from 'firebase/compat/app';
9 | import 'firebase/firestore';
10 | import { getStorage, ref, getDownloadURL } from 'firebase/storage';
11 |
12 | import ArticleLayout from './ArticleLayout';
13 | import ArticleCard from '../../components/Article/ArticleCard';
14 | import Tag from '../../components/Tag/Tag';
15 |
16 | const Title = styled.h1`
17 | font-size: 3em;
18 | font-family: sans-serif;
19 | margin-bottom: 0;
20 | overflow-wrap: break-word;
21 | width: 100%;
22 | overflow: hidden;
23 | hyphens: auto;
24 | grid-area: titl;
25 | `;
26 |
27 | const Body = styled.div`
28 | grid-area: text;
29 | `;
30 |
31 | const Subtitle = styled.h2`
32 | font-family: sans-serif;
33 | grid-area: subt;
34 | margin-top: 0;
35 | `;
36 |
37 | const Tags = styled.div`
38 | grid-area: tags;
39 |
40 | & > button:first-child {
41 | margin-left: 0;
42 | }
43 |
44 | & > button:last-child {
45 | margin-right: 0;
46 | }
47 | `;
48 |
49 | const Related = styled.div`
50 | grid-area: foot;
51 | display: flex;
52 | flex-flow: row nowrap;
53 | justify-content: space-evenly;
54 | `;
55 |
56 | export default class Article extends Component {
57 | static propTypes = {
58 | title: PropTypes.string.isRequired,
59 | id: PropTypes.string,
60 | subtitle: PropTypes.string,
61 | markdown: PropTypes.string,
62 | history: PropTypes.array,
63 | tags: PropTypes.arrayOf(PropTypes.string),
64 | related: PropTypes.array,
65 | };
66 |
67 | static defaultProps = {
68 | id: null,
69 | subtitle: null,
70 | markdown: null,
71 | history: [],
72 | tags: [],
73 | related: [],
74 | };
75 |
76 | state = {
77 | markdown: null,
78 | }
79 |
80 | constructor(props) {
81 | super(props);
82 | this.fetchBody = this.fetchBody.bind(this);
83 | }
84 |
85 | componentDidMount() {
86 | this.fetchBody();
87 | }
88 |
89 | componentDidUpdate() {
90 | this.fetchBody();
91 | }
92 |
93 | fetchBody() {
94 | const { markdown } = this.state;
95 | const { id } = this.props;
96 |
97 | if (!id || markdown !== null) return;
98 |
99 | const storage = getStorage();
100 | const markdownRef = ref(storage, `${id}/body.md`);
101 |
102 | const reader = new FileReader();
103 | reader.addEventListener('loadend', e => this.setState({ markdown: e.srcElement.result }));
104 |
105 | getDownloadURL(markdownRef)
106 | .then(url => fetch(url))
107 | .then(res => res.blob())
108 | .then(blob => reader.readAsText(blob))
109 | .catch(err => this.setState({ markdown: `Error! \`${err.code}\`\n\n\`\`\`${err.message}\`\`\`` }));
110 | }
111 |
112 | render() {
113 | const {
114 | title, subtitle, tags, related,
115 | } = this.props;
116 |
117 | const { markdown } = this.state;
118 |
119 | const meta = {
120 | title,
121 | description: subtitle,
122 | };
123 |
124 | return (
125 |
126 |
127 | {title}
128 | {subtitle}
129 |
130 | {tags.map(tag => )}
131 |
132 |
133 |
134 | {
135 | related.map(({ id, ...article }) => )
136 | }
137 |
138 |
139 |
140 | );
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/src/components/Asset/AssetItem.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | // import firebase from 'firebase/compat/app';
5 | // import 'firebase/storage';
6 | import { getStorage, ref, getDownloadURL } from 'firebase/storage';
7 |
8 | import styled from 'styled-components';
9 | import { Submit } from '../Form/Form';
10 |
11 | const progressGradient = (progress, uploading) => `
12 | linear-gradient(to right,
13 | ${uploading ? '#1cd45e' : '#cfcfcf'} ${progress}%,
14 | #efefef ${progress}%
15 | )
16 | `;
17 |
18 | const Wrapper = styled.li.attrs(({ inactive, progress, uploading }) => ({
19 | style: ({
20 | background: (inactive ? progressGradient(progress, uploading) : '#fff'),
21 | }),
22 | }))`
23 | list-style: none;
24 | display: grid;
25 | grid-template-columns: minmax(0, 1fr) auto auto;
26 | padding: 10px 0 10px 0;
27 | border-radius: 4px;
28 | margin: 5px;
29 | box-shadow: 0px 0px 5px 0px rgba(50, 50, 50, 0.25);
30 | font: 1em arial;
31 | `;
32 |
33 | const Name = styled.h3`
34 | font: 1em arial;
35 | overflow: hidden;
36 | text-overflow: ellipsis;
37 | white-space: nowrap;
38 | `;
39 |
40 | export default class AssetItem extends Component {
41 | static propTypes = {
42 | onDelete: PropTypes.func.isRequired,
43 | articleId: PropTypes.string.isRequired,
44 | name: PropTypes.string.isRequired,
45 | inactive: PropTypes.bool,
46 | uploadTask: PropTypes.object,
47 | }
48 |
49 | static defaultProps = {
50 | inactive: false,
51 | uploadTask: null,
52 | }
53 |
54 |
55 | constructor(props) {
56 | super(props);
57 |
58 | const { inactive } = this.props;
59 |
60 | this.state = {
61 | url: '',
62 | bytes: 0,
63 | total: 1,
64 | uploading: inactive,
65 | };
66 |
67 | this.copyUrl = this.copyUrl.bind(this);
68 | this.togglePause = this.togglePause.bind(this);
69 | }
70 |
71 |
72 | componentDidMount() {
73 | const { articleId, name, uploadTask } = this.props;
74 |
75 | if (uploadTask) {
76 | const unSub = uploadTask.on('state_changed',
77 | ({ bytesTransferred, totalBytes }) => this.setState({
78 | bytes: bytesTransferred,
79 | total: totalBytes,
80 | }));
81 |
82 | this.setState({ unSub });
83 | }
84 |
85 | const storage = getStorage();
86 | // firebase.storage()
87 | // .ref()
88 | getDownloadURL(ref(storage, `${articleId}/${name}`))
89 | // .child(articleId)
90 | // .child(name)
91 | .then(url => this.setState({ url }))
92 | .catch(err => console.log('error loading url!', err));
93 | }
94 |
95 | componentWillUnmount() {
96 | const { uploadTask } = this.props;
97 | const { unSub } = this.state;
98 | if (unSub) unSub();
99 | if (uploadTask) uploadTask.cancel();
100 | }
101 |
102 | copyUrl() {
103 | const { url } = this.state;
104 | navigator.clipboard.writeText(url);
105 | }
106 |
107 | togglePause() {
108 | const { uploadTask } = this.props;
109 |
110 | this.setState(({ uploading }) => ({
111 | uploading: uploading
112 | ? !uploadTask.pause()
113 | : uploadTask.resume(),
114 | }));
115 | }
116 |
117 | render() {
118 | const { onDelete, name, inactive } = this.props;
119 | const { bytes, total, uploading } = this.state;
120 | return (
121 |
122 | {name}
123 | {inactive
124 | ?
125 | :
126 | }
127 |
128 |
129 | );
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/src/components/Article/ArticleForm.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import {
4 | Form, Header, ActionRow, Submit, Label, Text, Select, Option, Checkbox,
5 | } from '../Form/Form';
6 | import { TagContext } from '../Tag/TagProvider';
7 |
8 | export default class ArticleForm extends Component {
9 | static propTypes = {
10 | onSubmit: PropTypes.func.isRequired,
11 | id: PropTypes.string,
12 | url: PropTypes.string,
13 | visible: PropTypes.bool,
14 | tags: PropTypes.array,
15 | title: PropTypes.string,
16 | subtitle: PropTypes.string,
17 | }
18 |
19 | static defaultProps = {
20 | id: null,
21 | url: '',
22 | visible: true,
23 | tags: [],
24 | title: '',
25 | subtitle: '',
26 | };
27 |
28 | constructor(props) {
29 | super(props);
30 |
31 | const {
32 | url, visible, tags, title, subtitle, id,
33 | } = this.props;
34 |
35 | this.state = {
36 | url, visible, tags, title, subtitle, id,
37 | };
38 |
39 | this.handleChange = this.handleChange.bind(this);
40 | this.handleSelectChange = this.handleSelectChange.bind(this);
41 | this.handleCheckChange = this.handleCheckChange.bind(this);
42 | this.submit = this.submit.bind(this);
43 | this.cancel = this.cancel.bind(this);
44 | this.delete = this.delete.bind(this);
45 | }
46 |
47 | submit() {
48 | const { onSubmit } = this.props;
49 | onSubmit(this.state);
50 | }
51 |
52 | cancel() {
53 | const { onSubmit } = this.props;
54 | onSubmit({ cancel: true });
55 | }
56 |
57 | delete() {
58 | const { onSubmit } = this.props;
59 | const { id } = this.state;
60 | onSubmit({ id, delete: true });
61 | }
62 |
63 | handleChange(event) {
64 | this.setState({ [event.target.id]: event.target.value });
65 | }
66 |
67 | handleSelectChange(event) {
68 | const selectedValues = [...event.target.options].filter(o => o.selected).map(o => o.value);
69 |
70 | this.setState({ [event.target.id]: selectedValues });
71 | }
72 |
73 | handleCheckChange(event) {
74 | this.setState({ [event.target.id]: event.target.checked });
75 | }
76 |
77 | render() {
78 | const {
79 | url, title, subtitle, tags, visible,
80 | } = this.state;
81 | return (
82 |
122 | );
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/src/components/Edit/FocusArticle.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import styled from 'styled-components';
4 |
5 | // import firebase from 'firebase/compat/app';
6 | // import 'firebase/storage';
7 | import { getStorage, ref, getDownloadURL, uploadBytesResumable } from 'firebase/storage';
8 |
9 | import { TextArea, Label } from '../Form/Form';
10 | import EditAssets from './EditAssets';
11 |
12 | const Heading = styled.div`
13 | grid-area: article;
14 | display: flex;
15 | flex-flow: row nowrap;
16 | align-items: baseline;
17 | `;
18 |
19 | const Title = styled.h1`
20 | font-size: 2em;
21 | font-family: arial;
22 | margin: 0;
23 | margin-right: 10px;
24 | `;
25 |
26 | const Subtitle = styled.h2`
27 | font-size: 1.5em;
28 | font-family: arial;
29 | margin: 0;
30 | margin-right: 10px;
31 | `;
32 |
33 | const Small = styled.small`
34 | color: green;
35 | font-weight: bold;
36 | `;
37 |
38 | const Body = styled.form`
39 | grid-area: body;
40 | display: grid;
41 | grid-template-rows: auto 1fr;
42 | `;
43 |
44 | const Assets = styled.div`
45 | grid-area: assets;
46 | margin: 0;
47 | `;
48 |
49 | export default class FocusArticle extends Component {
50 | static propTypes = {
51 | id: PropTypes.string.isRequired,
52 | title: PropTypes.string,
53 | assets: PropTypes.array,
54 | }
55 |
56 | static defaultProps = {
57 | title: '',
58 | assets: [],
59 | };
60 |
61 | state = {
62 | body: '',
63 | bodyLoaded: false,
64 | bodySaved: false,
65 | bodyUploadTask: null,
66 | }
67 |
68 | constructor(props) {
69 | super(props);
70 |
71 | this.handleChange = this.handleChange.bind(this);
72 | this.loadArticle = this.loadArticle.bind(this);
73 | }
74 |
75 | componentDidMount() {
76 | this.loadArticle();
77 | }
78 |
79 |
80 | componentDidUpdate() {
81 | const { bodyLoaded } = this.state;
82 | if (!bodyLoaded) this.loadArticle();
83 | }
84 |
85 | loadArticle() {
86 | const { id } = this.props;
87 |
88 | const storage = getStorage();
89 | const bodyRef = ref(storage, `${id}/body.md`);
90 | // const bodyRef = ref(storage, id).child('body.md');
91 |
92 | const reader = new FileReader();
93 | reader.addEventListener('loadend', e => this.setState({ body: e.srcElement.result, bodyLoaded: true }));
94 |
95 | getDownloadURL(bodyRef)
96 | .then(url => fetch(url))
97 | .then(res => res.blob())
98 | .then(blob => reader.readAsText(blob))
99 | .catch(({ message, code }) => (
100 | (code === 'storage/object-not-found'
101 | ? this.setState({ body: '', bodyLoaded: true })
102 | : this.setState({ body: `${code}\n\n${message}` }))
103 | ));
104 | }
105 |
106 | handleChange(event) {
107 | const body = event.target.value;
108 | const { bodyUploadTask } = this.state;
109 |
110 | if (bodyUploadTask) bodyUploadTask.cancel(); // Don't upload two docs at same time
111 |
112 | const { id } = this.props;
113 | if (!id) return;
114 |
115 | const blob = new Blob([body], { type: 'text/markdown' });
116 |
117 | const storage = getStorage();
118 | const bodyRef = ref(storage, `${id}/body.md`);
119 | // const bodyRef = storage.child(id).child('body.md');
120 | // const bodyRef = ref(storage, id).child('body.md');
121 |
122 | const newUploadTask = uploadBytesResumable(bodyRef, blob, { contentType: 'text/markdown' });
123 | newUploadTask.then(() => this.setState({ bodySaved: true }))
124 | .catch(({ message, code }) => {
125 | if (code === 'storage/canceled') return;
126 | this.setState({ body: `${code}\n\n${message}` });
127 | });
128 |
129 | this.setState({ body, bodyUploadTask: newUploadTask, bodySaved: false });
130 | }
131 |
132 | render() {
133 | const { title, assets, id } = this.props;
134 | const {
135 | body, bodyLoaded, bodySaved,
136 | } = this.state;
137 | return (
138 | <>
139 |
140 | {title}
141 | {bodySaved && Saved}
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 | Assets
151 |
152 |
153 | >
154 | );
155 | }
156 | }
157 |
--------------------------------------------------------------------------------
/src/components/Edit/EditAssets.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import styled from 'styled-components';
4 |
5 | // import 'firebase/firestore';
6 | // import 'firebase/storage';
7 | import { getStorage, ref, uploadBytesResumable, deleteObject } from 'firebase/storage';
8 | import { getFirestore, doc, updateDoc, arrayRemove, arrayUnion } from 'firebase/firestore';
9 |
10 | import Dropzone from 'react-dropzone';
11 | import AssetItem from '../Asset/AssetItem';
12 | import { Label, File, Form } from '../Form/Form';
13 |
14 | const Wrapper = styled.ul`
15 | margin: 0;
16 | padding: 0;
17 | overflow-y: auto;
18 | `;
19 |
20 | // const Button = styled.button`
21 | // font-family: arial;
22 | // font-size: 2em;
23 | // border-radius: 4px;
24 | // cursor: pointer;
25 | // border-style: solid;
26 | // border-width: 2px;
27 | // width: 100%;
28 | // `;
29 |
30 | export default class EditAssets extends Component {
31 | static propTypes = {
32 | articleId: PropTypes.string.isRequired,
33 | assets: PropTypes.array,
34 | }
35 |
36 | static defaultProps = {
37 | assets: [],
38 | }
39 |
40 | state = {
41 | uploading: false,
42 | uploadQueue: [],
43 | }
44 |
45 | constructor(props) {
46 | super(props);
47 |
48 | this.deleteAsset = this.deleteAsset.bind(this);
49 | this.cancelUpload = this.cancelUpload.bind(this);
50 | this.onDrop = this.onDrop.bind(this);
51 | }
52 |
53 | onDrop(acceptedFiles) {
54 | if (acceptedFiles.length < 1) return;
55 |
56 | const { articleId } = this.props;
57 |
58 | const storage = getStorage();
59 |
60 | const db = getFirestore()
61 | const article = doc(db, 'articles', articleId);
62 |
63 | const { uploadQueue } = this.state;
64 |
65 | const newQueue = [
66 | ...uploadQueue,
67 | ...acceptedFiles.map((file) => {
68 | const storageRef = ref(storage, `${articleId}/${file.name}`);
69 | const uploadTask = uploadBytesResumable(storageRef, file);
70 |
71 | uploadTask.then(async () => {
72 | console.log('uploaded');
73 | await updateDoc(article, {
74 | assets: arrayUnion(file.name),
75 | });
76 |
77 | this.setState(({ uploadQueue: queue }) => ({
78 | uploadQueue: queue.filter(({ name }) => name !== file.name),
79 | }));
80 | });
81 |
82 | return { name: file.name, uploadTask };
83 | }),
84 | ];
85 |
86 | this.setState({
87 | uploadQueue: newQueue,
88 | });
89 | }
90 |
91 | deleteAsset(assetName) {
92 | const { articleId } = this.props;
93 |
94 | const db = getFirestore()
95 | const article = doc(db, 'articles', articleId);
96 |
97 | const storage = getStorage();
98 | const storageRef = ref(storage, `${articleId}/${assetName}`);
99 |
100 | deleteObject(storageRef)
101 | .then(async () => {
102 | await updateDoc(article, {
103 | assets: arrayRemove(assetName),
104 | })
105 | })
106 | .catch(err => console.log('Error deleting', err));
107 | }
108 |
109 | cancelUpload(fileName) {
110 | this.setState(({ uploadQueue }) => ({
111 | uploadQueue: uploadQueue.filter(({ name }) => name !== fileName),
112 | }));
113 | }
114 |
115 | render() {
116 | const { assets, articleId } = this.props;
117 | const { uploading, uploadQueue } = this.state;
118 | return (
119 |
120 | {
121 | [
122 | ...assets.map(assetName => (
123 | this.deleteAsset(assetName)}
128 | />
129 | )),
130 |
131 | ...uploadQueue.map(({ name, uploadTask }) => (
132 | this.cancelUpload(name)}
137 | uploadTask={uploadTask}
138 | inactive
139 | />
140 | )),
141 | ]
142 | }
143 |
155 |
156 | );
157 | }
158 | }
159 |
--------------------------------------------------------------------------------
/src/registerServiceWorker.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | // In production, we register a service worker to serve assets from local cache.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on the "N+1" visit to a page, since previously
7 | // cached resources are updated in the background.
8 |
9 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
10 | // This link also includes instructions on opting out of this behavior.
11 |
12 | const isLocalhost = Boolean(
13 | window.location.hostname === 'localhost' ||
14 | // [::1] is the IPv6 localhost address.
15 | window.location.hostname === '[::1]' ||
16 | // 127.0.0.1/8 is considered localhost for IPv4.
17 | window.location.hostname.match(
18 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
19 | )
20 | );
21 |
22 | export default function register() {
23 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
24 | // The URL constructor is available in all browsers that support SW.
25 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
26 | if (publicUrl.origin !== window.location.origin) {
27 | // Our service worker won't work if PUBLIC_URL is on a different origin
28 | // from what our page is served on. This might happen if a CDN is used to
29 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
30 | return;
31 | }
32 |
33 | window.addEventListener('load', () => {
34 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
35 |
36 | if (isLocalhost) {
37 | // This is running on localhost. Lets check if a service worker still exists or not.
38 | checkValidServiceWorker(swUrl);
39 |
40 | // Add some additional logging to localhost, pointing developers to the
41 | // service worker/PWA documentation.
42 | navigator.serviceWorker.ready.then(() => {
43 | console.log(
44 | 'This web app is being served cache-first by a service ' +
45 | 'worker. To learn more, visit https://goo.gl/SC7cgQ'
46 | );
47 | });
48 | } else {
49 | // Is not local host. Just register service worker
50 | registerValidSW(swUrl);
51 | }
52 | });
53 | }
54 | }
55 |
56 | function registerValidSW(swUrl) {
57 | navigator.serviceWorker
58 | .register(swUrl)
59 | .then(registration => {
60 | registration.onupdatefound = () => {
61 | const installingWorker = registration.installing;
62 | installingWorker.onstatechange = () => {
63 | if (installingWorker.state === 'installed') {
64 | if (navigator.serviceWorker.controller) {
65 | // At this point, the old content will have been purged and
66 | // the fresh content will have been added to the cache.
67 | // It's the perfect time to display a "New content is
68 | // available; please refresh." message in your web app.
69 | console.log('New content is available; please refresh.');
70 | } else {
71 | // At this point, everything has been precached.
72 | // It's the perfect time to display a
73 | // "Content is cached for offline use." message.
74 | console.log('Content is cached for offline use.');
75 | }
76 | }
77 | };
78 | };
79 | })
80 | .catch(error => {
81 | console.error('Error during service worker registration:', error);
82 | });
83 | }
84 |
85 | function checkValidServiceWorker(swUrl) {
86 | // Check if the service worker can be found. If it can't reload the page.
87 | fetch(swUrl)
88 | .then(response => {
89 | // Ensure service worker exists, and that we really are getting a JS file.
90 | if (
91 | response.status === 404 ||
92 | response.headers.get('content-type').indexOf('javascript') === -1
93 | ) {
94 | // No service worker found. Probably a different app. Reload the page.
95 | navigator.serviceWorker.ready.then(registration => {
96 | registration.unregister().then(() => {
97 | window.location.reload();
98 | });
99 | });
100 | } else {
101 | // Service worker found. Proceed as normal.
102 | registerValidSW(swUrl);
103 | }
104 | })
105 | .catch(() => {
106 | console.log(
107 | 'No internet connection found. App is running in offline mode.'
108 | );
109 | });
110 | }
111 |
112 | export function unregister() {
113 | if ('serviceWorker' in navigator) {
114 | navigator.serviceWorker.ready.then(registration => {
115 | registration.unregister();
116 | });
117 | }
118 | }
119 |
--------------------------------------------------------------------------------