├── .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 | 
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 | 
94 |
95 | - Snippet library
96 | - Manage your snippets through snippet library
97 | - Easily filter and access your code using tags
98 |
99 | 
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 | 
107 |
108 | - Editor
109 | - Create and edit your snippets from simple and easy to use editor
110 |
111 | 
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 | deleteSnippet(id)}
83 | />
84 |
85 | {
91 | setSnippet(id);
92 | history.push({
93 | pathname: `/editor/${id}`,
94 | state: { from: window.location.pathname }
95 | });
96 | }}
97 | />
98 |
99 | {
105 | const { protocol, host } = window.location;
106 | const rawUrl = `${protocol}//${host}/api/snippets/raw/${id}`;
107 | copy(rawUrl);
108 | }}
109 | />
110 |
111 | copy(code)}
116 | />
117 |
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 |
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 |
33 | {text}
34 |
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 |
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 |
76 |
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 |
--------------------------------------------------------------------------------