diff --git a/.dev/DEV_GUIDELINES.md b/.dev/DEV_GUIDELINES.md new file mode 100644 index 0000000..462a17f --- /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 0000000..983e282 --- /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 0000000..19786a8 --- /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 feb75e2..415e8b1 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 680ed26..951afd4 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 20ff6c2..42f5082 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 0000000..6aee3c4 --- /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 6c10c72..134be5d 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 1bb2edb..64e6517 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 0000000..13ccbc6 --- /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 39096dc..0000000 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 fe6999b..0000000 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 c24050f..0000000 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 0000000..17a1676 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 0000000..dcb63ba 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 0000000..5e9f578 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 0000000..73393b5 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/.gitignore b/.gitignore index 98ec862..147804b 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 2e1fa2d..98b42ea 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 54c68e1..e0766ae 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 884c1ef..851b071 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 - -- Backend - - Node.js + Express - - Sequelize ORM + SQLite -- Frontend - - React - - Redux - - TypeScript -- Deployment - - Docker - - Kubernetes - -## Development - -```sh -# clone repository -git clone https://github.com/pawelmalak/flame -cd flame - -# run only once -npm run dev-init - -# start backend and frontend development servers -npm run dev -``` +## 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 ## Installation ### With Docker (recommended) -[Docker Hub](https://hub.docker.com/r/pawelmalak/flame) - -#### Building images +[Docker Hub link](https://hub.docker.com/r/pawelmalak/flame) ```sh -# build image for amd64 only -docker build -t flame . +docker pull pawelmalak/flame -# 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 \ - -t flame:multiarch . +# for ARM architecture (e.g. RaspberryPi) +docker pull pawelmalak/flame:multiarch + +# installing specific version +docker pull pawelmalak/flame:2.0.0 ``` #### Deployment ```sh # run container -docker run -p 5005:5005 -v /path/to/data:/app/data flame +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 -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 .docker/Dockerfile.multiarch \ + -t flame:multiarch . ``` #### 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 -![Homescreen screenshot](./.github/_bookmarks.png) +```sh +# clone repository +git clone https://github.com/pawelmalak/flame +cd flame -- Weather +# run only once +npm run dev-init - - Get current temperature, cloud coverage and weather status with animated icons +# start backend and frontend development servers +npm run dev +``` -- Themes - - Customize your page by choosing from 12 color themes +## Screenshots -![Homescreen screenshot](./.github/_themes.png) +![Apps screenshot](.github/apps.png) + +![Bookmarks screenshot](.github/bookmarks.png) + +![Settings screenshot](.github/settings.png) + +![Themes screenshot](.github/themes.png) ## Usage +### Authentication + +Visit [project wiki](https://github.com/pawelmalak/flame/wiki/Authentication) to read more about authentication + ### Search bar #### Searching -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`. +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`. -> You can change where to open search results (same/new tab) in the settings - -#### Supported search engines - -| 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= | - -#### Supported services - -| 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 -### Custom CSS +### 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 and themes > This is an experimental feature. Its behaviour might change in the future. > diff --git a/api.js b/api.js index 9eb9b9f..840529a 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 6dbe18b..ea56cdf 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 2717839..72d0b7a 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 6e05667..096f723 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 0000000..301cd25 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 0000000..28ba56d 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 0000000..f13012b 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 0000000..e1a5b1d 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 0000000..33d6131 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 0000000..b07d1da 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 0000000..0ebf2c8 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 0000000..d636fe9 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 0000000..b07d1da 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 c93d95e..32e17fe 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 0000000..69028a9 --- /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 0000000..6d9460c --- /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 bfb4880..c202ad8 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 c8d66fe..de12f6a 100644 --- a/client/src/components/Apps/AppCard/AppCard.tsx +++ b/client/src/components/Apps/AppCard/AppCard.tsx @@ -1,64 +1,100 @@ +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}

