diff --git a/.eslintrc.js b/.eslintrc.js index 2490d3526..bddf82eae 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -18,6 +18,7 @@ module.exports = { project: './tsconfig.json', }, rules: { + 'import/no-cycle': 'off', 'react/react-in-jsx-scope': 'off', 'react/no-children-prop': 'off', 'unused-imports/no-unused-imports': 'warn', @@ -28,5 +29,6 @@ module.exports = { '@typescript-eslint/no-shadow': 'off', '@typescript-eslint/no-use-before-define': 'off', '@typescript-eslint/no-non-null-assertion': 'off', + 'linebreak-style': 0, }, }; diff --git a/Dockerfile b/Dockerfile index d303b00ef..df42614d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,8 @@ COPY package.json ./package.json COPY .next/standalone ./ COPY .next/static ./.next/static +RUN apk add --update nodejs npm + EXPOSE 7575 ENV PORT 7575 diff --git a/data/configs/default.json b/data/configs/default.json index 3f2acea0d..509415425 100644 --- a/data/configs/default.json +++ b/data/configs/default.json @@ -1,20 +1,391 @@ { - "name": "default", - "services": [ + "schemaVersion": 1, + "configProperties": { + "name": "default" + }, + "categories": [ { - "name": "example", - "id": "09c45847-8afc-4c1a-9697-f03192de948a", - "type": "Other", - "icon": "https://c.tenor.com/o656qFKDzeUAAAAC/rick-astley-never-gonna-give-you-up.gif", - "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + "id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f", + "position": 0, + "name": "Welcome to Homarr 🎉", + "type": "category" + } + ], + "wrappers": [ + { + "id": "5823c4d6-6baf-4436-b990-93fe77e1dc62", + "position": 1 + }, + { + "id": "943f0681-a15b-4576-9a61-a74bd6fdd3ab", + "position": 3 + }, + { + "id": "default", + "position": 5 + } + ], + "apps": [ + { + "id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a337", + "name": "Discord", + "url": "https://discord.com/invite/aCsmEV5RgA", + "behaviour": { + "onClickUrl": "https://discord.com/invite/aCsmEV5RgA", + "isOpeningNewTab": true, + "externalUrl": "https://discord.com/invite/aCsmEV5RgA" + }, + "network": { + "enabledStatusChecker": false, + "okStatus": [ + 200 + ] + }, + "appearance": { + "iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/discord.png" + }, + "integration": { + "type": null, + "properties": [] + }, + "area": { + "type": "category", + "properties": { + "id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f" + } + }, + "shape": { + "md": { + "location": { + "x": 3, + "y": 1 + }, + "size": { + "width": 3, + "height": 1 + } + }, + "sm": { + "location": { + "x": 2, + "y": 1 + }, + "size": { + "width": 1, + "height": 1 + } + }, + "lg": { + "location": { + "x": 2, + "y": 1 + }, + "size": { + "width": 1, + "height": 1 + } + } + } + }, + { + "id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a330", + "name": "Contribute", + "url": "https://github.com/ajnart/homarr", + "behaviour": { + "onClickUrl": "https://github.com/ajnart/homarr", + "externalUrl": "https://github.com/ajnart/homarr", + "isOpeningNewTab": true + }, + "network": { + "enabledStatusChecker": false, + "okStatus": [] + }, + "appearance": { + "iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/github.png" + }, + "integration": { + "type": null, + "properties": [] + }, + "area": { + "type": "category", + "properties": { + "id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f" + } + }, + "shape": { + "md": { + "location": { + "x": 2, + "y": 0 + }, + "size": { + "width": 2, + "height": 1 + } + }, + "sm": { + "location": { + "x": 0, + "y": 2 + }, + "size": { + "width": 2, + "height": 1 + } + }, + "lg": { + "location": { + "x": 4, + "y": 0 + }, + "size": { + "width": 2, + "height": 2 + } + } + } + }, + { + "id": "5df743d9-5cb1-457c-85d2-64ff86855652", + "name": "Documentation", + "url": "https://homarr.dev", + "behaviour": { + "onClickUrl": "https://homarr.dev", + "externalUrl": "https://homarr.dev", + "isOpeningNewTab": true + }, + "network": { + "enabledStatusChecker": false, + "okStatus": [ + 200 + ] + }, + "appearance": { + "iconUrl": "/imgs/logo/logo.png" + }, + "integration": { + "type": null, + "properties": [] + }, + "area": { + "type": "category", + "properties": { + "id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f" + } + }, + "shape": { + "md": { + "location": { + "x": 0, + "y": 1 + }, + "size": { + "width": 2, + "height": 1 + } + }, + "sm": { + "location": { + "x": 0, + "y": 0 + }, + "size": { + "width": 1, + "height": 1 + } + }, + "lg": { + "location": { + "x": 0, + "y": 1 + }, + "size": { + "width": 2, + "height": 1 + } + } + } + }, + { + "id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a990", + "name": "Donate", + "url": "https://ko-fi.com/ajnart", + "behaviour": { + "onClickUrl": "https://ko-fi.com/ajnart", + "externalUrl": "https://ko-fi.com/ajnart", + "isOpeningNewTab": true + }, + "network": { + "enabledStatusChecker": false, + "okStatus": [ + 200 + ] + }, + "appearance": { + "iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/ko-fi.png" + }, + "integration": { + "type": null, + "properties": [] + }, + "area": { + "type": "category", + "properties": { + "id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f" + } + }, + "shape": { + "md": { + "location": { + "x": 2, + "y": 1 + }, + "size": { + "width": 1, + "height": 1 + } + }, + "sm": { + "location": { + "x": 2, + "y": 2 + }, + "size": { + "width": 1, + "height": 1 + } + }, + "lg": { + "location": { + "x": 3, + "y": 1 + }, + "size": { + "width": 1, + "height": 1 + } + } + } + } + ], + "widgets": [ + { + "id": "date", + "properties": { + "display24HourFormat": true + }, + "area": { + "type": "category", + "properties": { + "id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f" + } + }, + "shape": { + "sm": { + "location": { + "x": 0, + "y": 1 + }, + "size": { + "width": 2, + "height": 1 + } + }, + "md": { + "location": { + "x": 4, + "y": 0 + }, + "size": { + "width": 2, + "height": 1 + } + }, + "lg": { + "location": { + "x": 2, + "y": 0 + }, + "size": { + "width": 2, + "height": 1 + } + } + } + }, + { + "id": "weather", + "properties": { + "displayInFahrenheit": false, + "location": "Paris" + }, + "area": { + "type": "category", + "properties": { + "id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f" + } + }, + "shape": { + "md": { + "location": { + "x": 0, + "y": 0 + }, + "size": { + "width": 2, + "height": 1 + } + }, + "sm": { + "location": { + "x": 1, + "y": 0 + }, + "size": { + "width": 2, + "height": 1 + } + }, + "lg": { + "location": { + "x": 0, + "y": 0 + }, + "size": { + "width": 2, + "height": 1 + } + } + } } ], "settings": { - "searchUrl": "https://google.com/search?q=" - }, - "modules": { - "Search Bar": { - "enabled": true + "common": { + "searchEngine": { + "type": "google", + "properties": {} + } + }, + "customization": { + "layout": { + "enabledLeftSidebar": false, + "enabledRightSidebar": false, + "enabledDocker": false, + "enabledPing": false, + "enabledSearchbar": true + }, + "pageTitle": "Homarr v0.11 ⭐️", + "logoImageUrl": "/imgs/logo/logo.png", + "faviconUrl": "/imgs/logo/logo.png", + "backgroundImageUrl": "", + "customCss": "", + "colors": { + "primary": "red", + "secondary": "yellow", + "shade": 7 + }, + "appOpacity": 100 } } } \ No newline at end of file diff --git a/data/constants.ts b/data/constants.ts index 8ca22cd0b..2dfb47db2 100644 --- a/data/constants.ts +++ b/data/constants.ts @@ -1,2 +1,3 @@ export const REPO_URL = 'ajnart/homarr'; -export const CURRENT_VERSION = 'v0.10.7'; +export const CURRENT_VERSION = 'v0.11'; +export const ICON_PICKER_SLICE_LIMIT = 36; diff --git a/next.config.js b/next.config.js index b9989cfa8..dde3ec977 100644 --- a/next.config.js +++ b/next.config.js @@ -1,5 +1,3 @@ -const { env } = require('process'); - const { i18n } = require('./next-i18next.config'); const withBundleAnalyzer = require('@next/bundle-analyzer')({ @@ -10,7 +8,7 @@ module.exports = withBundleAnalyzer({ images: { domains: ['cdn.jsdelivr.net'], }, - reactStrictMode: false, + reactStrictMode: true, output: 'standalone', i18n, }); diff --git a/package.json b/package.json index 8f72efb94..2894dad75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homarr", - "version": "0.10.7", + "version": "0.11.0", "description": "Homarr - A homepage for your server.", "license": "MIT", "repository": { @@ -32,27 +32,27 @@ "@dnd-kit/utilities": "^3.2.0", "@emotion/react": "^11.10.5", "@emotion/server": "^11.10.0", - "@mantine/carousel": "^5.1.0", - "@mantine/core": "^5.7.2", - "@mantine/dates": "^5.7.2", - "@mantine/dropzone": "^5.7.2", - "@mantine/form": "^5.7.2", - "@mantine/hooks": "^5.7.2", - "@mantine/modals": "^5.7.2", - "@mantine/next": "^5.2.3", - "@mantine/notifications": "^5.7.2", - "@mantine/prism": "^5.0.0", + "@mantine/carousel": "^5.9.3", + "@mantine/core": "^5.9.3", + "@mantine/dates": "^5.9.3", + "@mantine/dropzone": "^5.9.3", + "@mantine/form": "^5.9.3", + "@mantine/hooks": "^5.9.3", + "@mantine/modals": "^5.9.3", + "@mantine/next": "^5.9.3", + "@mantine/notifications": "^5.9.3", + "@mantine/prism": "^5.9.3", "@nivo/core": "^0.79.0", "@nivo/line": "^0.79.1", - "@tabler/icons": "^1.78.0", + "@tabler/icons": "^1.106.0", "@tanstack/react-query": "^4.2.1", - "add": "^2.0.6", "axios": "^0.27.2", "consola": "^2.15.3", "cookies-next": "^2.1.1", "dayjs": "^1.11.6", "dockerode": "^3.3.2", "embla-carousel-react": "^7.0.0", + "fily-publish-gridstack": "^0.0.13", "framer-motion": "^6.5.1", "i18next": "^21.9.1", "i18next-browser-languagedetector": "^6.1.5", @@ -69,7 +69,8 @@ "sharp": "^0.30.7", "systeminformation": "^5.12.1", "uuid": "^8.3.2", - "yarn": "^1.22.19" + "yarn": "^1.22.19", + "zustand": "^4.1.4" }, "devDependencies": { "@next/bundle-analyzer": "^12.1.4", @@ -94,6 +95,7 @@ "eslint-plugin-unused-imports": "^2.0.0", "jest": "^28.1.3", "prettier": "^2.7.1", + "sass": "^1.56.1", "typescript": "^4.7.4" }, "resolutions": { diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 64232fcf5..9b3388f6f 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -1,6 +1,22 @@ { - "actions": { - "save": "Save" + "save": "Save", + "about": "About", + "cancel": "Cancel", + "close": "Close", + "delete": "Delete", + "ok": "OK", + "edit": "Edit", + "version": "Version", + "changePosition": "Change position", + "remove": "Remove", + "sections": { + "settings": "Settings", + "dangerZone": "Danger zone" + }, + "secrets": { + "apiKey": "Api key", + "username": "Username", + "password": "Password" }, "tip": "Tip: ", "time": { @@ -8,4 +24,4 @@ "minutes": "minutes", "hours": "hours" } -} +} \ No newline at end of file diff --git a/public/locales/en/layout/add-service-app-shelf.json b/public/locales/en/layout/add-service-app-shelf.json deleted file mode 100644 index ee5d2676e..000000000 --- a/public/locales/en/layout/add-service-app-shelf.json +++ /dev/null @@ -1,128 +0,0 @@ -{ - "actionIcon": { - "tooltip": "Add a service" - }, - "modal": { - "title": "Add service", - "form": { - "validation": { - "invalidUrl": "Please enter a valid URL", - "noStatusCodeSelected": "Please select a status code" - } - }, - "tabs": { - "options": { - "title": "Options", - "form": { - "serviceName": { - "label": "Service name", - "placeholder": "Plex" - }, - "iconUrl": { - "label": "Icon URL" - }, - "serviceUrl": { - "label": "Service URL" - }, - "onClickUrl": { - "label": "On Click URL" - }, - "serviceType": { - "label": "Service type", - "defaultValue": "Other", - "placeholder": "Pick one" - }, - "category": { - "label": "Category", - "placeholder": "Select a category or create a new one", - "nothingFound": "Nothing found", - "createLabel": "+ Create {{query}}" - }, - "integrations": { - "apiKey": { - "label": "API key", - "placeholder": "Your API key", - "validation": { - "noKey": "Invalid Key" - }, - "tip": { - "text": "Get your API key", - "link": "here." - } - }, - "qBittorrent": { - "username": { - "label": "Username", - "placeholder": "admin", - "validation": { - "invalidUsername": "Invalid username" - } - }, - "password": { - "label": "Password", - "placeholder": "adminadmin", - "validation": { - "invalidPassword": "Invalid password" - } - } - }, - "deluge": { - "password": { - "label": "Password", - "placeholder": "password", - "validation": { - "invalidPassword": "Invalid password" - } - } - }, - "transmission": { - "username": { - "label": "Username", - "placeholder": "admin", - "validation": { - "invalidUsername": "Invalid username" - } - }, - "password": { - "label": "Password", - "placeholder": "adminadmin", - "validation": { - "invalidPassword": "Invalid password" - } - } - }, - "nzbget": { - "username": { - "label": "Username", - "placeholder": "admin", - "validation": { - "invalidUsername": "Invalid username" - } - }, - "password": { - "label": "Password", - "placeholder": "password", - "validation": { - "invalidPassword": "Invalid password" - } - } - } - } - } - }, - "advancedOptions": { - "title": "Advanced options", - "form": { - "openServiceInNewTab": { - "label": "Open service in new tab" - }, - "buttons": { - "submit": { - "content": "Add service" - } - } - } - } - } - } -} \ No newline at end of file diff --git a/public/locales/en/layout/app-shelf-menu.json b/public/locales/en/layout/app-shelf-menu.json deleted file mode 100644 index 006e906c2..000000000 --- a/public/locales/en/layout/app-shelf-menu.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "modal": { - "title": "Modify a service", - "buttons": { - "save": "Save service" - } - }, - "menu": { - "labels": { - "settings": "Settings", - "dangerZone": "Danger zone" - }, - "actions": { - "edit": "Edit", - "delete": "Delete" - } - } -} \ No newline at end of file diff --git a/public/locales/en/layout/app-shelf.json b/public/locales/en/layout/app-shelf.json deleted file mode 100644 index 2074f4105..000000000 --- a/public/locales/en/layout/app-shelf.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "accordions": { - "downloads": { - "text": "Your downloads", - "torrents": "Your Torrent downloads", - "usenet": "Your Usenet downloads" - }, - "others": { - "text": "Others" - } - } -} \ No newline at end of file diff --git a/public/locales/en/layout/element-selector/selector.json b/public/locales/en/layout/element-selector/selector.json new file mode 100644 index 000000000..a1b509acd --- /dev/null +++ b/public/locales/en/layout/element-selector/selector.json @@ -0,0 +1,11 @@ +{ + "modal": { + "title": "Add a new tile", + "text": "Tiles are the main element of homarr. They allow you to configure the dashboard and display the information you want." + }, + "widgetDescription": "Widgets interact with your apps, to provide you with more control over your applications. They usually require a few configurations before use.", + "goBack": "Go back to the previous step", + "actionIcon": { + "tooltip": "Add a tile" + } +} \ No newline at end of file diff --git a/public/locales/en/layout/header/actions/toggle-edit-mode.json b/public/locales/en/layout/header/actions/toggle-edit-mode.json new file mode 100644 index 000000000..7fce83076 --- /dev/null +++ b/public/locales/en/layout/header/actions/toggle-edit-mode.json @@ -0,0 +1,11 @@ +{ + "description": "In edit mode, you can adjust the size and position of your tiles.", + "button": { + "disabled": "Enter Edit Mode", + "enabled": "Exit and Save" + }, + "popover": { + "title": "Edit mode is enabled", + "text": "You can adjust and configure your apps now. Changes are not saved until you exit edit mode" + } +} \ No newline at end of file diff --git a/public/locales/en/layout/mobile/drawer.json b/public/locales/en/layout/mobile/drawer.json new file mode 100644 index 000000000..ac34cee62 --- /dev/null +++ b/public/locales/en/layout/mobile/drawer.json @@ -0,0 +1,3 @@ +{ + "title": "{{position}} sidebar" +} \ No newline at end of file diff --git a/public/locales/en/layout/modals/about.json b/public/locales/en/layout/modals/about.json new file mode 100644 index 000000000..c2b8c37c3 --- /dev/null +++ b/public/locales/en/layout/modals/about.json @@ -0,0 +1,7 @@ +{ + "description": "Homarr is a simple and modern homepage for your server that helps you access all of your apps in one place. It integrates with the apps you use to display useful information or control them. It's easy to install and supports many different devices.", + "i18n": "Loaded I18n translation namespaces", + "locales": "Configured I18n locales", + "contact": "Having trouble or questions? Connect with us!", + "addToDashboard": "Add to Dashboard" +} \ No newline at end of file diff --git a/public/locales/en/layout/modals/add-app.json b/public/locales/en/layout/modals/add-app.json new file mode 100644 index 000000000..ff66e30df --- /dev/null +++ b/public/locales/en/layout/modals/add-app.json @@ -0,0 +1,65 @@ +{ + "tabs": { + "general": "General", + "behaviour": "Behaviour", + "network": "Network", + "appearance": "Appearance", + "integration": "Integration" + }, + "general": { + "appname": { + "label": "App name", + "description": "Used for displaying the app on the dashboard" + }, + "internalAddress": { + "label": "Internal address", + "description": "Internal IP of the app" + }, + "externalAddress": { + "label": "External address", + "description": "Url that will be opened in the browser when clicking on the app" + } + }, + "behaviour": { + "isOpeningNewTab": { + "label": "Open in new tab", + "description": "Open the link in a new tab" + } + }, + "network": { + "statusChecker": { + "label": "Status checker", + "description": "Sends a simple HTTP / HTTPS request to check if your app is online" + }, + "statusCodes": { + "label": "HTTP status codes", + "description": "Determines what response codes are allowed for this app to be 'Online'" + } + }, + "appearance": { + "icon": { + "label": "App Icon", + "description": "Logo of your app displayed in your dashboard. (Must return a body content containg an image)" + } + }, + "integration": { + "type": { + "label": "Integration configuration", + "description": "Treats this app as the selected integration and provides you with per-app configuration", + "placeholder": "Select an integration", + "defined": "Defined", + "undefined": "Undefined", + "public": "Public", + "private": "Private", + "explanationPublic": "A private secret will be sent to the server. Once your browser has refreshed the page, it will never be sent to the client.", + "explanationPrivate": "A public secret will always be sent to the client and is accessible over the API. It should not contain any confidential values such as usernames, passwords, tokens, certificates and similar" + }, + "secrets": { + "description": "To update a secret, enter a value and click the save button. To remove a secret, use the clear button.", + "warning": "Your credentials act as the access for your integrations and you should never share them with anybody else. The official Homarr team will never ask for credentials. Make sure to store and manage your secrets safely.", + "clear": "Clear secret", + "save": "Save secret", + "update": "Update secret" + } + } +} \ No newline at end of file diff --git a/public/locales/en/layout/modals/change-position.json b/public/locales/en/layout/modals/change-position.json new file mode 100644 index 000000000..464a676de --- /dev/null +++ b/public/locales/en/layout/modals/change-position.json @@ -0,0 +1,8 @@ +{ + "xPosition": "X axis position", + "width": "Width", + "height": "Height", + "yPosition": "Y axis position", + "zeroOrHigher": "0 or higher", + "betweenXandY": "Between {{min}} and {{max}}" +} \ No newline at end of file diff --git a/public/locales/en/layout/tools.json b/public/locales/en/layout/tools.json new file mode 100644 index 000000000..4224b621e --- /dev/null +++ b/public/locales/en/layout/tools.json @@ -0,0 +1,10 @@ +{ + "fallback": { + "title": "You currently do not have any tools" + }, + "iconPicker": { + "textInputPlaceholder": "Search for icons...", + "searchLimitationTitle": "Search is limited to {{max}} icons", + "searchLimitationMessage": "To keep things snappy and fast, the search is limited to {{max}} icons. Use the search box to find more icons" + } +} \ No newline at end of file diff --git a/public/locales/en/modules/common.json b/public/locales/en/modules/common.json index 3f4b36b03..39adbd37b 100644 --- a/public/locales/en/modules/common.json +++ b/public/locales/en/modules/common.json @@ -1,5 +1,10 @@ { "settings": { "label": "Settings" + }, + "errors": { + "unmappedOptions": { + "text": "Un-used parameter in configuration detected
{{key}}. Homarr is unable to interprete and use this parameter. To avoid any unexpected behavior, back up your configuration and correct your configuration." + } } } \ No newline at end of file diff --git a/public/locales/en/modules/dashdot.json b/public/locales/en/modules/dashdot.json index c44e04ca2..3b628ebb3 100644 --- a/public/locales/en/modules/dashdot.json +++ b/public/locales/en/modules/dashdot.json @@ -3,6 +3,7 @@ "name": "Dash.", "description": "A module for displaying the graphs of your running Dash. instance.", "settings": { + "title": "Settings for Dash. widget", "cpuMultiView": { "label": "CPU Multi-Core View" }, @@ -18,6 +19,10 @@ "url": { "label": "Dash. URL" } + }, + "remove": { + "title": "Remove Dash. widget", + "confirm": "Are you sure, that you want to remove the Dash. widget?" } }, "card": { @@ -50,4 +55,4 @@ } } } -} \ No newline at end of file +} diff --git a/public/locales/en/modules/date.json b/public/locales/en/modules/date.json index 521e220a4..e55f397ef 100644 --- a/public/locales/en/modules/date.json +++ b/public/locales/en/modules/date.json @@ -3,6 +3,7 @@ "name": "Date", "description": "Show the current time and date in a card", "settings": { + "title": "Settings for date integration", "display24HourFormat": { "label": "Display full time (24-hour)" } diff --git a/public/locales/en/modules/search.json b/public/locales/en/modules/search.json index cf3f50de0..8b3ed6302 100644 --- a/public/locales/en/modules/search.json +++ b/public/locales/en/modules/search.json @@ -26,5 +26,6 @@ } }, "tip": "You can select the search bar with the shortcut ", - "switchedSearchEngine": "Switched to searching with {{searchEngine}}" + "switchedSearchEngine": "Switched to searching with {{searchEngine}}", + "configurationName": "Search engine configuration" } \ No newline at end of file diff --git a/public/locales/en/modules/torrents-status.json b/public/locales/en/modules/torrents-status.json index 7e8970a92..83d75d6dd 100644 --- a/public/locales/en/modules/torrents-status.json +++ b/public/locales/en/modules/torrents-status.json @@ -1,10 +1,17 @@ { "descriptor": { - "name": "Torrent", - "description": "Show the current download speed of supported services", + "name": "BitTorrent", + "description": "Displays a list of the torrent which are currently downloading", "settings": { - "hideComplete": { - "label": "Hide completed torrents" + "title": "Settings for BitTorrent integration", + "refreshInterval": { + "label": "Refresh interval (in seconds)" + }, + "displayCompletedTorrents": { + "label": "Display completed torrents" + }, + "displayStaleTorrents": { + "label": "Display stale torrents" } } }, @@ -34,7 +41,14 @@ "noDownloadClients": { "title": "No supported download clients found!", "text": "Add a download service to view your current downloads" + }, + "generic": { + "title": "An unexpected error occured", + "text": "Homarr was unable to communicate with your download clients. Please check your configuration" } + }, + "loading": { + "title": "Loading..." } } } \ No newline at end of file diff --git a/public/locales/en/modules/weather.json b/public/locales/en/modules/weather.json index 405c36263..816b907b0 100644 --- a/public/locales/en/modules/weather.json +++ b/public/locales/en/modules/weather.json @@ -3,6 +3,7 @@ "name": "Weather", "description": "Look up the current weather in your location", "settings": { + "title": "Settings for weather integration", "displayInFahrenheit": { "label": "Display in Fahrenheit" }, @@ -29,4 +30,4 @@ "unknown": "Unknown" } } -} \ No newline at end of file +} diff --git a/public/locales/en/settings/common.json b/public/locales/en/settings/common.json index 4ff9b984e..1e4ed349a 100644 --- a/public/locales/en/settings/common.json +++ b/public/locales/en/settings/common.json @@ -11,5 +11,19 @@ "credits": { "madeWithLove": "Made with ❤️ by @" }, - "grow": "Grow grid (take all space)" + "grow": "Grow grid (take all space)", + "layout": { + "title": "Dashboard layout", + "main": "Main", + "sidebar": "Sidebar", + "cannotturnoff": "Cannot be turned off", + "dashboardlayout": "Dashboard layout", + "enablersidebar": "Enable right sidebar", + "enablelsidebar": "Enable left sidebar", + "enablesearchbar": "Enable search bar", + "enabledocker": "Enable docker integration", + "enableping": "Enable pings", + "enablelsidebardesc": "Optional. Can be used for apps and integrations only", + "enablersidebardesc": "Optional. Can be used for apps and integrations only" + } } \ No newline at end of file diff --git a/public/locales/en/settings/customization/page-appearance.json b/public/locales/en/settings/customization/page-appearance.json index 63fa96886..2a1e0424b 100644 --- a/public/locales/en/settings/customization/page-appearance.json +++ b/public/locales/en/settings/customization/page-appearance.json @@ -1,6 +1,10 @@ { "pageTitle": { "label": "Page Title", + "placeholder": "Homarr" + }, + "metaTitle": { + "label": "Meta Title", "placeholder": "Homarr 🦞" }, "logo": { diff --git a/public/locales/en/settings/general/config-changer.json b/public/locales/en/settings/general/config-changer.json index ad4ac012d..927547d02 100644 --- a/public/locales/en/settings/general/config-changer.json +++ b/public/locales/en/settings/general/config-changer.json @@ -1,12 +1,17 @@ { "configSelect": { - "label": "Config loader" + "label": "Config loader", + "loadingNew": "Loading your config...", + "pleaseWait": "Please wait until your new config is loaded" }, "modal": { "title": "Choose the name of your new config", "form": { "configName": { "label": "Config name", + "validation": { + "required": "Config name is required" + }, "placeholder": "Your new config name" }, "submitButton": "Confirm" @@ -15,6 +20,14 @@ "configSaved": { "title": "Config saved", "message": "Config saved as {{configName}}" + }, + "configCopied": { + "title": "Config copied", + "message": "Config copied as {{configName}}" + }, + "configNotCopied": { + "title": "Unable to copy config", + "message": "Your config was not copied as {{configName}}" } } }, @@ -46,9 +59,11 @@ } }, "accept": { + "title": "Configuration Upload", "text": "Drag files here to upload a config. Support for JSON only." }, "reject": { + "title": "Drag and Drop Upload rejected", "text": "This file format is not supported. Please only upload JSON." } } diff --git a/public/locales/en/settings/general/module-enabler.json b/public/locales/en/settings/general/module-enabler.json index 179753b6f..26c1dae88 100644 --- a/public/locales/en/settings/general/module-enabler.json +++ b/public/locales/en/settings/general/module-enabler.json @@ -1,3 +1,3 @@ { - "title": "Module enabler" + "title": "Enabled modules" } \ No newline at end of file diff --git a/public/locales/en/settings/general/search-engine.json b/public/locales/en/settings/general/search-engine.json index 8d419fcf8..fa9ae412f 100644 --- a/public/locales/en/settings/general/search-engine.json +++ b/public/locales/en/settings/general/search-engine.json @@ -5,10 +5,14 @@ "placeholderTip": "%s can be used as a placeholder for the query." }, "customEngine": { + "title": "Custom search engine", "label": "Query URL", "placeholder": "Custom query URL" }, "searchNewTab": { "label": "Open search results in new tab" + }, + "searchEnabled": { + "label": "Search enabled" } } \ No newline at end of file diff --git a/src/components/About/AboutModal.tsx b/src/components/About/AboutModal.tsx new file mode 100644 index 000000000..8d258ebd3 --- /dev/null +++ b/src/components/About/AboutModal.tsx @@ -0,0 +1,241 @@ +import { + ActionIcon, + Anchor, + Badge, + Button, + createStyles, + Divider, + Group, + HoverCard, + Modal, + Table, + Text, + Title, +} from '@mantine/core'; +import { + IconBrandDiscord, + IconBrandGithub, + IconFile, + IconLanguage, + IconSchema, + IconVersions, + IconVocabulary, + IconWorldWww, +} from '@tabler/icons'; +import { motion } from 'framer-motion'; +import { InitOptions } from 'i18next'; +import { i18n, Trans, useTranslation } from 'next-i18next'; +import Image from 'next/image'; +import { ReactNode } from 'react'; +import { CURRENT_VERSION } from '../../../data/constants'; +import { useConfigContext } from '../../config/provider'; +import { useConfigStore } from '../../config/store'; +import { usePrimaryGradient } from '../layout/useGradient'; +import Credits from '../Settings/Common/Credits'; + +interface AboutModalProps { + opened: boolean; + closeModal: () => void; + newVersionAvailable?: string; +} + +export const AboutModal = ({ opened, closeModal, newVersionAvailable }: AboutModalProps) => { + const { classes } = useStyles(); + const colorGradiant = usePrimaryGradient(); + const informations = useInformationTableItems(newVersionAvailable); + const { t } = useTranslation(['common', 'layout/modals/about']); + + return ( + closeModal()} + opened={opened} + title={ + + + + {t('about')} Homarr + + + } + size="xl" + > + + + + + + + {informations.map((item, index) => ( + + + + + ))} + +
+ + + {item.icon} + + {t(item.label)} + + {item.content}
+ + + {t('layout/modals/about:contact')} + + + + + + + + +
+ ); +}; + +interface InformationTableItem { + icon: ReactNode; + label: string; + content: ReactNode; +} + +interface ExtendedInitOptions extends InitOptions { + locales: string[]; +} + +const useInformationTableItems = (newVersionAvailable?: string): InformationTableItem[] => { + // TODO: Fix this to not request. Pass it as a prop. + const colorGradiant = usePrimaryGradient(); + + const { configVersion } = useConfigContext(); + const { configs } = useConfigStore(); + + let items: InformationTableItem[] = []; + + if (i18n !== null) { + const usedI18nNamespaces = i18n.reportNamespaces.getUsedNamespaces(); + const initOptions = i18n.options as ExtendedInitOptions; + + items = [ + ...items, + { + icon: , + label: 'layout/modals/about:i18n', + content: ( + + {usedI18nNamespaces.length} + + ), + }, + { + icon: , + label: 'layout/modals/about:locales', + content: ( + + {initOptions.locales.length} + + ), + }, + ]; + } + + items = [ + { + icon: , + label: 'Configuration schema version', + content: ( + + {configVersion} + + ), + }, + { + icon: , + label: 'Available configurations', + content: ( + + {configs.length} + + ), + }, + { + icon: , + label: 'version', + content: ( + + + {CURRENT_VERSION} + + {newVersionAvailable && ( + + + + + new: {newVersionAvailable} + + + + + Version{' '} + + + {newVersionAvailable} + + {' '} + is available ! Current version: {CURRENT_VERSION} + + + )} + + ), + }, + ...items, + ]; + + return items; +}; + +const useStyles = createStyles(() => ({ + informationTableColumn: { + textAlign: 'right', + }, + informationIcon: { + cursor: 'default', + }, +})); diff --git a/src/components/AppShelf/AddAppShelfItem.tsx b/src/components/AppShelf/AddAppShelfItem.tsx deleted file mode 100644 index 3b89d1c85..000000000 --- a/src/components/AppShelf/AddAppShelfItem.tsx +++ /dev/null @@ -1,460 +0,0 @@ -import { - ActionIcon, - Anchor, - Button, - Center, - Group, - Image, - LoadingOverlay, - Modal, - PasswordInput, - Select, - Space, - Stack, - Switch, - Tabs, - TextInput, - Title, - Tooltip, -} from '@mantine/core'; -import { useForm } from '@mantine/form'; -import { useDebouncedValue } from '@mantine/hooks'; -import { IconApps } from '@tabler/icons'; -import { useTranslation } from 'next-i18next'; -import { useEffect, useState } from 'react'; -import { v4 as uuidv4 } from 'uuid'; -import { useConfig } from '../../tools/state'; -import { tryMatchPort, ServiceTypeList, Config } from '../../tools/types'; -import apiKeyPaths from './apiKeyPaths.json'; -import Tip from '../layout/Tip'; - -export function AddItemShelfButton(props: any) { - const { config, setConfig } = useConfig(); - const [opened, setOpened] = useState(false); - const { t } = useTranslation('layout/add-service-app-shelf'); - return ( - <> - {t('modal.title')}} - opened={props.opened || opened} - onClose={() => setOpened(false)} - > - - - - setOpened(true)} - > - - - - - ); -} - -function MatchIcon(name: string | undefined, form: any) { - if (name === undefined || name === '') return null; - fetch( - `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${name - .replace(/\s+/g, '-') - .toLowerCase() - .replace(/^dash\.$/, 'dashdot')}.png` - ).then((res) => { - if (res.ok) { - form.setFieldValue('icon', res.url); - } - }); - - return false; -} - -function MatchService(name: string, form: any) { - const service = ServiceTypeList.find((s) => s.toLowerCase() === name.toLowerCase()); - if (service) { - form.setFieldValue('type', service); - } -} - -const DEFAULT_ICON = '/imgs/favicon/favicon.png'; - -interface AddAppShelfItemFormProps { - setOpened: (b: boolean) => void; - config: Config; - setConfig: (config: Config) => void; - // Any other props you want to pass to the form - [key: string]: any; -} - -export function AddAppShelfItemForm(props: AddAppShelfItemFormProps) { - const { setOpened, config, setConfig } = props; - // Only get config and setConfig from useCOnfig if they are not present in props - const [isLoading, setLoading] = useState(false); - const { t } = useTranslation('layout/add-service-app-shelf'); - - // Extract all the categories from the services in config - const InitialCategories = config.services.reduce((acc, cur) => { - if (cur.category && !acc.includes(cur.category)) { - acc.push(cur.category); - } - return acc; - }, [] as string[]); - const [categories, setCategories] = useState(InitialCategories); - - const form = useForm({ - initialValues: { - id: props.id ?? uuidv4(), - type: props.type ?? 'Other', - category: props.category ?? null, - name: props.name ?? '', - icon: props.icon ?? DEFAULT_ICON, - url: props.url ?? '', - apiKey: props.apiKey ?? undefined, - username: props.username ?? undefined, - password: props.password ?? undefined, - openedUrl: props.openedUrl ?? undefined, - ping: props.ping ?? true, - newTab: props.newTab ?? true, - }, - validate: { - apiKey: () => null, - // Validate icon with a regex - icon: (value: string) => - // Disable matching to allow any values - null, - // Validate url with a regex http/https - url: (value: string) => { - try { - const _isValid = new URL(value); - } catch (e) { - return t('modal.form.validation.invalidUrl'); - } - return null; - }, - }, - }); - - const [debounced, cancel] = useDebouncedValue(form.values.name, 250); - useEffect(() => { - if ( - form.values.name !== debounced || - form.values.icon !== DEFAULT_ICON || - form.values.type !== 'Other' - ) { - return; - } - MatchIcon(form.values.name, form); - MatchService(form.values.name, form); - tryMatchPort(form.values.name, form); - }, [debounced]); - - // Try to set const hostname to new URL(form.values.url).hostname) - // If it fails, set it to the form.values.url - let hostname = form.values.url; - try { - hostname = new URL(form.values.url).origin; - } catch (e) { - // Do nothing - } - - return ( - <> -
- Placeholder -
-
{ - const newForm = { ...form.values }; - if (newForm.newTab === true) newForm.newTab = undefined; - if (newForm.openedUrl === '') newForm.openedUrl = undefined; - if (newForm.category === null) newForm.category = undefined; - if (newForm.ping === true) newForm.ping = undefined; - // If service already exists, update it. - if (config.services && config.services.find((s) => s.id === newForm.id)) { - setConfig({ - ...config, - // replace the found item by matching ID - services: config.services.map((s) => { - if (s.id === newForm.id) { - return { - ...newForm, - }; - } - return s; - }), - }); - } else { - setConfig({ - ...config, - services: [...config.services, newForm], - }); - } - setOpened(false); - form.reset(); - })} - > - - - {t('modal.tabs.options.title')} - {t('modal.tabs.advancedOptions.title')} - - - - - - - - - { - const item = { value: query, label: query }; - setCategories([...InitialCategories, query]); - return item; - }} - getCreateLabel={(query) => - t('modal.tabs.options.form.category.createLabel', { - query, - }) - } - {...form.getInputProps('category')} - /> - - {(form.values.type === 'Sonarr' || - form.values.type === 'Radarr' || - form.values.type === 'Lidarr' || - form.values.type === 'Overseerr' || - form.values.type === 'Jellyseerr' || - form.values.type === 'Readarr' || - form.values.type === 'Sabnzbd') && ( - <> - { - form.setFieldValue('apiKey', event.currentTarget.value); - }} - error={ - form.errors.apiKey && - t('modal.tabs.options.form.integrations.apiKey.validation.noKey') - } - /> - - {t('modal.tabs.options.form.integrations.apiKey.tip.text')}{' '} - - {t('modal.tabs.options.form.integrations.apiKey.tip.link')} - - - - )} - {form.values.type === 'qBittorrent' && ( - <> - { - form.setFieldValue('username', event.currentTarget.value); - }} - error={ - form.errors.username && - t( - 'modal.tabs.options.form.integrations.qBittorrent.username.validation.invalidUsername' - ) - } - /> - { - form.setFieldValue('password', event.currentTarget.value); - }} - error={ - form.errors.password && - t( - 'modal.tabs.options.form.integrations.qBittorrent.password.validation.invalidPassword' - ) - } - /> - - )} - {form.values.type === 'Deluge' && ( - <> - { - form.setFieldValue('password', event.currentTarget.value); - }} - error={ - form.errors.password && - t( - 'modal.tabs.options.form.integrations.deluge.password.validation.invalidPassword' - ) - } - /> - - )} - {form.values.type === 'Transmission' && ( - <> - { - form.setFieldValue('username', event.currentTarget.value); - }} - error={ - form.errors.username && - t( - 'modal.tabs.options.form.integrations.transmission.username.validation.invalidUsername' - ) - } - /> - { - form.setFieldValue('password', event.currentTarget.value); - }} - error={ - form.errors.password && - t( - 'modal.tabs.options.form.integrations.transmission.password.validation.invalidPassword' - ) - } - /> - - )} - {form.values.type === 'NZBGet' && ( - <> - { - form.setFieldValue('username', event.currentTarget.value); - }} - error={ - form.errors.username && - t( - 'modal.tabs.options.form.integrations.nzbget.username.validation.invalidUsername' - ) - } - /> - { - form.setFieldValue('password', event.currentTarget.value); - }} - error={ - form.errors.password && - t( - 'modal.tabs.options.form.integrations.nzbget.password.validation.invalidPassword' - ) - } - /> - - )} - - - - - - - - - - - - - -
- - ); -} diff --git a/src/components/AppShelf/AppShelf.tsx b/src/components/AppShelf/AppShelf.tsx deleted file mode 100644 index ff2d118c8..000000000 --- a/src/components/AppShelf/AppShelf.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import React, { useState } from 'react'; -import { Accordion, Grid, Stack, Title, useMantineColorScheme } from '@mantine/core'; -import { - closestCenter, - DndContext, - DragOverlay, - MouseSensor, - TouchSensor, - useSensor, - useSensors, -} from '@dnd-kit/core'; - -import { arrayMove, SortableContext } from '@dnd-kit/sortable'; -import { useLocalStorage } from '@mantine/hooks'; -import { useTranslation } from 'next-i18next'; -import * as Modules from '../../modules'; -import { useConfig } from '../../tools/state'; - -import { AppShelfItem, SortableItem } from './AppShelfItem'; -import { ModuleWrapper } from '../../modules/moduleWrapper'; -import { UsenetModule, TorrentsModule } from '../../modules'; - -const AppShelf = (props: any) => { - const { config, setConfig } = useConfig(); - // Extract all the categories from the services in config - const categoryList = config.services.reduce((acc, cur) => { - if (cur.category && !acc.includes(cur.category)) { - acc.push(cur.category); - } - return acc; - }, [] as string[]); - - const [toggledCategories, setToggledCategories] = useLocalStorage({ - key: 'app-shelf-toggled', - // This is a bit of a hack to toggle the categories on the first load, return a string[] of the categories - defaultValue: categoryList, - }); - const [activeId, setActiveId] = useState(null); - const { colorScheme } = useMantineColorScheme(); - - const { t } = useTranslation('layout/app-shelf'); - - const sensors = useSensors( - useSensor(TouchSensor, { - activationConstraint: { - delay: 500, - tolerance: 5, - }, - }), - useSensor(MouseSensor, { - // Require the mouse to move by 10 pixels before activating - activationConstraint: { - delay: 500, - tolerance: 5, - }, - }) - ); - - function handleDragStart(event: any) { - const { active } = event; - - setActiveId(active.id); - } - - function handleDragEnd(event: any) { - const { active, over } = event; - - if (active.id !== over.id) { - const newConfig = { ...config }; - const activeIndex = newConfig.services.findIndex((e) => e.id === active.id); - const overIndex = newConfig.services.findIndex((e) => e.id === over.id); - newConfig.services = arrayMove(newConfig.services, activeIndex, overIndex); - setConfig(newConfig); - } - - setActiveId(null); - } - - const getItems = (filter?: string) => { - // If filter is not set, return all the services without a category or a null category - let filtered = config.services; - const modules = Object.values(Modules).map((module) => module); - - if (!filter) { - filtered = config.services.filter((e) => !e.category || e.category === null); - } - if (filter) { - filtered = config.services.filter((e) => e.category === filter); - } - return ( - - - - {filtered.map((service) => ( - - - - - - ))} - - - - {activeId ? ( - e.id === activeId)} id={activeId} /> - ) : null} - - - ); - }; - - return ( - - { - setToggledCategories([...state]); - }} - > - {categoryList.map((category, idx) => ( - - - - {category} - - - {getItems(category)} - - ))} - - {getItems()} - - - - ); -}; - -export default AppShelf; diff --git a/src/components/AppShelf/AppShelfItem.tsx b/src/components/AppShelf/AppShelfItem.tsx deleted file mode 100644 index bbc51122c..000000000 --- a/src/components/AppShelf/AppShelfItem.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { useSortable } from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; -import { - Anchor, - AspectRatio, - Card, - Center, - createStyles, - Image, - Text, - useMantineColorScheme, -} from '@mantine/core'; -import { motion } from 'framer-motion'; -import { useState } from 'react'; -import PingComponent from '../../modules/ping/PingModule'; -import { useConfig } from '../../tools/state'; -import { serviceItem } from '../../tools/types'; -import AppShelfMenu from './AppShelfMenu'; - -const useStyles = createStyles((theme) => ({ - item: { - transition: 'box-shadow 150ms ease, transform 100ms ease', - - '&:hover': { - boxShadow: `${theme.shadows.md} !important`, - transform: 'scale(1.05)', - }, - [theme.fn.smallerThan('sm')]: { - WebkitUserSelect: 'none', - }, - }, -})); - -export function SortableItem(props: any) { - const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ - id: props.id, - }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - }; - - return ( -
- {props.children} -
- ); -} - -export function AppShelfItem(props: any) { - const { service }: { service: serviceItem } = props; - const [hovering, setHovering] = useState(false); - const { config } = useConfig(); - const { colorScheme } = useMantineColorScheme(); - const { classes } = useStyles(); - return ( - { - setHovering(true); - }} - onHoverEnd={() => { - setHovering(false); - }} - > - - - - - {service.name} - - - - - - - -
- - - - - - - - {service.ping !== false && } -
-
-
-
- ); -} diff --git a/src/components/AppShelf/AppShelfMenu.tsx b/src/components/AppShelf/AppShelfMenu.tsx deleted file mode 100644 index 402867ac0..000000000 --- a/src/components/AppShelf/AppShelfMenu.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { ActionIcon, Menu, Modal, Text } from '@mantine/core'; -import { showNotification } from '@mantine/notifications'; -import { useState } from 'react'; -import { IconCheck as Check, IconEdit as Edit, IconMenu, IconTrash as Trash } from '@tabler/icons'; -import { useTranslation } from 'next-i18next'; -import { useConfig } from '../../tools/state'; -import { serviceItem } from '../../tools/types'; -import { AddAppShelfItemForm } from './AddAppShelfItem'; -import { useColorTheme } from '../../tools/color'; - -export default function AppShelfMenu(props: any) { - const { service }: { service: serviceItem } = props; - const { config, setConfig } = useConfig(); - const { secondaryColor } = useColorTheme(); - const { t } = useTranslation('layout/app-shelf-menu'); - const [opened, setOpened] = useState(false); - return ( - <> - setOpened(false)} - title={t('modal.title')} - > - - - - - - - - - - {t('menu.labels.settings')} - } onClick={() => setOpened(true)}> - {t('menu.actions.edit')} - - {t('menu.labels.dangerZone')} - { - setConfig({ - ...config, - services: config.services.filter((s) => s.id !== service.id), - }); - showNotification({ - autoClose: 5000, - title: ( - - Service {service.name} removed successfully! - - ), - color: 'green', - icon: , - message: undefined, - }); - }} - icon={} - > - {t('menu.actions.delete')} - - - - - ); -} diff --git a/src/components/AppShelf/SmallServiceItem.tsx b/src/components/AppShelf/SmallServiceItem.tsx deleted file mode 100644 index 98c8bbf53..000000000 --- a/src/components/AppShelf/SmallServiceItem.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Avatar, Group, Text } from '@mantine/core'; - -interface smallServiceItem { - label: string; - icon?: string; - url?: string; -} - -export default function SmallServiceItem(props: any) { - const { service }: { service: smallServiceItem } = props; - // TODO : Use Next/link - return ( - - {service.icon && } - {service.label} - - ); -} diff --git a/src/components/AppShelf/apiKeyPaths.json b/src/components/AppShelf/apiKeyPaths.json deleted file mode 100644 index 3ecd0f338..000000000 --- a/src/components/AppShelf/apiKeyPaths.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Jellyseerr": "settings", - "Overseerr": "settings", - "Sonarr": "settings/general", - "Radarr": "settings/general", - "Readarr": "settings/general", - "Lidarr": "settings/general", - "Sabnzbd": "sabnzbd/config/general" -} diff --git a/src/components/AppShelf/index.ts b/src/components/AppShelf/index.ts deleted file mode 100644 index fd496bd5b..000000000 --- a/src/components/AppShelf/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as AppShelf } from './AppShelf'; -export * from './AppShelfItem'; diff --git a/src/components/ColorSchemeToggle/ColorSchemeSwitch.tsx b/src/components/ColorSchemeToggle/ColorSchemeSwitch.tsx deleted file mode 100644 index a167d0b94..000000000 --- a/src/components/ColorSchemeToggle/ColorSchemeSwitch.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from 'react'; -import { - createStyles, - Switch, - Group, - useMantineColorScheme, - Kbd, - useMantineTheme, -} from '@mantine/core'; -import { IconMoonStars, IconSun } from '@tabler/icons'; -import { useTranslation } from 'next-i18next'; - -const useStyles = createStyles((theme) => ({ - root: { - position: 'relative', - '& *': { - cursor: 'pointer', - }, - }, - - icon: { - pointerEvents: 'none', - position: 'absolute', - zIndex: 1, - top: 3, - }, - - iconLight: { - left: 4, - color: theme.white, - }, - - iconDark: { - right: 4, - color: theme.colors.gray[6], - }, -})); - -export function ColorSchemeSwitch() { - const { colorScheme, toggleColorScheme } = useMantineColorScheme(); - const { t } = useTranslation('settings/general/theme-selector'); - const theme = useMantineTheme(); - return ( - - toggleColorScheme()} - size="md" - onLabel={} - offLabel={} - /> - {t('label', { - theme: colorScheme === 'dark' ? 'light' : 'dark', - })} - - Ctrl+J - - - ); -} diff --git a/src/components/ColorSchemeToggle/ColorSchemeToggle.tsx b/src/components/ColorSchemeToggle/ColorSchemeToggle.tsx deleted file mode 100644 index 3fbe701ee..000000000 --- a/src/components/ColorSchemeToggle/ColorSchemeToggle.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Box, useMantineColorScheme } from '@mantine/core'; -import { IconSun as Sun, IconMoonStars as MoonStars } from '@tabler/icons'; -import { motion } from 'framer-motion'; - -export function ColorSchemeToggle() { - const { colorScheme, toggleColorScheme } = useMantineColorScheme(); - - return ( - - toggleColorScheme()} - sx={(theme) => ({ - cursor: 'pointer', - color: theme.colorScheme === 'dark' ? theme.colors.yellow[4] : theme.colors.blue[6], - })} - > - {colorScheme === 'dark' ? : } - - - ); -} diff --git a/src/components/Config/ConfigChanger.tsx b/src/components/Config/ConfigChanger.tsx index 0e129bd2c..7d5c43c33 100644 --- a/src/components/Config/ConfigChanger.tsx +++ b/src/components/Config/ConfigChanger.tsx @@ -1,20 +1,38 @@ -import { Center, Loader, Select, Tooltip } from '@mantine/core'; +import { Center, Dialog, Loader, Notification, Select, Tooltip } from '@mantine/core'; +import { useToggle } from '@mantine/hooks'; +import { useQuery } from '@tanstack/react-query'; import { setCookie } from 'cookies-next'; import { useTranslation } from 'next-i18next'; -import { useEffect, useState } from 'react'; -import { useConfig } from '../../tools/state'; +import { useState } from 'react'; +import { useConfigContext } from '../../config/provider'; export default function ConfigChanger() { - const { config, loadConfig, setConfig, getConfigs } = useConfig(); - const [configList, setConfigList] = useState([]); - const [value, setValue] = useState(config.name); const { t } = useTranslation('settings/general/config-changer'); + const { name: configName } = useConfigContext(); + // const loadConfig = useConfigStore((x) => x.loadConfig); + + const { data: configs, isLoading, isError } = useConfigsQuery(); + const [activeConfig, setActiveConfig] = useState(configName); + const [isRefreshing, toggle] = useToggle(); + + const onConfigChange = (value: string) => { + // TODO: check what should happen here with @manuel-rw + // Wheter it should check for the current url and then load the new config only on index + // Or it should always load the selected config and open index or ? --> change url to page + setCookie('config-name', value ?? 'default', { + maxAge: 60 * 60 * 24 * 30, + sameSite: 'strict', + }); + setActiveConfig(value); + toggle(); + // Use timeout to wait for the cookie to be set + setTimeout(() => { + window.location.reload(); + }, 1000); + }; - useEffect(() => { - getConfigs().then((configs) => setConfigList(configs)); - }, [config]); // If configlist is empty, return a loading indicator - if (configList.length === 0) { + if (isLoading || !configs || configs?.length === 0 || !configName) { return (
@@ -23,23 +41,35 @@ export default function ConfigChanger() { ); } - // return { - loadConfig(e ?? 'default'); - setCookie('config-name', e ?? 'default', { - maxAge: 60 * 60 * 24 * 30, - sameSite: 'strict', - }); - }} - data={ - // If config list is empty, return the current config - configList.length === 0 ? [config.name] : configList - } - /> + <> + + + + + + item.label?.toLowerCase().includes(value.toLowerCase().trim()) || + item.description?.toLowerCase().includes(value.toLowerCase().trim()) + } + icon={ + form.values.integration?.type && ( + x.value === form.values.integration?.type)?.image} + alt="integration" + width={20} + height={20} + /> + ) + } + onChange={(value) => { + form.setFieldValue('integration.properties', getNewProperties(value)); + inputProps.onChange(value); + }} + withinPortal + {...inputProps} + /> + ); +}; + +interface ItemProps extends React.ComponentPropsWithoutRef<'div'> { + image: string; + description: string; + label: string; +} + +const SelectItemComponent = forwardRef( + ({ image, label, description, ...others }: ItemProps, ref) => ( +
+ + integration icon + +
+ {label} + {description && ( + + {description} + + )} +
+
+
+ ) +); diff --git a/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/IntegrationOptionsRenderer/IntegrationOptionsRenderer.tsx b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/IntegrationOptionsRenderer/IntegrationOptionsRenderer.tsx new file mode 100644 index 000000000..145dd3f00 --- /dev/null +++ b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/IntegrationOptionsRenderer/IntegrationOptionsRenderer.tsx @@ -0,0 +1,84 @@ +import { Stack } from '@mantine/core'; +import { UseFormReturnType } from '@mantine/form'; +import { IconKey } from '@tabler/icons'; +import { + IntegrationField, + integrationFieldDefinitions, + integrationFieldProperties, + AppIntegrationPropertyType, + AppType, +} from '../../../../../../../../types/app'; +import { GenericSecretInput } from '../InputElements/GenericSecretInput'; + +interface IntegrationOptionsRendererProps { + form: UseFormReturnType AppType>; +} + +export const IntegrationOptionsRenderer = ({ form }: IntegrationOptionsRendererProps) => { + const selectedIntegration = form.values.integration?.type; + + if (!selectedIntegration) return null; + + const displayedProperties = integrationFieldProperties[selectedIntegration]; + + return ( + + {displayedProperties.map((property, index) => { + const [_, definition] = Object.entries(integrationFieldDefinitions).find( + ([key]) => property === key + )!; + + let indexInFormValue = + form.values.integration?.properties.findIndex((p) => p.field === property) ?? -1; + if (indexInFormValue === -1) { + const { type } = Object.entries(integrationFieldDefinitions).find( + ([k, v]) => k === property + )![1]; + const newProperty: AppIntegrationPropertyType = { + type, + field: property as IntegrationField, + isDefined: false, + }; + form.insertListItem('integration.properties', newProperty); + indexInFormValue = form.values.integration!.properties.length; + } + const formValue = form.values.integration?.properties[indexInFormValue]; + + const isPresent = formValue?.isDefined; + const accessabilityType = formValue?.type; + + if (!definition) { + return ( + { + form.setFieldValue(`integration.properties.${index}.value`, value); + }} + key={`input-${property}`} + label={`${property} (potentionally unmapped)`} + secretIsPresent={isPresent} + setIcon={IconKey} + value={formValue.value} + type={accessabilityType} + {...form.getInputProps(`integration.properties.${index}.value`)} + /> + ); + } + + return ( + { + form.setFieldValue(`integration.properties.${index}.value`, value); + }} + key={`input-${definition.label}`} + label={definition.label} + value="" + secretIsPresent={isPresent} + setIcon={definition.icon} + type={accessabilityType} + {...form.getInputProps(`integration.properties.${index}.value`)} + /> + ); + })} + + ); +}; diff --git a/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/IntegrationTab.tsx b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/IntegrationTab.tsx new file mode 100644 index 000000000..c57c69ced --- /dev/null +++ b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/IntegrationTab.tsx @@ -0,0 +1,37 @@ +import { Alert, Divider, Tabs, Text } from '@mantine/core'; +import { UseFormReturnType } from '@mantine/form'; +import { IconAlertTriangle } from '@tabler/icons'; +import { Trans, useTranslation } from 'next-i18next'; +import { AppType } from '../../../../../../types/app'; +import { IntegrationSelector } from './Components/InputElements/IntegrationSelector'; +import { IntegrationOptionsRenderer } from './Components/IntegrationOptionsRenderer/IntegrationOptionsRenderer'; + +interface IntegrationTabProps { + form: UseFormReturnType AppType>; +} + +export const IntegrationTab = ({ form }: IntegrationTabProps) => { + const { t } = useTranslation('layout/modals/add-app'); + const hasIntegrationSelected = form.values.integration?.type; + + return ( + + + + {hasIntegrationSelected && ( + <> + + + {t('integration.secrets.description')} + + + } color="yellow"> + + + + + + )} + + ); +}; diff --git a/src/components/Dashboard/Modals/EditAppModal/Tabs/NetworkTab/NetworkTab.tsx b/src/components/Dashboard/Modals/EditAppModal/Tabs/NetworkTab/NetworkTab.tsx new file mode 100644 index 000000000..7845c77d7 --- /dev/null +++ b/src/components/Dashboard/Modals/EditAppModal/Tabs/NetworkTab/NetworkTab.tsx @@ -0,0 +1,37 @@ +import { Tabs, Switch, MultiSelect } from '@mantine/core'; +import { UseFormReturnType } from '@mantine/form'; +import { useTranslation } from 'next-i18next'; +import { StatusCodes } from '../../../../../../tools/acceptableStatusCodes'; +import { AppType } from '../../../../../../types/app'; + +interface NetworkTabProps { + form: UseFormReturnType AppType>; +} + +export const NetworkTab = ({ form }: NetworkTabProps) => { + const { t } = useTranslation('layout/modals/add-app'); + return ( + + + {form.values.network.enabledStatusChecker && ( + + )} + + ); +}; diff --git a/src/components/Dashboard/Modals/EditAppModal/Tabs/Shared/DebouncedAppIcon.tsx b/src/components/Dashboard/Modals/EditAppModal/Tabs/Shared/DebouncedAppIcon.tsx new file mode 100644 index 000000000..90e575fa1 --- /dev/null +++ b/src/components/Dashboard/Modals/EditAppModal/Tabs/Shared/DebouncedAppIcon.tsx @@ -0,0 +1,59 @@ +// disabled due to too many dynamic targets for next image cache +/* eslint-disable @next/next/no-img-element */ +import Image from 'next/image'; +import { createStyles, Loader } from '@mantine/core'; +import { UseFormReturnType } from '@mantine/form'; +import { useDebouncedValue } from '@mantine/hooks'; +import { AppType } from '../../../../../../types/app'; + +interface DebouncedAppIconProps { + width: number; + height: number; + form: UseFormReturnType AppType>; + debouncedWaitPeriod?: number; +} + +export const DebouncedAppIcon = ({ + form, + width, + height, + debouncedWaitPeriod = 1000, +}: DebouncedAppIconProps) => { + const { classes } = useStyles(); + const [debouncedIconImageUrl] = useDebouncedValue( + form.values.appearance.iconUrl, + debouncedWaitPeriod + ); + + if (debouncedIconImageUrl !== form.values.appearance.iconUrl) { + return ; + } + + if (debouncedIconImageUrl.length > 0) { + return ( + + ); + } + + return ( + + ); +}; + +const useStyles = createStyles(() => ({ + iconImage: { + objectFit: 'contain', + }, +})); diff --git a/src/components/Dashboard/Modals/EditAppModal/Tabs/type.ts b/src/components/Dashboard/Modals/EditAppModal/Tabs/type.ts new file mode 100644 index 000000000..a67947134 --- /dev/null +++ b/src/components/Dashboard/Modals/EditAppModal/Tabs/type.ts @@ -0,0 +1 @@ +export type EditAppModalTab = 'general' | 'behaviour' | 'network' | 'appereance' | 'integration'; diff --git a/src/components/Dashboard/Modals/SelectElement/Components/Overview/AvailableElementsOverview.tsx b/src/components/Dashboard/Modals/SelectElement/Components/Overview/AvailableElementsOverview.tsx new file mode 100644 index 000000000..97c62de4c --- /dev/null +++ b/src/components/Dashboard/Modals/SelectElement/Components/Overview/AvailableElementsOverview.tsx @@ -0,0 +1,172 @@ +import { Group, Space, Stack, Text, UnstyledButton } from '@mantine/core'; +import { closeModal } from '@mantine/modals'; +import { showNotification } from '@mantine/notifications'; +import { IconBox, IconBoxAlignTop, IconStack } from '@tabler/icons'; +import { motion } from 'framer-motion'; +import { useTranslation } from 'next-i18next'; +import { ReactNode } from 'react'; +import { v4 as uuidv4 } from 'uuid'; +import { useConfigContext } from '../../../../../../config/provider'; +import { useConfigStore } from '../../../../../../config/store'; +import { openContextModalGeneric } from '../../../../../../tools/mantineModalManagerExtensions'; +import { AppType } from '../../../../../../types/app'; +import { CategoryEditModalInnerProps } from '../../../../Wrappers/Category/CategoryEditModal'; +import { useStyles } from '../Shared/styles'; + +interface AvailableElementTypesProps { + modalId: string; + onOpenIntegrations: () => void; + onOpenStaticElements: () => void; +} + +export const AvailableElementTypes = ({ + modalId, + onOpenIntegrations: onOpenWidgets, + onOpenStaticElements, +}: AvailableElementTypesProps) => { + const { t } = useTranslation('layout/element-selector/selector'); + const { config, name: configName } = useConfigContext(); + const { updateConfig } = useConfigStore(); + const getLowestWrapper = () => config?.wrappers.sort((a, b) => a.position - b.position)[0]; + + const onClickCreateCategory = async () => { + openContextModalGeneric({ + modal: 'categoryEditModal', + title: 'Name of new category', + withCloseButton: false, + innerProps: { + category: { + id: uuidv4(), + name: 'New category', + position: 0, + }, + onSuccess: async (category) => { + if (!configName) return; + + await updateConfig(configName, (previousConfig) => ({ + ...previousConfig, + wrappers: + previousConfig.wrappers.length <= previousConfig.categories.length + ? [ + ...previousConfig.wrappers, + { + id: uuidv4(), + position: previousConfig.categories.length, + }, + ] + : previousConfig.wrappers, + categories: [ + ...previousConfig.categories, + { + id: uuidv4(), + name: category.name, + position: previousConfig.categories.length, + }, + ], + })).then(() => { + closeModal(modalId); + showNotification({ + title: 'Category created', + message: `The category ${category.name} has been created`, + color: 'teal', + }); + }); + }, + }, + }); + }; + + return ( + <> + {t('modal.text')} + + + } + onClick={() => { + openContextModalGeneric<{ app: AppType; allowAppNamePropagation: boolean }>({ + modal: 'editApp', + innerProps: { + app: { + id: uuidv4(), + name: 'Your app', + url: 'https://homarr.dev', + appearance: { + iconUrl: '/imgs/logo/logo.png', + }, + network: { + enabledStatusChecker: true, + okStatus: [200], + }, + behaviour: { + isOpeningNewTab: true, + externalUrl: '', + }, + + area: { + type: 'wrapper', + properties: { + id: getLowestWrapper()?.id ?? 'default', + }, + }, + shape: {}, + integration: { + type: null, + properties: [], + }, + }, + allowAppNamePropagation: true, + }, + size: 'xl', + }); + }} + /> + } + onClick={onOpenWidgets} + /> + } + onClick={onClickCreateCategory} + /> + {/*} + onClick={onOpenStaticElements} + />*/} + + + ); +}; + +interface ElementItemProps { + icon: ReactNode; + name: string; + onClick: () => void; +} + +const ElementItem = ({ name, icon, onClick }: ElementItemProps) => { + const { classes, cx } = useStyles(); + return ( + + + + {icon} + + + {name} + + + + ); +}; diff --git a/src/components/Dashboard/Modals/SelectElement/Components/Shared/GenericElementType.tsx b/src/components/Dashboard/Modals/SelectElement/Components/Shared/GenericElementType.tsx new file mode 100644 index 000000000..07938aba2 --- /dev/null +++ b/src/components/Dashboard/Modals/SelectElement/Components/Shared/GenericElementType.tsx @@ -0,0 +1,61 @@ +import { Button, Card, Center, Grid, Stack, Text } from '@mantine/core'; +import { TablerIcon } from '@tabler/icons'; +import { useTranslation } from 'next-i18next'; +import Image from 'next/image'; +import React from 'react'; +import { useStyles } from './styles'; + +interface GenericAvailableElementTypeProps { + name: string; + handleAddition: () => Promise; + description?: string; + image: string | TablerIcon; + disabled?: boolean; +} + +export const GenericAvailableElementType = ({ + name, + description, + image, + disabled, + handleAddition, +}: GenericAvailableElementTypeProps) => { + const { classes } = useStyles(); + const { t } = useTranslation('layout/modals/about'); + + const Icon = + typeof image === 'string' ? () => : image; + + return ( + + + + +
+ +
+ + {name} + + {description && ( + + {description} + + )} +
+ +
+
+
+ ); +}; diff --git a/src/components/Dashboard/Modals/SelectElement/Components/Shared/SelectorBackArrow.tsx b/src/components/Dashboard/Modals/SelectElement/Components/Shared/SelectorBackArrow.tsx new file mode 100644 index 000000000..575820366 --- /dev/null +++ b/src/components/Dashboard/Modals/SelectElement/Components/Shared/SelectorBackArrow.tsx @@ -0,0 +1,23 @@ +import { Button, Text } from '@mantine/core'; +import { IconArrowNarrowLeft } from '@tabler/icons'; +import { useTranslation } from 'next-i18next'; + +interface SelectorBackArrowProps { + onClickBack: () => void; +} + +export function SelectorBackArrow({ onClickBack }: SelectorBackArrowProps) { + const { t } = useTranslation('layout/element-selector/selector'); + return ( + + ); +} diff --git a/src/components/Dashboard/Modals/SelectElement/Components/Shared/styles.tsx b/src/components/Dashboard/Modals/SelectElement/Components/Shared/styles.tsx new file mode 100644 index 000000000..41a8d263e --- /dev/null +++ b/src/components/Dashboard/Modals/SelectElement/Components/Shared/styles.tsx @@ -0,0 +1,28 @@ +import { createStyles } from '@mantine/core'; + +export const useStyles = createStyles((theme) => ({ + styledButton: { + backgroundColor: theme.colorScheme === 'dark' ? theme.colors.gray[9] : theme.colors.gray[2], + color: theme.colorScheme === 'dark' ? theme.colors.gray[0] : theme.colors.dark[9], + '&:hover': { + backgroundColor: theme.colorScheme === 'dark' ? theme.colors.gray[8] : theme.colors.gray[3], + }, + }, + elementButton: { + width: '100%', + height: '100%', + borderRadius: theme.radius.sm, + }, + elementStack: { + width: '100%', + }, + elementName: { + whiteSpace: 'normal', + textAlign: 'center', + lineHeight: 1.2, + }, + elementText: { + lineHeight: 1.2, + whiteSpace: 'normal', + }, +})); diff --git a/src/components/Dashboard/Modals/SelectElement/Components/StaticElementsTab/AvailableStaticElementsTab.tsx b/src/components/Dashboard/Modals/SelectElement/Components/StaticElementsTab/AvailableStaticElementsTab.tsx new file mode 100644 index 000000000..ef0da1757 --- /dev/null +++ b/src/components/Dashboard/Modals/SelectElement/Components/StaticElementsTab/AvailableStaticElementsTab.tsx @@ -0,0 +1,32 @@ +import { Grid, Text } from '@mantine/core'; +import { IconCursorText } from '@tabler/icons'; +import { useTranslation } from 'next-i18next'; +import { GenericAvailableElementType } from '../Shared/GenericElementType'; +import { SelectorBackArrow } from '../Shared/SelectorBackArrow'; + +interface AvailableStaticTypesProps { + onClickBack: () => void; +} + +export const AvailableStaticTypes = ({ onClickBack }: AvailableStaticTypesProps) => { + const { t } = useTranslation('layout/element-selector/selector'); + return ( + <> + + + + Static elements provide you additional control over your dashboard. They are static, because + they don't integrate with any apps and their content never changes. + + + + {}} + /> + + + ); +}; diff --git a/src/components/Dashboard/Modals/SelectElement/Components/WidgetsTab/AvailableWidgetsTab.tsx b/src/components/Dashboard/Modals/SelectElement/Components/WidgetsTab/AvailableWidgetsTab.tsx new file mode 100644 index 000000000..c6192ab58 --- /dev/null +++ b/src/components/Dashboard/Modals/SelectElement/Components/WidgetsTab/AvailableWidgetsTab.tsx @@ -0,0 +1,34 @@ +import { Grid, Text } from '@mantine/core'; +import { useTranslation } from 'next-i18next'; +import { useConfigContext } from '../../../../../../config/provider'; +import widgets from '../../../../../../widgets'; +import { SelectorBackArrow } from '../Shared/SelectorBackArrow'; +import { WidgetElementType } from './WidgetElementType'; + +interface AvailableIntegrationElementsProps { + onClickBack: () => void; +} + +export const AvailableIntegrationElements = ({ + onClickBack, +}: AvailableIntegrationElementsProps) => { + const { t } = useTranslation('layout/element-selector/selector'); + const activeWidgets = useConfigContext().config?.widgets ?? []; + return ( + <> + + + + {t('widgetDescription')} + + + + {Object.entries(widgets) + .filter(([widgetId]) => !activeWidgets.some((aw) => aw.id === widgetId)) + .map(([k, v]) => ( + + ))} + + + ); +}; diff --git a/src/components/Dashboard/Modals/SelectElement/Components/WidgetsTab/WidgetElementType.tsx b/src/components/Dashboard/Modals/SelectElement/Components/WidgetsTab/WidgetElementType.tsx new file mode 100644 index 000000000..ce3a2b708 --- /dev/null +++ b/src/components/Dashboard/Modals/SelectElement/Components/WidgetsTab/WidgetElementType.tsx @@ -0,0 +1,105 @@ +import { useModals } from '@mantine/modals'; +import { showNotification } from '@mantine/notifications'; +import { IconChecks, TablerIcon } from '@tabler/icons'; +import { useTranslation } from 'next-i18next'; +import { useConfigContext } from '../../../../../../config/provider'; +import { useConfigStore } from '../../../../../../config/store'; +import { IWidget, IWidgetDefinition } from '../../../../../../widgets/widgets'; +import { useEditModeStore } from '../../../../Views/useEditModeStore'; +import { GenericAvailableElementType } from '../Shared/GenericElementType'; + +interface WidgetElementTypeProps { + id: string; + image: string | TablerIcon; + disabled?: boolean; + widget: IWidgetDefinition; +} + +export const WidgetElementType = ({ id, image, disabled, widget }: WidgetElementTypeProps) => { + const { closeModal } = useModals(); + const { t } = useTranslation(`modules/${id}`); + const { name: configName, config } = useConfigContext(); + const updateConfig = useConfigStore((x) => x.updateConfig); + const isEditMode = useEditModeStore((x) => x.enabled); + + if (!configName) return null; + + const getLowestWrapper = () => config?.wrappers.sort((a, b) => a.position - b.position)[0]; + + const handleAddition = async () => { + updateConfig( + configName, + (prev) => ({ + ...prev, + widgets: [ + ...prev.widgets.filter((w) => w.id !== widget.id), + { + id: widget.id, + properties: Object.entries(widget.options).reduce((prev, [k, v]) => { + const newPrev = prev; + newPrev[k] = v.defaultValue; + return newPrev; + }, {} as IWidget['properties']), + area: { + type: 'wrapper', + properties: { + id: getLowestWrapper()?.id ?? '', + }, + }, + shape: { + sm: { + location: { + x: 0, + y: 0, + }, + size: { + width: widget.gridstack.minWidth, + height: widget.gridstack.minHeight, + }, + }, + md: { + location: { + x: 0, + y: 0, + }, + size: { + width: widget.gridstack.minWidth, + height: widget.gridstack.minHeight, + }, + }, + lg: { + location: { + x: 0, + y: 0, + }, + size: { + width: widget.gridstack.minWidth, + height: widget.gridstack.minHeight, + }, + }, + }, + }, + ], + }), + true, + !isEditMode + ); + closeModal('selectElement'); + showNotification({ + title: t('descriptor.name'), + message: t('descriptor.description'), + icon: , + color: 'teal', + }); + }; + + return ( + + ); +}; diff --git a/src/components/Dashboard/Modals/SelectElement/SelectElementModal.tsx b/src/components/Dashboard/Modals/SelectElement/SelectElementModal.tsx new file mode 100644 index 000000000..0f288c1ce --- /dev/null +++ b/src/components/Dashboard/Modals/SelectElement/SelectElementModal.tsx @@ -0,0 +1,28 @@ +import { ContextModalProps } from '@mantine/modals'; +import { useState } from 'react'; +import { AvailableIntegrationElements } from './Components/WidgetsTab/AvailableWidgetsTab'; +import { AvailableElementTypes } from './Components/Overview/AvailableElementsOverview'; +import { AvailableStaticTypes } from './Components/StaticElementsTab/AvailableStaticElementsTab'; + +export const SelectElementModal = ({ context, id }: ContextModalProps) => { + const [activeTab, setActiveTab] = useState(); + + switch (activeTab) { + case undefined: + return ( + setActiveTab('integrations')} + onOpenStaticElements={() => setActiveTab('static_elements')} + /> + ); + case 'integrations': + return setActiveTab(undefined)} />; + case 'static_elements': + return setActiveTab(undefined)} />; + default: + /* default to the main selection tab */ + setActiveTab(undefined); + return <>; + } +}; diff --git a/src/components/Dashboard/Tiles/Apps/AppIcon.tsx b/src/components/Dashboard/Tiles/Apps/AppIcon.tsx new file mode 100644 index 000000000..9775b9849 --- /dev/null +++ b/src/components/Dashboard/Tiles/Apps/AppIcon.tsx @@ -0,0 +1,5 @@ +interface ServiceIconProps { + size: '100%' | number; +} + +export const AppIcon = ({ size }: ServiceIconProps) => null; diff --git a/src/components/Dashboard/Tiles/Apps/AppMenu.tsx b/src/components/Dashboard/Tiles/Apps/AppMenu.tsx new file mode 100644 index 000000000..fea3a6e2c --- /dev/null +++ b/src/components/Dashboard/Tiles/Apps/AppMenu.tsx @@ -0,0 +1,64 @@ +import { useConfigContext } from '../../../../config/provider'; +import { useConfigStore } from '../../../../config/store'; +import { openContextModalGeneric } from '../../../../tools/mantineModalManagerExtensions'; +import { AppType } from '../../../../types/app'; +import { GenericTileMenu } from '../GenericTileMenu'; + +interface TileMenuProps { + app: AppType; +} + +export const AppMenu = ({ app }: TileMenuProps) => { + const { config, name: configName } = useConfigContext(); + const { updateConfig } = useConfigStore(); + + const handleClickEdit = () => { + openContextModalGeneric<{ app: AppType; allowAppNamePropagation: boolean }>({ + modal: 'editApp', + size: 'xl', + innerProps: { + app, + allowAppNamePropagation: false, + }, + styles: { + root: { + zIndex: 201, + }, + }, + }); + }; + + const handleClickChangePosition = () => { + openContextModalGeneric({ + modal: 'changeAppPositionModal', + innerProps: { + app, + }, + styles: { + root: { + zIndex: 201, + }, + }, + }); + }; + + const handleClickDelete = () => { + if (configName === undefined) { + return; + } + + updateConfig(configName, (previousConfig) => ({ + ...previousConfig, + apps: previousConfig.apps.filter((a) => a.id !== app.id), + })); + }; + + return ( + + ); +}; diff --git a/src/components/Dashboard/Tiles/Apps/AppPing.tsx b/src/components/Dashboard/Tiles/Apps/AppPing.tsx new file mode 100644 index 000000000..3f2a8c925 --- /dev/null +++ b/src/components/Dashboard/Tiles/Apps/AppPing.tsx @@ -0,0 +1,64 @@ +import { Indicator, Tooltip } from '@mantine/core'; +import { useQuery } from '@tanstack/react-query'; +import { motion } from 'framer-motion'; +import { useTranslation } from 'next-i18next'; +import { useConfigContext } from '../../../../config/provider'; +import { AppType } from '../../../../types/app'; + +interface AppPingProps { + app: AppType; +} + +export const AppPing = ({ app }: AppPingProps) => { + const { t } = useTranslation('modules/ping'); + const { config } = useConfigContext(); + const active = + (config?.settings.customization.layout.enabledPing && app.network.enabledStatusChecker) ?? + false; + const { data, isLoading } = useQuery({ + queryKey: [`ping/${app.id}`], + queryFn: async () => { + const response = await fetch(`/api/modules/ping?url=${encodeURI(app.url)}`); + const isOk = app.network.okStatus.includes(response.status); + return { + status: response.status, + state: isOk ? 'online' : 'down', + }; + }, + enabled: active, + }); + + const isOnline = data?.state === 'online'; + + if (!active) return null; + + return ( + + + + + + ); +}; + +type PingState = 'loading' | 'down' | 'online'; diff --git a/src/components/Dashboard/Tiles/Apps/AppTile.tsx b/src/components/Dashboard/Tiles/Apps/AppTile.tsx new file mode 100644 index 000000000..0bd65decf --- /dev/null +++ b/src/components/Dashboard/Tiles/Apps/AppTile.tsx @@ -0,0 +1,103 @@ +import { Center, Text, UnstyledButton } from '@mantine/core'; +import { NextLink } from '@mantine/next'; +import { createStyles } from '@mantine/styles'; +import { motion } from 'framer-motion'; +import { AppType } from '../../../../types/app'; +import { useCardStyles } from '../../../layout/useCardStyles'; +import { useEditModeStore } from '../../Views/useEditModeStore'; +import { HomarrCardWrapper } from '../HomarrCardWrapper'; +import { BaseTileProps } from '../type'; +import { AppMenu } from './AppMenu'; +import { AppPing } from './AppPing'; + +interface AppTileProps extends BaseTileProps { + app: AppType; +} + +export const AppTile = ({ className, app }: AppTileProps) => { + const isEditMode = useEditModeStore((x) => x.enabled); + + const { cx, classes } = useStyles(); + + const { + classes: { card: cardClass }, + } = useCardStyles(false); + + function Inner() { + return ( + <> + + {app.name} + +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ + ); + } + + return ( + + + {!app.url || isEditMode ? ( + + + + ) : ( + 0 ? app.behaviour.externalUrl : app.url} + target={app.behaviour.isOpeningNewTab ? '_blank' : '_self'} + className={cx(classes.button)} + > + + + )} + + + ); +}; + +const useStyles = createStyles((theme, _params, getRef) => ({ + image: { + ref: getRef('image'), + maxHeight: '90%', + maxWidth: '90%', + }, + appName: { + ref: getRef('appName'), + }, + button: { + paddingBottom: 10, + height: '100%', + width: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 4, + }, +})); + +export const appTileDefinition = { + component: AppTile, + minWidth: 1, + minHeight: 1, + maxWidth: 12, + maxHeight: 12, +}; diff --git a/src/components/Dashboard/Tiles/EmptyTile.tsx b/src/components/Dashboard/Tiles/EmptyTile.tsx new file mode 100644 index 000000000..9fdb26db7 --- /dev/null +++ b/src/components/Dashboard/Tiles/EmptyTile.tsx @@ -0,0 +1,6 @@ +import { HomarrCardWrapper } from './HomarrCardWrapper'; +import { BaseTileProps } from './type'; + +export const EmptyTile = ({ className }: BaseTileProps) => ( + Empty +); diff --git a/src/components/Dashboard/Tiles/GenericTileMenu.tsx b/src/components/Dashboard/Tiles/GenericTileMenu.tsx new file mode 100644 index 000000000..fba87edfa --- /dev/null +++ b/src/components/Dashboard/Tiles/GenericTileMenu.tsx @@ -0,0 +1,57 @@ +import { ActionIcon, Menu } from '@mantine/core'; +import { IconDots, IconLayoutKanban, IconPencil, IconTrash } from '@tabler/icons'; +import { useTranslation } from 'next-i18next'; +import { useEditModeStore } from '../Views/useEditModeStore'; + +interface GenericTileMenuProps { + handleClickEdit: () => void; + handleClickChangePosition: () => void; + handleClickDelete: () => void; + displayEdit: boolean; +} + +export const GenericTileMenu = ({ + handleClickEdit, + handleClickChangePosition, + handleClickDelete, + displayEdit, +}: GenericTileMenuProps) => { + const { t } = useTranslation('common'); + const isEditMode = useEditModeStore((x) => x.enabled); + + if (!isEditMode) { + return null; + } + + return ( + + + + + + + + {t('sections.settings')} + {displayEdit && ( + } onClick={handleClickEdit}> + {t('edit')} + + )} + } + onClick={handleClickChangePosition} + > + {t('changePosition')} + + {t('sections.dangerZone')} + } + onClick={handleClickDelete} + > + {t('remove')} + + + + ); +}; diff --git a/src/components/Dashboard/Tiles/HomarrCardWrapper.tsx b/src/components/Dashboard/Tiles/HomarrCardWrapper.tsx new file mode 100644 index 000000000..8958667fc --- /dev/null +++ b/src/components/Dashboard/Tiles/HomarrCardWrapper.tsx @@ -0,0 +1,28 @@ +import { Card, CardProps } from '@mantine/core'; +import { ReactNode } from 'react'; +import { useCardStyles } from '../../layout/useCardStyles'; +import { useEditModeStore } from '../Views/useEditModeStore'; + +interface HomarrCardWrapperProps extends CardProps { + children: ReactNode; + isCategory?: boolean; +} + +export const HomarrCardWrapper = ({ ...props }: HomarrCardWrapperProps) => { + const { isCategory = false, ...restProps } = props; + const { + cx, + classes: { card: cardClass }, + } = useCardStyles(isCategory); + const isEditMode = useEditModeStore((x) => x.enabled); + return ( + + ); +}; diff --git a/src/components/Dashboard/Tiles/TileWrapper.tsx b/src/components/Dashboard/Tiles/TileWrapper.tsx new file mode 100644 index 000000000..8d94abdd7 --- /dev/null +++ b/src/components/Dashboard/Tiles/TileWrapper.tsx @@ -0,0 +1,73 @@ +/* eslint-disable react/no-unknown-property */ +import { ReactNode, RefObject } from 'react'; + +interface GridstackTileWrapperProps { + id: string; + type: 'app' | 'widget'; + x?: number; + y?: number; + width?: number; + height?: number; + minWidth?: number; + minHeight?: number; + maxWidth?: number; + maxHeight?: number; + itemRef: RefObject; + children: ReactNode; +} + +export const GridstackTileWrapper = ({ + id, + type, + x, + y, + width, + height, + minWidth, + minHeight, + maxWidth, + maxHeight, + children, + itemRef, +}: GridstackTileWrapperProps) => { + const locationProperties = useLocationProperties(x, y); + const normalizedWidth = width ?? minWidth; + const normalizedHeight = height ?? minHeight; + + return ( +
+ {children} +
+ ); +}; + +const useLocationProperties = (x: number | undefined, y: number | undefined) => { + const isLocationDefined = x !== undefined && y !== undefined; + + if (!isLocationDefined) { + return { + 'gs-auto-position': 'true', + }; + } + + return { + 'gs-x': x.toString(), + 'data-gridstack-x': x.toString(), + 'gs-y': y.toString(), + 'data-gridstack-y': y.toString(), + }; +}; diff --git a/src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx b/src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx new file mode 100644 index 000000000..b5714c805 --- /dev/null +++ b/src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx @@ -0,0 +1,196 @@ +import { + Alert, + Button, + Group, + MultiSelect, + Stack, + Switch, + TextInput, + Text, + NumberInput, + Slider, +} from '@mantine/core'; +import { ContextModalProps } from '@mantine/modals'; +import { IconAlertTriangle } from '@tabler/icons'; +import { Trans, useTranslation } from 'next-i18next'; +import { useState } from 'react'; +import Widgets from '../../../../widgets'; +import type { IWidgetOptionValue } from '../../../../widgets/widgets'; +import { useConfigContext } from '../../../../config/provider'; +import { useConfigStore } from '../../../../config/store'; +import { IWidget } from '../../../../widgets/widgets'; +import { useColorTheme } from '../../../../tools/color'; + +export type WidgetEditModalInnerProps = { + widgetId: string; + options: IWidget['properties']; + widgetOptions: IWidget['properties']; +}; + +type IntegrationOptionsValueType = IWidget['properties'][string]; + +export const WidgetsEditModal = ({ + context, + id, + innerProps, +}: ContextModalProps) => { + const { t } = useTranslation([`modules/${innerProps.widgetId}`, 'common']); + const [moduleProperties, setModuleProperties] = useState(innerProps.options); + // const items = Object.entries(moduleProperties ?? {}) as [string, IntegrationOptionsValueType][]; + const items = Object.entries(innerProps.widgetOptions ?? {}) as [ + string, + IntegrationOptionsValueType + ][]; + + // Find the Key in the "Widgets" Object that matches the widgetId + const currentWidgetDefinition = Widgets[innerProps.widgetId as keyof typeof Widgets]; + const { name: configName } = useConfigContext(); + const updateConfig = useConfigStore((x) => x.updateConfig); + + if (!configName || !innerProps.options) return null; + + const handleChange = (key: string, value: IntegrationOptionsValueType) => { + setModuleProperties((prev) => { + const copyOfPrev: any = { ...prev }; + copyOfPrev[key] = value; + return copyOfPrev; + }); + }; + + const getMutliselectData = (option: string) => { + const currentWidgetDefinition = Widgets[innerProps.widgetId as keyof typeof Widgets]; + if (!Widgets) return []; + + const options = currentWidgetDefinition.options as any; + return options[option]?.data ?? []; + }; + + const handleSave = () => { + updateConfig( + configName, + (prev) => { + const currentWidget = prev.widgets.find((x) => x.id === innerProps.widgetId); + currentWidget!.properties = moduleProperties; + + return { + ...prev, + widgets: [...prev.widgets.filter((x) => x.id !== innerProps.widgetId), currentWidget!], + }; + }, + true + ); + context.closeModal(id); + }; + + return ( + + {items.map(([key, defaultValue], index) => { + const option = (currentWidgetDefinition as any).options[key] as IWidgetOptionValue; + const value = moduleProperties[key] ?? defaultValue; + + if (!option) { + return ( + } color="red"> + + , code: }} + /> + + + ); + } + return WidgetOptionTypeSwitch( + option, + index, + t, + key, + value, + handleChange, + getMutliselectData + ); + })} + + + + + + ); +}; + +// Widget switch +// Widget options are computed based on their type. +// here you can define new types for options (along with editing the widgets.d.ts file) +function WidgetOptionTypeSwitch( + option: IWidgetOptionValue, + index: number, + t: any, + key: string, + value: string | number | boolean | string[], + handleChange: (key: string, value: IntegrationOptionsValueType) => void, + getMutliselectData: (option: string) => any +) { + const { primaryColor, secondaryColor } = useColorTheme(); + switch (option.type) { + case 'switch': + return ( + handleChange(key, ev.currentTarget.checked)} + /> + ); + case 'text': + return ( + handleChange(key, ev.currentTarget.value)} + /> + ); + case 'multi-select': + return ( + handleChange(key, v)} + /> + ); + case 'number': + return ( + handleChange(key, v!)} + /> + ); + case 'slider': + return ( + + {t(`descriptor.settings.${key}.label`)} + handleChange(key, v)} + /> + + ); + default: + return null; + } +} diff --git a/src/components/Dashboard/Tiles/Widgets/WidgetsMenu.tsx b/src/components/Dashboard/Tiles/Widgets/WidgetsMenu.tsx new file mode 100644 index 000000000..7a380794c --- /dev/null +++ b/src/components/Dashboard/Tiles/Widgets/WidgetsMenu.tsx @@ -0,0 +1,83 @@ +import { Title } from '@mantine/core'; +import { useTranslation } from 'next-i18next'; +import { openContextModalGeneric } from '../../../../tools/mantineModalManagerExtensions'; +import { IWidget } from '../../../../widgets/widgets'; +import { useWrapperColumnCount } from '../../Wrappers/gridstack/store'; +import { GenericTileMenu } from '../GenericTileMenu'; +import { WidgetEditModalInnerProps } from './WidgetsEditModal'; +import { WidgetsRemoveModalInnerProps } from './WidgetsRemoveModal'; +import WidgetsDefinitions from '../../../../widgets'; + +export type WidgetChangePositionModalInnerProps = { + widgetId: string; + widget: IWidget; + wrapperColumnCount: number; +}; + +interface WidgetsMenuProps { + integration: string; + widget: IWidget | undefined; +} + +export const WidgetsMenu = ({ integration, widget }: WidgetsMenuProps) => { + const { t } = useTranslation(`modules/${integration}`); + const wrapperColumnCount = useWrapperColumnCount(); + + if (!widget || !wrapperColumnCount) return null; + // Match widget.id with WidgetsDefinitions + // First get the keys + const keys = Object.keys(WidgetsDefinitions); + // Then find the key that matches the widget.id + const widgetDefinition = keys.find((key) => key === widget.id); + // Then get the widget definition + const widgetDefinitionObject = + WidgetsDefinitions[widgetDefinition as keyof typeof WidgetsDefinitions]; + + const handleDeleteClick = () => { + openContextModalGeneric({ + modal: 'integrationRemove', + title: {t('descriptor.remove.title')}, + innerProps: { + widgetId: integration, + }, + }); + }; + + const handleChangeSizeClick = () => { + openContextModalGeneric({ + modal: 'changeIntegrationPositionModal', + size: 'xl', + title: null, + innerProps: { + widgetId: integration, + widget, + wrapperColumnCount, + }, + }); + }; + + const handleEditClick = () => { + openContextModalGeneric({ + modal: 'integrationOptions', + title: {t('descriptor.settings.title')}, + innerProps: { + widgetId: integration, + options: widget.properties, + // Cast as the right type for the correct widget + widgetOptions: widgetDefinitionObject.options as any, + }, + zIndex: 5, + }); + }; + + return ( + + ); +}; diff --git a/src/components/Dashboard/Tiles/Widgets/WidgetsRemoveModal.tsx b/src/components/Dashboard/Tiles/Widgets/WidgetsRemoveModal.tsx new file mode 100644 index 000000000..59c20f394 --- /dev/null +++ b/src/components/Dashboard/Tiles/Widgets/WidgetsRemoveModal.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { Button, Group, Stack, Text } from '@mantine/core'; +import { ContextModalProps } from '@mantine/modals'; +import { useTranslation } from 'next-i18next'; +import { useConfigContext } from '../../../../config/provider'; +import { useConfigStore } from '../../../../config/store'; + +export type WidgetsRemoveModalInnerProps = { + widgetId: string; +}; + +export const WidgetsRemoveModal = ({ + context, + id, + innerProps, +}: ContextModalProps) => { + const { t } = useTranslation([`modules/${innerProps.widgetId}`, 'common']); + const { name: configName } = useConfigContext(); + if (!configName) return null; + const updateConfig = useConfigStore((x) => x.updateConfig); + const handleDeletion = () => { + updateConfig( + configName, + (prev) => ({ + ...prev, + widgets: prev.widgets.filter((w) => w.id !== innerProps.widgetId), + }), + true + ); + context.closeModal(id); + }; + + return ( + + {t('descriptor.remove.confirm')} + + + + + + ); +}; diff --git a/src/components/Dashboard/Tiles/type.ts b/src/components/Dashboard/Tiles/type.ts new file mode 100644 index 000000000..e5e860af9 --- /dev/null +++ b/src/components/Dashboard/Tiles/type.ts @@ -0,0 +1,3 @@ +export interface BaseTileProps { + className?: string; +} diff --git a/src/components/Dashboard/Views/DashboardView.tsx b/src/components/Dashboard/Views/DashboardView.tsx new file mode 100644 index 000000000..11f6cb0b4 --- /dev/null +++ b/src/components/Dashboard/Views/DashboardView.tsx @@ -0,0 +1,94 @@ +import { Center, Group, Loader, Stack } from '@mantine/core'; +import { useEffect, useMemo, useRef } from 'react'; +import { useConfigContext } from '../../../config/provider'; +import { useResize } from '../../../hooks/use-resize'; +import { useScreenLargerThan } from '../../../hooks/useScreenLargerThan'; +import { CategoryType } from '../../../types/category'; +import { WrapperType } from '../../../types/wrapper'; +import { DashboardCategory } from '../Wrappers/Category/Category'; +import { useGridstackStore } from '../Wrappers/gridstack/store'; +import { DashboardSidebar } from '../Wrappers/Sidebar/Sidebar'; +import { DashboardWrapper } from '../Wrappers/Wrapper/Wrapper'; + +export const DashboardView = () => { + const wrappers = useWrapperItems(); + const sidebarsVisible = useSidebarVisibility(); + const { isReady, mainAreaRef } = usePrepareGridstack(); + + return ( + + {sidebarsVisible.isLoading ? ( +
+ +
+ ) : ( + <> + {sidebarsVisible.left ? ( + + ) : null} + + + {!isReady + ? null + : wrappers.map((item) => + item.type === 'category' ? ( + + ) : ( + + ) + )} + + + {sidebarsVisible.right ? ( + + ) : null} + + )} +
+ ); +}; + +const usePrepareGridstack = () => { + const mainAreaRef = useRef(null); + const { width } = useResize(mainAreaRef, []); + const setMainAreaWidth = useGridstackStore((x) => x.setMainAreaWidth); + const mainAreaWidth = useGridstackStore((x) => x.mainAreaWidth); + + useEffect(() => { + if (width === 0) return; + setMainAreaWidth(width); + }, [width]); + + return { + isReady: !!mainAreaWidth, + mainAreaRef, + }; +}; + +const useSidebarVisibility = () => { + const layoutSettings = useConfigContext()?.config?.settings.customization.layout; + const screenLargerThanMd = useScreenLargerThan('md'); // For smaller screens mobile ribbons are displayed with drawers + + const isScreenSizeUnknown = typeof screenLargerThanMd === 'undefined'; + + return { + right: layoutSettings?.enabledRightSidebar && screenLargerThanMd, + left: layoutSettings?.enabledLeftSidebar && screenLargerThanMd, + isLoading: isScreenSizeUnknown, + }; +}; + +const useWrapperItems = () => { + const { config } = useConfigContext(); + + return useMemo( + () => + config + ? [ + ...config.categories.map((c) => ({ ...c, type: 'category' })), + ...config.wrappers.map((w) => ({ ...w, type: 'wrapper' })), + ].sort((a, b) => a.position - b.position) + : [], + [config?.categories, config?.wrappers] + ); +}; diff --git a/src/components/Dashboard/Views/DetailView.tsx b/src/components/Dashboard/Views/DetailView.tsx new file mode 100644 index 000000000..e73c664bc --- /dev/null +++ b/src/components/Dashboard/Views/DetailView.tsx @@ -0,0 +1,3 @@ +import { DashboardView } from './DashboardView'; + +export const DashboardDetailView = () => ; diff --git a/src/components/Dashboard/Views/EditView.tsx b/src/components/Dashboard/Views/EditView.tsx new file mode 100644 index 000000000..11b387d81 --- /dev/null +++ b/src/components/Dashboard/Views/EditView.tsx @@ -0,0 +1,3 @@ +import { DashboardView } from './DashboardView'; + +export const DashboardEditView = () => ; diff --git a/src/components/Dashboard/Views/ViewToggleButton.tsx b/src/components/Dashboard/Views/ViewToggleButton.tsx new file mode 100644 index 000000000..8be927a46 --- /dev/null +++ b/src/components/Dashboard/Views/ViewToggleButton.tsx @@ -0,0 +1,39 @@ +import { ActionIcon, Button, Text, Tooltip } from '@mantine/core'; +import { IconEdit, IconEditOff } from '@tabler/icons'; +import { useTranslation } from 'next-i18next'; +import { useScreenLargerThan } from '../../../hooks/useScreenLargerThan'; +import { useEditModeStore } from './useEditModeStore'; + +export const ViewToggleButton = () => { + const screenLargerThanMd = useScreenLargerThan('md'); + const { enabled: isEditMode, toggleEditMode } = useEditModeStore(); + const { t } = useTranslation('layout/header/actions/toggle-edit-mode'); + + return ( + {t('description')}}> + {screenLargerThanMd ? ( + + ) : ( + toggleEditMode()} + variant="default" + radius="md" + size="xl" + color="blue" + > + {isEditMode ? : } + + )} + + ); +}; diff --git a/src/components/Dashboard/Views/useEditModeStore.ts b/src/components/Dashboard/Views/useEditModeStore.ts new file mode 100644 index 000000000..5c9fbafc2 --- /dev/null +++ b/src/components/Dashboard/Views/useEditModeStore.ts @@ -0,0 +1,11 @@ +import create from 'zustand'; + +interface EditModeState { + enabled: boolean; + toggleEditMode: () => void; +} + +export const useEditModeStore = create((set) => ({ + enabled: false, + toggleEditMode: () => set((state) => ({ enabled: !state.enabled })), +})); diff --git a/src/components/Dashboard/Wrappers/Category/Category.tsx b/src/components/Dashboard/Wrappers/Category/Category.tsx new file mode 100644 index 000000000..9034a2e1a --- /dev/null +++ b/src/components/Dashboard/Wrappers/Category/Category.tsx @@ -0,0 +1,32 @@ +import { Group, Title } from '@mantine/core'; +import { CategoryType } from '../../../../types/category'; +import { HomarrCardWrapper } from '../../Tiles/HomarrCardWrapper'; +import { useEditModeStore } from '../../Views/useEditModeStore'; +import { useGridstack } from '../gridstack/use-gridstack'; +import { WrapperContent } from '../WrapperContent'; +import { CategoryEditMenu } from './CategoryEditMenu'; + +interface DashboardCategoryProps { + category: CategoryType; +} + +export const DashboardCategory = ({ category }: DashboardCategoryProps) => { + const { refs, apps, widgets } = useGridstack('category', category.id); + const isEditMode = useEditModeStore((x) => x.enabled); + + return ( + + + {category.name} + {isEditMode ? : null} + +
+ +
+
+ ); +}; diff --git a/src/components/Dashboard/Wrappers/Category/CategoryEditMenu.tsx b/src/components/Dashboard/Wrappers/Category/CategoryEditMenu.tsx new file mode 100644 index 000000000..24c89bc8f --- /dev/null +++ b/src/components/Dashboard/Wrappers/Category/CategoryEditMenu.tsx @@ -0,0 +1,55 @@ +import { ActionIcon, Menu } from '@mantine/core'; +import { + IconDots, + IconTransitionTop, + IconTransitionBottom, + IconRowInsertTop, + IconRowInsertBottom, + IconEdit, + IconTrash, +} from '@tabler/icons'; +import { useConfigContext } from '../../../../config/provider'; +import { CategoryType } from '../../../../types/category'; +import { useCategoryActions } from './useCategoryActions'; + +interface CategoryEditMenuProps { + category: CategoryType; +} + +export const CategoryEditMenu = ({ category }: CategoryEditMenuProps) => { + const { name: configName } = useConfigContext(); + const { addCategoryAbove, addCategoryBelow, moveCategoryUp, moveCategoryDown, edit, remove } = + useCategoryActions(configName, category); + + return ( + + + + + + + + } onClick={edit}> + Edit + + } onClick={remove}> + Remove + + Change positon + } onClick={moveCategoryUp}> + Move up + + } onClick={moveCategoryDown}> + Move down + + Add category + } onClick={addCategoryAbove}> + Add category above + + } onClick={addCategoryBelow}> + Add category below + + + + ); +}; diff --git a/src/components/Dashboard/Wrappers/Category/CategoryEditModal.tsx b/src/components/Dashboard/Wrappers/Category/CategoryEditModal.tsx new file mode 100644 index 000000000..0c23d45a6 --- /dev/null +++ b/src/components/Dashboard/Wrappers/Category/CategoryEditModal.tsx @@ -0,0 +1,53 @@ +import { Button, Group, TextInput } from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { ContextModalProps } from '@mantine/modals'; +import { useTranslation } from 'next-i18next'; +import { useConfigContext } from '../../../../config/provider'; +import { useConfigStore } from '../../../../config/store'; +import { CategoryType } from '../../../../types/category'; + +export type CategoryEditModalInnerProps = { + category: CategoryType; + onSuccess: (category: CategoryType) => Promise; +}; + +export const CategoryEditModal = ({ + context, + innerProps, + id, +}: ContextModalProps) => { + const { name: configName } = useConfigContext(); + const updateConfig = useConfigStore((x) => x.updateConfig); + const form = useForm({ + initialValues: { + name: innerProps.category.name, + }, + validate: { + name: (val: string) => (!val || val.trim().length === 0 ? 'Name is required' : null), + }, + }); + + const handleSubmit = async (values: FormType) => { + innerProps.onSuccess({ ...innerProps.category, name: values.name }); + context.closeModal(id); + }; + + const { t } = useTranslation('common'); + + return ( +
+ + + + + + + + ); +}; + +type FormType = { + name: string; +}; diff --git a/src/components/Dashboard/Wrappers/Category/useCategoryActions.tsx b/src/components/Dashboard/Wrappers/Category/useCategoryActions.tsx new file mode 100644 index 000000000..0c9b68bb6 --- /dev/null +++ b/src/components/Dashboard/Wrappers/Category/useCategoryActions.tsx @@ -0,0 +1,239 @@ +import { v4 as uuidv4 } from 'uuid'; +import { useConfigStore } from '../../../../config/store'; +import { openContextModalGeneric } from '../../../../tools/mantineModalManagerExtensions'; +import { CategoryType } from '../../../../types/category'; +import { WrapperType } from '../../../../types/wrapper'; +import { CategoryEditModalInnerProps } from './CategoryEditModal'; + +export const useCategoryActions = (configName: string | undefined, category: CategoryType) => { + const updateConfig = useConfigStore((x) => x.updateConfig); + + // creates a new category above the current + const addCategoryAbove = () => { + const abovePosition = category.position - 1; + + openContextModalGeneric({ + modal: 'categoryEditModal', + innerProps: { + category: { + id: uuidv4(), + name: 'New category', + position: abovePosition + 1, + }, + onSuccess: async (category) => { + if (!configName) return; + + const newWrapper: WrapperType = { + id: uuidv4(), + position: abovePosition + 2, + }; + + // Adding category and wrapper and moving other items down + updateConfig( + configName, + (previous) => { + const aboveWrappers = previous.wrappers.filter((x) => x.position <= abovePosition); + const aboveCategories = previous.categories.filter( + (x) => x.position <= abovePosition + ); + + const belowWrappers = previous.wrappers.filter((x) => x.position > abovePosition); + const belowCategories = previous.categories.filter((x) => x.position > abovePosition); + + return { + ...previous, + categories: [ + ...aboveCategories, + category, + // Move categories below down + ...belowCategories.map((x) => ({ ...x, position: x.position + 2 })), + ], + wrappers: [ + ...aboveWrappers, + newWrapper, + // Move wrappers below down + ...belowWrappers.map((x) => ({ ...x, position: x.position + 2 })), + ], + }; + }, + true + ); + }, + }, + }); + }; + + // creates a new category below the current + const addCategoryBelow = () => { + const belowPosition = category.position + 1; + + openContextModalGeneric({ + modal: 'categoryEditModal', + innerProps: { + category: { + id: uuidv4(), + name: 'New category', + position: belowPosition + 1, + }, + onSuccess: async (category) => { + if (!configName) return; + + const newWrapper: WrapperType = { + id: uuidv4(), + position: belowPosition, + }; + + // Adding category and wrapper and moving other items down + updateConfig( + configName, + (previous) => { + const aboveWrappers = previous.wrappers.filter((x) => x.position < belowPosition); + const aboveCategories = previous.categories.filter((x) => x.position < belowPosition); + + const belowWrappers = previous.wrappers.filter((x) => x.position >= belowPosition); + const belowCategories = previous.categories.filter( + (x) => x.position >= belowPosition + ); + + return { + ...previous, + categories: [ + ...aboveCategories, + category, + // Move categories below down + ...belowCategories.map((x) => ({ ...x, position: x.position + 2 })), + ], + wrappers: [ + ...aboveWrappers, + newWrapper, + // Move wrappers below down + ...belowWrappers.map((x) => ({ ...x, position: x.position + 2 })), + ], + }; + }, + true + ); + }, + }, + }); + }; + + const moveCategoryUp = () => { + if (!configName) return; + + updateConfig( + configName, + (previous) => { + const currentItem = previous.categories.find((x) => x.id === category.id); + if (!currentItem) return previous; + + const upperItem = previous.categories.find((x) => x.position === currentItem.position - 2); + + if (!upperItem) return previous; + + currentItem.position -= 2; + upperItem.position += 2; + + return { + ...previous, + categories: [ + ...previous.categories.filter((c) => ![currentItem.id, upperItem.id].includes(c.id)), + { ...upperItem }, + { ...currentItem }, + ], + }; + }, + true + ); + }; + + const moveCategoryDown = () => { + if (!configName) return; + + updateConfig( + configName, + (previous) => { + const currentItem = previous.categories.find((x) => x.id === category.id); + if (!currentItem) return previous; + + const belowItem = previous.categories.find((x) => x.position === currentItem.position + 2); + + if (!belowItem) return previous; + + currentItem.position += 2; + belowItem.position -= 2; + + return { + ...previous, + categories: [ + ...previous.categories.filter((c) => ![currentItem.id, belowItem.id].includes(c.id)), + { ...currentItem }, + { ...belowItem }, + ], + }; + }, + true + ); + }; + + // Removes the current category + const remove = () => { + if (!configName) return; + updateConfig( + configName, + (previous) => { + const currentItem = previous.categories.find((x) => x.id === category.id); + if (!currentItem) return previous; + // Find the main wrapper + const mainWrapper = previous.wrappers.find((x) => x.position === 1); + + // Check that the app has an area.type or "category" and that the area.id is the current category + const appsToMove = previous.apps.filter( + (x) => x.area && x.area.type === 'category' && x.area.properties.id === currentItem.id + ); + appsToMove.forEach((x) => { + // eslint-disable-next-line no-param-reassign + x.area = { type: 'wrapper', properties: { id: mainWrapper?.id ?? 'default' } }; + }); + + return { + ...previous, + apps: previous.apps, + categories: previous.categories.filter((x) => x.id !== category.id), + wrappers: previous.wrappers.filter((x) => x.position !== currentItem.position), + }; + }, + true + ); + }; + + const edit = async () => { + openContextModalGeneric({ + modal: 'categoryEditModal', + withCloseButton: false, + innerProps: { + category, + onSuccess: async (category) => { + if (!configName) return; + await updateConfig(configName, (prev) => { + const currentCategory = prev.categories.find((c) => c.id === category.id); + if (!currentCategory) return prev; + return { + ...prev, + categories: [...prev.categories.filter((c) => c.id !== category.id), { ...category }], + }; + }); + }, + }, + }); + }; + + return { + addCategoryAbove, + addCategoryBelow, + moveCategoryUp, + moveCategoryDown, + remove, + edit, + }; +}; diff --git a/src/components/Dashboard/Wrappers/Sidebar/Sidebar.tsx b/src/components/Dashboard/Wrappers/Sidebar/Sidebar.tsx new file mode 100644 index 000000000..a8bd89ebe --- /dev/null +++ b/src/components/Dashboard/Wrappers/Sidebar/Sidebar.tsx @@ -0,0 +1,55 @@ +import { Card } from '@mantine/core'; +import { RefObject } from 'react'; +import { useCardStyles } from '../../../layout/useCardStyles'; +import { useGridstack } from '../gridstack/use-gridstack'; +import { WrapperContent } from '../WrapperContent'; + +interface DashboardSidebarProps extends DashboardSidebarInnerProps { + location: 'right' | 'left'; + isGridstackReady: boolean; +} + +export const DashboardSidebar = ({ location, isGridstackReady }: DashboardSidebarProps) => ( + + {isGridstackReady && } + +); + +interface DashboardSidebarInnerProps { + location: 'right' | 'left'; +} + +// Is Required because of the gridstack main area width. +const SidebarInner = ({ location }: DashboardSidebarInnerProps) => { + const { refs, apps, widgets } = useGridstack('sidebar', location); + + const minRow = useMinRowForFullHeight(refs.wrapper); + const { + cx, + classes: { card: cardClass }, + } = useCardStyles(false); + + return ( + +
+ +
+
+ ); +}; + +const useMinRowForFullHeight = (wrapperRef: RefObject) => + wrapperRef.current ? Math.floor(wrapperRef.current!.offsetHeight / 128) : 2; diff --git a/src/components/Dashboard/Wrappers/Wrapper/Wrapper.tsx b/src/components/Dashboard/Wrappers/Wrapper/Wrapper.tsx new file mode 100644 index 000000000..ab8d92319 --- /dev/null +++ b/src/components/Dashboard/Wrappers/Wrapper/Wrapper.tsx @@ -0,0 +1,22 @@ +import { WrapperType } from '../../../../types/wrapper'; +import { useGridstack } from '../gridstack/use-gridstack'; +import { WrapperContent } from '../WrapperContent'; + +interface DashboardWrapperProps { + wrapper: WrapperType; +} + +export const DashboardWrapper = ({ wrapper }: DashboardWrapperProps) => { + const { refs, apps, widgets } = useGridstack('wrapper', wrapper.id); + + return ( +
+ +
+ ); +}; diff --git a/src/components/Dashboard/Wrappers/WrapperContent.tsx b/src/components/Dashboard/Wrappers/WrapperContent.tsx new file mode 100644 index 000000000..5a864b6ab --- /dev/null +++ b/src/components/Dashboard/Wrappers/WrapperContent.tsx @@ -0,0 +1,68 @@ +import { GridStack } from 'fily-publish-gridstack'; +import { MutableRefObject, RefObject } from 'react'; +import { AppType } from '../../../types/app'; +import Widgets from '../../../widgets'; +import { IWidget, IWidgetDefinition } from '../../../widgets/widgets'; +import { WidgetWrapper } from '../../../widgets/WidgetWrapper'; +import { appTileDefinition } from '../Tiles/Apps/AppTile'; +import { GridstackTileWrapper } from '../Tiles/TileWrapper'; +import { useGridstackStore } from './gridstack/store'; + +interface WrapperContentProps { + apps: AppType[]; + widgets: IWidget[]; + refs: { + wrapper: RefObject; + items: MutableRefObject>>; + gridstack: MutableRefObject; + }; +} + +export function WrapperContent({ apps, refs, widgets }: WrapperContentProps) { + const shapeSize = useGridstackStore((x) => x.currentShapeSize); + + if (!shapeSize) return null; + + return ( + <> + {apps?.map((app) => { + const { component: TileComponent, ...tile } = appTileDefinition; + return ( + + + + ); + })} + {widgets.map((widget) => { + const definition = Widgets[widget.id as keyof typeof Widgets] as + | IWidgetDefinition + | undefined; + if (!definition) return null; + + return ( + + + + + + ); + })} + + ); +} diff --git a/src/components/Dashboard/Wrappers/gridstack/init-gridstack.ts b/src/components/Dashboard/Wrappers/gridstack/init-gridstack.ts new file mode 100644 index 000000000..6da312a9d --- /dev/null +++ b/src/components/Dashboard/Wrappers/gridstack/init-gridstack.ts @@ -0,0 +1,110 @@ +import { GridItemHTMLElement, GridStack, GridStackNode } from 'fily-publish-gridstack'; +import { MutableRefObject, RefObject } from 'react'; +import { AppType } from '../../../../types/app'; +import { ShapeType } from '../../../../types/shape'; +import { IWidget } from '../../../../widgets/widgets'; + +export const initializeGridstack = ( + areaType: 'wrapper' | 'category' | 'sidebar', + wrapperRef: RefObject, + gridRef: MutableRefObject, + itemRefs: MutableRefObject>>, + areaId: string, + items: AppType[], + widgets: IWidget[], + isEditMode: boolean, + wrapperColumnCount: 3 | 6 | 12, + shapeSize: 'sm' | 'md' | 'lg', + tilesWithUnknownLocation: TileWithUnknownLocation[], + events: { + onChange: (changedNode: GridStackNode) => void; + onAdd: (addedNode: GridStackNode) => void; + } +) => { + if (!wrapperRef.current) return; + // calculates the currently available count of columns + const columnCount = areaType === 'sidebar' ? 2 : wrapperColumnCount; + const minRow = areaType !== 'sidebar' ? 1 : Math.floor(wrapperRef.current.offsetHeight / 128); + // initialize gridstack + const newGrid = gridRef; + newGrid.current = GridStack.init( + { + column: columnCount, + margin: 10, + cellHeight: 128, + float: true, + alwaysShowResizeHandle: 'mobile', + acceptWidgets: true, + disableOneColumnMode: true, + staticGrid: !isEditMode, + minRow, + animate: false, + }, + // selector of the gridstack item (it's eather category or wrapper) + `.grid-stack-${areaType}[data-${areaType}='${areaId}']` + ); + const grid = newGrid.current; + // Must be used to update the column count after the initialization + grid.column(columnCount); + + // Add listener for moving items around in a wrapper + grid.on('change', (_, el) => { + const nodes = el as GridStackNode[]; + const firstNode = nodes.at(0); + if (!firstNode) return; + events.onChange(firstNode); + }); + + // Add listener for moving items in config from one wrapper to another + grid.on('added', (_, el) => { + const nodes = el as GridStackNode[]; + const firstNode = nodes.at(0); + if (!firstNode) return; + events.onAdd(firstNode); + }); + + grid.batchUpdate(); + grid.removeAll(false); + items.forEach(({ id, shape }) => { + const item = itemRefs.current[id]?.current; + setAttributesFromShape(item, shape[shapeSize]); + item && grid.makeWidget(item as HTMLDivElement); + if (!shape[shapeSize] && item) { + const gridItemElement = item as GridItemHTMLElement; + if (gridItemElement.gridstackNode) { + const { x, y, w, h } = gridItemElement.gridstackNode; + tilesWithUnknownLocation.push({ x, y, w, h, type: 'app', id }); + } + } + }); + widgets.forEach(({ id, shape }) => { + const item = itemRefs.current[id]?.current; + setAttributesFromShape(item, shape[shapeSize]); + item && grid.makeWidget(item as HTMLDivElement); + if (!shape[shapeSize] && item) { + const gridItemElement = item as GridItemHTMLElement; + if (gridItemElement.gridstackNode) { + const { x, y, w, h } = gridItemElement.gridstackNode; + tilesWithUnknownLocation.push({ x, y, w, h, type: 'widget', id }); + } + } + }); + grid.batchUpdate(false); +}; + +function setAttributesFromShape(ref: HTMLDivElement | null, sizedShape: ShapeType['lg']) { + if (!sizedShape || !ref) return; + ref.setAttribute('gs-x', sizedShape.location.x.toString()); + ref.setAttribute('gs-y', sizedShape.location.y.toString()); + ref.setAttribute('gs-w', sizedShape.size.width.toString()); + ref.setAttribute('gs-h', sizedShape.size.height.toString()); +} + +export type TileWithUnknownLocation = { + x?: number; + y?: number; + w?: number; + h?: number; + type: 'app' | 'widget'; + id: string; +}; diff --git a/src/components/Dashboard/Wrappers/gridstack/store.tsx b/src/components/Dashboard/Wrappers/gridstack/store.tsx new file mode 100644 index 000000000..881c0ee39 --- /dev/null +++ b/src/components/Dashboard/Wrappers/gridstack/store.tsx @@ -0,0 +1,31 @@ +import { useMantineTheme } from '@mantine/core'; +import create from 'zustand'; + +export const useGridstackStore = create((set, get) => ({ + mainAreaWidth: null, + currentShapeSize: null, + setMainAreaWidth: (w: number) => + set((v) => ({ ...v, mainAreaWidth: w, currentShapeSize: getCurrentShapeSize(w) })), +})); + +interface GridstackStoreType { + mainAreaWidth: null | number; + currentShapeSize: null | 'sm' | 'md' | 'lg'; + setMainAreaWidth: (width: number) => void; +} + +export const useWrapperColumnCount = () => { + const mainAreaWidth = useGridstackStore((x) => x.mainAreaWidth); + const { sm, xl } = useMantineTheme().breakpoints; + if (!mainAreaWidth) return null; + + if (mainAreaWidth >= xl) return 12; + + if (mainAreaWidth >= sm) return 6; + + return 3; +}; + +function getCurrentShapeSize(size: number) { + return size >= 1400 ? 'lg' : size >= 768 ? 'md' : 'sm'; +} diff --git a/src/components/Dashboard/Wrappers/gridstack/use-gridstack.ts b/src/components/Dashboard/Wrappers/gridstack/use-gridstack.ts new file mode 100644 index 000000000..8a87cca8a --- /dev/null +++ b/src/components/Dashboard/Wrappers/gridstack/use-gridstack.ts @@ -0,0 +1,314 @@ +import { GridStack, GridStackNode } from 'fily-publish-gridstack'; +import { createRef, MutableRefObject, RefObject, useEffect, useMemo, useRef } from 'react'; +import { useConfigContext } from '../../../../config/provider'; +import { useConfigStore } from '../../../../config/store'; +import { AppType } from '../../../../types/app'; +import { AreaType } from '../../../../types/area'; +import { IWidget } from '../../../../widgets/widgets'; +import { useEditModeStore } from '../../Views/useEditModeStore'; +import { initializeGridstack, TileWithUnknownLocation } from './init-gridstack'; +import { useGridstackStore, useWrapperColumnCount } from './store'; + +interface UseGristackReturnType { + apps: AppType[]; + widgets: IWidget[]; + refs: { + wrapper: RefObject; + items: MutableRefObject>>; + gridstack: MutableRefObject; + }; +} + +export const useGridstack = ( + areaType: 'wrapper' | 'category' | 'sidebar', + areaId: string +): UseGristackReturnType => { + const isEditMode = useEditModeStore((x) => x.enabled); + const { config, configVersion, name: configName } = useConfigContext(); + const updateConfig = useConfigStore((x) => x.updateConfig); + // define reference for wrapper - is used to calculate the width of the wrapper + const wrapperRef = useRef(null); + // references to the diffrent items contained in the gridstack + const itemRefs = useRef>>({}); + // reference of the gridstack object for modifications after initialization + const gridRef = useRef(); + const wrapperColumnCount = useWrapperColumnCount(); + const shapeSize = useGridstackStore((x) => x.currentShapeSize); + const mainAreaWidth = useGridstackStore((x) => x.mainAreaWidth); + // width of the wrapper (updating on page resize) + const root: HTMLHtmlElement = useMemo(() => document.querySelector(':root')!, []); + + if (!mainAreaWidth || !shapeSize || !wrapperColumnCount) { + throw new Error('UseGridstack should not be executed before mainAreaWidth has been set!'); + } + + const items = useMemo( + () => + config?.apps.filter( + (x) => + x.area.type === areaType && + (x.area.type === 'sidebar' + ? x.area.properties.location === areaId + : x.area.properties.id === areaId) + ) ?? [], + [configVersion, config?.apps.length] + ); + const widgets = useMemo(() => { + if (!config) return []; + return config.widgets.filter( + (w) => + w.area.type === areaType && + (w.area.type === 'sidebar' + ? w.area.properties.location === areaId + : w.area.properties.id === areaId) + ); + }, [configVersion, config?.widgets.length]); + + // define items in itemRefs for easy access and reference to items + if (Object.keys(itemRefs.current).length !== items.length + (widgets ?? []).length) { + items.forEach(({ id }: { id: keyof typeof itemRefs.current }) => { + itemRefs.current[id] = itemRefs.current[id] || createRef(); + }); + (widgets ?? []).forEach(({ id }) => { + itemRefs.current[id] = itemRefs.current[id] || createRef(); + }); + } + + useEffect(() => { + const widgetWidth = mainAreaWidth / wrapperColumnCount; + // widget width is used to define sizes of gridstack items within global.scss + root.style.setProperty('--gridstack-widget-width', widgetWidth.toString()); + gridRef.current?.cellHeight(widgetWidth); + }, [mainAreaWidth, wrapperColumnCount, gridRef.current]); + + useEffect(() => { + // column count is used to define count of columns of gridstack within global.scss + root.style.setProperty('--gridstack-column-count', wrapperColumnCount.toString()); + }, [wrapperColumnCount]); + + const onChange = isEditMode + ? (changedNode: GridStackNode) => { + if (!configName) return; + + const itemType = changedNode.el?.getAttribute('data-type'); + const itemId = changedNode.el?.getAttribute('data-id'); + if (!itemType || !itemId) return; + + // Updates the config and defines the new position of the item + updateConfig(configName, (previous) => { + const currentItem = + itemType === 'app' + ? previous.apps.find((x) => x.id === itemId) + : previous.widgets.find((x) => x.id === itemId); + if (!currentItem) return previous; + + currentItem.shape[shapeSize] = { + location: { + x: changedNode.x!, + y: changedNode.y!, + }, + size: { + width: changedNode.w!, + height: changedNode.h!, + }, + }; + + if (itemType === 'app') { + return { + ...previous, + apps: [ + ...previous.apps.filter((x) => x.id !== itemId), + { ...(currentItem as AppType) }, + ], + }; + } + + return { + ...previous, + widgets: [ + ...previous.widgets.filter((x) => x.id !== itemId), + { ...(currentItem as IWidget) }, + ], + }; + }); + } + : () => {}; + + const onAdd = isEditMode + ? (addedNode: GridStackNode) => { + if (!configName) return; + + const itemType = addedNode.el?.getAttribute('data-type'); + const itemId = addedNode.el?.getAttribute('data-id'); + if (!itemType || !itemId) return; + + // Updates the config and defines the new position and wrapper of the item + updateConfig( + configName, + (previous) => { + const currentItem = + itemType === 'app' + ? previous.apps.find((x) => x.id === itemId) + : previous.widgets.find((x) => x.id === itemId); + if (!currentItem) return previous; + + if (areaType === 'sidebar') { + currentItem.area = { + type: areaType, + properties: { + location: areaId as 'right' | 'left', + }, + }; + } else { + currentItem.area = { + type: areaType, + properties: { + id: areaId, + }, + }; + } + + currentItem.shape[shapeSize] = { + location: { + x: addedNode.x!, + y: addedNode.y!, + }, + size: { + width: addedNode.w!, + height: addedNode.h!, + }, + }; + + if (itemType === 'app') { + return { + ...previous, + apps: [ + ...previous.apps.filter((x) => x.id !== itemId), + { ...(currentItem as AppType) }, + ], + }; + } + + return { + ...previous, + widgets: [ + ...previous.widgets.filter((x) => x.id !== itemId), + { ...(currentItem as IWidget) }, + ], + }; + }, + (prev, curr) => { + const isApp = itemType === 'app'; + + if (isApp) { + const currItem = curr.apps.find((x) => x.id === itemId); + const prevItem = prev.apps.find((x) => x.id === itemId); + if (!currItem || !prevItem) return false; + + return ( + currItem.area.type !== prevItem.area.type || + Object.entries(currItem.area.properties).some( + ([key, value]) => + prevItem.area.properties[key as keyof AreaType['properties']] !== value + ) + ); + } + + const currItem = curr.widgets.find((x) => x.id === itemId); + const prevItem = prev.widgets.find((x) => x.id === itemId); + if (!currItem || !prevItem) return false; + + return ( + currItem.area.type !== prevItem.area.type || + Object.entries(currItem.area.properties).some( + ([key, value]) => + prevItem.area.properties[key as keyof AreaType['properties']] !== value + ) + ); + } + ); + } + : () => {}; + + // initialize the gridstack + useEffect(() => { + const tilesWithUnknownLocation: TileWithUnknownLocation[] = []; + initializeGridstack( + areaType, + wrapperRef, + gridRef, + itemRefs, + areaId, + items, + widgets ?? [], + isEditMode, + wrapperColumnCount, + shapeSize, + tilesWithUnknownLocation, + { + onChange, + onAdd, + } + ); + if (!configName) return; + updateConfig(configName, (prev) => ({ + ...prev, + apps: prev.apps.map((app) => { + const currentUnknownLocation = tilesWithUnknownLocation.find( + (x) => x.type === 'app' && x.id === app.id + ); + if (!currentUnknownLocation) return app; + + return { + ...app, + shape: { + ...app.shape, + [shapeSize]: { + location: { + x: currentUnknownLocation.x, + y: currentUnknownLocation.y, + }, + size: { + width: currentUnknownLocation.w, + height: currentUnknownLocation.h, + }, + }, + }, + }; + }), + widgets: prev.widgets.map((widget) => { + const currentUnknownLocation = tilesWithUnknownLocation.find( + (x) => x.type === 'widget' && x.id === widget.id + ); + if (!currentUnknownLocation) return widget; + + return { + ...widget, + shape: { + ...widget.shape, + [shapeSize]: { + location: { + x: currentUnknownLocation.x, + y: currentUnknownLocation.y, + }, + size: { + width: currentUnknownLocation.w, + height: currentUnknownLocation.h, + }, + }, + }, + }; + }), + })); + }, [items, wrapperRef.current, widgets, wrapperColumnCount]); + + return { + apps: items, + widgets: widgets ?? [], + refs: { + items: itemRefs, + wrapper: wrapperRef, + gridstack: gridRef, + }, + }; +}; diff --git a/src/components/SearchNewTabSwitch/SearchNewTabSwitch.tsx b/src/components/SearchNewTabSwitch/SearchNewTabSwitch.tsx deleted file mode 100644 index 00ebb88f4..000000000 --- a/src/components/SearchNewTabSwitch/SearchNewTabSwitch.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React, { useState } from 'react'; -import { createStyles, Switch, Group } from '@mantine/core'; -import { useTranslation } from 'next-i18next'; -import { useConfig } from '../../tools/state'; - -const useStyles = createStyles((theme) => ({ - root: { - position: 'relative', - '& *': { - cursor: 'pointer', - }, - }, - - icon: { - pointerEvents: 'none', - position: 'absolute', - zIndex: 1, - top: 3, - }, - - iconLight: { - left: 4, - color: theme.white, - }, - - iconDark: { - right: 4, - color: theme.colors.gray[6], - }, -})); - -export function SearchNewTabSwitch() { - const { config, setConfig } = useConfig(); - const { classes, cx } = useStyles(); - const defaultPosition = config?.settings?.searchNewTab ?? true; - const [openInNewTab, setOpenInNewTab] = useState(defaultPosition); - const { t } = useTranslation('settings/general/search-engine'); - const toggleOpenInNewTab = () => { - setOpenInNewTab(!openInNewTab); - setConfig({ - ...config, - settings: { - ...config.settings, - searchNewTab: !openInNewTab, - }, - }); - }; - - return ( - -
- toggleOpenInNewTab()} size="md" /> -
- {t('searchNewTab.label')} -
- ); -} diff --git a/src/components/Settings/AdvancedSettings.tsx b/src/components/Settings/AdvancedSettings.tsx deleted file mode 100644 index 3d5b7bd96..000000000 --- a/src/components/Settings/AdvancedSettings.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { TextInput, Button, Stack, Textarea } from '@mantine/core'; -import { useForm } from '@mantine/form'; -import { useTranslation } from 'next-i18next'; -import { useConfig } from '../../tools/state'; -import { ColorSelector } from './ColorSelector'; -import { OpacitySelector } from './OpacitySelector'; -import { AppCardWidthSelector } from './AppCardWidthSelector'; -import { ShadeSelector } from './ShadeSelector'; -import { GrowthSelector } from './GrowthSelector'; - -export default function TitleChanger() { - const { config, setConfig } = useConfig(); - const { t } = useTranslation('settings/customization/page-appearance'); - - const form = useForm({ - initialValues: { - title: config.settings.title, - logo: config.settings.logo, - favicon: config.settings.favicon, - background: config.settings.background, - customCSS: config.settings.customCSS, - }, - }); - - const saveChanges = (values: { - title?: string; - logo?: string; - favicon?: string; - background?: string; - customCSS?: string; - }) => { - setConfig({ - ...config, - settings: { - ...config.settings, - title: values.title, - logo: values.logo, - favicon: values.favicon, - background: values.background, - customCSS: values.customCSS, - }, - }); - }; - - return ( - -
saveChanges(values))}> - - - - - -