├── .dockerignore ├── .github └── img │ ├── editor.png │ ├── home.png │ ├── snippet.png │ └── snippets.png ├── .gitignore ├── .prettierignore ├── CHANGELOG.md ├── Dockerfile ├── Dockerfile.arm ├── LICENCE.md ├── README.md ├── client ├── .gitignore ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.tsx │ ├── components │ │ ├── Navigation │ │ │ ├── Navbar.tsx │ │ │ └── routes.json │ │ ├── SearchBar.tsx │ │ ├── Snippets │ │ │ ├── SnippetCard.tsx │ │ │ ├── SnippetCode.tsx │ │ │ ├── SnippetDetails.tsx │ │ │ ├── SnippetDocs.tsx │ │ │ ├── SnippetForm.tsx │ │ │ ├── SnippetGrid.tsx │ │ │ └── SnippetPin.tsx │ │ └── UI │ │ │ ├── Badge.tsx │ │ │ ├── Button.tsx │ │ │ ├── Card.tsx │ │ │ ├── EmptyState.tsx │ │ │ ├── Layout.tsx │ │ │ ├── PageHeader.tsx │ │ │ └── index.ts │ ├── containers │ │ ├── Editor.tsx │ │ ├── Home.tsx │ │ ├── Snippet.tsx │ │ ├── Snippets.tsx │ │ └── index.ts │ ├── data │ │ ├── aliases_raw.json │ │ └── languages.json │ ├── index.tsx │ ├── react-app-env.d.ts │ ├── store │ │ ├── SnippetsContext.tsx │ │ └── index.ts │ ├── styles │ │ ├── _bootswatch.scss │ │ ├── _variables.scss │ │ └── style.scss │ ├── typescript │ │ ├── interfaces │ │ │ ├── Context.ts │ │ │ ├── Model.ts │ │ │ ├── Response.ts │ │ │ ├── Route.ts │ │ │ ├── SearchQuery.ts │ │ │ ├── Snippet.ts │ │ │ ├── Statistics.ts │ │ │ └── index.ts │ │ └── types │ │ │ ├── Colors.ts │ │ │ └── index.ts │ └── utils │ │ ├── badgeColor.ts │ │ ├── dateParser.ts │ │ ├── findLanguage.ts │ │ ├── index.ts │ │ └── searchParser.ts └── tsconfig.json ├── docker-compose.yml ├── nodemon.json ├── package-lock.json ├── package.json ├── src ├── config │ └── .env ├── controllers │ └── snippets.ts ├── db │ ├── associateModels.ts │ ├── index.ts │ └── migrations │ │ ├── 00_initial.ts │ │ ├── 01_pinned_snippets.ts │ │ └── 02_tags.ts ├── environment.d.ts ├── middleware │ ├── asyncWrapper.ts │ ├── errorHandler.ts │ ├── index.ts │ └── requireBody.ts ├── models │ ├── Snippet.ts │ ├── Snippet_Tag.ts │ ├── Tag.ts │ └── index.ts ├── routes │ └── snippets.ts ├── server.ts ├── typescript │ ├── interfaces │ │ ├── Body.ts │ │ ├── Model.ts │ │ ├── SearchQuery.ts │ │ ├── Snippet.ts │ │ ├── Snippet_Tag.ts │ │ ├── Tag.ts │ │ └── index.ts │ └── types │ │ └── ErrorLevel.ts └── utils │ ├── ErrorResponse.ts │ ├── Logger.ts │ ├── createTags.ts │ ├── getTags.ts │ ├── index.ts │ └── tagParser.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ -------------------------------------------------------------------------------- /.github/img/editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/snippet-box/f501941a22ead0fccd196d109098e9b065205d98/.github/img/editor.png -------------------------------------------------------------------------------- /.github/img/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/snippet-box/f501941a22ead0fccd196d109098e9b065205d98/.github/img/home.png -------------------------------------------------------------------------------- /.github/img/snippet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/snippet-box/f501941a22ead0fccd196d109098e9b065205d98/.github/img/snippet.png -------------------------------------------------------------------------------- /.github/img/snippets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/snippet-box/f501941a22ead0fccd196d109098e9b065205d98/.github/img/snippets.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | data/* 4 | !data/**/*.json -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.css 2 | client/**/data/**/*.json 3 | CHANGELOG.md -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### v1.4 (2021-10-14) 2 | - Added search functionality ([#18](https://github.com/pawelmalak/snippet-box/issues/18)) 3 | - Fixed date parsing bug ([#22](https://github.com/pawelmalak/snippet-box/issues/22)) 4 | - Minor UI fixes 5 | 6 | ### v1.3.1 (2021-10-05) 7 | - Added support for raw snippets ([#15](https://github.com/pawelmalak/snippet-box/issues/15)) 8 | 9 | ### v1.3 (2021-09-30) 10 | - Added dark mode ([#7](https://github.com/pawelmalak/snippet-box/issues/7)) 11 | - Added syntax highlighting ([#14](https://github.com/pawelmalak/snippet-box/issues/14)) 12 | 13 | ### v1.2 (2021-09-28) 14 | - Added support for tags ([#10](https://github.com/pawelmalak/snippet-box/issues/10)) 15 | 16 | ### v1.1 (2021-09-24) 17 | - Added pin icon directly to snippet card ([#4](https://github.com/pawelmalak/snippet-box/issues/4)) 18 | - Fixed issue with copying snippets ([#6](https://github.com/pawelmalak/snippet-box/issues/6)) 19 | 20 | ### v1.0 (2021-09-23) 21 | Initial release -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN npm install 8 | 9 | COPY . . 10 | 11 | # Install client dependencies 12 | RUN mkdir -p ./public ./data \ 13 | && cd client \ 14 | && npm install \ 15 | && npm rebuild node-sass 16 | 17 | # Build 18 | RUN npm run build \ 19 | && mv ./client/build/* ./public 20 | 21 | # Clean up src files 22 | RUN rm -rf src/ ./client \ 23 | && npm prune --production 24 | 25 | EXPOSE 5000 26 | 27 | ENV NODE_ENV=production 28 | 29 | CMD ["node", "build/server.js"] -------------------------------------------------------------------------------- /Dockerfile.arm: -------------------------------------------------------------------------------- 1 | FROM node:14-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN apk --no-cache --virtual build-dependencies add python make g++ \ 8 | && npm install 9 | 10 | COPY . . 11 | 12 | # Install client dependencies 13 | RUN mkdir -p ./public ./data \ 14 | && cd client \ 15 | && npm install \ 16 | && npm rebuild node-sass 17 | 18 | # Build 19 | RUN npm run build \ 20 | && mv ./client/build/* ./public 21 | 22 | # Clean up src files 23 | RUN rm -rf src/ ./client \ 24 | && npm prune --production \ 25 | && apk del build-dependencies 26 | 27 | EXPOSE 5000 28 | 29 | ENV NODE_ENV=production 30 | 31 | CMD ["node", "build/server.js"] -------------------------------------------------------------------------------- /LICENCE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Paweł Malak 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Snippet Box 2 | 3 | ![Snippet library screenshot](./.github/img/snippets.png) 4 | 5 | ## Description 6 | 7 | Snippet Box is a simple self-hosted app for organizing your code snippets. It allows you to easily create, edit, browse and manage your snippets in various languages. With built-in Markdown support, Snippet Box makes it very easy to add notes or simple documentation to your code. 8 | 9 | ## Technology 10 | 11 | - Backend 12 | - Node.js 13 | - Typescript 14 | - Express.js 15 | - Sequelize ORM + SQLite 16 | - Frontend 17 | - React 18 | - TypeScript 19 | - Bootstrap 20 | - Deployment 21 | - Docker 22 | 23 | ## Development 24 | 25 | ```sh 26 | # clone repository 27 | git clone https://github.com/pawelmalak/snippet-box 28 | cd snippet-box 29 | 30 | # install dependencies (run only once) 31 | npm run init 32 | 33 | # start backend and frontend development servers 34 | npm run dev 35 | ``` 36 | 37 | ## Installation 38 | 39 | ### With Docker 40 | 41 | #### Docker Hub 42 | 43 | [Docker Hub image link](https://hub.docker.com/r/pawelmalak/snippet-box). 44 | For arm platforms use `:arm` tag. 45 | 46 | #### Building image 47 | 48 | ```sh 49 | # Building image for Linux 50 | docker build -t snippet-box . 51 | 52 | # Build image for ARM 53 | docker buildx build \ 54 | --platform linux/arm/v7,linux/arm64 \ 55 | -f Dockerfile.arm \ 56 | -t snippet-box:arm . 57 | ``` 58 | 59 | #### Deployment 60 | 61 | ```sh 62 | # run container 63 | # for ARM use snippet-box:arm tag 64 | docker run -p 5000:5000 -v /path/to/data:/app/data snippet-box 65 | ``` 66 | 67 | #### Docker Compose 68 | 69 | ```yaml 70 | version: '3' 71 | services: 72 | snippet-box: 73 | image: pawelmalak/snippet-box:latest 74 | container_name: snippet-box 75 | volumes: 76 | - /path/to/host/data:/app/data 77 | ports: 78 | - 5000:5000 79 | restart: unless-stopped 80 | ``` 81 | 82 | ### Without Docker 83 | 84 | Follow instructions from wiki - [Installation without Docker](https://github.com/pawelmalak/snippet-box/wiki/Installation-without-Docker) 85 | 86 | ## Functionality 87 | 88 | - Search 89 | - Search your snippets with built-in tags and language filters 90 | - Pinned snippets 91 | - Pin your favorite / important snippets to home screen for easy and quick access 92 | 93 | ![Homescreen screenshot](./.github/img/home.png) 94 | 95 | - Snippet library 96 | - Manage your snippets through snippet library 97 | - Easily filter and access your code using tags 98 | 99 | ![Snippet library screenshot](./.github/img/snippets.png) 100 | 101 | - Snippet 102 | - View your code, snippet details and documentation 103 | - Built-in syntax highlighting 104 | - Easily perform snippet actions like edit, pin or delete from a single place 105 | 106 | ![Snippet screenshot](./.github/img/snippet.png) 107 | 108 | - Editor 109 | - Create and edit your snippets from simple and easy to use editor 110 | 111 | ![Editor screenshot](./.github/img/editor.png) 112 | 113 | ## Usage 114 | 115 | ### Search functionality 116 | 117 | Visit wiki for search functionality and available filters reference: [Search functionality](https://github.com/pawelmalak/snippet-box/wiki/Search-functionality) 118 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@icons-pack/react-simple-icons": "^4.6.1", 7 | "@mdi/js": "^6.1.95", 8 | "@mdi/react": "^1.5.0", 9 | "@types/node": "^12.20.25", 10 | "@types/react": "^17.0.21", 11 | "@types/react-dom": "^17.0.9", 12 | "@types/react-router-dom": "^5.3.0", 13 | "axios": "^0.21.4", 14 | "bootstrap": "^5.1.1", 15 | "clipboard-copy": "^4.0.1", 16 | "dayjs": "^1.10.7", 17 | "highlight.js": "^11.2.0", 18 | "node-sass": "^6.0.1", 19 | "react": "^17.0.2", 20 | "react-dom": "^17.0.2", 21 | "react-markdown": "^7.0.1", 22 | "react-router-dom": "^5.3.0", 23 | "react-scripts": "4.0.3", 24 | "typescript": "^4.4.3" 25 | }, 26 | "scripts": { 27 | "start": "react-scripts start", 28 | "build": "react-scripts build", 29 | "test": "react-scripts test", 30 | "eject": "react-scripts eject" 31 | }, 32 | "eslintConfig": { 33 | "extends": [ 34 | "react-app", 35 | "react-app/jest" 36 | ] 37 | }, 38 | "browserslist": { 39 | "production": [ 40 | ">0.2%", 41 | "not dead", 42 | "not op_mini all" 43 | ], 44 | "development": [ 45 | "last 1 chrome version", 46 | "last 1 firefox version", 47 | "last 1 safari version" 48 | ] 49 | }, 50 | "proxy": "http://localhost:5000", 51 | "devDependencies": { 52 | "purgecss": "^4.0.3" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/snippet-box/f501941a22ead0fccd196d109098e9b065205d98/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Snippet Box 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /client/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/snippet-box/f501941a22ead0fccd196d109098e9b065205d98/client/public/logo192.png -------------------------------------------------------------------------------- /client/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/snippet-box/f501941a22ead0fccd196d109098e9b065205d98/client/public/logo512.png -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter, Switch, Route } from 'react-router-dom'; 2 | import { Navbar } from './components/Navigation/Navbar'; 3 | import { Editor, Home, Snippet, Snippets } from './containers'; 4 | import { SnippetsContextProvider } from './store'; 5 | 6 | export const App = () => { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /client/src/components/Navigation/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import { NavLink } from 'react-router-dom'; 2 | import { Route } from '../../typescript/interfaces'; 3 | import { routes as clientRoutes } from './routes.json'; 4 | 5 | export const Navbar = (): JSX.Element => { 6 | const routes = clientRoutes as Route[]; 7 | 8 | return ( 9 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /client/src/components/Navigation/routes.json: -------------------------------------------------------------------------------- 1 | { 2 | "routes": [ 3 | { 4 | "name": "Home", 5 | "dest": "/" 6 | }, 7 | { 8 | "name": "Snippets", 9 | "dest": "/snippets" 10 | }, 11 | { 12 | "name": "Editor", 13 | "dest": "/editor" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /client/src/components/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect, KeyboardEvent, useContext } from 'react'; 2 | import { SnippetsContext } from '../store'; 3 | import { searchParser } from '../utils'; 4 | 5 | export const SearchBar = (): JSX.Element => { 6 | const { searchSnippets } = useContext(SnippetsContext); 7 | const inputRef = useRef(document.createElement('input')); 8 | 9 | useEffect(() => { 10 | inputRef.current.focus(); 11 | }, [inputRef]); 12 | 13 | const inputHandler = (e: KeyboardEvent) => { 14 | const query = searchParser(inputRef.current.value); 15 | 16 | if (e.key === 'Enter') { 17 | searchSnippets(query); 18 | } else if (e.key === 'Escape') { 19 | inputRef.current.value = ''; 20 | searchSnippets(searchParser(inputRef.current.value)); 21 | } 22 | }; 23 | 24 | return ( 25 |
26 | inputHandler(e)} 32 | /> 33 |
34 | Search by pressing `Enter`. Clear with `Esc`. Read more about available 35 | filters{' '} 36 | 42 | here 43 | 44 |
45 |
46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /client/src/components/Snippets/SnippetCard.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | import { useContext } from 'react'; 3 | import { Snippet } from '../../typescript/interfaces'; 4 | import { dateParser, badgeColor } from '../../utils'; 5 | import { Badge, Button, Card } from '../UI'; 6 | import { SnippetsContext } from '../../store'; 7 | import copy from 'clipboard-copy'; 8 | import { SnippetPin } from './SnippetPin'; 9 | 10 | interface Props { 11 | snippet: Snippet; 12 | } 13 | 14 | export const SnippetCard = (props: Props): JSX.Element => { 15 | const { title, description, language, code, id, createdAt, isPinned } = 16 | props.snippet; 17 | const { setSnippet } = useContext(SnippetsContext); 18 | 19 | const copyHandler = () => { 20 | copy(code); 21 | }; 22 | 23 | return ( 24 | 25 | {/* TITLE */} 26 |
27 | {title} 28 | 29 |
30 | 31 |
32 | {/* LANGUAGE */} 33 | 34 |
35 | 36 | {/* DESCRIPTION */} 37 |

{description ? description : 'No description'}

38 | 39 |
40 | {/* UPDATE DATE */} 41 |

Created {dateParser(createdAt).relative}

42 |
43 | 44 | {/* ACTIONS */} 45 |
46 | 52 |
70 |
71 |
72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /client/src/components/Snippets/SnippetCode.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { findLanguage } from '../../utils'; 3 | import hljs from 'highlight.js'; 4 | import 'highlight.js/styles/atom-one-dark.css'; 5 | 6 | interface Props { 7 | code: string; 8 | language: string; 9 | } 10 | 11 | export const SnippetCode = (props: Props): JSX.Element => { 12 | const { code, language } = props; 13 | 14 | const syntax = findLanguage(language) ? language : 'plaintext'; 15 | 16 | useEffect(() => { 17 | hljs.highlightAll(); 18 | }, []); 19 | 20 | return ( 21 |
22 |       
26 |         {code}
27 |       
28 |     
29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /client/src/components/Snippets/SnippetDetails.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { useHistory } from 'react-router-dom'; 3 | import { SnippetsContext } from '../../store'; 4 | import { Snippet } from '../../typescript/interfaces'; 5 | import { dateParser } from '../../utils'; 6 | import { Badge, Button, Card } from '../UI'; 7 | import copy from 'clipboard-copy'; 8 | import { SnippetPin } from './SnippetPin'; 9 | 10 | interface Props { 11 | snippet: Snippet; 12 | } 13 | 14 | export const SnippetDetails = (props: Props): JSX.Element => { 15 | const { 16 | title, 17 | language, 18 | tags, 19 | createdAt, 20 | updatedAt, 21 | description, 22 | code, 23 | id, 24 | isPinned 25 | } = props.snippet; 26 | 27 | const history = useHistory(); 28 | 29 | const { deleteSnippet, setSnippet } = useContext(SnippetsContext); 30 | 31 | const creationDate = dateParser(createdAt); 32 | const updateDate = dateParser(updatedAt); 33 | 34 | // const copyHandler = () => { 35 | // copy(code); 36 | // }; 37 | 38 | return ( 39 | 40 |
41 | {title} 42 | 43 |
44 |

{description}

45 | 46 | {/* LANGUAGE */} 47 |
48 | Language 49 | {language} 50 |
51 | 52 | {/* CREATED AT */} 53 |
54 | Created 55 | {creationDate.relative} 56 |
57 | 58 | {/* UPDATED AT */} 59 |
60 | Last updated 61 | {updateDate.relative} 62 |
63 |
64 | 65 | {/* TAGS */} 66 |
67 | {tags.map((tag, idx) => ( 68 | 69 | 70 | 71 | ))} 72 |
73 |
74 | 75 | {/* ACTIONS */} 76 |
77 |
118 |
119 | ); 120 | }; 121 | -------------------------------------------------------------------------------- /client/src/components/Snippets/SnippetDocs.tsx: -------------------------------------------------------------------------------- 1 | import ReactMarkdown from 'react-markdown'; 2 | 3 | interface Props { 4 | markdown: string; 5 | } 6 | 7 | export const SnippetDocs = (props: Props): JSX.Element => { 8 | return ( 9 |
10 | {props.markdown} 11 |
12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /client/src/components/Snippets/SnippetForm.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeEvent, 3 | FormEvent, 4 | Fragment, 5 | useState, 6 | useContext, 7 | useEffect 8 | } from 'react'; 9 | import { SnippetsContext } from '../../store'; 10 | import { NewSnippet } from '../../typescript/interfaces'; 11 | import { Button, Card } from '../UI'; 12 | 13 | interface Props { 14 | inEdit?: boolean; 15 | } 16 | 17 | export const SnippetForm = (props: Props): JSX.Element => { 18 | const { inEdit = false } = props; 19 | const { createSnippet, currentSnippet, updateSnippet } = 20 | useContext(SnippetsContext); 21 | 22 | const [formData, setFormData] = useState({ 23 | title: '', 24 | description: '', 25 | language: '', 26 | code: '', 27 | docs: '', 28 | isPinned: false, 29 | tags: [] 30 | }); 31 | 32 | useEffect(() => { 33 | if (inEdit) { 34 | if (currentSnippet) { 35 | setFormData({ ...currentSnippet }); 36 | } 37 | } 38 | }, [currentSnippet]); 39 | 40 | const inputHandler = ( 41 | e: ChangeEvent 42 | ) => { 43 | setFormData({ 44 | ...formData, 45 | [e.target.name]: e.target.value 46 | }); 47 | }; 48 | 49 | const stringToTags = (e: ChangeEvent) => { 50 | const tags = e.target.value.split(','); 51 | setFormData({ 52 | ...formData, 53 | tags 54 | }); 55 | }; 56 | 57 | const tagsToString = (): string => { 58 | return formData.tags.join(','); 59 | }; 60 | 61 | const formHandler = (e: FormEvent) => { 62 | e.preventDefault(); 63 | 64 | if (inEdit) { 65 | if (currentSnippet) { 66 | updateSnippet(formData, currentSnippet.id); 67 | } 68 | } else { 69 | createSnippet(formData); 70 | } 71 | }; 72 | 73 | return ( 74 | 75 |
76 | 77 |
formHandler(e)}> 78 | {/* DETAILS SECTION */} 79 |
Snippet details
80 | 81 | {/* TITLE */} 82 |
83 | 86 | inputHandler(e)} 95 | /> 96 |
97 | 98 | {/* DESCRIPTION */} 99 |
100 | 103 | inputHandler(e)} 111 | /> 112 |
113 | 114 | {/* LANGUAGE */} 115 |
116 | 119 | inputHandler(e)} 128 | /> 129 |
130 | 131 | {/* TAGS */} 132 |
133 | 136 | stringToTags(e)} 144 | /> 145 |
146 | Tags should be separated with a comma. Language tag will be 147 | added automatically 148 |
149 |
150 |
151 | 152 | {/* CODE SECTION */} 153 |
Snippet code
154 |
155 | 165 |
166 |
167 | 168 | {/* DOCS SECTION */} 169 |
Snippet documentation
170 |
171 | 180 |
181 | 182 | {/* SUBMIT SECTION */} 183 |
184 |
190 |
191 |
192 |
193 |
194 | ); 195 | }; 196 | -------------------------------------------------------------------------------- /client/src/components/Snippets/SnippetGrid.tsx: -------------------------------------------------------------------------------- 1 | import { Snippet } from '../../typescript/interfaces'; 2 | import { SnippetCard } from './SnippetCard'; 3 | 4 | interface Props { 5 | snippets: Snippet[]; 6 | } 7 | 8 | export const SnippetGrid = (props: Props): JSX.Element => { 9 | const { snippets } = props; 10 | 11 | return ( 12 |
13 | {snippets.map(snippet => ( 14 |
15 | 16 |
17 | ))} 18 |
19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /client/src/components/Snippets/SnippetPin.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { SnippetsContext } from '../../store'; 3 | import Icon from '@mdi/react'; 4 | import { mdiPin, mdiPinOutline } from '@mdi/js'; 5 | 6 | interface Props { 7 | id: number; 8 | isPinned: boolean; 9 | } 10 | 11 | export const SnippetPin = (props: Props): JSX.Element => { 12 | const { toggleSnippetPin } = useContext(SnippetsContext); 13 | const { id, isPinned } = props; 14 | 15 | return ( 16 |
toggleSnippetPin(id)} className='cursor-pointer'> 17 | {isPinned ? ( 18 | 19 | ) : ( 20 | 21 | )} 22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /client/src/components/UI/Badge.tsx: -------------------------------------------------------------------------------- 1 | import { Color } from '../../typescript/types'; 2 | 3 | interface Props { 4 | text: string; 5 | color: Color; 6 | } 7 | 8 | export const Badge = (props: Props): JSX.Element => { 9 | const { text, color } = props; 10 | 11 | return {text}; 12 | }; 13 | -------------------------------------------------------------------------------- /client/src/components/UI/Button.tsx: -------------------------------------------------------------------------------- 1 | import { Color } from '../../typescript/types'; 2 | 3 | interface Props { 4 | text: string; 5 | color: Color; 6 | outline?: boolean; 7 | small?: boolean; 8 | handler?: () => void; 9 | classes?: string; 10 | type?: 'button' | 'submit' | 'reset'; 11 | } 12 | 13 | export const Button = (props: Props): JSX.Element => { 14 | const { 15 | text, 16 | color, 17 | outline = false, 18 | small = false, 19 | handler, 20 | classes = '', 21 | type = 'button' 22 | } = props; 23 | 24 | const elClasses = [ 25 | 'btn', 26 | outline ? `btn-outline-${color}` : `btn-${color}`, 27 | small && 'btn-sm', 28 | classes 29 | ]; 30 | 31 | return ( 32 | 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /client/src/components/UI/Card.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | interface Props { 4 | title?: string; 5 | children?: ReactNode; 6 | classes?: string; 7 | bodyClasses?: string; 8 | } 9 | 10 | export const Card = (props: Props): JSX.Element => { 11 | const { title, children, classes = '', bodyClasses = '' } = props; 12 | 13 | const parentClasses = `card mb-3 ${classes}`; 14 | const childClasses = `card-body ${bodyClasses}`; 15 | 16 | return ( 17 |
18 |
19 |
{title}
20 | {children} 21 |
22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /client/src/components/UI/EmptyState.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | 3 | export const EmptyState = (): JSX.Element => { 4 | const editorLink = ( 5 | 6 | editor 7 | 8 | ); 9 | 10 | return ( 11 |
12 |

You currently don't have any snippets

13 |

Go to the {editorLink} and create one

14 |
15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /client/src/components/UI/Layout.tsx: -------------------------------------------------------------------------------- 1 | interface Props { 2 | children: JSX.Element | JSX.Element[]; 3 | } 4 | 5 | export const Layout = (props: Props): JSX.Element => { 6 | return ( 7 |
8 |
{props.children}
9 |
10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /client/src/components/UI/PageHeader.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | 3 | interface Props { 4 | title: string; 5 | prevDest?: string; 6 | prevState?: T; 7 | } 8 | 9 | export const PageHeader = (props: Props): JSX.Element => { 10 | const { title, prevDest, prevState } = props; 11 | 12 | return ( 13 |
14 |

{title}

15 | {prevDest && ( 16 |
17 | 24 | <- Go back 25 | 26 |
27 | )} 28 |
29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /client/src/components/UI/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Layout'; 2 | export * from './Badge'; 3 | export * from './Card'; 4 | export * from './PageHeader'; 5 | export * from './Button'; 6 | export * from './EmptyState'; 7 | -------------------------------------------------------------------------------- /client/src/containers/Editor.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useEffect, useContext, useState } from 'react'; 2 | import { useLocation, useParams } from 'react-router-dom'; 3 | import { SnippetForm } from '../components/Snippets/SnippetForm'; 4 | import { Layout, PageHeader } from '../components/UI'; 5 | import { SnippetsContext } from '../store'; 6 | 7 | interface Params { 8 | id?: string; 9 | } 10 | 11 | export const Editor = (): JSX.Element => { 12 | const { setSnippet: setCurrentSnippet } = useContext(SnippetsContext); 13 | const [inEdit, setInEdit] = useState(false); 14 | 15 | // Get previous location 16 | const location = useLocation<{ from: string }>(); 17 | const { from } = location.state || '/snippets'; 18 | 19 | // Get id 20 | const { id } = useParams(); 21 | 22 | // Set snippet 23 | useEffect(() => { 24 | if (id) { 25 | setCurrentSnippet(+id); 26 | setInEdit(true); 27 | } 28 | }, []); 29 | 30 | return ( 31 | 32 | {inEdit ? ( 33 | 34 | 35 | title='Edit snippet' 36 | prevDest={from} 37 | prevState={{ from: '/snippets' }} 38 | /> 39 | 40 | 41 | ) : ( 42 | 43 | 44 | 45 | 46 | )} 47 | 48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /client/src/containers/Home.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useContext, Fragment } from 'react'; 2 | import { SnippetsContext } from '../store'; 3 | import { Layout, PageHeader, EmptyState } from '../components/UI'; 4 | import { SnippetGrid } from '../components/Snippets/SnippetGrid'; 5 | import { SearchBar } from '../components/SearchBar'; 6 | 7 | export const Home = (): JSX.Element => { 8 | const { snippets, getSnippets, searchResults } = useContext(SnippetsContext); 9 | 10 | useEffect(() => { 11 | getSnippets(); 12 | }, []); 13 | 14 | return ( 15 | 16 | {snippets.length === 0 ? ( 17 | 18 | ) : ( 19 | 20 | 21 | 22 |
23 | 24 |
25 | 26 | {snippets.some(s => s.isPinned) && ( 27 | 28 | 29 |
30 | s.isPinned)} /> 31 |
32 |
33 | )} 34 |
35 | )} 36 |
37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /client/src/containers/Snippet.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useContext, useEffect } from 'react'; 2 | import { useParams, useLocation } from 'react-router-dom'; 3 | import { SnippetCode } from '../components/Snippets/SnippetCode'; 4 | import { Layout, PageHeader, Card } from '../components/UI'; 5 | import { SnippetsContext } from '../store'; 6 | import { SnippetDetails } from '../components/Snippets/SnippetDetails'; 7 | import { SnippetDocs } from '../components/Snippets/SnippetDocs'; 8 | 9 | interface Params { 10 | id: string; 11 | } 12 | 13 | export const Snippet = (): JSX.Element => { 14 | const { currentSnippet, getSnippetById } = useContext(SnippetsContext); 15 | const { id } = useParams(); 16 | 17 | // Get previous location 18 | const location = useLocation<{ from: string }>(); 19 | const { from } = location.state || '/snippets'; 20 | 21 | useEffect(() => { 22 | getSnippetById(+id); 23 | }, []); 24 | 25 | return ( 26 | 27 | {!currentSnippet ? ( 28 |
Loading...
29 | ) : ( 30 | 31 | 32 |
33 | 37 |
38 |
39 | 40 |
41 | {currentSnippet.docs && ( 42 |
43 | 44 |
45 | 46 |
47 |
48 | )} 49 |
50 | )} 51 |
52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /client/src/containers/Snippets.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useContext, useState, Fragment } from 'react'; 2 | import { SnippetsContext } from '../store'; 3 | import { SnippetGrid } from '../components/Snippets/SnippetGrid'; 4 | import { Button, Card, EmptyState, Layout } from '../components/UI'; 5 | import { Snippet } from '../typescript/interfaces'; 6 | 7 | export const Snippets = (): JSX.Element => { 8 | const { snippets, tagCount, getSnippets, countTags } = 9 | useContext(SnippetsContext); 10 | 11 | const [filter, setFilter] = useState(null); 12 | const [localSnippets, setLocalSnippets] = useState([]); 13 | 14 | useEffect(() => { 15 | getSnippets(); 16 | countTags(); 17 | }, []); 18 | 19 | useEffect(() => { 20 | setLocalSnippets([...snippets]); 21 | }, [snippets]); 22 | 23 | const filterHandler = (tag: string) => { 24 | setFilter(tag); 25 | const filteredSnippets = snippets.filter(s => s.tags.includes(tag)); 26 | setLocalSnippets(filteredSnippets); 27 | }; 28 | 29 | const clearFilterHandler = () => { 30 | setFilter(null); 31 | setLocalSnippets([...snippets]); 32 | }; 33 | 34 | return ( 35 | 36 | {snippets.length === 0 ? ( 37 | 38 | ) : ( 39 | 40 |
41 | 42 |
All snippets
43 |
44 | Total 45 | {snippets.length} 46 |
47 |
48 | 49 |
Filter by tags
50 | 51 | {tagCount.map((tag, idx) => { 52 | const isActiveFilter = filter === tag.name; 53 | 54 | return ( 55 |
filterHandler(tag.name)} 61 | > 62 | {tag.name} 63 | {tag.count} 64 |
65 | ); 66 | })} 67 |
68 |
69 |
77 |
78 |
79 |
80 | 81 |
82 |
83 | )} 84 |
85 | ); 86 | }; 87 | -------------------------------------------------------------------------------- /client/src/containers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Home'; 2 | export * from './Snippet'; 3 | export * from './Snippets'; 4 | export * from './Editor'; 5 | -------------------------------------------------------------------------------- /client/src/data/aliases_raw.json: -------------------------------------------------------------------------------- 1 | {"aliases":["1c","abnf","accesslog","actionscript","ada","adoc","angelscript","apache","apacheconf","applescript","arcade","arduino","arm","armasm","as","asc","asciidoc","aspectj","atom","autohotkey","autoit","avrasm","awk","axapta","bash","basic","bat","bf","bind","bnf","brainfuck","c","c++","cal","capnp","capnproto","cc","clj","clojure","cls","cmake.in","cmake","cmd","coffee","coffeescript","console","coq","cos","cpp","cr","craftcms","crm","crmsh","crystal","cs","csharp","cson","csp","css","cxx","d","dart","dfm","diff","django","dns","docker","dockerfile","dos","dpr","dsconfig","dst","dts","dust","ebnf","elixir","elm","erl","erlang","excel","f90","f95","fix","fortran","fs","fsharp","gams","gauss","gawk","gcode","gemspec","gherkin","glsl","gms","go","golang","golo","gololang","gradle","graph","groovy","gss","gyp","h","h++","haml","handlebars","haskell","haxe","hbs","hh","hpp","hs","html.handlebars","html.hbs","html","http","https","hx","hxx","hy","hylang","i7","iced","inform7","ini","ino","instances","irb","irpf90","java","javascript","jinja","js","json","jsp","jsx","julia-repl","julia","k","kdb","kotlin","kt","lasso","lassoscript","ldif","leaf","less","lisp","livecodeserver","livescript","ls","ls","lua","mak","make","makefile","markdown","mathematica","matlab","mawk","maxima","md","mel","mercury","mizar","mk","mkd","mkdown","ml","ml","mm","mma","mojolicious","monkey","moon","moonscript","n1ql","nawk","nc","nginx","nginxconf","nim","nimrod","nix","nsis","obj-c","obj-c++","objc","objective-c++","objectivec","ocaml","openscad","osascript","oxygene","p21","parser3","pas","pascal","patch","pcmk","perl","pf.conf","pf","pgsql","php","pl","plaintext","plist","pm","podspec","pony","postgres","postgresql","powershell","pp","processing","profile","prolog","properties","protobuf","ps","ps1","puppet","py","pycon","python-repl","python","qml","r","rb","re","reasonml","rib","rs","rsl","rss","ruby","ruleslanguage","rust","SAS","sas","scad","scala","scheme","sci","scilab","scss","sh","shell","smali","smalltalk","sml","sql","st","stan","stanfuncs","stata","step","stp","styl","stylus","subunit","svg","swift","tao","tap","tcl","tex","text","thor","thrift","tk","toml","tp","ts","twig","txt","typescript","v","vala","vb","vbnet","vbs","vbscript","verilog","vhdl","vim","wl","x++","x86asm","xhtml","xjb","xl","xls","xlsx","xml","xpath","xq","xquery","xsd","xsl","yaml","yml","zep","zephir","zone","zsh"]} -------------------------------------------------------------------------------- /client/src/data/languages.json: -------------------------------------------------------------------------------- 1 | { 2 | "languages": [ 3 | { "name": "1C", "aliases": ["1c"] }, 4 | { "name": "ABNF", "aliases": ["abnf"] }, 5 | { "name": "Access logs", "aliases": ["accesslog"] }, 6 | { "name": "Ada", "aliases": ["ada"] }, 7 | { "name": "Arduino (C++ w/Arduino libs)", "aliases": ["arduino", "ino"] }, 8 | { "name": "ARM assembler", "aliases": ["armasm", "arm"] }, 9 | { "name": "AVR assembler", "aliases": ["avrasm"] }, 10 | { "name": "ActionScript", "aliases": ["actionscript", "as"] }, 11 | { "name": "AngelScript", "aliases": ["angelscript", "asc"] }, 12 | { "name": "Apache", "aliases": ["apache", "apacheconf"] }, 13 | { "name": "AppleScript", "aliases": ["applescript", "osascript"] }, 14 | { "name": "Arcade", "aliases": ["arcade"] }, 15 | { "name": "AsciiDoc", "aliases": ["asciidoc", "adoc"] }, 16 | { "name": "AspectJ", "aliases": ["aspectj"] }, 17 | { "name": "AutoHotkey", "aliases": ["autohotkey"] }, 18 | { "name": "AutoIt", "aliases": ["autoit"] }, 19 | { "name": "Awk", "aliases": ["awk", "mawk", "nawk", "gawk"] }, 20 | { "name": "Bash", "aliases": ["bash", "sh", "zsh"] }, 21 | { "name": "Basic", "aliases": ["basic"] }, 22 | { "name": "BNF", "aliases": ["bnf"] }, 23 | { "name": "Brainfuck", "aliases": ["brainfuck", "bf"] }, 24 | { "name": "C#", "aliases": ["csharp", "cs"] }, 25 | { "name": "C", "aliases": ["c", "h"] }, 26 | { "name": "C++", "aliases": ["cpp", "hpp", "cc", "hh", "c++", "h++", "cxx", "hxx"] }, 27 | { "name": "C/AL", "aliases": ["cal"] }, 28 | { "name": "Cache Object Script", "aliases": ["cos", "cls"] }, 29 | { "name": "CMake", "aliases": ["cmake", "cmake.in"] }, 30 | { "name": "Coq", "aliases": ["coq"] }, 31 | { "name": "CSP", "aliases": ["csp"] }, 32 | { "name": "CSS", "aliases": ["css"] }, 33 | { "name": "Cap’n Proto", "aliases": ["capnproto", "capnp"] }, 34 | { "name": "Clojure", "aliases": ["clojure", "clj"] }, 35 | { "name": "CoffeeScript", "aliases": ["coffeescript", "coffee", "cson", "iced"] }, 36 | { "name": "Crmsh", "aliases": ["crmsh", "crm", "pcmk"] }, 37 | { "name": "Crystal", "aliases": ["crystal", "cr"] }, 38 | { "name": "D", "aliases": ["d"] }, 39 | { "name": "Dart", "aliases": ["dart"] }, 40 | { "name": "Delphi", "aliases": ["dpr", "dfm", "pas", "pascal"] }, 41 | { "name": "Diff", "aliases": ["diff", "patch"] }, 42 | { "name": "Django", "aliases": ["django", "jinja"] }, 43 | { "name": "DNS Zone file", "aliases": ["dns", "zone", "bind"] }, 44 | { "name": "Dockerfile", "aliases": ["dockerfile", "docker"] }, 45 | { "name": "DOS", "aliases": ["dos", "bat", "cmd"] }, 46 | { "name": "dsconfig", "aliases": ["dsconfig"] }, 47 | { "name": "DTS (Device Tree)", "aliases": ["dts"] }, 48 | { "name": "Dust", "aliases": ["dust", "dst"] }, 49 | { "name": "EBNF", "aliases": ["ebnf"] }, 50 | { "name": "Elixir", "aliases": ["elixir"] }, 51 | { "name": "Elm", "aliases": ["elm"] }, 52 | { "name": "Erlang", "aliases": ["erlang", "erl"] }, 53 | { "name": "Excel", "aliases": ["excel", "xls", "xlsx"] }, 54 | { "name": "F#", "aliases": ["fsharp", "fs"] }, 55 | { "name": "FIX", "aliases": ["fix"] }, 56 | { "name": "Fortran", "aliases": ["fortran", "f90", "f95"] }, 57 | { "name": "G-Code", "aliases": ["gcode", "nc"] }, 58 | { "name": "Gams", "aliases": ["gams", "gms"] }, 59 | { "name": "GAUSS", "aliases": ["gauss", "gss"] }, 60 | { "name": "Gherkin", "aliases": ["gherkin"] }, 61 | { "name": "Go", "aliases": ["go", "golang"] }, 62 | { "name": "Golo", "aliases": ["golo", "gololang"] }, 63 | { "name": "Gradle", "aliases": ["gradle"] }, 64 | { "name": "Groovy", "aliases": ["groovy"] }, 65 | { "name": "HTML,XML", "aliases": ["xml", "html", "xhtml", "rss", "atom", "xjb", "xsd", "xsl", "plist", "svg"] }, 66 | { "name": "HTTP", "aliases": ["http", "https"] }, 67 | { "name": "Haml", "aliases": ["haml"] }, 68 | { "name": "Handlebars", "aliases": ["handlebars", "hbs", "html.hbs", "html.handlebars"] }, 69 | { "name": "Haskell", "aliases": ["haskell", "hs"] }, 70 | { "name": "Haxe", "aliases": ["haxe", "hx"] }, 71 | { "name": "Hy", "aliases": ["hy", "hylang"] }, 72 | { "name": "Ini,TOML", "aliases": ["ini", "toml"] }, 73 | { "name": "Inform7", "aliases": ["inform7", "i7"] }, 74 | { "name": "IRPF90", "aliases": ["irpf90"] }, 75 | { "name": "JSON", "aliases": ["json"] }, 76 | { "name": "Java", "aliases": ["java", "jsp"] }, 77 | { "name": "JavaScript", "aliases": ["javascript", "js", "jsx"] }, 78 | { "name": "Julia", "aliases": ["julia", "julia-repl"] }, 79 | { "name": "Kotlin", "aliases": ["kotlin", "kt"] }, 80 | { "name": "LaTeX", "aliases": ["tex"] }, 81 | { "name": "Leaf", "aliases": ["leaf"] }, 82 | { "name": "Lasso", "aliases": ["lasso", "ls", "lassoscript"] }, 83 | { "name": "Less", "aliases": ["less"] }, 84 | { "name": "LDIF", "aliases": ["ldif"] }, 85 | { "name": "Lisp", "aliases": ["lisp"] }, 86 | { "name": "LiveCode Server", "aliases": ["livecodeserver"] }, 87 | { "name": "LiveScript", "aliases": ["livescript", "ls"] }, 88 | { "name": "Lua", "aliases": ["lua"] }, 89 | { "name": "Makefile", "aliases": ["makefile", "mk", "mak", "make"] }, 90 | { "name": "Markdown", "aliases": ["markdown", "md", "mkdown", "mkd"] }, 91 | { "name": "Mathematica", "aliases": ["mathematica", "mma", "wl"] }, 92 | { "name": "Matlab", "aliases": ["matlab"] }, 93 | { "name": "Maxima", "aliases": ["maxima"] }, 94 | { "name": "Maya Embedded Language", "aliases": ["mel"] }, 95 | { "name": "Mercury", "aliases": ["mercury"] }, 96 | { "name": "Mizar", "aliases": ["mizar"] }, 97 | { "name": "Mojolicious", "aliases": ["mojolicious"] }, 98 | { "name": "Monkey", "aliases": ["monkey"] }, 99 | { "name": "Moonscript", "aliases": ["moonscript", "moon"] }, 100 | { "name": "N1QL", "aliases": ["n1ql"] }, 101 | { "name": "NSIS", "aliases": ["nsis"] }, 102 | { "name": "Nginx", "aliases": ["nginx", "nginxconf"] }, 103 | { "name": "Nim", "aliases": ["nim", "nimrod"] }, 104 | { "name": "Nix", "aliases": ["nix"] }, 105 | { "name": "OCaml", "aliases": ["ocaml", "ml"] }, 106 | { "name": "Objective C", "aliases": ["objectivec", "mm", "objc", "obj-c", "obj-c++", "objective-c++"] }, 107 | { "name": "OpenGL Shading Language", "aliases": ["glsl"] }, 108 | { "name": "OpenSCAD", "aliases": ["openscad", "scad"] }, 109 | { "name": "Oracle Rules Language", "aliases": ["ruleslanguage"] }, 110 | { "name": "Oxygene", "aliases": ["oxygene"] }, 111 | { "name": "PF", "aliases": ["pf", "pf.conf"] }, 112 | { "name": "PHP", "aliases": ["php"] }, 113 | { "name": "Parser3", "aliases": ["parser3"] }, 114 | { "name": "Perl", "aliases": ["perl", "pl", "pm"] }, 115 | { "name": "Plaintext", "aliases": ["plaintext", "txt", "text"] }, 116 | { "name": "Pony", "aliases": ["pony"] }, 117 | { "name": "PostgreSQL & PL/pgSQL", "aliases": ["pgsql", "postgres", "postgresql"] }, 118 | { "name": "PowerShell", "aliases": ["powershell", "ps", "ps1"] }, 119 | { "name": "Processing", "aliases": ["processing"] }, 120 | { "name": "Prolog", "aliases": ["prolog"] }, 121 | { "name": "Properties", "aliases": ["properties"] }, 122 | { "name": "Protocol Buffers", "aliases": ["protobuf"] }, 123 | { "name": "Puppet", "aliases": ["puppet", "pp"] }, 124 | { "name": "Python", "aliases": ["python", "py", "gyp"] }, 125 | { "name": "Python profiler results", "aliases": ["profile"] }, 126 | { "name": "Python REPL", "aliases": ["python-repl", "pycon"] }, 127 | { "name": "Q", "aliases": ["k", "kdb"] }, 128 | { "name": "QML", "aliases": ["qml"] }, 129 | { "name": "R", "aliases": ["r"] }, 130 | { "name": "ReasonML", "aliases": ["reasonml", "re"] }, 131 | { "name": "RenderMan RIB", "aliases": ["rib"] }, 132 | { "name": "RenderMan RSL", "aliases": ["rsl"] }, 133 | { "name": "Roboconf", "aliases": ["graph", "instances"] }, 134 | { "name": "Ruby", "aliases": ["ruby", "rb", "gemspec", "podspec", "thor", "irb"] }, 135 | { "name": "Rust", "aliases": ["rust", "rs"] }, 136 | { "name": "SAS", "aliases": ["SAS", "sas"] }, 137 | { "name": "SCSS", "aliases": ["scss"] }, 138 | { "name": "SQL", "aliases": ["sql"] }, 139 | { "name": "STEP Part 21", "aliases": ["p21", "step", "stp"] }, 140 | { "name": "Scala", "aliases": ["scala"] }, 141 | { "name": "Scheme", "aliases": ["scheme"] }, 142 | { "name": "Scilab", "aliases": ["scilab", "sci"] }, 143 | { "name": "Shell", "aliases": ["shell", "console"] }, 144 | { "name": "Smali", "aliases": ["smali"] }, 145 | { "name": "Smalltalk", "aliases": ["smalltalk", "st"] }, 146 | { "name": "SML", "aliases": ["sml", "ml"] }, 147 | { "name": "Stan", "aliases": ["stan", "stanfuncs"] }, 148 | { "name": "Stata", "aliases": ["stata"] }, 149 | { "name": "Stylus", "aliases": ["stylus", "styl"] }, 150 | { "name": "SubUnit", "aliases": ["subunit"] }, 151 | { "name": "Swift", "aliases": ["swift"] }, 152 | { "name": "Tcl", "aliases": ["tcl", "tk"] }, 153 | { "name": "Test Anything Protocol", "aliases": ["tap"] }, 154 | { "name": "Thrift", "aliases": ["thrift"] }, 155 | { "name": "TP", "aliases": ["tp"] }, 156 | { "name": "Twig", "aliases": ["twig", "craftcms"] }, 157 | { "name": "TypeScript", "aliases": ["typescript", "ts"] }, 158 | { "name": "VB.Net", "aliases": ["vbnet", "vb"] }, 159 | { "name": "VBScript", "aliases": ["vbscript", "vbs"] }, 160 | { "name": "VHDL", "aliases": ["vhdl"] }, 161 | { "name": "Vala", "aliases": ["vala"] }, 162 | { "name": "Verilog", "aliases": ["verilog", "v"] }, 163 | { "name": "Vim Script", "aliases": ["vim"] }, 164 | { "name": "X++", "aliases": ["axapta", "x++"] }, 165 | { "name": "x86 Assembly", "aliases": ["x86asm"] }, 166 | { "name": "XL", "aliases": ["xl", "tao"] }, 167 | { "name": "XQuery", "aliases": ["xquery", "xpath", "xq"] }, 168 | { "name": "YAML", "aliases": ["yml", "yaml"] }, 169 | { "name": "Zephir", "aliases": ["zephir", "zep"] } 170 | ] 171 | } -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './styles/style.scss'; 4 | import { App } from './App'; 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById('root') 11 | ); 12 | -------------------------------------------------------------------------------- /client/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/src/store/SnippetsContext.tsx: -------------------------------------------------------------------------------- 1 | import { useState, createContext } from 'react'; 2 | import { useHistory } from 'react-router-dom'; 3 | import axios from 'axios'; 4 | import { 5 | Context, 6 | Snippet, 7 | Response, 8 | TagCount, 9 | NewSnippet, 10 | SearchQuery 11 | } from '../typescript/interfaces'; 12 | 13 | export const SnippetsContext = createContext({ 14 | snippets: [], 15 | searchResults: [], 16 | currentSnippet: null, 17 | tagCount: [], 18 | getSnippets: () => {}, 19 | getSnippetById: (id: number) => {}, 20 | setSnippet: (id: number) => {}, 21 | createSnippet: (snippet: NewSnippet) => {}, 22 | updateSnippet: (snippet: NewSnippet, id: number, isLocal?: boolean) => {}, 23 | deleteSnippet: (id: number) => {}, 24 | toggleSnippetPin: (id: number) => {}, 25 | countTags: () => {}, 26 | searchSnippets: (query: SearchQuery) => {} 27 | }); 28 | 29 | interface Props { 30 | children: JSX.Element | JSX.Element[]; 31 | } 32 | 33 | export const SnippetsContextProvider = (props: Props): JSX.Element => { 34 | const [snippets, setSnippets] = useState([]); 35 | const [searchResults, setSearchResults] = useState([]); 36 | const [currentSnippet, setCurrentSnippet] = useState(null); 37 | const [tagCount, setTagCount] = useState([]); 38 | 39 | const history = useHistory(); 40 | 41 | const redirectOnError = () => { 42 | history.push('/'); 43 | }; 44 | 45 | const getSnippets = (): void => { 46 | axios 47 | .get>('/api/snippets') 48 | .then(res => setSnippets(res.data.data)) 49 | .catch(err => redirectOnError()); 50 | }; 51 | 52 | const getSnippetById = (id: number): void => { 53 | axios 54 | .get>(`/api/snippets/${id}`) 55 | .then(res => setCurrentSnippet(res.data.data)) 56 | .catch(err => redirectOnError()); 57 | }; 58 | 59 | const setSnippet = (id: number): void => { 60 | if (id < 0) { 61 | setCurrentSnippet(null); 62 | return; 63 | } 64 | 65 | getSnippetById(id); 66 | 67 | const snippet = snippets.find(s => s.id === id); 68 | 69 | if (snippet) { 70 | setCurrentSnippet(snippet); 71 | } 72 | }; 73 | 74 | const createSnippet = (snippet: NewSnippet): void => { 75 | axios 76 | .post>('/api/snippets', snippet) 77 | .then(res => { 78 | setSnippets([...snippets, res.data.data]); 79 | setCurrentSnippet(res.data.data); 80 | history.push({ 81 | pathname: `/snippet/${res.data.data.id}`, 82 | state: { from: '/snippets' } 83 | }); 84 | }) 85 | .catch(err => redirectOnError()); 86 | }; 87 | 88 | const updateSnippet = ( 89 | snippet: NewSnippet, 90 | id: number, 91 | isLocal?: boolean 92 | ): void => { 93 | axios 94 | .put>(`/api/snippets/${id}`, snippet) 95 | .then(res => { 96 | const oldSnippetIdx = snippets.findIndex(s => s.id === id); 97 | setSnippets([ 98 | ...snippets.slice(0, oldSnippetIdx), 99 | res.data.data, 100 | ...snippets.slice(oldSnippetIdx + 1) 101 | ]); 102 | setCurrentSnippet(res.data.data); 103 | 104 | if (!isLocal) { 105 | history.push({ 106 | pathname: `/snippet/${res.data.data.id}`, 107 | state: { from: '/snippets' } 108 | }); 109 | } 110 | }) 111 | .catch(err => redirectOnError()); 112 | }; 113 | 114 | const deleteSnippet = (id: number): void => { 115 | if (window.confirm('Are you sure you want to delete this snippet?')) { 116 | axios 117 | .delete>(`/api/snippets/${id}`) 118 | .then(res => { 119 | const deletedSnippetIdx = snippets.findIndex(s => s.id === id); 120 | setSnippets([ 121 | ...snippets.slice(0, deletedSnippetIdx), 122 | ...snippets.slice(deletedSnippetIdx + 1) 123 | ]); 124 | setSnippet(-1); 125 | history.push('/snippets'); 126 | }) 127 | .catch(err => redirectOnError()); 128 | } 129 | }; 130 | 131 | const toggleSnippetPin = (id: number): void => { 132 | const snippet = snippets.find(s => s.id === id); 133 | 134 | if (snippet) { 135 | updateSnippet({ ...snippet, isPinned: !snippet.isPinned }, id, true); 136 | } 137 | }; 138 | 139 | const countTags = (): void => { 140 | axios 141 | .get>('/api/snippets/statistics/count') 142 | .then(res => setTagCount(res.data.data)) 143 | .catch(err => redirectOnError()); 144 | }; 145 | 146 | const searchSnippets = (query: SearchQuery): void => { 147 | axios 148 | .post>('/api/snippets/search', query) 149 | .then(res => { 150 | setSearchResults(res.data.data); 151 | console.log(res.data.data); 152 | }) 153 | .catch(err => console.log(err)); 154 | }; 155 | 156 | const context = { 157 | snippets, 158 | searchResults, 159 | currentSnippet, 160 | tagCount, 161 | getSnippets, 162 | getSnippetById, 163 | setSnippet, 164 | createSnippet, 165 | updateSnippet, 166 | deleteSnippet, 167 | toggleSnippetPin, 168 | countTags, 169 | searchSnippets 170 | }; 171 | 172 | return ( 173 | 174 | {props.children} 175 | 176 | ); 177 | }; 178 | -------------------------------------------------------------------------------- /client/src/store/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SnippetsContext'; 2 | -------------------------------------------------------------------------------- /client/src/styles/_bootswatch.scss: -------------------------------------------------------------------------------- 1 | // Zephyr 5.1.1 2 | // Bootswatch 3 | 4 | // Variables 5 | $web-font-path: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap' !default; 6 | @if $web-font-path { 7 | @import url($web-font-path); 8 | } 9 | 10 | // Navbar 11 | .navbar { 12 | font-size: $font-size-sm; 13 | font-weight: 500; 14 | 15 | .nav-item { 16 | margin-left: 0.5rem; 17 | margin-right: 0.5rem; 18 | } 19 | 20 | .navbar-nav { 21 | .nav-link { 22 | border-radius: $border-radius; 23 | } 24 | } 25 | } 26 | 27 | .navbar-dark { 28 | .navbar-nav { 29 | .nav-link { 30 | &:hover { 31 | background-color: rgba(255, 255, 255, 0.1); 32 | } 33 | 34 | &.active { 35 | background-color: rgba(0, 0, 0, 0.5); 36 | } 37 | } 38 | } 39 | } 40 | 41 | .navbar-light { 42 | .navbar-nav { 43 | .nav-link { 44 | &:hover { 45 | background-color: rgba(0, 0, 0, 0.03); 46 | } 47 | 48 | &.active { 49 | background-color: rgba(0, 0, 0, 0.05); 50 | } 51 | } 52 | } 53 | } 54 | 55 | // Buttons 56 | // .btn-secondary, 57 | // .btn-light, 58 | // .btn-outline-secondary, 59 | // .btn-outline-light { 60 | // color: $gray-900; 61 | 62 | // &:disabled, 63 | // &.disabled { 64 | // border: 1px solid shade-color($secondary, 10%); 65 | // } 66 | // } 67 | 68 | .btn-light, 69 | .btn-outline-light { 70 | color: $gray-900; 71 | 72 | &:disabled, 73 | &.disabled { 74 | border: 1px solid shade-color($secondary, 10%); 75 | } 76 | } 77 | 78 | .btn-secondary, 79 | .btn-outline-secondary { 80 | border-color: shade-color($secondary, 10%); 81 | 82 | &:hover, 83 | &:active { 84 | background-color: shade-color($secondary, 10%); 85 | border-color: shade-color($secondary, 10%); 86 | } 87 | } 88 | 89 | .btn-light, 90 | .btn-outline-light { 91 | border-color: shade-color($light, 10%); 92 | 93 | &:hover, 94 | &:active { 95 | background-color: shade-color($light, 10%); 96 | border-color: shade-color($light, 10%); 97 | } 98 | } 99 | 100 | // Tables 101 | .table { 102 | box-shadow: $box-shadow-lg; 103 | font-size: $font-size-sm; 104 | } 105 | 106 | thead th { 107 | text-transform: uppercase; 108 | font-size: $font-size-sm; 109 | } 110 | 111 | // Forms 112 | .input-group-text { 113 | box-shadow: $box-shadow; 114 | } 115 | 116 | // Navs 117 | .nav-tabs { 118 | font-weight: 500; 119 | 120 | .nav-link { 121 | padding-top: 1rem; 122 | padding-bottom: 1rem; 123 | border-width: 0 0 1px; 124 | } 125 | 126 | .nav-link.active, 127 | .nav-item.show .nav-link { 128 | box-shadow: inset 0 -2px 0 $primary; 129 | } 130 | } 131 | 132 | .nav-pills { 133 | font-weight: 500; 134 | } 135 | 136 | .pagination { 137 | font-size: $font-size-sm; 138 | font-weight: 500; 139 | 140 | .page-link { 141 | box-shadow: $box-shadow; 142 | } 143 | } 144 | 145 | .breadcrumb { 146 | border: 1px solid $gray-300; 147 | border-radius: $border-radius; 148 | box-shadow: $box-shadow; 149 | font-size: $font-size-sm; 150 | font-weight: 500; 151 | 152 | &-item { 153 | padding: 1rem 0.5rem 1rem 0; 154 | } 155 | } 156 | 157 | .breadcrumb-item + .breadcrumb-item::before { 158 | padding-right: 1rem; 159 | } 160 | 161 | // Indicators 162 | .alert { 163 | .btn-close { 164 | color: inherit; 165 | } 166 | } 167 | 168 | .badge { 169 | &.bg-secondary, 170 | &.bg-light { 171 | color: $gray-900; 172 | } 173 | } 174 | 175 | // Containers 176 | .list-group { 177 | box-shadow: $box-shadow-lg; 178 | } 179 | 180 | .card { 181 | box-shadow: $box-shadow-lg; 182 | 183 | &-title { 184 | color: inherit; 185 | } 186 | } 187 | 188 | .modal-footer { 189 | background-color: $gray-100; 190 | } 191 | 192 | .modal-content { 193 | box-shadow: $box-shadow-lg; 194 | } 195 | -------------------------------------------------------------------------------- /client/src/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | $theme: 'zephyr'; 2 | $white: #fff; 3 | $gray-100: #f8f9fa; 4 | $gray-200: #e9ecef; 5 | $gray-300: #dee2e6; 6 | $gray-400: #ced4da; 7 | $gray-500: #adb5bd; 8 | $gray-600: #6c757d; 9 | $gray-700: #495057; 10 | $gray-800: #343a40; 11 | $gray-900: #212529; 12 | $black: #000; 13 | $blue: #375a7f; 14 | $indigo: #6610f2; 15 | $purple: #6f42c1; 16 | $pink: #e83e8c; 17 | $red: #e74c3c; 18 | $orange: #fd7e14; 19 | $yellow: #f39c12; 20 | $green: #00bc8c; 21 | $teal: #20c997; 22 | $cyan: #3498db; 23 | $primary: $blue; 24 | $secondary: $white; 25 | $success: $green; 26 | $info: $cyan; 27 | $warning: $yellow; 28 | $danger: $red; 29 | $light: $gray-100; 30 | $dark: $gray-900; 31 | $min-contrast-ratio: 1.65; 32 | $enable-shadows: true; 33 | $body-color: $gray-200; 34 | $headings-color: $gray-100; 35 | $font-family-sans-serif: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI'; 36 | $font-size-base: 1rem; 37 | $font-size-sm: $font-size-base * 0.875 !default; 38 | $box-shadow: 0 1px 2px rgba($black, 0.05); 39 | $box-shadow-lg: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); 40 | $table-cell-padding-y: 1rem; 41 | $table-cell-padding-x: 1rem; 42 | $table-cell-padding-y-sm: 0.5rem; 43 | $table-cell-padding-x-sm: 0.5rem; 44 | $table-th-font-weight: 500; 45 | $input-btn-padding-y: 0.5rem; 46 | $input-btn-padding-x: 1rem; 47 | $input-btn-font-size: $font-size-sm; 48 | $btn-font-weight: 500; 49 | $btn-box-shadow: $box-shadow; 50 | $btn-focus-box-shadow: $box-shadow; 51 | $btn-active-box-shadow: $box-shadow; 52 | $form-label-font-weight: 500; 53 | $input-box-shadow: $box-shadow; 54 | $input-group-addon-bg: $gray-100; 55 | $nav-link-color: $body-color; 56 | $nav-link-hover-color: $body-color; 57 | $nav-tabs-border-radius: 0; 58 | $nav-tabs-link-active-color: $primary; 59 | $navbar-padding-y: 0.85rem; 60 | $navbar-nav-link-padding-x: 0.75rem; 61 | $navbar-dark-color: $white; 62 | $navbar-dark-hover-color: $white; 63 | $navbar-dark-active-color: $white; 64 | $navbar-light-color: $black; 65 | $navbar-light-hover-color: $black; 66 | $navbar-light-active-color: $black; 67 | $dropdown-font-size: $font-size-sm; 68 | $dropdown-border-color: $gray-300; 69 | $dropdown-divider-bg: $gray-200; 70 | $dropdown-link-hover-color: $white; 71 | $dropdown-link-hover-bg: $primary; 72 | $dropdown-item-padding-y: 0.5rem; 73 | $dropdown-item-padding-x: 1rem; 74 | $pagination-padding-y: 0.5rem; 75 | $pagination-padding-x: 1rem; 76 | $pagination-color: $light; 77 | $pagination-focus-color: $pagination-color; 78 | $pagination-hover-color: $pagination-color; 79 | $pagination-hover-bg: $gray-100; 80 | $card-spacer-x: 1.5rem; 81 | $card-cap-padding-y: 1rem; 82 | $card-cap-padding-x: 1.5rem; 83 | $toast-header-color: $headings-color; 84 | $modal-content-border-color: $gray-700; 85 | $modal-header-border-width: 0; 86 | $list-group-item-padding-y: 1rem; 87 | $list-group-item-padding-x: 1.5rem; 88 | $breadcrumb-padding-x: 1rem; 89 | $breadcrumb-divider: quote('>'); 90 | $body-bg: $gray-800; 91 | $card-color: $gray-200; 92 | $card-bg: $dark; 93 | $list-group-bg: $dark; 94 | $pagination-bg: $dark; 95 | $pagination-border-color: $gray-800; 96 | $breadcrumb-bg: $dark; 97 | $breadcrumb-divider-color: $light; 98 | $breadcrumb-active-color: $light; 99 | $input-bg: $dark; 100 | $input-color: $light; 101 | $card-border-width: 0; 102 | $modal-content-bg: $dark; 103 | $input-border-color: $gray-700; 104 | $input-disabled-bg: $dark; 105 | -------------------------------------------------------------------------------- /client/src/styles/style.scss: -------------------------------------------------------------------------------- 1 | @import './variables'; 2 | @import '../../node_modules/bootstrap/scss/bootstrap.scss'; 3 | @import './bootswatch'; 4 | 5 | .cursor-pointer:hover { 6 | cursor: pointer; 7 | } 8 | -------------------------------------------------------------------------------- /client/src/typescript/interfaces/Context.ts: -------------------------------------------------------------------------------- 1 | import { TagCount, NewSnippet, Snippet, SearchQuery } from '.'; 2 | 3 | export interface Context { 4 | snippets: Snippet[]; 5 | searchResults: Snippet[]; 6 | currentSnippet: Snippet | null; 7 | tagCount: TagCount[]; 8 | getSnippets: () => void; 9 | getSnippetById: (id: number) => void; 10 | setSnippet: (id: number) => void; 11 | createSnippet: (snippet: NewSnippet) => void; 12 | updateSnippet: (snippet: NewSnippet, id: number, isLocal?: boolean) => void; 13 | deleteSnippet: (id: number) => void; 14 | toggleSnippetPin: (id: number) => void; 15 | countTags: () => void; 16 | searchSnippets: (query: SearchQuery) => void; 17 | } 18 | -------------------------------------------------------------------------------- /client/src/typescript/interfaces/Model.ts: -------------------------------------------------------------------------------- 1 | export interface Model { 2 | id: number; 3 | createdAt: Date; 4 | updatedAt: Date; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/typescript/interfaces/Response.ts: -------------------------------------------------------------------------------- 1 | export interface Response { 2 | data: T; 3 | } 4 | -------------------------------------------------------------------------------- /client/src/typescript/interfaces/Route.ts: -------------------------------------------------------------------------------- 1 | export interface Route { 2 | name: string; 3 | dest: string; 4 | } 5 | -------------------------------------------------------------------------------- /client/src/typescript/interfaces/SearchQuery.ts: -------------------------------------------------------------------------------- 1 | export interface SearchQuery { 2 | query: string; 3 | tags: string[]; 4 | languages: string[]; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/typescript/interfaces/Snippet.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '.'; 2 | 3 | export interface NewSnippet { 4 | title: string; 5 | description?: string; 6 | language: string; 7 | code: string; 8 | docs?: string; 9 | isPinned: boolean; 10 | tags: string[]; 11 | } 12 | 13 | export interface Snippet extends Model, NewSnippet {} 14 | -------------------------------------------------------------------------------- /client/src/typescript/interfaces/Statistics.ts: -------------------------------------------------------------------------------- 1 | export interface TagCount { 2 | count: number; 3 | name: string; 4 | } 5 | -------------------------------------------------------------------------------- /client/src/typescript/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Model'; 2 | export * from './Snippet'; 3 | export * from './Route'; 4 | export * from './Response'; 5 | export * from './Context'; 6 | export * from './Statistics'; 7 | export * from './SearchQuery'; 8 | -------------------------------------------------------------------------------- /client/src/typescript/types/Colors.ts: -------------------------------------------------------------------------------- 1 | export const colors = [ 2 | 'primary', 3 | 'secondary', 4 | 'success', 5 | 'info', 6 | 'warning', 7 | 'danger', 8 | 'light', 9 | 'dark' 10 | ] as const; 11 | 12 | export type Color = typeof colors[number]; 13 | -------------------------------------------------------------------------------- /client/src/typescript/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Colors'; 2 | -------------------------------------------------------------------------------- /client/src/utils/badgeColor.ts: -------------------------------------------------------------------------------- 1 | import { Color } from '../typescript/types'; 2 | 3 | export const badgeColor = (language: string): Color => { 4 | const code = language.toLowerCase().charCodeAt(0); 5 | let color: Color = 'primary'; 6 | 7 | switch (code % 6) { 8 | case 0: 9 | default: 10 | color = 'primary'; 11 | break; 12 | case 1: 13 | color = 'success'; 14 | break; 15 | case 2: 16 | color = 'info'; 17 | break; 18 | case 3: 19 | color = 'warning'; 20 | break; 21 | case 4: 22 | color = 'danger'; 23 | break; 24 | case 5: 25 | color = 'light'; 26 | break; 27 | } 28 | 29 | return color; 30 | }; 31 | -------------------------------------------------------------------------------- /client/src/utils/dateParser.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import relativeTime from 'dayjs/plugin/relativeTime'; 3 | import customParseFormat from 'dayjs/plugin/customParseFormat'; 4 | 5 | interface Return { 6 | formatted: string; 7 | relative: string; 8 | } 9 | 10 | export const dateParser = (date: Date): Return => { 11 | dayjs.extend(relativeTime); 12 | dayjs.extend(customParseFormat); 13 | 14 | // test date format 15 | const regex = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.|\+|-|Z)+/; 16 | const dateFormat = regex.test(date.toString()) 17 | ? 'YYYY-MM-DD[T]HH:mm:ss.SSSZ' 18 | : 'YYYY-MM-DD HH:mm:ss.SSS Z'; 19 | 20 | // parse date 21 | const parsedDate = dayjs(date, dateFormat); 22 | const formatted = parsedDate.format('YYYY-MM-DD HH:mm'); 23 | const relative = parsedDate.fromNow(); 24 | 25 | return { formatted, relative }; 26 | }; 27 | -------------------------------------------------------------------------------- /client/src/utils/findLanguage.ts: -------------------------------------------------------------------------------- 1 | import { aliases } from '../data/aliases_raw.json'; 2 | 3 | export const findLanguage = (language: string): boolean => { 4 | const search = language.toLowerCase(); 5 | return aliases.some(alias => alias === search); 6 | }; 7 | -------------------------------------------------------------------------------- /client/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './dateParser'; 2 | export * from './badgeColor'; 3 | export * from './findLanguage'; 4 | export * from './searchParser'; 5 | -------------------------------------------------------------------------------- /client/src/utils/searchParser.ts: -------------------------------------------------------------------------------- 1 | import { SearchQuery } from '../typescript/interfaces'; 2 | 3 | export const searchParser = (rawQuery: string): SearchQuery => { 4 | // Extract filters from query 5 | const tags = extractFilters(rawQuery, 'tags'); 6 | const languages = extractFilters(rawQuery, 'lang'); 7 | const query = rawQuery.replaceAll(/(tags|lang):[a-zA-Z]+(,[a-zA-Z]+)*/g, ''); 8 | 9 | return { 10 | query: query.trim(), 11 | tags, 12 | languages 13 | }; 14 | }; 15 | 16 | const extractFilters = (query: string, filter: string): string[] => { 17 | let filters: string[] = []; 18 | 19 | const regex = new RegExp(filter + ':[a-zA-Z]+(,[a-zA-Z]+)*'); 20 | const matcher = query.match(regex); 21 | 22 | if (matcher) { 23 | filters = matcher[0].split(':')[1].split(','); 24 | } 25 | 26 | return filters; 27 | }; 28 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | snippet-box: 4 | image: pawelmalak/snippet-box 5 | container_name: snippet-box 6 | volumes: 7 | - /path/to/host/data:/app/data 8 | ports: 9 | - 5000:5000 10 | restart: unless-stopped 11 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts,json", 4 | "ignore": ["src/**/*.spec.ts"], 5 | "exec": "ts-node ./src/server.ts" 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "snippet-hub", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "build/server.js", 6 | "scripts": { 7 | "init:client": "npm install --prefix=client", 8 | "init:server": "npm install", 9 | "init": "npm i && cd client && npm i", 10 | "dev:client": "npm start --prefix=client", 11 | "dev:server": "nodemon", 12 | "dev": "npm-run-all -n --parallel dev:**", 13 | "build:client": "npm run build --prefix=client", 14 | "build:clear": "rm -rf build", 15 | "build:tsc": "tsc", 16 | "build": "npm-run-all -n build:**" 17 | }, 18 | "keywords": [], 19 | "author": "", 20 | "license": "ISC", 21 | "devDependencies": { 22 | "@types/express": "^4.17.13", 23 | "@types/node": "^16.9.2", 24 | "@types/validator": "^13.6.3", 25 | "nodemon": "^2.0.12", 26 | "npm-run-all": "^4.1.5", 27 | "ts-node": "^10.2.1", 28 | "typescript": "^4.4.3" 29 | }, 30 | "dependencies": { 31 | "@types/umzug": "^2.3.2", 32 | "dotenv": "^10.0.0", 33 | "express": "^4.17.1", 34 | "sequelize": "^6.6.5", 35 | "sqlite3": "^5.0.2", 36 | "umzug": "^2.3.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/config/.env: -------------------------------------------------------------------------------- 1 | PORT=5000 2 | NODE_ENV=development -------------------------------------------------------------------------------- /src/controllers/snippets.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import { QueryTypes, Op } from 'sequelize'; 3 | import { sequelize } from '../db'; 4 | import { asyncWrapper } from '../middleware'; 5 | import { SnippetModel, Snippet_TagModel, TagModel } from '../models'; 6 | import { ErrorResponse, tagParser, Logger, createTags } from '../utils'; 7 | import { Body, SearchQuery } from '../typescript/interfaces'; 8 | 9 | /** 10 | * @description Create new snippet 11 | * @route /api/snippets 12 | * @request POST 13 | */ 14 | export const createSnippet = asyncWrapper( 15 | async (req: Request, res: Response, next: NextFunction): Promise => { 16 | // Get tags from request body 17 | const { language, tags: requestTags } = req.body; 18 | const parsedRequestTags = tagParser([ 19 | ...requestTags, 20 | language.toLowerCase() 21 | ]); 22 | 23 | // Create snippet 24 | const snippet = await SnippetModel.create({ 25 | ...req.body, 26 | tags: [...parsedRequestTags].join(',') 27 | }); 28 | 29 | // Create tags 30 | await createTags(parsedRequestTags, snippet.id); 31 | 32 | // Get raw snippet values 33 | const rawSnippet = snippet.get({ plain: true }); 34 | 35 | res.status(201).json({ 36 | data: { 37 | ...rawSnippet, 38 | tags: [...parsedRequestTags] 39 | } 40 | }); 41 | } 42 | ); 43 | 44 | /** 45 | * @description Get all snippets 46 | * @route /api/snippets 47 | * @request GET 48 | */ 49 | export const getAllSnippets = asyncWrapper( 50 | async (req: Request, res: Response, next: NextFunction): Promise => { 51 | const snippets = await SnippetModel.findAll({ 52 | include: { 53 | model: TagModel, 54 | as: 'tags', 55 | attributes: ['name'], 56 | through: { 57 | attributes: [] 58 | } 59 | } 60 | }); 61 | 62 | const populatedSnippets = snippets.map(snippet => { 63 | const rawSnippet = snippet.get({ plain: true }); 64 | 65 | return { 66 | ...rawSnippet, 67 | tags: rawSnippet.tags?.map(tag => tag.name) 68 | }; 69 | }); 70 | 71 | res.status(200).json({ 72 | data: populatedSnippets 73 | }); 74 | } 75 | ); 76 | 77 | /** 78 | * @description Get single snippet by id 79 | * @route /api/snippets/:id 80 | * @request GET 81 | */ 82 | export const getSnippet = asyncWrapper( 83 | async (req: Request, res: Response, next: NextFunction): Promise => { 84 | const snippet = await SnippetModel.findOne({ 85 | where: { id: req.params.id }, 86 | include: { 87 | model: TagModel, 88 | as: 'tags', 89 | attributes: ['name'], 90 | through: { 91 | attributes: [] 92 | } 93 | } 94 | }); 95 | 96 | if (!snippet) { 97 | return next( 98 | new ErrorResponse( 99 | 404, 100 | `Snippet with id of ${req.params.id} was not found` 101 | ) 102 | ); 103 | } 104 | 105 | const rawSnippet = snippet.get({ plain: true }); 106 | const populatedSnippet = { 107 | ...rawSnippet, 108 | tags: rawSnippet.tags?.map(tag => tag.name) 109 | }; 110 | 111 | res.status(200).json({ 112 | data: populatedSnippet 113 | }); 114 | } 115 | ); 116 | 117 | /** 118 | * @description Update snippet 119 | * @route /api/snippets/:id 120 | * @request PUT 121 | */ 122 | export const updateSnippet = asyncWrapper( 123 | async (req: Request, res: Response, next: NextFunction): Promise => { 124 | let snippet = await SnippetModel.findOne({ 125 | where: { id: req.params.id } 126 | }); 127 | 128 | if (!snippet) { 129 | return next( 130 | new ErrorResponse( 131 | 404, 132 | `Snippet with id of ${req.params.id} was not found` 133 | ) 134 | ); 135 | } 136 | 137 | // Get tags from request body 138 | const { language, tags: requestTags } = req.body; 139 | let parsedRequestTags = tagParser([...requestTags, language.toLowerCase()]); 140 | 141 | // Update snippet 142 | snippet = await snippet.update({ 143 | ...req.body, 144 | tags: [...parsedRequestTags].join(',') 145 | }); 146 | 147 | // Delete old tags and create new ones 148 | await Snippet_TagModel.destroy({ where: { snippet_id: req.params.id } }); 149 | await createTags(parsedRequestTags, snippet.id); 150 | 151 | // Get raw snippet values 152 | const rawSnippet = snippet.get({ plain: true }); 153 | 154 | res.status(200).json({ 155 | data: { 156 | ...rawSnippet, 157 | tags: [...parsedRequestTags] 158 | } 159 | }); 160 | } 161 | ); 162 | 163 | /** 164 | * @description Delete snippet 165 | * @route /api/snippets/:id 166 | * @request DELETE 167 | */ 168 | export const deleteSnippet = asyncWrapper( 169 | async (req: Request, res: Response, next: NextFunction): Promise => { 170 | const snippet = await SnippetModel.findOne({ 171 | where: { id: req.params.id } 172 | }); 173 | 174 | if (!snippet) { 175 | return next( 176 | new ErrorResponse( 177 | 404, 178 | `Snippet with id of ${req.params.id} was not found` 179 | ) 180 | ); 181 | } 182 | 183 | await Snippet_TagModel.destroy({ where: { snippet_id: req.params.id } }); 184 | await snippet.destroy(); 185 | 186 | res.status(200).json({ 187 | data: {} 188 | }); 189 | } 190 | ); 191 | 192 | /** 193 | * @description Count tags 194 | * @route /api/snippets/statistics/count 195 | * @request GET 196 | */ 197 | export const countTags = asyncWrapper( 198 | async (req: Request, res: Response, next: NextFunction): Promise => { 199 | const result = await sequelize.query( 200 | `SELECT 201 | COUNT(tags.name) as count, 202 | tags.name 203 | FROM snippets_tags 204 | INNER JOIN tags ON snippets_tags.tag_id = tags.id 205 | GROUP BY tags.name 206 | ORDER BY name ASC`, 207 | { 208 | type: QueryTypes.SELECT 209 | } 210 | ); 211 | 212 | res.status(200).json({ 213 | data: result 214 | }); 215 | } 216 | ); 217 | 218 | /** 219 | * @description Get raw snippet code 220 | * @route /api/snippets/raw/:id 221 | * @request GET 222 | */ 223 | export const getRawCode = asyncWrapper( 224 | async (req: Request, res: Response, next: NextFunction): Promise => { 225 | const snippet = await SnippetModel.findOne({ 226 | where: { id: req.params.id }, 227 | raw: true 228 | }); 229 | 230 | if (!snippet) { 231 | return next( 232 | new ErrorResponse( 233 | 404, 234 | `Snippet with id of ${req.params.id} was not found` 235 | ) 236 | ); 237 | } 238 | 239 | res.status(200).send(snippet.code); 240 | } 241 | ); 242 | 243 | /** 244 | * @description Search snippets 245 | * @route /api/snippets/search 246 | * @request POST 247 | */ 248 | export const searchSnippets = asyncWrapper( 249 | async (req: Request, res: Response, next: NextFunction): Promise => { 250 | const { query, tags, languages } = req.body; 251 | 252 | // Check if query is empty 253 | if (query === '' && !tags.length && !languages.length) { 254 | res.status(200).json({ 255 | data: [] 256 | }); 257 | 258 | return; 259 | } 260 | 261 | const languageFilter = languages.length 262 | ? { [Op.in]: languages } 263 | : { [Op.notIn]: languages }; 264 | 265 | const tagFilter = tags.length ? { [Op.in]: tags } : { [Op.notIn]: tags }; 266 | 267 | const snippets = await SnippetModel.findAll({ 268 | where: { 269 | [Op.and]: [ 270 | { 271 | [Op.or]: [ 272 | { title: { [Op.substring]: `${query}` } }, 273 | { description: { [Op.substring]: `${query}` } } 274 | ] 275 | }, 276 | { 277 | language: languageFilter 278 | } 279 | ] 280 | }, 281 | include: { 282 | model: TagModel, 283 | as: 'tags', 284 | attributes: ['name'], 285 | where: { 286 | name: tagFilter 287 | }, 288 | through: { 289 | attributes: [] 290 | } 291 | } 292 | }); 293 | 294 | res.status(200).json({ 295 | data: snippets 296 | }); 297 | } 298 | ); 299 | -------------------------------------------------------------------------------- /src/db/associateModels.ts: -------------------------------------------------------------------------------- 1 | import { TagModel, SnippetModel, Snippet_TagModel } from '../models'; 2 | 3 | export const associateModels = async () => { 4 | TagModel.belongsToMany(SnippetModel, { 5 | through: Snippet_TagModel, 6 | foreignKey: 'tag_id', 7 | as: 'snippets' 8 | }); 9 | 10 | SnippetModel.belongsToMany(TagModel, { 11 | through: Snippet_TagModel, 12 | foreignKey: 'snippet_id', 13 | as: 'tags' 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /src/db/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { Sequelize } from 'sequelize'; 3 | import Umzug from 'umzug'; 4 | import { Logger } from '../utils'; 5 | 6 | const logger = new Logger('db'); 7 | 8 | // DB config 9 | export const sequelize = new Sequelize({ 10 | dialect: 'sqlite', 11 | storage: 'data/db.sqlite3', 12 | logging: false 13 | }); 14 | 15 | // Migrations config 16 | const umzug = new Umzug({ 17 | migrations: { 18 | path: path.join(__dirname, './migrations'), 19 | params: [sequelize.getQueryInterface()], 20 | pattern: /^\d+[\w-]+\.(js|ts)$/ 21 | }, 22 | storage: 'sequelize', 23 | storageOptions: { 24 | sequelize 25 | }, 26 | logging: false 27 | }); 28 | 29 | export const connectDB = async () => { 30 | const isDev = process.env.NODE_ENV == 'development'; 31 | 32 | try { 33 | // Create & connect db 34 | await sequelize.authenticate(); 35 | logger.log('Database connected'); 36 | 37 | // Check migrations 38 | const pendingMigrations = await umzug.pending(); 39 | 40 | if (pendingMigrations.length > 0) { 41 | logger.log(`Found pending migrations. Executing...`); 42 | 43 | if (isDev) { 44 | pendingMigrations.forEach(({ file }) => 45 | logger.log(`Executing ${file} migration`, 'DEV') 46 | ); 47 | } 48 | } 49 | 50 | await umzug.up(); 51 | } catch (err) { 52 | logger.log(`Database connection error`, 'ERROR'); 53 | 54 | if (isDev) { 55 | console.log(err); 56 | } 57 | 58 | process.exit(1); 59 | } 60 | }; 61 | -------------------------------------------------------------------------------- /src/db/migrations/00_initial.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes, QueryInterface } from 'sequelize'; 2 | const { INTEGER, STRING, DATE, TEXT } = DataTypes; 3 | 4 | export const up = async (queryInterface: QueryInterface): Promise => { 5 | await queryInterface.createTable('snippets', { 6 | id: { 7 | type: INTEGER, 8 | allowNull: false, 9 | primaryKey: true, 10 | autoIncrement: true 11 | }, 12 | title: { 13 | type: STRING, 14 | allowNull: false 15 | }, 16 | description: { 17 | type: TEXT, 18 | allowNull: true, 19 | defaultValue: '' 20 | }, 21 | language: { 22 | type: STRING, 23 | allowNull: false 24 | }, 25 | code: { 26 | type: TEXT, 27 | allowNull: false 28 | }, 29 | docs: { 30 | type: TEXT, 31 | allowNull: true, 32 | defaultValue: '' 33 | }, 34 | createdAt: { 35 | type: DATE, 36 | allowNull: false 37 | }, 38 | updatedAt: { 39 | type: DATE, 40 | allowNull: false 41 | } 42 | }); 43 | }; 44 | 45 | export const down = async (queryInterface: QueryInterface): Promise => { 46 | await queryInterface.dropTable('snippets'); 47 | }; 48 | -------------------------------------------------------------------------------- /src/db/migrations/01_pinned_snippets.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes, QueryInterface } from 'sequelize'; 2 | const { INTEGER } = DataTypes; 3 | 4 | export const up = async (queryInterface: QueryInterface): Promise => { 5 | await queryInterface.addColumn('snippets', 'isPinned', { 6 | type: INTEGER, 7 | allowNull: true, 8 | defaultValue: 0 9 | }); 10 | }; 11 | 12 | export const down = async (queryInterface: QueryInterface): Promise => { 13 | await queryInterface.removeColumn('snippets', 'isPinned'); 14 | }; 15 | -------------------------------------------------------------------------------- /src/db/migrations/02_tags.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '../../utils'; 2 | import { DataTypes, QueryInterface } from 'sequelize'; 3 | import { 4 | SnippetModel, 5 | Snippet_TagModel, 6 | TagInstance, 7 | TagModel 8 | } from '../../models'; 9 | 10 | const { STRING, INTEGER } = DataTypes; 11 | const logger = new Logger('migration[02]'); 12 | 13 | export const up = async (queryInterface: QueryInterface): Promise => { 14 | await queryInterface.createTable('tags', { 15 | id: { 16 | type: INTEGER, 17 | allowNull: false, 18 | primaryKey: true, 19 | autoIncrement: true 20 | }, 21 | name: { 22 | type: STRING, 23 | allowNull: false, 24 | unique: true 25 | } 26 | }); 27 | 28 | await queryInterface.createTable('snippets_tags', { 29 | id: { 30 | type: INTEGER, 31 | allowNull: false, 32 | primaryKey: true, 33 | autoIncrement: true 34 | }, 35 | snippet_id: { 36 | type: INTEGER, 37 | allowNull: false 38 | }, 39 | tag_id: { 40 | type: INTEGER, 41 | allowNull: false 42 | } 43 | }); 44 | 45 | // Create new tags from language column 46 | const snippets = await SnippetModel.findAll(); 47 | const languages = snippets.map(snippet => snippet.language); 48 | const uniqueLanguages = [...new Set(languages)]; 49 | const tags: TagInstance[] = []; 50 | 51 | if (snippets.length > 0) { 52 | await new Promise(resolve => { 53 | uniqueLanguages.forEach(async language => { 54 | try { 55 | const tag = await TagModel.create({ name: language }); 56 | tags.push(tag); 57 | } catch (err) { 58 | logger.log('Error while creating new tags'); 59 | } finally { 60 | if (uniqueLanguages.length == tags.length) { 61 | resolve(); 62 | } 63 | } 64 | }); 65 | }); 66 | 67 | // Assign tag to snippet 68 | await new Promise(resolve => { 69 | snippets.forEach(async snippet => { 70 | try { 71 | const tag = tags.find(tag => tag.name == snippet.language); 72 | 73 | if (tag) { 74 | await Snippet_TagModel.create({ 75 | snippet_id: snippet.id, 76 | tag_id: tag.id 77 | }); 78 | } 79 | } catch (err) { 80 | logger.log('Error while assigning tags to snippets'); 81 | } finally { 82 | resolve(); 83 | } 84 | }); 85 | }); 86 | } 87 | }; 88 | 89 | export const down = async (queryInterface: QueryInterface): Promise => { 90 | await queryInterface.dropTable('tags'); 91 | await queryInterface.dropTable('snippets_tags'); 92 | }; 93 | -------------------------------------------------------------------------------- /src/environment.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | PORT: number; 5 | NODE_ENV: string; 6 | } 7 | } 8 | } 9 | 10 | export {}; 11 | -------------------------------------------------------------------------------- /src/middleware/asyncWrapper.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | 3 | type Foo = (req: Request, res: Response, next: NextFunction) => Promise; 4 | 5 | export const asyncWrapper = 6 | (foo: Foo) => (req: Request, res: Response, next: NextFunction) => { 7 | return Promise.resolve(foo(req, res, next)).catch(next); 8 | }; 9 | -------------------------------------------------------------------------------- /src/middleware/errorHandler.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import { ErrorResponse, Logger } from '../utils'; 3 | 4 | const logger = new Logger('errorHandler'); 5 | 6 | export const errorHandler = ( 7 | err: ErrorResponse, 8 | req: Request, 9 | res: Response, 10 | next: NextFunction 11 | ) => { 12 | logger.log(err.message, 'ERROR'); 13 | 14 | res.status(err.statusCode || 500).json({ 15 | error: err.message || 'Internal Server Error' 16 | }); 17 | }; 18 | -------------------------------------------------------------------------------- /src/middleware/index.ts: -------------------------------------------------------------------------------- 1 | export * from './asyncWrapper'; 2 | export * from './requireBody'; 3 | export * from './errorHandler'; 4 | -------------------------------------------------------------------------------- /src/middleware/requireBody.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import { ErrorResponse } from '../utils'; 3 | 4 | export const requireBody = 5 | (...fields: string[]) => 6 | (req: Request, res: Response, next: NextFunction): void => { 7 | const bodyKeys = Object.keys(req.body); 8 | const missingKeys: string[] = []; 9 | 10 | fields.forEach(field => { 11 | if (!bodyKeys.includes(field)) { 12 | missingKeys.push(field); 13 | } 14 | }); 15 | 16 | if (missingKeys.length > 0) { 17 | return next( 18 | new ErrorResponse(400, `These fields are required: ${missingKeys}`) 19 | ); 20 | } 21 | 22 | next(); 23 | }; 24 | -------------------------------------------------------------------------------- /src/models/Snippet.ts: -------------------------------------------------------------------------------- 1 | import { Model, DataTypes } from 'sequelize'; 2 | import { sequelize } from '../db'; 3 | import { Snippet, SnippetCreationAttributes } from '../typescript/interfaces'; 4 | 5 | const { INTEGER, STRING, DATE, TEXT } = DataTypes; 6 | 7 | export interface SnippetInstance 8 | extends Model, 9 | Snippet {} 10 | 11 | export const SnippetModel = sequelize.define( 12 | 'Snippet', 13 | { 14 | id: { 15 | type: INTEGER, 16 | primaryKey: true, 17 | autoIncrement: true 18 | }, 19 | title: { 20 | type: STRING, 21 | allowNull: false 22 | }, 23 | description: { 24 | type: TEXT, 25 | allowNull: true, 26 | defaultValue: '' 27 | }, 28 | language: { 29 | type: STRING, 30 | allowNull: false 31 | }, 32 | code: { 33 | type: TEXT, 34 | allowNull: false 35 | }, 36 | docs: { 37 | type: TEXT, 38 | allowNull: true, 39 | defaultValue: '' 40 | }, 41 | isPinned: { 42 | type: INTEGER, 43 | allowNull: true, 44 | defaultValue: 0 45 | }, 46 | createdAt: { 47 | type: DATE 48 | }, 49 | updatedAt: { 50 | type: DATE 51 | } 52 | }, 53 | { 54 | tableName: 'snippets' 55 | } 56 | ); 57 | -------------------------------------------------------------------------------- /src/models/Snippet_Tag.ts: -------------------------------------------------------------------------------- 1 | import { Model, DataTypes } from 'sequelize'; 2 | import { sequelize } from '../db'; 3 | import { 4 | Snippet_Tag, 5 | Snippet_TagCreationAttributes 6 | } from '../typescript/interfaces'; 7 | 8 | const { INTEGER } = DataTypes; 9 | 10 | export interface Snippet_TagInstance 11 | extends Model, 12 | Snippet_Tag {} 13 | 14 | export const Snippet_TagModel = sequelize.define( 15 | 'Snippet_Tag', 16 | { 17 | id: { 18 | type: INTEGER, 19 | primaryKey: true, 20 | autoIncrement: true 21 | }, 22 | snippet_id: { 23 | type: INTEGER, 24 | allowNull: false 25 | }, 26 | tag_id: { 27 | type: INTEGER, 28 | allowNull: false 29 | } 30 | }, 31 | { 32 | timestamps: false, 33 | tableName: 'snippets_tags' 34 | } 35 | ); 36 | -------------------------------------------------------------------------------- /src/models/Tag.ts: -------------------------------------------------------------------------------- 1 | import { Model, DataTypes } from 'sequelize'; 2 | import { sequelize } from '../db'; 3 | import { Tag, TagCreationAttributes } from '../typescript/interfaces'; 4 | 5 | const { INTEGER, STRING } = DataTypes; 6 | 7 | export interface TagInstance extends Model, Tag {} 8 | 9 | export const TagModel = sequelize.define( 10 | 'Tag', 11 | { 12 | id: { 13 | type: INTEGER, 14 | primaryKey: true, 15 | autoIncrement: true 16 | }, 17 | name: { 18 | type: STRING, 19 | allowNull: false, 20 | unique: true 21 | } 22 | }, 23 | { 24 | timestamps: false, 25 | tableName: 'tags' 26 | } 27 | ); 28 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Snippet'; 2 | export * from './Tag'; 3 | export * from './Snippet_Tag'; 4 | -------------------------------------------------------------------------------- /src/routes/snippets.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { 3 | countTags, 4 | createSnippet, 5 | deleteSnippet, 6 | getAllSnippets, 7 | getRawCode, 8 | getSnippet, 9 | searchSnippets, 10 | updateSnippet 11 | } from '../controllers/snippets'; 12 | import { requireBody } from '../middleware'; 13 | 14 | export const snippetRouter = Router(); 15 | 16 | snippetRouter 17 | .route('/') 18 | .post(requireBody('title', 'language', 'code'), createSnippet) 19 | .get(getAllSnippets); 20 | 21 | snippetRouter 22 | .route('/:id') 23 | .get(getSnippet) 24 | .put(updateSnippet) 25 | .delete(deleteSnippet); 26 | 27 | snippetRouter.route('/statistics/count').get(countTags); 28 | snippetRouter.route('/raw/:id').get(getRawCode); 29 | snippetRouter.route('/search').post(searchSnippets); -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | import dotenv from 'dotenv'; 3 | import express, { Request, Response } from 'express'; 4 | import { Logger } from './utils'; 5 | import { connectDB } from './db'; 6 | import { errorHandler } from './middleware'; 7 | 8 | // Routers 9 | import { snippetRouter } from './routes/snippets'; 10 | import { associateModels } from './db/associateModels'; 11 | 12 | // Env config 13 | dotenv.config({ path: './src/config/.env' }); 14 | 15 | const app = express(); 16 | const logger = new Logger('server'); 17 | const PORT = process.env.PORT || 5000; 18 | 19 | // App config 20 | app.use(express.json()); 21 | app.use(express.static(join(__dirname, '../public'))); 22 | 23 | // Serve client code 24 | app.get(/^\/(?!api)/, (req: Request, res: Response) => { 25 | res.sendFile(join(__dirname, '../public/index.html')); 26 | }); 27 | 28 | // Routes 29 | app.use('/api/snippets', snippetRouter); 30 | 31 | // Error handler 32 | app.use(errorHandler); 33 | 34 | (async () => { 35 | await connectDB(); 36 | await associateModels(); 37 | 38 | app.listen(PORT, () => { 39 | logger.log( 40 | `Server is working on port ${PORT} in ${process.env.NODE_ENV} mode` 41 | ); 42 | }); 43 | })(); 44 | -------------------------------------------------------------------------------- /src/typescript/interfaces/Body.ts: -------------------------------------------------------------------------------- 1 | export interface Body { 2 | title: string; 3 | description?: string; 4 | language: string; 5 | code: string; 6 | docs?: string; 7 | isPinned: boolean; 8 | tags: string[]; 9 | } 10 | -------------------------------------------------------------------------------- /src/typescript/interfaces/Model.ts: -------------------------------------------------------------------------------- 1 | export interface Model { 2 | id: number; 3 | createdAt: Date; 4 | updatedAt: Date; 5 | } 6 | -------------------------------------------------------------------------------- /src/typescript/interfaces/SearchQuery.ts: -------------------------------------------------------------------------------- 1 | export interface SearchQuery { 2 | query: string; 3 | tags: string[]; 4 | languages: string[]; 5 | } 6 | -------------------------------------------------------------------------------- /src/typescript/interfaces/Snippet.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '.'; 2 | import { Optional } from 'sequelize'; 3 | 4 | export interface Snippet extends Model { 5 | title: string; 6 | description: string; 7 | language: string; 8 | code: string; 9 | docs: string; 10 | isPinned: number; 11 | tags?: { name: string }[]; 12 | } 13 | 14 | export interface SnippetCreationAttributes 15 | extends Optional {} 16 | -------------------------------------------------------------------------------- /src/typescript/interfaces/Snippet_Tag.ts: -------------------------------------------------------------------------------- 1 | import { Optional } from 'sequelize'; 2 | 3 | export interface Snippet_Tag { 4 | id: number; 5 | snippet_id: number; 6 | tag_id: number; 7 | } 8 | 9 | export interface Snippet_TagCreationAttributes 10 | extends Optional {} 11 | -------------------------------------------------------------------------------- /src/typescript/interfaces/Tag.ts: -------------------------------------------------------------------------------- 1 | import { Optional } from 'sequelize'; 2 | 3 | export interface Tag { 4 | id: number; 5 | name: string; 6 | } 7 | 8 | export interface TagCreationAttributes extends Optional {} 9 | -------------------------------------------------------------------------------- /src/typescript/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Model'; 2 | export * from './Snippet'; 3 | export * from './Tag'; 4 | export * from './Snippet_Tag'; 5 | export * from './Body'; 6 | export * from './SearchQuery'; 7 | -------------------------------------------------------------------------------- /src/typescript/types/ErrorLevel.ts: -------------------------------------------------------------------------------- 1 | export type ErrorLevel = 'INFO' | 'ERROR' | 'WARN' | 'DEV'; 2 | -------------------------------------------------------------------------------- /src/utils/ErrorResponse.ts: -------------------------------------------------------------------------------- 1 | export class ErrorResponse extends Error { 2 | public statusCode: number; 3 | 4 | constructor(statusCode: number, msg: string) { 5 | super(msg); 6 | this.statusCode = statusCode; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/Logger.ts: -------------------------------------------------------------------------------- 1 | import { ErrorLevel } from '../typescript/types/ErrorLevel'; 2 | 3 | export class Logger { 4 | private namespace: string; 5 | 6 | constructor(namespace: string) { 7 | this.namespace = namespace; 8 | } 9 | 10 | public log(message: string, level: ErrorLevel = 'INFO'): void { 11 | console.log( 12 | `[${this.generateTimestamp()}] [${level}] ${this.namespace}: ${message}` 13 | ); 14 | } 15 | 16 | private generateTimestamp(): string { 17 | const d = new Date(); 18 | 19 | // Date 20 | const year = d.getFullYear(); 21 | const month = this.parseDate(d.getMonth() + 1); 22 | const day = this.parseDate(d.getDate()); 23 | 24 | // Time 25 | const hour = this.parseDate(d.getHours()); 26 | const minutes = this.parseDate(d.getMinutes()); 27 | const seconds = this.parseDate(d.getSeconds()); 28 | const miliseconds = this.parseDate(d.getMilliseconds(), true); 29 | 30 | // Timezone 31 | const tz = -d.getTimezoneOffset() / 60; 32 | 33 | // Construct string 34 | const timestamp = `${year}-${month}-${day} ${hour}:${minutes}:${seconds}.${miliseconds} UTC${ 35 | tz >= 0 ? '+' + tz : tz 36 | }`; 37 | 38 | return timestamp; 39 | } 40 | 41 | private parseDate(dateFragment: number, ms: boolean = false): string { 42 | if (ms) { 43 | if (dateFragment >= 10 && dateFragment < 100) { 44 | return `0${dateFragment}`; 45 | } else if (dateFragment < 10) { 46 | return `00${dateFragment}`; 47 | } 48 | } 49 | 50 | return dateFragment < 10 ? `0${dateFragment}` : dateFragment.toString(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/utils/createTags.ts: -------------------------------------------------------------------------------- 1 | import { sequelize } from '../db'; 2 | import { QueryTypes } from 'sequelize'; 3 | import { TagModel, Snippet_TagModel } from '../models'; 4 | 5 | export const createTags = async ( 6 | parsedTags: Set, 7 | snippetId: number 8 | ): Promise => { 9 | // Get all tags 10 | const rawAllTags = await sequelize.query<{ id: number; name: string }>( 11 | `SELECT * FROM tags`, 12 | { type: QueryTypes.SELECT } 13 | ); 14 | 15 | const parsedAllTags = rawAllTags.map(tag => tag.name); 16 | 17 | // Create array of new tags 18 | const newTags = [...parsedTags].filter(tag => !parsedAllTags.includes(tag)); 19 | 20 | // Create new tags 21 | if (newTags.length > 0) { 22 | for (const tag of newTags) { 23 | const { id, name } = await TagModel.create({ name: tag }); 24 | rawAllTags.push({ id, name }); 25 | } 26 | } 27 | 28 | // Associate tags with snippet 29 | for (const tag of parsedTags) { 30 | const tagObj = rawAllTags.find(t => t.name == tag); 31 | 32 | if (tagObj) { 33 | await Snippet_TagModel.create({ 34 | snippet_id: snippetId, 35 | tag_id: tagObj.id 36 | }); 37 | } 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /src/utils/getTags.ts: -------------------------------------------------------------------------------- 1 | import { sequelize } from '../db'; 2 | import { QueryTypes } from 'sequelize'; 3 | 4 | export const getTags = async (snippetId: number): Promise => { 5 | const tags = await sequelize.query<{ name: string }>( 6 | `SELECT tags.name 7 | FROM tags 8 | INNER JOIN 9 | snippets_tags ON tags.id = snippets_tags.tag_id 10 | INNER JOIN 11 | snippets ON snippets.id = snippets_tags.snippet_id 12 | WHERE 13 | snippets_tags.snippet_id = ${snippetId};`, 14 | { type: QueryTypes.SELECT } 15 | ); 16 | 17 | return tags.map(tag => tag.name); 18 | }; 19 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Logger'; 2 | export * from './ErrorResponse'; 3 | export * from './tagParser'; 4 | export * from './getTags'; 5 | export * from './createTags'; 6 | -------------------------------------------------------------------------------- /src/utils/tagParser.ts: -------------------------------------------------------------------------------- 1 | export const tagParser = (tags: string[]): Set => { 2 | const parsedTags = tags.map(tag => tag.trim().toLowerCase()).filter(String); 3 | const uniqueTags = new Set([...parsedTags]); 4 | 5 | return uniqueTags; 6 | }; 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 8 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | "outDir": "./build" /* Redirect output structure to the directory. */, 18 | "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true /* Enable all strict type-checking options. */, 29 | // "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 43 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 44 | 45 | /* Module Resolution Options */ 46 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 47 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 48 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 49 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 50 | // "typeRoots": [], /* List of folders to include type definitions from. */ 51 | // "types": [], /* Type declaration files to be included in compilation. */ 52 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 53 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 54 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 55 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 56 | 57 | /* Source Map Options */ 58 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 61 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 62 | 63 | /* Experimental Options */ 64 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 65 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 66 | 67 | /* Advanced Options */ 68 | "skipLibCheck": true /* Skip type checking of declaration files. */, 69 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 70 | }, 71 | "include": ["src"] 72 | } 73 | --------------------------------------------------------------------------------