-
- {props.apps.map((app: App) => { - const [displayUrl, redirectUrl] = urlParser(app.url); +

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

- 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 = ; +
+ {category.apps.map((app: App) => { + const redirectUrl = urlParser(app.url)[1]; + + 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}
-
-
{app.name}
- {displayUrl} -
+ target={config.appsSameTab ? '' : '_blank'} + rel="noreferrer" + key={`app-${app.id}`} + > + {app.icon && iconEl} + {app.name}
- ) + ); })}
- ) -} - -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 d67fe4c..0000000 --- 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 7874918..daff441 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 079d84d..9bef4e9 100644 --- a/client/src/components/Apps/AppGrid/AppGrid.tsx +++ b/client/src/components/Apps/AppGrid/AppGrid.tsx @@ -1,63 +1,62 @@ import { Link } from 'react-router-dom'; -import { App, Category } from '../../../interfaces'; -import AppCard from '../AppCard/AppCard'; +import { Category } from '../../../interfaces'; +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; + let apps: JSX.Element; - if (props.categories.length > 0) { - if (props.apps.length > 0) { + if (categories.length) { + 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.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) { 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 2d467cb..a3f37e5 100644 --- a/client/src/components/Apps/AppTable/AppTable.tsx +++ b/client/src/components/Apps/AppTable/AppTable.tsx @@ -1,100 +1,51 @@ -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, 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'; +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'; -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; +// Redux +// Typescript +// UI +interface Props { + openFormForUpdating: (data: Category | App) => void; } -const AppTable = (props: ComponentProps): JSX.Element => { - const [localCategories, setLocalCategories] = useState([]); - const [localApps, setLocalApps] = useState([]); - const [isCustomOrder, setIsCustomOrder] = useState(false); +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 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 (order === "orderId") { - setIsCustomOrder(true); + if (categoryInEdit) { + setLocalApps([...categoryInEdit.apps]); } - }, []); + }, [categoryInEdit]); - const deleteCategoryHandler = (category: Category): void => { - const proceed = window.confirm( - `Are you sure you want to delete ${category.name}? It will delete ALL assigned apps` - ); - - if (proceed) { - props.deleteAppCategory(category.id); - } - }; - - const deleteAppHandler = (app: App): void => { - const proceed = window.confirm( - `Are you sure you want to delete ${app.name} at ${app.url} ?` - ); - - if (proceed) { - props.deleteApp(app.id, app.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", + // Drag and drop handler + const dragEndHanlder = (result: DropResult): void => { + if (config.useOrdering !== 'orderId') { + createNotification({ + title: 'Error', + message: 'Custom order is disabled', }); return; } @@ -103,149 +54,76 @@ const AppTable = (props: ComponentProps): JSX.Element => { return; } - if (props.contentType === ContentType.app) { - const tmpApps = [...localApps]; - const [movedApp] = tmpApps.splice(result.source.index, 1); - tmpApps.splice(result.destination.index, 0, movedApp); + const tmpApps = [...localApps]; + const [movedApp] = tmpApps.splice(result.source.index, 1); + tmpApps.splice(result.destination.index, 0, movedApp); - 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); + setLocalApps(tmpApps); - setLocalCategories(tmpCategories); - props.reorderAppCategories(tmpCategories); + 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); } }; - - 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 ? ( - - ) : ( - - )} -
-
- )} -
-
-
+ 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 } ); - } 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 a9624e8..dd9ed8a 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'; +import { Form } from './Form/Form'; +import { Table } from './Table/Table'; -interface ComponentProps { - loading: boolean; - categories: Category[]; - getAppCategories: () => void; - apps: App[]; - getApps: () => void; +interface Props { searching: boolean; } @@ -28,131 +21,159 @@ 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 { getCategories, setEditCategory, setEditApp } = + bindActionCreators(actionCreators, dispatch); + + // Load categories if array is empty + useEffect(() => { + if (!categories.length) { + getCategories(); + } + }, []); + + // 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 (!isAuthenticated) { + setShowTable(false); + setModalIsOpen(false); + } + }, [isAuthenticated]); useEffect(() => { - if (apps.length === 0) { - getApps(); + if (categoryInEdit && !modalIsOpen) { + setTableContentType(ContentType.app); + setShowTable(true); } - }, [getApps]); + }, [categoryInEdit]); useEffect(() => { - if (categories.length === 0) { - getAppCategories(); - } - }, [getAppCategories]); + 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); + const openFormForUpdating = (data: Category | App): void => { + setIsInUpdate(true); + + const instanceOfCategory = (object: any): object is Category => { + return 'apps' in object; + }; + + if (instanceOfCategory(data)) { + setFormContentType(ContentType.category); + setEditCategory(data); } else { - setIsInEdit(true); + setFormContentType(ContentType.app); + 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 instanceOfCategory = (object: any): object is Category => { - return !("categoryId" in object); - }; - - const goToUpdateMode = (data: Category | App): void => { - setIsInUpdate(true); - if (instanceOfCategory(data)) { - setFormContentType(ContentType.category); - setCategoryInUpdate(data); - } else { - setFormContentType(ContentType.app); - setAppInUpdate(data); - } - toggleModal(); + const finishEditing = () => { + setShowTable(false); + setEditCategory(null); }; return ( - {!isInUpdate ? ( - - ) : ( - formContentType === ContentType.category ? ( - - ) : ( - - ) - )} +
- Go back)} - /> + Go back} /> -
- addActionHandler(ContentType.category)} /> - addActionHandler(ContentType.app)} /> - editActionHandler(ContentType.category)} /> - editActionHandler(ContentType.app)} /> -
+ {isAuthenticated && ( +
+ openFormForAdding(ContentType.category)} + /> + openFormForAdding(ContentType.app)} + /> + showTableForEditing(ContentType.category)} + /> + {showTable && tableContentType === 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 0000000..fe7447b --- /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 0000000..0124df3 --- /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 0000000..a67ac78 --- /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 0000000..a3f37e5 --- /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 0000000..2788c8f --- /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 0000000..7542c34 --- /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 b840a42..2fd52f0 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 8c4014c..796ffc2 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 f18f96b..0000000 --- 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 8c0d1ab..9e89f3a 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 22136b2..6ed99b9 100644 --- a/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.tsx +++ b/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.tsx @@ -1,56 +1,62 @@ import { Link } from 'react-router-dom'; -import { Bookmark, Category } from '../../../interfaces'; -import BookmarkCard from '../BookmarkCard/BookmarkCard'; +import { Category } from '../../../interfaces'; +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; + 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) { + if (searching && !categories[0].bookmarks.length) { + bookmarks = No bookmarks match your search criteria; } else { bookmarks = (
- {props.categories.map( + {categories.map( (category: Category): JSX.Element => ( - bookmark.categoryId === category.id)} /> + ) )}
); } } else { - if (props.totalCategories) { + if (totalCategories) { 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 8b1e0ed..0000000 --- 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 0fca8fb..0000000 --- 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 9838699..e2cc4dd 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'; +import { Form } from './Form/Form'; +import { Table } from './Table/Table'; -interface ComponentProps { - loading: boolean; - categories: Category[]; - getBookmarkCategories: () => void; - bookmarks: Bookmark[]; - getBookmarks: () => void; +interface Props { searching: boolean; } @@ -28,153 +21,159 @@ 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 { getCategories, setEditCategory, setEditBookmark } = + bindActionCreators(actionCreators, dispatch); + + // Load categories if array is empty + useEffect(() => { + if (!categories.length) { + getCategories(); + } + }, []); + + // 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 (!isAuthenticated) { + setShowTable(false); + setModalIsOpen(false); + } + }, [isAuthenticated]); useEffect(() => { - if (!bookmarks || bookmarks.length === 0) { - getBookmarks(); + if (categoryInEdit && !modalIsOpen) { + setTableContentType(ContentType.bookmark); + setShowTable(true); } - }, [getBookmarks]); + }, [categoryInEdit]); useEffect(() => { - if (categories.length === 0) { - getBookmarkCategories(); - } - }, [getBookmarkCategories]); + 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); + const openFormForUpdating = (data: Category | Bookmark): void => { + setIsInUpdate(true); + + const instanceOfCategory = (object: any): object is Category => { + return 'bookmarks' in object; + }; + + if (instanceOfCategory(data)) { + setFormContentType(ContentType.category); + setEditCategory(data); } else { - setIsInEdit(true); + setFormContentType(ContentType.bookmark); + 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 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); - } else { - setFormContentType(ContentType.bookmark); - setBookmarkInUpdate(data); - } - toggleModal(); + 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 0000000..893b334 --- /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 0000000..f980e49 --- /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 0000000..1d08ad9 --- /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 0000000..86f0db0 --- /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 0000000..d909cab --- /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 0000000..8704fdb --- /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 0000000..d7ee22b --- /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 0000000..84da280 --- /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 0000000..45ffc9d --- /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 0000000..93b32b4 --- /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 652ca22..f425184 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 4e3c478..73c8f4b 100644 --- a/client/src/components/Home/Home.tsx +++ b/client/src/components/Home/Home.tsx @@ -1,144 +1,121 @@ import { Fragment, 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, 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 { 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; + apps: { categories: appCategories, loading: appsLoading }, + bookmarks: { categories: bookmarkCategories, loading: bookmarksLoading }, + config: { config }, + auth: { isAuthenticated }, + } = useSelector((state: State) => state); - const [header, setHeader] = useState({ - dateTime: dateTime(), - greeting: greeter(), - }); + 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 useEffect(() => { - if (apps.length === 0) { - getApps(); + if (!appCategories.length && !bookmarkCategories.length) { + getCategories(); } - }, [getApps]); - - // Load bookmarks - useEffect(() => { - if (bookmarks.length === 0) { - getBookmarks(); - } - }, [getBookmarks]); - - // 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); - } - - return () => clearInterval(interval); }, []); - // 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)) - .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(), - }; + // Load bookmarks + // useEffect(() => { + // if (!bookmarkCategories.length) { + // getBookmarkCategories(); + // } + // }, []); - return [category]; - }; + useEffect(() => { + if (localSearch) { + // Search through apps + setAppSearchResult([ + ...appCategories.filter(({ name }) => + new RegExp(escapeRegex(localSearch), 'i').test(name) + ), + ]); - 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; + // Search through bookmarks + const appCategory = { ...bookmarkCategories[0] }; + + appCategory.name = 'Search Results'; + appCategory.apps = appCategories + .map(({ apps }) => apps) + .flat() + .filter(({ name }) => + new RegExp(escapeRegex(localSearch), 'i').test(name) + ); + + setBookmarkSearchResult([appCategory]); + + // Search through bookmarks + const bookmarkCategory = { ...bookmarkCategories[0] }; + + bookmarkCategory.name = 'Search Results'; + bookmarkCategory.bookmarks = bookmarkCategories + .map(({ bookmarks }) => bookmarks) + .flat() + .filter(({ name }) => + new RegExp(escapeRegex(localSearch), 'i').test(name) + ); + + 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,16 +123,9 @@ 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} @@ -164,35 +134,30 @@ const Home = (props: ComponentProps): JSX.Element => {
) : ( -
+ <> )} - {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 +166,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 44cc5e1..0000000 --- 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 64cb2ea..0000000 --- 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 733316b..4bda8bf 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 0000000..45b6504 --- /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 887a2ef..c25e685 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'; - -// Typescript -import { NewNotification } from '../../interfaces'; - -// CSS +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 +// 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 8f5fae3..6a7b939 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 109053a..1829a4d 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 0000000..d0f6466 --- /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 0000000..d950501 --- /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 a963676..0000000 --- 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 c5dac62..747be3b 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 42ad654..2cb76a9 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 b2ac422..9b057f5 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 5df8ec6..f9f5102 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 */}