├── 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 | 6 | 15 | 16 | 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 | 8 | { 9 | colors.map( 10 | (color, i) => ( 11 | 21 | ), 22 | ) 23 | } 24 | 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 |
e.preventDefault()}> 64 | 65 |
Edit Tag
66 | 67 |
68 | 69 | 70 | 71 | 72 | 73 |
74 | 75 |
76 | 77 | 78 |
79 | 80 | 81 | 82 | 83 | 84 | 85 |
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 |
Authenticate
73 | 74 |
75 | 76 | 77 |
78 | 79 |
80 | 81 | 82 |
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 |
e.preventDefault()}> 83 |
Edit Article
84 | 85 |
86 | 87 | 88 |
89 | 90 |
91 | 92 | 93 |
94 | 95 |
96 | 97 | 98 |
99 | 100 | 101 | 110 | 111 |
112 | 113 | 114 |
115 | 116 | 117 | 118 | 119 | 120 | 121 |
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 |