diff --git a/.dev/DEV_GUIDELINES.md b/.dev/DEV_GUIDELINES.md new file mode 100644 index 00000000..462a17fa --- /dev/null +++ b/.dev/DEV_GUIDELINES.md @@ -0,0 +1,10 @@ +## Adding new config key + +1. Edit utils/init/initialConfig.json +2. Edit client/src/interfaces/Config.ts +3. Edit client/src/utility/templateObjects/configTemplate.ts + +If config value will be used in a form: + +4. Edit client/src/interfaces/Forms.ts +5. Edit client/src/utility/templateObjects/settingsTemplate.ts \ No newline at end of file diff --git a/.dev/bookmarks_importer.py b/.dev/bookmarks_importer.py new file mode 100755 index 00000000..983e2821 --- /dev/null +++ b/.dev/bookmarks_importer.py @@ -0,0 +1,166 @@ +import sqlite3 +from bs4 import BeautifulSoup +from PIL import Image, UnidentifiedImageError +from io import BytesIO +import re +import base64 +from datetime import datetime, timezone +import os +import argparse + + +""" +Imports html bookmarks file into Flame. +Tested only on Firefox html exports so far. + +Usage: +python3 bookmarks_importer.py --bookmarks --data + +""" + +parser = argparse.ArgumentParser() +parser.add_argument('--bookmarks', type=str, required=True) +parser.add_argument('--data', type=str, required=True) +args = parser.parse_args() + +bookmarks_path = args.bookmarks +data_path = args.data +created = datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3] + datetime.now().astimezone().strftime(" %z") +updated = created +if data_path[-1] != '/': + data_path = data_path + '/' + + + + +def Base64toPNG(codec, name): + + """ + Convert base64 encoded image to png file + Reference: https://github.com/python-pillow/Pillow/issues/3400#issuecomment-428104239 + + Parameters: + codec (str): icon in html bookmark format.e.g. 'data:image/png;base64,' + name (str): name for export file + + Returns: + icon_name(str): name of png output E.g. 1636473849374--mybookmark.png + None: if image not produced successfully + + """ + + try: + unix_t = str(int(datetime.now(tz=timezone.utc).timestamp() * 1000)) + icon_name = unix_t + '--' + re.sub(r'\W+', '', name).lower() + '.png' + image_path = data_path + 'uploads/' + icon_name + if os.path.exists(image_path): + return image_path + base64_data = re.sub('^data:image/.+;base64,', '', codec) + byte_data = base64.b64decode(base64_data) + image_data = BytesIO(byte_data) + img = Image.open(image_data) + img.save(image_path, "PNG") + return icon_name + except UnidentifiedImageError: + return None + + + + +def FlameBookmarkParser(bookmarks_path): + + """ + Parses HTML bookmarks file + Reference: https://stackoverflow.com/questions/68621107/extracting-bookmarks-and-folder-hierarchy-from-google-chrome-with-beautifulsoup + + Parameters: + bookmarks_path (str): path to bookmarks.html + + Returns: + None + + """ + + soup = BeautifulSoup() + with open(bookmarks_path) as f: + soup = BeautifulSoup(f.read(), 'lxml') + + dt = soup.find_all('dt') + folder_name ='' + for i in dt: + n = i.find_next() + if n.name == 'h3': + folder_name = n.text + continue + else: + url = n.get("href") + website_name = n.text + icon = n.get("icon") + if icon != None: + icon_name = Base64toPNG(icon, website_name) + cat_id = AddFlameCategory(folder_name) + AddFlameBookmark(website_name, url, cat_id, icon_name) + + + + +def AddFlameCategory(cat_name): + """ + Parses HTML bookmarks file + + Parameters: + cat_name (str): category name + + Returns: + cat_id (int): primary key id of cat_name + + """ + + + + con = sqlite3.connect(data_path + 'db.sqlite') + cur = con.cursor() + count_sql = ("SELECT count(*) FROM categories WHERE name = ?;") + cur.execute(count_sql, [cat_name]) + count = int(cur.fetchall()[0][0]) + if count > 0: + getid_sql = ("SELECT id FROM categories WHERE name = ?;") + cur.execute(getid_sql, [cat_name]) + cat_id = int(cur.fetchall()[0][0]) + return cat_id + + is_pinned = 1 + + insert_sql = "INSERT OR IGNORE INTO categories(name, isPinned, createdAt, updatedAt) VALUES (?, ?, ?, ?);" + cur.execute(insert_sql, (cat_name, is_pinned, created, updated)) + con.commit() + + getid_sql = ("SELECT id FROM categories WHERE name = ?;") + cur.execute(getid_sql, [cat_name]) + cat_id = int(cur.fetchall()[0][0]) + return cat_id + + + + +def AddFlameBookmark(website_name, url, cat_id, icon_name): + con = sqlite3.connect(data_path + 'db.sqlite') + cur = con.cursor() + if icon_name == None: + insert_sql = "INSERT OR IGNORE INTO bookmarks(name, url, categoryId, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?);" + cur.execute(insert_sql, (website_name, url, cat_id, created, updated)) + con.commit() + else: + insert_sql = "INSERT OR IGNORE INTO bookmarks(name, url, categoryId, icon, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?);" + cur.execute(insert_sql, (website_name, url, cat_id, icon_name, created, updated)) + con.commit() + + + + + + + + +if __name__ == "__main__": + FlameBookmarkParser(bookmarks_path) \ No newline at end of file diff --git a/.dev/getMdi.js b/.dev/getMdi.js new file mode 100644 index 00000000..19786a8d --- /dev/null +++ b/.dev/getMdi.js @@ -0,0 +1,9 @@ +// Script to get all icon names from materialdesignicons.com +const getMdi = () => { + const icons = document.querySelectorAll('#icons div span'); + const names = [...icons].map((icon) => icon.textContent.replace('mdi-', '')); + const output = names.map((name) => ({ name })); + output.pop(); + const json = JSON.stringify(output); + console.log(json); +}; diff --git a/Dockerfile b/.docker/Dockerfile similarity index 84% rename from Dockerfile rename to .docker/Dockerfile index feb75e20..415e8b18 100644 --- a/Dockerfile +++ b/.docker/Dockerfile @@ -1,6 +1,4 @@ -FROM node:14-alpine - -RUN apk update && apk add --no-cache nano curl +FROM node:16 as builder # Get package.json and install modules COPY package*.json /tmp/package.json @@ -23,8 +21,15 @@ RUN mkdir -p ./public ./data \ && mv ./client/build/* ./public \ && rm -rf ./client +FROM node:16-alpine + +COPY --from=builder /app /app + +WORKDIR /app + EXPOSE 5005 ENV NODE_ENV=production +ENV PASSWORD=flame_password CMD ["node", "server.js"] diff --git a/Dockerfile.dev b/.docker/Dockerfile.dev similarity index 88% rename from Dockerfile.dev rename to .docker/Dockerfile.dev index 680ed26f..951afd4d 100644 --- a/Dockerfile.dev +++ b/.docker/Dockerfile.dev @@ -1,16 +1,26 @@ FROM node:lts-alpine as build-front + RUN apk add --no-cache curl + WORKDIR /app + COPY ./client . + RUN npm install --production \ && npm run build FROM node:lts-alpine + WORKDIR /app + RUN mkdir -p ./public + COPY --from=build-front /app/build/ ./public COPY package*.json ./ + RUN npm install + COPY . . -CMD ["npm", "run", "skaffold"] + +CMD ["npm", "run", "skaffold"] \ No newline at end of file diff --git a/Dockerfile.multiarch b/.docker/Dockerfile.multiarch similarity index 54% rename from Dockerfile.multiarch rename to .docker/Dockerfile.multiarch index 20ff6c25..42f50825 100644 --- a/Dockerfile.multiarch +++ b/.docker/Dockerfile.multiarch @@ -1,15 +1,13 @@ -FROM node:14-alpine - -RUN apk update && apk add --no-cache nano curl +FROM node:16-alpine3.11 as builder WORKDIR /app COPY package*.json ./ -RUN apk --no-cache --virtual build-dependencies add python make g++ \ +RUN apk --no-cache --virtual build-dependencies add python python3 make g++ \ && npm install --production -COPY . . +COPY . . RUN mkdir -p ./public ./data \ && cd ./client \ @@ -17,11 +15,17 @@ RUN mkdir -p ./public ./data \ && npm run build \ && cd .. \ && mv ./client/build/* ./public \ - && rm -rf ./client \ - && apk del build-dependencies + && rm -rf ./client + +FROM node:16-alpine3.11 + +COPY --from=builder /app /app + +WORKDIR /app EXPOSE 5005 ENV NODE_ENV=production +ENV PASSWORD=flame_password CMD ["node", "server.js"] \ No newline at end of file diff --git a/.docker/docker-compose.yml b/.docker/docker-compose.yml new file mode 100644 index 00000000..6aee3c4a --- /dev/null +++ b/.docker/docker-compose.yml @@ -0,0 +1,22 @@ +version: '3.6' + +services: + flame: + image: pawelmalak/flame + container_name: flame + volumes: + - /path/to/host/data:/app/data + # - /var/run/docker.sock:/var/run/docker.sock # optional but required for Docker integration + ports: + - 5005:5005 + # secrets: + # - password # optional but required for (1) + environment: + - PASSWORD=flame_password + # - PASSWORD_FILE=/run/secrets/password # optional but required for (1) + restart: unless-stopped + +# optional but required for Docker secrets (1) +# secrets: +# password: +# file: /path/to/secrets/password diff --git a/.dockerignore b/.dockerignore index 6c10c72c..134be5da 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,5 @@ node_modules -github +.github public -build.sh k8s -skaffold.yaml +skaffold.yaml \ No newline at end of file diff --git a/.env b/.env index 1bb2edb9..64e65177 100644 --- a/.env +++ b/.env @@ -1,3 +1,5 @@ PORT=5005 NODE_ENV=development -VERSION=1.7.0 \ No newline at end of file +VERSION=2.1.1 +PASSWORD=flame_password +SECRET=e02eb43d69953658c6d07311d6313f2d4467672cb881f96b29368ba1f3f4da4b \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..13ccbc62 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,27 @@ +--- +name: Bug report +about: Create a bug report +title: "[BUG] " +labels: '' +assignees: '' + +--- + +**Deployment details:** +- App version [e.g. v1.7.4]: +- Platform [e.g. amd64, arm64, arm/v7]: +- Docker image tag [e.g. latest, multiarch]: + +--- + +**Bug description:** + +A clear and concise description of what the bug is. + +--- + +**Steps to reproduce:** + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' diff --git a/.github/_apps.png b/.github/_apps.png deleted file mode 100644 index 39096dc1..00000000 Binary files a/.github/_apps.png and /dev/null differ diff --git a/.github/_bookmarks.png b/.github/_bookmarks.png deleted file mode 100644 index fe6999b0..00000000 Binary files a/.github/_bookmarks.png and /dev/null differ diff --git a/.github/_home.png b/.github/_home.png deleted file mode 100644 index c24050f0..00000000 Binary files a/.github/_home.png and /dev/null differ diff --git a/.github/apps.png b/.github/apps.png new file mode 100644 index 00000000..17a16763 Binary files /dev/null and b/.github/apps.png differ diff --git a/.github/bookmarks.png b/.github/bookmarks.png new file mode 100644 index 00000000..dcb63baa Binary files /dev/null and b/.github/bookmarks.png differ diff --git a/.github/home.png b/.github/home.png new file mode 100644 index 00000000..5e9f5788 Binary files /dev/null and b/.github/home.png differ diff --git a/.github/settings.png b/.github/settings.png new file mode 100644 index 00000000..73393b5c Binary files /dev/null and b/.github/settings.png differ diff --git a/.github/_themes.png b/.github/themes.png similarity index 100% rename from .github/_themes.png rename to .github/themes.png diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 393fbc00..6ae50e2a 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -44,6 +44,6 @@ jobs: uses: docker/build-push-action@v2 with: context: . - file: ./Dockerfile + file: ./.docker/Dockerfile push: true tags: ${{ steps.prep.outputs.tags }} diff --git a/.gitignore b/.gitignore index 98ec8629..147804b1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules data public +!client/public build.sh \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index 2e1fa2d5..98b42ea8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ -*.md \ No newline at end of file +*.md +docker-compose.yml \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 54c68e1b..e0766ae4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,63 @@ +### v2.1.1 (2021-12-02) +- Added support for Docker secrets ([#189](https://github.com/pawelmalak/flame/issues/189)) +- Changed some messages and buttons to make it easier to open bookmarks editor ([#239](https://github.com/pawelmalak/flame/issues/239)) + +### v2.1.0 (2021-11-26) +- Added option to set custom order for bookmarks ([#43](https://github.com/pawelmalak/flame/issues/43)) and ([#187](https://github.com/pawelmalak/flame/issues/187)) +- Added support for .ico files for custom icons ([#209](https://github.com/pawelmalak/flame/issues/209)) +- Empty apps and categories sections will now be hidden from guests ([#210](https://github.com/pawelmalak/flame/issues/210)) +- Fixed bug with fahrenheit degrees being displayed as float ([#221](https://github.com/pawelmalak/flame/issues/221)) +- Fixed bug with alphabetical order not working for bookmarks until the page was refreshed ([#224](https://github.com/pawelmalak/flame/issues/224)) +- Added option to change visibilty of apps, categories and bookmarks directly from table view +- Password input will now autofocus when visiting /settings/app + +### v2.0.1 (2021-11-19) +- Added option to display humidity in the weather widget ([#136](https://github.com/pawelmalak/flame/issues/136)) +- Added option to set default theme for all new users ([#165](https://github.com/pawelmalak/flame/issues/165)) +- Added option to hide header greetings and date separately ([#200](https://github.com/pawelmalak/flame/issues/200)) +- Fixed bug with broken basic auth ([#202](https://github.com/pawelmalak/flame/issues/202)) +- Fixed bug with parsing visibility value for apps and bookmarks when custom icon was used ([#203](https://github.com/pawelmalak/flame/issues/203)) +- Fixed bug with custom icons not working with apps when "pin by default" was disabled + +### v2.0.0 (2021-11-15) +- Added authentication system: + - Only logged in user can access settings ([#33](https://github.com/pawelmalak/flame/issues/33)) + - User can set which apps, categories and bookmarks should be available for guest users ([#45](https://github.com/pawelmalak/flame/issues/45)) + - Visit [project wiki](https://github.com/pawelmalak/flame/wiki/Authentication) to read more about this feature +- Docker images will now be versioned ([#110](https://github.com/pawelmalak/flame/issues/110)) +- Icons can now be set via URL ([#138](https://github.com/pawelmalak/flame/issues/138)) +- Added current time to the header ([#157](https://github.com/pawelmalak/flame/issues/157)) +- Fixed bug where typing certain characters in the search bar would result in a blank page ([#158](https://github.com/pawelmalak/flame/issues/158)) +- Fixed bug with MDI icon name not being properly parsed if there was leading or trailing whitespace ([#164](https://github.com/pawelmalak/flame/issues/164)) +- Added new shortcut to clear search bar and focus on it ([#170](https://github.com/pawelmalak/flame/issues/170)) +- Added Wikipedia to search queries +- Updated project wiki +- Lots of changes and refactors under the hood to make future development easier + +### v1.7.4 (2021-11-08) +- Added option to set custom greetings and date ([#103](https://github.com/pawelmalak/flame/issues/103)) +- Fallback to web search if local search has zero results ([#129](https://github.com/pawelmalak/flame/issues/129)) +- Added iOS "Add to homescreen" icon ([#131](https://github.com/pawelmalak/flame/issues/131)) +- Added experimental script to import bookmarks ([#141](https://github.com/pawelmalak/flame/issues/141)) +- Added 3 new themes + +### v1.7.3 (2021-10-28) +- Fixed bug with custom CSS not updating + +### v1.7.2 (2021-10-28) +- Pressing Enter while search bar is focused will now redirect to first result of local search ([#121](https://github.com/pawelmalak/flame/issues/121)) +- Use search bar shortcuts when it's not focused ([#124](https://github.com/pawelmalak/flame/issues/124)) +- Fixed bug with Weather API still logging with module being disabled ([#125](https://github.com/pawelmalak/flame/issues/125)) +- Added option to disable search bar autofocus ([#127](https://github.com/pawelmalak/flame/issues/127)) + +### v1.7.1 (2021-10-22) +- Fixed search action not being triggered by Numpad Enter +- Added option to change date formatting ([#92](https://github.com/pawelmalak/flame/issues/92)) +- Added shortcuts (Esc and double click) to clear search bar ([#100](https://github.com/pawelmalak/flame/issues/100)) +- Added Traefik integration ([#102](https://github.com/pawelmalak/flame/issues/102)) +- Fixed search bar not redirecting to valid URL if it starts with capital letter ([#118](https://github.com/pawelmalak/flame/issues/118)) +- Performance improvements + ### v1.7.0 (2021-10-11) - Search bar will now redirect if valid URL or IP is provided ([#67](https://github.com/pawelmalak/flame/issues/67)) - Users can now add their custom search providers ([#71](https://github.com/pawelmalak/flame/issues/71)) @@ -38,12 +98,12 @@ - Fixed custom icons not updating ([#58](https://github.com/pawelmalak/flame/issues/58)) - Added changelog file -### v1.6 (2021-07-17) +### v1.6.0 (2021-07-17) - Added support for Steam URLs ([#62](https://github.com/pawelmalak/flame/issues/62)) - Fixed bug with custom CSS not persisting ([#64](https://github.com/pawelmalak/flame/issues/64)) - Added option to set default prefix for search bar ([#65](https://github.com/pawelmalak/flame/issues/65)) -### v1.5 (2021-06-24) +### v1.5.0 (2021-06-24) - Added ability to set custom CSS from settings ([#8](https://github.com/pawelmalak/flame/issues/8) and [#17](https://github.com/pawelmalak/flame/issues/17)) (experimental) - Added option to upload custom icons ([#12](https://github.com/pawelmalak/flame/issues/12)) - Added option to open links in a new or the same tab ([#27](https://github.com/pawelmalak/flame/issues/27)) @@ -51,7 +111,7 @@ - Added option to hide applications and categories ([#48](https://github.com/pawelmalak/flame/issues/48)) - Improved Logger -### v1.4 (2021-06-18) +### v1.4.0 (2021-06-18) - Added more sorting options. User can now choose to sort apps and categories by name, creation time or to use custom order ([#13](https://github.com/pawelmalak/flame/issues/13)) - Added reordering functionality. User can now set custom order for apps and categories from their 'edit tables' ([#13](https://github.com/pawelmalak/flame/issues/13)) - Changed get all controllers for applications and categories to use case-insensitive ordering ([#36](https://github.com/pawelmalak/flame/issues/36)) @@ -60,14 +120,14 @@ - Added update check on app start ([#38](https://github.com/pawelmalak/flame/issues/38)) - Fixed bug with decimal input values in Safari browser ([#40](https://github.com/pawelmalak/flame/issues/40)) -### v1.3 (2021-06-14) +### v1.3.0 (2021-06-14) - Added reverse proxy support ([#23](https://github.com/pawelmalak/flame/issues/23) and [#24](https://github.com/pawelmalak/flame/issues/24)) - Added support for more url formats ([#26](https://github.com/pawelmalak/flame/issues/26)) - Added ability to hide main header ([#28](https://github.com/pawelmalak/flame/issues/28)) - Fixed settings not being synchronized ([#29](https://github.com/pawelmalak/flame/issues/29)) - Added auto-refresh for greeting and date ([#34](https://github.com/pawelmalak/flame/issues/34)) -### v1.2 (2021-06-10) +### v1.2.0 (2021-06-10) - Added simple check to the weather module settings to inform user if the api key is missing ([#2](https://github.com/pawelmalak/flame/issues/2)) - Added ability to set optional icons to the bookmarks ([#7](https://github.com/pawelmalak/flame/issues/7)) - Added option to pin new applications and categories to the homescreen by default ([#11](https://github.com/pawelmalak/flame/issues/11)) @@ -76,11 +136,11 @@ - Added proxy for websocket instead of using hard coded host ([#18](https://github.com/pawelmalak/flame/issues/18)) - Fixed bug with overwriting opened tabs ([#20](https://github.com/pawelmalak/flame/issues/20)) -### v1.1 (2021-06-09) +### v1.1.0 (2021-06-09) - Added custom favicon and changed page title ([#3](https://github.com/pawelmalak/flame/issues/3)) - Added functionality to set custom page title ([#3](https://github.com/pawelmalak/flame/issues/3)) - Changed messages on the homescreen when there are apps/bookmarks created but not pinned to the homescreen ([#4](https://github.com/pawelmalak/flame/issues/4)) - Added 'warnings' to apps and bookmarks forms about supported url formats ([#5](https://github.com/pawelmalak/flame/issues/5)) -### v1.0 (2021-06-08) +### v1.0.0 (2021-06-08) Initial release of Flame - self-hosted startpage using Node.js on backend and React on frontend. diff --git a/README.md b/README.md index 884c1ef1..851b071a 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,96 @@ # Flame -[![JS Badge](https://img.shields.io/badge/JavaScript-F7DF1E?style=for-the-badge&logo=javascript&logoColor=black)](https://shields.io/) -[![TS Badge](https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white)](https://shields.io/) -[![Node Badge](https://img.shields.io/badge/Node.js-43853D?style=for-the-badge&logo=node.js&logoColor=white)](https://shields.io/) -[![React Badge](https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB)](https://shields.io/) - -![Homescreen screenshot](./.github/_home.png) +![Homescreen screenshot](.github/home.png) ## Description -Flame is self-hosted startpage for your server. Its design is inspired (heavily) by [SUI](https://github.com/jeroenpardon/sui). Flame is very easy to setup and use. With built-in editors it allows you to setup your very own appliaction hub in no time - no file editing necessary. +Flame is self-hosted startpage for your server. Its design is inspired (heavily) by [SUI](https://github.com/jeroenpardon/sui). Flame is very easy to setup and use. With built-in editors, it allows you to setup your very own application hub in no time - no file editing necessary. -## Technology +## Functionality +- 📝 Create, update, delete your applications and bookmarks directly from the app using built-in GUI editors +- 📌 Pin your favourite items to the homescreen for quick and easy access +- 🔍 Integrated search bar with local filtering, 11 web search providers and ability to add your own +- 🔑 Authentication system to protect your settings, apps and bookmarks +- 🔨 Dozens of options to customize Flame interface to your needs, including support for custom CSS and 15 built-in color themes +- ☀️ Weather widget with current temperature, cloud coverage and animated weather status +- 🐳 Docker integration to automatically pick and add apps based on their labels -- Backend - - Node.js + Express - - Sequelize ORM + SQLite -- Frontend - - React - - Redux - - TypeScript -- Deployment - - Docker - - Kubernetes +## Installation -## Development +### With Docker (recommended) + +[Docker Hub link](https://hub.docker.com/r/pawelmalak/flame) ```sh -# clone repository -git clone https://github.com/pawelmalak/flame -cd flame +docker pull pawelmalak/flame -# run only once -npm run dev-init +# for ARM architecture (e.g. RaspberryPi) +docker pull pawelmalak/flame:multiarch -# start backend and frontend development servers -npm run dev +# installing specific version +docker pull pawelmalak/flame:2.0.0 ``` -## Installation - -### With Docker (recommended) +#### Deployment -[Docker Hub](https://hub.docker.com/r/pawelmalak/flame) +```sh +# run container +docker run -p 5005:5005 -v /path/to/data:/app/data -e PASSWORD=flame_password flame +``` #### Building images ```sh # build image for amd64 only -docker build -t flame . +docker build -t flame -f .docker/Dockerfile . # build multiarch image for amd64, armv7 and arm64 # building failed multiple times with 2GB memory usage limit so you might want to increase it docker buildx build \ --platform linux/arm/v7,linux/arm64,linux/amd64 \ - -f Dockerfile.multiarch \ + -f .docker/Dockerfile.multiarch \ -t flame:multiarch . ``` -#### Deployment - -```sh -# run container -docker run -p 5005:5005 -v /path/to/data:/app/data flame -``` - #### Docker-Compose ```yaml -version: '2.1' +version: '3.6' + services: flame: - image: pawelmalak/flame:latest + image: pawelmalak/flame container_name: flame volumes: - - :/app/data - - /var/run/docker.sock:/var/run/docker.sock # optional but required for Docker integration feature + - /path/to/host/data:/app/data + - /var/run/docker.sock:/var/run/docker.sock # optional but required for Docker integration ports: - 5005:5005 + secrets: + - password # optional but required for (1) + environment: + - PASSWORD=flame_password + - PASSWORD_FILE=/run/secrets/password # optional but required for (1) restart: unless-stopped + +# optional but required for Docker secrets (1) +secrets: + password: + file: /path/to/secrets/password +``` + +##### Docker Secrets + +All environment variables can be overwritten by appending `_FILE` to the variable value. For example, you can use `PASSWORD_FILE` to pass through a docker secret instead of `PASSWORD`. If both `PASSWORD` and `PASSWORD_FILE` are set, the docker secret will take precedent. + +```bash +# ./secrets/flame_password +my_custom_secret_password_123 + +# ./docker-compose.yml +secrets: + password: + file: ./secrets/flame_password ``` #### Skaffold @@ -92,56 +104,58 @@ skaffold dev Follow instructions from wiki: [Installation without Docker](https://github.com/pawelmalak/flame/wiki/Installation-without-docker) -## Functionality +## Development -- Applications - - Create, update, delete and organize applications using GUI - - Pin your favourite apps to homescreen +### Technology -![Homescreen screenshot](./.github/_apps.png) +- Backend + - Node.js + Express + - Sequelize ORM + SQLite +- Frontend + - React + - Redux + - TypeScript +- Deployment + - Docker + - Kubernetes -- Bookmarks - - Create, update, delete and organize bookmarks and categories using GUI - - Pin your favourite categories to homescreen +### Creating dev environment + +```sh +# clone repository +git clone https://github.com/pawelmalak/flame +cd flame -![Homescreen screenshot](./.github/_bookmarks.png) +# run only once +npm run dev-init -- Weather +# start backend and frontend development servers +npm run dev +``` - - Get current temperature, cloud coverage and weather status with animated icons +## Screenshots -- Themes - - Customize your page by choosing from 12 color themes +![Apps screenshot](.github/apps.png) -![Homescreen screenshot](./.github/_themes.png) +![Bookmarks screenshot](.github/bookmarks.png) -## Usage +![Settings screenshot](.github/settings.png) -### Search bar +![Themes screenshot](.github/themes.png) -#### Searching +## Usage -To use search bar you need to type your search query with selected prefix. For example, to search for "what is docker" using google search you would type: `/g what is docker`. +### Authentication -> You can change where to open search results (same/new tab) in the settings +Visit [project wiki](https://github.com/pawelmalak/flame/wiki/Authentication) to read more about authentication -#### Supported search engines +### Search bar -| Name | Prefix | Search URL | -| ---------- | ------ | ----------------------------------- | -| Disroot | /ds | http://search.disroot.org/search?q= | -| DuckDuckGo | /d | https://duckduckgo.com/?q= | -| Google | /g | https://www.google.com/search?q= | +#### Searching -#### Supported services +The default search setting is to search through all your apps and bookmarks. If you want to search using specific search engine, you need to type your search query with selected prefix. For example, to search for "what is docker" using google search you would type: `/g what is docker`. -| Name | Prefix | Search URL | -| ------------------ | ------ | --------------------------------------------- | -| IMDb | /im | https://www.imdb.com/find?q= | -| Reddit | /r | https://www.reddit.com/search?q= | -| Spotify | /sp | https://open.spotify.com/search/ | -| The Movie Database | /mv | https://www.themoviedb.org/search?query= | -| Youtube | /yt | https://www.youtube.com/results?search_query= | +For list of supported search engines, shortcuts and more about searching functionality visit [project wiki](https://github.com/pawelmalak/flame/wiki/Search-bar). ### Setting up weather module @@ -164,9 +178,9 @@ labels: - flame.order=1 # Optional, default is 500; lower number is first in the list ``` -And you must have activated the Docker sync option in the settings panel. +> "Use Docker API" option must be enabled for this to work. You can find it in Settings > Docker -You can set up different apps in the same label adding `;` between each one. +You can also set up different apps in the same label adding `;` between each one. ```yml labels: @@ -214,9 +228,25 @@ metadata: - flame.pawelmalak/order=1 # Optional, default is 500; lower number is first in the list ``` -And you must have activated the Kubernetes sync option in the settings panel. +> "Use Kubernetes Ingress API" option must be enabled for this to work. You can find it in Settings > Docker + +### Import HTML Bookmarks (Experimental) + +- Requirements + - python3 + - pip packages: Pillow, beautifulsoup4 +- Backup your `db.sqlite` before running script! +- Known Issues: + - generated icons are sometimes incorrect + +```bash +pip3 install Pillow, beautifulsoup4 + +cd flame/.dev +python3 bookmarks_importer.py --bookmarks --data +``` -### Custom CSS +### Custom CSS and themes > This is an experimental feature. Its behaviour might change in the future. > diff --git a/api.js b/api.js index 9eb9b9f6..840529a5 100644 --- a/api.js +++ b/api.js @@ -1,6 +1,6 @@ const { join } = require('path'); const express = require('express'); -const errorHandler = require('./middleware/errorHandler'); +const { errorHandler } = require('./middleware'); const api = express(); @@ -21,6 +21,7 @@ api.use('/api/weather', require('./routes/weather')); api.use('/api/categories', require('./routes/category')); api.use('/api/bookmarks', require('./routes/bookmark')); api.use('/api/queries', require('./routes/queries')); +api.use('/api/auth', require('./routes/auth')); // Custom error handler api.use(errorHandler); diff --git a/client/.env b/client/.env index 6dbe18b1..ea56cdf2 100644 --- a/client/.env +++ b/client/.env @@ -1 +1 @@ -REACT_APP_VERSION=1.7.0 \ No newline at end of file +REACT_APP_VERSION=2.1.1 \ No newline at end of file diff --git a/client/package-lock.json b/client/package-lock.json index 27178395..72d0b7aa 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1806,9 +1806,9 @@ } }, "@mdi/js": { - "version": "5.9.55", - "resolved": "https://registry.npmjs.org/@mdi/js/-/js-5.9.55.tgz", - "integrity": "sha512-BbeHMgeK2/vjdJIRnx12wvQ6s8xAYfvMmEAVsUx9b+7GiQGQ9Za8jpwp17dMKr9CgKRvemlAM4S7S3QOtEbp4A==" + "version": "6.4.95", + "resolved": "https://registry.npmjs.org/@mdi/js/-/js-6.4.95.tgz", + "integrity": "sha512-b1/P//1D2KOzta8YRGyoSLGsAlWyUHfxzVBhV4e/ppnjM4DfBgay/vWz7Eg5Ee80JZ4zsQz8h54X+KOahtBk5Q==" }, "@mdi/react": { "version": "1.5.0", @@ -2047,20 +2047,45 @@ } }, "@testing-library/dom": { - "version": "7.30.4", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.30.4.tgz", - "integrity": "sha512-GObDVMaI4ARrZEXaRy4moolNAxWPKvEYNV/fa6Uc2eAzR/t4otS6A7EhrntPBIQLeehL9DbVhscvvv7gd6hWqA==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.11.0.tgz", + "integrity": "sha512-8Ay4UDiMlB5YWy+ZvCeRyFFofs53ebxrWnOFvCoM1HpMAX4cHyuSrCuIM9l2lVuUWUt+Gr3loz/nCwdrnG6ShQ==", "requires": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^4.2.0", - "aria-query": "^4.2.2", + "aria-query": "^5.0.0", "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.4", + "dom-accessibility-api": "^0.5.9", "lz-string": "^1.4.4", - "pretty-format": "^26.6.2" + "pretty-format": "^27.0.2" }, "dependencies": { + "@jest/types": { + "version": "27.2.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.2.5.tgz", + "integrity": "sha512-nmuM4VuDtCZcY+eTpw+0nvstwReMsjPoj7ZR80/BbixulhLaiX+fbv8oeLW8WZlJMcsGQsTmMKT/iTZu1Uy/lQ==", + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "requires": { + "@types/yargs-parser": "*" + } + }, + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, "ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2069,10 +2094,15 @@ "color-convert": "^2.0.1" } }, + "aria-query": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.0.0.tgz", + "integrity": "sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg==" + }, "chalk": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz", - "integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "requires": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -2096,6 +2126,29 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" }, + "pretty-format": { + "version": "27.3.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.3.1.tgz", + "integrity": "sha512-DR/c+pvFc52nLimLROYjnXPtolawm+uWDxr4FjuLDLUn+ktWnSN851KoHwHzzqq6rfCOjkzN8FLgDrSub6UDuA==", + "requires": { + "@jest/types": "^27.2.5", + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "dependencies": { + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==" + } + } + }, + "react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + }, "supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -2107,9 +2160,9 @@ } }, "@testing-library/jest-dom": { - "version": "5.12.0", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.12.0.tgz", - "integrity": "sha512-N9Y82b2Z3j6wzIoAqajlKVF1Zt7sOH0pPee0sUHXHc5cv2Fdn23r+vpWm0MBBoGJtPOly5+Bdx1lnc3CD+A+ow==", + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.15.0.tgz", + "integrity": "sha512-lOMuQidnL1tWHLEWIhL6UvSZC1Qt3OkNe1khvi2h6xFiqpe5O8arYs46OU0qyUGq0cSTbroQyMktYNXu3a7sAA==", "requires": { "@babel/runtime": "^7.9.2", "@types/testing-library__jest-dom": "^5.9.1", @@ -2117,6 +2170,7 @@ "chalk": "^3.0.0", "css": "^3.0.0", "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.5.6", "lodash": "^4.17.15", "redent": "^3.0.0" }, @@ -2191,18 +2245,18 @@ } }, "@testing-library/react": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-11.2.6.tgz", - "integrity": "sha512-TXMCg0jT8xmuU8BkKMtp8l7Z50Ykew5WNX8UoIKTaLFwKkP2+1YDhOLA2Ga3wY4x29jyntk7EWfum0kjlYiSjQ==", + "version": "12.1.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.2.tgz", + "integrity": "sha512-ihQiEOklNyHIpo2Y8FREkyD1QAea054U0MVbwH1m8N9TxeFz+KoJ9LkqoKqJlzx2JDm56DVwaJ1r36JYxZM05g==", "requires": { "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^7.28.1" + "@testing-library/dom": "^8.0.0" } }, "@testing-library/user-event": { - "version": "12.8.3", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-12.8.3.tgz", - "integrity": "sha512-IR0iWbFkgd56Bu5ZI/ej8yQwrkCv8Qydx6RzwbKz9faXazR/+5tvYKsZQgyXJiwgpcva127YO6JcWy7YlCfofQ==", + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz", + "integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==", "requires": { "@babel/runtime": "^7.12.5" } @@ -2213,9 +2267,9 @@ "integrity": "sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA==" }, "@types/aria-query": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.1.tgz", - "integrity": "sha512-S6oPal772qJZHoRZLFc/XoZW2gFvwXusYUmXPXkgxJLuEk2vOt7jc4Yo6z/vtI0EBkbPBVrJJ0B+prLIKiWqHg==" + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.2.tgz", + "integrity": "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==" }, "@types/babel__core": { "version": "7.1.14", @@ -2286,9 +2340,9 @@ } }, "@types/history": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.8.tgz", - "integrity": "sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA==" + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.9.tgz", + "integrity": "sha512-MUc6zSmU3tEVnkQ78q0peeEjKWPUADMlC/t++2bI8WnAG2tvYRPIgHG8lWkXwqc8MsUF6Z2MOf+Mh5sazOmhiQ==" }, "@types/hoist-non-react-statics": { "version": "3.3.1", @@ -2305,9 +2359,9 @@ "integrity": "sha512-giAlZwstKbmvMk1OO7WXSj4OZ0keXAcl2TQq4LWHiiPH2ByaH7WeUzng+Qej8UPxxv+8lRTuouo0iaNDBuzIBA==" }, "@types/http-proxy": { - "version": "1.17.6", - "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.6.tgz", - "integrity": "sha512-+qsjqR75S/ib0ig0R9WN+CDoZeOBU6F2XLewgC4KVgdXiNHiKKHFEMRHOrs5PbYE97D5vataw5wPj4KLYfUkuQ==", + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.7.tgz", + "integrity": "sha512-9hdj6iXH64tHSLTY+Vt2eYOGzSogC+JQ2H7bdPWkuh7KXP5qLllWx++t+K9Wk556c3dkDdPws/SpMRi0sdCT1w==", "requires": { "@types/node": "*" } @@ -2334,12 +2388,126 @@ } }, "@types/jest": { - "version": "26.0.23", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.23.tgz", - "integrity": "sha512-ZHLmWMJ9jJ9PTiT58juykZpL7KjwJywFN3Rr2pTSkyQfydf/rk22yS7W8p5DaVUMQ2BQC7oYiU3FjbTM/mYrOA==", + "version": "27.0.2", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-27.0.2.tgz", + "integrity": "sha512-4dRxkS/AFX0c5XW6IPMNOydLn2tEhNhJV7DnYK+0bjoJZ+QTmfucBlihX7aoEsh/ocYtkLC73UbnBXBXIxsULA==", "requires": { - "jest-diff": "^26.0.0", - "pretty-format": "^26.0.0" + "jest-diff": "^27.0.0", + "pretty-format": "^27.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "27.2.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.2.5.tgz", + "integrity": "sha512-nmuM4VuDtCZcY+eTpw+0nvstwReMsjPoj7ZR80/BbixulhLaiX+fbv8oeLW8WZlJMcsGQsTmMKT/iTZu1Uy/lQ==", + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "requires": { + "@types/yargs-parser": "*" + } + }, + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "diff-sequences": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.0.6.tgz", + "integrity": "sha512-ag6wfpBFyNXZ0p8pcuIDS//D8H062ZQJ3fzYxjpmeKjnz8W4pekL3AI8VohmyZmsWW2PWaHgjsmqR6L13101VQ==" + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "jest-diff": { + "version": "27.3.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.3.1.tgz", + "integrity": "sha512-PCeuAH4AWUo2O5+ksW4pL9v5xJAcIKPUPfIhZBcG1RKv/0+dvaWTQK1Nrau8d67dp65fOqbeMdoil+6PedyEPQ==", + "requires": { + "chalk": "^4.0.0", + "diff-sequences": "^27.0.6", + "jest-get-type": "^27.3.1", + "pretty-format": "^27.3.1" + } + }, + "jest-get-type": { + "version": "27.3.1", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.3.1.tgz", + "integrity": "sha512-+Ilqi8hgHSAdhlQ3s12CAVNd8H96ZkQBfYoXmArzZnOfAtVAJEiPDBirjByEblvG/4LPJmkL+nBqPO3A1YJAEg==" + }, + "pretty-format": { + "version": "27.3.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.3.1.tgz", + "integrity": "sha512-DR/c+pvFc52nLimLROYjnXPtolawm+uWDxr4FjuLDLUn+ktWnSN851KoHwHzzqq6rfCOjkzN8FLgDrSub6UDuA==", + "requires": { + "@jest/types": "^27.2.5", + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "dependencies": { + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==" + } + } + }, + "react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } + } } }, "@types/json-schema": { @@ -2358,9 +2526,9 @@ "integrity": "sha512-1z8k4wzFnNjVK/tlxvrWuK5WMt6mydWWP7+zvH5eFep4oj+UkrfiJTRtjCeBXNpwaA/FYqqtb4/QS4ianFpIRA==" }, "@types/node": { - "version": "12.20.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.12.tgz", - "integrity": "sha512-KQZ1al2hKOONAs2MFv+yTQP1LkDWMrRJ9YCVRalXltOfXsBmH5IownLxQaiq0lnAHwAViLnh2aTYqrPcRGEbgg==" + "version": "16.11.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.6.tgz", + "integrity": "sha512-ua7PgUoeQFjmWPcoo9khiPum3Pd60k4/2ZGXt18sm2Slk0W0xZTqt5Y0Ny1NyBiN1EVQ/+FaF9NcY4Qe6rwk5w==" }, "@types/normalize-package-data": { "version": "2.4.0", @@ -2378,9 +2546,9 @@ "integrity": "sha512-PijRCG/K3s3w1We6ynUKdxEc5AcuuH3NBmMDP8uvKVp6X43UY7NQlTzczakXP3DJR0F4dfNQIGjU2cUeRYs2AA==" }, "@types/prop-types": { - "version": "15.7.3", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz", - "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==" + "version": "15.7.4", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz", + "integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==" }, "@types/q": { "version": "1.5.4", @@ -2388,9 +2556,9 @@ "integrity": "sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug==" }, "@types/react": { - "version": "17.0.5", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.5.tgz", - "integrity": "sha512-bj4biDB9ZJmGAYTWSKJly6bMr4BLUiBrx9ujiJEoP9XIDY9CTaPGxE5QWN/1WjpPLzYF7/jRNnV2nNxNe970sw==", + "version": "17.0.34", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.34.tgz", + "integrity": "sha512-46FEGrMjc2+8XhHXILr+3+/sTe3OfzSPU9YGKILLrUYbQ1CLQC9Daqo1KzENGXAWwrFwiY0l4ZbF20gRvgpWTg==", "requires": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -2398,25 +2566,25 @@ } }, "@types/react-beautiful-dnd": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.0.0.tgz", - "integrity": "sha512-by80tJ8aTTDXT256Gl+RfLRtFjYbUWOnZuEigJgNsJrSEGxvFe5eY6k3g4VIvf0M/6+xoLgfYWoWonlOo6Wqdg==", + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.2.tgz", + "integrity": "sha512-+OvPkB8CdE/bGdXKyIhc/Lm2U7UAYCCJgsqmopFmh9gbAudmslkI8eOrPDjg4JhwSE6wytz4a3/wRjKtovHVJg==", "requires": { "@types/react": "*" } }, "@types/react-dom": { - "version": "17.0.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.3.tgz", - "integrity": "sha512-4NnJbCeWE+8YBzupn/YrJxZ8VnjcJq5iR1laqQ1vkpQgBiA7bwk0Rp24fxsdNinzJY2U+HHS4dJJDPdoMjdJ7w==", + "version": "17.0.11", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.11.tgz", + "integrity": "sha512-f96K3k+24RaLGVu/Y2Ng3e1EbZ8/cVJvypZWd7cy0ofCBaf2lcM46xNhycMZ2xGwbBjRql7hOlZ+e2WlJ5MH3Q==", "requires": { "@types/react": "*" } }, "@types/react-redux": { - "version": "7.1.16", - "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.16.tgz", - "integrity": "sha512-f/FKzIrZwZk7YEO9E1yoxIuDNRiDducxkFlkw/GNMGEnK9n4K8wJzlJBghpSuOVDgEUHoDkDF7Gi9lHNQR4siw==", + "version": "7.1.20", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.20.tgz", + "integrity": "sha512-q42es4c8iIeTgcnB+yJgRTTzftv3eYYvCZOh1Ckn2eX/3o5TdsQYKUWpLoLuGlcY/p+VAhV9IOEZJcWk/vfkXw==", "requires": { "@types/hoist-non-react-statics": "^3.3.0", "@types/react": "*", @@ -2425,9 +2593,9 @@ } }, "@types/react-router": { - "version": "5.1.14", - "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.14.tgz", - "integrity": "sha512-LAJpqYUaCTMT2anZheoidiIymt8MuX286zoVFPM3DVb23aQBH0mAkFvzpd4LKqiolV8bBtZWT5Qp7hClCNDENw==", + "version": "5.1.17", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.17.tgz", + "integrity": "sha512-RNSXOyb3VyRs/EOGmjBhhGKTbnN6fHWvy5FNLzWfOWOGjgVUKqJZXfpKzLmgoU8h6Hj8mpALj/mbXQASOb92wQ==", "requires": { "@types/history": "*", "@types/react": "*" @@ -2452,9 +2620,9 @@ } }, "@types/scheduler": { - "version": "0.16.1", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.1.tgz", - "integrity": "sha512-EaCxbanVeyxDRTQBkdLb3Bvl/HK7PBK6UJjsSixB0iHKoWxE5uu2Q/DgtpOhPIojN0Zl1whvOd7PoHs2P0s5eA==" + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", + "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" }, "@types/source-list-map": { "version": "0.1.2", @@ -2472,9 +2640,9 @@ "integrity": "sha512-0VBprVqfgFD7Ehb2vd8Lh9TG3jP98gvr8rgehQqzztZNI7o8zS8Ad4jyZneKELphpuE212D8J70LnSNQSyO6bQ==" }, "@types/testing-library__jest-dom": { - "version": "5.9.5", - "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.9.5.tgz", - "integrity": "sha512-ggn3ws+yRbOHog9GxnXiEZ/35Mow6YtPZpd7Z5mKDeZS/o7zx3yAle0ov/wjhVB5QT4N2Dt+GNoGCdqkBGCajQ==", + "version": "5.14.1", + "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.1.tgz", + "integrity": "sha512-Gk9vaXfbzc5zCXI9eYE9BI5BNHEp4D3FWjgqBE/ePGYElLAP+KvxBcsdkwfIVvezs605oiyd/VrpiHe3Oeg+Aw==", "requires": { "@types/jest": "*" } @@ -3159,11 +3327,18 @@ "integrity": "sha512-1uIESzroqpaTzt9uX48HO+6gfnKu3RwvWdCcWSrX4csMInJfCo1yvKPNXCwXFRpJqRW25tiASb6No0YH57PXqg==" }, "axios": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", - "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz", + "integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==", "requires": { - "follow-redirects": "^1.10.0" + "follow-redirects": "^1.14.4" + }, + "dependencies": { + "follow-redirects": { + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.5.tgz", + "integrity": "sha512-wtphSXy7d4/OR+MvIFbCVBDzZ5520qV8XfPklSN5QtxuMUJZ+b0Wnst1e1lCDocfzuCkHqj8k0FpZqO+UIaKNA==" + } } }, "axobject-query": { @@ -3722,6 +3897,15 @@ "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", "optional": true }, + "bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "optional": true, + "requires": { + "file-uri-to-path": "1.0.0" + } + }, "bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -4906,9 +5090,9 @@ } }, "csstype": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.8.tgz", - "integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw==" + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.9.tgz", + "integrity": "sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw==" }, "cyclist": { "version": "1.0.1", @@ -5202,9 +5386,9 @@ "integrity": "sha1-s55/HabrCnW6nBcySzR1PEfgZU0=" }, "dns-packet": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-1.3.1.tgz", - "integrity": "sha512-0UxfQkMhYAUaZI+xrNZOz/as5KgDU0M/fQ9b6SpkyLbk3GEswDi6PADJVaYJradtRVsRIlF1zLyOodbcTCDzUg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-1.3.4.tgz", + "integrity": "sha512-BQ6F4vycLXBvdrJZ6S3gZewt6rcrks9KBgM9vrhW+knGRqc8uEdT7fuCwloc7nny5xNoMJ17HGH0R/6fpo8ECA==", "requires": { "ip": "^1.1.0", "safe-buffer": "^5.0.1" @@ -5227,9 +5411,9 @@ } }, "dom-accessibility-api": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.4.tgz", - "integrity": "sha512-TvrjBckDy2c6v6RLxPv5QXOnU+SmF9nBII5621Ve5fu6Z/BDrENurBEvlC1f44lKEUVqOpK4w9E5Idc5/EgkLQ==" + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.10.tgz", + "integrity": "sha512-Xu9mD0UjrJisTmv7lmVSDMagQcU9R5hwAbxsaAE/35XPnPLJobbuREfV/rraiSaEj/UOvgrzQs66zyTWTlyd+g==" }, "dom-converter": { "version": "0.2.0", @@ -6620,6 +6804,12 @@ } } }, + "file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "optional": true + }, "filesize": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/filesize/-/filesize-6.1.0.tgz", @@ -7481,9 +7671,9 @@ } }, "http-proxy-middleware": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.0.tgz", - "integrity": "sha512-S+RN5njuyvYV760aiVKnyuTXqUMcSIvYOsHA891DOVQyrdZOwaXtBHpt9FUVPEDAsOvsPArZp6VXQLs44yvkow==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.1.tgz", + "integrity": "sha512-cfaXRVoZxSed/BmkA7SwBVNI9Kj7HFltaE5rqYOub5kWzWZ+gofV2koVN1j2rMW7pEfSSlCHGJ31xmuyFyfLOg==", "requires": { "@types/http-proxy": "^1.17.5", "http-proxy": "^1.18.1", @@ -9688,6 +9878,11 @@ "object.assign": "^4.1.2" } }, + "jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + }, "killable": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz", @@ -10277,6 +10472,12 @@ "resolved": "https://registry.npmjs.org/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz", "integrity": "sha1-iZ8R2WhuXgXLkbNdXw5jt3PPyQE=" }, + "nan": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz", + "integrity": "sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==", + "optional": true + }, "nanoid": { "version": "3.1.22", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.22.tgz", @@ -10935,9 +11136,9 @@ "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=" }, "path-parse": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", - "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==" + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "path-to-regexp": { "version": "0.1.7", @@ -12114,9 +12315,9 @@ "integrity": "sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=" }, "prettier": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.2.tgz", - "integrity": "sha512-lnJzDfJ66zkMy58OL5/NY5zp70S7Nz6KqcKkXYzn2tMVrNxvbqaBpg7H3qHaLxCJ5lNMsGuM8+ohS7cZrthdLQ==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz", + "integrity": "sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA==", "dev": true }, "pretty-bytes": { @@ -12548,16 +12749,31 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "react-redux": { - "version": "7.2.4", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.4.tgz", - "integrity": "sha512-hOQ5eOSkEJEXdpIKbnRyl04LhaWabkDPV+Ix97wqQX3T3d2NQ8DUblNXXtNMavc7DpswyQM6xfaN4HQDKNY2JA==", + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.6.tgz", + "integrity": "sha512-10RPdsz0UUrRL1NZE0ejTkucnclYSgXp5q+tB5SWx2qeG2ZJQJyymgAhwKy73yiL/13btfB6fPr+rgbMAaZIAQ==", "requires": { - "@babel/runtime": "^7.12.1", - "@types/react-redux": "^7.1.16", + "@babel/runtime": "^7.15.4", + "@types/react-redux": "^7.1.20", "hoist-non-react-statics": "^3.3.2", "loose-envify": "^1.4.0", "prop-types": "^15.7.2", - "react-is": "^16.13.1" + "react-is": "^17.0.2" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.0.tgz", + "integrity": "sha512-Nht8L0O8YCktmsDV6FqFue7vQLRx3Hb0B37lS5y0jDRqRxlBG4wIJHnf9/bgSE2UyipKFA01YtS+npRdTWBUyw==", + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + } } }, "react-refresh": { @@ -12793,9 +13009,9 @@ } }, "redux": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/redux/-/redux-4.1.0.tgz", - "integrity": "sha512-uI2dQN43zqLWCt6B/BMGRMY6db7TTY4qeHHfGeKb3EOhmOKjU3KdWvNLJyqaHRksv/ErdNH7cFZWg9jXtewy4g==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.1.2.tgz", + "integrity": "sha512-SH8PglcebESbd/shgf6mii6EIoRM0zrQyjcuQ+ojmfxjTtE0z9Y8pa62iA/OJ58qjP6j27uyW4kUF4jl/jd6sw==", "requires": { "@babel/runtime": "^7.9.2" } @@ -12806,9 +13022,9 @@ "integrity": "sha512-cNJ8Q/EtjhQaZ71c8I9+BPySIBVEKssbPpskBfsXqb8HJ002A3KRVHfeRzwRo6mGPqsm7XuHTqNSNeS1Khig0A==" }, "redux-thunk": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.3.0.tgz", - "integrity": "sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw==" + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.0.tgz", + "integrity": "sha512-/y6ZKQNU/0u8Bm7ROLq9Pt/7lU93cT0IucYMrubo89ENjxPa7i8pqLKu6V4X7/TvYovQ6x01unTeyeZ9lgXiTA==" }, "regenerate": { "version": "1.4.2", @@ -14489,9 +14705,9 @@ "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==" }, "tar": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.0.tgz", - "integrity": "sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA==", + "version": "6.1.11", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz", + "integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==", "requires": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -14737,9 +14953,9 @@ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" }, "tmpl": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz", - "integrity": "sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE=" + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==" }, "to-arraybuffer": { "version": "1.0.1", @@ -14926,9 +15142,9 @@ } }, "typescript": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.2.4.tgz", - "integrity": "sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==" + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.4.4.tgz", + "integrity": "sha512-DqGhF5IKoBl8WNf8C1gu8q0xZSInh9j1kJJMqT3a94w1JzVaBU4EXOSMrz9yDqMT0xt3selp83fuFMQ0uzv6qA==" }, "unbox-primitive": { "version": "1.0.1", @@ -15123,9 +15339,9 @@ } }, "url-parse": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.1.tgz", - "integrity": "sha512-HOfCOUJt7iSYzEx/UqgtwKRMC6EU91NFhsCHMv9oM03VJcVo2Qrp8T8kI9D7amFf1cu+/3CEhgb3rF9zL7k85Q==", + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.3.tgz", + "integrity": "sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==", "requires": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" @@ -15396,7 +15612,11 @@ "version": "1.2.13", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", - "optional": true + "optional": true, + "requires": { + "bindings": "^1.5.0", + "nan": "^2.12.1" + } }, "glob-parent": { "version": "3.1.0", @@ -15525,9 +15745,9 @@ } }, "web-vitals": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-1.1.2.tgz", - "integrity": "sha512-PFMKIY+bRSXlMxVAQ+m2aw9c/ioUYfDgrYot0YUa+/xa0sakubWhSDyxAKwzymvXVdF4CZI71g06W+mqhzu6ig==" + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.2.tgz", + "integrity": "sha512-nZnEH8dj+vJFqCRYdvYv0a59iLXsb8jJkt+xvXfwgnkyPdsSLtKNlYmtTDiHmTNGXeSXtpjTTUcNvFtrAk6VMQ==" }, "webidl-conversions": { "version": "6.1.0", @@ -15995,7 +16215,11 @@ "version": "1.2.13", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", - "optional": true + "optional": true, + "requires": { + "bindings": "^1.5.0", + "nan": "^2.12.1" + } }, "glob-parent": { "version": "3.1.0", @@ -16250,9 +16474,9 @@ } }, "ws": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.1.tgz", - "integrity": "sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.2.tgz", + "integrity": "sha512-zmhltoSR8u1cnDsD43TX59mzoMZsLKqUweyYBAIvTngR3shc0W6aOZylZmq/7hqyVxPdi+5Ud2QInblgyE72fw==", "requires": { "async-limiter": "~1.0.0" } @@ -16694,9 +16918,9 @@ } }, "ws": { - "version": "7.4.5", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.5.tgz", - "integrity": "sha512-xzyu3hFvomRfXKH8vOFMU3OguG6oOvhXMo3xsGy3xWExqaM2dxBbVxuD99O7m3ZUFMvvscsZDqxfgMaRr/Nr1g==" + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.5.tgz", + "integrity": "sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w==" }, "xml-name-validator": { "version": "3.0.0", diff --git a/client/package.json b/client/package.json index 6e056676..096f723d 100644 --- a/client/package.json +++ b/client/package.json @@ -3,33 +3,34 @@ "version": "0.1.0", "private": true, "dependencies": { - "@mdi/js": "^5.9.55", + "@mdi/js": "^6.4.95", "@mdi/react": "^1.5.0", - "@testing-library/jest-dom": "^5.12.0", - "@testing-library/react": "^11.2.6", - "@testing-library/user-event": "^12.8.3", - "@types/jest": "^26.0.23", - "@types/node": "^12.20.12", - "@types/react": "^17.0.5", - "@types/react-beautiful-dnd": "^13.0.0", - "@types/react-dom": "^17.0.3", - "@types/react-redux": "^7.1.16", + "@testing-library/jest-dom": "^5.15.0", + "@testing-library/react": "^12.1.2", + "@testing-library/user-event": "^13.5.0", + "@types/jest": "^27.0.2", + "@types/node": "^16.11.6", + "@types/react": "^17.0.34", + "@types/react-beautiful-dnd": "^13.1.2", + "@types/react-dom": "^17.0.11", + "@types/react-redux": "^7.1.20", "@types/react-router-dom": "^5.1.7", - "axios": "^0.21.1", + "axios": "^0.24.0", "external-svg-loader": "^1.3.4", - "http-proxy-middleware": "^2.0.0", + "http-proxy-middleware": "^2.0.1", + "jwt-decode": "^3.1.2", "react": "^17.0.2", "react-beautiful-dnd": "^13.1.0", "react-dom": "^17.0.2", - "react-redux": "^7.2.4", + "react-redux": "^7.2.6", "react-router-dom": "^5.2.0", "react-scripts": "4.0.3", - "redux": "^4.1.0", + "redux": "^4.1.2", "redux-devtools-extension": "^2.13.9", - "redux-thunk": "^2.3.0", + "redux-thunk": "^2.4.0", "skycons-ts": "^0.2.0", - "typescript": "^4.2.4", - "web-vitals": "^1.1.2" + "typescript": "^4.4.4", + "web-vitals": "^2.1.2" }, "scripts": { "start": "react-scripts start", @@ -56,6 +57,6 @@ ] }, "devDependencies": { - "prettier": "^2.3.2" + "prettier": "^2.4.1" } } diff --git a/client/public/icons/apple-touch-icon-114x114.png b/client/public/icons/apple-touch-icon-114x114.png new file mode 100644 index 00000000..301cd252 Binary files /dev/null and b/client/public/icons/apple-touch-icon-114x114.png differ diff --git a/client/public/icons/apple-touch-icon-120x120.png b/client/public/icons/apple-touch-icon-120x120.png new file mode 100644 index 00000000..28ba56d6 Binary files /dev/null and b/client/public/icons/apple-touch-icon-120x120.png differ diff --git a/client/public/icons/apple-touch-icon-144x144.png b/client/public/icons/apple-touch-icon-144x144.png new file mode 100644 index 00000000..f13012b8 Binary files /dev/null and b/client/public/icons/apple-touch-icon-144x144.png differ diff --git a/client/public/icons/apple-touch-icon-152x152.png b/client/public/icons/apple-touch-icon-152x152.png new file mode 100644 index 00000000..e1a5b1d1 Binary files /dev/null and b/client/public/icons/apple-touch-icon-152x152.png differ diff --git a/client/public/icons/apple-touch-icon-180x180.png b/client/public/icons/apple-touch-icon-180x180.png new file mode 100644 index 00000000..33d6131f Binary files /dev/null and b/client/public/icons/apple-touch-icon-180x180.png differ diff --git a/client/public/icons/apple-touch-icon-57x57.png b/client/public/icons/apple-touch-icon-57x57.png new file mode 100644 index 00000000..b07d1da5 Binary files /dev/null and b/client/public/icons/apple-touch-icon-57x57.png differ diff --git a/client/public/icons/apple-touch-icon-72x72.png b/client/public/icons/apple-touch-icon-72x72.png new file mode 100644 index 00000000..0ebf2c8c Binary files /dev/null and b/client/public/icons/apple-touch-icon-72x72.png differ diff --git a/client/public/icons/apple-touch-icon-76x76.png b/client/public/icons/apple-touch-icon-76x76.png new file mode 100644 index 00000000..d636fe97 Binary files /dev/null and b/client/public/icons/apple-touch-icon-76x76.png differ diff --git a/client/public/icons/apple-touch-icon.png b/client/public/icons/apple-touch-icon.png new file mode 100644 index 00000000..b07d1da5 Binary files /dev/null and b/client/public/icons/apple-touch-icon.png differ diff --git a/client/public/favicon.ico b/client/public/icons/favicon.ico similarity index 100% rename from client/public/favicon.ico rename to client/public/icons/favicon.ico diff --git a/client/public/index.html b/client/public/index.html index c93d95eb..32e17fef 100644 --- a/client/public/index.html +++ b/client/public/index.html @@ -2,7 +2,51 @@ - + + + + + + + + + + (getConfig()); -// Set theme -if (localStorage.theme) { - store.dispatch(setTheme(localStorage.theme)); +// Validate token +if (localStorage.token) { + store.dispatch(autoLogin()); } -// Check for updates -checkVersion(); +export const App = (): JSX.Element => { + const { config, loading } = useSelector((state: State) => state.config); -// fetch queries -store.dispatch(fetchQueries()); + const dispath = useDispatch(); + const { fetchQueries, setTheme, logout, createNotification } = + bindActionCreators(actionCreators, dispath); + + useEffect(() => { + // check if token is valid + const tokenIsValid = setInterval(() => { + if (localStorage.token) { + const expiresIn = decodeToken(localStorage.token).exp * 1000; + const now = new Date().getTime(); + + if (now > expiresIn) { + logout(); + createNotification({ + title: 'Info', + message: 'Session expired. You have been logged out', + }); + } + } + }, 1000); + + // set user theme if present + if (localStorage.theme) { + setTheme(localStorage.theme); + } + + // check for updated + checkVersion(); + + // load custom search queries + fetchQueries(); + + return () => window.clearInterval(tokenIsValid); + }, []); + + // If there is no user theme, set the default one + useEffect(() => { + if (!loading && !localStorage.theme) { + setTheme(config.defaultTheme, false); + } + }, [loading]); -const App = (): JSX.Element => { return ( - + <> @@ -42,8 +83,6 @@ const App = (): JSX.Element => { - + ); }; - -export default App; diff --git a/client/src/components/Actions/TableActions.module.css b/client/src/components/Actions/TableActions.module.css new file mode 100644 index 00000000..69028a9b --- /dev/null +++ b/client/src/components/Actions/TableActions.module.css @@ -0,0 +1,12 @@ +.TableActions { + display: flex; + align-items: center; +} + +.TableAction { + width: 22px; +} + +.TableAction:hover { + cursor: pointer; +} diff --git a/client/src/components/Actions/TableActions.tsx b/client/src/components/Actions/TableActions.tsx new file mode 100644 index 00000000..6d9460c9 --- /dev/null +++ b/client/src/components/Actions/TableActions.tsx @@ -0,0 +1,81 @@ +import { Icon } from '../UI'; +import classes from './TableActions.module.css'; + +interface Entity { + id: number; + name: string; + isPinned?: boolean; + isPublic: boolean; +} + +interface Props { + entity: Entity; + deleteHandler: (id: number, name: string) => void; + updateHandler: (id: number) => void; + pinHanlder?: (id: number) => void; + changeVisibilty: (id: number) => void; + showPin?: boolean; +} + +export const TableActions = (props: Props): JSX.Element => { + const { + entity, + deleteHandler, + updateHandler, + pinHanlder, + changeVisibilty, + showPin = true, + } = props; + + const _pinHandler = pinHanlder || function () {}; + + return ( + + {/* DELETE */} +
deleteHandler(entity.id, entity.name)} + tabIndex={0} + > + +
+ + {/* UPDATE */} +
updateHandler(entity.id)} + tabIndex={0} + > + +
+ + {/* PIN */} + {showPin && ( +
_pinHandler(entity.id)} + tabIndex={0} + > + {entity.isPinned ? ( + + ) : ( + + )} +
+ )} + + {/* VISIBILITY */} +
changeVisibilty(entity.id)} + tabIndex={0} + > + {entity.isPublic ? ( + + ) : ( + + )} +
+ + ); +}; diff --git a/client/src/components/Apps/AppCard/AppCard.module.css b/client/src/components/Apps/AppCard/AppCard.module.css index bfb48806..c202ad87 100644 --- a/client/src/components/Apps/AppCard/AppCard.module.css +++ b/client/src/components/Apps/AppCard/AppCard.module.css @@ -10,7 +10,7 @@ text-transform: uppercase; } -.AppCardIcon { +.AppIcon { width: 35px; height: 35px; margin-right: 0.5em; diff --git a/client/src/components/Apps/AppCard/AppCard.tsx b/client/src/components/Apps/AppCard/AppCard.tsx index c8d66fe0..1463055c 100644 --- a/client/src/components/Apps/AppCard/AppCard.tsx +++ b/client/src/components/Apps/AppCard/AppCard.tsx @@ -1,64 +1,103 @@ +import { Fragment } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { bindActionCreators } from 'redux'; + import { App, Category } from '../../../interfaces'; -import { iconParser, searchConfig, urlParser } from '../../../utility'; -import Icon from '../../UI/Icons/Icon/Icon'; +import { actionCreators } from '../../../store'; +import { State } from '../../../store/reducers'; +import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility'; +import { Icon } from '../../UI'; import classes from './AppCard.module.css'; -interface ComponentProps { +interface Props { category: Category; - apps: App[] - pinHandler?: Function; + fromHomepage?: boolean; } -const AppCard = (props: ComponentProps): JSX.Element => { +export const AppCard = (props: Props): JSX.Element => { + const { category, fromHomepage = false } = props; + + const { + config: { config }, + auth: { isAuthenticated }, + } = useSelector((state: State) => state); + + const dispatch = useDispatch(); + const { setEditCategory } = bindActionCreators(actionCreators, dispatch); + return (
-

{props.category.name}

+

{ + if (!fromHomepage && isAuthenticated) { + setEditCategory(category); + } + }} + > + {category.name} +

+
- {props.apps.map((app: App) => { + {category.apps.map((app: App) => { const [displayUrl, redirectUrl] = urlParser(app.url); - let iconEl: JSX.Element; - const { icon } = app; - - if (/.(jpeg|jpg|png)$/i.test(icon)) { - iconEl = ( - {`${app.name} - ); - } else if (/.(svg)$/i.test(icon)) { - iconEl = ( -
- -
- ); - } else { - iconEl = ; + let iconEl: JSX.Element = ; + + if (app.icon) { + const { icon, name } = app; + + if (isImage(icon)) { + const source = isUrl(icon) ? icon : `/uploads/${icon}`; + + iconEl = ( +
+ {`${name} +
+ ); + } else if (isSvg(icon)) { + const source = isUrl(icon) ? icon : `/uploads/${icon}`; + + iconEl = ( +
+ +
+ ); + } else { + iconEl = ( +
+ +
+ ); + } } return ( -
{iconEl}
+ target={config.appsSameTab ? '' : '_blank'} + rel="noreferrer" + key={`app-${app.id}`} + > + {app.icon && iconEl}
{app.name}
{displayUrl}
- ) + ); })}
- ) -} - -export default AppCard; + ); +}; diff --git a/client/src/components/Apps/AppForm/AppForm.tsx b/client/src/components/Apps/AppForm/AppForm.tsx deleted file mode 100644 index d67fe4c6..00000000 --- a/client/src/components/Apps/AppForm/AppForm.tsx +++ /dev/null @@ -1,325 +0,0 @@ -import { ChangeEvent, Dispatch, Fragment, SetStateAction, SyntheticEvent, useEffect, useState } from 'react'; -import { connect } from 'react-redux'; - -import { App, Category, GlobalState, NewApp, NewCategory, NewNotification } from '../../../interfaces'; -import { - addApp, - addAppCategory, - createNotification, - getAppCategories, - updateApp, - updateAppCategory, -} from '../../../store/actions'; -import Button from '../../UI/Buttons/Button/Button'; -import InputGroup from '../../UI/Forms/InputGroup/InputGroup'; -import ModalForm from '../../UI/Forms/ModalForm/ModalForm'; -import { ContentType } from '../Apps'; -import classes from './AppForm.module.css'; - -interface ComponentProps { - modalHandler: () => void; - contentType: ContentType; - categories: Category[]; - category?: Category; - app?: App; - addAppCategory: (formData: NewCategory) => void; - addApp: (formData: NewApp | FormData) => void; - updateAppCategory: (id: number, formData: NewCategory) => void; - updateApp: (id: number, formData: NewApp | FormData, previousCategoryId: number) => void; - createNotification: (notification: NewNotification) => void; -} - -const AppForm = (props: ComponentProps): JSX.Element => { - const [useCustomIcon, setUseCustomIcon] = useState(false); - const [customIcon, setCustomIcon] = useState(null); - const [categoryData, setCategoryData] = useState({ - name: '', - type: 'apps' - }) - - const [appData, setAppData] = useState({ - name: '', - url: '', - categoryId: -1, - icon: '', - }); - - // Load category data if provided for editing - useEffect(() => { - if (props.category) { - setCategoryData({ name: props.category.name, type: props.category.type }); - } else { - setCategoryData({ name: '', type: "apps" }); - } - }, [props.category]); - - // Load app data if provided for editing - useEffect(() => { - if (props.app) { - setAppData({ - name: props.app.name, - url: props.app.url, - categoryId: props.app.categoryId, - icon: props.app.icon, - }); - } else { - setAppData({ - name: '', - url: '', - categoryId: -1, - icon: '', - }); - } - }, [props.app]); - - const formSubmitHandler = (e: SyntheticEvent): void => { - e.preventDefault(); - - const createFormData = (): FormData => { - const data = new FormData(); - Object.entries(appData).forEach((entry: [string, any]) => { - data.append(entry[0], entry[1]); - }); - if (customIcon) { - data.append('icon', customIcon); - } - - return data; - }; - - if (!props.category && !props.app) { - // Add new - if (props.contentType === ContentType.category) { - // Add category - props.addAppCategory(categoryData); - setCategoryData({ name: '', type: 'apps' }); - } else if (props.contentType === ContentType.app) { - // Add app - if (appData.categoryId === -1) { - props.createNotification({ - title: 'Error', - message: 'Please select a category' - }) - return; - } - if (customIcon) { - const data = createFormData(); - props.addApp(data); - } else { - props.addApp(appData); - } - setAppData({ - name: '', - url: '', - categoryId: appData.categoryId, - icon: '' - }) - } - } else { - // Update - if (props.contentType === ContentType.category && props.category) { - // Update category - props.updateAppCategory(props.category.id, categoryData); - setCategoryData({ name: '', type: 'apps' }); - } else if (props.contentType === ContentType.app && props.app) { - // Update app - if (customIcon) { - const data = createFormData(); - props.updateApp(props.app.id, data, props.app.categoryId); - } else { - props.updateApp(props.app.id, appData, props.app.categoryId); - props.modalHandler(); - } - } - - setAppData({ - name: '', - url: '', - categoryId: -1, - icon: '' - }); - - setCustomIcon(null); - } - } - - const inputChangeHandler = (e: ChangeEvent, setDataFunction: Dispatch>, data: any): void => { - setDataFunction({ - ...data, - [e.target.name]: e.target.value - }) - } - - const toggleUseCustomIcon = (): void => { - setUseCustomIcon(!useCustomIcon); - setCustomIcon(null); - }; - - const fileChangeHandler = (e: ChangeEvent): void => { - if (e.target.files) { - setCustomIcon(e.target.files[0]); - } - } - - let button = - - if (!props.category && !props.app) { - if (props.contentType === ContentType.category) { - button = ; - } else { - button = ; - } - } else if (props.category) { - button = - } else if (props.app) { - button = - } - - return ( - - {props.contentType === ContentType.category - ? ( - - - - inputChangeHandler(e, setCategoryData, categoryData)} - /> - - - ) - : ( - - - - inputChangeHandler(e, setAppData, appData)} - /> - - - - inputChangeHandler(e, setAppData, appData)} - /> - - - {' '}Check supported URL formats - - - - - - - - {!useCustomIcon - // use mdi icon - ? ( - - inputChangeHandler(e, setAppData, appData)} - /> - - Use icon name from MDI. - - {' '}Click here for reference - - - toggleUseCustomIcon()} - className={classes.Switch}> - Switch to custom icon upload - - ) - // upload custom icon - : ( - - fileChangeHandler(e)} - accept='.jpg,.jpeg,.png,.svg' - /> - toggleUseCustomIcon()} - className={classes.Switch}> - Switch to MDI - - ) - } - - ) - } - {button} - - ); -}; - -const mapStateToProps = (state: GlobalState) => { - return { - categories: state.app.categories - } -} - -const dispatchMap = { - getAppCategories, - addAppCategory, - addApp, - updateAppCategory, - updateApp, - createNotification -} - -export default connect(mapStateToProps, dispatchMap)(AppForm); diff --git a/client/src/components/Apps/AppGrid/AppGrid.module.css b/client/src/components/Apps/AppGrid/AppGrid.module.css index 78749183..daff4417 100644 --- a/client/src/components/Apps/AppGrid/AppGrid.module.css +++ b/client/src/components/Apps/AppGrid/AppGrid.module.css @@ -20,21 +20,3 @@ grid-template-columns: repeat(4, 1fr); } } - -.GridMessage { - color: var(--color-primary); -} - -.GridMessage a { - color: var(--color-accent); - font-weight: 600; -} - -.AppsMessage { - color: var(--color-primary); -} - -.AppsMessage a { - color: var(--color-accent); - font-weight: 600; -} \ No newline at end of file diff --git a/client/src/components/Apps/AppGrid/AppGrid.tsx b/client/src/components/Apps/AppGrid/AppGrid.tsx index 079d84d2..42913e02 100644 --- a/client/src/components/Apps/AppGrid/AppGrid.tsx +++ b/client/src/components/Apps/AppGrid/AppGrid.tsx @@ -1,63 +1,72 @@ +import { useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; -import { App, Category } from '../../../interfaces'; -import AppCard from '../AppCard/AppCard'; +import { Category } from '../../../interfaces'; +import { State } from '../../../store/reducers'; +import { Message } from '../../UI'; +import { AppCard } from '../AppCard/AppCard'; import classes from './AppGrid.module.css'; -interface ComponentProps { +interface Props { categories: Category[]; - apps: App[]; totalCategories?: number; searching: boolean; + fromHomepage?: boolean; } -const AppGrid = (props: ComponentProps): JSX.Element => { +export const AppGrid = (props: Props): JSX.Element => { + const { + categories, + totalCategories, + searching, + fromHomepage = false, + } = props; + + const { + config: { config } + } = useSelector((state: State) => state); + + const shouldBeShown = (category: Category) => { + return !config.hideEmptyCategories || category.apps.length > 0 || !fromHomepage + } + let apps: JSX.Element; - if (props.categories.length > 0) { - if (props.apps.length > 0) { + if (categories.length && categories.some(shouldBeShown)) { + if (searching && !categories[0].apps.length) { + apps = No apps match your search criteria; + } else { apps = (
- {props.categories.map((category: Category): JSX.Element => { - return app.categoryId === category.id)} /> - })} + {categories.filter(shouldBeShown).map( + (category: Category): JSX.Element => ( + + ) + )}
); - } else { - if (props.searching) { - apps = ( -

- No apps match your search criteria -

- ); - } else { - apps = ( -

- You don't have any applications. You can add a new one from the{' '} - /applications menu -

- ); - } } } else { - if (props.totalCategories) { + if (totalCategories && !config.hideEmptyCategories) { apps = ( -

- There are no pinned application categories. You can pin them from the{' '} - /applications menu -

+ + There are no pinned categories. You can pin them from the{' '} + /apps menu + ); } else { apps = ( -

- You don't have any applications. You can add a new one from the{' '} - /applications menu -

+ + You don't have any apps. You can add a new one from{' '} + /apps menu + ); } } return apps; }; - -export default AppGrid; diff --git a/client/src/components/Apps/AppTable/AppTable.tsx b/client/src/components/Apps/AppTable/AppTable.tsx index 2d467cbe..a3f37e50 100644 --- a/client/src/components/Apps/AppTable/AppTable.tsx +++ b/client/src/components/Apps/AppTable/AppTable.tsx @@ -1,251 +1,129 @@ -import { Fragment, KeyboardEvent, useEffect, useState } from 'react'; +import { Fragment, useEffect, useState } from 'react'; import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd'; -import { connect } from 'react-redux'; -import { Link } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; +import { bindActionCreators } from 'redux'; + +import { App, Category } from '../../../interfaces'; +import { actionCreators } from '../../../store'; +import { State } from '../../../store/reducers'; +import { appTemplate } from '../../../utility'; +import { TableActions } from '../../Actions/TableActions'; +import { Message, Table } from '../../UI'; + +// Redux +// Typescript +// UI +interface Props { + openFormForUpdating: (data: Category | App) => void; +} -import { App, Category, NewNotification } from '../../../interfaces'; -import { - createNotification, - deleteApp, - deleteAppCategory, - pinApp, - pinAppCategory, - reorderAppCategories, - reorderApps, - updateConfig, -} from '../../../store/actions'; -import { searchConfig } from '../../../utility'; -import Icon from '../../UI/Icons/Icon/Icon'; -import Table from '../../UI/Table/Table'; -import { ContentType } from '../Apps'; -import classes from './AppTable.module.css'; +export const AppsTable = ({ openFormForUpdating }: Props): JSX.Element => { + const { + apps: { categoryInEdit }, + config: { config }, + } = useSelector((state: State) => state); -interface ComponentProps { - contentType: ContentType; - categories: Category[]; - apps: App[]; - pinAppCategory: (category: Category) => void; - deleteAppCategory: (id: number) => void; - reorderAppCategories: (categories: Category[]) => void; - updateHandler: (data: Category | App) => void; - pinApp: (app: App) => void; - deleteApp: (id: number, categoryId: number) => void; - reorderApps: (apps: App[]) => void; - updateConfig: (formData: any) => void; - createNotification: (notification: NewNotification) => void; -} + const dispatch = useDispatch(); + const { + deleteApp, + updateApp, + createNotification, + reorderApps, + } = bindActionCreators(actionCreators, dispatch); -const AppTable = (props: ComponentProps): JSX.Element => { - const [localCategories, setLocalCategories] = useState([]); const [localApps, setLocalApps] = useState([]); - const [isCustomOrder, setIsCustomOrder] = useState(false); - // Copy categories array - useEffect(() => { - setLocalCategories([...props.categories]); - }, [props.categories]); - // Copy apps array useEffect(() => { - setLocalApps([...props.apps]); - }, [props.apps]); - - // Check ordering - useEffect(() => { - const order = searchConfig("useOrdering", ""); + if (categoryInEdit) { + setLocalApps([...categoryInEdit.apps]); + } + }, [categoryInEdit]); + + // Drag and drop handler + const dragEndHanlder = (result: DropResult): void => { + if (config.useOrdering !== 'orderId') { + createNotification({ + title: 'Error', + message: 'Custom order is disabled', + }); + return; + } - if (order === "orderId") { - setIsCustomOrder(true); + if (!result.destination) { + return; } - }, []); - const deleteCategoryHandler = (category: Category): void => { - const proceed = window.confirm( - `Are you sure you want to delete ${category.name}? It will delete ALL assigned apps` - ); + const tmpApps = [...localApps]; + const [movedApp] = tmpApps.splice(result.source.index, 1); + tmpApps.splice(result.destination.index, 0, movedApp); - if (proceed) { - props.deleteAppCategory(category.id); - } + setLocalApps(tmpApps); + + const categoryId = categoryInEdit?.id || -1; + reorderApps(tmpApps, categoryId); }; - const deleteAppHandler = (app: App): void => { - const proceed = window.confirm( - `Are you sure you want to delete ${app.name} at ${app.url} ?` - ); + // Action hanlders + const deleteAppHandler = (id: number, name: string) => { + const categoryId = categoryInEdit?.id || -1; + const proceed = window.confirm(`Are you sure you want to delete ${name}?`); if (proceed) { - props.deleteApp(app.id, app.categoryId); + deleteApp(id, categoryId); } }; - // Support keyboard navigation for actions - const keyboardActionHandler = ( - e: KeyboardEvent, - object: any, - handler: Function - ) => { - if (e.key === "Enter") { - handler(object); - } - }; - - const dragEndHandler = (result: DropResult): void => { - if (!isCustomOrder) { - props.createNotification({ - title: "Error", - message: "Custom order is disabled", - }); - return; - } + const updateAppHandler = (id: number) => { + const app = + categoryInEdit?.apps.find((b) => b.id === id) || appTemplate; - if (!result.destination) { - return; - } + openFormForUpdating(app); + }; - if (props.contentType === ContentType.app) { - const tmpApps = [...localApps]; - const [movedApp] = tmpApps.splice(result.source.index, 1); - tmpApps.splice(result.destination.index, 0, movedApp); + const changeAppVisibiltyHandler = (id: number) => { + const app = + categoryInEdit?.apps.find((b) => b.id === id) || appTemplate; - setLocalApps(tmpApps); - props.reorderApps(tmpApps); - } else if (props.contentType === ContentType.category) { - const tmpCategories = [...localCategories]; - const [movedCategory] = tmpCategories.splice(result.source.index, 1); - tmpCategories.splice(result.destination.index, 0, movedCategory); + const categoryId = categoryInEdit?.id || -1; + const [prev, curr] = [categoryId, categoryId]; - setLocalCategories(tmpCategories); - props.reorderAppCategories(tmpCategories); - } + updateApp( + id, + { ...app, isPublic: !app.isPublic }, + { prev, curr } + ); }; - - if (props.contentType === ContentType.category) { - return ( - -
- {isCustomOrder ? ( -

You can drag and drop single rows to reorder categories

- ) : ( -

- Custom order is disabled. You can change it in{" "} - settings -

- )} -
- - - {(provided) => ( - - {localCategories.map( - (category: Category, index): JSX.Element => { - return ( - - {(provided, snapshot) => { - const style = { - border: snapshot.isDragging - ? "1px solid var(--color-accent)" - : "none", - borderRadius: "4px", - ...provided.draggableProps.style, - }; - return ( - - - {!snapshot.isDragging && category.id >= 0 && ( - - )} - - ); - }} - - ); - } - )} -
{category.name} -
- deleteCategoryHandler(category) - } - onKeyDown={(e) => - keyboardActionHandler( - e, - category, - deleteCategoryHandler - ) - } - tabIndex={0} - > - -
-
- props.updateHandler(category) - } - tabIndex={0} - > - -
-
props.pinAppCategory(category)} - onKeyDown={(e) => - keyboardActionHandler( - e, - category, - props.pinAppCategory - ) - } - tabIndex={0} - > - {category.isPinned ? ( - - ) : ( - - )} -
-
- )} -
-
-
- ); - } else { - return ( - -
- {isCustomOrder ? ( -

You can drag and drop single rows to reorder application

- ) : ( -

- Custom order is disabled. You can change it in{" "} - settings -

- )} -
- + return ( + + {!categoryInEdit ? ( + + Switch to grid view and click on the name of category you want to edit + + ) : ( + + Editing apps from {categoryInEdit.name} +  category + + )} + + {categoryInEdit && ( + {(provided) => ( - {localApps.map((app: App, index): JSX.Element => { + {localApps.map((app, index): JSX.Element => { return ( { {(provided, snapshot) => { const style = { border: snapshot.isDragging - ? "1px solid var(--color-accent)" - : "none", - borderRadius: "4px", + ? '1px solid var(--color-accent)' + : 'none', + borderRadius: '4px', ...provided.draggableProps.style, }; - const category = localCategories.find((category: Category) => category.id === app.categoryId); - const categoryName = category?.name; - return ( { ref={provided.innerRef} style={style} > - - - - + + + + + + {!snapshot.isDragging && ( - + )} ); @@ -334,20 +175,7 @@ const AppTable = (props: ComponentProps): JSX.Element => { )} - - ); - } + )} + + ); }; - -const actions = { - pinAppCategory, - deleteAppCategory, - reorderAppCategories, - pinApp, - deleteApp, - reorderApps, - updateConfig, - createNotification, -}; - -export default connect(null, actions)(AppTable); diff --git a/client/src/components/Apps/Apps.tsx b/client/src/components/Apps/Apps.tsx index a9624e83..60dc22ea 100644 --- a/client/src/components/Apps/Apps.tsx +++ b/client/src/components/Apps/Apps.tsx @@ -1,25 +1,18 @@ import { useEffect, useState } from 'react'; -import { connect } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; +import { bindActionCreators } from 'redux'; -import { App, Category, GlobalState } from '../../interfaces'; -import { getAppCategories, getApps } from '../../store/actions'; -import ActionButton from '../UI/Buttons/ActionButton/ActionButton'; -import Headline from '../UI/Headlines/Headline/Headline'; -import { Container } from '../UI/Layout/Layout'; -import Modal from '../UI/Modal/Modal'; -import Spinner from '../UI/Spinner/Spinner'; -import AppForm from './AppForm/AppForm'; -import AppGrid from './AppGrid/AppGrid'; +import { App, Category } from '../../interfaces'; +import { actionCreators } from '../../store'; +import { State } from '../../store/reducers'; +import { ActionButton, Container, Headline, Message, Modal, Spinner } from '../UI'; +import { AppGrid } from './AppGrid/AppGrid'; import classes from './Apps.module.css'; -import AppTable from './AppTable/AppTable'; - -interface ComponentProps { - loading: boolean; - categories: Category[]; - getAppCategories: () => void; - apps: App[]; - getApps: () => void; +import { Form } from './Form/Form'; +import { Table } from './Table/Table'; + +interface Props { searching: boolean; } @@ -28,131 +21,152 @@ export enum ContentType { app, } -const Apps = (props: ComponentProps): JSX.Element => { - const { apps, getApps, getAppCategories, categories, loading, searching = false } = props; +export const Apps = (props: Props): JSX.Element => { + // Get Redux state + const { + apps: { loading, categories, categoryInEdit }, + auth: { isAuthenticated }, + } = useSelector((state: State) => state); + + // Get Redux action creators + const dispatch = useDispatch(); + const { setEditCategory, setEditApp } = + bindActionCreators(actionCreators, dispatch); + // Form const [modalIsOpen, setModalIsOpen] = useState(false); const [formContentType, setFormContentType] = useState(ContentType.category); - const [isInEdit, setIsInEdit] = useState(false); + const [isInUpdate, setIsInUpdate] = useState(false); + + // Table + const [showTable, setShowTable] = useState(false); const [tableContentType, setTableContentType] = useState( ContentType.category ); - const [isInUpdate, setIsInUpdate] = useState(false); - const [categoryInUpdate, setCategoryInUpdate] = useState({ - name: "", - id: -1, - isPinned: false, - orderId: 0, - type: "apps", - apps: [], - bookmarks: [], - createdAt: new Date(), - updatedAt: new Date(), - }); - const [appInUpdate, setAppInUpdate] = useState({ - name: "string", - url: "string", - categoryId: -1, - icon: "string", - isPinned: false, - orderId: 0, - id: 0, - createdAt: new Date(), - updatedAt: new Date(), - }); + // Observe if user is authenticated -> set default view (grid) if not useEffect(() => { - if (apps.length === 0) { - getApps(); + if (!isAuthenticated) { + setShowTable(false); + setModalIsOpen(false); } - }, [getApps]); + }, [isAuthenticated]); useEffect(() => { - if (categories.length === 0) { - getAppCategories(); + if (categoryInEdit && !modalIsOpen) { + setTableContentType(ContentType.app); + setShowTable(true); } - }, [getAppCategories]); + }, [categoryInEdit]); + + useEffect(() => { + setShowTable(false); + setEditCategory(null); + }, []); + // Form actions const toggleModal = (): void => { setModalIsOpen(!modalIsOpen); }; - const addActionHandler = (contentType: ContentType) => { + const openFormForAdding = (contentType: ContentType) => { setFormContentType(contentType); setIsInUpdate(false); toggleModal(); }; - const editActionHandler = (contentType: ContentType) => { - // We"re in the edit mode and the same button was clicked - go back to list - if (isInEdit && contentType === tableContentType) { - setIsInEdit(false); - } else { - setIsInEdit(true); - setTableContentType(contentType); - } - }; + const openFormForUpdating = (data: Category | App): void => { + setIsInUpdate(true); - const instanceOfCategory = (object: any): object is Category => { - return !("categoryId" in object); - }; + const instanceOfCategory = (object: any): object is Category => { + return 'apps' in object; + }; - const goToUpdateMode = (data: Category | App): void => { - setIsInUpdate(true); if (instanceOfCategory(data)) { setFormContentType(ContentType.category); - setCategoryInUpdate(data); + setEditCategory(data); } else { setFormContentType(ContentType.app); - setAppInUpdate(data); + setEditApp(data); } + toggleModal(); }; + // Table actions + const showTableForEditing = (contentType: ContentType) => { + // We're in the edit mode and the same button was clicked - go back to list + if (showTable && contentType === tableContentType) { + setEditCategory(null); + setShowTable(false); + } else { + setShowTable(true); + setTableContentType(contentType); + } + }; + + const finishEditing = () => { + setShowTable(false); + setEditCategory(null); + }; + return ( - {!isInUpdate ? ( - - ) : ( - formContentType === ContentType.category ? ( - - ) : ( - - ) - )} +
- Go back)} - /> + Go back} /> + + {isAuthenticated && ( +
+ openFormForAdding(ContentType.category)} + /> + openFormForAdding(ContentType.app)} + /> + showTableForEditing(ContentType.category)} + /> + {showTable && tableContentType === ContentType.app && ( + + )} +
+ )} -
- addActionHandler(ContentType.category)} /> - addActionHandler(ContentType.app)} /> - editActionHandler(ContentType.category)} /> - editActionHandler(ContentType.app)} /> -
+ {categories.length && isAuthenticated && !showTable ? ( + + Click on category name to edit its apps + + ) : ( + <> + )} {loading ? ( - ) : (!isInEdit ? ( - - ) : ( - - ) + ) : !showTable ? ( + + ) : ( +
{app.name}{app.url}{app.icon}{categoryName}{app.name}{app.url}{app.icon} + {app.isPublic ? 'Visible' : 'Hidden'} + + {categoryInEdit.name} + -
deleteAppHandler(app)} - onKeyDown={(e) => - keyboardActionHandler( - e, - app, - deleteAppHandler - ) - } - tabIndex={0} - > - -
-
props.updateHandler(app)} - onKeyDown={(e) => - keyboardActionHandler( - e, - app, - props.updateHandler - ) - } - tabIndex={0} - > - -
-
props.pinApp(app)} - onKeyDown={(e) => - keyboardActionHandler(e, app, props.pinApp) - } - tabIndex={0} - > - {app.isPinned ? ( - - ) : ( - - )} -
-
)} ); }; - -const mapStateToProps = (state: GlobalState) => { - return { - loading: state.app.loading, - categories: state.app.categories, - apps: state.app.apps, - }; -}; - -export default connect(mapStateToProps, { getApps, getAppCategories })(Apps); diff --git a/client/src/components/Apps/Form/AppsForm.tsx b/client/src/components/Apps/Form/AppsForm.tsx new file mode 100644 index 00000000..fe7447bd --- /dev/null +++ b/client/src/components/Apps/Form/AppsForm.tsx @@ -0,0 +1,260 @@ +import { ChangeEvent, FormEvent, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { bindActionCreators } from 'redux'; + +import { App, Category, NewApp } from '../../../interfaces'; +import { actionCreators } from '../../../store'; +import { State } from '../../../store/reducers'; +import { inputHandler, newAppTemplate } from '../../../utility'; +import { Button, InputGroup, ModalForm } from '../../UI'; +import classes from './Form.module.css'; + +// Redux +// Typescript +// UI +// CSS +// Utils +interface Props { + modalHandler: () => void; + app?: App; +} + +export const AppsForm = ({ + app, + modalHandler, +}: Props): JSX.Element => { + const { categories } = useSelector((state: State) => state.apps); + + const dispatch = useDispatch(); + const { addApp, updateApp, createNotification } = + bindActionCreators(actionCreators, dispatch); + + const [useCustomIcon, toggleUseCustomIcon] = useState(false); + const [customIcon, setCustomIcon] = useState(null); + + const [formData, setFormData] = useState(newAppTemplate); + + // Load app data if provided for editing + useEffect(() => { + if (app) { + setFormData({ ...app }); + } else { + setFormData(newAppTemplate); + } + }, [app]); + + const inputChangeHandler = ( + e: ChangeEvent, + options?: { isNumber?: boolean; isBool?: boolean } + ) => { + inputHandler({ + e, + options, + setStateHandler: setFormData, + state: formData, + }); + }; + + const fileChangeHandler = (e: ChangeEvent): void => { + if (e.target.files) { + setCustomIcon(e.target.files[0]); + } + }; + + // Apps form handler + const formSubmitHandler = (e: FormEvent): void => { + e.preventDefault(); + + const createFormData = (): FormData => { + const data = new FormData(); + if (customIcon) { + data.append('icon', customIcon); + } + data.append('name', formData.name); + data.append('url', formData.url); + data.append('categoryId', `${formData.categoryId}`); + data.append('isPublic', `${formData.isPublic ? 1 : 0}`); + + return data; + }; + + const checkCategory = (): boolean => { + if (formData.categoryId < 0) { + createNotification({ + title: 'Error', + message: 'Please select category', + }); + + return false; + } + + return true; + }; + + if (!app) { + // add new app + if (!checkCategory()) return; + + if (formData.categoryId < 0) { + createNotification({ + title: 'Error', + message: 'Please select category', + }); + return; + } + + if (customIcon) { + const data = createFormData(); + addApp(data); + } else { + addApp(formData); + } + + setFormData({ + ...newAppTemplate, + categoryId: formData.categoryId, + isPublic: formData.isPublic, + }); + } else { + // update + if (!checkCategory()) return; + + if (customIcon) { + const data = createFormData(); + updateApp(app.id, data, { + prev: app.categoryId, + curr: formData.categoryId, + }); + } else { + updateApp(app.id, formData, { + prev: app.categoryId, + curr: formData.categoryId, + }); + } + + modalHandler(); + } + + setFormData({ ...newAppTemplate, categoryId: formData.categoryId }); + setCustomIcon(null); + }; + + return ( + + {/* NAME */} + + + inputChangeHandler(e)} + /> + + + {/* URL */} + + + inputChangeHandler(e)} + /> + + + {/* CATEGORY */} + + + + + + {/* ICON */} + {!useCustomIcon ? ( + // mdi + + + inputChangeHandler(e)} + /> + + Use icon name from MDI or pass a valid URL. + + {' '} + Click here for reference + + + toggleUseCustomIcon(!useCustomIcon)} + className={classes.Switch} + > + Switch to custom icon upload + + + ) : ( + // custom + + + fileChangeHandler(e)} + accept=".jpg,.jpeg,.png,.svg,.ico" + /> + { + setCustomIcon(null); + toggleUseCustomIcon(!useCustomIcon); + }} + className={classes.Switch} + > + Switch to MDI + + + )} + + {/* VISIBILTY */} + + + + + + + + ); +}; diff --git a/client/src/components/Apps/Form/CategoryForm.tsx b/client/src/components/Apps/Form/CategoryForm.tsx new file mode 100644 index 00000000..0124df31 --- /dev/null +++ b/client/src/components/Apps/Form/CategoryForm.tsx @@ -0,0 +1,97 @@ +import { ChangeEvent, FormEvent, useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { bindActionCreators } from 'redux'; + +import { Category, NewCategory } from '../../../interfaces'; +import { actionCreators } from '../../../store'; +import { inputHandler, newAppCategoryTemplate } from '../../../utility'; +import { Button, InputGroup, ModalForm } from '../../UI'; + +// Redux +// Typescript +// UI +// Utils +interface Props { + modalHandler: () => void; + category?: Category; +} + +export const CategoryForm = ({ + category, + modalHandler, +}: Props): JSX.Element => { + const dispatch = useDispatch(); + const { addCategory, updateCategory } = bindActionCreators( + actionCreators, + dispatch + ); + + const [formData, setFormData] = useState(newAppCategoryTemplate); + + // Load category data if provided for editing + useEffect(() => { + if (category) { + setFormData({ ...category }); + } else { + setFormData(newAppCategoryTemplate); + } + }, [category]); + + const inputChangeHandler = ( + e: ChangeEvent, + options?: { isNumber?: boolean; isBool?: boolean } + ) => { + inputHandler({ + e, + options, + setStateHandler: setFormData, + state: formData, + }); + }; + + // Category form handler + const formSubmitHandler = (e: FormEvent): void => { + e.preventDefault(); + + if (!category) { + addCategory(formData); + } else { + updateCategory(category.id, formData); + modalHandler(); + } + + setFormData(newAppCategoryTemplate); + }; + + return ( + + + + inputChangeHandler(e)} + /> + + + + + + + + + + ); +}; diff --git a/client/src/components/Apps/AppForm/AppForm.module.css b/client/src/components/Apps/Form/Form.module.css similarity index 100% rename from client/src/components/Apps/AppForm/AppForm.module.css rename to client/src/components/Apps/Form/Form.module.css diff --git a/client/src/components/Apps/Form/Form.tsx b/client/src/components/Apps/Form/Form.tsx new file mode 100644 index 00000000..a67ac78a --- /dev/null +++ b/client/src/components/Apps/Form/Form.tsx @@ -0,0 +1,54 @@ +import { Fragment } from 'react'; +import { useSelector } from 'react-redux'; + +import { State } from '../../../store/reducers'; +import { appCategoryTemplate, appTemplate } from '../../../utility'; +import { ContentType } from '../Apps'; +import { AppsForm } from './AppsForm'; +import { CategoryForm } from './CategoryForm'; + +// Typescript +// Utils +interface Props { + modalHandler: () => void; + contentType: ContentType; + inUpdate?: boolean; +} + +export const Form = (props: Props): JSX.Element => { + const { categoryInEdit, appInEdit } = useSelector( + (state: State) => state.apps + ); + + const { modalHandler, contentType, inUpdate } = props; + + return ( + + {!inUpdate ? ( + // form: add new + + {contentType === ContentType.category ? ( + + ) : ( + + )} + + ) : ( + // form: update + + {contentType === ContentType.category ? ( + + ) : ( + + )} + + )} + + ); +}; diff --git a/client/src/components/Apps/Table/AppsTable.tsx b/client/src/components/Apps/Table/AppsTable.tsx new file mode 100644 index 00000000..a3f37e50 --- /dev/null +++ b/client/src/components/Apps/Table/AppsTable.tsx @@ -0,0 +1,181 @@ +import { Fragment, useEffect, useState } from 'react'; +import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd'; +import { useDispatch, useSelector } from 'react-redux'; +import { bindActionCreators } from 'redux'; + +import { App, Category } from '../../../interfaces'; +import { actionCreators } from '../../../store'; +import { State } from '../../../store/reducers'; +import { appTemplate } from '../../../utility'; +import { TableActions } from '../../Actions/TableActions'; +import { Message, Table } from '../../UI'; + +// Redux +// Typescript +// UI +interface Props { + openFormForUpdating: (data: Category | App) => void; +} + +export const AppsTable = ({ openFormForUpdating }: Props): JSX.Element => { + const { + apps: { categoryInEdit }, + config: { config }, + } = useSelector((state: State) => state); + + const dispatch = useDispatch(); + const { + deleteApp, + updateApp, + createNotification, + reorderApps, + } = bindActionCreators(actionCreators, dispatch); + + const [localApps, setLocalApps] = useState([]); + + // Copy apps array + useEffect(() => { + if (categoryInEdit) { + setLocalApps([...categoryInEdit.apps]); + } + }, [categoryInEdit]); + + // Drag and drop handler + const dragEndHanlder = (result: DropResult): void => { + if (config.useOrdering !== 'orderId') { + createNotification({ + title: 'Error', + message: 'Custom order is disabled', + }); + return; + } + + if (!result.destination) { + return; + } + + const tmpApps = [...localApps]; + const [movedApp] = tmpApps.splice(result.source.index, 1); + tmpApps.splice(result.destination.index, 0, movedApp); + + setLocalApps(tmpApps); + + const categoryId = categoryInEdit?.id || -1; + reorderApps(tmpApps, categoryId); + }; + + // Action hanlders + const deleteAppHandler = (id: number, name: string) => { + const categoryId = categoryInEdit?.id || -1; + + const proceed = window.confirm(`Are you sure you want to delete ${name}?`); + if (proceed) { + deleteApp(id, categoryId); + } + }; + + const updateAppHandler = (id: number) => { + const app = + categoryInEdit?.apps.find((b) => b.id === id) || appTemplate; + + openFormForUpdating(app); + }; + + const changeAppVisibiltyHandler = (id: number) => { + const app = + categoryInEdit?.apps.find((b) => b.id === id) || appTemplate; + + const categoryId = categoryInEdit?.id || -1; + const [prev, curr] = [categoryId, categoryId]; + + updateApp( + id, + { ...app, isPublic: !app.isPublic }, + { prev, curr } + ); + }; + + return ( + + {!categoryInEdit ? ( + + Switch to grid view and click on the name of category you want to edit + + ) : ( + + Editing apps from {categoryInEdit.name} +  category + + )} + + {categoryInEdit && ( + + + {(provided) => ( +
+ {localApps.map((app, index): JSX.Element => { + return ( + + {(provided, snapshot) => { + const style = { + border: snapshot.isDragging + ? '1px solid var(--color-accent)' + : 'none', + borderRadius: '4px', + ...provided.draggableProps.style, + }; + + return ( + + + + + + + + {!snapshot.isDragging && ( + + )} + + ); + }} + + ); + })} +
{app.name}{app.url}{app.icon} + {app.isPublic ? 'Visible' : 'Hidden'} + + {categoryInEdit.name} +
+ )} +
+
+ )} +
+ ); +}; diff --git a/client/src/components/Apps/Table/CategoryTable.tsx b/client/src/components/Apps/Table/CategoryTable.tsx new file mode 100644 index 00000000..2788c8f6 --- /dev/null +++ b/client/src/components/Apps/Table/CategoryTable.tsx @@ -0,0 +1,159 @@ +import { Fragment, useEffect, useState } from 'react'; +import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd'; +import { useDispatch, useSelector } from 'react-redux'; +import { Link } from 'react-router-dom'; +import { bindActionCreators } from 'redux'; + +import { App, Category } from '../../../interfaces'; +import { actionCreators } from '../../../store'; +import { State } from '../../../store/reducers'; +import { TableActions } from '../../Actions/TableActions'; +import { Message, Table } from '../../UI'; + +// Redux +// Typescript +// UI +interface Props { + openFormForUpdating: (data: Category | App) => void; +} + +export const CategoryTable = ({ openFormForUpdating }: Props): JSX.Element => { + const { + config: { config }, + apps: { categories }, + } = useSelector((state: State) => state); + + const dispatch = useDispatch(); + const { + pinCategory, + deleteCategory, + createNotification, + reorderCategories, + updateCategory, + } = bindActionCreators(actionCreators, dispatch); + + const [localCategories, setLocalCategories] = useState([]); + + // Copy categories array + useEffect(() => { + setLocalCategories([...categories]); + }, [categories]); + + // Drag and drop handler + const dragEndHanlder = (result: DropResult): void => { + if (config.useOrdering !== 'orderId') { + createNotification({ + title: 'Error', + message: 'Custom order is disabled', + }); + return; + } + + if (!result.destination) { + return; + } + + const tmpCategories = [...localCategories]; + const [movedCategory] = tmpCategories.splice(result.source.index, 1); + tmpCategories.splice(result.destination.index, 0, movedCategory); + + setLocalCategories(tmpCategories); + reorderCategories(tmpCategories); + }; + + // Action handlers + const deleteCategoryHandler = (id: number, name: string) => { + const proceed = window.confirm( + `Are you sure you want to delete ${name}? It will delete ALL assigned apps` + ); + + if (proceed) { + deleteCategory(id); + } + }; + + const updateCategoryHandler = (id: number) => { + const category = categories.find((c) => c.id === id) as Category; + openFormForUpdating(category); + }; + + const pinCategoryHandler = (id: number) => { + const category = categories.find((c) => c.id === id) as Category; + pinCategory(category); + }; + + const changeCategoryVisibiltyHandler = (id: number) => { + const category = categories.find((c) => c.id === id) as Category; + updateCategory(id, { ...category, isPublic: !category.isPublic }); + }; + + return ( + + + {config.useOrdering === 'orderId' ? ( +

