├── .dev ├── DEV_GUIDELINES.md ├── bookmarks_importer.py ├── build_dev.sh ├── build_latest.sh ├── build_multiarch.sh └── getMdi.js ├── .docker ├── Dockerfile ├── Dockerfile.dev ├── Dockerfile.multiarch └── docker-compose.yml ├── .dockerignore ├── .env ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md ├── apps.png ├── bookmarks.png ├── home.png ├── settings.png └── themes.png ├── .gitignore ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── Socket.js ├── Sockets.js ├── api.js ├── client ├── .env ├── .gitignore ├── package-lock.json ├── package.json ├── public │ ├── flame.css │ ├── icons │ │ ├── apple-touch-icon-114x114.png │ │ ├── apple-touch-icon-120x120.png │ │ ├── apple-touch-icon-144x144.png │ │ ├── apple-touch-icon-152x152.png │ │ ├── apple-touch-icon-180x180.png │ │ ├── apple-touch-icon-57x57.png │ │ ├── apple-touch-icon-72x72.png │ │ ├── apple-touch-icon-76x76.png │ │ ├── apple-touch-icon.png │ │ └── favicon.ico │ ├── index.html │ └── robots.txt ├── src │ ├── App.tsx │ ├── assets │ │ └── fonts │ │ │ └── Roboto │ │ │ ├── roboto-v29-latin-500.woff │ │ │ ├── roboto-v29-latin-500.woff2 │ │ │ ├── roboto-v29-latin-700.woff │ │ │ ├── roboto-v29-latin-700.woff2 │ │ │ ├── roboto-v29-latin-900.woff │ │ │ ├── roboto-v29-latin-900.woff2 │ │ │ ├── roboto-v29-latin-regular.woff │ │ │ └── roboto-v29-latin-regular.woff2 │ ├── components │ │ ├── Actions │ │ │ ├── TableActions.module.css │ │ │ └── TableActions.tsx │ │ ├── Apps │ │ │ ├── AppCard │ │ │ │ ├── AppCard.module.css │ │ │ │ └── AppCard.tsx │ │ │ ├── AppForm │ │ │ │ ├── AppForm.module.css │ │ │ │ └── AppForm.tsx │ │ │ ├── AppGrid │ │ │ │ ├── AppGrid.module.css │ │ │ │ └── AppGrid.tsx │ │ │ ├── AppTable │ │ │ │ └── AppTable.tsx │ │ │ ├── Apps.module.css │ │ │ └── Apps.tsx │ │ ├── Bookmarks │ │ │ ├── BookmarkCard │ │ │ │ ├── BookmarkCard.module.css │ │ │ │ └── BookmarkCard.tsx │ │ │ ├── BookmarkGrid │ │ │ │ ├── BookmarkGrid.module.css │ │ │ │ └── BookmarkGrid.tsx │ │ │ ├── Bookmarks.module.css │ │ │ ├── Bookmarks.tsx │ │ │ ├── Form │ │ │ │ ├── BookmarksForm.tsx │ │ │ │ ├── CategoryForm.tsx │ │ │ │ ├── Form.module.css │ │ │ │ └── Form.tsx │ │ │ └── Table │ │ │ │ ├── BookmarksTable.tsx │ │ │ │ ├── CategoryTable.tsx │ │ │ │ └── Table.tsx │ │ ├── Home │ │ │ ├── Header │ │ │ │ ├── Header.module.css │ │ │ │ ├── Header.tsx │ │ │ │ └── functions │ │ │ │ │ ├── getDateTime.ts │ │ │ │ │ └── greeter.ts │ │ │ ├── Home.module.css │ │ │ └── Home.tsx │ │ ├── NotificationCenter │ │ │ ├── NotificationCenter.module.css │ │ │ └── NotificationCenter.tsx │ │ ├── Routing │ │ │ └── ProtectedRoute.tsx │ │ ├── SearchBar │ │ │ ├── SearchBar.module.css │ │ │ └── SearchBar.tsx │ │ ├── Settings │ │ │ ├── AppDetails │ │ │ │ ├── AppDetails.module.css │ │ │ │ ├── AppDetails.tsx │ │ │ │ └── AuthForm │ │ │ │ │ └── AuthForm.tsx │ │ │ ├── DockerSettings │ │ │ │ └── DockerSettings.tsx │ │ │ ├── GeneralSettings │ │ │ │ ├── CustomQueries │ │ │ │ │ ├── CustomQueries.tsx │ │ │ │ │ └── QueriesForm.tsx │ │ │ │ └── GeneralSettings.tsx │ │ │ ├── Settings.module.css │ │ │ ├── Settings.tsx │ │ │ ├── StyleSettings │ │ │ │ └── StyleSettings.tsx │ │ │ ├── Themer │ │ │ │ ├── ThemeBuilder │ │ │ │ │ ├── ThemeBuilder.module.css │ │ │ │ │ ├── ThemeBuilder.tsx │ │ │ │ │ ├── ThemeCreator.module.css │ │ │ │ │ ├── ThemeCreator.tsx │ │ │ │ │ └── ThemeEditor.tsx │ │ │ │ ├── ThemeGrid │ │ │ │ │ ├── ThemeGrid.module.css │ │ │ │ │ └── ThemeGrid.tsx │ │ │ │ ├── ThemePreview │ │ │ │ │ ├── ThemePreview.module.css │ │ │ │ │ └── ThemePreview.tsx │ │ │ │ └── Themer.tsx │ │ │ ├── UISettings │ │ │ │ └── UISettings.tsx │ │ │ ├── WeatherSettings │ │ │ │ └── WeatherSettings.tsx │ │ │ └── settings.json │ │ ├── UI │ │ │ ├── Buttons │ │ │ │ ├── ActionButton │ │ │ │ │ ├── ActionButton.module.css │ │ │ │ │ └── ActionButton.tsx │ │ │ │ └── Button │ │ │ │ │ ├── Button.module.css │ │ │ │ │ └── Button.tsx │ │ │ ├── Forms │ │ │ │ ├── InputGroup │ │ │ │ │ ├── InputGroup.module.css │ │ │ │ │ └── InputGroup.tsx │ │ │ │ └── ModalForm │ │ │ │ │ ├── ModalForm.module.css │ │ │ │ │ └── ModalForm.tsx │ │ │ ├── Headlines │ │ │ │ ├── Headline │ │ │ │ │ ├── Headline.module.css │ │ │ │ │ └── Headline.tsx │ │ │ │ ├── SectionHeadline │ │ │ │ │ ├── SectionHeadline.module.css │ │ │ │ │ └── SectionHeadline.tsx │ │ │ │ └── SettingsHeadline │ │ │ │ │ ├── SettingsHeadline.module.css │ │ │ │ │ └── SettingsHeadline.tsx │ │ │ ├── Icons │ │ │ │ ├── ActionIcons │ │ │ │ │ ├── ActionIcons.module.css │ │ │ │ │ └── ActionIcons.tsx │ │ │ │ ├── Icon │ │ │ │ │ ├── Icon.module.css │ │ │ │ │ └── Icon.tsx │ │ │ │ └── WeatherIcon │ │ │ │ │ ├── IconMapping.ts │ │ │ │ │ ├── WeatherIcon.tsx │ │ │ │ │ └── WeatherMapping.json │ │ │ ├── Layout │ │ │ │ ├── Layout.module.css │ │ │ │ └── Layout.tsx │ │ │ ├── Modal │ │ │ │ ├── Modal.module.css │ │ │ │ └── Modal.tsx │ │ │ ├── Notification │ │ │ │ ├── Notification.module.css │ │ │ │ └── Notification.tsx │ │ │ ├── Spinner │ │ │ │ ├── Spinner.module.css │ │ │ │ └── Spinner.tsx │ │ │ ├── Tables │ │ │ │ ├── CompactTable │ │ │ │ │ ├── CompactTable.module.css │ │ │ │ │ └── CompactTable.tsx │ │ │ │ └── Table │ │ │ │ │ ├── Table.module.css │ │ │ │ │ └── Table.tsx │ │ │ ├── Text │ │ │ │ └── Message │ │ │ │ │ ├── Message.module.css │ │ │ │ │ └── Message.tsx │ │ │ └── index.ts │ │ └── Widgets │ │ │ └── WeatherWidget │ │ │ ├── WeatherWidget.module.css │ │ │ └── WeatherWidget.tsx │ ├── index.css │ ├── index.tsx │ ├── interfaces │ │ ├── Api.ts │ │ ├── App.ts │ │ ├── Bookmark.ts │ │ ├── Category.ts │ │ ├── Config.ts │ │ ├── Forms.ts │ │ ├── Notification.ts │ │ ├── Query.ts │ │ ├── Route.ts │ │ ├── SearchResult.ts │ │ ├── Theme.ts │ │ ├── Weather.ts │ │ └── index.ts │ ├── react-app-env.d.ts │ ├── setupProxy.js │ ├── store │ │ ├── action-creators │ │ │ ├── app.ts │ │ │ ├── auth.ts │ │ │ ├── bookmark.ts │ │ │ ├── config.ts │ │ │ ├── index.ts │ │ │ ├── notification.ts │ │ │ └── theme.ts │ │ ├── action-types │ │ │ └── index.ts │ │ ├── actions │ │ │ ├── app.ts │ │ │ ├── auth.ts │ │ │ ├── bookmark.ts │ │ │ ├── config.ts │ │ │ ├── index.ts │ │ │ ├── notification.ts │ │ │ └── theme.ts │ │ ├── index.ts │ │ ├── reducers │ │ │ ├── app.ts │ │ │ ├── auth.ts │ │ │ ├── bookmark.ts │ │ │ ├── config.ts │ │ │ ├── index.ts │ │ │ ├── notification.ts │ │ │ └── theme.ts │ │ └── store.ts │ ├── types │ │ ├── ConfigFormData.ts │ │ ├── WeatherData.ts │ │ └── index.ts │ └── utility │ │ ├── applyAuth.ts │ │ ├── arrayPartition.ts │ │ ├── checkVersion.ts │ │ ├── decodeToken.ts │ │ ├── escapeRegex.ts │ │ ├── iconParser.ts │ │ ├── index.ts │ │ ├── inputHandler.ts │ │ ├── parseTheme.ts │ │ ├── parseTime.ts │ │ ├── redirectUrl.ts │ │ ├── searchParser.ts │ │ ├── searchQueries.json │ │ ├── sortData.ts │ │ ├── storeUIConfig.ts │ │ ├── templateObjects │ │ ├── appTemplate.ts │ │ ├── bookmarkTemplate.ts │ │ ├── categoryTemplate.ts │ │ ├── configTemplate.ts │ │ ├── index.ts │ │ ├── settingsTemplate.ts │ │ └── weatherTemplate.ts │ │ ├── urlParser.ts │ │ └── validators.ts └── tsconfig.json ├── controllers ├── apps │ ├── createApp.js │ ├── deleteApp.js │ ├── docker │ │ ├── index.js │ │ ├── useDocker.js │ │ └── useKubernetes.js │ ├── getAllApps.js │ ├── getSingleApp.js │ ├── index.js │ ├── reorderApps.js │ └── updateApp.js ├── auth │ ├── index.js │ ├── login.js │ └── validate.js ├── bookmarks │ ├── createBookmark.js │ ├── deleteBookmark.js │ ├── getAllBookmarks.js │ ├── getSingleBookmark.js │ ├── index.js │ ├── reorderBookmarks.js │ └── updateBookmark.js ├── categories │ ├── createCategory.js │ ├── deleteCategory.js │ ├── getAllCategories.js │ ├── getSingleCategory.js │ ├── index.js │ ├── reorderCategories.js │ └── updateCategory.js ├── config │ ├── getCSS.js │ ├── getConfig.js │ ├── index.js │ ├── updateCSS.js │ └── updateConfig.js ├── queries │ ├── addQuery.js │ ├── deleteQuery.js │ ├── getQueries.js │ ├── index.js │ └── updateQuery.js ├── themes │ ├── addTheme.js │ ├── deleteTheme.js │ ├── getThemes.js │ ├── index.js │ └── updateTheme.js └── weather │ ├── getWather.js │ ├── index.js │ └── updateWeather.js ├── db ├── index.js ├── migrations │ ├── 00_initial.js │ ├── 01_new-config.js │ ├── 02_resource-access.js │ ├── 03_weather.js │ ├── 04_bookmarks-order.js │ └── 05_app-description.js └── utils │ ├── backupDb.js │ └── slugify.js ├── k8s ├── base │ ├── deployment.yaml │ ├── ingress.yaml │ ├── kustomization.yaml │ ├── namespace.yaml │ ├── rbac.yaml │ └── service.yaml └── overlays │ └── shokohsc │ ├── deployment.yaml │ ├── ingress.yaml │ ├── kustomization.yaml │ ├── namespace.yaml │ ├── rbac.yaml │ └── service.yaml ├── middleware ├── asyncWrapper.js ├── auth.js ├── errorHandler.js ├── index.js ├── multer.js ├── requireAuth.js └── requireBody.js ├── models ├── App.js ├── Bookmark.js ├── Category.js ├── Config.js ├── Weather.js └── associateModels.js ├── package-lock.json ├── package.json ├── routes ├── apps.js ├── auth.js ├── bookmark.js ├── category.js ├── config.js ├── queries.js ├── themes.js └── weather.js ├── server.js ├── skaffold.yaml └── utils ├── ErrorResponse.js ├── File.js ├── Logger.js ├── checkFileExists.js ├── clearWeatherData.js ├── getExternalWeather.js ├── init ├── createFile.js ├── index.js ├── initConfig.js ├── initDockerSecrets.js ├── initFiles.js ├── initialConfig.json ├── initialFiles.json ├── normalizeTheme.js └── themes.json ├── jobs.js ├── loadConfig.js └── signToken.js /.dev/DEV_GUIDELINES.md: -------------------------------------------------------------------------------- 1 | ## Adding new config key 2 | 3 | 1. Edit utils/init/initialConfig.json 4 | 2. Edit client/src/interfaces/Config.ts 5 | 3. Edit client/src/utility/templateObjects/configTemplate.ts 6 | 7 | If config value will be used in a form: 8 | 9 | 4. Edit client/src/interfaces/Forms.ts 10 | 5. Edit client/src/utility/templateObjects/settingsTemplate.ts -------------------------------------------------------------------------------- /.dev/build_dev.sh: -------------------------------------------------------------------------------- 1 | docker build -t flame:dev -f .docker/Dockerfile . -------------------------------------------------------------------------------- /.dev/build_latest.sh: -------------------------------------------------------------------------------- 1 | docker build -t pawelmalak/flame -t "pawelmalak/flame:$1" -f .docker/Dockerfile . \ 2 | && docker push pawelmalak/flame && docker push "pawelmalak/flame:$1" -------------------------------------------------------------------------------- /.dev/build_multiarch.sh: -------------------------------------------------------------------------------- 1 | docker buildx build \ 2 | --platform linux/arm/v7,linux/arm64,linux/amd64 \ 3 | -f .docker/Dockerfile.multiarch \ 4 | -t pawelmalak/flame:multiarch \ 5 | -t "pawelmalak/flame:multiarch$1" \ 6 | --push . -------------------------------------------------------------------------------- /.dev/getMdi.js: -------------------------------------------------------------------------------- 1 | // Script to get all icon names from materialdesignicons.com 2 | const getMdi = () => { 3 | const icons = document.querySelectorAll('#icons div span'); 4 | const names = [...icons].map((icon) => icon.textContent.replace('mdi-', '')); 5 | const output = names.map((name) => ({ name })); 6 | output.pop(); 7 | const json = JSON.stringify(output); 8 | console.log(json); 9 | }; 10 | -------------------------------------------------------------------------------- /.docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16 as builder 2 | 3 | WORKDIR /app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN npm install --production 8 | 9 | COPY . . 10 | 11 | RUN mkdir -p ./public ./data \ 12 | && cd ./client \ 13 | && npm install --production \ 14 | && npm run build \ 15 | && cd .. \ 16 | && mv ./client/build/* ./public \ 17 | && rm -rf ./client 18 | 19 | FROM node:16-alpine 20 | 21 | COPY --from=builder /app /app 22 | 23 | WORKDIR /app 24 | 25 | EXPOSE 5005 26 | 27 | ENV NODE_ENV=production 28 | ENV PASSWORD=flame_password 29 | 30 | CMD ["sh", "-c", "chown -R node /app/data && node server.js"] -------------------------------------------------------------------------------- /.docker/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine as build-front 2 | 3 | RUN apk add --no-cache curl 4 | 5 | WORKDIR /app 6 | 7 | COPY ./client . 8 | 9 | RUN npm install --production \ 10 | && npm run build 11 | 12 | FROM node:lts-alpine 13 | 14 | WORKDIR /app 15 | 16 | RUN mkdir -p ./public 17 | 18 | COPY --from=build-front /app/build/ ./public 19 | 20 | COPY package*.json ./ 21 | 22 | RUN npm install 23 | 24 | COPY . . 25 | 26 | CMD ["npm", "run", "skaffold"] -------------------------------------------------------------------------------- /.docker/Dockerfile.multiarch: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine3.11 as builder 2 | 3 | WORKDIR /app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN apk --no-cache --virtual build-dependencies add python python3 make g++ \ 8 | && npm install --production 9 | 10 | COPY . . 11 | 12 | RUN mkdir -p ./public ./data \ 13 | && cd ./client \ 14 | && npm install --production \ 15 | && npm run build \ 16 | && cd .. \ 17 | && mv ./client/build/* ./public \ 18 | && rm -rf ./client 19 | 20 | FROM node:16-alpine3.11 21 | 22 | COPY --from=builder /app /app 23 | 24 | WORKDIR /app 25 | 26 | EXPOSE 5005 27 | 28 | ENV NODE_ENV=production 29 | ENV PASSWORD=flame_password 30 | 31 | CMD ["sh", "-c", "chown -R node /app/data && node server.js"] -------------------------------------------------------------------------------- /.docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.6' 2 | 3 | services: 4 | flame: 5 | image: pawelmalak/flame 6 | container_name: flame 7 | volumes: 8 | - /path/to/host/data:/app/data 9 | # - /var/run/docker.sock:/var/run/docker.sock # optional but required for Docker integration 10 | ports: 11 | - 5005:5005 12 | # secrets: 13 | # - password # optional but required for (1) 14 | environment: 15 | - PASSWORD=flame_password 16 | # - PASSWORD_FILE=/run/secrets/password # optional but required for (1) 17 | restart: unless-stopped 18 | 19 | # optional but required for Docker secrets (1) 20 | # secrets: 21 | # password: 22 | # file: /path/to/secrets/password 23 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .github 3 | public 4 | k8s 5 | skaffold.yaml 6 | data -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | PORT=5005 2 | NODE_ENV=development 3 | VERSION=2.3.1 4 | PASSWORD=flame_password 5 | SECRET=e02eb43d69953658c6d07311d6313f2d4467672cb881f96b29368ba1f3f4da4b -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report 4 | title: "[BUG] " 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Deployment details:** 11 | - App version [e.g. v1.7.4]: 12 | - Platform [e.g. amd64, arm64, arm/v7]: 13 | - Docker image tag [e.g. latest, multiarch]: 14 | 15 | --- 16 | 17 | **Bug description:** 18 | 19 | A clear and concise description of what the bug is. 20 | 21 | --- 22 | 23 | **Steps to reproduce:** 24 | 25 | 1. Go to '...' 26 | 2. Click on '....' 27 | 3. Scroll down to '....' 28 | -------------------------------------------------------------------------------- /.github/apps.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/flame/3c347c854c4c55456785ff026a703422d8f02f62/.github/apps.png -------------------------------------------------------------------------------- /.github/bookmarks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/flame/3c347c854c4c55456785ff026a703422d8f02f62/.github/bookmarks.png -------------------------------------------------------------------------------- /.github/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/flame/3c347c854c4c55456785ff026a703422d8f02f62/.github/home.png -------------------------------------------------------------------------------- /.github/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/flame/3c347c854c4c55456785ff026a703422d8f02f62/.github/settings.png -------------------------------------------------------------------------------- /.github/themes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/flame/3c347c854c4c55456785ff026a703422d8f02f62/.github/themes.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | data 3 | public 4 | !client/public -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.md 2 | docker-compose.yml -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "singleQuote": true, 5 | "arrowParens": "always", 6 | "printWidth": 80, 7 | "trailingComma": "es5" 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE.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. -------------------------------------------------------------------------------- /Socket.js: -------------------------------------------------------------------------------- 1 | const WebSocket = require('ws'); 2 | const Logger = require('./utils/Logger'); 3 | const logger = new Logger(); 4 | 5 | class Socket { 6 | constructor(server) { 7 | this.webSocketServer = new WebSocket.Server({ server }) 8 | 9 | this.webSocketServer.on('listening', () => { 10 | logger.log('Socket: listen'); 11 | }) 12 | 13 | this.webSocketServer.on('connection', (webSocketClient) => { 14 | // console.log('Socket: new connection'); 15 | }) 16 | } 17 | 18 | send(msg) { 19 | this.webSocketServer.clients.forEach(client => client.send(msg)); 20 | } 21 | } 22 | 23 | module.exports = Socket; -------------------------------------------------------------------------------- /Sockets.js: -------------------------------------------------------------------------------- 1 | class Sockets { 2 | constructor() { 3 | this.sockets = []; 4 | } 5 | 6 | registerSocket(name, socket) { 7 | this.sockets.push({ name, socket }); 8 | } 9 | 10 | getAllSockets() { 11 | return this.sockets; 12 | } 13 | 14 | getSocket(name) { 15 | const socket = this.sockets.find(socket => socket.name === name); 16 | return socket; 17 | } 18 | } 19 | 20 | module.exports = new Sockets(); -------------------------------------------------------------------------------- /api.js: -------------------------------------------------------------------------------- 1 | const { join } = require('path'); 2 | const express = require('express'); 3 | const { errorHandler } = require('./middleware'); 4 | 5 | const api = express(); 6 | 7 | // Static files 8 | api.use(express.static(join(__dirname, 'public'))); 9 | api.use('/uploads', express.static(join(__dirname, 'data/uploads'))); 10 | api.get(/^\/(?!api)/, (req, res) => { 11 | res.sendFile(join(__dirname, 'public/index.html')); 12 | }); 13 | 14 | // Body parser 15 | api.use(express.json()); 16 | 17 | // Link controllers with routes 18 | api.use('/api/apps', require('./routes/apps')); 19 | api.use('/api/config', require('./routes/config')); 20 | api.use('/api/weather', require('./routes/weather')); 21 | api.use('/api/categories', require('./routes/category')); 22 | api.use('/api/bookmarks', require('./routes/bookmark')); 23 | api.use('/api/queries', require('./routes/queries')); 24 | api.use('/api/auth', require('./routes/auth')); 25 | api.use('/api/themes', require('./routes/themes')); 26 | 27 | // Custom error handler 28 | api.use(errorHandler); 29 | 30 | module.exports = api; 31 | -------------------------------------------------------------------------------- /client/.env: -------------------------------------------------------------------------------- 1 | REACT_APP_VERSION=2.3.1 -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@mdi/js": "^6.4.95", 7 | "@mdi/react": "^1.5.0", 8 | "@testing-library/jest-dom": "^5.15.0", 9 | "@testing-library/react": "^12.1.2", 10 | "@testing-library/user-event": "^13.5.0", 11 | "@types/jest": "^27.0.2", 12 | "@types/node": "^16.11.6", 13 | "@types/react": "^17.0.34", 14 | "@types/react-beautiful-dnd": "^13.1.2", 15 | "@types/react-dom": "^17.0.11", 16 | "@types/react-redux": "^7.1.20", 17 | "@types/react-router-dom": "^5.1.7", 18 | "axios": "^0.24.0", 19 | "external-svg-loader": "^1.3.4", 20 | "http-proxy-middleware": "^2.0.1", 21 | "jwt-decode": "^3.1.2", 22 | "react": "^17.0.2", 23 | "react-beautiful-dnd": "^13.1.0", 24 | "react-dom": "^17.0.2", 25 | "react-redux": "^7.2.6", 26 | "react-router-dom": "^5.2.0", 27 | "react-scripts": "^5.0.1", 28 | "redux": "^4.1.2", 29 | "redux-devtools-extension": "^2.13.9", 30 | "redux-thunk": "^2.4.0", 31 | "skycons-ts": "^0.2.0", 32 | "typescript": "^4.4.4", 33 | "web-vitals": "^2.1.2" 34 | }, 35 | "scripts": { 36 | "start": "react-scripts start", 37 | "build": "react-scripts build", 38 | "test": "react-scripts test", 39 | "eject": "react-scripts eject" 40 | }, 41 | "eslintConfig": { 42 | "extends": [ 43 | "react-app", 44 | "react-app/jest" 45 | ] 46 | }, 47 | "browserslist": { 48 | "production": [ 49 | ">0.2%", 50 | "not dead", 51 | "not op_mini all" 52 | ], 53 | "development": [ 54 | "last 1 chrome version", 55 | "last 1 firefox version", 56 | "last 1 safari version" 57 | ] 58 | }, 59 | "devDependencies": { 60 | "prettier": "^2.4.1" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /client/public/flame.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/flame/3c347c854c4c55456785ff026a703422d8f02f62/client/public/flame.css -------------------------------------------------------------------------------- /client/public/icons/apple-touch-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/flame/3c347c854c4c55456785ff026a703422d8f02f62/client/public/icons/apple-touch-icon-114x114.png -------------------------------------------------------------------------------- /client/public/icons/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/flame/3c347c854c4c55456785ff026a703422d8f02f62/client/public/icons/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /client/public/icons/apple-touch-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/flame/3c347c854c4c55456785ff026a703422d8f02f62/client/public/icons/apple-touch-icon-144x144.png -------------------------------------------------------------------------------- /client/public/icons/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/flame/3c347c854c4c55456785ff026a703422d8f02f62/client/public/icons/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /client/public/icons/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/flame/3c347c854c4c55456785ff026a703422d8f02f62/client/public/icons/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /client/public/icons/apple-touch-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/flame/3c347c854c4c55456785ff026a703422d8f02f62/client/public/icons/apple-touch-icon-57x57.png -------------------------------------------------------------------------------- /client/public/icons/apple-touch-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/flame/3c347c854c4c55456785ff026a703422d8f02f62/client/public/icons/apple-touch-icon-72x72.png -------------------------------------------------------------------------------- /client/public/icons/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/flame/3c347c854c4c55456785ff026a703422d8f02f62/client/public/icons/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /client/public/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/flame/3c347c854c4c55456785ff026a703422d8f02f62/client/public/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /client/public/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/flame/3c347c854c4c55456785ff026a703422d8f02f62/client/public/icons/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 51 | 55 | 56 | Flame 57 | 58 | 59 | 60 |
61 | 62 | 63 | -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / -------------------------------------------------------------------------------- /client/src/assets/fonts/Roboto/roboto-v29-latin-500.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/flame/3c347c854c4c55456785ff026a703422d8f02f62/client/src/assets/fonts/Roboto/roboto-v29-latin-500.woff -------------------------------------------------------------------------------- /client/src/assets/fonts/Roboto/roboto-v29-latin-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/flame/3c347c854c4c55456785ff026a703422d8f02f62/client/src/assets/fonts/Roboto/roboto-v29-latin-500.woff2 -------------------------------------------------------------------------------- /client/src/assets/fonts/Roboto/roboto-v29-latin-700.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/flame/3c347c854c4c55456785ff026a703422d8f02f62/client/src/assets/fonts/Roboto/roboto-v29-latin-700.woff -------------------------------------------------------------------------------- /client/src/assets/fonts/Roboto/roboto-v29-latin-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/flame/3c347c854c4c55456785ff026a703422d8f02f62/client/src/assets/fonts/Roboto/roboto-v29-latin-700.woff2 -------------------------------------------------------------------------------- /client/src/assets/fonts/Roboto/roboto-v29-latin-900.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/flame/3c347c854c4c55456785ff026a703422d8f02f62/client/src/assets/fonts/Roboto/roboto-v29-latin-900.woff -------------------------------------------------------------------------------- /client/src/assets/fonts/Roboto/roboto-v29-latin-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/flame/3c347c854c4c55456785ff026a703422d8f02f62/client/src/assets/fonts/Roboto/roboto-v29-latin-900.woff2 -------------------------------------------------------------------------------- /client/src/assets/fonts/Roboto/roboto-v29-latin-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/flame/3c347c854c4c55456785ff026a703422d8f02f62/client/src/assets/fonts/Roboto/roboto-v29-latin-regular.woff -------------------------------------------------------------------------------- /client/src/assets/fonts/Roboto/roboto-v29-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelmalak/flame/3c347c854c4c55456785ff026a703422d8f02f62/client/src/assets/fonts/Roboto/roboto-v29-latin-regular.woff2 -------------------------------------------------------------------------------- /client/src/components/Actions/TableActions.module.css: -------------------------------------------------------------------------------- 1 | .TableActions { 2 | display: flex; 3 | align-items: center; 4 | } 5 | 6 | .TableAction { 7 | width: 22px; 8 | } 9 | 10 | .TableAction:hover { 11 | cursor: pointer; 12 | } 13 | -------------------------------------------------------------------------------- /client/src/components/Actions/TableActions.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '../UI'; 2 | import classes from './TableActions.module.css'; 3 | 4 | interface Entity { 5 | id: number; 6 | name: string; 7 | isPinned?: boolean; 8 | isPublic: boolean; 9 | } 10 | 11 | interface Props { 12 | entity: Entity; 13 | deleteHandler: (id: number, name: string) => void; 14 | updateHandler: (id: number) => void; 15 | pinHanlder?: (id: number) => void; 16 | changeVisibilty: (id: number) => void; 17 | showPin?: boolean; 18 | } 19 | 20 | export const TableActions = (props: Props): JSX.Element => { 21 | const { 22 | entity, 23 | deleteHandler, 24 | updateHandler, 25 | pinHanlder, 26 | changeVisibilty, 27 | showPin = true, 28 | } = props; 29 | 30 | const _pinHandler = pinHanlder || function () {}; 31 | 32 | return ( 33 | 34 | {/* DELETE */} 35 |
deleteHandler(entity.id, entity.name)} 38 | tabIndex={0} 39 | > 40 | 41 |
42 | 43 | {/* UPDATE */} 44 |
updateHandler(entity.id)} 47 | tabIndex={0} 48 | > 49 | 50 |
51 | 52 | {/* PIN */} 53 | {showPin && ( 54 |
_pinHandler(entity.id)} 57 | tabIndex={0} 58 | > 59 | {entity.isPinned ? ( 60 | 61 | ) : ( 62 | 63 | )} 64 |
65 | )} 66 | 67 | {/* VISIBILITY */} 68 |
changeVisibilty(entity.id)} 71 | tabIndex={0} 72 | > 73 | {entity.isPublic ? ( 74 | 75 | ) : ( 76 | 77 | )} 78 |
79 | 80 | ); 81 | }; 82 | -------------------------------------------------------------------------------- /client/src/components/Apps/AppCard/AppCard.module.css: -------------------------------------------------------------------------------- 1 | .AppCard { 2 | width: 100%; 3 | display: flex; 4 | align-items: center; 5 | margin-bottom: 20px; 6 | } 7 | 8 | .AppCardIcon { 9 | width: 35px; 10 | height: 35px; 11 | margin-right: 0.5em; 12 | } 13 | 14 | .AppCardDetails { 15 | text-transform: uppercase; 16 | } 17 | 18 | .AppCardDetails h5 { 19 | font-size: 1em; 20 | font-weight: 500; 21 | color: var(--color-primary); 22 | margin-bottom: -4px; 23 | } 24 | 25 | .AppCardDetails span { 26 | color: var(--color-accent); 27 | font-weight: 400; 28 | font-size: 0.8em; 29 | opacity: 1; 30 | } 31 | 32 | @media (min-width: 500px) { 33 | .AppCard { 34 | padding: 2px; 35 | border-radius: 4px; 36 | transition: all 0.1s; 37 | } 38 | 39 | .AppCard:hover { 40 | background-color: rgba(0, 0, 0, 0.2); 41 | } 42 | } 43 | 44 | .CustomIcon { 45 | width: 90%; 46 | height: 90%; 47 | margin-top: 2px; 48 | margin-left: 2px; 49 | object-fit: contain; 50 | } 51 | -------------------------------------------------------------------------------- /client/src/components/Apps/AppCard/AppCard.tsx: -------------------------------------------------------------------------------- 1 | import classes from './AppCard.module.css'; 2 | import { Icon } from '../../UI'; 3 | import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility'; 4 | 5 | import { App } from '../../../interfaces'; 6 | import { useSelector } from 'react-redux'; 7 | import { State } from '../../../store/reducers'; 8 | 9 | interface Props { 10 | app: App; 11 | } 12 | 13 | export const AppCard = ({ app }: Props): JSX.Element => { 14 | const { config } = useSelector((state: State) => state.config); 15 | 16 | const [displayUrl, redirectUrl] = urlParser(app.url); 17 | 18 | let iconEl: JSX.Element; 19 | const { icon } = app; 20 | 21 | if (isImage(icon)) { 22 | const source = isUrl(icon) ? icon : `/uploads/${icon}`; 23 | 24 | iconEl = ( 25 | {`${app.name} 30 | ); 31 | } else if (isSvg(icon)) { 32 | const source = isUrl(icon) ? icon : `/uploads/${icon}`; 33 | 34 | iconEl = ( 35 |
36 | 41 |
42 | ); 43 | } else { 44 | iconEl = ; 45 | } 46 | 47 | return ( 48 | 54 |
{iconEl}
55 |
56 |
{app.name}
57 | {!app.description.length ? displayUrl : app.description} 58 |
59 |
60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /client/src/components/Apps/AppForm/AppForm.module.css: -------------------------------------------------------------------------------- 1 | .Switch { 2 | text-decoration: underline; 3 | } 4 | 5 | .Switch:hover { 6 | cursor: pointer; 7 | } -------------------------------------------------------------------------------- /client/src/components/Apps/AppGrid/AppGrid.module.css: -------------------------------------------------------------------------------- 1 | .AppGrid { 2 | display: grid; 3 | grid-template-columns: repeat(1, 1fr); 4 | } 5 | 6 | @media (min-width: 430px) { 7 | .AppGrid { 8 | grid-template-columns: repeat(2, 1fr); 9 | } 10 | } 11 | 12 | @media (min-width: 670px) { 13 | .AppGrid { 14 | grid-template-columns: repeat(3, 1fr); 15 | } 16 | } 17 | 18 | @media (min-width: 900px) { 19 | .AppGrid { 20 | grid-template-columns: repeat(4, 1fr); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /client/src/components/Apps/AppGrid/AppGrid.tsx: -------------------------------------------------------------------------------- 1 | import classes from './AppGrid.module.css'; 2 | import { Link } from 'react-router-dom'; 3 | import { App } from '../../../interfaces/App'; 4 | 5 | import { AppCard } from '../AppCard/AppCard'; 6 | import { Message } from '../../UI'; 7 | 8 | interface Props { 9 | apps: App[]; 10 | totalApps?: number; 11 | searching: boolean; 12 | } 13 | 14 | export const AppGrid = (props: Props): JSX.Element => { 15 | let apps: JSX.Element; 16 | 17 | if (props.searching || props.apps.length) { 18 | if (!props.apps.length) { 19 | apps = No apps match your search criteria; 20 | } else { 21 | apps = ( 22 |
23 | {props.apps.map((app: App): JSX.Element => { 24 | return ; 25 | })} 26 |
27 | ); 28 | } 29 | } else { 30 | if (props.totalApps) { 31 | apps = ( 32 | 33 | There are no pinned applications. You can pin them from the{' '} 34 | /applications menu 35 | 36 | ); 37 | } else { 38 | apps = ( 39 | 40 | You don't have any applications. You can add a new one from{' '} 41 | /applications menu 42 | 43 | ); 44 | } 45 | } 46 | 47 | return apps; 48 | }; 49 | -------------------------------------------------------------------------------- /client/src/components/Apps/Apps.module.css: -------------------------------------------------------------------------------- 1 | .ActionsContainer { 2 | display: flex; 3 | align-items: center; 4 | } -------------------------------------------------------------------------------- /client/src/components/Bookmarks/BookmarkCard/BookmarkCard.module.css: -------------------------------------------------------------------------------- 1 | .BookmarkCard { 2 | margin-bottom: 30px; 3 | } 4 | 5 | .BookmarkCard h3 { 6 | color: var(--color-accent); 7 | margin-bottom: 10px; 8 | font-size: 16px; 9 | font-weight: 400; 10 | text-transform: uppercase; 11 | } 12 | 13 | .BookmarkHeader:hover { 14 | cursor: pointer; 15 | } 16 | 17 | .Bookmarks { 18 | display: flex; 19 | flex-direction: column; 20 | } 21 | 22 | .Bookmarks a { 23 | line-height: 2; 24 | transition: all 0.25s; 25 | display: flex; 26 | } 27 | 28 | .BookmarkCard a:hover { 29 | text-decoration: underline; 30 | padding-left: 10px; 31 | } 32 | 33 | .BookmarkIcon { 34 | width: 20px; 35 | height: 20px; 36 | display: flex; 37 | margin-top: 3px; 38 | margin-right: 2px; 39 | justify-content: center; 40 | align-items: center; 41 | } 42 | 43 | .BookmarkIconSvg { 44 | width: 80%; 45 | height: 80%; 46 | margin-top: 2px; 47 | margin-left: 2px; 48 | object-fit: contain; 49 | } 50 | 51 | .CustomIcon { 52 | width: 90%; 53 | height: 90%; 54 | margin-top: 2px; 55 | object-fit: contain; 56 | } 57 | -------------------------------------------------------------------------------- /client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.module.css: -------------------------------------------------------------------------------- 1 | .BookmarkGrid { 2 | display: grid; 3 | grid-template-columns: repeat(1, 1fr); 4 | } 5 | 6 | @media (min-width: 430px) { 7 | .BookmarkGrid { 8 | grid-template-columns: repeat(2, 1fr); 9 | } 10 | } 11 | 12 | @media (min-width: 670px) { 13 | .BookmarkGrid { 14 | grid-template-columns: repeat(3, 1fr); 15 | } 16 | } 17 | 18 | @media (min-width: 900px) { 19 | .BookmarkGrid { 20 | grid-template-columns: repeat(4, 1fr); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | 3 | import classes from './BookmarkGrid.module.css'; 4 | 5 | import { Category } from '../../../interfaces'; 6 | 7 | import { BookmarkCard } from '../BookmarkCard/BookmarkCard'; 8 | import { Message } from '../../UI'; 9 | 10 | interface Props { 11 | categories: Category[]; 12 | totalCategories?: number; 13 | searching: boolean; 14 | fromHomepage?: boolean; 15 | } 16 | 17 | export const BookmarkGrid = (props: Props): JSX.Element => { 18 | const { 19 | categories, 20 | totalCategories, 21 | searching, 22 | fromHomepage = false, 23 | } = props; 24 | 25 | let bookmarks: JSX.Element; 26 | 27 | if (categories.length) { 28 | if (searching && !categories[0].bookmarks.length) { 29 | bookmarks = No bookmarks match your search criteria; 30 | } else { 31 | bookmarks = ( 32 |
33 | {categories.map( 34 | (category: Category): JSX.Element => ( 35 | 40 | ) 41 | )} 42 |
43 | ); 44 | } 45 | } else { 46 | if (totalCategories) { 47 | bookmarks = ( 48 | 49 | There are no pinned categories. You can pin them from the{' '} 50 | /bookmarks menu 51 | 52 | ); 53 | } else { 54 | bookmarks = ( 55 | 56 | You don't have any bookmarks. You can add a new one from{' '} 57 | /bookmarks menu 58 | 59 | ); 60 | } 61 | } 62 | 63 | return bookmarks; 64 | }; 65 | -------------------------------------------------------------------------------- /client/src/components/Bookmarks/Bookmarks.module.css: -------------------------------------------------------------------------------- 1 | .ActionsContainer { 2 | display: flex; 3 | align-items: center; 4 | } -------------------------------------------------------------------------------- /client/src/components/Bookmarks/Form/Form.module.css: -------------------------------------------------------------------------------- 1 | .Switch { 2 | text-decoration: underline; 3 | } 4 | 5 | .Switch:hover { 6 | cursor: pointer; 7 | } -------------------------------------------------------------------------------- /client/src/components/Bookmarks/Form/Form.tsx: -------------------------------------------------------------------------------- 1 | // Typescript 2 | import { ContentType } from '../Bookmarks'; 3 | 4 | // Utils 5 | import { CategoryForm } from './CategoryForm'; 6 | import { BookmarksForm } from './BookmarksForm'; 7 | import { Fragment } from 'react'; 8 | import { useSelector } from 'react-redux'; 9 | import { State } from '../../../store/reducers'; 10 | import { bookmarkTemplate, categoryTemplate } from '../../../utility'; 11 | 12 | interface Props { 13 | modalHandler: () => void; 14 | contentType: ContentType; 15 | inUpdate?: boolean; 16 | } 17 | 18 | export const Form = (props: Props): JSX.Element => { 19 | const { categoryInEdit, bookmarkInEdit } = useSelector( 20 | (state: State) => state.bookmarks 21 | ); 22 | 23 | const { modalHandler, contentType, inUpdate } = props; 24 | 25 | return ( 26 | 27 | {!inUpdate ? ( 28 | // form: add new 29 | 30 | {contentType === ContentType.category ? ( 31 | 32 | ) : ( 33 | 34 | )} 35 | 36 | ) : ( 37 | // form: update 38 | 39 | {contentType === ContentType.category ? ( 40 | 44 | ) : ( 45 | 49 | )} 50 | 51 | )} 52 | 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /client/src/components/Bookmarks/Table/Table.tsx: -------------------------------------------------------------------------------- 1 | import { Category, Bookmark } from '../../../interfaces'; 2 | import { ContentType } from '../Bookmarks'; 3 | import { BookmarksTable } from './BookmarksTable'; 4 | import { CategoryTable } from './CategoryTable'; 5 | 6 | interface Props { 7 | contentType: ContentType; 8 | openFormForUpdating: (data: Category | Bookmark) => void; 9 | } 10 | 11 | export const Table = (props: Props): JSX.Element => { 12 | const tableEl = 13 | props.contentType === ContentType.category ? ( 14 | 15 | ) : ( 16 | 17 | ); 18 | 19 | return tableEl; 20 | }; 21 | -------------------------------------------------------------------------------- /client/src/components/Home/Header/Header.module.css: -------------------------------------------------------------------------------- 1 | .Header h1 { 2 | color: var(--color-primary); 3 | font-weight: 700; 4 | font-size: 4em; 5 | display: inline-block; 6 | } 7 | 8 | .Header p { 9 | color: var(--color-primary); 10 | font-weight: 300; 11 | text-transform: uppercase; 12 | height: 30px; 13 | } 14 | 15 | .HeaderMain { 16 | display: flex; 17 | justify-content: space-between; 18 | align-items: center; 19 | margin-bottom: 2.5rem; 20 | } 21 | 22 | .SettingsLink { 23 | visibility: visible; 24 | color: var(--color-accent); 25 | } 26 | 27 | @media (min-width: 769px) { 28 | .SettingsLink { 29 | visibility: hidden; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /client/src/components/Home/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | // Redux 5 | import { useSelector } from 'react-redux'; 6 | import { State } from '../../../store/reducers'; 7 | 8 | // CSS 9 | import classes from './Header.module.css'; 10 | 11 | // Components 12 | import { WeatherWidget } from '../../Widgets/WeatherWidget/WeatherWidget'; 13 | 14 | // Utils 15 | import { getDateTime } from './functions/getDateTime'; 16 | import { greeter } from './functions/greeter'; 17 | 18 | export const Header = (): JSX.Element => { 19 | const { hideHeader, hideDate, showTime } = useSelector( 20 | (state: State) => state.config.config 21 | ); 22 | 23 | const [dateTime, setDateTime] = useState(getDateTime()); 24 | const [greeting, setGreeting] = useState(greeter()); 25 | 26 | useEffect(() => { 27 | let dateTimeInterval: NodeJS.Timeout; 28 | 29 | dateTimeInterval = setInterval(() => { 30 | setDateTime(getDateTime()); 31 | setGreeting(greeter()); 32 | }, 1000); 33 | 34 | return () => window.clearInterval(dateTimeInterval); 35 | }, []); 36 | 37 | return ( 38 |
39 | {(!hideDate || showTime) &&

{dateTime}

} 40 | 41 | 42 | Go to Settings 43 | 44 | 45 | {!hideHeader && ( 46 | 47 |

{greeting}

48 | 49 |
50 | )} 51 |
52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /client/src/components/Home/Header/functions/getDateTime.ts: -------------------------------------------------------------------------------- 1 | import { parseTime } from '../../../../utility'; 2 | 3 | export const getDateTime = (): string => { 4 | const days = localStorage.getItem('daySchema')?.split(';') || [ 5 | 'Sunday', 6 | 'Monday', 7 | 'Tuesday', 8 | 'Wednesday', 9 | 'Thursday', 10 | 'Friday', 11 | 'Saturday', 12 | ]; 13 | 14 | const months = localStorage.getItem('monthSchema')?.split(';') || [ 15 | 'January', 16 | 'February', 17 | 'March', 18 | 'April', 19 | 'May', 20 | 'June', 21 | 'July', 22 | 'August', 23 | 'September', 24 | 'October', 25 | 'November', 26 | 'December', 27 | ]; 28 | 29 | const now = new Date(); 30 | 31 | const useAmericanDate = localStorage.useAmericanDate === 'true'; 32 | const showTime = localStorage.showTime === 'true'; 33 | const hideDate = localStorage.hideDate === 'true'; 34 | 35 | // Date 36 | let dateEl = ''; 37 | 38 | if (!hideDate) { 39 | if (!useAmericanDate) { 40 | dateEl = `${days[now.getDay()]}, ${now.getDate()} ${ 41 | months[now.getMonth()] 42 | } ${now.getFullYear()}`; 43 | } else { 44 | dateEl = `${days[now.getDay()]}, ${ 45 | months[now.getMonth()] 46 | } ${now.getDate()} ${now.getFullYear()}`; 47 | } 48 | } 49 | 50 | // Time 51 | const p = parseTime; 52 | let timeEl = ''; 53 | 54 | if (showTime) { 55 | const time = `${p(now.getHours())}:${p(now.getMinutes())}:${p( 56 | now.getSeconds() 57 | )}`; 58 | 59 | timeEl = time; 60 | } 61 | 62 | // Separator 63 | let separator = ''; 64 | 65 | if (!hideDate && showTime) { 66 | separator = ' - '; 67 | } 68 | 69 | // Output 70 | return `${dateEl}${separator}${timeEl}`; 71 | }; 72 | -------------------------------------------------------------------------------- /client/src/components/Home/Header/functions/greeter.ts: -------------------------------------------------------------------------------- 1 | export const greeter = (): string => { 2 | const now = new Date().getHours(); 3 | let msg: string; 4 | 5 | const greetingsSchemaRaw = 6 | localStorage.getItem('greetingsSchema') || 7 | 'Good evening!;Good afternoon!;Good morning!;Good night!'; 8 | const greetingsSchema = greetingsSchemaRaw.split(';'); 9 | 10 | if (now >= 18) msg = greetingsSchema[0]; 11 | else if (now >= 12) msg = greetingsSchema[1]; 12 | else if (now >= 6) msg = greetingsSchema[2]; 13 | else if (now >= 0) msg = greetingsSchema[3]; 14 | else msg = 'Hello!'; 15 | 16 | return msg; 17 | }; 18 | -------------------------------------------------------------------------------- /client/src/components/Home/Home.module.css: -------------------------------------------------------------------------------- 1 | .SettingsButton { 2 | width: 35px; 3 | height: 35px; 4 | background-color: var(--color-accent); 5 | border-radius: 50%; 6 | position: fixed; 7 | bottom: var(--spacing-ui); 8 | left: var(--spacing-ui); 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | opacity: 0.25; 13 | transition: all 0.3s; 14 | visibility: hidden; 15 | } 16 | 17 | .SettingsButton:hover { 18 | cursor: pointer; 19 | opacity: 1; 20 | } 21 | 22 | @media (min-width: 769px) { 23 | .SettingsButton { 24 | visibility: visible; 25 | } 26 | } 27 | 28 | .HomeSpace { 29 | height: 20px; 30 | } 31 | -------------------------------------------------------------------------------- /client/src/components/NotificationCenter/NotificationCenter.module.css: -------------------------------------------------------------------------------- 1 | .NotificationCenter { 2 | position: fixed; 3 | right: var(--spacing-ui); 4 | bottom: var(--spacing-ui); 5 | max-width: 300px; 6 | z-index: 500; 7 | color: white; 8 | } -------------------------------------------------------------------------------- /client/src/components/NotificationCenter/NotificationCenter.tsx: -------------------------------------------------------------------------------- 1 | import { useSelector } from 'react-redux'; 2 | import { Notification as NotificationInterface } from '../../interfaces'; 3 | 4 | import classes from './NotificationCenter.module.css'; 5 | 6 | import { Notification } from '../UI'; 7 | import { State } from '../../store/reducers'; 8 | 9 | export const NotificationCenter = (): JSX.Element => { 10 | const { notifications } = useSelector((state: State) => state.notification); 11 | 12 | return ( 13 |
17 | {notifications.map((notification: NotificationInterface) => { 18 | return ( 19 | 26 | ); 27 | })} 28 |
29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /client/src/components/Routing/ProtectedRoute.tsx: -------------------------------------------------------------------------------- 1 | import { useSelector } from 'react-redux'; 2 | import { Redirect, Route, RouteProps } from 'react-router'; 3 | import { State } from '../../store/reducers'; 4 | 5 | export const ProtectedRoute = ({ ...rest }: RouteProps) => { 6 | const { isAuthenticated } = useSelector((state: State) => state.auth); 7 | 8 | if (isAuthenticated) { 9 | return ; 10 | } else { 11 | return ; 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /client/src/components/SearchBar/SearchBar.module.css: -------------------------------------------------------------------------------- 1 | .SearchBar { 2 | width: 100%; 3 | padding: 10px 0; 4 | color: var(--color-primary); 5 | /* font-size: 20px; */ 6 | margin-bottom: 20px; 7 | background-color: transparent; 8 | border: none; 9 | border-bottom: 2px solid var(--color-accent); 10 | opacity: 0.5; 11 | transition: all 0.2s; 12 | border-radius: 0px; 13 | } 14 | 15 | .SearchBar:focus { 16 | opacity: 1; 17 | outline: none; 18 | } -------------------------------------------------------------------------------- /client/src/components/Settings/AppDetails/AppDetails.module.css: -------------------------------------------------------------------------------- 1 | .text { 2 | color: var(--color-primary); 3 | margin-bottom: 15px; 4 | } 5 | 6 | .text a, 7 | .text span { 8 | color: var(--color-accent); 9 | } 10 | 11 | .separator { 12 | margin: 30px 0; 13 | border: 1px solid var(--color-primary); 14 | } 15 | -------------------------------------------------------------------------------- /client/src/components/Settings/AppDetails/AppDetails.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from 'react'; 2 | 3 | // UI 4 | import { Button, SettingsHeadline } from '../../UI'; 5 | import { AuthForm } from './AuthForm/AuthForm'; 6 | import classes from './AppDetails.module.css'; 7 | 8 | // Store 9 | import { useSelector } from 'react-redux'; 10 | import { State } from '../../../store/reducers'; 11 | 12 | // Other 13 | import { checkVersion } from '../../../utility'; 14 | 15 | export const AppDetails = (): JSX.Element => { 16 | const { isAuthenticated } = useSelector((state: State) => state.auth); 17 | 18 | return ( 19 | 20 | 21 | 22 | 23 | {isAuthenticated && ( 24 | 25 |
26 | 27 |
28 | 29 |

30 | 35 | Flame 36 | {' '} 37 | version {process.env.REACT_APP_VERSION} 38 |

39 | 40 |

41 | See changelog{' '} 42 | 47 | here 48 | 49 |

50 | 51 | 52 |
53 |
54 | )} 55 |
56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /client/src/components/Settings/Settings.module.css: -------------------------------------------------------------------------------- 1 | .Settings { 2 | width: 100%; 3 | display: grid; 4 | grid-template-columns: 1fr; 5 | } 6 | 7 | .SettingsNav { 8 | display: flex; 9 | flex-direction: column; 10 | } 11 | 12 | .SettingsNavLink { 13 | padding-left: 7px; 14 | border-left: 3px solid transparent; 15 | display: flex; 16 | align-items: center; 17 | height: 40px; 18 | transition: all 0.3s; 19 | } 20 | 21 | .SettingsNavLink:hover, 22 | .SettingsNavLink:focus { 23 | border-left: 3px solid var(--color-primary); 24 | } 25 | 26 | .SettingsNavLinkActive { 27 | border-left: 3px solid var(--color-primary); 28 | } 29 | 30 | @media (min-width: 480px) { 31 | .Settings { 32 | grid-template-columns: 1fr 2fr; 33 | } 34 | } 35 | 36 | @media (min-width: 500px) { 37 | .Settings { 38 | grid-template-columns: 1fr 3fr; 39 | } 40 | } -------------------------------------------------------------------------------- /client/src/components/Settings/StyleSettings/StyleSettings.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, ChangeEvent, FormEvent } from 'react'; 2 | import axios from 'axios'; 3 | 4 | // Redux 5 | import { useDispatch } from 'react-redux'; 6 | import { bindActionCreators } from 'redux'; 7 | import { actionCreators } from '../../../store'; 8 | 9 | // Typescript 10 | import { ApiResponse } from '../../../interfaces'; 11 | 12 | // Other 13 | import { InputGroup, Button } from '../../UI'; 14 | import { applyAuth } from '../../../utility'; 15 | 16 | export const StyleSettings = (): JSX.Element => { 17 | const dispatch = useDispatch(); 18 | const { createNotification } = bindActionCreators(actionCreators, dispatch); 19 | 20 | const [customStyles, setCustomStyles] = useState(''); 21 | 22 | useEffect(() => { 23 | axios 24 | .get>('/api/config/0/css') 25 | .then((data) => setCustomStyles(data.data.data)) 26 | .catch((err) => console.log(err.response)); 27 | }, []); 28 | 29 | const inputChangeHandler = (e: ChangeEvent) => { 30 | e.preventDefault(); 31 | setCustomStyles(e.target.value); 32 | }; 33 | 34 | const formSubmitHandler = (e: FormEvent) => { 35 | e.preventDefault(); 36 | 37 | axios 38 | .put>( 39 | '/api/config/0/css', 40 | { styles: customStyles }, 41 | { headers: applyAuth() } 42 | ) 43 | .then(() => { 44 | createNotification({ 45 | title: 'Success', 46 | message: 'CSS saved. Reload page to see changes', 47 | }); 48 | }) 49 | .catch((err) => console.log(err.response)); 50 | }; 51 | 52 | return ( 53 |
formSubmitHandler(e)}> 54 | 55 | 56 | 63 | 64 | 65 |
66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /client/src/components/Settings/Themer/ThemeBuilder/ThemeBuilder.module.css: -------------------------------------------------------------------------------- 1 | .ThemeBuilder { 2 | margin-bottom: 30px; 3 | } 4 | 5 | .Buttons button:not(:last-child) { 6 | margin-right: 10px; 7 | } 8 | -------------------------------------------------------------------------------- /client/src/components/Settings/Themer/ThemeBuilder/ThemeCreator.module.css: -------------------------------------------------------------------------------- 1 | .ColorsContainer { 2 | display: grid; 3 | grid-template-columns: repeat(3, 1fr); 4 | grid-gap: 10px; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/components/Settings/Themer/ThemeBuilder/ThemeEditor.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from 'react'; 2 | 3 | // Redux 4 | import { useSelector, useDispatch } from 'react-redux'; 5 | import { bindActionCreators } from 'redux'; 6 | import { Theme } from '../../../../interfaces'; 7 | import { actionCreators } from '../../../../store'; 8 | import { State } from '../../../../store/reducers'; 9 | 10 | // Other 11 | import { ActionIcons, CompactTable, Icon, ModalForm } from '../../../UI'; 12 | 13 | interface Props { 14 | modalHandler: () => void; 15 | } 16 | 17 | export const ThemeEditor = (props: Props): JSX.Element => { 18 | const { 19 | theme: { userThemes }, 20 | } = useSelector((state: State) => state); 21 | 22 | const { deleteTheme, editTheme } = bindActionCreators( 23 | actionCreators, 24 | useDispatch() 25 | ); 26 | 27 | const updateHandler = (theme: Theme) => { 28 | props.modalHandler(); 29 | editTheme(theme); 30 | }; 31 | 32 | const deleteHandler = (theme: Theme) => { 33 | if (window.confirm(`Are you sure you want to delete this theme?`)) { 34 | deleteTheme(theme.name); 35 | } 36 | }; 37 | 38 | return ( 39 | {}} modalHandler={props.modalHandler}> 40 | 41 | {userThemes.map((t, idx) => ( 42 | 43 | {t.name} 44 | 45 | updateHandler(t)}> 46 | 47 | 48 | deleteHandler(t)}> 49 | 50 | 51 | 52 | 53 | ))} 54 | 55 | 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /client/src/components/Settings/Themer/ThemeGrid/ThemeGrid.module.css: -------------------------------------------------------------------------------- 1 | .ThemerGrid { 2 | width: 100%; 3 | display: grid; 4 | grid-template-columns: 1fr; 5 | grid-auto-rows: 100px; 6 | } 7 | 8 | @media (min-width: 340px) { 9 | .ThemerGrid { 10 | grid-template-columns: 1fr 1fr; 11 | } 12 | } 13 | 14 | @media (min-width: 680px) { 15 | .ThemerGrid { 16 | grid-template-columns: 1fr 1fr 1fr; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /client/src/components/Settings/Themer/ThemeGrid/ThemeGrid.tsx: -------------------------------------------------------------------------------- 1 | // Components 2 | import { ThemePreview } from '../ThemePreview/ThemePreview'; 3 | 4 | // Other 5 | import { Theme } from '../../../../interfaces'; 6 | import classes from './ThemeGrid.module.css'; 7 | 8 | interface Props { 9 | themes: Theme[]; 10 | } 11 | 12 | export const ThemeGrid = ({ themes }: Props): JSX.Element => { 13 | return ( 14 |
15 | {themes.map( 16 | (theme: Theme, idx: number): JSX.Element => ( 17 | 18 | ) 19 | )} 20 |
21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /client/src/components/Settings/Themer/ThemePreview/ThemePreview.module.css: -------------------------------------------------------------------------------- 1 | .ThemePreview { 2 | width: 100%; 3 | display: flex; 4 | flex-direction: column; 5 | justify-content: center; 6 | align-items: flex-start; 7 | } 8 | 9 | .ThemePreview:hover { 10 | cursor: pointer; 11 | } 12 | 13 | .ThemePreview p { 14 | text-transform: capitalize; 15 | margin: 8px 0; 16 | color: var(--color-primary); 17 | } 18 | 19 | .ColorsPreview { 20 | display: flex; 21 | border: 1px solid silver; 22 | } 23 | 24 | .ColorPreview { 25 | width: 50px; 26 | height: 50px; 27 | } 28 | 29 | @media (min-width: 340px) { 30 | .ColorPreview { 31 | width: 40px; 32 | height: 40px; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /client/src/components/Settings/Themer/ThemePreview/ThemePreview.tsx: -------------------------------------------------------------------------------- 1 | // Redux 2 | import { useDispatch } from 'react-redux'; 3 | import { bindActionCreators } from 'redux'; 4 | import { actionCreators } from '../../../../store'; 5 | 6 | // Other 7 | import { Theme } from '../../../../interfaces/Theme'; 8 | import classes from './ThemePreview.module.css'; 9 | 10 | interface Props { 11 | theme: Theme; 12 | } 13 | 14 | export const ThemePreview = ({ 15 | theme: { colors, name }, 16 | }: Props): JSX.Element => { 17 | const { setTheme } = bindActionCreators(actionCreators, useDispatch()); 18 | 19 | return ( 20 |
setTheme(colors)}> 21 |
22 |
26 |
30 |
34 |
35 |

{name}

36 |
37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /client/src/components/Settings/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "routes": [ 3 | { 4 | "name": "Theme", 5 | "dest": "/settings", 6 | "authRequired": false 7 | }, 8 | { 9 | "name": "General", 10 | "dest": "/settings/general", 11 | "authRequired": true 12 | }, 13 | { 14 | "name": "Interface", 15 | "dest": "/settings/interface", 16 | "authRequired": true 17 | }, 18 | { 19 | "name": "Weather", 20 | "dest": "/settings/weather", 21 | "authRequired": true 22 | }, 23 | { 24 | "name": "Docker", 25 | "dest": "/settings/docker", 26 | "authRequired": true 27 | }, 28 | { 29 | "name": "CSS", 30 | "dest": "/settings/css", 31 | "authRequired": true 32 | }, 33 | { 34 | "name": "App", 35 | "dest": "/settings/app", 36 | "authRequired": false 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /client/src/components/UI/Buttons/ActionButton/ActionButton.module.css: -------------------------------------------------------------------------------- 1 | .ActionButton { 2 | /* background-color: var(--color-accent); */ 3 | border: 1.5px solid var(--color-accent); 4 | border-radius: 4px; 5 | color: var(--color-primary); 6 | padding: 5px 15px; 7 | transition: all 0.3s; 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | max-width: 250px; 12 | margin-right: 10px; 13 | margin-bottom: 20px; 14 | } 15 | 16 | .ActionButton:hover { 17 | background-color: var(--color-accent); 18 | color: var(--color-background); 19 | cursor: pointer; 20 | } 21 | 22 | .ActionButtonIcon { 23 | --size: 20px; 24 | width: var(--size); 25 | height: var(--size); 26 | /* display: flex; */ 27 | } 28 | 29 | .ActionButtonName { 30 | display: flex; 31 | } -------------------------------------------------------------------------------- /client/src/components/UI/Buttons/ActionButton/ActionButton.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | import classes from './ActionButton.module.css'; 5 | import { Icon } from '../..'; 6 | 7 | interface Props { 8 | name: string; 9 | icon: string; 10 | link?: string; 11 | handler?: () => void; 12 | } 13 | 14 | export const ActionButton = (props: Props): JSX.Element => { 15 | const body = ( 16 | 17 |
18 | 19 |
20 |
{props.name}
21 |
22 | ); 23 | 24 | if (props.link) { 25 | return ( 26 | 27 | {body} 28 | 29 | ); 30 | } else if (props.handler) { 31 | return ( 32 |
{ 36 | if (e.key === 'Enter' && props.handler) props.handler(); 37 | }} 38 | tabIndex={0} 39 | > 40 | {body} 41 |
42 | ); 43 | } else { 44 | return
{body}
; 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /client/src/components/UI/Buttons/Button/Button.module.css: -------------------------------------------------------------------------------- 1 | .Button { 2 | padding: 8px 15px; 3 | border: 1px solid var(--color-accent); 4 | background-color: var(--color-background); 5 | color: var(--color-primary); 6 | border-radius: 4px; 7 | } 8 | 9 | .Button:hover { 10 | cursor: pointer; 11 | background-color: var(--color-accent); 12 | color: var(--color-background); 13 | } -------------------------------------------------------------------------------- /client/src/components/UI/Buttons/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import classes from './Button.module.css'; 3 | 4 | interface Props { 5 | children: ReactNode; 6 | click?: any; 7 | } 8 | 9 | export const Button = (props: Props): JSX.Element => { 10 | const { children, click } = props; 11 | 12 | return ( 13 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /client/src/components/UI/Forms/InputGroup/InputGroup.module.css: -------------------------------------------------------------------------------- 1 | .InputGroup { 2 | margin-bottom: 15px; 3 | } 4 | 5 | .InputGroup label, 6 | .InputGroup span, 7 | .InputGroup input, 8 | .InputGroup textarea { 9 | display: block; 10 | } 11 | 12 | .InputGroup input, 13 | .InputGroup select, 14 | .InputGroup textarea { 15 | margin: 8px 0; 16 | width: 100%; 17 | border: none; 18 | border-radius: 4px; 19 | padding: 10px; 20 | background-color: var(--color-primary); 21 | color: var(--color-background); 22 | } 23 | 24 | .InputGroup span { 25 | font-size: 12px; 26 | color: var(--color-primary); 27 | } 28 | 29 | .InputGroup span a { 30 | color: var(--color-accent); 31 | } 32 | 33 | .InputGroup label { 34 | color: var(--color-primary); 35 | } 36 | 37 | .InputGroup textarea { 38 | resize: none; 39 | height: 50vh; 40 | } 41 | 42 | .InputGroup input[type='color'] { 43 | margin: 0; 44 | padding: 0; 45 | background-color: transparent; 46 | } 47 | 48 | .InputGroup input[type='color']:hover { 49 | cursor: pointer; 50 | } 51 | -------------------------------------------------------------------------------- /client/src/components/UI/Forms/InputGroup/InputGroup.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import classes from './InputGroup.module.css'; 3 | 4 | interface Props { 5 | children: ReactNode; 6 | } 7 | 8 | export const InputGroup = (props: Props): JSX.Element => { 9 | return
{props.children}
; 10 | }; 11 | -------------------------------------------------------------------------------- /client/src/components/UI/Forms/ModalForm/ModalForm.module.css: -------------------------------------------------------------------------------- 1 | .ModalForm { 2 | background-color: var(--color-background); 3 | color: var(--color-primary); 4 | border-radius: 6px; 5 | width: 95%; 6 | position: relative; 7 | padding: 50px 50px; 8 | } 9 | 10 | .ModalFormIcon { 11 | width: 40px; 12 | position: absolute; 13 | right: 5px; 14 | top: 5px; 15 | } 16 | 17 | .ModalFormIcon:hover { 18 | cursor: pointer; 19 | } 20 | 21 | @media (min-width: 430px) { 22 | .ModalForm { 23 | width: 90%; 24 | } 25 | } 26 | 27 | @media (min-width: 800px) { 28 | .ModalForm { 29 | width: 70%; 30 | } 31 | } 32 | 33 | @media (min-width: 1000px) { 34 | .ModalForm { 35 | width: 60%; 36 | } 37 | } 38 | 39 | @media (min-width: 1400px) { 40 | .ModalForm { 41 | width: 40%; 42 | } 43 | } -------------------------------------------------------------------------------- /client/src/components/UI/Forms/ModalForm/ModalForm.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, SyntheticEvent } from 'react'; 2 | 3 | import classes from './ModalForm.module.css'; 4 | import { Icon } from '../..'; 5 | 6 | interface ComponentProps { 7 | children: ReactNode; 8 | modalHandler?: () => void; 9 | formHandler: (e: SyntheticEvent) => void; 10 | } 11 | 12 | export const ModalForm = (props: ComponentProps): JSX.Element => { 13 | const _modalHandler = (): void => { 14 | if (props.modalHandler) { 15 | props.modalHandler(); 16 | } 17 | }; 18 | 19 | return ( 20 |
21 |
22 | 23 |
24 |
props.formHandler(e)}>{props.children}
25 |
26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /client/src/components/UI/Headlines/Headline/Headline.module.css: -------------------------------------------------------------------------------- 1 | .HeadlineTitle { 2 | color: var(--color-primary); 3 | font-weight: 700; 4 | } 5 | 6 | .HeadlineSubtitle { 7 | color: var(--color-primary); 8 | margin-bottom: 20px; 9 | font-weight: 400; 10 | } -------------------------------------------------------------------------------- /client/src/components/UI/Headlines/Headline/Headline.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, ReactNode } from 'react'; 2 | import classes from './Headline.module.css'; 3 | 4 | interface Props { 5 | title: string; 6 | subtitle?: ReactNode; 7 | } 8 | 9 | export const Headline = (props: Props): JSX.Element => { 10 | return ( 11 | 12 |

{props.title}

13 | {props.subtitle && ( 14 |

{props.subtitle}

15 | )} 16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /client/src/components/UI/Headlines/SectionHeadline/SectionHeadline.module.css: -------------------------------------------------------------------------------- 1 | .SectionHeadline { 2 | text-transform: uppercase; 3 | color: var(--color-primary); 4 | font-weight: 900; 5 | font-size: 20px; 6 | margin-bottom: 16px; 7 | } -------------------------------------------------------------------------------- /client/src/components/UI/Headlines/SectionHeadline/SectionHeadline.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | 3 | import classes from './SectionHeadline.module.css'; 4 | 5 | interface Props { 6 | title: string; 7 | link: string; 8 | } 9 | 10 | export const SectionHeadline = (props: Props): JSX.Element => { 11 | return ( 12 | 13 |

{props.title}

14 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /client/src/components/UI/Headlines/SettingsHeadline/SettingsHeadline.module.css: -------------------------------------------------------------------------------- 1 | .SettingsHeadline { 2 | color: var(--color-primary); 3 | padding-bottom: 3px; 4 | margin-bottom: 10px; 5 | font-size: 20px; 6 | font-weight: 500; 7 | border-bottom: 2px solid var(--color-accent); 8 | display: inline-block; 9 | } 10 | -------------------------------------------------------------------------------- /client/src/components/UI/Headlines/SettingsHeadline/SettingsHeadline.tsx: -------------------------------------------------------------------------------- 1 | import classes from './SettingsHeadline.module.css'; 2 | 3 | interface Props { 4 | text: string; 5 | } 6 | 7 | export const SettingsHeadline = (props: Props): JSX.Element => { 8 | return

{props.text}

; 9 | }; 10 | -------------------------------------------------------------------------------- /client/src/components/UI/Icons/ActionIcons/ActionIcons.module.css: -------------------------------------------------------------------------------- 1 | .ActionIcons { 2 | display: flex; 3 | } 4 | 5 | .ActionIcons svg { 6 | width: 20px; 7 | } 8 | 9 | .ActionIcons svg:hover { 10 | cursor: pointer; 11 | } 12 | -------------------------------------------------------------------------------- /client/src/components/UI/Icons/ActionIcons/ActionIcons.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import styles from './ActionIcons.module.css'; 3 | 4 | interface Props { 5 | children: ReactNode; 6 | } 7 | 8 | export const ActionIcons = ({ children }: Props): JSX.Element => { 9 | return {children}; 10 | }; 11 | -------------------------------------------------------------------------------- /client/src/components/UI/Icons/Icon/Icon.module.css: -------------------------------------------------------------------------------- 1 | .Icon { 2 | color: var(--color-primary); 3 | width: 90%; 4 | } 5 | -------------------------------------------------------------------------------- /client/src/components/UI/Icons/Icon/Icon.tsx: -------------------------------------------------------------------------------- 1 | import classes from './Icon.module.css'; 2 | 3 | import { Icon as MDIcon } from '@mdi/react'; 4 | 5 | interface Props { 6 | icon: string; 7 | color?: string; 8 | } 9 | 10 | export const Icon = (props: Props): JSX.Element => { 11 | const MDIcons = require('@mdi/js'); 12 | let iconPath = MDIcons[props.icon]; 13 | 14 | if (!iconPath) { 15 | console.log(`Icon ${props.icon} not found`); 16 | iconPath = MDIcons.mdiCancel; 17 | } 18 | 19 | return ( 20 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /client/src/components/UI/Icons/WeatherIcon/WeatherIcon.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | import { Skycons } from 'skycons-ts'; 4 | import { State } from '../../../../store/reducers'; 5 | import { IconMapping, TimeOfDay } from './IconMapping'; 6 | 7 | interface Props { 8 | weatherStatusCode: number; 9 | isDay: number; 10 | } 11 | 12 | export const WeatherIcon = (props: Props): JSX.Element => { 13 | const { activeTheme } = useSelector((state: State) => state.theme); 14 | 15 | const icon = props.isDay 16 | ? new IconMapping().mapIcon(props.weatherStatusCode, TimeOfDay.day) 17 | : new IconMapping().mapIcon(props.weatherStatusCode, TimeOfDay.night); 18 | 19 | useEffect(() => { 20 | const delay = setTimeout(() => { 21 | const skycons = new Skycons({ color: activeTheme.colors.accent }); 22 | skycons.add(`weather-icon`, icon); 23 | skycons.play(); 24 | }, 1); 25 | 26 | return () => { 27 | clearTimeout(delay); 28 | }; 29 | }, [props.weatherStatusCode, icon, activeTheme.colors.accent]); 30 | 31 | return ; 32 | }; 33 | -------------------------------------------------------------------------------- /client/src/components/UI/Layout/Layout.module.css: -------------------------------------------------------------------------------- 1 | .Container { 2 | width: 100%; 3 | padding: 20px; 4 | margin: 0 auto; 5 | } 6 | 7 | /* .Container { 8 | width: 60%; 9 | margin: 0 auto; 10 | padding: 20px; 11 | padding-top: 20px; 12 | } */ 13 | 14 | /* 320px — 480px: Mobile devices. 15 | 481px — 768px: iPads, Tablets. 16 | 769px — 1024px: Small screens, laptops. 17 | 1025px — 1200px: Desktops, large screens. 18 | 1201px and more — Extra large screens, TV. */ 19 | 20 | @media (min-width: 769px) { 21 | .Container { 22 | /* padding: 25px 40px; */ 23 | width: 90%; 24 | } 25 | } 26 | 27 | @media (min-width: 1201px) { 28 | .Container { 29 | padding: 50px 250px; 30 | } 31 | } -------------------------------------------------------------------------------- /client/src/components/UI/Layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import classes from './Layout.module.css'; 3 | 4 | interface ComponentProps { 5 | children: ReactNode; 6 | } 7 | 8 | export const Container = (props: ComponentProps): JSX.Element => { 9 | return
{props.children}
; 10 | }; 11 | -------------------------------------------------------------------------------- /client/src/components/UI/Modal/Modal.module.css: -------------------------------------------------------------------------------- 1 | .Modal { 2 | width: 100%; 3 | height: 100%; 4 | position: fixed; 5 | top: 0; 6 | left: 0; 7 | z-index: 100; 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | } 12 | 13 | .ModalClose { 14 | background-color: rgba(0, 0, 0, 0); 15 | visibility: hidden; 16 | } 17 | 18 | .ModalOpen { 19 | background-color: rgba(0, 0, 0, 0.7); 20 | visibility: visible; 21 | } -------------------------------------------------------------------------------- /client/src/components/UI/Modal/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { MouseEvent, ReactNode, useRef } from 'react'; 2 | 3 | import classes from './Modal.module.css'; 4 | 5 | interface Props { 6 | isOpen: boolean; 7 | setIsOpen: Function; 8 | children: ReactNode; 9 | cb?: Function; 10 | } 11 | 12 | export const Modal = ({ 13 | isOpen, 14 | setIsOpen, 15 | children, 16 | cb, 17 | }: Props): JSX.Element => { 18 | const modalRef = useRef(null); 19 | const modalClasses = [ 20 | classes.Modal, 21 | isOpen ? classes.ModalOpen : classes.ModalClose, 22 | ].join(' '); 23 | 24 | const clickHandler = (e: MouseEvent) => { 25 | if (e.target === modalRef.current) { 26 | setIsOpen(false); 27 | 28 | if (cb) cb(); 29 | } 30 | }; 31 | 32 | return ( 33 |
34 | {children} 35 |
36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /client/src/components/UI/Notification/Notification.module.css: -------------------------------------------------------------------------------- 1 | .Notification { 2 | width: 300px; 3 | background-color: var(--color-background); 4 | border: 1px solid var(--color-primary); 5 | color: var(--color-primary); 6 | border-radius: 4px; 7 | padding: 15px 10px; 8 | transition: all 0.25s; 9 | margin-bottom: 10px; 10 | } 11 | 12 | .Notification:hover { 13 | background-color: var(--color-primary); 14 | color: var(--color-background); 15 | cursor: pointer; 16 | } 17 | 18 | .Notification:last-child { 19 | margin-bottom: 0; 20 | } 21 | 22 | .NotificationOpen { 23 | animation: slideIn 0.3s; 24 | } 25 | 26 | .NotificationClose { 27 | animation: slideOut 0.3s; 28 | transform: translateX(600px); 29 | } 30 | 31 | @keyframes slideIn { 32 | from { 33 | transform: translateX(600px); 34 | } 35 | to { 36 | transform: translateX(0); 37 | } 38 | } 39 | 40 | @keyframes slideOut { 41 | from { 42 | transform: translateX(0); 43 | } 44 | to { 45 | transform: translateX(600px); 46 | } 47 | } -------------------------------------------------------------------------------- /client/src/components/UI/Notification/Notification.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | import { bindActionCreators } from 'redux'; 4 | import { actionCreators } from '../../../store'; 5 | 6 | import classes from './Notification.module.css'; 7 | 8 | interface Props { 9 | title: string; 10 | message: string; 11 | id: number; 12 | url: string | null; 13 | } 14 | 15 | export const Notification = (props: Props): JSX.Element => { 16 | const dispatch = useDispatch(); 17 | const { clearNotification } = bindActionCreators(actionCreators, dispatch); 18 | 19 | const [isOpen, setIsOpen] = useState(true); 20 | const elementClasses = [ 21 | classes.Notification, 22 | isOpen ? classes.NotificationOpen : classes.NotificationClose, 23 | ].join(' '); 24 | 25 | useEffect(() => { 26 | const closeNotification = setTimeout(() => { 27 | setIsOpen(false); 28 | }, 3500); 29 | 30 | const clearNotificationTimeout = setTimeout(() => { 31 | clearNotification(props.id); 32 | }, 3600); 33 | 34 | return () => { 35 | window.clearTimeout(closeNotification); 36 | window.clearTimeout(clearNotificationTimeout); 37 | }; 38 | }, []); 39 | 40 | const clickHandler = () => { 41 | if (props.url) { 42 | window.open(props.url, '_blank'); 43 | } 44 | }; 45 | 46 | return ( 47 |
48 |

{props.title}

49 |

{props.message}

50 |
51 |
52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /client/src/components/UI/Spinner/Spinner.module.css: -------------------------------------------------------------------------------- 1 | .Spinner, 2 | .Spinner:before, 3 | .Spinner:after { 4 | background: var(--color-primary); 5 | animation: load1 1s infinite ease-in-out; 6 | width: 1em; 7 | height: 4em; 8 | } 9 | 10 | .Spinner { 11 | color: var(--color-primary); 12 | text-indent: -9999em; 13 | margin: 88px auto; 14 | position: relative; 15 | font-size: 11px; 16 | transform: translateZ(0); 17 | animation-delay: -0.16s; 18 | } 19 | 20 | .Spinner:before, 21 | .Spinner:after { 22 | position: absolute; 23 | top: 0; 24 | content: ''; 25 | } 26 | 27 | .Spinner:before { 28 | left: -1.5em; 29 | animation-delay: -0.32s; 30 | } 31 | 32 | .Spinner:after { 33 | left: 1.5em; 34 | } 35 | 36 | @keyframes load1 { 37 | 0%, 38 | 80%, 39 | 100% { 40 | box-shadow: 0 0; 41 | height: 4em; 42 | } 43 | 40% { 44 | box-shadow: 0 -2em; 45 | height: 5em; 46 | } 47 | } 48 | 49 | .SpinnerWrapper { 50 | height: 150px; 51 | display: flex; 52 | justify-content: center; 53 | align-items: center; 54 | } -------------------------------------------------------------------------------- /client/src/components/UI/Spinner/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import classes from './Spinner.module.css'; 2 | 3 | export const Spinner = (): JSX.Element => { 4 | return ( 5 |
6 |
Loading...
7 |
8 | ); 9 | }; 10 | -------------------------------------------------------------------------------- /client/src/components/UI/Tables/CompactTable/CompactTable.module.css: -------------------------------------------------------------------------------- 1 | .CompactTable { 2 | display: grid; 3 | } 4 | 5 | .CompactTable span { 6 | color: var(--color-primary); 7 | } 8 | 9 | .CompactTable span:last-child { 10 | margin-bottom: 10px; 11 | } 12 | 13 | .Separator { 14 | border-bottom: 1px solid var(--color-primary); 15 | margin: 10px 0; 16 | } 17 | -------------------------------------------------------------------------------- /client/src/components/UI/Tables/CompactTable/CompactTable.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import classes from './CompactTable.module.css'; 3 | 4 | interface Props { 5 | headers: string[]; 6 | children?: ReactNode; 7 | } 8 | 9 | export const CompactTable = ({ headers, children }: Props): JSX.Element => { 10 | return ( 11 |
15 | {headers.map((h, idx) => ( 16 | {h} 17 | ))} 18 | 19 |
23 | 24 | {children} 25 |
26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /client/src/components/UI/Tables/Table/Table.module.css: -------------------------------------------------------------------------------- 1 | .TableContainer { 2 | width: 100%; 3 | } 4 | 5 | .Table { 6 | border-collapse: collapse; 7 | width: 100%; 8 | text-align: left; 9 | font-size: 16px; 10 | color: var(--color-primary); 11 | table-layout: fixed; 12 | } 13 | 14 | .Table th, 15 | .Table td { 16 | padding: 10px; 17 | overflow: hidden; 18 | text-overflow: ellipsis; 19 | } 20 | 21 | /* Head */ 22 | .Table th { 23 | --header-radius: 4px; 24 | background-color: var(--color-primary); 25 | color: var(--color-background); 26 | } 27 | 28 | .Table th:first-child { 29 | border-top-left-radius: var(--header-radius); 30 | border-bottom-left-radius: var(--header-radius); 31 | } 32 | 33 | .Table th:last-child { 34 | border-top-right-radius: var(--header-radius); 35 | border-bottom-right-radius: var(--header-radius); 36 | } 37 | 38 | /* Body */ 39 | .Table td { 40 | transition: all 0.2s; 41 | } -------------------------------------------------------------------------------- /client/src/components/UI/Tables/Table/Table.tsx: -------------------------------------------------------------------------------- 1 | import classes from './Table.module.css'; 2 | 3 | interface Props { 4 | children: React.ReactNode; 5 | headers: string[]; 6 | innerRef?: any; 7 | } 8 | 9 | export const Table = (props: Props): JSX.Element => { 10 | return ( 11 |
12 | 13 | 14 | 15 | {props.headers.map( 16 | (header: string, index: number): JSX.Element => ( 17 | 18 | ) 19 | )} 20 | 21 | 22 | {props.children} 23 |
{header}
24 |
25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /client/src/components/UI/Text/Message/Message.module.css: -------------------------------------------------------------------------------- 1 | .message { 2 | color: var(--color-primary); 3 | } 4 | 5 | .message a { 6 | color: var(--color-accent); 7 | font-weight: 600; 8 | } 9 | 10 | .messageCenter { 11 | width: 100%; 12 | display: flex; 13 | justify-content: center; 14 | align-items: baseline; 15 | color: var(--color-primary); 16 | margin-bottom: 20px; 17 | } 18 | 19 | .messageCenter a, 20 | .messageCenter span { 21 | color: var(--color-accent); 22 | } 23 | 24 | .messageCenter a:hover { 25 | cursor: pointer; 26 | } 27 | -------------------------------------------------------------------------------- /client/src/components/UI/Text/Message/Message.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | import classes from './Message.module.css'; 4 | 5 | interface Props { 6 | children: ReactNode; 7 | isPrimary?: boolean; 8 | } 9 | 10 | export const Message = ({ children, isPrimary = true }: Props): JSX.Element => { 11 | const style = isPrimary ? classes.message : classes.messageCenter; 12 | 13 | return

{children}

; 14 | }; 15 | -------------------------------------------------------------------------------- /client/src/components/UI/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Tables/Table/Table'; 2 | export * from './Tables/CompactTable/CompactTable'; 3 | export * from './Spinner/Spinner'; 4 | export * from './Notification/Notification'; 5 | export * from './Modal/Modal'; 6 | export * from './Layout/Layout'; 7 | export * from './Icons/Icon/Icon'; 8 | export * from './Icons/WeatherIcon/WeatherIcon'; 9 | export * from './Icons/ActionIcons/ActionIcons'; 10 | export * from './Headlines/Headline/Headline'; 11 | export * from './Headlines/SectionHeadline/SectionHeadline'; 12 | export * from './Headlines/SettingsHeadline/SettingsHeadline'; 13 | export * from './Forms/InputGroup/InputGroup'; 14 | export * from './Forms/ModalForm/ModalForm'; 15 | export * from './Buttons/ActionButton/ActionButton'; 16 | export * from './Buttons/Button/Button'; 17 | export * from './Text/Message/Message'; 18 | -------------------------------------------------------------------------------- /client/src/components/Widgets/WeatherWidget/WeatherWidget.module.css: -------------------------------------------------------------------------------- 1 | .WeatherWidget { 2 | display: flex; 3 | visibility: hidden; 4 | } 5 | 6 | .WeatherDetails { 7 | display: flex; 8 | flex-direction: column; 9 | justify-content: center; 10 | font-size: 16px; 11 | color: var(--color-primary); 12 | margin-left: 10px; 13 | font-weight: 500; 14 | } 15 | 16 | .WeatherDetails span:first-child { 17 | border-bottom: 1px solid var(--color-primary); 18 | padding-bottom: 5px; 19 | } 20 | 21 | .WeatherDetails span:last-child { 22 | padding-top: 5px; 23 | } 24 | 25 | @media (min-width: 600px) { 26 | .WeatherWidget { 27 | visibility: visible; 28 | } 29 | } -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Roboto'; 3 | font-style: normal; 4 | font-weight: 400; 5 | src: local(''), 6 | url('./assets/fonts/Roboto/roboto-v29-latin-regular.woff2') format('woff2'), 7 | url('./assets/fonts/Roboto/roboto-v29-latin-regular.woff') format('woff'); 8 | } 9 | 10 | @font-face { 11 | font-family: 'Roboto'; 12 | font-style: normal; 13 | font-weight: 500; 14 | src: local(''), 15 | url('./assets/fonts/Roboto/roboto-v29-latin-500.woff2') format('woff2'), 16 | url('./assets/fonts/Roboto/roboto-v29-latin-500.woff') format('woff'); 17 | } 18 | 19 | @font-face { 20 | font-family: 'Roboto'; 21 | font-style: normal; 22 | font-weight: 900; 23 | src: local(''), 24 | url('./assets/fonts/Roboto/roboto-v29-latin-900.woff2') format('woff2'), 25 | url('./assets/fonts/Roboto/roboto-v29-latin-900.woff') format('woff'); 26 | } 27 | 28 | @font-face { 29 | font-family: 'Roboto'; 30 | font-style: normal; 31 | font-weight: 700; 32 | src: local(''), 33 | url('./assets/fonts/Roboto/roboto-v29-latin-700.woff2') format('woff2'), 34 | url('./assets/fonts/Roboto/roboto-v29-latin-700.woff') format('woff'); 35 | } 36 | 37 | * { 38 | margin: 0; 39 | padding: 0; 40 | box-sizing: border-box; 41 | } 42 | 43 | body { 44 | --color-background: #242b33; 45 | --color-primary: #effbff; 46 | --color-accent: #6ee2ff; 47 | --spacing-ui: 10px; 48 | 49 | background-color: var(--color-background); 50 | transition: background-color 0.3s; 51 | font-family: Roboto, sans-serif; 52 | font-size: 14px; 53 | } 54 | 55 | a { 56 | color: var(--color-primary); 57 | text-decoration: none; 58 | } 59 | -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | 5 | import { Provider } from 'react-redux'; 6 | import { store } from './store/store'; 7 | 8 | import { App } from './App'; 9 | 10 | ReactDOM.render( 11 | 12 | 13 | 14 | 15 | , 16 | document.getElementById('root') 17 | ); 18 | -------------------------------------------------------------------------------- /client/src/interfaces/Api.ts: -------------------------------------------------------------------------------- 1 | export interface Model { 2 | id: number; 3 | createdAt: Date; 4 | updatedAt: Date; 5 | } 6 | 7 | export interface ApiResponse { 8 | success: boolean; 9 | data: T; 10 | } 11 | 12 | export interface Token { 13 | app: string; 14 | exp: number; 15 | iat: number; 16 | } 17 | -------------------------------------------------------------------------------- /client/src/interfaces/App.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '.'; 2 | 3 | export interface NewApp { 4 | name: string; 5 | url: string; 6 | icon: string; 7 | isPublic: boolean; 8 | description: string; 9 | } 10 | 11 | export interface App extends Model, NewApp { 12 | orderId: number; 13 | isPinned: boolean; 14 | } 15 | -------------------------------------------------------------------------------- /client/src/interfaces/Bookmark.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '.'; 2 | 3 | export interface NewBookmark { 4 | name: string; 5 | url: string; 6 | categoryId: number; 7 | icon: string; 8 | isPublic: boolean; 9 | } 10 | 11 | export interface Bookmark extends Model, NewBookmark { 12 | orderId: number; 13 | } 14 | -------------------------------------------------------------------------------- /client/src/interfaces/Category.ts: -------------------------------------------------------------------------------- 1 | import { Model, Bookmark } from '.'; 2 | 3 | export interface NewCategory { 4 | name: string; 5 | isPublic: boolean; 6 | } 7 | 8 | export interface Category extends Model, NewCategory { 9 | isPinned: boolean; 10 | orderId: number; 11 | bookmarks: Bookmark[]; 12 | } 13 | -------------------------------------------------------------------------------- /client/src/interfaces/Config.ts: -------------------------------------------------------------------------------- 1 | import { WeatherData } from '../types'; 2 | 3 | export interface Config { 4 | WEATHER_API_KEY: string; 5 | lat: number; 6 | long: number; 7 | isCelsius: boolean; 8 | customTitle: string; 9 | pinAppsByDefault: boolean; 10 | pinCategoriesByDefault: boolean; 11 | hideHeader: boolean; 12 | useOrdering: string; 13 | appsSameTab: boolean; 14 | bookmarksSameTab: boolean; 15 | searchSameTab: boolean; 16 | hideApps: boolean; 17 | hideCategories: boolean; 18 | hideSearch: boolean; 19 | defaultSearchProvider: string; 20 | secondarySearchProvider: string; 21 | dockerApps: boolean; 22 | dockerHost: string; 23 | kubernetesApps: boolean; 24 | unpinStoppedApps: boolean; 25 | useAmericanDate: boolean; 26 | disableAutofocus: boolean; 27 | greetingsSchema: string; 28 | daySchema: string; 29 | monthSchema: string; 30 | showTime: boolean; 31 | defaultTheme: string; 32 | isKilometer: boolean; 33 | weatherData: WeatherData; 34 | hideDate: boolean; 35 | } 36 | -------------------------------------------------------------------------------- /client/src/interfaces/Forms.ts: -------------------------------------------------------------------------------- 1 | import { WeatherData } from '../types'; 2 | 3 | export interface WeatherForm { 4 | WEATHER_API_KEY: string; 5 | lat: number; 6 | long: number; 7 | isCelsius: boolean; 8 | weatherData: WeatherData; 9 | } 10 | 11 | export interface GeneralForm { 12 | defaultSearchProvider: string; 13 | secondarySearchProvider: string; 14 | searchSameTab: boolean; 15 | pinAppsByDefault: boolean; 16 | pinCategoriesByDefault: boolean; 17 | useOrdering: string; 18 | appsSameTab: boolean; 19 | bookmarksSameTab: boolean; 20 | } 21 | 22 | export interface UISettingsForm { 23 | customTitle: string; 24 | hideHeader: boolean; 25 | hideApps: boolean; 26 | hideCategories: boolean; 27 | useAmericanDate: boolean; 28 | greetingsSchema: string; 29 | daySchema: string; 30 | monthSchema: string; 31 | showTime: boolean; 32 | hideDate: boolean; 33 | hideSearch: boolean; 34 | disableAutofocus: boolean; 35 | } 36 | 37 | export interface DockerSettingsForm { 38 | dockerApps: boolean; 39 | dockerHost: string; 40 | kubernetesApps: boolean; 41 | unpinStoppedApps: boolean; 42 | } 43 | 44 | export interface ThemeSettingsForm { 45 | defaultTheme: string; 46 | } 47 | -------------------------------------------------------------------------------- /client/src/interfaces/Notification.ts: -------------------------------------------------------------------------------- 1 | export interface NewNotification { 2 | title: string; 3 | message: string; 4 | url?: string; 5 | } 6 | 7 | export interface Notification extends NewNotification { 8 | id: number; 9 | } 10 | -------------------------------------------------------------------------------- /client/src/interfaces/Query.ts: -------------------------------------------------------------------------------- 1 | export interface Query { 2 | name: string; 3 | prefix: string; 4 | template: string; 5 | } -------------------------------------------------------------------------------- /client/src/interfaces/Route.ts: -------------------------------------------------------------------------------- 1 | export interface Route { 2 | name: string; 3 | dest: string; 4 | authRequired: boolean; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/interfaces/SearchResult.ts: -------------------------------------------------------------------------------- 1 | import { Query } from './Query'; 2 | 3 | export interface SearchResult { 4 | isLocal: boolean; 5 | isURL: boolean; 6 | sameTab: boolean; 7 | encodedURL: string; 8 | primarySearch: Query; 9 | secondarySearch: Query; 10 | rawQuery: string; 11 | } 12 | -------------------------------------------------------------------------------- /client/src/interfaces/Theme.ts: -------------------------------------------------------------------------------- 1 | export interface ThemeColors { 2 | background: string; 3 | primary: string; 4 | accent: string; 5 | } 6 | 7 | export interface Theme { 8 | name: string; 9 | colors: ThemeColors; 10 | isCustom: boolean; 11 | } 12 | -------------------------------------------------------------------------------- /client/src/interfaces/Weather.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '.'; 2 | 3 | export interface Weather extends Model { 4 | externalLastUpdate: string; 5 | tempC: number; 6 | tempF: number; 7 | isDay: number; 8 | cloud: number; 9 | conditionText: string; 10 | conditionCode: number; 11 | humidity: number; 12 | windK: number; 13 | windM: number; 14 | } 15 | -------------------------------------------------------------------------------- /client/src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from './App'; 2 | export * from './Theme'; 3 | export * from './Api'; 4 | export * from './Weather'; 5 | export * from './Bookmark'; 6 | export * from './Category'; 7 | export * from './Notification'; 8 | export * from './Config'; 9 | export * from './Forms'; 10 | export * from './Query'; 11 | export * from './SearchResult'; 12 | export * from './Route'; 13 | -------------------------------------------------------------------------------- /client/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/src/setupProxy.js: -------------------------------------------------------------------------------- 1 | const { createProxyMiddleware } = require('http-proxy-middleware'); 2 | 3 | module.exports = function (app) { 4 | const apiProxy = createProxyMiddleware('/api', { 5 | target: 'http://localhost:5005' 6 | }) 7 | 8 | const assetsProxy = createProxyMiddleware('/uploads', { 9 | target: 'http://localhost:5005' 10 | }) 11 | 12 | const wsProxy = createProxyMiddleware('/socket', { 13 | target: 'http://localhost:5005', 14 | ws: true 15 | }) 16 | 17 | app.use(apiProxy); 18 | app.use(assetsProxy); 19 | app.use(wsProxy); 20 | }; -------------------------------------------------------------------------------- /client/src/store/action-creators/auth.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch } from 'redux'; 2 | import { ApiResponse } from '../../interfaces'; 3 | import { ActionType } from '../action-types'; 4 | import { 5 | AuthErrorAction, 6 | AutoLoginAction, 7 | LoginAction, 8 | LogoutAction, 9 | } from '../actions/auth'; 10 | import axios, { AxiosError } from 'axios'; 11 | import { getApps, getCategories } from '.'; 12 | 13 | export const login = 14 | (formData: { password: string; duration: string }) => 15 | async (dispatch: Dispatch) => { 16 | try { 17 | const res = await axios.post>( 18 | '/api/auth', 19 | formData 20 | ); 21 | 22 | localStorage.setItem('token', res.data.data.token); 23 | 24 | dispatch({ 25 | type: ActionType.login, 26 | payload: res.data.data.token, 27 | }); 28 | 29 | dispatch(getApps()); 30 | dispatch(getCategories()); 31 | } catch (err) { 32 | dispatch(authError(err, true)); 33 | } 34 | }; 35 | 36 | export const logout = () => (dispatch: Dispatch) => { 37 | localStorage.removeItem('token'); 38 | 39 | dispatch({ 40 | type: ActionType.logout, 41 | }); 42 | 43 | dispatch(getApps()); 44 | dispatch(getCategories()); 45 | }; 46 | 47 | export const autoLogin = () => async (dispatch: Dispatch) => { 48 | const token: string = localStorage.token; 49 | 50 | try { 51 | await axios.post>( 52 | '/api/auth/validate', 53 | { token } 54 | ); 55 | 56 | dispatch({ 57 | type: ActionType.autoLogin, 58 | payload: token, 59 | }); 60 | 61 | dispatch(getApps()); 62 | dispatch(getCategories()); 63 | } catch (err) { 64 | dispatch(authError(err, false)); 65 | } 66 | }; 67 | 68 | export const authError = 69 | (error: unknown, showNotification: boolean) => 70 | (dispatch: Dispatch) => { 71 | const apiError = error as AxiosError; 72 | 73 | if (showNotification) { 74 | dispatch({ 75 | type: ActionType.createNotification, 76 | payload: { 77 | title: 'Error', 78 | message: apiError.response?.data.error, 79 | }, 80 | }); 81 | } 82 | 83 | dispatch(getApps()); 84 | dispatch(getCategories()); 85 | }; 86 | -------------------------------------------------------------------------------- /client/src/store/action-creators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './theme'; 2 | export * from './config'; 3 | export * from './notification'; 4 | export * from './app'; 5 | export * from './bookmark'; 6 | export * from './auth'; 7 | -------------------------------------------------------------------------------- /client/src/store/action-creators/notification.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch } from 'redux'; 2 | import { NewNotification } from '../../interfaces'; 3 | import { ActionType } from '../action-types'; 4 | import { 5 | CreateNotificationAction, 6 | ClearNotificationAction, 7 | } from '../actions/notification'; 8 | 9 | export const createNotification = 10 | (notification: NewNotification) => 11 | (dispatch: Dispatch) => { 12 | dispatch({ 13 | type: ActionType.createNotification, 14 | payload: notification, 15 | }); 16 | }; 17 | 18 | export const clearNotification = 19 | (id: number) => (dispatch: Dispatch) => { 20 | dispatch({ 21 | type: ActionType.clearNotification, 22 | payload: id, 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /client/src/store/action-types/index.ts: -------------------------------------------------------------------------------- 1 | export enum ActionType { 2 | // THEME 3 | setTheme = 'SET_THEME', 4 | fetchThemes = 'FETCH_THEMES', 5 | addTheme = 'ADD_THEME', 6 | deleteTheme = 'DELETE_THEME', 7 | updateTheme = 'UPDATE_THEME', 8 | editTheme = 'EDIT_THEME', 9 | // CONFIG 10 | getConfig = 'GET_CONFIG', 11 | updateConfig = 'UPDATE_CONFIG', 12 | // QUERIES 13 | addQuery = 'ADD_QUERY', 14 | deleteQuery = 'DELETE_QUERY', 15 | fetchQueries = 'FETCH_QUERIES', 16 | updateQuery = 'UPDATE_QUERY', 17 | // NOTIFICATIONS 18 | createNotification = 'CREATE_NOTIFICATION', 19 | clearNotification = 'CLEAR_NOTIFICATION', 20 | // APPS 21 | getApps = 'GET_APPS', 22 | getAppsSuccess = 'GET_APPS_SUCCESS', 23 | getAppsError = 'GET_APPS_ERROR', 24 | pinApp = 'PIN_APP', 25 | addApp = 'ADD_APP', 26 | addAppSuccess = 'ADD_APP_SUCCESS', 27 | deleteApp = 'DELETE_APP', 28 | updateApp = 'UPDATE_APP', 29 | reorderApps = 'REORDER_APPS', 30 | sortApps = 'SORT_APPS', 31 | setEditApp = 'SET_EDIT_APP', 32 | // CATEGORES 33 | getCategories = 'GET_CATEGORIES', 34 | getCategoriesSuccess = 'GET_CATEGORIES_SUCCESS', 35 | getCategoriesError = 'GET_CATEGORIES_ERROR', 36 | addCategory = 'ADD_CATEGORY', 37 | pinCategory = 'PIN_CATEGORY', 38 | deleteCategory = 'DELETE_CATEGORY', 39 | updateCategory = 'UPDATE_CATEGORY', 40 | sortCategories = 'SORT_CATEGORIES', 41 | reorderCategories = 'REORDER_CATEGORIES', 42 | setEditCategory = 'SET_EDIT_CATEGORY', 43 | // BOOKMARKS 44 | addBookmark = 'ADD_BOOKMARK', 45 | deleteBookmark = 'DELETE_BOOKMARK', 46 | updateBookmark = 'UPDATE_BOOKMARK', 47 | setEditBookmark = 'SET_EDIT_BOOKMARK', 48 | reorderBookmarks = 'REORDER_BOOKMARKS', 49 | sortBookmarks = 'SORT_BOOKMARKS', 50 | // AUTH 51 | login = 'LOGIN', 52 | logout = 'LOGOUT', 53 | autoLogin = 'AUTO_LOGIN', 54 | authError = 'AUTH_ERROR', 55 | } 56 | -------------------------------------------------------------------------------- /client/src/store/actions/app.ts: -------------------------------------------------------------------------------- 1 | import { ActionType } from '../action-types'; 2 | import { App } from '../../interfaces'; 3 | 4 | export interface GetAppsAction { 5 | type: 6 | | ActionType.getApps 7 | | ActionType.getAppsSuccess 8 | | ActionType.getAppsError; 9 | payload: T; 10 | } 11 | export interface PinAppAction { 12 | type: ActionType.pinApp; 13 | payload: App; 14 | } 15 | 16 | export interface AddAppAction { 17 | type: ActionType.addAppSuccess; 18 | payload: App; 19 | } 20 | export interface DeleteAppAction { 21 | type: ActionType.deleteApp; 22 | payload: number; 23 | } 24 | 25 | export interface UpdateAppAction { 26 | type: ActionType.updateApp; 27 | payload: App; 28 | } 29 | 30 | export interface ReorderAppsAction { 31 | type: ActionType.reorderApps; 32 | payload: App[]; 33 | } 34 | 35 | export interface SortAppsAction { 36 | type: ActionType.sortApps; 37 | payload: string; 38 | } 39 | 40 | export interface SetEditAppAction { 41 | type: ActionType.setEditApp; 42 | payload: App | null; 43 | } 44 | -------------------------------------------------------------------------------- /client/src/store/actions/auth.ts: -------------------------------------------------------------------------------- 1 | import { ActionType } from '../action-types'; 2 | 3 | export interface LoginAction { 4 | type: ActionType.login; 5 | payload: string; 6 | } 7 | 8 | export interface LogoutAction { 9 | type: ActionType.logout; 10 | } 11 | 12 | export interface AutoLoginAction { 13 | type: ActionType.autoLogin; 14 | payload: string; 15 | } 16 | 17 | export interface AuthErrorAction { 18 | type: ActionType.authError; 19 | } 20 | -------------------------------------------------------------------------------- /client/src/store/actions/bookmark.ts: -------------------------------------------------------------------------------- 1 | import { Bookmark, Category } from '../../interfaces'; 2 | import { ActionType } from '../action-types'; 3 | 4 | export interface GetCategoriesAction { 5 | type: 6 | | ActionType.getCategories 7 | | ActionType.getCategoriesSuccess 8 | | ActionType.getCategoriesError; 9 | payload: T; 10 | } 11 | 12 | export interface AddCategoryAction { 13 | type: ActionType.addCategory; 14 | payload: Category; 15 | } 16 | 17 | export interface AddBookmarkAction { 18 | type: ActionType.addBookmark; 19 | payload: Bookmark; 20 | } 21 | 22 | export interface PinCategoryAction { 23 | type: ActionType.pinCategory; 24 | payload: Category; 25 | } 26 | 27 | export interface DeleteCategoryAction { 28 | type: ActionType.deleteCategory; 29 | payload: number; 30 | } 31 | 32 | export interface UpdateCategoryAction { 33 | type: ActionType.updateCategory; 34 | payload: Category; 35 | } 36 | 37 | export interface DeleteBookmarkAction { 38 | type: ActionType.deleteBookmark; 39 | payload: { 40 | bookmarkId: number; 41 | categoryId: number; 42 | }; 43 | } 44 | 45 | export interface UpdateBookmarkAction { 46 | type: ActionType.updateBookmark; 47 | payload: Bookmark; 48 | } 49 | 50 | export interface SortCategoriesAction { 51 | type: ActionType.sortCategories; 52 | payload: string; 53 | } 54 | 55 | export interface ReorderCategoriesAction { 56 | type: ActionType.reorderCategories; 57 | payload: Category[]; 58 | } 59 | 60 | export interface SetEditCategoryAction { 61 | type: ActionType.setEditCategory; 62 | payload: Category | null; 63 | } 64 | 65 | export interface SetEditBookmarkAction { 66 | type: ActionType.setEditBookmark; 67 | payload: Bookmark | null; 68 | } 69 | 70 | export interface ReorderBookmarksAction { 71 | type: ActionType.reorderBookmarks; 72 | payload: { 73 | bookmarks: Bookmark[]; 74 | categoryId: number; 75 | }; 76 | } 77 | 78 | export interface SortBookmarksAction { 79 | type: ActionType.sortBookmarks; 80 | payload: { 81 | orderType: string; 82 | categoryId: number; 83 | }; 84 | } 85 | -------------------------------------------------------------------------------- /client/src/store/actions/config.ts: -------------------------------------------------------------------------------- 1 | import { ActionType } from '../action-types'; 2 | import { Config, Query } from '../../interfaces'; 3 | 4 | export interface GetConfigAction { 5 | type: ActionType.getConfig; 6 | payload: Config; 7 | } 8 | 9 | export interface UpdateConfigAction { 10 | type: ActionType.updateConfig; 11 | payload: Config; 12 | } 13 | 14 | export interface FetchQueriesAction { 15 | type: ActionType.fetchQueries; 16 | payload: Query[]; 17 | } 18 | 19 | export interface AddQueryAction { 20 | type: ActionType.addQuery; 21 | payload: Query; 22 | } 23 | 24 | export interface DeleteQueryAction { 25 | type: ActionType.deleteQuery; 26 | payload: Query[]; 27 | } 28 | 29 | export interface UpdateQueryAction { 30 | type: ActionType.updateQuery; 31 | payload: Query[]; 32 | } 33 | -------------------------------------------------------------------------------- /client/src/store/actions/index.ts: -------------------------------------------------------------------------------- 1 | import { App } from '../../interfaces'; 2 | 3 | import { 4 | AddThemeAction, 5 | DeleteThemeAction, 6 | EditThemeAction, 7 | FetchThemesAction, 8 | SetThemeAction, 9 | UpdateThemeAction, 10 | } from './theme'; 11 | 12 | import { 13 | AddQueryAction, 14 | DeleteQueryAction, 15 | FetchQueriesAction, 16 | GetConfigAction, 17 | UpdateConfigAction, 18 | UpdateQueryAction, 19 | } from './config'; 20 | 21 | import { 22 | ClearNotificationAction, 23 | CreateNotificationAction, 24 | } from './notification'; 25 | 26 | import { 27 | GetAppsAction, 28 | PinAppAction, 29 | AddAppAction, 30 | DeleteAppAction, 31 | UpdateAppAction, 32 | ReorderAppsAction, 33 | SortAppsAction, 34 | SetEditAppAction, 35 | } from './app'; 36 | 37 | import { 38 | GetCategoriesAction, 39 | AddCategoryAction, 40 | PinCategoryAction, 41 | DeleteCategoryAction, 42 | UpdateCategoryAction, 43 | SortCategoriesAction, 44 | ReorderCategoriesAction, 45 | AddBookmarkAction, 46 | DeleteBookmarkAction, 47 | UpdateBookmarkAction, 48 | SetEditCategoryAction, 49 | SetEditBookmarkAction, 50 | ReorderBookmarksAction, 51 | SortBookmarksAction, 52 | } from './bookmark'; 53 | 54 | import { 55 | AuthErrorAction, 56 | AutoLoginAction, 57 | LoginAction, 58 | LogoutAction, 59 | } from './auth'; 60 | 61 | export type Action = 62 | // Theme 63 | | SetThemeAction 64 | | FetchThemesAction 65 | | AddThemeAction 66 | | DeleteThemeAction 67 | | UpdateThemeAction 68 | | EditThemeAction 69 | // Config 70 | | GetConfigAction 71 | | UpdateConfigAction 72 | | AddQueryAction 73 | | DeleteQueryAction 74 | | FetchQueriesAction 75 | | UpdateQueryAction 76 | // Notifications 77 | | CreateNotificationAction 78 | | ClearNotificationAction 79 | // Apps 80 | | GetAppsAction 81 | | PinAppAction 82 | | AddAppAction 83 | | DeleteAppAction 84 | | UpdateAppAction 85 | | ReorderAppsAction 86 | | SortAppsAction 87 | | SetEditAppAction 88 | // Categories 89 | | GetCategoriesAction 90 | | AddCategoryAction 91 | | PinCategoryAction 92 | | DeleteCategoryAction 93 | | UpdateCategoryAction 94 | | SortCategoriesAction 95 | | ReorderCategoriesAction 96 | | SetEditCategoryAction 97 | // Bookmarks 98 | | AddBookmarkAction 99 | | DeleteBookmarkAction 100 | | UpdateBookmarkAction 101 | | SetEditBookmarkAction 102 | | ReorderBookmarksAction 103 | | SortBookmarksAction 104 | // Auth 105 | | LoginAction 106 | | LogoutAction 107 | | AutoLoginAction 108 | | AuthErrorAction; 109 | -------------------------------------------------------------------------------- /client/src/store/actions/notification.ts: -------------------------------------------------------------------------------- 1 | import { ActionType } from '../action-types'; 2 | import { NewNotification } from '../../interfaces'; 3 | 4 | export interface CreateNotificationAction { 5 | type: ActionType.createNotification; 6 | payload: NewNotification; 7 | } 8 | 9 | export interface ClearNotificationAction { 10 | type: ActionType.clearNotification; 11 | payload: number; 12 | } 13 | -------------------------------------------------------------------------------- /client/src/store/actions/theme.ts: -------------------------------------------------------------------------------- 1 | import { ActionType } from '../action-types'; 2 | import { Theme, ThemeColors } from '../../interfaces'; 3 | 4 | export interface SetThemeAction { 5 | type: ActionType.setTheme; 6 | payload: ThemeColors; 7 | } 8 | 9 | export interface FetchThemesAction { 10 | type: ActionType.fetchThemes; 11 | payload: Theme[]; 12 | } 13 | 14 | export interface AddThemeAction { 15 | type: ActionType.addTheme; 16 | payload: Theme; 17 | } 18 | 19 | export interface DeleteThemeAction { 20 | type: ActionType.deleteTheme; 21 | payload: Theme[]; 22 | } 23 | 24 | export interface UpdateThemeAction { 25 | type: ActionType.updateTheme; 26 | payload: Theme[]; 27 | } 28 | 29 | export interface EditThemeAction { 30 | type: ActionType.editTheme; 31 | payload: Theme | null; 32 | } 33 | -------------------------------------------------------------------------------- /client/src/store/index.ts: -------------------------------------------------------------------------------- 1 | export * from './store'; 2 | export * as actionCreators from './action-creators'; 3 | -------------------------------------------------------------------------------- /client/src/store/reducers/auth.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '../actions'; 2 | import { ActionType } from '../action-types'; 3 | 4 | interface AuthState { 5 | isAuthenticated: boolean; 6 | token: string | null; 7 | } 8 | 9 | const initialState: AuthState = { 10 | isAuthenticated: false, 11 | token: null, 12 | }; 13 | 14 | export const authReducer = ( 15 | state: AuthState = initialState, 16 | action: Action 17 | ): AuthState => { 18 | switch (action.type) { 19 | case ActionType.login: 20 | return { 21 | ...state, 22 | token: action.payload, 23 | isAuthenticated: true, 24 | }; 25 | 26 | case ActionType.logout: 27 | return { 28 | ...state, 29 | token: null, 30 | isAuthenticated: false, 31 | }; 32 | 33 | case ActionType.autoLogin: 34 | return { 35 | ...state, 36 | token: action.payload, 37 | isAuthenticated: true, 38 | }; 39 | 40 | case ActionType.authError: 41 | return { 42 | ...state, 43 | token: null, 44 | isAuthenticated: false, 45 | }; 46 | 47 | default: 48 | return state; 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /client/src/store/reducers/config.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '../actions'; 2 | import { ActionType } from '../action-types'; 3 | import { Config, Query } from '../../interfaces'; 4 | import { configTemplate } from '../../utility'; 5 | 6 | interface ConfigState { 7 | loading: boolean; 8 | config: Config; 9 | customQueries: Query[]; 10 | } 11 | 12 | const initialState: ConfigState = { 13 | loading: true, 14 | config: { ...configTemplate }, 15 | customQueries: [], 16 | }; 17 | 18 | export const configReducer = ( 19 | state: ConfigState = initialState, 20 | action: Action 21 | ): ConfigState => { 22 | switch (action.type) { 23 | case ActionType.getConfig: 24 | return { 25 | ...state, 26 | loading: false, 27 | config: action.payload, 28 | }; 29 | 30 | case ActionType.updateConfig: 31 | return { 32 | ...state, 33 | config: action.payload, 34 | }; 35 | 36 | case ActionType.fetchQueries: 37 | return { 38 | ...state, 39 | customQueries: action.payload, 40 | }; 41 | 42 | case ActionType.addQuery: 43 | return { 44 | ...state, 45 | customQueries: [...state.customQueries, action.payload], 46 | }; 47 | 48 | case ActionType.deleteQuery: 49 | return { 50 | ...state, 51 | customQueries: action.payload, 52 | }; 53 | 54 | case ActionType.updateQuery: 55 | return { 56 | ...state, 57 | customQueries: action.payload, 58 | }; 59 | 60 | default: 61 | return state; 62 | } 63 | }; 64 | -------------------------------------------------------------------------------- /client/src/store/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import { themeReducer } from './theme'; 4 | import { configReducer } from './config'; 5 | import { notificationReducer } from './notification'; 6 | import { appsReducer } from './app'; 7 | import { bookmarksReducer } from './bookmark'; 8 | import { authReducer } from './auth'; 9 | 10 | export const reducers = combineReducers({ 11 | theme: themeReducer, 12 | config: configReducer, 13 | notification: notificationReducer, 14 | apps: appsReducer, 15 | bookmarks: bookmarksReducer, 16 | auth: authReducer, 17 | }); 18 | 19 | export type State = ReturnType; 20 | -------------------------------------------------------------------------------- /client/src/store/reducers/notification.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '../actions'; 2 | import { ActionType } from '../action-types'; 3 | import { Notification } from '../../interfaces'; 4 | 5 | export interface NotificationState { 6 | notifications: Notification[]; 7 | idCounter: number; 8 | } 9 | 10 | const initialState: NotificationState = { 11 | notifications: [], 12 | idCounter: 0, 13 | }; 14 | 15 | export const notificationReducer = ( 16 | state: NotificationState = initialState, 17 | action: Action 18 | ): NotificationState => { 19 | switch (action.type) { 20 | case ActionType.createNotification: 21 | return { 22 | ...state, 23 | notifications: [ 24 | ...state.notifications, 25 | { 26 | ...action.payload, 27 | id: state.idCounter, 28 | }, 29 | ], 30 | idCounter: state.idCounter + 1, 31 | }; 32 | 33 | case ActionType.clearNotification: 34 | return { 35 | ...state, 36 | notifications: [...state.notifications].filter( 37 | (notification) => notification.id !== action.payload 38 | ), 39 | }; 40 | default: 41 | return state; 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /client/src/store/reducers/theme.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '../actions'; 2 | import { ActionType } from '../action-types'; 3 | import { Theme } from '../../interfaces/Theme'; 4 | import { arrayPartition, parsePABToTheme } from '../../utility'; 5 | 6 | interface ThemeState { 7 | activeTheme: Theme; 8 | themes: Theme[]; 9 | userThemes: Theme[]; 10 | themeInEdit: Theme | null; 11 | } 12 | 13 | const savedTheme = localStorage.theme 14 | ? parsePABToTheme(localStorage.theme) 15 | : parsePABToTheme('#effbff;#6ee2ff;#242b33'); 16 | 17 | const initialState: ThemeState = { 18 | activeTheme: { 19 | name: 'main', 20 | isCustom: false, 21 | colors: { 22 | ...savedTheme, 23 | }, 24 | }, 25 | themes: [], 26 | userThemes: [], 27 | themeInEdit: null, 28 | }; 29 | 30 | export const themeReducer = ( 31 | state: ThemeState = initialState, 32 | action: Action 33 | ): ThemeState => { 34 | switch (action.type) { 35 | case ActionType.setTheme: { 36 | return { 37 | ...state, 38 | activeTheme: { 39 | ...state.activeTheme, 40 | colors: action.payload, 41 | }, 42 | }; 43 | } 44 | 45 | case ActionType.fetchThemes: { 46 | const [themes, userThemes] = arrayPartition( 47 | action.payload, 48 | (e) => !e.isCustom 49 | ); 50 | 51 | return { 52 | ...state, 53 | themes, 54 | userThemes, 55 | }; 56 | } 57 | 58 | case ActionType.addTheme: { 59 | return { 60 | ...state, 61 | userThemes: [...state.userThemes, action.payload], 62 | }; 63 | } 64 | 65 | case ActionType.deleteTheme: { 66 | return { 67 | ...state, 68 | userThemes: action.payload, 69 | }; 70 | } 71 | 72 | case ActionType.editTheme: { 73 | return { 74 | ...state, 75 | themeInEdit: action.payload, 76 | }; 77 | } 78 | 79 | case ActionType.updateTheme: { 80 | return { 81 | ...state, 82 | userThemes: action.payload, 83 | }; 84 | } 85 | 86 | default: 87 | return state; 88 | } 89 | }; 90 | -------------------------------------------------------------------------------- /client/src/store/store.ts: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux'; 2 | import { composeWithDevTools } from 'redux-devtools-extension'; 3 | import thunk from 'redux-thunk'; 4 | import { reducers } from './reducers'; 5 | 6 | export const store = createStore( 7 | reducers, 8 | {}, 9 | composeWithDevTools(applyMiddleware(thunk)) 10 | ); 11 | -------------------------------------------------------------------------------- /client/src/types/ConfigFormData.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DockerSettingsForm, 3 | UISettingsForm, 4 | GeneralForm, 5 | ThemeSettingsForm, 6 | WeatherForm, 7 | } from '../interfaces'; 8 | 9 | export type ConfigFormData = 10 | | WeatherForm 11 | | GeneralForm 12 | | DockerSettingsForm 13 | | UISettingsForm 14 | | ThemeSettingsForm; 15 | -------------------------------------------------------------------------------- /client/src/types/WeatherData.ts: -------------------------------------------------------------------------------- 1 | export type WeatherData = 'cloud' | 'humidity'; 2 | -------------------------------------------------------------------------------- /client/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ConfigFormData'; 2 | export * from './WeatherData'; 3 | -------------------------------------------------------------------------------- /client/src/utility/applyAuth.ts: -------------------------------------------------------------------------------- 1 | export const applyAuth = () => { 2 | const token = localStorage.getItem('token') || ''; 3 | return { 'Authorization-Flame': `Bearer ${token}` }; 4 | }; 5 | -------------------------------------------------------------------------------- /client/src/utility/arrayPartition.ts: -------------------------------------------------------------------------------- 1 | export const arrayPartition = ( 2 | arr: T[], 3 | isValid: (e: T) => boolean 4 | ): T[][] => { 5 | let pass: T[] = []; 6 | let fail: T[] = []; 7 | 8 | arr.forEach((e) => (isValid(e) ? pass : fail).push(e)); 9 | 10 | return [pass, fail]; 11 | }; 12 | -------------------------------------------------------------------------------- /client/src/utility/checkVersion.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { store } from '../store/store'; 3 | import { createNotification } from '../store/action-creators'; 4 | 5 | export const checkVersion = async (isForced: boolean = false) => { 6 | try { 7 | const res = await axios.get( 8 | 'https://raw.githubusercontent.com/pawelmalak/flame/master/client/.env' 9 | ); 10 | 11 | const githubVersion = res.data 12 | .split('\n') 13 | .map((pair) => pair.split('='))[0][1]; 14 | 15 | if (githubVersion !== process.env.REACT_APP_VERSION) { 16 | store.dispatch( 17 | createNotification({ 18 | title: 'Info', 19 | message: 'New version is available!', 20 | url: 'https://github.com/pawelmalak/flame/blob/master/CHANGELOG.md', 21 | }) 22 | ); 23 | } else if (isForced) { 24 | store.dispatch( 25 | createNotification({ 26 | title: 'Info', 27 | message: 'You are using the latest version!', 28 | }) 29 | ); 30 | } 31 | } catch (err) { 32 | console.log(err); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /client/src/utility/decodeToken.ts: -------------------------------------------------------------------------------- 1 | import jwtDecode from 'jwt-decode'; 2 | import { parseTime } from '.'; 3 | import { Token } from '../interfaces'; 4 | 5 | export const decodeToken = (token: string): Token => { 6 | const decoded = jwtDecode(token) as Token; 7 | 8 | return decoded; 9 | }; 10 | 11 | export const parseTokenExpire = (expiresIn: number): string => { 12 | const d = new Date(expiresIn * 1000); 13 | const p = parseTime; 14 | 15 | const useAmericanDate = localStorage.useAmericanDate === 'true'; 16 | const time = `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; 17 | 18 | if (useAmericanDate) { 19 | return `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()} ${time}`; 20 | } else { 21 | return `${d.getDate()}/${d.getMonth() + 1}/${d.getFullYear()} ${time}`; 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /client/src/utility/escapeRegex.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * https://stackoverflow.com/a/30851002/16957052 3 | */ 4 | export const escapeRegex = (exp: string) => { 5 | return exp.replace(/[-[\]{}()*+!<=:?.\/\\^$|#\s,]/g, '\\$&'); 6 | }; 7 | -------------------------------------------------------------------------------- /client/src/utility/iconParser.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parse Material Desgin icon name to be used with mdi/js 3 | * @param mdiName Dash separated icon name from MDI, e.g. alert-box-outline 4 | * @returns Parsed icon name to be used with mdi/js, e.g mdiAlertBoxOutline 5 | */ 6 | export const iconParser = (mdiName: string): string => { 7 | let parsedName = mdiName 8 | .split('-') 9 | .map((word: string) => `${word[0].toUpperCase()}${word.slice(1)}`) 10 | .join(''); 11 | parsedName = `mdi${parsedName}`; 12 | 13 | return parsedName; 14 | } -------------------------------------------------------------------------------- /client/src/utility/index.ts: -------------------------------------------------------------------------------- 1 | export * from './iconParser'; 2 | export * from './urlParser'; 3 | export * from './checkVersion'; 4 | export * from './sortData'; 5 | export * from './searchParser'; 6 | export * from './redirectUrl'; 7 | export * from './templateObjects'; 8 | export * from './inputHandler'; 9 | export * from './storeUIConfig'; 10 | export * from './validators'; 11 | export * from './parseTime'; 12 | export * from './decodeToken'; 13 | export * from './applyAuth'; 14 | export * from './escapeRegex'; 15 | export * from './parseTheme'; 16 | export * from './arrayPartition'; 17 | -------------------------------------------------------------------------------- /client/src/utility/inputHandler.ts: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, SetStateAction } from 'react'; 2 | 3 | type Event = ChangeEvent; 4 | 5 | interface Options { 6 | isNumber?: boolean; 7 | isBool?: boolean; 8 | } 9 | 10 | interface Params { 11 | e: Event; 12 | options?: Options; 13 | setStateHandler: (v: SetStateAction) => void; 14 | state: T; 15 | } 16 | 17 | export const inputHandler = (params: Params): void => { 18 | const { e, options, setStateHandler, state } = params; 19 | 20 | const rawValue = e.target.value; 21 | let value: string | number | boolean = e.target.value; 22 | 23 | if (options) { 24 | const { isNumber = false, isBool = false } = options; 25 | 26 | if (isNumber) { 27 | value = parseFloat(rawValue); 28 | } 29 | 30 | if (isBool) { 31 | value = !!parseInt(rawValue); 32 | } 33 | } 34 | 35 | setStateHandler({ 36 | ...state, 37 | [e.target.name]: value, 38 | }); 39 | }; 40 | -------------------------------------------------------------------------------- /client/src/utility/parseTheme.ts: -------------------------------------------------------------------------------- 1 | import { ThemeColors } from '../interfaces'; 2 | 3 | // parse theme in PAB (primary;accent;background) format to theme colors object 4 | export const parsePABToTheme = (themeStr: string): ThemeColors => { 5 | const [primary, accent, background] = themeStr.split(';'); 6 | 7 | return { 8 | primary, 9 | accent, 10 | background, 11 | }; 12 | }; 13 | 14 | export const parseThemeToPAB = ({ 15 | primary: p, 16 | accent: a, 17 | background: b, 18 | }: ThemeColors): string => { 19 | return `${p};${a};${b}`; 20 | }; 21 | -------------------------------------------------------------------------------- /client/src/utility/parseTime.ts: -------------------------------------------------------------------------------- 1 | export const parseTime = (time: number, ms = false) => { 2 | if (ms) { 3 | if (time >= 10 && time < 100) { 4 | return `0${time}`; 5 | } else if (time < 10) { 6 | return `00${time}`; 7 | } 8 | } 9 | 10 | return time < 10 ? `0${time}` : time.toString(); 11 | }; 12 | -------------------------------------------------------------------------------- /client/src/utility/redirectUrl.ts: -------------------------------------------------------------------------------- 1 | import { urlParser } from '.'; 2 | 3 | export const redirectUrl = (url: string, sameTab: boolean) => { 4 | const parsedUrl = urlParser(url)[1]; 5 | 6 | if (sameTab) { 7 | document.location.assign(parsedUrl); 8 | } else { 9 | window.open(parsedUrl); 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /client/src/utility/searchParser.ts: -------------------------------------------------------------------------------- 1 | import searchQueries from './searchQueries.json'; 2 | import { SearchResult } from '../interfaces'; 3 | import { store } from '../store/store'; 4 | import { isUrlOrIp } from '.'; 5 | 6 | export const searchParser = (searchQuery: string): SearchResult => { 7 | const queries = searchQueries.queries; 8 | 9 | const result: SearchResult = { 10 | isLocal: false, 11 | isURL: false, 12 | sameTab: false, 13 | encodedURL: '', 14 | primarySearch: { 15 | name: '', 16 | prefix: '', 17 | template: '', 18 | }, 19 | secondarySearch: { 20 | name: '', 21 | prefix: '', 22 | template: '', 23 | }, 24 | rawQuery: searchQuery, 25 | }; 26 | 27 | const { customQueries, config } = store.getState().config; 28 | 29 | // Check if url or ip was passed 30 | result.isURL = isUrlOrIp(searchQuery); 31 | 32 | // Match prefix and query 33 | const splitQuery = searchQuery.match(/^\/([a-z]+)[ ](.+)$/i); 34 | 35 | // Extract prefix 36 | const prefix = splitQuery ? splitQuery[1] : config.defaultSearchProvider; 37 | 38 | // Encode url 39 | const encodedURL = splitQuery 40 | ? encodeURIComponent(splitQuery[2]) 41 | : encodeURIComponent(searchQuery); 42 | 43 | // Find primary search engine template 44 | const findProvider = (prefix: string) => { 45 | return [...queries, ...customQueries].find((q) => q.prefix === prefix); 46 | }; 47 | 48 | const primarySearch = findProvider(prefix); 49 | const secondarySearch = findProvider(config.secondarySearchProvider); 50 | 51 | // If search providers were found 52 | if (primarySearch) { 53 | result.primarySearch = primarySearch; 54 | result.encodedURL = encodedURL; 55 | 56 | if (prefix === 'l') { 57 | result.isLocal = true; 58 | } 59 | 60 | result.sameTab = config.searchSameTab; 61 | 62 | if (secondarySearch) { 63 | result.secondarySearch = secondarySearch; 64 | } 65 | 66 | return result; 67 | } 68 | 69 | return result; 70 | }; 71 | -------------------------------------------------------------------------------- /client/src/utility/searchQueries.json: -------------------------------------------------------------------------------- 1 | { 2 | "queries": [ 3 | { 4 | "name": "Deezer", 5 | "prefix": "dz", 6 | "template": "https://www.deezer.com/search/" 7 | }, 8 | { 9 | "name": "Disroot", 10 | "prefix": "ds", 11 | "template": "http://search.disroot.org/search?q=" 12 | }, 13 | { 14 | "name": "DuckDuckGo", 15 | "prefix": "d", 16 | "template": "https://duckduckgo.com/?q=" 17 | }, 18 | { 19 | "name": "Google", 20 | "prefix": "g", 21 | "template": "https://www.google.com/search?q=" 22 | }, 23 | { 24 | "name": "IMDb", 25 | "prefix": "im", 26 | "template": "https://www.imdb.com/find?q=" 27 | }, 28 | { 29 | "name": "Local search", 30 | "prefix": "l", 31 | "template": "#" 32 | }, 33 | { 34 | "name": "Reddit", 35 | "prefix": "r", 36 | "template": "https://www.reddit.com/search?q=" 37 | }, 38 | { 39 | "name": "Spotify", 40 | "prefix": "sp", 41 | "template": "https://open.spotify.com/search/" 42 | }, 43 | { 44 | "name": "The Movie Database", 45 | "prefix": "mv", 46 | "template": "https://www.themoviedb.org/search?query=" 47 | }, 48 | { 49 | "name": "Tidal", 50 | "prefix": "td", 51 | "template": "https://listen.tidal.com/search?q=" 52 | }, 53 | { 54 | "name": "Wikipedia", 55 | "prefix": "w", 56 | "template": "https://en.wikipedia.org/w/index.php?search=" 57 | }, 58 | { 59 | "name": "YouTube", 60 | "prefix": "yt", 61 | "template": "https://www.youtube.com/results?search_query=" 62 | } 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /client/src/utility/sortData.ts: -------------------------------------------------------------------------------- 1 | interface Data { 2 | name: string; 3 | orderId: number; 4 | createdAt: Date; 5 | } 6 | 7 | export const sortData = (array: T[], field: string): T[] => { 8 | const sortedData = array.slice(); 9 | 10 | if (field === 'name') { 11 | sortedData.sort((a: T, b: T) => { 12 | return a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }) 13 | }) 14 | } else if (field === 'orderId') { 15 | sortedData.sort((a: T, b: T) => { 16 | if (a.orderId < b.orderId) { return -1 } 17 | if (a.orderId > b.orderId) { return 1 } 18 | return 0; 19 | }) 20 | } else { 21 | sortedData.sort((a: T, b: T) => { 22 | if (a.createdAt < b.createdAt) { return -1 } 23 | if (a.createdAt > b.createdAt) { return 1 } 24 | return 0; 25 | }) 26 | } 27 | 28 | return sortedData; 29 | } -------------------------------------------------------------------------------- /client/src/utility/storeUIConfig.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '../interfaces'; 2 | 3 | export const storeUIConfig = ( 4 | key: K, 5 | config: Config 6 | ) => { 7 | localStorage.setItem(key, `${config[key]}`); 8 | }; 9 | -------------------------------------------------------------------------------- /client/src/utility/templateObjects/appTemplate.ts: -------------------------------------------------------------------------------- 1 | import { App, NewApp } from '../../interfaces'; 2 | 3 | export const newAppTemplate: NewApp = { 4 | name: '', 5 | url: '', 6 | icon: '', 7 | isPublic: true, 8 | description: '', 9 | }; 10 | 11 | export const appTemplate: App = { 12 | ...newAppTemplate, 13 | isPinned: false, 14 | orderId: 0, 15 | id: -1, 16 | createdAt: new Date(), 17 | updatedAt: new Date(), 18 | }; 19 | -------------------------------------------------------------------------------- /client/src/utility/templateObjects/bookmarkTemplate.ts: -------------------------------------------------------------------------------- 1 | import { Bookmark, NewBookmark } from '../../interfaces'; 2 | 3 | export const newBookmarkTemplate: NewBookmark = { 4 | name: '', 5 | url: '', 6 | categoryId: -1, 7 | icon: '', 8 | isPublic: true, 9 | }; 10 | 11 | export const bookmarkTemplate: Bookmark = { 12 | ...newBookmarkTemplate, 13 | id: -1, 14 | createdAt: new Date(), 15 | updatedAt: new Date(), 16 | orderId: 0, 17 | }; 18 | -------------------------------------------------------------------------------- /client/src/utility/templateObjects/categoryTemplate.ts: -------------------------------------------------------------------------------- 1 | import { Category, NewCategory } from '../../interfaces'; 2 | 3 | export const newCategoryTemplate: NewCategory = { 4 | name: '', 5 | isPublic: true, 6 | }; 7 | 8 | export const categoryTemplate: Category = { 9 | ...newCategoryTemplate, 10 | id: -1, 11 | isPinned: false, 12 | orderId: 0, 13 | bookmarks: [], 14 | createdAt: new Date(), 15 | updatedAt: new Date(), 16 | }; 17 | -------------------------------------------------------------------------------- /client/src/utility/templateObjects/configTemplate.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '../../interfaces'; 2 | 3 | export const configTemplate: Config = { 4 | WEATHER_API_KEY: '', 5 | lat: 0, 6 | long: 0, 7 | isCelsius: true, 8 | customTitle: 'Flame', 9 | pinAppsByDefault: true, 10 | pinCategoriesByDefault: true, 11 | hideHeader: false, 12 | useOrdering: 'createdAt', 13 | appsSameTab: false, 14 | bookmarksSameTab: false, 15 | searchSameTab: false, 16 | hideApps: false, 17 | hideCategories: false, 18 | hideSearch: false, 19 | defaultSearchProvider: 'l', 20 | secondarySearchProvider: 'd', 21 | dockerApps: false, 22 | dockerHost: 'localhost', 23 | kubernetesApps: false, 24 | unpinStoppedApps: false, 25 | useAmericanDate: false, 26 | disableAutofocus: false, 27 | greetingsSchema: 'Good evening!;Good afternoon!;Good morning!;Good night!', 28 | daySchema: 'Sunday;Monday;Tuesday;Wednesday;Thursday;Friday;Saturday', 29 | monthSchema: 30 | 'January;February;March;April;May;June;July;August;September;October;November;December', 31 | showTime: false, 32 | defaultTheme: 'tron', 33 | isKilometer: true, 34 | weatherData: 'cloud', 35 | hideDate: false, 36 | }; 37 | -------------------------------------------------------------------------------- /client/src/utility/templateObjects/index.ts: -------------------------------------------------------------------------------- 1 | export * from './configTemplate'; 2 | export * from './settingsTemplate'; 3 | export * from './appTemplate'; 4 | export * from './categoryTemplate'; 5 | export * from './bookmarkTemplate'; 6 | -------------------------------------------------------------------------------- /client/src/utility/templateObjects/settingsTemplate.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DockerSettingsForm, 3 | UISettingsForm, 4 | GeneralForm, 5 | ThemeSettingsForm, 6 | WeatherForm, 7 | } from '../../interfaces'; 8 | 9 | export const uiSettingsTemplate: UISettingsForm = { 10 | customTitle: document.title, 11 | hideHeader: false, 12 | hideApps: false, 13 | hideCategories: false, 14 | useAmericanDate: false, 15 | greetingsSchema: 'Good evening!;Good afternoon!;Good morning!;Good night!', 16 | daySchema: 'Sunday;Monday;Tuesday;Wednesday;Thursday;Friday;Saturday', 17 | monthSchema: 18 | 'January;February;March;April;May;June;July;August;September;October;November;December', 19 | showTime: false, 20 | hideDate: false, 21 | hideSearch: false, 22 | disableAutofocus: false, 23 | }; 24 | 25 | export const weatherSettingsTemplate: WeatherForm = { 26 | WEATHER_API_KEY: '', 27 | lat: 0, 28 | long: 0, 29 | isCelsius: true, 30 | weatherData: 'cloud', 31 | }; 32 | 33 | export const generalSettingsTemplate: GeneralForm = { 34 | searchSameTab: false, 35 | defaultSearchProvider: 'l', 36 | secondarySearchProvider: 'd', 37 | pinAppsByDefault: true, 38 | pinCategoriesByDefault: true, 39 | useOrdering: 'createdAt', 40 | appsSameTab: false, 41 | bookmarksSameTab: false, 42 | }; 43 | 44 | export const dockerSettingsTemplate: DockerSettingsForm = { 45 | dockerApps: true, 46 | dockerHost: 'localhost', 47 | kubernetesApps: true, 48 | unpinStoppedApps: true, 49 | }; 50 | 51 | export const themeSettingsTemplate: ThemeSettingsForm = { 52 | defaultTheme: 'tron', 53 | }; 54 | -------------------------------------------------------------------------------- /client/src/utility/templateObjects/weatherTemplate.ts: -------------------------------------------------------------------------------- 1 | import { Weather } from '../../interfaces'; 2 | 3 | export const weatherTemplate: Weather = { 4 | externalLastUpdate: '', 5 | tempC: 0, 6 | tempF: 0, 7 | isDay: 1, 8 | cloud: 0, 9 | conditionText: '', 10 | conditionCode: 1000, 11 | id: -1, 12 | createdAt: new Date(), 13 | updatedAt: new Date(), 14 | humidity: 0, 15 | windK: 0, 16 | windM: 0, 17 | }; 18 | -------------------------------------------------------------------------------- /client/src/utility/urlParser.ts: -------------------------------------------------------------------------------- 1 | const hasProtocol = (url: string): boolean => /^\w+:\/\//.test(url); 2 | const isSteamUrl = (url: string): boolean => /^steam:\/\//.test(url); 3 | const isWebUrl = (url: string): boolean => /^https?:\/\//.test(url); 4 | 5 | export const urlParser = (url: string): string[] => { 6 | if (!hasProtocol(url)) { 7 | // No protocol -> apply http:// prefix 8 | url = `http://${url}`; 9 | } 10 | 11 | // Create simplified url to display as text 12 | let displayUrl: string; 13 | if (isSteamUrl(url)) { 14 | displayUrl = 'Run Steam App'; 15 | } else if (isWebUrl(url)) { 16 | displayUrl = url 17 | .replace(/https?:\/\//, '') 18 | .replace('www.', '') 19 | .replace(/\/$/, ''); 20 | } else displayUrl = url; 21 | 22 | return [displayUrl, url]; 23 | }; 24 | -------------------------------------------------------------------------------- /client/src/utility/validators.ts: -------------------------------------------------------------------------------- 1 | export const isUrlOrIp = (data: string): boolean => { 2 | const regex = 3 | /^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?|^((http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/i; 4 | 5 | return regex.test(data); 6 | }; 7 | 8 | export const isUrl = (data: string): boolean => { 9 | const regex = 10 | /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/i; 11 | 12 | return regex.test(data); 13 | }; 14 | 15 | export const isImage = (data: string): boolean => { 16 | const regex = /.(jpeg|jpg|png|ico)$/i; 17 | 18 | return regex.test(data); 19 | }; 20 | 21 | export const isSvg = (data: string): boolean => { 22 | const regex = /.(svg)$/i; 23 | 24 | return regex.test(data); 25 | }; 26 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /controllers/apps/createApp.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const App = require('../../models/App'); 3 | const loadConfig = require('../../utils/loadConfig'); 4 | 5 | // @desc Create new app 6 | // @route POST /api/apps 7 | // @access Public 8 | const createApp = asyncWrapper(async (req, res, next) => { 9 | const { pinAppsByDefault } = await loadConfig(); 10 | 11 | let body = { ...req.body }; 12 | 13 | if (body.icon) { 14 | body.icon = body.icon.trim(); 15 | } 16 | 17 | if (req.file) { 18 | body.icon = req.file.filename; 19 | } 20 | 21 | const app = await App.create({ 22 | ...body, 23 | isPinned: pinAppsByDefault, 24 | }); 25 | 26 | res.status(201).json({ 27 | success: true, 28 | data: app, 29 | }); 30 | }); 31 | 32 | module.exports = createApp; 33 | -------------------------------------------------------------------------------- /controllers/apps/deleteApp.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const App = require('../../models/App'); 3 | 4 | // @desc Delete app 5 | // @route DELETE /api/apps/:id 6 | // @access Public 7 | const deleteApp = asyncWrapper(async (req, res, next) => { 8 | await App.destroy({ 9 | where: { id: req.params.id }, 10 | }); 11 | 12 | res.status(200).json({ 13 | success: true, 14 | data: {}, 15 | }); 16 | }); 17 | 18 | module.exports = deleteApp; 19 | -------------------------------------------------------------------------------- /controllers/apps/docker/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | useKubernetes: require('./useKubernetes'), 3 | useDocker: require('./useDocker'), 4 | }; 5 | -------------------------------------------------------------------------------- /controllers/apps/docker/useKubernetes.js: -------------------------------------------------------------------------------- 1 | const App = require('../../../models/App'); 2 | const k8s = require('@kubernetes/client-node'); 3 | const Logger = require('../../../utils/Logger'); 4 | const logger = new Logger(); 5 | const loadConfig = require('../../../utils/loadConfig'); 6 | 7 | const useKubernetes = async (apps) => { 8 | const { useOrdering: orderType, unpinStoppedApps } = await loadConfig(); 9 | 10 | let ingresses = null; 11 | 12 | try { 13 | const kc = new k8s.KubeConfig(); 14 | kc.loadFromCluster(); 15 | const k8sNetworkingV1Api = kc.makeApiClient(k8s.NetworkingV1Api); 16 | await k8sNetworkingV1Api.listIngressForAllNamespaces().then((res) => { 17 | ingresses = res.body.items; 18 | }); 19 | } catch { 20 | logger.log("Can't connect to the Kubernetes API", 'ERROR'); 21 | } 22 | 23 | if (ingresses) { 24 | apps = await App.findAll({ 25 | order: [[orderType, 'ASC']], 26 | }); 27 | 28 | ingresses = ingresses.filter( 29 | (e) => Object.keys(e.metadata.annotations).length !== 0 30 | ); 31 | 32 | const kubernetesApps = []; 33 | 34 | for (const ingress of ingresses) { 35 | const annotations = ingress.metadata.annotations; 36 | 37 | if ( 38 | 'flame.pawelmalak/name' in annotations && 39 | 'flame.pawelmalak/url' in annotations && 40 | /^app/.test(annotations['flame.pawelmalak/type']) 41 | ) { 42 | kubernetesApps.push({ 43 | name: annotations['flame.pawelmalak/name'], 44 | url: annotations['flame.pawelmalak/url'], 45 | icon: annotations['flame.pawelmalak/icon'] || 'kubernetes', 46 | }); 47 | } 48 | } 49 | 50 | if (unpinStoppedApps) { 51 | for (const app of apps) { 52 | await app.update({ isPinned: false }); 53 | } 54 | } 55 | 56 | for (const item of kubernetesApps) { 57 | if (apps.some((app) => app.name === item.name)) { 58 | const app = apps.find((a) => a.name === item.name); 59 | await app.update({ ...item, isPinned: true }); 60 | } else { 61 | await App.create({ 62 | ...item, 63 | isPinned: true, 64 | }); 65 | } 66 | } 67 | } 68 | }; 69 | 70 | module.exports = useKubernetes; 71 | -------------------------------------------------------------------------------- /controllers/apps/getAllApps.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const App = require('../../models/App'); 3 | const { Sequelize } = require('sequelize'); 4 | const loadConfig = require('../../utils/loadConfig'); 5 | 6 | const { useKubernetes, useDocker } = require('./docker'); 7 | 8 | // @desc Get all apps 9 | // @route GET /api/apps 10 | // @access Public 11 | const getAllApps = asyncWrapper(async (req, res, next) => { 12 | const { 13 | useOrdering: orderType, 14 | dockerApps: useDockerAPI, 15 | kubernetesApps: useKubernetesAPI, 16 | } = await loadConfig(); 17 | 18 | let apps; 19 | 20 | if (useDockerAPI) { 21 | await useDocker(apps); 22 | } 23 | 24 | if (useKubernetesAPI) { 25 | await useKubernetes(apps); 26 | } 27 | 28 | // apps visibility 29 | const where = req.isAuthenticated ? {} : { isPublic: true }; 30 | 31 | const order = 32 | orderType == 'name' 33 | ? [[Sequelize.fn('lower', Sequelize.col('name')), 'ASC']] 34 | : [[orderType, 'ASC']]; 35 | 36 | apps = await App.findAll({ 37 | order, 38 | where, 39 | }); 40 | 41 | if (process.env.NODE_ENV === 'production') { 42 | // Set header to fetch containers info every time 43 | return res.status(200).setHeader('Cache-Control', 'no-store').json({ 44 | success: true, 45 | data: apps, 46 | }); 47 | } 48 | 49 | res.status(200).json({ 50 | success: true, 51 | data: apps, 52 | }); 53 | }); 54 | 55 | module.exports = getAllApps; 56 | -------------------------------------------------------------------------------- /controllers/apps/getSingleApp.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const App = require('../../models/App'); 3 | const ErrorResponse = require('../../utils/ErrorResponse'); 4 | 5 | // @desc Get single app 6 | // @route GET /api/apps/:id 7 | // @access Public 8 | const getSingleApp = asyncWrapper(async (req, res, next) => { 9 | const visibility = req.isAuthenticated ? {} : { isPublic: true }; 10 | 11 | const app = await App.findOne({ 12 | where: { id: req.params.id, ...visibility }, 13 | }); 14 | 15 | if (!app) { 16 | return next( 17 | new ErrorResponse( 18 | `App with the id of ${req.params.id} was not found`, 19 | 404 20 | ) 21 | ); 22 | } 23 | 24 | res.status(200).json({ 25 | success: true, 26 | data: app, 27 | }); 28 | }); 29 | 30 | module.exports = getSingleApp; 31 | -------------------------------------------------------------------------------- /controllers/apps/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | createApp: require('./createApp'), 3 | getSingleApp: require('./getSingleApp'), 4 | deleteApp: require('./deleteApp'), 5 | updateApp: require('./updateApp'), 6 | reorderApps: require('./reorderApps'), 7 | getAllApps: require('./getAllApps'), 8 | }; 9 | -------------------------------------------------------------------------------- /controllers/apps/reorderApps.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const App = require('../../models/App'); 3 | 4 | // @desc Reorder apps 5 | // @route PUT /api/apps/0/reorder 6 | // @access Public 7 | const reorderApps = asyncWrapper(async (req, res, next) => { 8 | req.body.apps.forEach(async ({ id, orderId }) => { 9 | await App.update( 10 | { orderId }, 11 | { 12 | where: { id }, 13 | } 14 | ); 15 | }); 16 | 17 | res.status(200).json({ 18 | success: true, 19 | data: {}, 20 | }); 21 | }); 22 | 23 | module.exports = reorderApps; 24 | -------------------------------------------------------------------------------- /controllers/apps/updateApp.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const App = require('../../models/App'); 3 | 4 | // @desc Update app 5 | // @route PUT /api/apps/:id 6 | // @access Public 7 | const updateApp = asyncWrapper(async (req, res, next) => { 8 | let app = await App.findOne({ 9 | where: { id: req.params.id }, 10 | }); 11 | 12 | if (!app) { 13 | return next( 14 | new ErrorResponse( 15 | `App with the id of ${req.params.id} was not found`, 16 | 404 17 | ) 18 | ); 19 | } 20 | 21 | let body = { ...req.body }; 22 | 23 | if (body.icon) { 24 | body.icon = body.icon.trim(); 25 | } 26 | 27 | if (req.file) { 28 | body.icon = req.file.filename; 29 | } 30 | 31 | app = await app.update(body); 32 | 33 | res.status(200).json({ 34 | success: true, 35 | data: app, 36 | }); 37 | }); 38 | 39 | module.exports = updateApp; 40 | -------------------------------------------------------------------------------- /controllers/auth/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | login: require('./login'), 3 | validate: require('./validate'), 4 | }; 5 | -------------------------------------------------------------------------------- /controllers/auth/login.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const ErrorResponse = require('../../utils/ErrorResponse'); 3 | const signToken = require('../../utils/signToken'); 4 | 5 | // @desc Login user 6 | // @route POST /api/auth/ 7 | // @access Public 8 | const login = asyncWrapper(async (req, res, next) => { 9 | const { password, duration } = req.body; 10 | 11 | const isMatch = process.env.PASSWORD == password; 12 | 13 | if (!isMatch) { 14 | return next(new ErrorResponse('Invalid credentials', 401)); 15 | } 16 | 17 | const token = signToken(duration); 18 | 19 | res.status(200).json({ 20 | success: true, 21 | data: { token }, 22 | }); 23 | }); 24 | 25 | module.exports = login; 26 | -------------------------------------------------------------------------------- /controllers/auth/validate.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const ErrorResponse = require('../../utils/ErrorResponse'); 3 | const jwt = require('jsonwebtoken'); 4 | 5 | // @desc Verify token 6 | // @route POST /api/auth/verify 7 | // @access Public 8 | const validate = asyncWrapper(async (req, res, next) => { 9 | try { 10 | jwt.verify(req.body.token, process.env.SECRET); 11 | 12 | res.status(200).json({ 13 | success: true, 14 | data: { token: { isValid: true } }, 15 | }); 16 | } catch (err) { 17 | return next(new ErrorResponse('Token expired', 401)); 18 | } 19 | }); 20 | 21 | module.exports = validate; 22 | -------------------------------------------------------------------------------- /controllers/bookmarks/createBookmark.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const Bookmark = require('../../models/Bookmark'); 3 | 4 | // @desc Create new bookmark 5 | // @route POST /api/bookmarks 6 | // @access Public 7 | const createBookmark = asyncWrapper(async (req, res, next) => { 8 | let bookmark; 9 | 10 | let body = { 11 | ...req.body, 12 | categoryId: parseInt(req.body.categoryId), 13 | }; 14 | 15 | if (body.icon) { 16 | body.icon = body.icon.trim(); 17 | } 18 | 19 | if (req.file) { 20 | body.icon = req.file.filename; 21 | } 22 | 23 | bookmark = await Bookmark.create(body); 24 | 25 | res.status(201).json({ 26 | success: true, 27 | data: bookmark, 28 | }); 29 | }); 30 | 31 | module.exports = createBookmark; 32 | -------------------------------------------------------------------------------- /controllers/bookmarks/deleteBookmark.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const Bookmark = require('../../models/Bookmark'); 3 | 4 | // @desc Delete bookmark 5 | // @route DELETE /api/bookmarks/:id 6 | // @access Public 7 | const deleteBookmark = asyncWrapper(async (req, res, next) => { 8 | await Bookmark.destroy({ 9 | where: { id: req.params.id }, 10 | }); 11 | 12 | res.status(200).json({ 13 | success: true, 14 | data: {}, 15 | }); 16 | }); 17 | 18 | module.exports = deleteBookmark; 19 | -------------------------------------------------------------------------------- /controllers/bookmarks/getAllBookmarks.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const Bookmark = require('../../models/Bookmark'); 3 | const { Sequelize } = require('sequelize'); 4 | const loadConfig = require('../../utils/loadConfig'); 5 | 6 | // @desc Get all bookmarks 7 | // @route GET /api/bookmarks 8 | // @access Public 9 | const getAllBookmarks = asyncWrapper(async (req, res, next) => { 10 | const { useOrdering: orderType } = await loadConfig(); 11 | 12 | // bookmarks visibility 13 | const where = req.isAuthenticated ? {} : { isPublic: true }; 14 | 15 | const order = 16 | orderType == 'name' 17 | ? [[Sequelize.fn('lower', Sequelize.col('name')), 'ASC']] 18 | : [[orderType, 'ASC']]; 19 | 20 | const bookmarks = await Bookmark.findAll({ 21 | order, 22 | where, 23 | }); 24 | 25 | res.status(200).json({ 26 | success: true, 27 | data: bookmarks, 28 | }); 29 | }); 30 | 31 | module.exports = getAllBookmarks; 32 | -------------------------------------------------------------------------------- /controllers/bookmarks/getSingleBookmark.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const ErrorResponse = require('../../utils/ErrorResponse'); 3 | const Bookmark = require('../../models/Bookmark'); 4 | 5 | // @desc Get single bookmark 6 | // @route GET /api/bookmarks/:id 7 | // @access Public 8 | const getSingleBookmark = asyncWrapper(async (req, res, next) => { 9 | const visibility = req.isAuthenticated ? {} : { isPublic: true }; 10 | 11 | const bookmark = await Bookmark.findOne({ 12 | where: { id: req.params.id, ...visibility }, 13 | }); 14 | 15 | if (!bookmark) { 16 | return next( 17 | new ErrorResponse( 18 | `Bookmark with the id of ${req.params.id} was not found`, 19 | 404 20 | ) 21 | ); 22 | } 23 | 24 | res.status(200).json({ 25 | success: true, 26 | data: bookmark, 27 | }); 28 | }); 29 | 30 | module.exports = getSingleBookmark; 31 | -------------------------------------------------------------------------------- /controllers/bookmarks/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | createBookmark: require('./createBookmark'), 3 | getAllBookmarks: require('./getAllBookmarks'), 4 | getSingleBookmark: require('./getSingleBookmark'), 5 | updateBookmark: require('./updateBookmark'), 6 | deleteBookmark: require('./deleteBookmark'), 7 | reorderBookmarks: require('./reorderBookmarks'), 8 | }; 9 | -------------------------------------------------------------------------------- /controllers/bookmarks/reorderBookmarks.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const Bookmark = require('../../models/Bookmark'); 3 | 4 | // @desc Reorder bookmarks 5 | // @route PUT /api/bookmarks/0/reorder 6 | // @access Public 7 | const reorderBookmarks = asyncWrapper(async (req, res, next) => { 8 | req.body.bookmarks.forEach(async ({ id, orderId }) => { 9 | await Bookmark.update( 10 | { orderId }, 11 | { 12 | where: { id }, 13 | } 14 | ); 15 | }); 16 | 17 | res.status(200).json({ 18 | success: true, 19 | data: {}, 20 | }); 21 | }); 22 | 23 | module.exports = reorderBookmarks; 24 | -------------------------------------------------------------------------------- /controllers/bookmarks/updateBookmark.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const ErrorResponse = require('../../utils/ErrorResponse'); 3 | const Bookmark = require('../../models/Bookmark'); 4 | 5 | // @desc Update bookmark 6 | // @route PUT /api/bookmarks/:id 7 | // @access Public 8 | const updateBookmark = asyncWrapper(async (req, res, next) => { 9 | let bookmark = await Bookmark.findOne({ 10 | where: { id: req.params.id }, 11 | }); 12 | 13 | if (!bookmark) { 14 | return next( 15 | new ErrorResponse( 16 | `Bookmark with id of ${req.params.id} was not found`, 17 | 404 18 | ) 19 | ); 20 | } 21 | 22 | let body = { 23 | ...req.body, 24 | categoryId: parseInt(req.body.categoryId), 25 | }; 26 | 27 | if (body.icon) { 28 | body.icon = body.icon.trim(); 29 | } 30 | 31 | if (req.file) { 32 | body.icon = req.file.filename; 33 | } 34 | 35 | bookmark = await bookmark.update(body); 36 | 37 | res.status(200).json({ 38 | success: true, 39 | data: bookmark, 40 | }); 41 | }); 42 | 43 | module.exports = updateBookmark; 44 | -------------------------------------------------------------------------------- /controllers/categories/createCategory.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const Category = require('../../models/Category'); 3 | const loadConfig = require('../../utils/loadConfig'); 4 | 5 | // @desc Create new category 6 | // @route POST /api/categories 7 | // @access Public 8 | const createCategory = asyncWrapper(async (req, res, next) => { 9 | const { pinCategoriesByDefault: pinCategories } = await loadConfig(); 10 | 11 | const category = await Category.create({ 12 | ...req.body, 13 | isPinned: pinCategories, 14 | }); 15 | 16 | res.status(201).json({ 17 | success: true, 18 | data: category, 19 | }); 20 | }); 21 | 22 | module.exports = createCategory; 23 | -------------------------------------------------------------------------------- /controllers/categories/deleteCategory.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const ErrorResponse = require('../../utils/ErrorResponse'); 3 | const Category = require('../../models/Category'); 4 | const Bookmark = require('../../models/Bookmark'); 5 | 6 | // @desc Delete category 7 | // @route DELETE /api/categories/:id 8 | // @access Public 9 | const deleteCategory = asyncWrapper(async (req, res, next) => { 10 | const category = await Category.findOne({ 11 | where: { id: req.params.id }, 12 | include: [ 13 | { 14 | model: Bookmark, 15 | as: 'bookmarks', 16 | }, 17 | ], 18 | }); 19 | 20 | if (!category) { 21 | return next( 22 | new ErrorResponse( 23 | `Category with id of ${req.params.id} was not found`, 24 | 404 25 | ) 26 | ); 27 | } 28 | 29 | category.bookmarks.forEach(async (bookmark) => { 30 | await Bookmark.destroy({ 31 | where: { id: bookmark.id }, 32 | }); 33 | }); 34 | 35 | await Category.destroy({ 36 | where: { id: req.params.id }, 37 | }); 38 | 39 | res.status(200).json({ 40 | success: true, 41 | data: {}, 42 | }); 43 | }); 44 | 45 | module.exports = deleteCategory; 46 | -------------------------------------------------------------------------------- /controllers/categories/getAllCategories.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const Category = require('../../models/Category'); 3 | const Bookmark = require('../../models/Bookmark'); 4 | const { Sequelize } = require('sequelize'); 5 | const loadConfig = require('../../utils/loadConfig'); 6 | 7 | // @desc Get all categories 8 | // @route GET /api/categories 9 | // @access Public 10 | const getAllCategories = asyncWrapper(async (req, res, next) => { 11 | const { useOrdering: orderType } = await loadConfig(); 12 | 13 | let categories; 14 | let output; 15 | 16 | // categories visibility 17 | const where = req.isAuthenticated ? {} : { isPublic: true }; 18 | 19 | const order = 20 | orderType == 'name' 21 | ? [ 22 | [Sequelize.fn('lower', Sequelize.col('Category.name')), 'ASC'], 23 | [Sequelize.fn('lower', Sequelize.col('bookmarks.name')), 'ASC'], 24 | ] 25 | : [ 26 | [orderType, 'ASC'], 27 | [{ model: Bookmark, as: 'bookmarks' }, orderType, 'ASC'], 28 | ]; 29 | 30 | categories = categories = await Category.findAll({ 31 | include: [ 32 | { 33 | model: Bookmark, 34 | as: 'bookmarks', 35 | }, 36 | ], 37 | order, 38 | where, 39 | }); 40 | 41 | if (req.isAuthenticated) { 42 | output = categories; 43 | } else { 44 | // filter out private bookmarks 45 | output = categories.map((c) => c.get({ plain: true })); 46 | output = output.map((c) => ({ 47 | ...c, 48 | bookmarks: c.bookmarks.filter((b) => b.isPublic), 49 | })); 50 | } 51 | 52 | res.status(200).json({ 53 | success: true, 54 | data: output, 55 | }); 56 | }); 57 | 58 | module.exports = getAllCategories; 59 | -------------------------------------------------------------------------------- /controllers/categories/getSingleCategory.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const ErrorResponse = require('../../utils/ErrorResponse'); 3 | const Category = require('../../models/Category'); 4 | const Bookmark = require('../../models/Bookmark'); 5 | const { Sequelize } = require('sequelize'); 6 | const loadConfig = require('../../utils/loadConfig'); 7 | 8 | // @desc Get single category 9 | // @route GET /api/categories/:id 10 | // @access Public 11 | const getSingleCategory = asyncWrapper(async (req, res, next) => { 12 | const { useOrdering: orderType } = await loadConfig(); 13 | 14 | const visibility = req.isAuthenticated ? {} : { isPublic: true }; 15 | 16 | const order = 17 | orderType == 'name' 18 | ? [[Sequelize.fn('lower', Sequelize.col('bookmarks.name')), 'ASC']] 19 | : [[{ model: Bookmark, as: 'bookmarks' }, orderType, 'ASC']]; 20 | 21 | const category = await Category.findOne({ 22 | where: { id: req.params.id, ...visibility }, 23 | include: [ 24 | { 25 | model: Bookmark, 26 | as: 'bookmarks', 27 | where: visibility, 28 | }, 29 | ], 30 | order, 31 | }); 32 | 33 | if (!category) { 34 | return next( 35 | new ErrorResponse( 36 | `Category with id of ${req.params.id} was not found`, 37 | 404 38 | ) 39 | ); 40 | } 41 | 42 | res.status(200).json({ 43 | success: true, 44 | data: category, 45 | }); 46 | }); 47 | 48 | module.exports = getSingleCategory; 49 | -------------------------------------------------------------------------------- /controllers/categories/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | createCategory: require('./createCategory'), 3 | getAllCategories: require('./getAllCategories'), 4 | getSingleCategory: require('./getSingleCategory'), 5 | updateCategory: require('./updateCategory'), 6 | deleteCategory: require('./deleteCategory'), 7 | reorderCategories: require('./reorderCategories'), 8 | }; 9 | -------------------------------------------------------------------------------- /controllers/categories/reorderCategories.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const Category = require('../../models/Category'); 3 | 4 | // @desc Reorder categories 5 | // @route PUT /api/categories/0/reorder 6 | // @access Public 7 | const reorderCategories = asyncWrapper(async (req, res, next) => { 8 | req.body.categories.forEach(async ({ id, orderId }) => { 9 | await Category.update( 10 | { orderId }, 11 | { 12 | where: { id }, 13 | } 14 | ); 15 | }); 16 | 17 | res.status(200).json({ 18 | success: true, 19 | data: {}, 20 | }); 21 | }); 22 | 23 | module.exports = reorderCategories; 24 | -------------------------------------------------------------------------------- /controllers/categories/updateCategory.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const ErrorResponse = require('../../utils/ErrorResponse'); 3 | const Category = require('../../models/Category'); 4 | 5 | // @desc Update category 6 | // @route PUT /api/categories/:id 7 | // @access Public 8 | const updateCategory = asyncWrapper(async (req, res, next) => { 9 | let category = await Category.findOne({ 10 | where: { id: req.params.id }, 11 | }); 12 | 13 | if (!category) { 14 | return next( 15 | new ErrorResponse( 16 | `Category with id of ${req.params.id} was not found`, 17 | 404 18 | ) 19 | ); 20 | } 21 | 22 | category = await category.update({ ...req.body }); 23 | 24 | res.status(200).json({ 25 | success: true, 26 | data: category, 27 | }); 28 | }); 29 | 30 | module.exports = updateCategory; 31 | -------------------------------------------------------------------------------- /controllers/config/getCSS.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const File = require('../../utils/File'); 3 | const { join } = require('path'); 4 | 5 | // @desc Get custom CSS file 6 | // @route GET /api/config/0/css 7 | // @access Public 8 | const getCSS = asyncWrapper(async (req, res, next) => { 9 | const file = new File(join(__dirname, '../../public/flame.css')); 10 | const content = file.read(); 11 | 12 | res.status(200).json({ 13 | success: true, 14 | data: content, 15 | }); 16 | }); 17 | 18 | module.exports = getCSS; 19 | -------------------------------------------------------------------------------- /controllers/config/getConfig.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const loadConfig = require('../../utils/loadConfig'); 3 | 4 | // @desc Get config 5 | // @route GET /api/config 6 | // @access Public 7 | const getConfig = asyncWrapper(async (req, res, next) => { 8 | const config = await loadConfig(); 9 | 10 | res.status(200).json({ 11 | success: true, 12 | data: config, 13 | }); 14 | }); 15 | 16 | module.exports = getConfig; 17 | -------------------------------------------------------------------------------- /controllers/config/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | getCSS: require('./getCSS'), 3 | updateCSS: require('./updateCSS'), 4 | getConfig: require('./getConfig'), 5 | updateConfig: require('./updateConfig'), 6 | }; 7 | -------------------------------------------------------------------------------- /controllers/config/updateCSS.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const File = require('../../utils/File'); 3 | const { join } = require('path'); 4 | const fs = require('fs'); 5 | 6 | // @desc Update custom CSS file 7 | // @route PUT /api/config/0/css 8 | // @access Public 9 | const updateCSS = asyncWrapper(async (req, res, next) => { 10 | const file = new File(join(__dirname, '../../public/flame.css')); 11 | file.write(req.body.styles, false); 12 | 13 | // Copy file to docker volume 14 | fs.copyFileSync( 15 | join(__dirname, '../../public/flame.css'), 16 | join(__dirname, '../../data/flame.css') 17 | ); 18 | 19 | res.status(200).json({ 20 | success: true, 21 | data: {}, 22 | }); 23 | }); 24 | 25 | module.exports = updateCSS; 26 | -------------------------------------------------------------------------------- /controllers/config/updateConfig.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const loadConfig = require('../../utils/loadConfig'); 3 | const { writeFile } = require('fs/promises'); 4 | 5 | // @desc Update config 6 | // @route PUT /api/config/ 7 | // @access Public 8 | const updateConfig = asyncWrapper(async (req, res, next) => { 9 | const existingConfig = await loadConfig(); 10 | 11 | const newConfig = { 12 | ...existingConfig, 13 | ...req.body, 14 | }; 15 | 16 | await writeFile('data/config.json', JSON.stringify(newConfig)); 17 | 18 | res.status(200).send({ 19 | success: true, 20 | data: newConfig, 21 | }); 22 | }); 23 | 24 | module.exports = updateConfig; 25 | -------------------------------------------------------------------------------- /controllers/queries/addQuery.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const ErrorResponse = require('../../utils/ErrorResponse'); 3 | const File = require('../../utils/File'); 4 | 5 | // @desc Add custom search query 6 | // @route POST /api/queries 7 | // @access Public 8 | const addQuery = asyncWrapper(async (req, res, next) => { 9 | const file = new File('data/customQueries.json'); 10 | let content = JSON.parse(file.read()); 11 | 12 | const prefixes = content.queries.map((q) => q.prefix); 13 | 14 | if (prefixes.includes(req.body.prefix)) { 15 | return next(new ErrorResponse('Prefix must be unique', 400)); 16 | } 17 | 18 | // Add new query 19 | content.queries.push(req.body); 20 | file.write(content, true); 21 | 22 | res.status(201).json({ 23 | success: true, 24 | data: req.body, 25 | }); 26 | }); 27 | 28 | module.exports = addQuery; 29 | -------------------------------------------------------------------------------- /controllers/queries/deleteQuery.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const File = require('../../utils/File'); 3 | 4 | // @desc Delete query 5 | // @route DELETE /api/queries/:prefix 6 | // @access Public 7 | const deleteQuery = asyncWrapper(async (req, res, next) => { 8 | const file = new File('data/customQueries.json'); 9 | let content = JSON.parse(file.read()); 10 | 11 | content.queries = content.queries.filter( 12 | (q) => q.prefix != req.params.prefix 13 | ); 14 | file.write(content, true); 15 | 16 | res.status(200).json({ 17 | success: true, 18 | data: content.queries, 19 | }); 20 | }); 21 | 22 | module.exports = deleteQuery; 23 | -------------------------------------------------------------------------------- /controllers/queries/getQueries.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const File = require('../../utils/File'); 3 | 4 | // @desc Get custom queries file 5 | // @route GET /api/queries 6 | // @access Public 7 | const getQueries = asyncWrapper(async (req, res, next) => { 8 | const file = new File('data/customQueries.json'); 9 | const content = JSON.parse(file.read()); 10 | 11 | res.status(200).json({ 12 | success: true, 13 | data: content.queries, 14 | }); 15 | }); 16 | 17 | module.exports = getQueries; 18 | -------------------------------------------------------------------------------- /controllers/queries/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | addQuery: require('./addQuery'), 3 | getQueries: require('./getQueries'), 4 | updateQuery: require('./updateQuery'), 5 | deleteQuery: require('./deleteQuery'), 6 | }; 7 | -------------------------------------------------------------------------------- /controllers/queries/updateQuery.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const File = require('../../utils/File'); 3 | 4 | // @desc Update query 5 | // @route PUT /api/queries/:prefix 6 | // @access Public 7 | const updateQuery = asyncWrapper(async (req, res, next) => { 8 | const file = new File('data/customQueries.json'); 9 | let content = JSON.parse(file.read()); 10 | 11 | let queryIdx = content.queries.findIndex( 12 | (q) => q.prefix == req.params.prefix 13 | ); 14 | 15 | // query found 16 | if (queryIdx > -1) { 17 | content.queries = [ 18 | ...content.queries.slice(0, queryIdx), 19 | req.body, 20 | ...content.queries.slice(queryIdx + 1), 21 | ]; 22 | } 23 | 24 | file.write(content, true); 25 | 26 | res.status(200).json({ 27 | success: true, 28 | data: content.queries, 29 | }); 30 | }); 31 | 32 | module.exports = updateQuery; 33 | -------------------------------------------------------------------------------- /controllers/themes/addTheme.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const ErrorResponse = require('../../utils/ErrorResponse'); 3 | const File = require('../../utils/File'); 4 | 5 | // @desc Create new theme 6 | // @route POST /api/themes 7 | // @access Private 8 | const addTheme = asyncWrapper(async (req, res, next) => { 9 | const file = new File('data/themes.json'); 10 | let content = JSON.parse(file.read()); 11 | 12 | const themeNames = content.themes.map((t) => t.name); 13 | 14 | if (themeNames.includes(req.body.name)) { 15 | return next(new ErrorResponse('Name must be unique', 400)); 16 | } 17 | 18 | // Add new theme 19 | content.themes.push(req.body); 20 | file.write(content, true); 21 | 22 | res.status(201).json({ 23 | success: true, 24 | data: req.body, 25 | }); 26 | }); 27 | 28 | module.exports = addTheme; 29 | -------------------------------------------------------------------------------- /controllers/themes/deleteTheme.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const File = require('../../utils/File'); 3 | 4 | // @desc Delete theme 5 | // @route DELETE /api/themes/:name 6 | // @access Public 7 | const deleteTheme = asyncWrapper(async (req, res, next) => { 8 | const file = new File('data/themes.json'); 9 | let content = JSON.parse(file.read()); 10 | 11 | content.themes = content.themes.filter((t) => t.name != req.params.name); 12 | file.write(content, true); 13 | 14 | const userThemes = content.themes.filter((t) => t.isCustom); 15 | 16 | res.status(200).json({ 17 | success: true, 18 | data: userThemes, 19 | }); 20 | }); 21 | 22 | module.exports = deleteTheme; 23 | -------------------------------------------------------------------------------- /controllers/themes/getThemes.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const File = require('../../utils/File'); 3 | 4 | // @desc Get themes file 5 | // @route GET /api/themes 6 | // @access Public 7 | const getThemes = asyncWrapper(async (req, res, next) => { 8 | const file = new File('data/themes.json'); 9 | const content = JSON.parse(file.read()); 10 | 11 | res.status(200).json({ 12 | success: true, 13 | data: content.themes, 14 | }); 15 | }); 16 | 17 | module.exports = getThemes; 18 | -------------------------------------------------------------------------------- /controllers/themes/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | getThemes: require('./getThemes'), 3 | addTheme: require('./addTheme'), 4 | deleteTheme: require('./deleteTheme'), 5 | updateTheme: require('./updateTheme'), 6 | }; 7 | -------------------------------------------------------------------------------- /controllers/themes/updateTheme.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const File = require('../../utils/File'); 3 | 4 | // @desc Update theme 5 | // @route PUT /api/themes/:name 6 | // @access Public 7 | const updateTheme = asyncWrapper(async (req, res, next) => { 8 | const file = new File('data/themes.json'); 9 | let content = JSON.parse(file.read()); 10 | 11 | let themeIdx = content.themes.findIndex((t) => t.name == req.params.name); 12 | 13 | // theme found 14 | if (themeIdx > -1) { 15 | content.themes = [ 16 | ...content.themes.slice(0, themeIdx), 17 | req.body, 18 | ...content.themes.slice(themeIdx + 1), 19 | ]; 20 | } 21 | 22 | file.write(content, true); 23 | 24 | const userThemes = content.themes.filter((t) => t.isCustom); 25 | 26 | res.status(200).json({ 27 | success: true, 28 | data: userThemes, 29 | }); 30 | }); 31 | 32 | module.exports = updateTheme; 33 | -------------------------------------------------------------------------------- /controllers/weather/getWather.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const Weather = require('../../models/Weather'); 3 | 4 | // @desc Get latest weather status 5 | // @route GET /api/weather 6 | // @access Public 7 | const getWeather = asyncWrapper(async (req, res, next) => { 8 | const weather = await Weather.findAll({ 9 | order: [['createdAt', 'DESC']], 10 | limit: 1, 11 | }); 12 | 13 | res.status(200).json({ 14 | success: true, 15 | data: weather, 16 | }); 17 | }); 18 | 19 | module.exports = getWeather; 20 | -------------------------------------------------------------------------------- /controllers/weather/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | getWeather: require('./getWather'), 3 | updateWeather: require('./updateWeather'), 4 | }; 5 | -------------------------------------------------------------------------------- /controllers/weather/updateWeather.js: -------------------------------------------------------------------------------- 1 | const asyncWrapper = require('../../middleware/asyncWrapper'); 2 | const getExternalWeather = require('../../utils/getExternalWeather'); 3 | 4 | // @desc Update weather 5 | // @route GET /api/weather/update 6 | // @access Public 7 | const updateWeather = asyncWrapper(async (req, res, next) => { 8 | const weather = await getExternalWeather(); 9 | 10 | res.status(200).json({ 11 | success: true, 12 | data: weather, 13 | }); 14 | }); 15 | 16 | module.exports = updateWeather; 17 | -------------------------------------------------------------------------------- /db/index.js: -------------------------------------------------------------------------------- 1 | const { Sequelize } = require('sequelize'); 2 | const { join } = require('path'); 3 | const Umzug = require('umzug'); 4 | 5 | // Utils 6 | const backupDB = require('./utils/backupDb'); 7 | const Logger = require('../utils/Logger'); 8 | const logger = new Logger(); 9 | 10 | const sequelize = new Sequelize({ 11 | dialect: 'sqlite', 12 | storage: './data/db.sqlite', 13 | logging: false, 14 | }); 15 | 16 | const umzug = new Umzug({ 17 | migrations: { 18 | path: join(__dirname, './migrations'), 19 | params: [sequelize.getQueryInterface()], 20 | }, 21 | storage: 'sequelize', 22 | storageOptions: { 23 | sequelize, 24 | }, 25 | }); 26 | 27 | const connectDB = async () => { 28 | try { 29 | backupDB(); 30 | 31 | await sequelize.authenticate(); 32 | 33 | // execute all pending migrations 34 | const pendingMigrations = await umzug.pending(); 35 | 36 | if (pendingMigrations.length > 0) { 37 | logger.log('Executing pending migrations'); 38 | await umzug.up(); 39 | } 40 | 41 | logger.log('Connected to database'); 42 | } catch (error) { 43 | logger.log(`Unable to connect to the database: ${error.message}`, 'ERROR'); 44 | process.exit(1); 45 | } 46 | }; 47 | 48 | module.exports = { 49 | connectDB, 50 | sequelize, 51 | }; 52 | -------------------------------------------------------------------------------- /db/migrations/01_new-config.js: -------------------------------------------------------------------------------- 1 | const { readFile, writeFile, copyFile } = require('fs/promises'); 2 | const Config = require('../../models/Config'); 3 | 4 | const up = async (query) => { 5 | await copyFile('utils/init/initialConfig.json', 'data/config.json'); 6 | 7 | const initConfigFile = await readFile('data/config.json', 'utf-8'); 8 | const parsedNewConfig = JSON.parse(initConfigFile); 9 | 10 | const existingConfig = await Config.findAll({ raw: true }); 11 | 12 | for (let pair of existingConfig) { 13 | const { key, value, valueType } = pair; 14 | 15 | let newValue = value; 16 | 17 | if (valueType == 'number') { 18 | newValue = parseFloat(value); 19 | } else if (valueType == 'boolean') { 20 | newValue = value == 1; 21 | } 22 | 23 | parsedNewConfig[key] = newValue; 24 | } 25 | 26 | const newConfig = JSON.stringify(parsedNewConfig); 27 | await writeFile('data/config.json', newConfig); 28 | 29 | await query.dropTable('config'); 30 | }; 31 | 32 | const down = async (query) => {}; 33 | 34 | module.exports = { 35 | up, 36 | down, 37 | }; 38 | -------------------------------------------------------------------------------- /db/migrations/02_resource-access.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { INTEGER } = DataTypes; 3 | 4 | const tables = ['categories', 'bookmarks', 'apps']; 5 | 6 | const up = async (query) => { 7 | const template = { 8 | type: INTEGER, 9 | allowNull: true, 10 | defaultValue: 1, 11 | }; 12 | 13 | for await (let table of tables) { 14 | await query.addColumn(table, 'isPublic', template); 15 | } 16 | }; 17 | 18 | const down = async (query) => { 19 | for await (let table of tables) { 20 | await query.removeColumn(table, 'isPublic'); 21 | } 22 | }; 23 | 24 | module.exports = { 25 | up, 26 | down, 27 | }; 28 | -------------------------------------------------------------------------------- /db/migrations/03_weather.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { INTEGER, FLOAT } = DataTypes; 3 | 4 | const up = async (query) => { 5 | await query.addColumn('weather', 'humidity', { 6 | type: INTEGER, 7 | }); 8 | 9 | await query.addColumn('weather', 'windK', { 10 | type: FLOAT, 11 | }); 12 | 13 | await query.addColumn('weather', 'windM', { 14 | type: FLOAT, 15 | }); 16 | }; 17 | 18 | const down = async (query) => { 19 | await query.removeColumn('weather', 'humidity'); 20 | await query.removeColumn('weather', 'windK'); 21 | await query.removeColumn('weather', 'windM'); 22 | }; 23 | 24 | module.exports = { 25 | up, 26 | down, 27 | }; 28 | -------------------------------------------------------------------------------- /db/migrations/04_bookmarks-order.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { INTEGER } = DataTypes; 3 | 4 | const up = async (query) => { 5 | await query.addColumn('bookmarks', 'orderId', { 6 | type: INTEGER, 7 | allowNull: true, 8 | defaultValue: null, 9 | }); 10 | }; 11 | 12 | const down = async (query) => { 13 | await query.removeColumn('bookmarks', 'orderId'); 14 | }; 15 | 16 | module.exports = { 17 | up, 18 | down, 19 | }; 20 | -------------------------------------------------------------------------------- /db/migrations/05_app-description.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { STRING } = DataTypes; 3 | 4 | const up = async (query) => { 5 | await query.addColumn('apps', 'description', { 6 | type: STRING, 7 | allowNull: false, 8 | defaultValue: '', 9 | }); 10 | }; 11 | 12 | const down = async (query) => { 13 | await query.removeColumn('apps', 'description'); 14 | }; 15 | 16 | module.exports = { 17 | up, 18 | down, 19 | }; 20 | -------------------------------------------------------------------------------- /db/utils/backupDb.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const { slugify } = require('./slugify'); 3 | 4 | const backupDB = () => { 5 | if (!fs.existsSync('data/db_backups')) { 6 | fs.mkdirSync('data/db_backups'); 7 | } 8 | 9 | const slug = slugify(); 10 | 11 | const srcPath = 'data/db.sqlite'; 12 | const destPath = `data/db_backups/${slug}`; 13 | 14 | if (fs.existsSync(srcPath)) { 15 | if (!fs.existsSync(destPath)) { 16 | fs.copyFileSync(srcPath, destPath); 17 | } 18 | } 19 | }; 20 | 21 | module.exports = backupDB; 22 | -------------------------------------------------------------------------------- /db/utils/slugify.js: -------------------------------------------------------------------------------- 1 | const slugify = () => { 2 | const version = process.env.VERSION; 3 | const slug = `db-${version.replace(/\./g, '')}-backup.sqlite`; 4 | return slug; 5 | }; 6 | 7 | const parseSlug = (slug) => { 8 | const parts = slug.split('-'); 9 | const version = { 10 | raw: parts[1], 11 | parsed: parts[1].split('').join('.'), 12 | }; 13 | return version; 14 | }; 15 | 16 | module.exports = { 17 | slugify, 18 | parseSlug, 19 | }; 20 | -------------------------------------------------------------------------------- /k8s/base/deployment.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | kind: Deployment 3 | apiVersion: apps/v1 4 | metadata: 5 | name: flame 6 | spec: 7 | selector: 8 | matchLabels: 9 | app: flame 10 | template: 11 | metadata: 12 | labels: 13 | app: flame 14 | spec: 15 | serviceAccountName: flame 16 | securityContext: 17 | fsGroup: 1000 18 | containers: 19 | - name: flame 20 | image: shokohsc/flame 21 | ports: 22 | - name: http 23 | containerPort: 5005 24 | protocol: TCP 25 | readinessProbe: 26 | httpGet: 27 | path: / 28 | port: http 29 | -------------------------------------------------------------------------------- /k8s/base/ingress.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: networking.k8s.io/v1 3 | kind: Ingress 4 | metadata: 5 | name: flame 6 | spec: 7 | rules: 8 | - host: flame.cluster.local 9 | http: 10 | paths: 11 | - path: / 12 | pathType: Prefix 13 | backend: 14 | service: 15 | name: flame 16 | port: 17 | number: 80 18 | -------------------------------------------------------------------------------- /k8s/base/kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | namespace: flame 4 | resources: 5 | - namespace.yaml 6 | - deployment.yaml 7 | - service.yaml 8 | - ingress.yaml 9 | - rbac.yaml 10 | -------------------------------------------------------------------------------- /k8s/base/namespace.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Namespace 4 | metadata: 5 | name: flame 6 | labels: 7 | namespace: flame 8 | goldilocks.fairwinds.com/enabled: "true" 9 | -------------------------------------------------------------------------------- /k8s/base/rbac.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: flame 6 | --- 7 | kind: ClusterRole 8 | apiVersion: rbac.authorization.k8s.io/v1 9 | metadata: 10 | name: flame 11 | rules: 12 | - apiGroups: ["networking.k8s.io"] 13 | resources: ["ingresses"] 14 | verbs: ["get", "list", "watch"] 15 | --- 16 | apiVersion: rbac.authorization.k8s.io/v1 17 | kind: ClusterRoleBinding 18 | metadata: 19 | name: flame 20 | subjects: 21 | - kind: ServiceAccount 22 | name: flame 23 | roleRef: 24 | apiGroup: rbac.authorization.k8s.io 25 | kind: ClusterRole 26 | name: flame 27 | -------------------------------------------------------------------------------- /k8s/base/service.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | name: flame 6 | labels: 7 | app: flame 8 | spec: 9 | type: ClusterIP 10 | ports: 11 | - port: 80 12 | targetPort: http 13 | protocol: TCP 14 | name: http 15 | selector: 16 | app: flame 17 | -------------------------------------------------------------------------------- /k8s/overlays/shokohsc/deployment.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | kind: Deployment 3 | apiVersion: apps/v1 4 | metadata: 5 | name: flame 6 | spec: 7 | selector: 8 | matchLabels: 9 | app: flame 10 | template: 11 | metadata: 12 | labels: 13 | app: flame 14 | spec: 15 | serviceAccountName: flame-dev 16 | securityContext: 17 | fsGroup: 1000 18 | containers: 19 | - name: flame 20 | image: shokohsc/flame 21 | command: 22 | - npm 23 | args: 24 | - run 25 | - skaffold 26 | env: 27 | - name: NODE_ENV 28 | value: development 29 | ports: 30 | - name: http 31 | containerPort: 5005 32 | protocol: TCP 33 | readinessProbe: 34 | httpGet: 35 | path: / 36 | port: http 37 | -------------------------------------------------------------------------------- /k8s/overlays/shokohsc/ingress.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: networking.k8s.io/v1 3 | kind: Ingress 4 | metadata: 5 | name: flame 6 | annotations: 7 | kubernetes.io/ingress.class: nginx 8 | cert-manager.io/cluster-issuer: ca-cluster-issuer 9 | flame.pawelmalak/name: flame 10 | flame.pawelmalak/url: dev.flame.shokohsc.home 11 | flame.pawelmalak/type: app 12 | flame.pawelmalak/icon: fire 13 | spec: 14 | rules: 15 | - host: dev.flame.shokohsc.home 16 | http: 17 | paths: 18 | - path: / 19 | pathType: Prefix 20 | backend: 21 | service: 22 | name: flame 23 | port: 24 | number: 80 25 | tls: 26 | - hosts: 27 | - dev.flame.shokohsc.home 28 | secretName: flame-cert 29 | -------------------------------------------------------------------------------- /k8s/overlays/shokohsc/kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | namespace: flame-dev 4 | resources: 5 | - namespace.yaml 6 | - deployment.yaml 7 | - service.yaml 8 | - ingress.yaml 9 | - rbac.yaml 10 | -------------------------------------------------------------------------------- /k8s/overlays/shokohsc/namespace.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Namespace 4 | metadata: 5 | name: flame-dev 6 | labels: 7 | namespace: flame-dev 8 | goldilocks.fairwinds.com/enabled: "true" 9 | -------------------------------------------------------------------------------- /k8s/overlays/shokohsc/rbac.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: flame-dev 6 | --- 7 | kind: ClusterRole 8 | apiVersion: rbac.authorization.k8s.io/v1 9 | metadata: 10 | name: flame-dev 11 | rules: 12 | - apiGroups: ["networking.k8s.io"] 13 | resources: ["ingresses"] 14 | verbs: ["get", "list", "watch"] 15 | --- 16 | apiVersion: rbac.authorization.k8s.io/v1 17 | kind: ClusterRoleBinding 18 | metadata: 19 | name: flame-dev 20 | subjects: 21 | - kind: ServiceAccount 22 | name: flame-dev 23 | roleRef: 24 | apiGroup: rbac.authorization.k8s.io 25 | kind: ClusterRole 26 | name: flame-dev 27 | -------------------------------------------------------------------------------- /k8s/overlays/shokohsc/service.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | name: flame 6 | labels: 7 | app: flame 8 | spec: 9 | type: ClusterIP 10 | ports: 11 | - port: 80 12 | targetPort: http 13 | protocol: TCP 14 | name: http 15 | selector: 16 | app: flame 17 | -------------------------------------------------------------------------------- /middleware/asyncWrapper.js: -------------------------------------------------------------------------------- 1 | function asyncWrapper(foo) { 2 | return function (req, res, next) { 3 | return Promise.resolve(foo(req, res, next)).catch(next); 4 | }; 5 | } 6 | 7 | module.exports = asyncWrapper; 8 | -------------------------------------------------------------------------------- /middleware/auth.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | 3 | const auth = (req, res, next) => { 4 | const authHeader = req.header('Authorization-Flame'); 5 | let token; 6 | let tokenIsValid = false; 7 | 8 | if (authHeader && authHeader.startsWith('Bearer ')) { 9 | token = authHeader.split(' ')[1]; 10 | } 11 | 12 | if (token) { 13 | try { 14 | jwt.verify(token, process.env.SECRET); 15 | } finally { 16 | tokenIsValid = true; 17 | } 18 | } 19 | 20 | req.isAuthenticated = tokenIsValid; 21 | 22 | next(); 23 | }; 24 | 25 | module.exports = auth; 26 | -------------------------------------------------------------------------------- /middleware/errorHandler.js: -------------------------------------------------------------------------------- 1 | const ErrorResponse = require('../utils/ErrorResponse'); 2 | const Logger = require('../utils/Logger'); 3 | const logger = new Logger(); 4 | 5 | const errorHandler = (err, req, res, next) => { 6 | let error = { ...err }; 7 | error.message = err.message; 8 | 9 | // if (error.errors[0].type === 'unique violation') { 10 | // const msg = error.errors[0].message; 11 | // error = new ErrorResponse(`Field ${msg}`, 400); 12 | // } 13 | 14 | logger.log(error.message.split(',')[0], 'ERROR'); 15 | 16 | if (process.env.NODE_ENV == 'development') { 17 | console.log(err); 18 | } 19 | 20 | res.status(err.statusCode || 500).json({ 21 | success: false, 22 | error: error.message || 'Server Error', 23 | }); 24 | }; 25 | 26 | module.exports = errorHandler; 27 | -------------------------------------------------------------------------------- /middleware/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | asyncWrapper: require('./asyncWrapper'), 3 | auth: require('./auth'), 4 | errorHandler: require('./errorHandler'), 5 | upload: require('./multer'), 6 | requireAuth: require('./requireAuth'), 7 | requireBody: require('./requireBody'), 8 | }; 9 | -------------------------------------------------------------------------------- /middleware/multer.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const multer = require('multer'); 3 | 4 | if (!fs.existsSync('data/uploads')) { 5 | fs.mkdirSync('data/uploads', { recursive: true }); 6 | } 7 | 8 | const storage = multer.diskStorage({ 9 | destination: (req, file, cb) => { 10 | cb(null, './data/uploads'); 11 | }, 12 | filename: (req, file, cb) => { 13 | cb(null, Date.now() + '--' + file.originalname); 14 | }, 15 | }); 16 | 17 | const supportedTypes = ['jpg', 'jpeg', 'png', 'svg', 'svg+xml', 'x-icon']; 18 | 19 | const fileFilter = (req, file, cb) => { 20 | if (supportedTypes.includes(file.mimetype.split('/')[1])) { 21 | cb(null, true); 22 | } else { 23 | cb(null, false); 24 | } 25 | }; 26 | 27 | const upload = multer({ storage, fileFilter }); 28 | 29 | module.exports = upload.single('icon'); 30 | -------------------------------------------------------------------------------- /middleware/requireAuth.js: -------------------------------------------------------------------------------- 1 | const ErrorResponse = require('../utils/ErrorResponse'); 2 | 3 | const requireAuth = (req, res, next) => { 4 | if (!req.isAuthenticated) { 5 | return next(new ErrorResponse('Unauthorized', 401)); 6 | } 7 | 8 | next(); 9 | }; 10 | 11 | module.exports = requireAuth; 12 | -------------------------------------------------------------------------------- /middleware/requireBody.js: -------------------------------------------------------------------------------- 1 | const ErrorResponse = require('../utils/ErrorResponse'); 2 | 3 | const requireBody = (keys) => (req, res, next) => { 4 | const missing = keys.filter((key) => !Object.keys(req.body).includes(key)); 5 | 6 | if (missing.length) { 7 | return next(new ErrorResponse(`'${missing[0]}' is required`, 400)); 8 | } 9 | 10 | next(); 11 | }; 12 | 13 | module.exports = requireBody; 14 | -------------------------------------------------------------------------------- /models/App.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { sequelize } = require('../db'); 3 | 4 | const App = sequelize.define( 5 | 'App', 6 | { 7 | name: { 8 | type: DataTypes.STRING, 9 | allowNull: false, 10 | }, 11 | url: { 12 | type: DataTypes.STRING, 13 | allowNull: false, 14 | }, 15 | icon: { 16 | type: DataTypes.STRING, 17 | allowNull: false, 18 | defaultValue: 'cancel', 19 | }, 20 | isPinned: { 21 | type: DataTypes.BOOLEAN, 22 | defaultValue: false, 23 | }, 24 | orderId: { 25 | type: DataTypes.INTEGER, 26 | allowNull: true, 27 | defaultValue: null, 28 | }, 29 | isPublic: { 30 | type: DataTypes.INTEGER, 31 | allowNull: true, 32 | defaultValue: 1, 33 | }, 34 | description: { 35 | type: DataTypes.STRING, 36 | allowNull: false, 37 | defaultValue: '', 38 | }, 39 | }, 40 | { 41 | tableName: 'apps', 42 | } 43 | ); 44 | 45 | module.exports = App; 46 | -------------------------------------------------------------------------------- /models/Bookmark.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { sequelize } = require('../db'); 3 | 4 | const Bookmark = sequelize.define( 5 | 'Bookmark', 6 | { 7 | name: { 8 | type: DataTypes.STRING, 9 | allowNull: false, 10 | }, 11 | url: { 12 | type: DataTypes.STRING, 13 | allowNull: false, 14 | }, 15 | categoryId: { 16 | type: DataTypes.INTEGER, 17 | allowNull: false, 18 | }, 19 | icon: { 20 | type: DataTypes.STRING, 21 | defaultValue: '', 22 | }, 23 | isPublic: { 24 | type: DataTypes.INTEGER, 25 | allowNull: true, 26 | defaultValue: 1, 27 | }, 28 | orderId: { 29 | type: DataTypes.INTEGER, 30 | allowNull: true, 31 | defaultValue: null, 32 | }, 33 | }, 34 | { 35 | tableName: 'bookmarks', 36 | } 37 | ); 38 | 39 | module.exports = Bookmark; 40 | -------------------------------------------------------------------------------- /models/Category.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { sequelize } = require('../db'); 3 | 4 | const Category = sequelize.define( 5 | 'Category', 6 | { 7 | name: { 8 | type: DataTypes.STRING, 9 | allowNull: false, 10 | }, 11 | isPinned: { 12 | type: DataTypes.BOOLEAN, 13 | defaultValue: false, 14 | }, 15 | orderId: { 16 | type: DataTypes.INTEGER, 17 | allowNull: true, 18 | defaultValue: null, 19 | }, 20 | isPublic: { 21 | type: DataTypes.INTEGER, 22 | allowNull: true, 23 | defaultValue: 1, 24 | }, 25 | }, 26 | { 27 | tableName: 'categories', 28 | } 29 | ); 30 | 31 | module.exports = Category; 32 | -------------------------------------------------------------------------------- /models/Config.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { sequelize } = require('../db'); 3 | 4 | const Config = sequelize.define( 5 | 'Config', 6 | { 7 | key: { 8 | type: DataTypes.STRING, 9 | allowNull: false, 10 | unique: true, 11 | }, 12 | value: { 13 | type: DataTypes.STRING, 14 | allowNull: false, 15 | }, 16 | valueType: { 17 | type: DataTypes.STRING, 18 | allowNull: false, 19 | }, 20 | isLocked: { 21 | type: DataTypes.TINYINT, 22 | defaultValue: 0, 23 | }, 24 | }, 25 | { 26 | tableName: 'config', 27 | } 28 | ); 29 | 30 | module.exports = Config; 31 | -------------------------------------------------------------------------------- /models/Weather.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { sequelize } = require('../db'); 3 | 4 | const Weather = sequelize.define( 5 | 'Weather', 6 | { 7 | externalLastUpdate: DataTypes.STRING, 8 | tempC: DataTypes.FLOAT, 9 | tempF: DataTypes.FLOAT, 10 | isDay: DataTypes.INTEGER, 11 | cloud: DataTypes.INTEGER, 12 | conditionText: DataTypes.TEXT, 13 | conditionCode: DataTypes.INTEGER, 14 | humidity: DataTypes.INTEGER, 15 | windK: DataTypes.FLOAT, 16 | windM: DataTypes.FLOAT, 17 | }, 18 | { 19 | tableName: 'weather', 20 | } 21 | ); 22 | 23 | module.exports = Weather; 24 | -------------------------------------------------------------------------------- /models/associateModels.js: -------------------------------------------------------------------------------- 1 | const Category = require('./Category'); 2 | const Bookmark = require('./Bookmark'); 3 | 4 | const associateModels = () => { 5 | Category.hasMany(Bookmark, { 6 | foreignKey: 'categoryId', 7 | as: 'bookmarks' 8 | }); 9 | 10 | Bookmark.belongsTo(Category, { 11 | foreignKey: 'categoryId' 12 | }); 13 | } 14 | 15 | module.exports = associateModels; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flame", 3 | "version": "0.1.0", 4 | "description": "Self-hosted start page", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node server.js", 8 | "init-server": "echo Instaling server dependencies && npm install", 9 | "init-client": "cd client && echo Instaling client dependencies && npm install", 10 | "dir-init": "npx mkdirp data public && touch public/flame.css public/customQueries.json", 11 | "dev-init": "npm run dir-init && npm run init-server && npm run init-client", 12 | "dev-server": "nodemon server.js -e js", 13 | "dev-client": "npm start --prefix client", 14 | "dev": "concurrently \"npm run dev-server\" \"npm run dev-client\"", 15 | "skaffold": "concurrently \"npm run init-client\" \"npm run dev-server\"" 16 | }, 17 | "author": "", 18 | "license": "ISC", 19 | "dependencies": { 20 | "@kubernetes/client-node": "^0.15.1", 21 | "@types/express": "^4.17.13", 22 | "axios": "^0.24.0", 23 | "concurrently": "^6.3.0", 24 | "docker-secret": "^1.2.4", 25 | "dotenv": "^10.0.0", 26 | "express": "^4.17.1", 27 | "jsonwebtoken": "^8.5.1", 28 | "multer": "^1.4.3", 29 | "node-schedule": "^2.0.0", 30 | "sequelize": "^6.9.0", 31 | "sqlite3": "^5.0.2", 32 | "umzug": "^2.3.0", 33 | "ws": "^8.2.3" 34 | }, 35 | "devDependencies": { 36 | "nodemon": "^2.0.14" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /routes/apps.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | // middleware 5 | const { auth, requireAuth, upload } = require('../middleware'); 6 | 7 | const { 8 | createApp, 9 | getAllApps, 10 | getSingleApp, 11 | updateApp, 12 | deleteApp, 13 | reorderApps, 14 | } = require('../controllers/apps'); 15 | 16 | router 17 | .route('/') 18 | .post(auth, requireAuth, upload, createApp) 19 | .get(auth, getAllApps); 20 | 21 | router 22 | .route('/:id') 23 | .get(auth, getSingleApp) 24 | .put(auth, requireAuth, upload, updateApp) 25 | .delete(auth, requireAuth, deleteApp); 26 | 27 | router.route('/0/reorder').put(auth, requireAuth, reorderApps); 28 | 29 | module.exports = router; 30 | -------------------------------------------------------------------------------- /routes/auth.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | const { login, validate } = require('../controllers/auth'); 5 | const requireBody = require('../middleware/requireBody'); 6 | 7 | router.route('/').post(requireBody(['password', 'duration']), login); 8 | 9 | router.route('/validate').post(requireBody(['token']), validate); 10 | 11 | module.exports = router; 12 | -------------------------------------------------------------------------------- /routes/bookmark.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | // middleware 5 | const { upload, auth, requireAuth } = require('../middleware'); 6 | 7 | const { 8 | createBookmark, 9 | getAllBookmarks, 10 | getSingleBookmark, 11 | updateBookmark, 12 | deleteBookmark, 13 | reorderBookmarks, 14 | } = require('../controllers/bookmarks'); 15 | 16 | router 17 | .route('/') 18 | .post(auth, requireAuth, upload, createBookmark) 19 | .get(auth, getAllBookmarks); 20 | 21 | router 22 | .route('/:id') 23 | .get(auth, getSingleBookmark) 24 | .put(auth, requireAuth, upload, updateBookmark) 25 | .delete(auth, requireAuth, deleteBookmark); 26 | 27 | router.route('/0/reorder').put(auth, requireAuth, reorderBookmarks); 28 | 29 | module.exports = router; 30 | -------------------------------------------------------------------------------- /routes/category.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | // middleware 5 | const { auth, requireAuth } = require('../middleware'); 6 | 7 | const { 8 | createCategory, 9 | getAllCategories, 10 | getSingleCategory, 11 | updateCategory, 12 | deleteCategory, 13 | reorderCategories, 14 | } = require('../controllers/categories'); 15 | 16 | router 17 | .route('/') 18 | .post(auth, requireAuth, createCategory) 19 | .get(auth, getAllCategories); 20 | 21 | router 22 | .route('/:id') 23 | .get(auth, getSingleCategory) 24 | .put(auth, requireAuth, updateCategory) 25 | .delete(auth, requireAuth, deleteCategory); 26 | 27 | router.route('/0/reorder').put(auth, requireAuth, reorderCategories); 28 | 29 | module.exports = router; 30 | -------------------------------------------------------------------------------- /routes/config.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | // middleware 5 | const { auth, requireAuth } = require('../middleware'); 6 | 7 | const { 8 | getCSS, 9 | updateCSS, 10 | getConfig, 11 | updateConfig, 12 | } = require('../controllers/config'); 13 | 14 | router.route('/').get(getConfig).put(auth, requireAuth, updateConfig); 15 | 16 | router.route('/0/css').get(getCSS).put(auth, requireAuth, updateCSS); 17 | 18 | module.exports = router; 19 | -------------------------------------------------------------------------------- /routes/queries.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | // middleware 5 | const { auth, requireAuth, requireBody } = require('../middleware'); 6 | 7 | const { 8 | getQueries, 9 | addQuery, 10 | deleteQuery, 11 | updateQuery, 12 | } = require('../controllers/queries/'); 13 | 14 | router 15 | .route('/') 16 | .post( 17 | auth, 18 | requireAuth, 19 | requireBody(['name', 'prefix', 'template']), 20 | addQuery 21 | ) 22 | .get(getQueries); 23 | 24 | router 25 | .route('/:prefix') 26 | .delete(auth, requireAuth, deleteQuery) 27 | .put(auth, requireAuth, updateQuery); 28 | 29 | module.exports = router; 30 | -------------------------------------------------------------------------------- /routes/themes.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | // middleware 5 | const { auth, requireAuth, requireBody } = require('../middleware'); 6 | 7 | const { 8 | getThemes, 9 | addTheme, 10 | deleteTheme, 11 | updateTheme, 12 | } = require('../controllers/themes/'); 13 | 14 | router 15 | .route('/') 16 | .get(getThemes) 17 | .post( 18 | auth, 19 | requireAuth, 20 | requireBody(['name', 'colors', 'isCustom']), 21 | addTheme 22 | ); 23 | 24 | router 25 | .route('/:name') 26 | .delete(auth, requireAuth, deleteTheme) 27 | .put(auth, requireAuth, updateTheme); 28 | 29 | module.exports = router; 30 | -------------------------------------------------------------------------------- /routes/weather.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | const { 5 | getWeather, updateWeather 6 | } = require('../controllers/weather'); 7 | 8 | router 9 | .route('/') 10 | .get(getWeather); 11 | 12 | router 13 | .route('/update') 14 | .get(updateWeather); 15 | 16 | 17 | module.exports = router; -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const http = require('http'); 3 | 4 | // Database 5 | const { connectDB } = require('./db'); 6 | const associateModels = require('./models/associateModels'); 7 | 8 | // Server 9 | const api = require('./api'); 10 | const jobs = require('./utils/jobs'); 11 | const Socket = require('./Socket'); 12 | const Sockets = require('./Sockets'); 13 | 14 | // Utils 15 | const initApp = require('./utils/init'); 16 | const Logger = require('./utils/Logger'); 17 | const logger = new Logger(); 18 | 19 | (async () => { 20 | const PORT = process.env.PORT || 5005; 21 | 22 | // Init app 23 | await initApp(); 24 | await connectDB(); 25 | await associateModels(); 26 | await jobs(); 27 | 28 | // Create server for Express API and WebSockets 29 | const server = http.createServer(); 30 | server.on('request', api); 31 | 32 | // Register weatherSocket 33 | const weatherSocket = new Socket(server); 34 | Sockets.registerSocket('weather', weatherSocket); 35 | 36 | server.listen(PORT, () => { 37 | logger.log( 38 | `Server is running on port ${PORT} in ${process.env.NODE_ENV} mode` 39 | ); 40 | }); 41 | })(); 42 | -------------------------------------------------------------------------------- /skaffold.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: skaffold/v2beta20 2 | kind: Config 3 | metadata: 4 | name: flame 5 | build: 6 | artifacts: 7 | - image: shokohsc/flame 8 | context: . 9 | sync: 10 | manual: 11 | - src: controllers/*.js 12 | dest: . 13 | docker: 14 | dockerfile: Dockerfile.dev 15 | deploy: 16 | kustomize: 17 | paths: 18 | - k8s/base 19 | profiles: 20 | - name: dev 21 | activation: 22 | - command: dev 23 | build: 24 | artifacts: 25 | - image: shokohsc/flame 26 | sync: 27 | manual: 28 | - src: controllers/*.js 29 | dest: . 30 | docker: 31 | dockerfile: Dockerfile.dev 32 | - name: shokohsc 33 | build: 34 | artifacts: 35 | - image: shokohsc/flame 36 | sync: 37 | manual: 38 | - src: controllers/*.js 39 | dest: . 40 | kaniko: 41 | dockerfile: Dockerfile.dev 42 | cache: 43 | repo: shokohsc/flame 44 | cluster: 45 | dockerConfig: 46 | secretName: kaniko-secret 47 | namespace: kaniko 48 | pullSecretName: kaniko-secret 49 | deploy: 50 | kustomize: 51 | paths: 52 | - k8s/overlays/shokohsc 53 | - name: prod 54 | build: 55 | artifacts: 56 | - image: shokohsc/flame 57 | kaniko: 58 | dockerfile: Dockerfile 59 | cache: 60 | repo: shokohsc/flame 61 | cluster: 62 | dockerConfig: 63 | secretName: kaniko-secret 64 | namespace: kaniko 65 | pullSecretName: kaniko-secret 66 | -------------------------------------------------------------------------------- /utils/ErrorResponse.js: -------------------------------------------------------------------------------- 1 | class ErrorResponse extends Error { 2 | constructor(message, statusCode) { 3 | super(message); 4 | this.statusCode = statusCode; 5 | } 6 | } 7 | 8 | module.exports = ErrorResponse; 9 | -------------------------------------------------------------------------------- /utils/File.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | class File { 4 | constructor(path) { 5 | this.path = path; 6 | this.content = null; 7 | } 8 | 9 | read() { 10 | try { 11 | const content = fs.readFileSync(this.path, { encoding: 'utf-8' }); 12 | this.content = content; 13 | return this.content; 14 | } catch (err) { 15 | return err.message; 16 | } 17 | } 18 | 19 | write(data, isJSON) { 20 | this.content = data; 21 | fs.writeFileSync( 22 | this.path, 23 | isJSON ? JSON.stringify(this.content) : this.content 24 | ); 25 | } 26 | } 27 | 28 | module.exports = File; 29 | -------------------------------------------------------------------------------- /utils/Logger.js: -------------------------------------------------------------------------------- 1 | class Logger { 2 | log(message, level = 'INFO') { 3 | console.log(`[${this.generateTimestamp()}] [${level}] ${message}`); 4 | } 5 | 6 | generateTimestamp() { 7 | const d = new Date(); 8 | 9 | // Date 10 | const year = d.getFullYear(); 11 | const month = this.parseDate(d.getMonth() + 1); 12 | const day = this.parseDate(d.getDate()); 13 | 14 | // Time 15 | const hour = this.parseDate(d.getHours()); 16 | const minutes = this.parseDate(d.getMinutes()); 17 | const seconds = this.parseDate(d.getSeconds()); 18 | const miliseconds = this.parseDate(d.getMilliseconds(), true); 19 | 20 | // Timezone 21 | const tz = -d.getTimezoneOffset() / 60; 22 | 23 | return `${year}-${month}-${day} ${hour}:${minutes}:${seconds}.${miliseconds} UTC${ 24 | tz >= 0 ? '+' + tz : tz 25 | }`; 26 | } 27 | 28 | parseDate(date, ms = false) { 29 | if (ms) { 30 | if (date >= 10 && date < 100) { 31 | return `0${date}`; 32 | } else if (date < 10) { 33 | return `00${date}`; 34 | } 35 | } 36 | 37 | return date < 10 ? `0${date}` : date.toString(); 38 | } 39 | } 40 | 41 | module.exports = Logger; 42 | -------------------------------------------------------------------------------- /utils/checkFileExists.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | const checkFileExists = (path) => { 4 | return fs.promises 5 | .access(path, fs.constants.F_OK) 6 | .then(() => true) 7 | .catch(() => false); 8 | }; 9 | 10 | module.exports = checkFileExists; 11 | -------------------------------------------------------------------------------- /utils/clearWeatherData.js: -------------------------------------------------------------------------------- 1 | const { Op } = require('sequelize'); 2 | const Weather = require('../models/Weather'); 3 | 4 | const clearWeatherData = async () => { 5 | const weather = await Weather.findOne({ 6 | order: [['createdAt', 'DESC']], 7 | }); 8 | 9 | if (weather) { 10 | await Weather.destroy({ 11 | where: { 12 | id: { 13 | [Op.lt]: weather.id, 14 | }, 15 | }, 16 | }); 17 | } 18 | }; 19 | 20 | module.exports = clearWeatherData; 21 | -------------------------------------------------------------------------------- /utils/getExternalWeather.js: -------------------------------------------------------------------------------- 1 | const Weather = require('../models/Weather'); 2 | const axios = require('axios'); 3 | const loadConfig = require('./loadConfig'); 4 | 5 | const getExternalWeather = async () => { 6 | const { WEATHER_API_KEY: secret, lat, long } = await loadConfig(); 7 | 8 | // Fetch data from external API 9 | try { 10 | const res = await axios.get( 11 | `http://api.weatherapi.com/v1/current.json?key=${secret}&q=${lat},${long}` 12 | ); 13 | 14 | // Save weather data 15 | const cursor = res.data.current; 16 | const weatherData = await Weather.create({ 17 | externalLastUpdate: cursor.last_updated, 18 | tempC: cursor.temp_c, 19 | tempF: cursor.temp_f, 20 | isDay: cursor.is_day, 21 | cloud: cursor.cloud, 22 | conditionText: cursor.condition.text, 23 | conditionCode: cursor.condition.code, 24 | humidity: cursor.humidity, 25 | windK: cursor.wind_kph, 26 | windM: cursor.wind_mph, 27 | }); 28 | return weatherData; 29 | } catch (err) { 30 | throw new Error('External API request failed'); 31 | } 32 | }; 33 | 34 | module.exports = getExternalWeather; 35 | -------------------------------------------------------------------------------- /utils/init/createFile.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const { join } = require('path'); 3 | 4 | const Logger = require('../Logger'); 5 | const logger = new Logger(); 6 | 7 | const createFile = async (file) => { 8 | const { name, msg, template, isJSON, paths } = file; 9 | 10 | const srcPath = join(__dirname, paths.src, name); 11 | const destPath = join(__dirname, paths.dest, name); 12 | 13 | // Check if file exists 14 | if (fs.existsSync(srcPath)) { 15 | fs.copyFileSync(srcPath, destPath); 16 | 17 | if (process.env.NODE_ENV == 'development') { 18 | logger.log(msg.found); 19 | } 20 | 21 | return; 22 | } 23 | 24 | // Create file if not 25 | fs.writeFileSync(destPath, isJSON ? JSON.stringify(template) : template); 26 | 27 | if (process.env.NODE_ENV == 'development') { 28 | logger.log(msg.created); 29 | } 30 | }; 31 | 32 | module.exports = createFile; 33 | -------------------------------------------------------------------------------- /utils/init/index.js: -------------------------------------------------------------------------------- 1 | const initConfig = require('./initConfig'); 2 | const initFiles = require('./initFiles'); 3 | const initDockerSecrets = require('./initDockerSecrets'); 4 | const normalizeTheme = require('./normalizeTheme'); 5 | 6 | const initApp = async () => { 7 | initDockerSecrets(); 8 | await initFiles(); 9 | await initConfig(); 10 | await normalizeTheme(); 11 | }; 12 | 13 | module.exports = initApp; 14 | -------------------------------------------------------------------------------- /utils/init/initConfig.js: -------------------------------------------------------------------------------- 1 | const { copyFile, readFile, writeFile } = require('fs/promises'); 2 | const checkFileExists = require('../checkFileExists'); 3 | const initialConfig = require('./initialConfig.json'); 4 | 5 | const initConfig = async () => { 6 | const configExists = await checkFileExists('data/config.json'); 7 | 8 | if (!configExists) { 9 | await copyFile('utils/init/initialConfig.json', 'data/config.json'); 10 | } 11 | 12 | const existingConfig = await readFile('data/config.json', 'utf-8'); 13 | const parsedConfig = JSON.parse(existingConfig); 14 | 15 | // Add new config pairs if necessary 16 | for (let key in initialConfig) { 17 | if (!Object.keys(parsedConfig).includes(key)) { 18 | parsedConfig[key] = initialConfig[key]; 19 | } 20 | } 21 | 22 | await writeFile('data/config.json', JSON.stringify(parsedConfig)); 23 | }; 24 | 25 | module.exports = initConfig; 26 | -------------------------------------------------------------------------------- /utils/init/initDockerSecrets.js: -------------------------------------------------------------------------------- 1 | const { getSecrets } = require('docker-secret'); 2 | const Logger = require('../Logger'); 3 | const logger = new Logger(); 4 | 5 | const initDockerSecrets = () => { 6 | try { 7 | const secrets = getSecrets(); 8 | 9 | for (const property in secrets) { 10 | const upperProperty = property.toUpperCase(); 11 | 12 | process.env[upperProperty] = secrets[property]; 13 | 14 | logger.log(`${upperProperty} was overwritten with docker secret value`); 15 | } 16 | } catch (e) { 17 | logger.log(`Failed to initialize docker secrets. Error: ${e}`, 'ERROR'); 18 | } 19 | }; 20 | 21 | module.exports = initDockerSecrets; 22 | -------------------------------------------------------------------------------- /utils/init/initFiles.js: -------------------------------------------------------------------------------- 1 | const createFile = require('./createFile'); 2 | const { files } = require('./initialFiles.json'); 3 | 4 | const initFiles = async () => { 5 | files.forEach(async (file) => await createFile(file)); 6 | }; 7 | 8 | module.exports = initFiles; 9 | -------------------------------------------------------------------------------- /utils/init/initialConfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "WEATHER_API_KEY": "", 3 | "lat": 0, 4 | "long": 0, 5 | "isCelsius": true, 6 | "customTitle": "Flame", 7 | "pinAppsByDefault": true, 8 | "pinCategoriesByDefault": true, 9 | "hideHeader": false, 10 | "useOrdering": "createdAt", 11 | "appsSameTab": false, 12 | "bookmarksSameTab": false, 13 | "searchSameTab": false, 14 | "hideApps": false, 15 | "hideCategories": false, 16 | "hideSearch": false, 17 | "defaultSearchProvider": "l", 18 | "secondarySearchProvider": "d", 19 | "dockerApps": false, 20 | "dockerHost": "localhost", 21 | "kubernetesApps": false, 22 | "unpinStoppedApps": false, 23 | "useAmericanDate": false, 24 | "disableAutofocus": false, 25 | "greetingsSchema": "Good evening!;Good afternoon!;Good morning!;Good night!", 26 | "daySchema": "Sunday;Monday;Tuesday;Wednesday;Thursday;Friday;Saturday", 27 | "monthSchema": "January;February;March;April;May;June;July;August;September;October;November;December", 28 | "showTime": false, 29 | "defaultTheme": "tron", 30 | "isKilometer": true, 31 | "weatherData": "cloud", 32 | "hideDate": false 33 | } 34 | -------------------------------------------------------------------------------- /utils/init/normalizeTheme.js: -------------------------------------------------------------------------------- 1 | const { readFile, writeFile } = require('fs/promises'); 2 | 3 | const normalizeTheme = async () => { 4 | // open main config file 5 | const configFile = await readFile('data/config.json', 'utf8'); 6 | const config = JSON.parse(configFile); 7 | 8 | // open default themes file 9 | const themesFile = await readFile('utils/init/themes.json', 'utf8'); 10 | const { themes } = JSON.parse(themesFile); 11 | 12 | // find theme 13 | const theme = themes.find((t) => t.name === config.defaultTheme); 14 | 15 | if (theme) { 16 | // save theme in new format 17 | // PAB - primary;accent;background 18 | const { primary: p, accent: a, background: b } = theme.colors; 19 | const normalizedTheme = `${p};${a};${b}`; 20 | 21 | await writeFile( 22 | 'data/config.json', 23 | JSON.stringify({ ...config, defaultTheme: normalizedTheme }) 24 | ); 25 | } 26 | }; 27 | 28 | module.exports = normalizeTheme; 29 | -------------------------------------------------------------------------------- /utils/jobs.js: -------------------------------------------------------------------------------- 1 | const schedule = require('node-schedule'); 2 | const getExternalWeather = require('./getExternalWeather'); 3 | const clearWeatherData = require('./clearWeatherData'); 4 | const Sockets = require('../Sockets'); 5 | const Logger = require('./Logger'); 6 | const loadConfig = require('./loadConfig'); 7 | const logger = new Logger(); 8 | 9 | module.exports = async function () { 10 | const { WEATHER_API_KEY } = await loadConfig(); 11 | 12 | if (WEATHER_API_KEY != '') { 13 | // Update weather data every 15 minutes 14 | const weatherJob = schedule.scheduleJob( 15 | 'updateWeather', 16 | '0 */15 * * * *', 17 | async () => { 18 | try { 19 | const weatherData = await getExternalWeather(); 20 | 21 | Sockets.getSocket('weather').socket.send(JSON.stringify(weatherData)); 22 | } catch (err) { 23 | if (WEATHER_API_KEY) { 24 | logger.log(err.message, 'ERROR'); 25 | } 26 | } 27 | } 28 | ); 29 | 30 | // Clear old weather data every 4 hours 31 | const weatherCleanerJob = schedule.scheduleJob( 32 | 'clearWeather', 33 | '0 5 */4 * * *', 34 | async () => { 35 | clearWeatherData(); 36 | } 37 | ); 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /utils/loadConfig.js: -------------------------------------------------------------------------------- 1 | const { readFile } = require('fs/promises'); 2 | const checkFileExists = require('../utils/checkFileExists'); 3 | const initConfig = require('../utils/init/initConfig'); 4 | 5 | const loadConfig = async () => { 6 | const configExists = await checkFileExists('data/config.json'); 7 | 8 | if (!configExists) { 9 | await initConfig(); 10 | } 11 | 12 | const config = await readFile('data/config.json', 'utf-8'); 13 | const parsedConfig = JSON.parse(config); 14 | 15 | return parsedConfig; 16 | }; 17 | 18 | module.exports = loadConfig; 19 | -------------------------------------------------------------------------------- /utils/signToken.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | 3 | const signToken = (expiresIn) => { 4 | const token = jwt.sign({ app: 'flame' }, process.env.SECRET, { expiresIn }); 5 | return token; 6 | }; 7 | 8 | module.exports = signToken; 9 | --------------------------------------------------------------------------------