You can drag and drop single rows to reorder categories

+ ) : ( +

+ Custom order is disabled. You can change it in the{' '} + settings +

+ )} +
+ + + + {(provided) => ( + + {localCategories.map((category, index): JSX.Element => { + return ( + + {(provided, snapshot) => { + const style = { + border: snapshot.isDragging + ? '1px solid var(--color-accent)' + : 'none', + borderRadius: '4px', + ...provided.draggableProps.style, + }; + + return ( + + + + + {!snapshot.isDragging && ( + + )} + + ); + }} + + ); + })} +
{category.name} + {category.isPublic ? 'Visible' : 'Hidden'} +
+ )} +
+
+
+ ); +}; diff --git a/client/src/components/Apps/Table/Table.tsx b/client/src/components/Apps/Table/Table.tsx new file mode 100644 index 00000000..7542c345 --- /dev/null +++ b/client/src/components/Apps/Table/Table.tsx @@ -0,0 +1,20 @@ +import { App, Category } from '../../../interfaces'; +import { ContentType } from '../Apps'; +import { AppsTable } from './AppsTable'; +import { CategoryTable } from './CategoryTable'; + +interface Props { + contentType: ContentType; + openFormForUpdating: (data: Category | App) => void; +} + +export const Table = (props: Props): JSX.Element => { + const tableEl = + props.contentType === ContentType.category ? ( + + ) : ( + + ); + + return tableEl; +}; diff --git a/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.module.css b/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.module.css index b840a42b..2fd52f08 100644 --- a/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.module.css +++ b/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.module.css @@ -10,6 +10,10 @@ text-transform: uppercase; } +.BookmarkHeader:hover { + cursor: pointer; +} + .Bookmarks { display: flex; flex-direction: column; diff --git a/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx b/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx index 8c4014cc..796ffc29 100644 --- a/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx +++ b/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx @@ -1,22 +1,47 @@ import { Fragment } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { bindActionCreators } from 'redux'; import { Bookmark, Category } from '../../../interfaces'; -import { iconParser, searchConfig, urlParser } from '../../../utility'; -import Icon from '../../UI/Icons/Icon/Icon'; +import { actionCreators } from '../../../store'; +import { State } from '../../../store/reducers'; +import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility'; +import { Icon } from '../../UI'; import classes from './BookmarkCard.module.css'; -interface ComponentProps { +interface Props { category: Category; - bookmarks: Bookmark[]; - pinHandler?: Function; + fromHomepage?: boolean; } -const BookmarkCard = (props: ComponentProps): JSX.Element => { +export const BookmarkCard = (props: Props): JSX.Element => { + const { category, fromHomepage = false } = props; + + const { + config: { config }, + auth: { isAuthenticated }, + } = useSelector((state: State) => state); + + const dispatch = useDispatch(); + const { setEditCategory } = bindActionCreators(actionCreators, dispatch); + return (
-

{props.category.name}

+

{ + if (!fromHomepage && isAuthenticated) { + setEditCategory(category); + } + }} + > + {category.name} +

+
- {props.bookmarks.map((bookmark: Bookmark) => { + {category.bookmarks.map((bookmark: Bookmark) => { const redirectUrl = urlParser(bookmark.url)[1]; let iconEl: JSX.Element = ; @@ -24,21 +49,25 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => { if (bookmark.icon) { const { icon, name } = bookmark; - if (/.(jpeg|jpg|png)$/i.test(icon)) { + if (isImage(icon)) { + const source = isUrl(icon) ? icon : `/uploads/${icon}`; + iconEl = (
{`${name}
); - } else if (/.(svg)$/i.test(icon)) { + } else if (isSvg(icon)) { + const source = isUrl(icon) ? icon : `/uploads/${icon}`; + iconEl = (
@@ -56,7 +85,7 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => { return ( @@ -69,5 +98,3 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => {
); }; - -export default BookmarkCard; diff --git a/client/src/components/Bookmarks/BookmarkForm/BookmarkForm.tsx b/client/src/components/Bookmarks/BookmarkForm/BookmarkForm.tsx deleted file mode 100644 index c9506e98..00000000 --- a/client/src/components/Bookmarks/BookmarkForm/BookmarkForm.tsx +++ /dev/null @@ -1,327 +0,0 @@ -import { ChangeEvent, Dispatch, Fragment, SetStateAction, SyntheticEvent, useEffect, useState } from 'react'; -import { connect } from 'react-redux'; - -import { Bookmark, Category, GlobalState, NewBookmark, NewCategory, NewNotification } from '../../../interfaces'; -import { - addBookmark, - addBookmarkCategory, - createNotification, - getBookmarkCategories, - updateBookmark, - updateBookmarkCategory, -} from '../../../store/actions'; -import Button from '../../UI/Buttons/Button/Button'; -import InputGroup from '../../UI/Forms/InputGroup/InputGroup'; -import ModalForm from '../../UI/Forms/ModalForm/ModalForm'; -import { ContentType } from '../Bookmarks'; -import classes from './BookmarkForm.module.css'; - -interface ComponentProps { - modalHandler: () => void; - contentType: ContentType; - categories: Category[]; - category?: Category; - bookmark?: Bookmark; - addBookmarkCategory: (formData: NewCategory) => void; - addBookmark: (formData: NewBookmark | FormData) => void; - updateBookmarkCategory: (id: number, formData: NewCategory) => void; - updateBookmark: ( - id: number, - formData: NewBookmark | FormData, - previousCategoryId: number - ) => void; - createNotification: (notification: NewNotification) => void; -} - -const BookmarkForm = (props: ComponentProps): JSX.Element => { - const [useCustomIcon, setUseCustomIcon] = useState(false); - const [customIcon, setCustomIcon] = useState(null); - const [categoryData, setCategoryData] = useState({ - name: '', - type: 'bookmarks', - }); - - const [bookmarkData, setBookmarkData] = useState({ - name: "", - url: "", - categoryId: -1, - icon: '', - }); - - // Load category data if provided for editing - useEffect(() => { - if (props.category) { - setCategoryData({ name: props.category.name, type: props.category.type }); - } else { - setCategoryData({ name: '', type: 'bookmarks' }); - } - }, [props.category]); - - // Load bookmark data if provided for editing - useEffect(() => { - if (props.bookmark) { - setBookmarkData({ - name: props.bookmark.name, - url: props.bookmark.url, - categoryId: props.bookmark.categoryId, - icon: props.bookmark.icon, - }); - } else { - setBookmarkData({ - name: "", - url: "", - categoryId: -1, - icon: '', - }); - } - }, [props.bookmark]); - - const formSubmitHandler = (e: SyntheticEvent): void => { - e.preventDefault(); - - const createFormData = (): FormData => { - const data = new FormData(); - if (customIcon) { - data.append('icon', customIcon); - } - Object.entries(bookmarkData).forEach((entry: [string, any]) => { - data.append(entry[0], entry[1]); - }); - - return data; - }; - - if (!props.category && !props.bookmark) { - // Add new - if (props.contentType === ContentType.category) { - // Add category - props.addBookmarkCategory(categoryData); - setCategoryData({ name: "", type: "bookmarks" }); - } else if (props.contentType === ContentType.bookmark) { - // Add bookmark - if (bookmarkData.categoryId === -1) { - props.createNotification({ - title: 'Error', - message: 'Please select category', - }); - return; - } - - if (customIcon) { - const data = createFormData(); - props.addBookmark(data); - } else { - props.addBookmark(bookmarkData); - } - setBookmarkData({ - name: "", - url: "", - categoryId: bookmarkData.categoryId, - icon: '' - }); - - // setCustomIcon(null); - } - } else { - // Update - if (props.contentType === ContentType.category && props.category) { - // Update category - props.updateBookmarkCategory(props.category.id, categoryData); - setCategoryData({ name: "", type: "bookmarks" }); - } else if (props.contentType === ContentType.bookmark && props.bookmark) { - // Update bookmark - props.updateBookmark( - props.bookmark.id, - createFormData(), - props.bookmark.categoryId - ); - - setBookmarkData({ - name: "", - url: "", - categoryId: -1, - icon: '', - }); - - setCustomIcon(null); - } - - props.modalHandler(); - } - }; - - const inputChangeHandler = (e: ChangeEvent, setDataFunction: Dispatch>, data: any): void => { - setDataFunction({ - ...data, - [e.target.name]: e.target.value - }); - }; - - const toggleUseCustomIcon = (): void => { - setUseCustomIcon(!useCustomIcon); - setCustomIcon(null); - }; - - const fileChangeHandler = (e: ChangeEvent): void => { - if (e.target.files) { - setCustomIcon(e.target.files[0]); - } - }; - - let button = ; - - if (!props.category && !props.bookmark) { - if (props.contentType === ContentType.category) { - button = ; - } else { - button = ; - } - } else if (props.category) { - button = ; - } else if (props.bookmark) { - button = ; - } - - return ( - - {props.contentType === ContentType.category ? ( - - - - inputChangeHandler(e, setCategoryData, categoryData)} - /> - - - ) : ( - - - - inputChangeHandler(e, setBookmarkData, bookmarkData)} - /> - - - - inputChangeHandler(e, setBookmarkData, bookmarkData)} - /> - - - {' '} - Check supported URL formats - - - - - - - - {!useCustomIcon ? ( - // mdi - - - inputChangeHandler(e, setBookmarkData, bookmarkData)} - /> - - Use icon name from MDI. - - {' '} - Click here for reference - - - toggleUseCustomIcon()} - className={classes.Switch} - > - Switch to custom icon upload - - - ) : ( - // custom - - - fileChangeHandler(e)} - accept=".jpg,.jpeg,.png,.svg" - /> - toggleUseCustomIcon()} - className={classes.Switch} - > - Switch to MDI - - - )} - - )} - {button} - - ); -}; - -const mapStateToProps = (state: GlobalState) => { - return { - categories: state.bookmark.categories, - }; -}; - -const dispatchMap = { - getBookmarkCategories, - addBookmarkCategory, - addBookmark, - updateBookmarkCategory, - updateBookmark, - createNotification, -}; - -export default connect(mapStateToProps, dispatchMap)(BookmarkForm); diff --git a/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.module.css b/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.module.css index 8c0d1abc..9e89f3a5 100644 --- a/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.module.css +++ b/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.module.css @@ -20,12 +20,3 @@ grid-template-columns: repeat(4, 1fr); } } - -.BookmarksMessage { - color: var(--color-primary); -} - -.BookmarksMessage a { - color: var(--color-accent); - font-weight: 600; -} \ No newline at end of file diff --git a/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.tsx b/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.tsx index 22136b2c..f93bd97d 100644 --- a/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.tsx +++ b/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.tsx @@ -1,56 +1,72 @@ +import { useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; -import { Bookmark, Category } from '../../../interfaces'; -import BookmarkCard from '../BookmarkCard/BookmarkCard'; +import { Category } from '../../../interfaces'; +import { State } from '../../../store/reducers'; +import { Message } from '../../UI'; +import { BookmarkCard } from '../BookmarkCard/BookmarkCard'; import classes from './BookmarkGrid.module.css'; -interface ComponentProps { +interface Props { categories: Category[]; - bookmarks: Bookmark[]; totalCategories?: number; searching: boolean; + fromHomepage?: boolean; } -const BookmarkGrid = (props: ComponentProps): JSX.Element => { +export const BookmarkGrid = (props: Props): JSX.Element => { + const { + categories, + totalCategories, + searching, + fromHomepage = false, + } = props; + + const { + config: { config } + } = useSelector((state: State) => state); + + const shouldBeShown = (category: Category) => { + return !config.hideEmptyCategories || category.bookmarks.length > 0 || !fromHomepage + } + let bookmarks: JSX.Element; - if (props.categories.length > 0) { - if (props.searching && props.categories[0].bookmarks.length === 0) { - bookmarks = ( -

- No bookmarks match your search criteria -

- ); + if (categories.length && categories.some(shouldBeShown)) { + if (searching && !categories[0].bookmarks.length) { + bookmarks = No bookmarks match your search criteria; } else { bookmarks = (
- {props.categories.map( + {categories.filter(shouldBeShown).map( (category: Category): JSX.Element => ( - bookmark.categoryId === category.id)} /> + ) )}
); } } else { - if (props.totalCategories) { + if (totalCategories && !config.hideEmptyCategories) { bookmarks = ( -

- There are no pinned bookmark categories. You can pin them from the{' '} + + There are no pinned categories. You can pin them from the{' '} /bookmarks menu -

+ ); } else { bookmarks = ( -

+ You don't have any bookmarks. You can add a new one from{' '} /bookmarks menu -

+ ); } } return bookmarks; }; - -export default BookmarkGrid; diff --git a/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.module.css b/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.module.css deleted file mode 100644 index 8b1e0edc..00000000 --- a/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.module.css +++ /dev/null @@ -1,29 +0,0 @@ -.TableActions { - display: flex; - align-items: center; -} - -.TableAction { - width: 22px; -} - -.TableAction:hover { - cursor: pointer; -} - -.Message { - width: 100%; - display: flex; - justify-content: center; - align-items: baseline; - color: var(--color-primary); - margin-bottom: 20px; -} - -.Message a { - color: var(--color-accent); -} - -.Message a:hover { - cursor: pointer; -} \ No newline at end of file diff --git a/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx b/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx deleted file mode 100644 index 0fca8fb1..00000000 --- a/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx +++ /dev/null @@ -1,372 +0,0 @@ -import { Fragment, KeyboardEvent, useEffect, useState } from 'react'; -import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd'; -import { connect } from 'react-redux'; -import { Link } from 'react-router-dom'; - -import { Bookmark, Category, NewNotification } from '../../../interfaces'; -import { - createNotification, - deleteBookmark, - deleteBookmarkCategory, - pinBookmark, - pinBookmarkCategory, - reorderBookmarkCategories, - reorderBookmarks, - updateConfig, -} from '../../../store/actions'; -import { searchConfig } from '../../../utility'; -import Icon from '../../UI/Icons/Icon/Icon'; -import Table from '../../UI/Table/Table'; -import { ContentType } from '../Bookmarks'; -import classes from './BookmarkTable.module.css'; - -interface ComponentProps { - contentType: ContentType; - categories: Category[]; - bookmarks: Bookmark[]; - pinBookmarkCategory: (category: Category) => void; - deleteBookmarkCategory: (id: number) => void; - reorderBookmarkCategories: (categories: Category[]) => void; - updateHandler: (data: Category | Bookmark) => void; - pinBookmark: (bookmark: Bookmark) => void; - deleteBookmark: (id: number, categoryId: number) => void; - reorderBookmarks: (bookmarks: Bookmark[]) => void; - updateConfig: (formData: any) => void; - createNotification: (notification: NewNotification) => void; -} - -const BookmarkTable = (props: ComponentProps): JSX.Element => { - const [localCategories, setLocalCategories] = useState([]); - const [localBookmarks, setLocalBookmarks] = useState([]); - const [isCustomOrder, setIsCustomOrder] = useState(false); - - // Copy categories array - useEffect(() => { - setLocalCategories([...props.categories]); - }, [props.categories]); - - // Copy bookmarks array - useEffect(() => { - setLocalBookmarks([...props.bookmarks]); - }, [props.bookmarks]); - - // Check ordering - useEffect(() => { - const order = searchConfig("useOrdering", ""); - - if (order === "orderId") { - setIsCustomOrder(true); - } - }, []); - - const deleteCategoryHandler = (category: Category): void => { - const proceed = window.confirm( - `Are you sure you want to delete ${category.name}? It will delete ALL assigned bookmarks` - ); - - if (proceed) { - props.deleteBookmarkCategory(category.id); - } - }; - - const deleteBookmarkHandler = (bookmark: Bookmark): void => { - const proceed = window.confirm( - `Are you sure you want to delete ${bookmark.name}?` - ); - - if (proceed) { - props.deleteBookmark(bookmark.id, bookmark.categoryId); - } - }; - - // Support keyboard navigation for actions - const keyboardActionHandler = ( - e: KeyboardEvent, - object: any, - handler: Function - ) => { - if (e.key === "Enter") { - handler(object); - } - }; - - const dragEndHandler = (result: DropResult): void => { - if (!isCustomOrder) { - props.createNotification({ - title: "Error", - message: "Custom order is disabled", - }); - return; - } - - if (!result.destination) { - return; - } - - if (props.contentType === ContentType.bookmark) { - const tmpBookmarks = [...localBookmarks]; - const [movedBookmark] = tmpBookmarks.splice(result.source.index, 1); - tmpBookmarks.splice(result.destination.index, 0, movedBookmark); - - setLocalBookmarks(tmpBookmarks); - props.reorderBookmarks(tmpBookmarks); - } else if (props.contentType === ContentType.category) { - const tmpCategories = [...localCategories]; - const [movedCategory] = tmpCategories.splice(result.source.index, 1); - tmpCategories.splice(result.destination.index, 0, movedCategory); - - setLocalCategories(tmpCategories); - props.reorderBookmarkCategories(tmpCategories); - } - }; - - if (props.contentType === ContentType.category) { - return ( - -
- {isCustomOrder ? ( -

You can drag and drop single rows to reorder categories

- ) : ( -

- Custom order is disabled. You can change it in{" "} - settings -

- )} -
- - - {(provided) => ( - - {localCategories.map( - (category: Category, index): JSX.Element => { - return ( - - {(provided, snapshot) => { - const style = { - border: snapshot.isDragging - ? "1px solid var(--color-accent)" - : "none", - borderRadius: "4px", - ...provided.draggableProps.style, - }; - - return ( - - - {!snapshot.isDragging && ( - - )} - - ); - }} - - ); - } - )} -
{category.name} -
- deleteCategoryHandler(category) - } - onKeyDown={(e) => - keyboardActionHandler( - e, - category, - deleteCategoryHandler - ) - } - tabIndex={0} - > - -
-
- props.updateHandler(category) - } - tabIndex={0} - > - -
-
- props.pinBookmarkCategory(category) - } - onKeyDown={(e) => - keyboardActionHandler( - e, - category, - props.pinBookmarkCategory - ) - } - tabIndex={0} - > - {category.isPinned ? ( - - ) : ( - - )} -
-
- )} -
-
-
- ); - } else { - return ( - -
- {isCustomOrder ? ( -

You can drag and drop single rows to reorder bookmark

- ) : ( -

- Custom order is disabled. You can change it in{" "} - settings -

- )} -
- - - {(provided) => ( - - {localBookmarks.map( - (bookmark: Bookmark, index): JSX.Element => { - return ( - - {(provided, snapshot) => { - const style = { - border: snapshot.isDragging - ? "1px solid var(--color-accent)" - : "none", - borderRadius: "4px", - ...provided.draggableProps.style, - }; - - const category = localCategories.find( - (category: Category) => - category.id === bookmark.categoryId - ); - const categoryName = category?.name; - - return ( - - - - - - {!snapshot.isDragging && ( - - )} - - ); - }} - - ); - } - )} -
- {bookmark.name} - {bookmark.url} - {bookmark.icon} - {categoryName} -
- deleteBookmarkHandler(bookmark) - } - onKeyDown={(e) => - keyboardActionHandler( - e, - bookmark, - deleteBookmarkHandler - ) - } - tabIndex={0} - > - -
-
- props.updateHandler(bookmark) - } - onKeyDown={(e) => - keyboardActionHandler( - e, - bookmark, - props.updateHandler - ) - } - tabIndex={0} - > - -
-
props.pinBookmark(bookmark)} - onKeyDown={(e) => - keyboardActionHandler( - e, - bookmark, - props.pinBookmark - ) - } - tabIndex={0} - > - {bookmark.isPinned ? ( - - ) : ( - - )} -
-
- )} -
-
-
- ); - } -}; - -const actions = { - pinBookmarkCategory, - deleteBookmarkCategory, - reorderBookmarkCategories, - pinBookmark, - deleteBookmark, - reorderBookmarks, - updateConfig, - createNotification, -}; - -export default connect(null, actions)(BookmarkTable); diff --git a/client/src/components/Bookmarks/Bookmarks.tsx b/client/src/components/Bookmarks/Bookmarks.tsx index 9838699f..f41e2726 100644 --- a/client/src/components/Bookmarks/Bookmarks.tsx +++ b/client/src/components/Bookmarks/Bookmarks.tsx @@ -1,25 +1,18 @@ import { useEffect, useState } from 'react'; -import { connect } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; +import { bindActionCreators } from 'redux'; -import { Bookmark, Category, GlobalState } from '../../interfaces'; -import { getBookmarkCategories, getBookmarks } from '../../store/actions'; -import ActionButton from '../UI/Buttons/ActionButton/ActionButton'; -import Headline from '../UI/Headlines/Headline/Headline'; -import { Container } from '../UI/Layout/Layout'; -import Modal from '../UI/Modal/Modal'; -import Spinner from '../UI/Spinner/Spinner'; -import BookmarkForm from './BookmarkForm/BookmarkForm'; -import BookmarkGrid from './BookmarkGrid/BookmarkGrid'; +import { Bookmark, Category } from '../../interfaces'; +import { actionCreators } from '../../store'; +import { State } from '../../store/reducers'; +import { ActionButton, Container, Headline, Message, Modal, Spinner } from '../UI'; +import { BookmarkGrid } from './BookmarkGrid/BookmarkGrid'; import classes from './Bookmarks.module.css'; -import BookmarkTable from './BookmarkTable/BookmarkTable'; - -interface ComponentProps { - loading: boolean; - categories: Category[]; - getBookmarkCategories: () => void; - bookmarks: Bookmark[]; - getBookmarks: () => void; +import { Form } from './Form/Form'; +import { Table } from './Table/Table'; + +interface Props { searching: boolean; } @@ -28,153 +21,152 @@ export enum ContentType { bookmark, } -const Bookmarks = (props: ComponentProps): JSX.Element => { - const { bookmarks, getBookmarks, getBookmarkCategories, categories, loading, searching = false } = props; +export const Bookmarks = (props: Props): JSX.Element => { + // Get Redux state + const { + bookmarks: { loading, categories, categoryInEdit }, + auth: { isAuthenticated }, + } = useSelector((state: State) => state); + + // Get Redux action creators + const dispatch = useDispatch(); + const { setEditCategory, setEditBookmark } = + bindActionCreators(actionCreators, dispatch); + // Form const [modalIsOpen, setModalIsOpen] = useState(false); const [formContentType, setFormContentType] = useState(ContentType.category); - const [isInEdit, setIsInEdit] = useState(false); + const [isInUpdate, setIsInUpdate] = useState(false); + + // Table + const [showTable, setShowTable] = useState(false); const [tableContentType, setTableContentType] = useState( ContentType.category ); - const [isInUpdate, setIsInUpdate] = useState(false); - const [categoryInUpdate, setCategoryInUpdate] = useState({ - name: "", - id: -1, - isPinned: false, - orderId: 0, - type: "bookmarks", - apps: [], - bookmarks: [], - createdAt: new Date(), - updatedAt: new Date(), - }); - const [bookmarkInUpdate, setBookmarkInUpdate] = useState({ - name: "string", - url: "string", - categoryId: -1, - icon: "string", - isPinned: false, - orderId: 0, - id: 0, - createdAt: new Date(), - updatedAt: new Date(), - }); + // Observe if user is authenticated -> set default view (grid) if not useEffect(() => { - if (!bookmarks || bookmarks.length === 0) { - getBookmarks(); + if (!isAuthenticated) { + setShowTable(false); + setModalIsOpen(false); } - }, [getBookmarks]); + }, [isAuthenticated]); useEffect(() => { - if (categories.length === 0) { - getBookmarkCategories(); + if (categoryInEdit && !modalIsOpen) { + setTableContentType(ContentType.bookmark); + setShowTable(true); } - }, [getBookmarkCategories]); + }, [categoryInEdit]); + useEffect(() => { + setShowTable(false); + setEditCategory(null); + }, []); + + // Form actions const toggleModal = (): void => { setModalIsOpen(!modalIsOpen); }; - const addActionHandler = (contentType: ContentType) => { + const openFormForAdding = (contentType: ContentType) => { setFormContentType(contentType); setIsInUpdate(false); toggleModal(); }; - const editActionHandler = (contentType: ContentType) => { - // We're in the edit mode and the same button was clicked - go back to list - if (isInEdit && contentType === tableContentType) { - setIsInEdit(false); - } else { - setIsInEdit(true); - setTableContentType(contentType); - } - }; + const openFormForUpdating = (data: Category | Bookmark): void => { + setIsInUpdate(true); - const instanceOfCategory = (object: any): object is Category => { - return "bookmarks" in object; - }; + const instanceOfCategory = (object: any): object is Category => { + return 'bookmarks' in object; + }; - const goToUpdateMode = (data: Category | Bookmark): void => { - setIsInUpdate(true); if (instanceOfCategory(data)) { setFormContentType(ContentType.category); - setCategoryInUpdate(data); + setEditCategory(data); } else { setFormContentType(ContentType.bookmark); - setBookmarkInUpdate(data); + setEditBookmark(data); } + toggleModal(); }; + // Table actions + const showTableForEditing = (contentType: ContentType) => { + // We're in the edit mode and the same button was clicked - go back to list + if (showTable && contentType === tableContentType) { + setEditCategory(null); + setShowTable(false); + } else { + setShowTable(true); + setTableContentType(contentType); + } + }; + + const finishEditing = () => { + setShowTable(false); + setEditCategory(null); + }; + return ( - {!isInUpdate ? ( - - ) : formContentType === ContentType.category ? ( - - ) : ( - - )} + Go back} /> -
- addActionHandler(ContentType.category)} - /> - addActionHandler(ContentType.bookmark)} - /> - editActionHandler(ContentType.category)} - /> - editActionHandler(ContentType.bookmark)} - /> -
+ {isAuthenticated && ( +
+ openFormForAdding(ContentType.category)} + /> + openFormForAdding(ContentType.bookmark)} + /> + showTableForEditing(ContentType.category)} + /> + {showTable && tableContentType === ContentType.bookmark && ( + + )} +
+ )} + + {categories.length && isAuthenticated && !showTable ? ( + + Click on category name to edit its bookmarks + + ) : ( + <> + )} {loading ? ( - ) : !isInEdit ? ( - + ) : !showTable ? ( + ) : ( - )}
); }; - -const mapStateToProps = (state: GlobalState) => { - return { - loading: state.bookmark.loading, - categories: state.bookmark.categories, - }; -}; - -export default connect(mapStateToProps, { getBookmarks, getBookmarkCategories })(Bookmarks); diff --git a/client/src/components/Bookmarks/Form/BookmarksForm.tsx b/client/src/components/Bookmarks/Form/BookmarksForm.tsx new file mode 100644 index 00000000..893b3348 --- /dev/null +++ b/client/src/components/Bookmarks/Form/BookmarksForm.tsx @@ -0,0 +1,264 @@ +import { useState, ChangeEvent, useEffect, FormEvent } from 'react'; + +// Redux +import { useDispatch, useSelector } from 'react-redux'; +import { State } from '../../../store/reducers'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../../store'; + +// Typescript +import { Bookmark, Category, NewBookmark } from '../../../interfaces'; + +// UI +import { ModalForm, InputGroup, Button } from '../../UI'; + +// CSS +import classes from './Form.module.css'; + +// Utils +import { inputHandler, newBookmarkTemplate } from '../../../utility'; + +interface Props { + modalHandler: () => void; + bookmark?: Bookmark; +} + +export const BookmarksForm = ({ + bookmark, + modalHandler, +}: Props): JSX.Element => { + const { categories } = useSelector((state: State) => state.bookmarks); + + const dispatch = useDispatch(); + const { addBookmark, updateBookmark, createNotification } = + bindActionCreators(actionCreators, dispatch); + + const [useCustomIcon, toggleUseCustomIcon] = useState(false); + const [customIcon, setCustomIcon] = useState(null); + + const [formData, setFormData] = useState(newBookmarkTemplate); + + // Load bookmark data if provided for editing + useEffect(() => { + if (bookmark) { + setFormData({ ...bookmark }); + } else { + setFormData(newBookmarkTemplate); + } + }, [bookmark]); + + const inputChangeHandler = ( + e: ChangeEvent, + options?: { isNumber?: boolean; isBool?: boolean } + ) => { + inputHandler({ + e, + options, + setStateHandler: setFormData, + state: formData, + }); + }; + + const fileChangeHandler = (e: ChangeEvent): void => { + if (e.target.files) { + setCustomIcon(e.target.files[0]); + } + }; + + // Bookmarks form handler + const formSubmitHandler = (e: FormEvent): void => { + e.preventDefault(); + + const createFormData = (): FormData => { + const data = new FormData(); + if (customIcon) { + data.append('icon', customIcon); + } + data.append('name', formData.name); + data.append('url', formData.url); + data.append('categoryId', `${formData.categoryId}`); + data.append('isPublic', `${formData.isPublic ? 1 : 0}`); + + return data; + }; + + const checkCategory = (): boolean => { + if (formData.categoryId < 0) { + createNotification({ + title: 'Error', + message: 'Please select category', + }); + + return false; + } + + return true; + }; + + if (!bookmark) { + // add new bookmark + if (!checkCategory()) return; + + if (formData.categoryId < 0) { + createNotification({ + title: 'Error', + message: 'Please select category', + }); + return; + } + + if (customIcon) { + const data = createFormData(); + addBookmark(data); + } else { + addBookmark(formData); + } + + setFormData({ + ...newBookmarkTemplate, + categoryId: formData.categoryId, + isPublic: formData.isPublic, + }); + } else { + // update + if (!checkCategory()) return; + + if (customIcon) { + const data = createFormData(); + updateBookmark(bookmark.id, data, { + prev: bookmark.categoryId, + curr: formData.categoryId, + }); + } else { + updateBookmark(bookmark.id, formData, { + prev: bookmark.categoryId, + curr: formData.categoryId, + }); + } + + modalHandler(); + } + + setFormData({ ...newBookmarkTemplate, categoryId: formData.categoryId }); + setCustomIcon(null); + }; + + return ( + + {/* NAME */} + + + inputChangeHandler(e)} + /> + + + {/* URL */} + + + inputChangeHandler(e)} + /> + + + {/* CATEGORY */} + + + + + + {/* ICON */} + {!useCustomIcon ? ( + // mdi + + + inputChangeHandler(e)} + /> + + Use icon name from MDI or pass a valid URL. + + {' '} + Click here for reference + + + toggleUseCustomIcon(!useCustomIcon)} + className={classes.Switch} + > + Switch to custom icon upload + + + ) : ( + // custom + + + fileChangeHandler(e)} + accept=".jpg,.jpeg,.png,.svg,.ico" + /> + { + setCustomIcon(null); + toggleUseCustomIcon(!useCustomIcon); + }} + className={classes.Switch} + > + Switch to MDI + + + )} + + {/* VISIBILTY */} + + + + + + + + ); +}; diff --git a/client/src/components/Bookmarks/Form/CategoryForm.tsx b/client/src/components/Bookmarks/Form/CategoryForm.tsx new file mode 100644 index 00000000..f980e498 --- /dev/null +++ b/client/src/components/Bookmarks/Form/CategoryForm.tsx @@ -0,0 +1,97 @@ +import { ChangeEvent, FormEvent, useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { bindActionCreators } from 'redux'; + +import { Category, NewCategory } from '../../../interfaces'; +import { actionCreators } from '../../../store'; +import { inputHandler, newBookmarkCategoryTemplate } from '../../../utility'; +import { Button, InputGroup, ModalForm } from '../../UI'; + +// Redux +// Typescript +// UI +// Utils +interface Props { + modalHandler: () => void; + category?: Category; +} + +export const CategoryForm = ({ + category, + modalHandler, +}: Props): JSX.Element => { + const dispatch = useDispatch(); + const { addCategory, updateCategory } = bindActionCreators( + actionCreators, + dispatch + ); + + const [formData, setFormData] = useState(newBookmarkCategoryTemplate); + + // Load category data if provided for editing + useEffect(() => { + if (category) { + setFormData({ ...category }); + } else { + setFormData(newBookmarkCategoryTemplate); + } + }, [category]); + + const inputChangeHandler = ( + e: ChangeEvent, + options?: { isNumber?: boolean; isBool?: boolean } + ) => { + inputHandler({ + e, + options, + setStateHandler: setFormData, + state: formData, + }); + }; + + // Category form handler + const formSubmitHandler = (e: FormEvent): void => { + e.preventDefault(); + + if (!category) { + addCategory(formData); + } else { + updateCategory(category.id, formData); + modalHandler(); + } + + setFormData(newBookmarkCategoryTemplate); + }; + + return ( + + + + inputChangeHandler(e)} + /> + + + + + + + + + + ); +}; diff --git a/client/src/components/Bookmarks/BookmarkForm/BookmarkForm.module.css b/client/src/components/Bookmarks/Form/Form.module.css similarity index 100% rename from client/src/components/Bookmarks/BookmarkForm/BookmarkForm.module.css rename to client/src/components/Bookmarks/Form/Form.module.css diff --git a/client/src/components/Bookmarks/Form/Form.tsx b/client/src/components/Bookmarks/Form/Form.tsx new file mode 100644 index 00000000..1d08ad9c --- /dev/null +++ b/client/src/components/Bookmarks/Form/Form.tsx @@ -0,0 +1,54 @@ +import { Fragment } from 'react'; +import { useSelector } from 'react-redux'; + +import { State } from '../../../store/reducers'; +import { bookmarkCategoryTemplate, bookmarkTemplate } from '../../../utility'; +import { ContentType } from '../Bookmarks'; +import { BookmarksForm } from './BookmarksForm'; +import { CategoryForm } from './CategoryForm'; + +// Typescript +// Utils +interface Props { + modalHandler: () => void; + contentType: ContentType; + inUpdate?: boolean; +} + +export const Form = (props: Props): JSX.Element => { + const { categoryInEdit, bookmarkInEdit } = useSelector( + (state: State) => state.bookmarks + ); + + const { modalHandler, contentType, inUpdate } = props; + + return ( + + {!inUpdate ? ( + // form: add new + + {contentType === ContentType.category ? ( + + ) : ( + + )} + + ) : ( + // form: update + + {contentType === ContentType.category ? ( + + ) : ( + + )} + + )} + + ); +}; diff --git a/client/src/components/Bookmarks/Table/BookmarksTable.tsx b/client/src/components/Bookmarks/Table/BookmarksTable.tsx new file mode 100644 index 00000000..86f0db0f --- /dev/null +++ b/client/src/components/Bookmarks/Table/BookmarksTable.tsx @@ -0,0 +1,188 @@ +import { useState, useEffect, Fragment } from 'react'; +import { + DragDropContext, + Droppable, + Draggable, + DropResult, +} from 'react-beautiful-dnd'; + +// Redux +import { useDispatch, useSelector } from 'react-redux'; +import { State } from '../../../store/reducers'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../../store'; + +// Typescript +import { Bookmark, Category } from '../../../interfaces'; + +// UI +import { Message, Table } from '../../UI'; +import { TableActions } from '../../Actions/TableActions'; +import { bookmarkTemplate } from '../../../utility'; + +interface Props { + openFormForUpdating: (data: Category | Bookmark) => void; +} + +export const BookmarksTable = ({ openFormForUpdating }: Props): JSX.Element => { + const { + bookmarks: { categoryInEdit }, + config: { config }, + } = useSelector((state: State) => state); + + const dispatch = useDispatch(); + const { + deleteBookmark, + updateBookmark, + createNotification, + reorderBookmarks, + } = bindActionCreators(actionCreators, dispatch); + + const [localBookmarks, setLocalBookmarks] = useState([]); + + // Copy bookmarks array + useEffect(() => { + if (categoryInEdit) { + setLocalBookmarks([...categoryInEdit.bookmarks]); + } + }, [categoryInEdit]); + + // Drag and drop handler + const dragEndHanlder = (result: DropResult): void => { + if (config.useOrdering !== 'orderId') { + createNotification({ + title: 'Error', + message: 'Custom order is disabled', + }); + return; + } + + if (!result.destination) { + return; + } + + const tmpBookmarks = [...localBookmarks]; + const [movedBookmark] = tmpBookmarks.splice(result.source.index, 1); + tmpBookmarks.splice(result.destination.index, 0, movedBookmark); + + setLocalBookmarks(tmpBookmarks); + + const categoryId = categoryInEdit?.id || -1; + reorderBookmarks(tmpBookmarks, categoryId); + }; + + // Action hanlders + const deleteBookmarkHandler = (id: number, name: string) => { + const categoryId = categoryInEdit?.id || -1; + + const proceed = window.confirm(`Are you sure you want to delete ${name}?`); + if (proceed) { + deleteBookmark(id, categoryId); + } + }; + + const updateBookmarkHandler = (id: number) => { + const bookmark = + categoryInEdit?.bookmarks.find((b) => b.id === id) || bookmarkTemplate; + + openFormForUpdating(bookmark); + }; + + const changeBookmarkVisibiltyHandler = (id: number) => { + const bookmark = + categoryInEdit?.bookmarks.find((b) => b.id === id) || bookmarkTemplate; + + const categoryId = categoryInEdit?.id || -1; + const [prev, curr] = [categoryId, categoryId]; + + updateBookmark( + id, + { ...bookmark, isPublic: !bookmark.isPublic }, + { prev, curr } + ); + }; + + return ( + + {!categoryInEdit ? ( + + Switch to grid view and click on the name of category you want to edit + + ) : ( + + Editing bookmarks from {categoryInEdit.name} +  category + + )} + + {categoryInEdit && ( + + + {(provided) => ( + + {localBookmarks.map((bookmark, index): JSX.Element => { + return ( + + {(provided, snapshot) => { + const style = { + border: snapshot.isDragging + ? '1px solid var(--color-accent)' + : 'none', + borderRadius: '4px', + ...provided.draggableProps.style, + }; + + return ( + + + + + + + + {!snapshot.isDragging && ( + + )} + + ); + }} + + ); + })} +
{bookmark.name}{bookmark.url}{bookmark.icon} + {bookmark.isPublic ? 'Visible' : 'Hidden'} + + {categoryInEdit.name} +
+ )} +
+
+ )} +
+ ); +}; diff --git a/client/src/components/Bookmarks/Table/CategoryTable.tsx b/client/src/components/Bookmarks/Table/CategoryTable.tsx new file mode 100644 index 00000000..d909caba --- /dev/null +++ b/client/src/components/Bookmarks/Table/CategoryTable.tsx @@ -0,0 +1,166 @@ +import { useState, useEffect, Fragment } from 'react'; +import { + DragDropContext, + Droppable, + Draggable, + DropResult, +} from 'react-beautiful-dnd'; +import { Link } from 'react-router-dom'; + +// Redux +import { useDispatch, useSelector } from 'react-redux'; +import { State } from '../../../store/reducers'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../../store'; + +// Typescript +import { Bookmark, Category } from '../../../interfaces'; + +// UI +import { Message, Table } from '../../UI'; +import { TableActions } from '../../Actions/TableActions'; + +interface Props { + openFormForUpdating: (data: Category | Bookmark) => void; +} + +export const CategoryTable = ({ openFormForUpdating }: Props): JSX.Element => { + const { + config: { config }, + bookmarks: { categories }, + } = useSelector((state: State) => state); + + const dispatch = useDispatch(); + const { + pinCategory, + deleteCategory, + createNotification, + reorderCategories, + updateCategory, + } = bindActionCreators(actionCreators, dispatch); + + const [localCategories, setLocalCategories] = useState([]); + + // Copy categories array + useEffect(() => { + setLocalCategories([...categories]); + }, [categories]); + + // Drag and drop handler + const dragEndHanlder = (result: DropResult): void => { + if (config.useOrdering !== 'orderId') { + createNotification({ + title: 'Error', + message: 'Custom order is disabled', + }); + return; + } + + if (!result.destination) { + return; + } + + const tmpCategories = [...localCategories]; + const [movedCategory] = tmpCategories.splice(result.source.index, 1); + tmpCategories.splice(result.destination.index, 0, movedCategory); + + setLocalCategories(tmpCategories); + reorderCategories(tmpCategories); + }; + + // Action handlers + const deleteCategoryHandler = (id: number, name: string) => { + const proceed = window.confirm( + `Are you sure you want to delete ${name}? It will delete ALL assigned bookmarks` + ); + + if (proceed) { + deleteCategory(id); + } + }; + + const updateCategoryHandler = (id: number) => { + const category = categories.find((c) => c.id === id) as Category; + openFormForUpdating(category); + }; + + const pinCategoryHandler = (id: number) => { + const category = categories.find((c) => c.id === id) as Category; + pinCategory(category); + }; + + const changeCategoryVisibiltyHandler = (id: number) => { + const category = categories.find((c) => c.id === id) as Category; + updateCategory(id, { ...category, isPublic: !category.isPublic }); + }; + + return ( + + + {config.useOrdering === 'orderId' ? ( +

You can drag and drop single rows to reorder categories

+ ) : ( +

+ Custom order is disabled. You can change it in the{' '} + settings +

+ )} +
+ + + + {(provided) => ( + + {localCategories.map((category, index): JSX.Element => { + return ( + + {(provided, snapshot) => { + const style = { + border: snapshot.isDragging + ? '1px solid var(--color-accent)' + : 'none', + borderRadius: '4px', + ...provided.draggableProps.style, + }; + + return ( + + + + + {!snapshot.isDragging && ( + + )} + + ); + }} + + ); + })} +
{category.name} + {category.isPublic ? 'Visible' : 'Hidden'} +
+ )} +
+
+
+ ); +}; diff --git a/client/src/components/Bookmarks/Table/Table.tsx b/client/src/components/Bookmarks/Table/Table.tsx new file mode 100644 index 00000000..8704fdbd --- /dev/null +++ b/client/src/components/Bookmarks/Table/Table.tsx @@ -0,0 +1,20 @@ +import { Category, Bookmark } from '../../../interfaces'; +import { ContentType } from '../Bookmarks'; +import { BookmarksTable } from './BookmarksTable'; +import { CategoryTable } from './CategoryTable'; + +interface Props { + contentType: ContentType; + openFormForUpdating: (data: Category | Bookmark) => void; +} + +export const Table = (props: Props): JSX.Element => { + const tableEl = + props.contentType === ContentType.category ? ( + + ) : ( + + ); + + return tableEl; +}; diff --git a/client/src/components/Home/Header/Header.module.css b/client/src/components/Home/Header/Header.module.css new file mode 100644 index 00000000..d7ee22b5 --- /dev/null +++ b/client/src/components/Home/Header/Header.module.css @@ -0,0 +1,31 @@ +.Header h1 { + color: var(--color-primary); + font-weight: 700; + font-size: 4em; + display: inline-block; +} + +.Header p { + color: var(--color-primary); + font-weight: 300; + text-transform: uppercase; + height: 30px; +} + +.HeaderMain { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2.5rem; +} + +.SettingsLink { + visibility: visible; + color: var(--color-accent); +} + +@media (min-width: 769px) { + .SettingsLink { + visibility: hidden; + } +} diff --git a/client/src/components/Home/Header/Header.tsx b/client/src/components/Home/Header/Header.tsx new file mode 100644 index 00000000..84da2806 --- /dev/null +++ b/client/src/components/Home/Header/Header.tsx @@ -0,0 +1,53 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; + +// Redux +import { useSelector } from 'react-redux'; +import { State } from '../../../store/reducers'; + +// CSS +import classes from './Header.module.css'; + +// Components +import { WeatherWidget } from '../../Widgets/WeatherWidget/WeatherWidget'; + +// Utils +import { getDateTime } from './functions/getDateTime'; +import { greeter } from './functions/greeter'; + +export const Header = (): JSX.Element => { + const { hideHeader, hideDate, showTime } = useSelector( + (state: State) => state.config.config + ); + + const [dateTime, setDateTime] = useState(getDateTime()); + const [greeting, setGreeting] = useState(greeter()); + + useEffect(() => { + let dateTimeInterval: NodeJS.Timeout; + + dateTimeInterval = setInterval(() => { + setDateTime(getDateTime()); + setGreeting(greeter()); + }, 1000); + + return () => window.clearInterval(dateTimeInterval); + }, []); + + return ( +
+ {(!hideDate || showTime) &&

{dateTime}

} + + + Go to Settings + + + {!hideHeader && ( + +

{greeting}

+ +
+ )} +
+ ); +}; diff --git a/client/src/components/Home/Header/functions/getDateTime.ts b/client/src/components/Home/Header/functions/getDateTime.ts new file mode 100644 index 00000000..45ffc9d5 --- /dev/null +++ b/client/src/components/Home/Header/functions/getDateTime.ts @@ -0,0 +1,71 @@ +import { parseTime } from '../../../../utility'; + +export const getDateTime = (): string => { + const days = localStorage.getItem('daySchema')?.split(';') || [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + ]; + + const months = localStorage.getItem('monthSchema')?.split(';') || [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + + const now = new Date(); + + const useAmericanDate = localStorage.useAmericanDate === 'true'; + const showTime = localStorage.showTime === 'true'; + const hideDate = localStorage.hideDate === 'true'; + + // Date + let dateEl = ''; + + if (!hideDate) { + if (!useAmericanDate) { + dateEl = `${days[now.getDay()]}, ${now.getDate()} ${ + months[now.getMonth()] + } ${now.getFullYear()}`; + } else { + dateEl = `${days[now.getDay()]}, ${ + months[now.getMonth()] + } ${now.getDate()} ${now.getFullYear()}`; + } + } + + // Time + const p = parseTime; + let timeEl = ''; + + if (showTime) { + const time = `${p(now.getHours())}:${p(now.getMinutes())}:${p( + now.getSeconds() + )}`; + + timeEl = time; + } + + // Separator + let separator = ''; + + if (!hideDate && showTime) { + separator = ' - '; + } + + // Output + return `${dateEl}${separator}${timeEl}`; +}; diff --git a/client/src/components/Home/Header/functions/greeter.ts b/client/src/components/Home/Header/functions/greeter.ts new file mode 100644 index 00000000..93b32b4e --- /dev/null +++ b/client/src/components/Home/Header/functions/greeter.ts @@ -0,0 +1,17 @@ +export const greeter = (): string => { + const now = new Date().getHours(); + let msg: string; + + const greetingsSchemaRaw = + localStorage.getItem('greetingsSchema') || + 'Good evening!;Good afternoon!;Good morning!;Good night!'; + const greetingsSchema = greetingsSchemaRaw.split(';'); + + if (now >= 18) msg = greetingsSchema[0]; + else if (now >= 12) msg = greetingsSchema[1]; + else if (now >= 6) msg = greetingsSchema[2]; + else if (now >= 0) msg = greetingsSchema[3]; + else msg = 'Hello!'; + + return msg; +}; diff --git a/client/src/components/Home/Home.module.css b/client/src/components/Home/Home.module.css index 652ca22a..f4251845 100644 --- a/client/src/components/Home/Home.module.css +++ b/client/src/components/Home/Home.module.css @@ -1,24 +1,3 @@ -.Header h1 { - color: var(--color-primary); - font-weight: 700; - font-size: 4em; - display: inline-block; -} - -.Header p { - color: var(--color-primary); - font-weight: 300; - text-transform: uppercase; - height: 30px; -} - -.HeaderMain { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 2.5rem; -} - .SettingsButton { width: 35px; height: 35px; @@ -40,21 +19,12 @@ opacity: 1; } -.SettingsLink { - visibility: visible; - color: var(--color-accent); -} - @media (min-width: 769px) { .SettingsButton { visibility: visible; } - - .SettingsLink { - visibility: hidden; - } } .HomeSpace { height: 20px; -} \ No newline at end of file +} diff --git a/client/src/components/Home/Home.tsx b/client/src/components/Home/Home.tsx index 4e3c4784..c9403616 100644 --- a/client/src/components/Home/Home.tsx +++ b/client/src/components/Home/Home.tsx @@ -1,144 +1,116 @@ import { Fragment, useEffect, useState } from 'react'; -import { connect } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; - -import { App, Bookmark, Category, GlobalState } from '../../interfaces'; -import { getAppCategories, getApps, getBookmarkCategories, getBookmarks } from '../../store/actions'; -import { searchConfig } from '../../utility'; -import AppGrid from '../Apps/AppGrid/AppGrid'; -import BookmarkGrid from '../Bookmarks/BookmarkGrid/BookmarkGrid'; -import SearchBar from '../SearchBar/SearchBar'; -import SectionHeadline from '../UI/Headlines/SectionHeadline/SectionHeadline'; -import Icon from '../UI/Icons/Icon/Icon'; +import { bindActionCreators } from 'redux'; + +import { Category } from '../../interfaces'; +import { actionCreators } from '../../store'; +import { State } from '../../store/reducers'; +import { escapeRegex } from '../../utility'; +import { AppGrid } from '../Apps/AppGrid/AppGrid'; +import { BookmarkGrid } from '../Bookmarks/BookmarkGrid/BookmarkGrid'; +import { SearchBar } from '../SearchBar/SearchBar'; +import { Icon, Message, SectionHeadline, Spinner } from '../UI'; import { Container } from '../UI/Layout/Layout'; -import Spinner from '../UI/Spinner/Spinner'; -import WeatherWidget from '../Widgets/WeatherWidget/WeatherWidget'; -import { dateTime } from './functions/dateTime'; -import { greeter } from './functions/greeter'; +import { Header } from './Header/Header'; import classes from './Home.module.css'; -interface ComponentProps { - getApps: () => void; - getAppCategories: () => void; - getBookmarks: () => void; - getBookmarkCategories: () => void; - appsLoading: boolean; - bookmarkCategoriesLoading: boolean; - appCategories: Category[]; - apps: App[]; - bookmarkCategories: Category[]; - bookmarks: Bookmark[]; -} - -const Home = (props: ComponentProps): JSX.Element => { +export const Home = (): JSX.Element => { const { - getAppCategories, - getApps, - getBookmarkCategories, - getBookmarks, - appCategories, - apps, - bookmarkCategories, - bookmarks, - appsLoading, - bookmarkCategoriesLoading, - } = props; - - const [header, setHeader] = useState({ - dateTime: dateTime(), - greeting: greeter(), - }); + apps: { categories: appCategories, loading: appsLoading }, + bookmarks: { categories: bookmarkCategories, loading: bookmarksLoading }, + config: { config }, + auth: { isAuthenticated }, + } = useSelector((state: State) => state); + + const dispatch = useDispatch(); + const { getCategories } = bindActionCreators( + actionCreators, + dispatch + ); // Local search query const [localSearch, setLocalSearch] = useState(null); + const [appSearchResult, setAppSearchResult] = useState(null); + const [bookmarkSearchResult, setBookmarkSearchResult] = useState< + null | Category[] + >(null); - // Load apps + // Load apps and bookmarks useEffect(() => { - if (apps.length === 0) { - getApps(); + if (!appCategories.length && !bookmarkCategories.length) { + getCategories(); } - }, [getApps]); + }, []); - // Load bookmarks useEffect(() => { - if (bookmarks.length === 0) { - getBookmarks(); - } - }, [getBookmarks]); + if (localSearch) { + // Search through apps + setAppSearchResult([ + ...appCategories.filter(({ name }) => + new RegExp(escapeRegex(localSearch), 'i').test(name) + ), + ]); + + // Search through bookmarks + const appCategory = { ...appCategories[0] }; + + appCategory.name = 'Search Results'; + appCategory.apps = appCategories + .map(({ apps }) => apps) + .flat() + .filter(({ name, url }) => + new RegExp(escapeRegex(localSearch), 'i').test(name) || + new RegExp(escapeRegex(localSearch), 'i').test(url) + ); - // Refresh greeter and time - useEffect(() => { - let interval: any; - - // Start interval only when hideHeader is false - if (searchConfig("hideHeader", 0) !== 1) { - interval = setInterval(() => { - setHeader({ - dateTime: dateTime(), - greeting: greeter(), - }); - }, 1000); - } + setAppSearchResult([appCategory]); - return () => clearInterval(interval); - }, []); + // Search through bookmarks + const bookmarkCategory = { ...bookmarkCategories[0] }; - // Search categories - const searchInCategories = (query: string, categoriesToSearch: Category[]): Category[] => { - const category: Category = { - name: "Search Results", - type: categoriesToSearch[0]?.type, - isPinned: true, - apps: categoriesToSearch - .map((c: Category) => c.id >= 0 ? c.apps : apps.filter((app: App) => app.categoryId === c.id)) + bookmarkCategory.name = 'Search Results'; + bookmarkCategory.bookmarks = bookmarkCategories + .map(({ bookmarks }) => bookmarks) .flat() - .filter((app: App) => new RegExp(query, 'i').test(app.name)), - bookmarks: categoriesToSearch - .map((c: Category) => c.id >= 0 ? c.bookmarks : bookmarks.filter((bookmark: Bookmark) => bookmark.categoryId === c.id)) - .flat() - .filter((bookmark: Bookmark) => new RegExp(query, 'i').test(bookmark.name)), - id: 0, - orderId: 0, - createdAt: new Date(), - updatedAt: new Date(), - }; - - return [category]; - }; - - const categoryContainsPinnedItems = (category: Category, allItems: App[] | Bookmark[]): boolean => { - if (category.apps?.filter((app: App) => app.isPinned).length > 0) return true; - if (category.bookmarks?.filter((bookmark: Bookmark) => bookmark.isPinned).length > 0) return true; - if (category.id < 0) { // Is a default category - return allItems.findIndex((item: App | Bookmark) => item.categoryId === category.id && item.isPinned) >= 0; + .filter(({ name, url }) => + new RegExp(escapeRegex(localSearch), 'i').test(name) || + new RegExp(escapeRegex(localSearch), 'i').test(url) + ); + + setBookmarkSearchResult([bookmarkCategory]); + } else { + setAppSearchResult(null); + setBookmarkSearchResult(null); } - return false; - }; + }, [localSearch]); return ( - {searchConfig('hideSearch', 0) !== 1 ? ( - + {!config.hideSearch ? ( + ) : (
)} - {searchConfig('hideHeader', 0) !== 1 ? ( -
-

{header.dateTime}

- - Go to Settings - - -

{header.greeting}

- -
-
+
+ + {!isAuthenticated && + !appCategories.some((a) => a.isPinned) && + !bookmarkCategories.some((c) => c.isPinned) ? ( + + Welcome to Flame! Go to /settings, + login and start customizing your new homepage + ) : ( -
+ <> )} - {searchConfig('hideApps', 0) !== 1 ? ( + {!config.hideApps && (isAuthenticated || appCategories.some((a) => a.isPinned)) ? ( {appsLoading ? ( @@ -146,53 +118,41 @@ const Home = (props: ComponentProps): JSX.Element => { ) : ( category.isPinned && categoryContainsPinnedItems(category, apps)) - : searchInCategories(localSearch, appCategories) - } - apps={ - !localSearch - ? apps.filter((app: App) => app.isPinned) - : apps.filter((app: App) => - new RegExp(localSearch, 'i').test(app.name) - ) + !appSearchResult + ? appCategories.filter(({ isPinned }) => isPinned) + : appSearchResult } totalCategories={appCategories.length} searching={!!localSearch} + fromHomepage={true} /> )}
) : ( -
+ <> )} - {searchConfig('hideBookmarks', 0) !== 1 ? ( + {!config.hideBookmarks && (isAuthenticated || bookmarkCategories.some((c) => c.isPinned)) ? ( - {bookmarkCategoriesLoading ? ( + {bookmarksLoading ? ( ) : ( category.isPinned && categoryContainsPinnedItems(category, bookmarks)) - : searchInCategories(localSearch, bookmarkCategories) - } - bookmarks={ - !localSearch - ? bookmarks.filter((bookmark: Bookmark) => bookmark.isPinned) - : bookmarks.filter((bookmark: Bookmark) => - new RegExp(localSearch, 'i').test(bookmark.name) - ) + !bookmarkSearchResult + ? bookmarkCategories.filter(({ isPinned }) => isPinned) + : bookmarkSearchResult } totalCategories={bookmarkCategories.length} searching={!!localSearch} + fromHomepage={true} /> )} ) : ( -
+ <> )} @@ -201,16 +161,3 @@ const Home = (props: ComponentProps): JSX.Element => { ); }; - -const mapStateToProps = (state: GlobalState) => { - return { - appCategories: state.app.categories, - appsLoading: state.app.loading, - apps: state.app.apps, - bookmarkCategoriesLoading: state.bookmark.loading, - bookmarkCategories: state.bookmark.categories, - bookmarks: state.bookmark.bookmarks, - } -} - -export default connect(mapStateToProps, { getApps, getAppCategories, getBookmarks, getBookmarkCategories })(Home); diff --git a/client/src/components/Home/functions/dateTime.ts b/client/src/components/Home/functions/dateTime.ts deleted file mode 100644 index 44cc5e18..00000000 --- a/client/src/components/Home/functions/dateTime.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const dateTime = (): string => { - const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; - const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; - - const now = new Date(); - - return `${days[now.getDay()]}, ${now.getDate()} ${months[now.getMonth()]} ${now.getFullYear()}`; -} \ No newline at end of file diff --git a/client/src/components/Home/functions/greeter.ts b/client/src/components/Home/functions/greeter.ts deleted file mode 100644 index 64cb2ea9..00000000 --- a/client/src/components/Home/functions/greeter.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const greeter = (): string => { - const now = new Date().getHours(); - let msg: string; - - if (now >= 18) msg = 'Good evening!'; - else if (now >= 12) msg = 'Good afternoon!'; - else if (now >= 6) msg = 'Good morning!'; - else if (now >= 0) msg = 'Good night!'; - else msg = 'Hello!'; - - return msg; -} \ No newline at end of file diff --git a/client/src/components/NotificationCenter/NotificationCenter.tsx b/client/src/components/NotificationCenter/NotificationCenter.tsx index 733316b2..4bda8bf8 100644 --- a/client/src/components/NotificationCenter/NotificationCenter.tsx +++ b/client/src/components/NotificationCenter/NotificationCenter.tsx @@ -1,21 +1,20 @@ -import { connect } from 'react-redux'; -import { GlobalState, Notification as _Notification } from '../../interfaces'; +import { useSelector } from 'react-redux'; +import { Notification as NotificationInterface } from '../../interfaces'; import classes from './NotificationCenter.module.css'; -import Notification from '../UI/Notification/Notification'; +import { Notification } from '../UI'; +import { State } from '../../store/reducers'; -interface ComponentProps { - notifications: _Notification[]; -} +export const NotificationCenter = (): JSX.Element => { + const { notifications } = useSelector((state: State) => state.notification); -const NotificationCenter = (props: ComponentProps): JSX.Element => { return (
- {props.notifications.map((notification: _Notification) => { + {notifications.map((notification: NotificationInterface) => { return ( {
); }; - -const mapStateToProps = (state: GlobalState) => { - return { - notifications: state.notification.notifications, - }; -}; - -export default connect(mapStateToProps)(NotificationCenter); diff --git a/client/src/components/Routing/ProtectedRoute.tsx b/client/src/components/Routing/ProtectedRoute.tsx new file mode 100644 index 00000000..45b65048 --- /dev/null +++ b/client/src/components/Routing/ProtectedRoute.tsx @@ -0,0 +1,13 @@ +import { useSelector } from 'react-redux'; +import { Redirect, Route, RouteProps } from 'react-router'; +import { State } from '../../store/reducers'; + +export const ProtectedRoute = ({ ...rest }: RouteProps) => { + const { isAuthenticated } = useSelector((state: State) => state.auth); + + if (isAuthenticated) { + return ; + } else { + return ; + } +}; diff --git a/client/src/components/SearchBar/SearchBar.tsx b/client/src/components/SearchBar/SearchBar.tsx index 887a2ef9..c25e685a 100644 --- a/client/src/components/SearchBar/SearchBar.tsx +++ b/client/src/components/SearchBar/SearchBar.tsx @@ -1,32 +1,65 @@ -import { useRef, useEffect, KeyboardEvent } from 'react'; +import { KeyboardEvent, useEffect, useRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { bindActionCreators } from 'redux'; -// Redux -import { connect } from 'react-redux'; -import { createNotification } from '../../store/actions'; +import { Category } from '../../interfaces'; +import { actionCreators } from '../../store'; +import { State } from '../../store/reducers'; +import { redirectUrl, searchParser, urlParser } from '../../utility'; +import classes from './SearchBar.module.css'; +// Redux // Typescript -import { NewNotification } from '../../interfaces'; - // CSS -import classes from './SearchBar.module.css'; - // Utils -import { searchParser, urlParser, redirectUrl } from '../../utility'; - -interface ComponentProps { - createNotification: (notification: NewNotification) => void; +interface Props { setLocalSearch: (query: string) => void; + appSearchResult: Category[] | null; + bookmarkSearchResult: Category[] | null; } -const SearchBar = (props: ComponentProps): JSX.Element => { - const { setLocalSearch, createNotification } = props; +export const SearchBar = (props: Props): JSX.Element => { + const { config, loading } = useSelector((state: State) => state.config); + + const dispatch = useDispatch(); + const { createNotification } = bindActionCreators(actionCreators, dispatch); + + const { setLocalSearch, appSearchResult, bookmarkSearchResult } = props; const inputRef = useRef(document.createElement('input')); + // Search bar autofocus useEffect(() => { - inputRef.current.focus(); + if (!loading && !config.disableAutofocus) { + inputRef.current.focus(); + } + }, [config]); + + // Listen for keyboard events outside of search bar + useEffect(() => { + const keyOutsideFocus = (e: any) => { + const { key } = e as KeyboardEvent; + + if (key === 'Escape') { + clearSearch(); + } else if (document.activeElement !== inputRef.current) { + if (key === '`') { + inputRef.current.focus(); + clearSearch(); + } + } + }; + + window.addEventListener('keyup', keyOutsideFocus); + + return () => window.removeEventListener('keyup', keyOutsideFocus); }, []); + const clearSearch = () => { + inputRef.current.value = ''; + setLocalSearch(''); + }; + const searchHandler = (e: KeyboardEvent) => { const { isLocal, search, query, isURL, sameTab } = searchParser( inputRef.current.value @@ -36,32 +69,53 @@ const SearchBar = (props: ComponentProps): JSX.Element => { setLocalSearch(search); } - if (e.code === 'Enter') { + if (e.code === 'Enter' || e.code === 'NumpadEnter') { if (!query.prefix) { + // Prefix not found -> emit notification createNotification({ title: 'Error', message: 'Prefix not found', }); } else if (isURL) { + // URL or IP passed -> redirect const url = urlParser(inputRef.current.value)[1]; redirectUrl(url, sameTab); } else if (isLocal) { - setLocalSearch(search); + // Local query -> redirect if at least 1 result found + if (appSearchResult?.[0]?.apps?.length) { + redirectUrl(appSearchResult[0].apps[0].url, sameTab); + } else if (bookmarkSearchResult?.[0]?.bookmarks?.length) { + redirectUrl(bookmarkSearchResult[0].bookmarks[0].url, sameTab); + } else { + // no local results -> search the internet with the default search provider + let template = query.template; + + if (query.prefix === 'l') { + template = 'https://duckduckgo.com/?q='; + } + + const url = `${template}${search}`; + redirectUrl(url, sameTab); + } } else { + // Valid query -> redirect to search results const url = `${query.template}${search}`; redirectUrl(url, sameTab); } + } else if (e.code === 'Escape') { + clearSearch(); } }; return ( - searchHandler(e)} - /> +
+ searchHandler(e)} + onDoubleClick={clearSearch} + /> +
); }; - -export default connect(null, { createNotification })(SearchBar); diff --git a/client/src/components/Settings/AppDetails/AppDetails.module.css b/client/src/components/Settings/AppDetails/AppDetails.module.css index 8f5fae3d..6a7b939b 100644 --- a/client/src/components/Settings/AppDetails/AppDetails.module.css +++ b/client/src/components/Settings/AppDetails/AppDetails.module.css @@ -1,8 +1,14 @@ -.AppVersion { +.text { color: var(--color-primary); margin-bottom: 15px; } -.AppVersion a { +.text a, +.text span { color: var(--color-accent); -} \ No newline at end of file +} + +.separator { + margin: 30px 0; + border: 1px solid var(--color-primary); +} diff --git a/client/src/components/Settings/AppDetails/AppDetails.tsx b/client/src/components/Settings/AppDetails/AppDetails.tsx index 109053aa..1829a4df 100644 --- a/client/src/components/Settings/AppDetails/AppDetails.tsx +++ b/client/src/components/Settings/AppDetails/AppDetails.tsx @@ -1,34 +1,43 @@ import { Fragment } from 'react'; - +import { Button, SettingsHeadline } from '../../UI'; import classes from './AppDetails.module.css'; -import Button from '../../UI/Buttons/Button/Button'; import { checkVersion } from '../../../utility'; +import { AuthForm } from './AuthForm/AuthForm'; -const AppDetails = (): JSX.Element => { +export const AppDetails = (): JSX.Element => { return ( -

- - Flame - - {' '} - version {process.env.REACT_APP_VERSION} -

-

- See changelog {' '} - - here - -

- -
- ) -} + + + +
-export default AppDetails; +
+ +

+ + Flame + {' '} + version {process.env.REACT_APP_VERSION} +

+ +

+ See changelog{' '} + + here + +

+ + +
+ + ); +}; diff --git a/client/src/components/Settings/AppDetails/AuthForm/AuthForm.tsx b/client/src/components/Settings/AppDetails/AuthForm/AuthForm.tsx new file mode 100644 index 00000000..d0f64665 --- /dev/null +++ b/client/src/components/Settings/AppDetails/AuthForm/AuthForm.tsx @@ -0,0 +1,110 @@ +import { FormEvent, Fragment, useEffect, useState, useRef } from 'react'; + +// Redux +import { useSelector, useDispatch } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../../../store'; +import { State } from '../../../../store/reducers'; +import { decodeToken, parseTokenExpire } from '../../../../utility'; + +// Other +import { InputGroup, Button } from '../../../UI'; +import classes from '../AppDetails.module.css'; + +export const AuthForm = (): JSX.Element => { + const { isAuthenticated, token } = useSelector((state: State) => state.auth); + + const dispatch = useDispatch(); + const { login, logout } = bindActionCreators(actionCreators, dispatch); + + const [tokenExpires, setTokenExpires] = useState(''); + const [formData, setFormData] = useState({ + password: '', + duration: '14d', + }); + + const passwordInputRef = useRef(null); + + useEffect(() => { + passwordInputRef.current?.focus(); + }, []); + + useEffect(() => { + if (token) { + const decoded = decodeToken(token); + const expiresIn = parseTokenExpire(decoded.exp); + setTokenExpires(expiresIn); + } + }, [token]); + + const formHandler = (e: FormEvent) => { + e.preventDefault(); + login(formData); + setFormData({ + password: '', + duration: '14d', + }); + }; + + return ( + + {!isAuthenticated ? ( + + + + + setFormData({ ...formData, password: e.target.value }) + } + /> + + See + + {` project wiki `} + + to read more about authentication + + + + + + + + + + + ) : ( +
+

+ You are logged in. Your session will expire{' '} + {tokenExpires} +

+ +
+ )} +
+ ); +}; diff --git a/client/src/components/Settings/DockerSettings/DockerSettings.tsx b/client/src/components/Settings/DockerSettings/DockerSettings.tsx new file mode 100644 index 00000000..d9505016 --- /dev/null +++ b/client/src/components/Settings/DockerSettings/DockerSettings.tsx @@ -0,0 +1,122 @@ +import { useState, useEffect, ChangeEvent, FormEvent } from 'react'; + +// Redux +import { useDispatch, useSelector } from 'react-redux'; +import { State } from '../../../store/reducers'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../../store'; + +// Typescript +import { DockerSettingsForm } from '../../../interfaces'; + +// UI +import { InputGroup, Button, SettingsHeadline } from '../../UI'; + +// Utils +import { inputHandler, dockerSettingsTemplate } from '../../../utility'; + +export const DockerSettings = (): JSX.Element => { + const { loading, config } = useSelector((state: State) => state.config); + + const dispatch = useDispatch(); + const { updateConfig } = bindActionCreators(actionCreators, dispatch); + + // Initial state + const [formData, setFormData] = useState( + dockerSettingsTemplate + ); + + // Get config + useEffect(() => { + setFormData({ + ...config, + }); + }, [loading]); + + // Form handler + const formSubmitHandler = async (e: FormEvent) => { + e.preventDefault(); + + // Save settings + await updateConfig(formData); + }; + + // Input handler + const inputChangeHandler = ( + e: ChangeEvent, + options?: { isNumber?: boolean; isBool?: boolean } + ) => { + inputHandler({ + e, + options, + setStateHandler: setFormData, + state: formData, + }); + }; + + return ( +
formSubmitHandler(e)}> + + {/* CUSTOM DOCKER SOCKET HOST */} + + + inputChangeHandler(e)} + /> + + + {/* USE DOCKER API */} + + + + + + {/* UNPIN DOCKER APPS */} + + + + + + {/* KUBERNETES SETTINGS */} + + {/* USE KUBERNETES */} + + + + + + + + ); +}; diff --git a/client/src/components/Settings/OtherSettings/OtherSettings.tsx b/client/src/components/Settings/OtherSettings/OtherSettings.tsx deleted file mode 100644 index a9636764..00000000 --- a/client/src/components/Settings/OtherSettings/OtherSettings.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import { ChangeEvent, FormEvent, useEffect, useState } from 'react'; -import { connect } from 'react-redux'; - -import { GlobalState, NewNotification, SettingsForm } from '../../../interfaces'; -import { - createNotification, - sortAppCategories, - sortApps, - sortBookmarkCategories, - updateConfig, -} from '../../../store/actions'; -import { searchConfig } from '../../../utility'; -import Button from '../../UI/Buttons/Button/Button'; -import InputGroup from '../../UI/Forms/InputGroup/InputGroup'; -import SettingsHeadline from '../../UI/Headlines/SettingsHeadline/SettingsHeadline'; - -interface ComponentProps { - createNotification: (notification: NewNotification) => void; - updateConfig: (formData: SettingsForm) => void; - sortAppCategories: () => void; - sortApps: () => void; - sortBookmarkCategories: () => void; - loading: boolean; -} - -const OtherSettings = (props: ComponentProps): JSX.Element => { - // Initial state - const [formData, setFormData] = useState({ - customTitle: document.title, - pinAppsByDefault: 1, - pinBookmarksByDefault: 1, - pinCategoriesByDefault: 1, - hideHeader: 0, - hideApps: 0, - hideBookmarks: 0, - useOrdering: 'createdAt', - appsSameTab: 0, - bookmarksSameTab: 0, - dockerApps: 1, - dockerHost: 'localhost', - kubernetesApps: 1, - unpinStoppedApps: 1, - }); - - // Get config - useEffect(() => { - setFormData({ - customTitle: searchConfig('customTitle', 'Flame'), - pinAppsByDefault: searchConfig('pinAppsByDefault', 1), - pinBookmarksByDefault: searchConfig('pinBookmarksByDefault', 1), - pinCategoriesByDefault: searchConfig('pinCategoriesByDefault', 1), - hideHeader: searchConfig('hideHeader', 0), - hideApps: searchConfig('hideApps', 0), - hideBookmarks: searchConfig('hideBookmarks', 0), - useOrdering: searchConfig('useOrdering', 'createdAt'), - appsSameTab: searchConfig('appsSameTab', 0), - bookmarksSameTab: searchConfig('bookmarksSameTab', 0), - dockerApps: searchConfig('dockerApps', 0), - dockerHost: searchConfig('dockerHost', 'localhost'), - kubernetesApps: searchConfig('kubernetesApps', 0), - unpinStoppedApps: searchConfig('unpinStoppedApps', 0), - }); - }, [props.loading]); - - // Form handler - const formSubmitHandler = async (e: FormEvent) => { - e.preventDefault(); - - // Save settings - await props.updateConfig(formData); - - // Update local page title - document.title = formData.customTitle; - - // Apply new sort settings - props.sortAppCategories(); - props.sortApps(); - props.sortBookmarkCategories(); - }; - - // Input handler - const inputChangeHandler = ( - e: ChangeEvent, - isNumber?: boolean - ) => { - let value: string | number = e.target.value; - - if (isNumber) { - value = parseFloat(value); - } - - setFormData({ - ...formData, - [e.target.name]: value, - }); - }; - - return ( -
formSubmitHandler(e)}> - {/* OTHER OPTIONS */} - - - - inputChangeHandler(e)} - /> - - - {/* BEAHVIOR OPTIONS */} - - - - - - - - - - - - - - - - - - - - - - - - - - - {/* MODULES OPTIONS */} - - - - - - - - - - - - - - - {/* DOCKER SETTINGS */} - - - - inputChangeHandler(e)} - /> - - - - - - - - - - - {/* KUBERNETES SETTINGS */} - - - - - - - - ); -}; - -const mapStateToProps = (state: GlobalState) => { - return { - loading: state.config.loading, - }; -}; - -const actions = { - createNotification, - updateConfig, - sortApps, - sortAppCategories, - sortBookmarkCategories -}; - -export default connect(mapStateToProps, actions)(OtherSettings); diff --git a/client/src/components/Settings/SearchSettings/CustomQueries/CustomQueries.tsx b/client/src/components/Settings/SearchSettings/CustomQueries/CustomQueries.tsx index c5dac623..747be3bb 100644 --- a/client/src/components/Settings/SearchSettings/CustomQueries/CustomQueries.tsx +++ b/client/src/components/Settings/SearchSettings/CustomQueries/CustomQueries.tsx @@ -1,24 +1,31 @@ import { Fragment, useState } from 'react'; -import { connect } from 'react-redux'; +// Redux +import { useDispatch, useSelector } from 'react-redux'; +import { State } from '../../../../store/reducers'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../../../store'; + +// Typescript +import { Query } from '../../../../interfaces'; + +// CSS import classes from './CustomQueries.module.css'; -import Modal from '../../../UI/Modal/Modal'; -import Icon from '../../../UI/Icons/Icon/Icon'; -import { GlobalState, NewNotification, Query } from '../../../../interfaces'; -import QueriesForm from './QueriesForm'; -import { deleteQuery, createNotification } from '../../../../store/actions'; -import Button from '../../../UI/Buttons/Button/Button'; -import { searchConfig } from '../../../../utility'; +// UI +import { Modal, Icon, Button } from '../../../UI'; -interface Props { - customQueries: Query[]; - deleteQuery: (prefix: string) => {}; - createNotification: (notification: NewNotification) => void; -} +// Components +import { QueriesForm } from './QueriesForm'; -const CustomQueries = (props: Props): JSX.Element => { - const { customQueries, deleteQuery, createNotification } = props; +export const CustomQueries = (): JSX.Element => { + const { customQueries, config } = useSelector((state: State) => state.config); + + const dispatch = useDispatch(); + const { deleteQuery, createNotification } = bindActionCreators( + actionCreators, + dispatch + ); const [modalIsOpen, setModalIsOpen] = useState(false); const [editableQuery, setEditableQuery] = useState(null); @@ -29,7 +36,7 @@ const CustomQueries = (props: Props): JSX.Element => { }; const deleteHandler = (query: Query) => { - const currentProvider = searchConfig('defaultSearchProvider', 'l'); + const currentProvider = config.defaultSearchProvider; const isCurrent = currentProvider === query.prefix; if (isCurrent) { @@ -100,13 +107,3 @@ const CustomQueries = (props: Props): JSX.Element => { ); }; - -const mapStateToProps = (state: GlobalState) => { - return { - customQueries: state.config.customQueries, - }; -}; - -export default connect(mapStateToProps, { deleteQuery, createNotification })( - CustomQueries -); diff --git a/client/src/components/Settings/SearchSettings/CustomQueries/QueriesForm.tsx b/client/src/components/Settings/SearchSettings/CustomQueries/QueriesForm.tsx index 42ad6542..2cb76a96 100644 --- a/client/src/components/Settings/SearchSettings/CustomQueries/QueriesForm.tsx +++ b/client/src/components/Settings/SearchSettings/CustomQueries/QueriesForm.tsx @@ -1,20 +1,26 @@ import { ChangeEvent, FormEvent, useState, useEffect } from 'react'; + +import { useDispatch } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../../../store'; + import { Query } from '../../../../interfaces'; -import Button from '../../../UI/Buttons/Button/Button'; -import InputGroup from '../../../UI/Forms/InputGroup/InputGroup'; -import ModalForm from '../../../UI/Forms/ModalForm/ModalForm'; -import { connect } from 'react-redux'; -import { addQuery, updateQuery } from '../../../../store/actions'; + +import { Button, InputGroup, ModalForm } from '../../../UI'; interface Props { modalHandler: () => void; - addQuery: (query: Query) => {}; - updateQuery: (query: Query, Oldprefix: string) => {}; query?: Query; } -const QueriesForm = (props: Props): JSX.Element => { - const { modalHandler, addQuery, updateQuery, query } = props; +export const QueriesForm = (props: Props): JSX.Element => { + const dispatch = useDispatch(); + const { addQuery, updateQuery } = bindActionCreators( + actionCreators, + dispatch + ); + + const { modalHandler, query } = props; const [formData, setFormData] = useState({ name: '', @@ -77,6 +83,7 @@ const QueriesForm = (props: Props): JSX.Element => { onChange={(e) => inputChangeHandler(e)} /> + { onChange={(e) => inputChangeHandler(e)} /> + { onChange={(e) => inputChangeHandler(e)} /> + {query ? : } ); }; - -export default connect(null, { addQuery, updateQuery })(QueriesForm); diff --git a/client/src/components/Settings/SearchSettings/SearchSettings.tsx b/client/src/components/Settings/SearchSettings/SearchSettings.tsx index b2ac4224..9b057f56 100644 --- a/client/src/components/Settings/SearchSettings/SearchSettings.tsx +++ b/client/src/components/Settings/SearchSettings/SearchSettings.tsx @@ -1,78 +1,63 @@ // React import { useState, useEffect, FormEvent, ChangeEvent, Fragment } from 'react'; -import { connect } from 'react-redux'; - -// State -import { createNotification, updateConfig } from '../../../store/actions'; +import { useDispatch, useSelector } from 'react-redux'; // Typescript -import { - GlobalState, - NewNotification, - Query, - SearchForm, -} from '../../../interfaces'; +import { Query, SearchForm } from '../../../interfaces'; // Components -import CustomQueries from './CustomQueries/CustomQueries'; +import { CustomQueries } from './CustomQueries/CustomQueries'; // UI -import Button from '../../UI/Buttons/Button/Button'; -import SettingsHeadline from '../../UI/Headlines/SettingsHeadline/SettingsHeadline'; -import InputGroup from '../../UI/Forms/InputGroup/InputGroup'; +import { Button, SettingsHeadline, InputGroup } from '../../UI'; // Utils -import { searchConfig } from '../../../utility'; +import { inputHandler, searchSettingsTemplate } from '../../../utility'; // Data import { queries } from '../../../utility/searchQueries.json'; -interface Props { - createNotification: (notification: NewNotification) => void; - updateConfig: (formData: SearchForm) => void; - loading: boolean; - customQueries: Query[]; -} +// Redux +import { State } from '../../../store/reducers'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../../store'; + +export const SearchSettings = (): JSX.Element => { + const { loading, customQueries, config } = useSelector( + (state: State) => state.config + ); + + const dispatch = useDispatch(); + const { updateConfig } = bindActionCreators(actionCreators, dispatch); -const SearchSettings = (props: Props): JSX.Element => { // Initial state - const [formData, setFormData] = useState({ - hideSearch: 0, - defaultSearchProvider: 'l', - searchSameTab: 0, - }); + const [formData, setFormData] = useState(searchSettingsTemplate); // Get config useEffect(() => { setFormData({ - hideSearch: searchConfig('hideSearch', 0), - defaultSearchProvider: searchConfig('defaultSearchProvider', 'l'), - searchSameTab: searchConfig('searchSameTab', 0), + ...config, }); - }, [props.loading]); + }, [loading]); // Form handler const formSubmitHandler = async (e: FormEvent) => { e.preventDefault(); // Save settings - await props.updateConfig(formData); + await updateConfig(formData); }; // Input handler const inputChangeHandler = ( e: ChangeEvent, - isNumber?: boolean + options?: { isNumber?: boolean; isBool?: boolean } ) => { - let value: string | number = e.target.value; - - if (isNumber) { - value = parseFloat(value); - } - - setFormData({ - ...formData, - [e.target.name]: value, + inputHandler({ + e, + options, + setStateHandler: setFormData, + state: formData, }); }; @@ -85,14 +70,14 @@ const SearchSettings = (props: Props): JSX.Element => { > - + + + + + + + + + @@ -138,17 +139,3 @@ const SearchSettings = (props: Props): JSX.Element => { ); }; - -const mapStateToProps = (state: GlobalState) => { - return { - loading: state.config.loading, - customQueries: state.config.customQueries, - }; -}; - -const actions = { - createNotification, - updateConfig, -}; - -export default connect(mapStateToProps, actions)(SearchSettings); diff --git a/client/src/components/Settings/Settings.tsx b/client/src/components/Settings/Settings.tsx index 5df8ec64..f9f51024 100644 --- a/client/src/components/Settings/Settings.tsx +++ b/client/src/components/Settings/Settings.tsx @@ -1,6 +1,9 @@ -// import { NavLink, Link, Switch, Route } from 'react-router-dom'; +// Redux +import { useSelector } from 'react-redux'; +import { State } from '../../store/reducers'; + // Typescript import { Route as SettingsRoute } from '../../interfaces'; @@ -8,28 +11,33 @@ import { Route as SettingsRoute } from '../../interfaces'; import classes from './Settings.module.css'; // Components -import Themer from '../Themer/Themer'; -import WeatherSettings from './WeatherSettings/WeatherSettings'; -import OtherSettings from './OtherSettings/OtherSettings'; -import AppDetails from './AppDetails/AppDetails'; -import StyleSettings from './StyleSettings/StyleSettings'; -import SearchSettings from './SearchSettings/SearchSettings'; +import { Themer } from './Themer/Themer'; +import { WeatherSettings } from './WeatherSettings/WeatherSettings'; +import { UISettings } from './UISettings/UISettings'; +import { AppDetails } from './AppDetails/AppDetails'; +import { StyleSettings } from './StyleSettings/StyleSettings'; +import { SearchSettings } from './SearchSettings/SearchSettings'; +import { DockerSettings } from './DockerSettings/DockerSettings'; +import { ProtectedRoute } from '../Routing/ProtectedRoute'; // UI -import { Container } from '../UI/Layout/Layout'; -import Headline from '../UI/Headlines/Headline/Headline'; +import { Container, Headline } from '../UI'; // Data import { routes } from './settings.json'; -const Settings = (): JSX.Element => { +export const Settings = (): JSX.Element => { + const { isAuthenticated } = useSelector((state: State) => state.auth); + + const tabs = isAuthenticated ? routes : routes.filter((r) => !r.authRequired); + return ( Go back} />
{/* NAVIGATION MENU */}