Merge v0.11 to dev
This commit is contained in:
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
30
package.json
30
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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"accordions": {
|
||||
"downloads": {
|
||||
"text": "Your downloads",
|
||||
"torrents": "Your Torrent downloads",
|
||||
"usenet": "Your Usenet downloads"
|
||||
},
|
||||
"others": {
|
||||
"text": "Others"
|
||||
}
|
||||
}
|
||||
}
|
||||
11
public/locales/en/layout/element-selector/selector.json
Normal file
11
public/locales/en/layout/element-selector/selector.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 <strong>not saved</strong> until you exit edit mode"
|
||||
}
|
||||
}
|
||||
3
public/locales/en/layout/mobile/drawer.json
Normal file
3
public/locales/en/layout/mobile/drawer.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"title": "{{position}} sidebar"
|
||||
}
|
||||
7
public/locales/en/layout/modals/about.json
Normal file
7
public/locales/en/layout/modals/about.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"description": "Homarr is a <strong>simple</strong> and <strong>modern</strong> 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"
|
||||
}
|
||||
65
public/locales/en/layout/modals/add-app.json
Normal file
65
public/locales/en/layout/modals/add-app.json
Normal file
@@ -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 <strong>never</strong> share them with anybody else. The official Homarr team will never ask for credentials. Make sure to <strong>store and manage your secrets safely</strong>.",
|
||||
"clear": "Clear secret",
|
||||
"save": "Save secret",
|
||||
"update": "Update secret"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
public/locales/en/layout/modals/change-position.json
Normal file
8
public/locales/en/layout/modals/change-position.json
Normal file
@@ -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}}"
|
||||
}
|
||||
10
public/locales/en/layout/tools.json
Normal file
10
public/locales/en/layout/tools.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"settings": {
|
||||
"label": "Settings"
|
||||
},
|
||||
"errors": {
|
||||
"unmappedOptions": {
|
||||
"text": "<b>Un-used parameter in configuration detected</b><br /><code>{{key}}</code>. Homarr is unable to interprete and use this parameter. To avoid any unexpected behavior, back up your configuration and correct your configuration."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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..."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"pageTitle": {
|
||||
"label": "Page Title",
|
||||
"placeholder": "Homarr"
|
||||
},
|
||||
"metaTitle": {
|
||||
"label": "Meta Title",
|
||||
"placeholder": "Homarr 🦞"
|
||||
},
|
||||
"logo": {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"title": "Module enabler"
|
||||
"title": "Enabled modules"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
241
src/components/About/AboutModal.tsx
Normal file
241
src/components/About/AboutModal.tsx
Normal file
@@ -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 (
|
||||
<Modal
|
||||
onClose={() => closeModal()}
|
||||
opened={opened}
|
||||
title={
|
||||
<Group spacing="sm">
|
||||
<Image src="/imgs/logo/logo.png" width={30} height={30} objectFit="contain" />
|
||||
<Title order={3} variant="gradient" gradient={colorGradiant}>
|
||||
{t('about')} Homarr
|
||||
</Title>
|
||||
</Group>
|
||||
}
|
||||
size="xl"
|
||||
>
|
||||
<Text mb="lg">
|
||||
<Trans i18nKey="layout/modals/about:description" />
|
||||
</Text>
|
||||
|
||||
<Table mb="lg" striped highlightOnHover withBorder>
|
||||
<tbody>
|
||||
{informations.map((item, index) => (
|
||||
<tr key={index}>
|
||||
<td>
|
||||
<Group spacing="xs">
|
||||
<ActionIcon className={classes.informationIcon} variant="default">
|
||||
{item.icon}
|
||||
</ActionIcon>
|
||||
{t(item.label)}
|
||||
</Group>
|
||||
</td>
|
||||
<td className={classes.informationTableColumn}>{item.content}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
<Divider variant="dashed" mb="md" />
|
||||
<Title order={6} mb="xs" align="center">
|
||||
{t('layout/modals/about:contact')}
|
||||
</Title>
|
||||
|
||||
<Group grow>
|
||||
<Button
|
||||
component="a"
|
||||
href="https://github.com/ajnart/homarr"
|
||||
target="_blank"
|
||||
leftIcon={<IconBrandGithub size={20} />}
|
||||
variant="default"
|
||||
>
|
||||
GitHub
|
||||
</Button>
|
||||
<Button
|
||||
component="a"
|
||||
href="https://homarr.dev/"
|
||||
target="_blank"
|
||||
leftIcon={<IconWorldWww size={20} />}
|
||||
variant="default"
|
||||
>
|
||||
Documentation
|
||||
</Button>
|
||||
<Button
|
||||
component="a"
|
||||
href="https://discord.gg/aCsmEV5RgA"
|
||||
target="_blank"
|
||||
leftIcon={<IconBrandDiscord size={20} />}
|
||||
variant="default"
|
||||
>
|
||||
Discord
|
||||
</Button>
|
||||
</Group>
|
||||
<Credits />
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
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: <IconLanguage size={20} />,
|
||||
label: 'layout/modals/about:i18n',
|
||||
content: (
|
||||
<Badge variant="gradient" gradient={colorGradiant}>
|
||||
{usedI18nNamespaces.length}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <IconVocabulary size={20} />,
|
||||
label: 'layout/modals/about:locales',
|
||||
content: (
|
||||
<Badge variant="gradient" gradient={colorGradiant}>
|
||||
{initOptions.locales.length}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
items = [
|
||||
{
|
||||
icon: <IconSchema size={20} />,
|
||||
label: 'Configuration schema version',
|
||||
content: (
|
||||
<Badge variant="gradient" gradient={colorGradiant}>
|
||||
{configVersion}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <IconFile size={20} />,
|
||||
label: 'Available configurations',
|
||||
content: (
|
||||
<Badge variant="gradient" gradient={colorGradiant}>
|
||||
{configs.length}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <IconVersions size={20} />,
|
||||
label: 'version',
|
||||
content: (
|
||||
<Group position="right">
|
||||
<Badge variant="gradient" gradient={colorGradiant}>
|
||||
{CURRENT_VERSION}
|
||||
</Badge>
|
||||
{newVersionAvailable && (
|
||||
<HoverCard shadow="md" position="top" withArrow>
|
||||
<HoverCard.Target>
|
||||
<motion.div
|
||||
initial={{ scale: 1.2 }}
|
||||
animate={{
|
||||
scale: [0.8, 1.1, 1],
|
||||
rotate: [0, 10, 0],
|
||||
}}
|
||||
transition={{ duration: 0.8, ease: 'easeInOut' }}
|
||||
>
|
||||
<Badge color="green" variant="filled">
|
||||
new: {newVersionAvailable}
|
||||
</Badge>
|
||||
</motion.div>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown>
|
||||
Version{' '}
|
||||
<b>
|
||||
<Anchor
|
||||
target="_blank"
|
||||
href={`https://github.com/ajnart/homarr/releases/tag/${newVersionAvailable}`}
|
||||
>
|
||||
{newVersionAvailable}
|
||||
</Anchor>
|
||||
</b>{' '}
|
||||
is available ! Current version: {CURRENT_VERSION}
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
)}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
...items,
|
||||
];
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
informationTableColumn: {
|
||||
textAlign: 'right',
|
||||
},
|
||||
informationIcon: {
|
||||
cursor: 'default',
|
||||
},
|
||||
}));
|
||||
@@ -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 (
|
||||
<>
|
||||
<Modal
|
||||
size="xl"
|
||||
radius="md"
|
||||
title={<Title order={3}>{t('modal.title')}</Title>}
|
||||
opened={props.opened || opened}
|
||||
onClose={() => setOpened(false)}
|
||||
>
|
||||
<AddAppShelfItemForm config={config} setConfig={setConfig} setOpened={setOpened} />
|
||||
</Modal>
|
||||
<Tooltip withinPortal label={t('actionIcon.tooltip')}>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="xl"
|
||||
color="blue"
|
||||
style={props.style}
|
||||
onClick={() => setOpened(true)}
|
||||
>
|
||||
<IconApps />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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<string[]>(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 (
|
||||
<>
|
||||
<Center mb="lg">
|
||||
<Image
|
||||
height={120}
|
||||
width={120}
|
||||
fit="contain"
|
||||
src={form.values.icon}
|
||||
alt="Placeholder"
|
||||
withPlaceholder
|
||||
/>
|
||||
</Center>
|
||||
<form
|
||||
onSubmit={form.onSubmit(() => {
|
||||
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();
|
||||
})}
|
||||
>
|
||||
<Tabs defaultValue="Options">
|
||||
<Tabs.List grow>
|
||||
<Tabs.Tab value="Options">{t('modal.tabs.options.title')}</Tabs.Tab>
|
||||
<Tabs.Tab value="Advanced Options">{t('modal.tabs.advancedOptions.title')}</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="Options">
|
||||
<Space h="sm" />
|
||||
<Stack>
|
||||
<TextInput
|
||||
required
|
||||
label={t('modal.tabs.options.form.serviceName.label')}
|
||||
placeholder={t('modal.tabs.options.form.serviceName.placeholder')}
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label={t('modal.tabs.options.form.iconUrl.label')}
|
||||
placeholder={DEFAULT_ICON}
|
||||
{...form.getInputProps('icon')}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label={t('modal.tabs.options.form.serviceUrl.label')}
|
||||
placeholder="http://localhost:7575"
|
||||
{...form.getInputProps('url')}
|
||||
/>
|
||||
<TextInput
|
||||
label={t('modal.tabs.options.form.onClickUrl.label')}
|
||||
placeholder="http://sonarr.example.com"
|
||||
{...form.getInputProps('openedUrl')}
|
||||
/>
|
||||
<Select
|
||||
label={t('modal.tabs.options.form.serviceType.label')}
|
||||
defaultValue={t('modal.tabs.options.form.serviceType.defaultValue')}
|
||||
placeholder={t('modal.tabs.options.form.serviceType.placeholder')}
|
||||
required
|
||||
searchable
|
||||
data={ServiceTypeList}
|
||||
{...form.getInputProps('type')}
|
||||
/>
|
||||
<Select
|
||||
label={t('modal.tabs.options.form.category.label')}
|
||||
data={categories}
|
||||
placeholder={t('modal.tabs.options.form.category.placeholder')}
|
||||
nothingFound={t('modal.tabs.options.form.category.nothingFound')}
|
||||
searchable
|
||||
clearable
|
||||
creatable
|
||||
onCreate={(query) => {
|
||||
const item = { value: query, label: query };
|
||||
setCategories([...InitialCategories, query]);
|
||||
return item;
|
||||
}}
|
||||
getCreateLabel={(query) =>
|
||||
t('modal.tabs.options.form.category.createLabel', {
|
||||
query,
|
||||
})
|
||||
}
|
||||
{...form.getInputProps('category')}
|
||||
/>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
{(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') && (
|
||||
<>
|
||||
<TextInput
|
||||
required
|
||||
label={t('modal.tabs.options.form.integrations.apiKey.label')}
|
||||
placeholder={t('modal.tabs.options.form.integrations.apiKey.placeholder')}
|
||||
value={form.values.apiKey}
|
||||
onChange={(event) => {
|
||||
form.setFieldValue('apiKey', event.currentTarget.value);
|
||||
}}
|
||||
error={
|
||||
form.errors.apiKey &&
|
||||
t('modal.tabs.options.form.integrations.apiKey.validation.noKey')
|
||||
}
|
||||
/>
|
||||
<Tip>
|
||||
{t('modal.tabs.options.form.integrations.apiKey.tip.text')}{' '}
|
||||
<Anchor
|
||||
target="_blank"
|
||||
weight="bold"
|
||||
style={{ fontStyle: 'inherit', fontSize: 'inherit' }}
|
||||
href={`${hostname}/${
|
||||
apiKeyPaths[form.values.type as keyof typeof apiKeyPaths]
|
||||
}`}
|
||||
>
|
||||
{t('modal.tabs.options.form.integrations.apiKey.tip.link')}
|
||||
</Anchor>
|
||||
</Tip>
|
||||
</>
|
||||
)}
|
||||
{form.values.type === 'qBittorrent' && (
|
||||
<>
|
||||
<TextInput
|
||||
label={t('modal.tabs.options.form.integrations.qBittorrent.username.label')}
|
||||
placeholder={t(
|
||||
'modal.tabs.options.form.integrations.qBittorrent.username.placeholder'
|
||||
)}
|
||||
value={form.values.username}
|
||||
onChange={(event) => {
|
||||
form.setFieldValue('username', event.currentTarget.value);
|
||||
}}
|
||||
error={
|
||||
form.errors.username &&
|
||||
t(
|
||||
'modal.tabs.options.form.integrations.qBittorrent.username.validation.invalidUsername'
|
||||
)
|
||||
}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t('modal.tabs.options.form.integrations.qBittorrent.password.label')}
|
||||
placeholder={t(
|
||||
'modal.tabs.options.form.integrations.qBittorrent.password.placeholder'
|
||||
)}
|
||||
value={form.values.password}
|
||||
onChange={(event) => {
|
||||
form.setFieldValue('password', event.currentTarget.value);
|
||||
}}
|
||||
error={
|
||||
form.errors.password &&
|
||||
t(
|
||||
'modal.tabs.options.form.integrations.qBittorrent.password.validation.invalidPassword'
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{form.values.type === 'Deluge' && (
|
||||
<>
|
||||
<PasswordInput
|
||||
label={t('modal.tabs.options.form.integrations.deluge.password.label')}
|
||||
placeholder={t(
|
||||
'modal.tabs.options.form.integrations.deluge.password.placeholder'
|
||||
)}
|
||||
value={form.values.password}
|
||||
onChange={(event) => {
|
||||
form.setFieldValue('password', event.currentTarget.value);
|
||||
}}
|
||||
error={
|
||||
form.errors.password &&
|
||||
t(
|
||||
'modal.tabs.options.form.integrations.deluge.password.validation.invalidPassword'
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{form.values.type === 'Transmission' && (
|
||||
<>
|
||||
<TextInput
|
||||
label={t('modal.tabs.options.form.integrations.transmission.username.label')}
|
||||
placeholder={t(
|
||||
'modal.tabs.options.form.integrations.transmission.username.placeholder'
|
||||
)}
|
||||
value={form.values.username}
|
||||
onChange={(event) => {
|
||||
form.setFieldValue('username', event.currentTarget.value);
|
||||
}}
|
||||
error={
|
||||
form.errors.username &&
|
||||
t(
|
||||
'modal.tabs.options.form.integrations.transmission.username.validation.invalidUsername'
|
||||
)
|
||||
}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t('modal.tabs.options.form.integrations.transmission.password.label')}
|
||||
placeholder={t(
|
||||
'modal.tabs.options.form.integrations.transmission.password.placeholder'
|
||||
)}
|
||||
value={form.values.password}
|
||||
onChange={(event) => {
|
||||
form.setFieldValue('password', event.currentTarget.value);
|
||||
}}
|
||||
error={
|
||||
form.errors.password &&
|
||||
t(
|
||||
'modal.tabs.options.form.integrations.transmission.password.validation.invalidPassword'
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{form.values.type === 'NZBGet' && (
|
||||
<>
|
||||
<TextInput
|
||||
label={t('modal.tabs.options.form.integrations.nzbget.username.label')}
|
||||
placeholder={t(
|
||||
'modal.tabs.options.form.integrations.nzbget.username.placeholder'
|
||||
)}
|
||||
value={form.values.username}
|
||||
onChange={(event) => {
|
||||
form.setFieldValue('username', event.currentTarget.value);
|
||||
}}
|
||||
error={
|
||||
form.errors.username &&
|
||||
t(
|
||||
'modal.tabs.options.form.integrations.nzbget.username.validation.invalidUsername'
|
||||
)
|
||||
}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t('modal.tabs.options.form.integrations.nzbget.password.label')}
|
||||
placeholder={t(
|
||||
'modal.tabs.options.form.integrations.nzbget.password.placeholder'
|
||||
)}
|
||||
value={form.values.password}
|
||||
onChange={(event) => {
|
||||
form.setFieldValue('password', event.currentTarget.value);
|
||||
}}
|
||||
error={
|
||||
form.errors.password &&
|
||||
t(
|
||||
'modal.tabs.options.form.integrations.nzbget.password.validation.invalidPassword'
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="Advanced Options">
|
||||
<Space h="sm" />
|
||||
<Stack>
|
||||
<Switch
|
||||
label="Ping service"
|
||||
defaultChecked={form.values.ping}
|
||||
{...form.getInputProps('ping')}
|
||||
/>
|
||||
<Switch
|
||||
label={t('modal.tabs.advancedOptions.form.openServiceInNewTab.label')}
|
||||
defaultChecked={form.values.newTab}
|
||||
{...form.getInputProps('newTab')}
|
||||
/>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
<Group grow position="center" mt="xl">
|
||||
<Button type="submit">
|
||||
{props.message ?? t('modal.tabs.advancedOptions.form.buttons.submit.content')}
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={config.services}>
|
||||
<Grid gutter="lg" grow={config.settings.grow}>
|
||||
{filtered.map((service) => (
|
||||
<Grid.Col key={service.id} span="content">
|
||||
<SortableItem service={service} key={service.id} id={service.id}>
|
||||
<AppShelfItem service={service} />
|
||||
</SortableItem>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</SortableContext>
|
||||
<DragOverlay
|
||||
style={{
|
||||
// Add a shadow to the drag overlay
|
||||
boxShadow: '0 0 10px rgba(0, 0, 0, 0.5)',
|
||||
}}
|
||||
>
|
||||
{activeId ? (
|
||||
<AppShelfItem service={config.services.find((e) => e.id === activeId)} id={activeId} />
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Accordion
|
||||
variant="separated"
|
||||
radius="lg"
|
||||
order={2}
|
||||
multiple
|
||||
value={toggledCategories}
|
||||
onChange={(state) => {
|
||||
setToggledCategories([...state]);
|
||||
}}
|
||||
>
|
||||
{categoryList.map((category, idx) => (
|
||||
<Accordion.Item
|
||||
style={{
|
||||
background: `rgba(${colorScheme === 'dark' ? '32, 33, 35,' : '255, 255, 255,'} \
|
||||
${(config.settings.appOpacity || 100) / 100}`,
|
||||
borderColor: `rgba(${colorScheme === 'dark' ? '32, 33, 35,' : '233, 236, 239,'} \
|
||||
${(config.settings.appOpacity || 100) / 100}`,
|
||||
}}
|
||||
key={category}
|
||||
value={idx.toString()}
|
||||
>
|
||||
<Accordion.Control>
|
||||
<Title
|
||||
order={5}
|
||||
style={{
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{category}
|
||||
</Title>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>{getItems(category)}</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
))}
|
||||
</Accordion>
|
||||
{getItems()}
|
||||
<ModuleWrapper mt="xl" module={TorrentsModule} />
|
||||
<ModuleWrapper mt="xl" module={UsenetModule} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppShelf;
|
||||
@@ -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 (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [0.9, 1.06, 1],
|
||||
rotate: [0, 5, 0],
|
||||
}}
|
||||
transition={{ duration: 0.6, type: 'spring', damping: 10, mass: 0.75, stiffness: 100 }}
|
||||
key={service.name}
|
||||
onHoverStart={() => {
|
||||
setHovering(true);
|
||||
}}
|
||||
onHoverEnd={() => {
|
||||
setHovering(false);
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
withBorder
|
||||
radius="lg"
|
||||
shadow="md"
|
||||
className={classes.item}
|
||||
style={{
|
||||
// Use the grab cursor when hovering over the card
|
||||
cursor: hovering ? 'grab' : 'auto',
|
||||
background: `rgba(${colorScheme === 'dark' ? '37, 38, 43,' : '255, 255, 255,'} \
|
||||
${(config.settings.appOpacity || 100) / 100}`,
|
||||
borderColor: `rgba(${colorScheme === 'dark' ? '37, 38, 43,' : '233, 236, 239,'} \
|
||||
${(config.settings.appOpacity || 100) / 100}`,
|
||||
}}
|
||||
>
|
||||
<Card.Section>
|
||||
<Anchor
|
||||
target={service.newTab === false ? '_top' : '_blank'}
|
||||
href={service.openedUrl ? service.openedUrl : service.url}
|
||||
style={{ color: 'inherit', fontStyle: 'inherit', fontSize: 'inherit' }}
|
||||
>
|
||||
<Text mt="sm" align="center" lineClamp={1} weight={550}>
|
||||
{service.name}
|
||||
</Text>
|
||||
</Anchor>
|
||||
<motion.div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 15,
|
||||
right: 15,
|
||||
alignSelf: 'flex-end',
|
||||
}}
|
||||
animate={{
|
||||
opacity: hovering ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<AppShelfMenu service={service} />
|
||||
</motion.div>
|
||||
</Card.Section>
|
||||
<Card.Section>
|
||||
<Center>
|
||||
<AspectRatio
|
||||
ratio={3 / 5}
|
||||
m="lg"
|
||||
style={{
|
||||
height: 75 * ((config.settings.appCardWidth ?? 1) * 1.2),
|
||||
width: 75 * ((config.settings.appCardWidth ?? 1) * 2),
|
||||
}}
|
||||
>
|
||||
<motion.i
|
||||
whileHover={{
|
||||
scale: 1.1,
|
||||
}}
|
||||
>
|
||||
<Anchor
|
||||
href={service.openedUrl ?? service.url}
|
||||
target={service.newTab === false ? '_top' : '_blank'}
|
||||
>
|
||||
<Image
|
||||
styles={{ root: { cursor: 'pointer' } }}
|
||||
width={75 * ((config.settings.appCardWidth ?? 1) * 1.2)}
|
||||
height={75 * ((config.settings.appCardWidth ?? 1) * 1.2)}
|
||||
src={service.icon}
|
||||
fit="contain"
|
||||
/>
|
||||
</Anchor>
|
||||
</motion.i>
|
||||
</AspectRatio>
|
||||
{service.ping !== false && <PingComponent url={service.url} status={service.status} />}
|
||||
</Center>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<Modal
|
||||
size="xl"
|
||||
radius="md"
|
||||
opened={props.opened || opened}
|
||||
onClose={() => setOpened(false)}
|
||||
title={t('modal.title')}
|
||||
>
|
||||
<AddAppShelfItemForm
|
||||
config={config}
|
||||
setConfig={setConfig}
|
||||
setOpened={setOpened}
|
||||
{...service}
|
||||
message={t('modal.buttons.save')}
|
||||
/>
|
||||
</Modal>
|
||||
<Menu
|
||||
withinPortal
|
||||
width={150}
|
||||
shadow="xl"
|
||||
withArrow
|
||||
radius="md"
|
||||
position="right"
|
||||
styles={{
|
||||
dropdown: {
|
||||
// Add shadow and elevation to the body
|
||||
boxShadow: '0 0 14px 14px rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon style={{}}>
|
||||
<IconMenu />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{t('menu.labels.settings')}</Menu.Label>
|
||||
<Menu.Item color={secondaryColor} icon={<Edit />} onClick={() => setOpened(true)}>
|
||||
{t('menu.actions.edit')}
|
||||
</Menu.Item>
|
||||
<Menu.Label>{t('menu.labels.dangerZone')}</Menu.Label>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
onClick={(e: any) => {
|
||||
setConfig({
|
||||
...config,
|
||||
services: config.services.filter((s) => s.id !== service.id),
|
||||
});
|
||||
showNotification({
|
||||
autoClose: 5000,
|
||||
title: (
|
||||
<Text>
|
||||
Service <b>{service.name}</b> removed successfully!
|
||||
</Text>
|
||||
),
|
||||
color: 'green',
|
||||
icon: <Check />,
|
||||
message: undefined,
|
||||
});
|
||||
}}
|
||||
icon={<Trash />}
|
||||
>
|
||||
{t('menu.actions.delete')}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Group>
|
||||
{service.icon && <Avatar src={service.icon} />}
|
||||
<Text>{service.label}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"Jellyseerr": "settings",
|
||||
"Overseerr": "settings",
|
||||
"Sonarr": "settings/general",
|
||||
"Radarr": "settings/general",
|
||||
"Readarr": "settings/general",
|
||||
"Lidarr": "settings/general",
|
||||
"Sabnzbd": "sabnzbd/config/general"
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as AppShelf } from './AppShelf';
|
||||
export * from './AppShelfItem';
|
||||
@@ -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 (
|
||||
<Group>
|
||||
<Switch
|
||||
checked={colorScheme === 'dark'}
|
||||
onChange={() => toggleColorScheme()}
|
||||
size="md"
|
||||
onLabel={<IconSun color={theme.white} size={20} stroke={1.5} />}
|
||||
offLabel={<IconMoonStars color={theme.colors.gray[6]} size={20} stroke={1.5} />}
|
||||
/>
|
||||
{t('label', {
|
||||
theme: colorScheme === 'dark' ? 'light' : 'dark',
|
||||
})}
|
||||
<Group spacing={2}>
|
||||
<Kbd>Ctrl</Kbd>+<Kbd>J</Kbd>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.2, rotate: 90 }}
|
||||
whileTap={{
|
||||
scale: 0.8,
|
||||
rotate: -90,
|
||||
borderRadius: '100%',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
onClick={() => toggleColorScheme()}
|
||||
sx={(theme) => ({
|
||||
cursor: 'pointer',
|
||||
color: theme.colorScheme === 'dark' ? theme.colors.yellow[4] : theme.colors.blue[6],
|
||||
})}
|
||||
>
|
||||
{colorScheme === 'dark' ? <Sun size={24} /> : <MoonStars size={24} />}
|
||||
</Box>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -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<string[]>([]);
|
||||
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 (
|
||||
<Tooltip label={"Loading your configs. This doesn't load in vercel."}>
|
||||
<Center>
|
||||
@@ -23,23 +41,35 @@ export default function ConfigChanger() {
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
// return <Select data={[{ value: '1', label: '1' },]} onChange={(e) => console.log(e)} value="1" />;
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={t('configSelect.label')}
|
||||
value={value}
|
||||
defaultValue={config.name}
|
||||
onChange={(e) => {
|
||||
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
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<Select
|
||||
label={t('configSelect.label')}
|
||||
value={activeConfig}
|
||||
onChange={onConfigChange}
|
||||
data={configs}
|
||||
/>
|
||||
<Dialog
|
||||
position={{ top: 0, left: 0 }}
|
||||
unstyled
|
||||
opened={isRefreshing}
|
||||
onClose={() => toggle()}
|
||||
size="lg"
|
||||
radius="md"
|
||||
>
|
||||
<Notification loading title={t('configSelect.loadingNew')} radius="md" disallowClose>
|
||||
{t('configSelect.pleaseWait')}
|
||||
</Notification>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const useConfigsQuery = () =>
|
||||
useQuery({
|
||||
queryKey: ['config/get-all'],
|
||||
queryFn: fetchConfigs,
|
||||
});
|
||||
|
||||
const fetchConfigs = async () => (await (await fetch('/api/configs')).json()) as string[];
|
||||
|
||||
@@ -1,79 +1,97 @@
|
||||
import { Group, Text, useMantineTheme } from '@mantine/core';
|
||||
import { IconX as X, IconCheck as Check, IconX, IconPhoto, IconUpload } from '@tabler/icons';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { setCookie } from 'cookies-next';
|
||||
import { Group, Stack, Text, Title, useMantineTheme } from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconCheck as Check, IconPhoto, IconUpload, IconX, IconX as X } from '@tabler/icons';
|
||||
import Consola from 'consola';
|
||||
import { setCookie } from 'cookies-next';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useConfig } from '../../tools/state';
|
||||
import { useConfigStore } from '../../config/store';
|
||||
import { migrateConfig } from '../../tools/config/migrateConfig';
|
||||
import { Config } from '../../tools/types';
|
||||
import { migrateToIdConfig } from '../../tools/migrate';
|
||||
import { ConfigType } from '../../types/config';
|
||||
|
||||
export default function LoadConfigComponent(props: any) {
|
||||
const { setConfig } = useConfig();
|
||||
export const LoadConfigComponent = () => {
|
||||
const { addConfig } = useConfigStore();
|
||||
const theme = useMantineTheme();
|
||||
const { t } = useTranslation('settings/general/config-changer');
|
||||
|
||||
return (
|
||||
<Dropzone.FullScreen
|
||||
onDrop={(files) => {
|
||||
files[0].text().then((e) => {
|
||||
try {
|
||||
JSON.parse(e) as Config;
|
||||
} catch (e) {
|
||||
showNotification({
|
||||
autoClose: 5000,
|
||||
title: <Text>{t('dropzone.notifications.invalidConfig.title')}</Text>,
|
||||
color: 'red',
|
||||
icon: <X />,
|
||||
message: t('dropzone.notifications.invalidConfig.message'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const newConfig: Config = JSON.parse(e);
|
||||
onDrop={async (files) => {
|
||||
const fileName = files[0].name.replaceAll('.json', '');
|
||||
const fileText = await files[0].text();
|
||||
|
||||
try {
|
||||
JSON.parse(fileText) as ConfigType;
|
||||
} catch (e) {
|
||||
showNotification({
|
||||
autoClose: 5000,
|
||||
radius: 'md',
|
||||
title: (
|
||||
<Text>
|
||||
{t('dropzone.notifications.loadedSuccessfully.title', {
|
||||
configName: newConfig.name,
|
||||
})}
|
||||
</Text>
|
||||
),
|
||||
color: 'green',
|
||||
icon: <Check />,
|
||||
message: undefined,
|
||||
title: <Text>{t('dropzone.notifications.invalidConfig.title')}</Text>,
|
||||
color: 'red',
|
||||
icon: <X />,
|
||||
message: t('dropzone.notifications.invalidConfig.message'),
|
||||
});
|
||||
setCookie('config-name', newConfig.name, {
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
sameSite: 'strict',
|
||||
});
|
||||
const migratedConfig = migrateToIdConfig(newConfig);
|
||||
setConfig(migratedConfig);
|
||||
return;
|
||||
}
|
||||
|
||||
let newConfig: ConfigType = JSON.parse(fileText);
|
||||
|
||||
if (!newConfig.schemaVersion) {
|
||||
Consola.warn(
|
||||
'a legacy configuration schema was deteced and migrated to the current schema'
|
||||
);
|
||||
const oldConfig = JSON.parse(fileText) as Config;
|
||||
newConfig = migrateConfig(oldConfig);
|
||||
}
|
||||
|
||||
await addConfig(fileName, newConfig, true);
|
||||
showNotification({
|
||||
autoClose: 5000,
|
||||
radius: 'md',
|
||||
title: (
|
||||
<Text>
|
||||
{t('dropzone.notifications.loadedSuccessfully.title', {
|
||||
configName: fileName,
|
||||
})}
|
||||
</Text>
|
||||
),
|
||||
color: 'green',
|
||||
icon: <Check />,
|
||||
message: undefined,
|
||||
});
|
||||
setCookie('config-name', fileName, {
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
sameSite: 'strict',
|
||||
});
|
||||
}}
|
||||
accept={['application/json']}
|
||||
>
|
||||
<Group position="center" spacing="xl" style={{ minHeight: 220, pointerEvents: 'none' }}>
|
||||
<Dropzone.Accept>
|
||||
<Text size="xl" inline>
|
||||
<Stack align="center">
|
||||
<IconUpload
|
||||
size={50}
|
||||
stroke={1.5}
|
||||
color={theme.colors[theme.primaryColor][theme.colorScheme === 'dark' ? 4 : 6]}
|
||||
/>
|
||||
{t('dropzone.accept.text')}
|
||||
</Text>
|
||||
<Title>{t('dropzone.accept.title')}</Title>
|
||||
<Text size="xl" inline>
|
||||
{t('dropzone.accept.text')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Dropzone.Accept>
|
||||
<Dropzone.Reject>
|
||||
<Text size="xl" inline>
|
||||
<Stack align="center">
|
||||
<IconX
|
||||
size={50}
|
||||
stroke={1.5}
|
||||
color={theme.colors.red[theme.colorScheme === 'dark' ? 4 : 6]}
|
||||
/>
|
||||
{t('dropzone.reject.text')}
|
||||
</Text>
|
||||
<Title>{t('dropzone.reject.title')}</Title>
|
||||
<Text size="xl" inline>
|
||||
{t('dropzone.reject.text')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Dropzone.Reject>
|
||||
<Dropzone.Idle>
|
||||
<IconPhoto size={50} stroke={1.5} />
|
||||
@@ -81,4 +99,4 @@ export default function LoadConfigComponent(props: any) {
|
||||
</Group>
|
||||
</Dropzone.FullScreen>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { Button, Group, Modal, TextInput } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import axios from 'axios';
|
||||
import fileDownload from 'js-file-download';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import {
|
||||
IconCheck as Check,
|
||||
IconDownload as Download,
|
||||
IconPlus as Plus,
|
||||
IconTrash as Trash,
|
||||
IconX as X,
|
||||
} from '@tabler/icons';
|
||||
import { useConfig } from '../../tools/state';
|
||||
|
||||
export default function SaveConfigComponent(props: any) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const { config, setConfig } = useConfig();
|
||||
const { t } = useTranslation('settings/general/config-changer');
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
configName: config.name,
|
||||
},
|
||||
});
|
||||
function onClick(e: any) {
|
||||
if (config) {
|
||||
fileDownload(JSON.stringify(config, null, '\t'), `${config.name}.json`);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Group spacing="xs">
|
||||
<Modal radius="md" opened={opened} onClose={() => setOpened(false)} title={t('modal.title')}>
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
setConfig({ ...config, name: values.configName });
|
||||
setOpened(false);
|
||||
showNotification({
|
||||
title: t('modal.events.configSaved.title'),
|
||||
icon: <Check />,
|
||||
color: 'green',
|
||||
autoClose: 1500,
|
||||
radius: 'md',
|
||||
message: t('modal.events.configSaved.message', { configName: values.configName }),
|
||||
});
|
||||
})}
|
||||
>
|
||||
<TextInput
|
||||
required
|
||||
label={t('modal.form.configName.label')}
|
||||
placeholder={t('modal.form.configName.placeholder')}
|
||||
{...form.getInputProps('configName')}
|
||||
/>
|
||||
<Group position="right" mt="md">
|
||||
<Button type="submit">{t('modal.form.submitButton')}</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Modal>
|
||||
<Button size="xs" leftIcon={<Download />} variant="outline" onClick={onClick}>
|
||||
{t('buttons.download')}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
leftIcon={<Trash />}
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
axios
|
||||
.delete(`/api/configs/${config.name}`)
|
||||
.then(() => {
|
||||
showNotification({
|
||||
title: t('buttons.delete.notifications.deleted.title'),
|
||||
icon: <Check />,
|
||||
color: 'green',
|
||||
autoClose: 1500,
|
||||
radius: 'md',
|
||||
message: t('buttons.delete.notifications.deleted.message'),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
showNotification({
|
||||
title: t('buttons.delete.notifications.deleteFailed.title'),
|
||||
icon: <X />,
|
||||
color: 'red',
|
||||
autoClose: 1500,
|
||||
radius: 'md',
|
||||
message: t('buttons.delete.notifications.deleteFailed.message'),
|
||||
});
|
||||
});
|
||||
setConfig({ ...config, name: 'default' });
|
||||
}}
|
||||
>
|
||||
{t('buttons.delete.text')}
|
||||
</Button>
|
||||
<Button size="xs" leftIcon={<Plus />} variant="outline" onClick={() => setOpened(true)}>
|
||||
{t('buttons.saveCopy')}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
16
src/components/Dashboard/Dashboard.tsx
Normal file
16
src/components/Dashboard/Dashboard.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { MobileRibbons } from './Mobile/Ribbon/MobileRibbon';
|
||||
import { DashboardDetailView } from './Views/DetailView';
|
||||
import { DashboardEditView } from './Views/EditView';
|
||||
import { useEditModeStore } from './Views/useEditModeStore';
|
||||
|
||||
export const Dashboard = () => {
|
||||
const isEditMode = useEditModeStore((x) => x.enabled);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The following elemens are splitted because gridstack doesn't reinitialize them when using same item. */}
|
||||
{isEditMode ? <DashboardEditView /> : <DashboardDetailView />}
|
||||
<MobileRibbons />
|
||||
</>
|
||||
);
|
||||
};
|
||||
87
src/components/Dashboard/Mobile/Ribbon/MobileRibbon.tsx
Normal file
87
src/components/Dashboard/Mobile/Ribbon/MobileRibbon.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { ActionIcon, createStyles, Space } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconChevronLeft, IconChevronRight } from '@tabler/icons';
|
||||
import { useConfigContext } from '../../../../config/provider';
|
||||
import { useScreenLargerThan } from '../../../../hooks/useScreenLargerThan';
|
||||
import { MobileRibbonSidebarDrawer } from './MobileRibbonSidebarDrawer';
|
||||
|
||||
export const MobileRibbons = () => {
|
||||
const { classes, cx } = useStyles();
|
||||
const { config } = useConfigContext();
|
||||
const [openedRight, rightSidebar] = useDisclosure(false);
|
||||
const [openedLeft, leftSidebar] = useDisclosure(false);
|
||||
const screenLargerThanMd = useScreenLargerThan('md');
|
||||
|
||||
if (screenLargerThanMd || !config) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const layoutSettings = config.settings.customization.layout;
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
{layoutSettings.enabledLeftSidebar ? (
|
||||
<>
|
||||
<ActionIcon
|
||||
onClick={leftSidebar.open}
|
||||
className={cx(classes.button, classes.removeBorderLeft)}
|
||||
variant="default"
|
||||
>
|
||||
<IconChevronRight />
|
||||
</ActionIcon>
|
||||
<MobileRibbonSidebarDrawer
|
||||
onClose={leftSidebar.close}
|
||||
opened={openedLeft}
|
||||
location="left"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Space />
|
||||
)}
|
||||
|
||||
{layoutSettings.enabledRightSidebar ? (
|
||||
<>
|
||||
<ActionIcon
|
||||
onClick={rightSidebar.open}
|
||||
className={cx(classes.button, classes.removeBorderRight)}
|
||||
variant="default"
|
||||
>
|
||||
<IconChevronLeft />
|
||||
</ActionIcon>
|
||||
<MobileRibbonSidebarDrawer
|
||||
onClose={rightSidebar.close}
|
||||
opened={openedRight}
|
||||
location="right"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
root: {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
button: {
|
||||
height: 100,
|
||||
width: 36,
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
removeBorderLeft: {
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
},
|
||||
removeBorderRight: {
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Drawer, Title } from '@mantine/core';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { DashboardSidebar } from '../../Wrappers/Sidebar/Sidebar';
|
||||
|
||||
interface MobileRibbonSidebarDrawerProps {
|
||||
onClose: () => void;
|
||||
opened: boolean;
|
||||
location: 'left' | 'right';
|
||||
}
|
||||
|
||||
export const MobileRibbonSidebarDrawer = ({
|
||||
location,
|
||||
...props
|
||||
}: MobileRibbonSidebarDrawerProps) => {
|
||||
const { t } = useTranslation('layout/mobile/drawer');
|
||||
return (
|
||||
<Drawer
|
||||
padding={10}
|
||||
position={location}
|
||||
title={<Title order={4}>{t('title', { position: location })}</Title>}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
styles={{
|
||||
title: {
|
||||
width: '100%',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<DashboardSidebar location={location} isGridstackReady />
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { SelectItem } from '@mantine/core';
|
||||
import { closeModal, ContextModalProps } from '@mantine/modals';
|
||||
import { useConfigContext } from '../../../../config/provider';
|
||||
import { useConfigStore } from '../../../../config/store';
|
||||
import { AppType } from '../../../../types/app';
|
||||
import { useGridstackStore, useWrapperColumnCount } from '../../Wrappers/gridstack/store';
|
||||
import { ChangePositionModal } from './ChangePositionModal';
|
||||
|
||||
type ChangeAppPositionModalInnerProps = {
|
||||
app: AppType;
|
||||
};
|
||||
|
||||
export const ChangeAppPositionModal = ({
|
||||
id,
|
||||
context,
|
||||
innerProps,
|
||||
}: ContextModalProps<ChangeAppPositionModalInnerProps>) => {
|
||||
const { name: configName } = useConfigContext();
|
||||
const updateConfig = useConfigStore((x) => x.updateConfig);
|
||||
const shapeSize = useGridstackStore((x) => x.currentShapeSize);
|
||||
|
||||
if (!shapeSize) return null;
|
||||
|
||||
const handleSubmit = (x: number, y: number, width: number, height: number) => {
|
||||
if (!configName) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateConfig(
|
||||
configName,
|
||||
(previousConfig) => ({
|
||||
...previousConfig,
|
||||
apps: [
|
||||
...previousConfig.apps.filter((x) => x.id !== innerProps.app.id),
|
||||
{
|
||||
...innerProps.app,
|
||||
shape: {
|
||||
...innerProps.app.shape,
|
||||
[shapeSize]: { location: { x, y }, size: { width, height } },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
true
|
||||
);
|
||||
context.closeModal(id);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
closeModal(id);
|
||||
};
|
||||
|
||||
const widthData = useWidthData();
|
||||
const heightData = useHeightData();
|
||||
|
||||
return (
|
||||
<ChangePositionModal
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
widthData={widthData}
|
||||
heightData={heightData}
|
||||
initialX={innerProps.app.shape[shapeSize]?.location.x}
|
||||
initialY={innerProps.app.shape[shapeSize]?.location.y}
|
||||
initialWidth={innerProps.app.shape[shapeSize]?.size.width}
|
||||
initialHeight={innerProps.app.shape[shapeSize]?.size.height}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const useHeightData = (): SelectItem[] => {
|
||||
const mainAreaWidth = useGridstackStore((x) => x.mainAreaWidth);
|
||||
const wrapperColumnCount = useWrapperColumnCount();
|
||||
|
||||
return Array.from(Array(11).keys()).map((n) => {
|
||||
const index = n + 1;
|
||||
return {
|
||||
value: index.toString(),
|
||||
label: `${Math.floor(index * (mainAreaWidth! / wrapperColumnCount!))}px`,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const useWidthData = (): SelectItem[] => {
|
||||
const wrapperColumnCount = useWrapperColumnCount();
|
||||
return Array.from(Array(wrapperColumnCount!).keys()).map((n) => {
|
||||
const index = n + 1;
|
||||
return {
|
||||
value: index.toString(),
|
||||
// eslint-disable-next-line no-mixed-operators
|
||||
label: `${((100 / wrapperColumnCount!) * index).toFixed(2)}%`,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Button, Flex, Grid, NumberInput, Select, SelectItem } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useConfigContext } from '../../../../config/provider';
|
||||
|
||||
interface ChangePositionModalProps {
|
||||
initialX?: number;
|
||||
initialY?: number;
|
||||
initialWidth?: number;
|
||||
initialHeight?: number;
|
||||
widthData: SelectItem[];
|
||||
heightData: SelectItem[];
|
||||
onSubmit: (x: number, y: number, width: number, height: number) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const ChangePositionModal = ({
|
||||
initialX,
|
||||
initialY,
|
||||
initialWidth,
|
||||
initialHeight,
|
||||
widthData,
|
||||
heightData,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: ChangePositionModalProps) => {
|
||||
const { name: configName } = useConfigContext();
|
||||
|
||||
const form = useForm<FormType>({
|
||||
initialValues: {
|
||||
x: initialX ?? null,
|
||||
y: initialY ?? null,
|
||||
width: initialWidth?.toString() ?? '',
|
||||
height: initialHeight?.toString() ?? '',
|
||||
},
|
||||
validateInputOnChange: true,
|
||||
validateInputOnBlur: true,
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!configName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const width = parseInt(form.values.width, 10);
|
||||
const height = parseInt(form.values.height, 10);
|
||||
|
||||
if (!form.values.x || !form.values.y || Number.isNaN(width) || Number.isNaN(height)) return;
|
||||
|
||||
onSubmit(form.values.x, form.values.y, width, height);
|
||||
};
|
||||
|
||||
const { t } = useTranslation(['layout/modals/change-position', 'common']);
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Grid>
|
||||
<Grid.Col xs={12} md={6}>
|
||||
<NumberInput
|
||||
max={99}
|
||||
min={0}
|
||||
label={t('xPosition')}
|
||||
description={t('layout/modals/change-position:zeroOrHigher')}
|
||||
{...form.getInputProps('x')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col xs={12} md={6}>
|
||||
<NumberInput
|
||||
max={99}
|
||||
min={0}
|
||||
label={t('layout/modals/change-position:yPosition')}
|
||||
description={t('layout/modals/change-position:zeroOrHigher')}
|
||||
{...form.getInputProps('y')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col xs={12} md={6}>
|
||||
<Select
|
||||
data={widthData}
|
||||
max={24}
|
||||
min={1}
|
||||
label={t('layout/modals/change-position:width')}
|
||||
description={t('layout/modals/change-position:betweenXandY', {
|
||||
min: widthData.at(0)?.label,
|
||||
max: widthData.at(-1)?.label,
|
||||
})}
|
||||
{...form.getInputProps('width')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col xs={12} md={6}>
|
||||
<Select
|
||||
data={heightData}
|
||||
max={24}
|
||||
min={1}
|
||||
label={t('layout/modals/change-position:height')}
|
||||
description={t('layout/modals/change-position:betweenXandY', {
|
||||
min: heightData.at(0)?.label,
|
||||
max: heightData.at(-1)?.label,
|
||||
})}
|
||||
{...form.getInputProps('height')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Flex justify="end" gap="sm" mt="md">
|
||||
<Button onClick={() => onCancel()} variant="light" color="gray">
|
||||
{t('common:cancel')}
|
||||
</Button>
|
||||
<Button type="submit">{t('common:save')}</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = {
|
||||
x: number | null;
|
||||
y: number | null;
|
||||
width: string;
|
||||
height: string;
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { SelectItem } from '@mantine/core';
|
||||
import { closeModal, ContextModalProps } from '@mantine/modals';
|
||||
import { useConfigContext } from '../../../../config/provider';
|
||||
import { useConfigStore } from '../../../../config/store';
|
||||
import widgets from '../../../../widgets';
|
||||
import { WidgetChangePositionModalInnerProps } from '../../Tiles/Widgets/WidgetsMenu';
|
||||
import { useGridstackStore, useWrapperColumnCount } from '../../Wrappers/gridstack/store';
|
||||
import { ChangePositionModal } from './ChangePositionModal';
|
||||
|
||||
export const ChangeWidgetPositionModal = ({
|
||||
context,
|
||||
id,
|
||||
innerProps,
|
||||
}: ContextModalProps<WidgetChangePositionModalInnerProps>) => {
|
||||
const { name: configName } = useConfigContext();
|
||||
const updateConfig = useConfigStore((x) => x.updateConfig);
|
||||
const shapeSize = useGridstackStore((x) => x.currentShapeSize);
|
||||
|
||||
if (shapeSize === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSubmit = (x: number, y: number, width: number, height: number) => {
|
||||
if (!configName) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateConfig(
|
||||
configName,
|
||||
(prev) => {
|
||||
const currentWidget = prev.widgets.find((x) => x.id === innerProps.widgetId);
|
||||
currentWidget!.shape[shapeSize] = {
|
||||
location: {
|
||||
x,
|
||||
y,
|
||||
},
|
||||
size: {
|
||||
height,
|
||||
width,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...prev,
|
||||
widgets: [...prev.widgets.filter((x) => x.id !== innerProps.widgetId), currentWidget!],
|
||||
};
|
||||
},
|
||||
true
|
||||
);
|
||||
context.closeModal(id);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
closeModal(id);
|
||||
};
|
||||
|
||||
const widthData = useWidthData(innerProps.widgetId);
|
||||
const heightData = useHeightData(innerProps.widgetId);
|
||||
|
||||
return (
|
||||
<ChangePositionModal
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
heightData={heightData}
|
||||
widthData={widthData}
|
||||
initialX={innerProps.widget.shape[shapeSize]?.location.x}
|
||||
initialY={innerProps.widget.shape[shapeSize]?.location.y}
|
||||
initialWidth={innerProps.widget.shape[shapeSize]?.size.width}
|
||||
initialHeight={innerProps.widget.shape[shapeSize]?.size.height}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const useWidthData = (integration: string): SelectItem[] => {
|
||||
const wrapperColumnCount = useWrapperColumnCount();
|
||||
const currentWidget = widgets[integration as keyof typeof widgets];
|
||||
if (!currentWidget) return [];
|
||||
const offset = currentWidget.gridstack.minWidth ?? 2;
|
||||
const length =
|
||||
(currentWidget.gridstack.maxWidth > wrapperColumnCount!
|
||||
? wrapperColumnCount!
|
||||
: currentWidget.gridstack.maxWidth) - offset;
|
||||
return Array.from({ length: length + 1 }, (_, i) => i + offset).map((n) => ({
|
||||
value: n.toString(),
|
||||
// eslint-disable-next-line no-mixed-operators
|
||||
label: `${((100 / wrapperColumnCount!) * n).toFixed(2)}%`,
|
||||
}));
|
||||
};
|
||||
|
||||
const useHeightData = (integration: string): SelectItem[] => {
|
||||
const mainAreaWidth = useGridstackStore((x) => x.mainAreaWidth);
|
||||
const wrapperColumnCount = useWrapperColumnCount();
|
||||
|
||||
const currentWidget = widgets[integration as keyof typeof widgets];
|
||||
if (!currentWidget) return [];
|
||||
const offset = currentWidget.gridstack.minHeight ?? 2;
|
||||
const length = (currentWidget.gridstack.maxHeight ?? 12) - offset;
|
||||
return Array.from({ length }, (_, i) => i + offset).map((n) => ({
|
||||
value: n.toString(),
|
||||
label: `${(mainAreaWidth! / wrapperColumnCount!) * n}px`,
|
||||
}));
|
||||
};
|
||||
221
src/components/Dashboard/Modals/EditAppModal/EditAppModal.tsx
Normal file
221
src/components/Dashboard/Modals/EditAppModal/EditAppModal.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { Alert, Button, Group, Stack, Tabs, Text, ThemeIcon } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { ContextModalProps } from '@mantine/modals';
|
||||
import {
|
||||
IconAccessPoint,
|
||||
IconAdjustments,
|
||||
IconAlertTriangle,
|
||||
IconBrush,
|
||||
IconClick,
|
||||
IconPlug,
|
||||
} from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useState } from 'react';
|
||||
import { useConfigContext } from '../../../../config/provider';
|
||||
import { useConfigStore } from '../../../../config/store';
|
||||
import { AppType } from '../../../../types/app';
|
||||
import { useEditModeStore } from '../../Views/useEditModeStore';
|
||||
import { AppearanceTab } from './Tabs/AppereanceTab/AppereanceTab';
|
||||
import { BehaviourTab } from './Tabs/BehaviourTab/BehaviourTab';
|
||||
import { GeneralTab } from './Tabs/GeneralTab/GeneralTab';
|
||||
import { IntegrationTab } from './Tabs/IntegrationTab/IntegrationTab';
|
||||
import { NetworkTab } from './Tabs/NetworkTab/NetworkTab';
|
||||
import { DebouncedAppIcon } from './Tabs/Shared/DebouncedAppIcon';
|
||||
import { EditAppModalTab } from './Tabs/type';
|
||||
|
||||
const appUrlRegex =
|
||||
'(https?://(?:www.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9].[^\\s]{2,}|www.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9].[^\\s]{2,}|https?://(?:www.|(?!www))[a-zA-Z0-9]+.[^\\s]{2,}|www.[a-zA-Z0-9]+.[^\\s]{2,})';
|
||||
|
||||
export const EditAppModal = ({
|
||||
context,
|
||||
id,
|
||||
innerProps,
|
||||
}: ContextModalProps<{ app: AppType; allowAppNamePropagation: boolean }>) => {
|
||||
const { t } = useTranslation(['layout/modals/add-app', 'common']);
|
||||
const { name: configName, config } = useConfigContext();
|
||||
const updateConfig = useConfigStore((store) => store.updateConfig);
|
||||
const { enabled: isEditMode } = useEditModeStore();
|
||||
const [allowAppNamePropagation, setAllowAppNamePropagation] = useState<boolean>(
|
||||
innerProps.allowAppNamePropagation
|
||||
);
|
||||
|
||||
const form = useForm<AppType>({
|
||||
initialValues: innerProps.app,
|
||||
validate: {
|
||||
name: (name) => (!name ? 'Name is required' : null),
|
||||
url: (url) => {
|
||||
if (!url) {
|
||||
return 'Url is required';
|
||||
}
|
||||
|
||||
if (!url.match(appUrlRegex)) {
|
||||
return 'Value is not a valid url';
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
appearance: {
|
||||
iconUrl: (url: string) => {
|
||||
if (url.length < 1) {
|
||||
return 'This field is required';
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
},
|
||||
behaviour: {
|
||||
externalUrl: (url: string) => {
|
||||
if (url === undefined || url.length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!url.match(appUrlRegex)) {
|
||||
return 'Uri override is not a valid uri';
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
},
|
||||
},
|
||||
validateInputOnChange: true,
|
||||
});
|
||||
|
||||
const onSubmit = (values: AppType) => {
|
||||
if (!configName) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateConfig(
|
||||
configName,
|
||||
(previousConfig) => ({
|
||||
...previousConfig,
|
||||
apps: [
|
||||
...previousConfig.apps.filter((x) => x.id !== values.id),
|
||||
{
|
||||
...values,
|
||||
},
|
||||
],
|
||||
}),
|
||||
true,
|
||||
!isEditMode
|
||||
);
|
||||
|
||||
// also close the parent modal
|
||||
context.closeAll();
|
||||
};
|
||||
|
||||
const [activeTab, setActiveTab] = useState<EditAppModalTab>('general');
|
||||
|
||||
const closeModal = () => {
|
||||
context.closeModal(id);
|
||||
};
|
||||
|
||||
const validationErrors = Object.keys(form.errors);
|
||||
|
||||
const ValidationErrorIndicator = ({ keys }: { keys: string[] }) => {
|
||||
const relevantErrors = validationErrors.filter((x) => keys.includes(x));
|
||||
|
||||
return (
|
||||
<ThemeIcon
|
||||
opacity={relevantErrors.length === 0 ? 0 : 1}
|
||||
color="red"
|
||||
size={18}
|
||||
variant="light"
|
||||
>
|
||||
<IconAlertTriangle size={15} />
|
||||
</ThemeIcon>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{configName === undefined ||
|
||||
(config === undefined && (
|
||||
<Alert color="red">
|
||||
There was an unexpected problem loading the configuration. Functionality might be
|
||||
restricted. Please report this incident.
|
||||
</Alert>
|
||||
))}
|
||||
<Stack spacing={0} align="center" my="lg">
|
||||
<DebouncedAppIcon form={form} width={120} height={120} />
|
||||
|
||||
<Text align="center" weight="bold" size="lg" mt="md">
|
||||
{form.values.name ?? 'New App'}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Stack
|
||||
justify="space-between"
|
||||
style={{
|
||||
minHeight: 300,
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onTabChange={(tab) => setActiveTab(tab as EditAppModalTab)}
|
||||
defaultValue="general"
|
||||
radius="md"
|
||||
>
|
||||
<Tabs.List grow>
|
||||
<Tabs.Tab
|
||||
rightSection={<ValidationErrorIndicator keys={['name', 'url']} />}
|
||||
icon={<IconAdjustments size={14} />}
|
||||
value="general"
|
||||
>
|
||||
{t('tabs.general')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
rightSection={<ValidationErrorIndicator keys={['behaviour.externalUrl']} />}
|
||||
icon={<IconClick size={14} />}
|
||||
value="behaviour"
|
||||
>
|
||||
{t('tabs.behaviour')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
rightSection={<ValidationErrorIndicator keys={[]} />}
|
||||
icon={<IconAccessPoint size={14} />}
|
||||
value="network"
|
||||
>
|
||||
{t('tabs.network')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
rightSection={<ValidationErrorIndicator keys={['appearance.iconUrl']} />}
|
||||
icon={<IconBrush size={14} />}
|
||||
value="appearance"
|
||||
>
|
||||
{t('tabs.appearance')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
rightSection={<ValidationErrorIndicator keys={[]} />}
|
||||
icon={<IconPlug size={14} />}
|
||||
value="integration"
|
||||
>
|
||||
{t('tabs.integration')}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<GeneralTab form={form} openTab={(targetTab) => setActiveTab(targetTab)} />
|
||||
<BehaviourTab form={form} />
|
||||
<NetworkTab form={form} />
|
||||
<AppearanceTab
|
||||
form={form}
|
||||
disallowAppNameProgagation={() => setAllowAppNamePropagation(false)}
|
||||
allowAppNamePropagation={allowAppNamePropagation}
|
||||
/>
|
||||
<IntegrationTab form={form} />
|
||||
</Tabs>
|
||||
|
||||
<Group position="right" mt="md">
|
||||
<Button onClick={closeModal} px={50} variant="light" color="gray">
|
||||
{t('common:cancel')}
|
||||
</Button>
|
||||
<Button disabled={!form.isValid()} px={50} type="submit">
|
||||
{t('common:save')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createStyles, Flex, Tabs, TextInput } from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { AppType } from '../../../../../../types/app';
|
||||
import { DebouncedAppIcon } from '../Shared/DebouncedAppIcon';
|
||||
import { IconSelector } from './IconSelector/IconSelector';
|
||||
|
||||
interface AppearanceTabProps {
|
||||
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
|
||||
disallowAppNameProgagation: () => void;
|
||||
allowAppNamePropagation: boolean;
|
||||
}
|
||||
|
||||
export const AppearanceTab = ({
|
||||
form,
|
||||
disallowAppNameProgagation,
|
||||
allowAppNamePropagation,
|
||||
}: AppearanceTabProps) => {
|
||||
const { t } = useTranslation('layout/modals/add-app');
|
||||
const { classes } = useStyles();
|
||||
|
||||
return (
|
||||
<Tabs.Panel value="appearance" pt="lg">
|
||||
<Flex gap={5}>
|
||||
<TextInput
|
||||
className={classes.textInput}
|
||||
icon={<DebouncedAppIcon form={form} width={20} height={20} />}
|
||||
label={t('appearance.icon.label')}
|
||||
description={t('appearance.icon.description')}
|
||||
variant="default"
|
||||
withAsterisk
|
||||
required
|
||||
{...form.getInputProps('appearance.iconUrl')}
|
||||
/>
|
||||
<IconSelector
|
||||
onChange={(item) => {
|
||||
form.setValues({
|
||||
appearance: {
|
||||
iconUrl: item.url,
|
||||
},
|
||||
});
|
||||
disallowAppNameProgagation();
|
||||
}}
|
||||
allowAppNamePropagation={allowAppNamePropagation}
|
||||
form={form}
|
||||
/>
|
||||
</Flex>
|
||||
</Tabs.Panel>
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
textInput: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,142 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
createStyles,
|
||||
Divider,
|
||||
Flex,
|
||||
Loader,
|
||||
Popover,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { IconSearch, IconX } from '@tabler/icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { ICON_PICKER_SLICE_LIMIT } from '../../../../../../../../data/constants';
|
||||
import { IconSelectorItem } from '../../../../../../../types/iconSelector/iconSelectorItem';
|
||||
import { WalkxcodeRepositoryIcon } from '../../../../../../../types/iconSelector/repositories/walkxcodeIconRepository';
|
||||
import { AppType } from '../../../../../../../types/app';
|
||||
import { useRepositoryIconsQuery } from '../../../../../../../hooks/useRepositoryIconsQuery';
|
||||
|
||||
interface IconSelectorProps {
|
||||
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
|
||||
onChange: (icon: IconSelectorItem) => void;
|
||||
allowAppNamePropagation: boolean;
|
||||
}
|
||||
|
||||
export const IconSelector = ({ onChange, allowAppNamePropagation, form }: IconSelectorProps) => {
|
||||
const { t } = useTranslation('layout/tools');
|
||||
|
||||
const { data, isLoading } = useRepositoryIconsQuery<WalkxcodeRepositoryIcon>({
|
||||
url: 'https://api.github.com/repos/walkxcode/Dashboard-Icons/contents/png',
|
||||
converter: (item) => ({
|
||||
url: `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/${item.name}`,
|
||||
fileName: item.name,
|
||||
}),
|
||||
});
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
const { classes } = useStyles();
|
||||
|
||||
const [debouncedValue] = useDebouncedValue(form.values.name, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (allowAppNamePropagation !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matchingDebouncedIcon = data?.find(
|
||||
(x) => replaceCharacters(x.fileName.split('.')[0]) === replaceCharacters(debouncedValue)
|
||||
);
|
||||
|
||||
if (!matchingDebouncedIcon) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.setFieldValue('appearance.iconUrl', matchingDebouncedIcon.url);
|
||||
}, [debouncedValue]);
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
const replaceCharacters = (value: string) => value.toLowerCase().replaceAll('', '-');
|
||||
|
||||
const filteredItems = searchTerm
|
||||
? data.filter((x) => replaceCharacters(x.url).includes(replaceCharacters(searchTerm)))
|
||||
: data;
|
||||
const slicedFilteredItems = filteredItems.slice(0, ICON_PICKER_SLICE_LIMIT);
|
||||
const isTruncated =
|
||||
slicedFilteredItems.length > 0 && slicedFilteredItems.length !== filteredItems.length;
|
||||
|
||||
return (
|
||||
<Popover width={310}>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
className={classes.actionIcon}
|
||||
variant="default"
|
||||
leftIcon={<IconSearch size={20} />}
|
||||
>
|
||||
Icon Picker
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack pt={4}>
|
||||
<TextInput
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.currentTarget.value)}
|
||||
placeholder={t('iconPicker.textInputPlaceholder')}
|
||||
variant="filled"
|
||||
rightSection={
|
||||
<ActionIcon onClick={() => setSearchTerm('')}>
|
||||
<IconX opacity={0.5} size={20} strokeWidth={2} />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
|
||||
<ScrollArea style={{ height: 250 }} type="always">
|
||||
<Flex gap={4} wrap="wrap" pr={15}>
|
||||
{slicedFilteredItems.map((item) => (
|
||||
<ActionIcon key={item.url} onClick={() => onChange(item)} size={40} p={3}>
|
||||
<img className={classes.icon} src={item.url} alt="" />
|
||||
</ActionIcon>
|
||||
))}
|
||||
</Flex>
|
||||
|
||||
{isTruncated && (
|
||||
<Stack spacing="xs" pr={15}>
|
||||
<Divider mt={35} mx="xl" />
|
||||
<Title order={6} color="dimmed" align="center">
|
||||
{t('iconPicker.searchLimitationTitle', { max: ICON_PICKER_SLICE_LIMIT })}
|
||||
</Title>
|
||||
<Text color="dimmed" align="center" size="sm">
|
||||
{t('iconPicker.searchLimitationMessage', { max: ICON_PICKER_SLICE_LIMIT })}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
flameIcon: {
|
||||
margin: '0 auto',
|
||||
},
|
||||
icon: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
},
|
||||
actionIcon: {
|
||||
alignSelf: 'end',
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Tabs, Switch } from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { AppType } from '../../../../../../types/app';
|
||||
|
||||
interface BehaviourTabProps {
|
||||
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
|
||||
}
|
||||
|
||||
export const BehaviourTab = ({ form }: BehaviourTabProps) => {
|
||||
const { t } = useTranslation('layout/modals/add-app');
|
||||
|
||||
return (
|
||||
<Tabs.Panel value="behaviour" pt="xs">
|
||||
<Switch
|
||||
label={t('behaviour.isOpeningNewTab.label')}
|
||||
description={t('behaviour.isOpeningNewTab.description')}
|
||||
{...form.getInputProps('behaviour.isOpeningNewTab', { type: 'checkbox' })}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Tabs, TextInput } from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { IconClick, IconCursorText, IconLink } from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { AppType } from '../../../../../../types/app';
|
||||
import { EditAppModalTab } from '../type';
|
||||
|
||||
interface GeneralTabProps {
|
||||
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
|
||||
openTab: (tab: EditAppModalTab) => void;
|
||||
}
|
||||
|
||||
export const GeneralTab = ({ form, openTab }: GeneralTabProps) => {
|
||||
const { t } = useTranslation('layout/modals/add-app');
|
||||
return (
|
||||
<Tabs.Panel value="general" pt="sm">
|
||||
<TextInput
|
||||
icon={<IconCursorText size={16} />}
|
||||
label={t('general.appname.label')}
|
||||
description={t('general.appname.description')}
|
||||
placeholder="My example app"
|
||||
variant="default"
|
||||
withAsterisk
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<TextInput
|
||||
icon={<IconLink size={16} />}
|
||||
label={t('general.internalAddress.label')}
|
||||
description={t('general.internalAddress.description')}
|
||||
placeholder="https://google.com"
|
||||
variant="default"
|
||||
withAsterisk
|
||||
{...form.getInputProps('url')}
|
||||
onChange={(e) => {
|
||||
form.setFieldValue('behaviour.externalUrl', e.target.value);
|
||||
form.setFieldValue('url', e.target.value);
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
icon={<IconClick size={16} />}
|
||||
label={t('general.externalAddress.label')}
|
||||
description={t('general.externalAddress.description')}
|
||||
placeholder="https://homarr.mywebsite.com/"
|
||||
variant="default"
|
||||
{...form.getInputProps('behaviour.externalUrl')}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
createStyles,
|
||||
Flex,
|
||||
Grid,
|
||||
Group,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Text,
|
||||
Badge,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { TablerIcon } from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useState } from 'react';
|
||||
import { AppIntegrationPropertyAccessabilityType } from '../../../../../../../../types/app';
|
||||
|
||||
interface GenericSecretInputProps {
|
||||
label: string;
|
||||
value: string;
|
||||
setIcon: TablerIcon;
|
||||
secretIsPresent: boolean;
|
||||
type: AppIntegrationPropertyAccessabilityType;
|
||||
onClickUpdateButton: (value: string | undefined) => void;
|
||||
}
|
||||
|
||||
export const GenericSecretInput = ({
|
||||
label,
|
||||
value,
|
||||
setIcon,
|
||||
secretIsPresent,
|
||||
type,
|
||||
onClickUpdateButton,
|
||||
...props
|
||||
}: GenericSecretInputProps) => {
|
||||
const { classes } = useStyles();
|
||||
|
||||
const Icon = setIcon;
|
||||
|
||||
const [displayUpdateField, setDisplayUpdateField] = useState<boolean>(false);
|
||||
const { t } = useTranslation(['layout/modals/add-app', 'common']);
|
||||
|
||||
return (
|
||||
<Card p="xs" withBorder>
|
||||
<Grid>
|
||||
<Grid.Col className={classes.alignSelfCenter} xs={12} md={6}>
|
||||
<Group spacing="sm" noWrap>
|
||||
<ThemeIcon color={secretIsPresent ? 'green' : 'red'} variant="light" size="lg">
|
||||
<Icon size={18} />
|
||||
</ThemeIcon>
|
||||
<Stack spacing={0}>
|
||||
<Group spacing="xs">
|
||||
<Title className={classes.subtitle} order={6}>
|
||||
{t(label)}
|
||||
</Title>
|
||||
|
||||
<Group spacing="xs">
|
||||
{secretIsPresent ? (
|
||||
<Badge className={classes.textTransformUnset} color="green" variant="dot">
|
||||
{t('integration.type.defined')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className={classes.textTransformUnset} color="red" variant="dot">
|
||||
{t('integration.type.undefined')}
|
||||
</Badge>
|
||||
)}
|
||||
{type === 'private' ? (
|
||||
<Tooltip
|
||||
label={t('integration.type.explanationPrivate')}
|
||||
width={200}
|
||||
multiline
|
||||
withinPortal
|
||||
withArrow
|
||||
>
|
||||
<Badge className={classes.textTransformUnset} color="orange" variant="dot">
|
||||
{t('integration.type.private')}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip
|
||||
label={t('integration.type.explanationPublic')}
|
||||
width={200}
|
||||
multiline
|
||||
withinPortal
|
||||
withArrow
|
||||
>
|
||||
<Badge className={classes.textTransformUnset} color="red" variant="dot">
|
||||
{t('integration.type.public')}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
<Text size="xs" color="dimmed">
|
||||
{type === 'private'
|
||||
? 'Private: Once saved, you cannot read out this value again'
|
||||
: 'Public: Can be read out repeatedly'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col xs={12} md={6}>
|
||||
<Flex gap={10} justify="end" align="end">
|
||||
<Button variant="subtle" color="gray" px="xl">
|
||||
{t('integration.secrets.clear')}
|
||||
</Button>
|
||||
{displayUpdateField === true ? (
|
||||
<PasswordInput
|
||||
placeholder="new secret"
|
||||
styles={{ root: { width: 200 } }}
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
<Button onClick={() => setDisplayUpdateField(true)} variant="light">
|
||||
{t('integration.secrets.update')}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
subtitle: {
|
||||
lineHeight: 1.1,
|
||||
},
|
||||
alignSelfCenter: {
|
||||
alignSelf: 'center',
|
||||
},
|
||||
textTransformUnset: {
|
||||
textTransform: 'inherit',
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,155 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { Group, Select, SelectItem, Text } from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { forwardRef } from 'react';
|
||||
import {
|
||||
IntegrationField,
|
||||
integrationFieldDefinitions,
|
||||
integrationFieldProperties,
|
||||
AppIntegrationPropertyType,
|
||||
AppIntegrationType,
|
||||
AppType,
|
||||
} from '../../../../../../../../types/app';
|
||||
|
||||
interface IntegrationSelectorProps {
|
||||
form: UseFormReturnType<AppType, (item: AppType) => AppType>;
|
||||
}
|
||||
|
||||
export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
|
||||
const { t } = useTranslation('layout/modals/add-app');
|
||||
|
||||
const data: SelectItem[] = [
|
||||
{
|
||||
value: 'sabnzbd',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sabnzbd.png',
|
||||
label: 'SABnzbd',
|
||||
},
|
||||
{
|
||||
value: 'deluge',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/deluge.png',
|
||||
label: 'Deluge',
|
||||
},
|
||||
{
|
||||
value: 'transmission',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/transmission.png',
|
||||
label: 'Transmission',
|
||||
},
|
||||
{
|
||||
value: 'qBittorrent',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/qbittorrent.png',
|
||||
label: 'qBittorrent',
|
||||
},
|
||||
{
|
||||
value: 'jellyseerr',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyseerr.png',
|
||||
label: 'Jellyseerr',
|
||||
},
|
||||
{
|
||||
value: 'overseerr',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/overseerr.png',
|
||||
label: 'Overseerr',
|
||||
},
|
||||
{
|
||||
value: 'sonarr',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sonarr.png',
|
||||
label: 'Sonarr',
|
||||
},
|
||||
{
|
||||
value: 'radarr',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/radarr.png',
|
||||
label: 'Radarr',
|
||||
},
|
||||
{
|
||||
value: 'lidarr',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/lidarr.png',
|
||||
label: 'Lidarr',
|
||||
},
|
||||
{
|
||||
value: 'readarr',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/readarr.png',
|
||||
label: 'Readarr',
|
||||
},
|
||||
].filter((x) => Object.keys(integrationFieldProperties).includes(x.value));
|
||||
|
||||
const getNewProperties = (value: string | null): AppIntegrationPropertyType[] => {
|
||||
if (!value) return [];
|
||||
const integrationType = value as Exclude<AppIntegrationType['type'], null>;
|
||||
if (integrationType === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const requiredProperties = Object.entries(integrationFieldDefinitions).filter(([k, v]) => {
|
||||
const val = integrationFieldProperties[integrationType];
|
||||
return val.includes(k as IntegrationField);
|
||||
})!;
|
||||
return requiredProperties.map(([k, value]) => ({
|
||||
type: value.type,
|
||||
field: k as IntegrationField,
|
||||
value: undefined,
|
||||
isDefined: false,
|
||||
}));
|
||||
};
|
||||
|
||||
const inputProps = form.getInputProps('integration.type');
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={t('integration.type.label')}
|
||||
description={t('integration.type.description')}
|
||||
placeholder={t('integration.type.placeholder')}
|
||||
itemComponent={SelectItemComponent}
|
||||
data={data}
|
||||
maxDropdownHeight={250}
|
||||
dropdownPosition="bottom"
|
||||
clearable
|
||||
variant="default"
|
||||
searchable
|
||||
filter={(value, item) =>
|
||||
item.label?.toLowerCase().includes(value.toLowerCase().trim()) ||
|
||||
item.description?.toLowerCase().includes(value.toLowerCase().trim())
|
||||
}
|
||||
icon={
|
||||
form.values.integration?.type && (
|
||||
<img
|
||||
src={data.find((x) => 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<HTMLDivElement, ItemProps>(
|
||||
({ image, label, description, ...others }: ItemProps, ref) => (
|
||||
<div ref={ref} {...others}>
|
||||
<Group noWrap>
|
||||
<img src={image} alt="integration icon" width={20} height={20} />
|
||||
|
||||
<div>
|
||||
<Text size="sm">{label}</Text>
|
||||
{description && (
|
||||
<Text size="xs" color="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
@@ -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, (values: AppType) => AppType>;
|
||||
}
|
||||
|
||||
export const IntegrationOptionsRenderer = ({ form }: IntegrationOptionsRendererProps) => {
|
||||
const selectedIntegration = form.values.integration?.type;
|
||||
|
||||
if (!selectedIntegration) return null;
|
||||
|
||||
const displayedProperties = integrationFieldProperties[selectedIntegration];
|
||||
|
||||
return (
|
||||
<Stack spacing="xs" mb="md">
|
||||
{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 (
|
||||
<GenericSecretInput
|
||||
onClickUpdateButton={(value) => {
|
||||
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 (
|
||||
<GenericSecretInput
|
||||
onClickUpdateButton={(value) => {
|
||||
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`)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -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, (values: AppType) => AppType>;
|
||||
}
|
||||
|
||||
export const IntegrationTab = ({ form }: IntegrationTabProps) => {
|
||||
const { t } = useTranslation('layout/modals/add-app');
|
||||
const hasIntegrationSelected = form.values.integration?.type;
|
||||
|
||||
return (
|
||||
<Tabs.Panel value="integration" pt="lg">
|
||||
<IntegrationSelector form={form} />
|
||||
|
||||
{hasIntegrationSelected && (
|
||||
<>
|
||||
<Divider label={t('integration.type.label')} labelPosition="center" mt="xl" mb="md" />
|
||||
<Text size="sm" color="dimmed" mb="lg">
|
||||
{t('integration.secrets.description')}
|
||||
</Text>
|
||||
<IntegrationOptionsRenderer form={form} />
|
||||
<Alert icon={<IconAlertTriangle />} color="yellow">
|
||||
<Text>
|
||||
<Trans i18nKey="layout/modals/add-app:integration.secrets.warning" />
|
||||
</Text>
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
);
|
||||
};
|
||||
@@ -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, (values: AppType) => AppType>;
|
||||
}
|
||||
|
||||
export const NetworkTab = ({ form }: NetworkTabProps) => {
|
||||
const { t } = useTranslation('layout/modals/add-app');
|
||||
return (
|
||||
<Tabs.Panel value="network" pt="lg">
|
||||
<Switch
|
||||
label={t('network.statusChecker.label')}
|
||||
description={t('network.statusChecker.description')}
|
||||
mb="md"
|
||||
defaultChecked={form.values.network.enabledStatusChecker}
|
||||
{...form.getInputProps('network.enabledStatusChecker')}
|
||||
/>
|
||||
{form.values.network.enabledStatusChecker && (
|
||||
<MultiSelect
|
||||
required
|
||||
label={t('network.statusCodes.label')}
|
||||
description={t('network.statusCodes.description')}
|
||||
data={StatusCodes}
|
||||
clearable
|
||||
searchable
|
||||
defaultValue={form.values.network.okStatus}
|
||||
variant="default"
|
||||
{...form.getInputProps('network.statusCodes')}
|
||||
/>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
);
|
||||
};
|
||||
@@ -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, (values: AppType) => 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 <Loader width={width} height={height} />;
|
||||
}
|
||||
|
||||
if (debouncedIconImageUrl.length > 0) {
|
||||
return (
|
||||
<img
|
||||
className={classes.iconImage}
|
||||
src={debouncedIconImageUrl}
|
||||
width={width}
|
||||
height={height}
|
||||
alt=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
className={classes.iconImage}
|
||||
src="/imgs/logo/logo.png"
|
||||
width={width}
|
||||
height={height}
|
||||
alt=""
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
iconImage: {
|
||||
objectFit: 'contain',
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1 @@
|
||||
export type EditAppModalTab = 'general' | 'behaviour' | 'network' | 'appereance' | 'integration';
|
||||
@@ -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<CategoryEditModalInnerProps>({
|
||||
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 (
|
||||
<>
|
||||
<Text color="dimmed">{t('modal.text')}</Text>
|
||||
<Space h="lg" />
|
||||
<Group spacing="md" grow>
|
||||
<ElementItem
|
||||
name="Apps"
|
||||
icon={<IconBox size={40} strokeWidth={1.3} />}
|
||||
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',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ElementItem
|
||||
name="Widgets"
|
||||
icon={<IconStack size={40} strokeWidth={1.3} />}
|
||||
onClick={onOpenWidgets}
|
||||
/>
|
||||
<ElementItem
|
||||
name="Category"
|
||||
icon={<IconBoxAlignTop size={40} strokeWidth={1.3} />}
|
||||
onClick={onClickCreateCategory}
|
||||
/>
|
||||
{/*<ElementItem
|
||||
name="Static Element"
|
||||
icon={<IconTextResize size={40} strokeWidth={1.3} />}
|
||||
onClick={onOpenStaticElements}
|
||||
/>*/}
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface ElementItemProps {
|
||||
icon: ReactNode;
|
||||
name: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const ElementItem = ({ name, icon, onClick }: ElementItemProps) => {
|
||||
const { classes, cx } = useStyles();
|
||||
return (
|
||||
<UnstyledButton
|
||||
className={cx(classes.elementButton, classes.styledButton)}
|
||||
onClick={onClick}
|
||||
py="md"
|
||||
>
|
||||
<Stack className={classes.elementStack} align="center" spacing={5}>
|
||||
<motion.div
|
||||
// On hover zoom in
|
||||
whileHover={{ scale: 1.2 }}
|
||||
>
|
||||
{icon}
|
||||
</motion.div>
|
||||
<Text className={classes.elementName} weight={500} size="sm">
|
||||
{name}
|
||||
</Text>
|
||||
</Stack>
|
||||
</UnstyledButton>
|
||||
);
|
||||
};
|
||||
@@ -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<void>;
|
||||
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 src={image} width={24} height={24} /> : image;
|
||||
|
||||
return (
|
||||
<Grid.Col span={3}>
|
||||
<Card style={{ height: '100%' }}>
|
||||
<Stack justify="space-between" style={{ height: '100%' }}>
|
||||
<Stack spacing="xs">
|
||||
<Center>
|
||||
<Icon />
|
||||
</Center>
|
||||
<Text className={classes.elementText} align="center">
|
||||
{name}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text className={classes.elementText} size="xs" align="center" color="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
onClick={handleAddition}
|
||||
variant="light"
|
||||
size="xs"
|
||||
mt="auto"
|
||||
radius="md"
|
||||
fullWidth
|
||||
>
|
||||
{t('addToDashboard')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<Button
|
||||
leftIcon={<IconArrowNarrowLeft />}
|
||||
onClick={onClickBack}
|
||||
styles={{ inner: { width: 'fit-content' } }}
|
||||
fullWidth
|
||||
variant="default"
|
||||
mb="md"
|
||||
>
|
||||
<Text>{t('goBack')}</Text>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
}));
|
||||
@@ -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 (
|
||||
<>
|
||||
<SelectorBackArrow onClickBack={onClickBack} />
|
||||
|
||||
<Text mb="md" color="dimmed">
|
||||
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.
|
||||
</Text>
|
||||
|
||||
<Grid>
|
||||
<GenericAvailableElementType
|
||||
name="Static Text"
|
||||
description="Display a fixed string on your dashboard"
|
||||
image={IconCursorText}
|
||||
handleAddition={/* TODO: add something? */ async () => {}}
|
||||
/>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<>
|
||||
<SelectorBackArrow onClickBack={onClickBack} />
|
||||
|
||||
<Text mb="md" color="dimmed">
|
||||
{t('widgetDescription')}
|
||||
</Text>
|
||||
|
||||
<Grid>
|
||||
{Object.entries(widgets)
|
||||
.filter(([widgetId]) => !activeWidgets.some((aw) => aw.id === widgetId))
|
||||
.map(([k, v]) => (
|
||||
<WidgetElementType key={k} id={k} image={v.icon} widget={v} />
|
||||
))}
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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<string, any>['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: <IconChecks stroke={1.5} />,
|
||||
color: 'teal',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<GenericAvailableElementType
|
||||
name={t('descriptor.name')}
|
||||
description={t('descriptor.description')}
|
||||
image={image}
|
||||
disabled={disabled}
|
||||
handleAddition={handleAddition}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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<undefined | 'integrations' | 'static_elements'>();
|
||||
|
||||
switch (activeTab) {
|
||||
case undefined:
|
||||
return (
|
||||
<AvailableElementTypes
|
||||
modalId={id}
|
||||
onOpenIntegrations={() => setActiveTab('integrations')}
|
||||
onOpenStaticElements={() => setActiveTab('static_elements')}
|
||||
/>
|
||||
);
|
||||
case 'integrations':
|
||||
return <AvailableIntegrationElements onClickBack={() => setActiveTab(undefined)} />;
|
||||
case 'static_elements':
|
||||
return <AvailableStaticTypes onClickBack={() => setActiveTab(undefined)} />;
|
||||
default:
|
||||
/* default to the main selection tab */
|
||||
setActiveTab(undefined);
|
||||
return <></>;
|
||||
}
|
||||
};
|
||||
5
src/components/Dashboard/Tiles/Apps/AppIcon.tsx
Normal file
5
src/components/Dashboard/Tiles/Apps/AppIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
interface ServiceIconProps {
|
||||
size: '100%' | number;
|
||||
}
|
||||
|
||||
export const AppIcon = ({ size }: ServiceIconProps) => null;
|
||||
64
src/components/Dashboard/Tiles/Apps/AppMenu.tsx
Normal file
64
src/components/Dashboard/Tiles/Apps/AppMenu.tsx
Normal file
@@ -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 (
|
||||
<GenericTileMenu
|
||||
handleClickEdit={handleClickEdit}
|
||||
handleClickChangePosition={handleClickChangePosition}
|
||||
handleClickDelete={handleClickDelete}
|
||||
displayEdit
|
||||
/>
|
||||
);
|
||||
};
|
||||
64
src/components/Dashboard/Tiles/Apps/AppPing.tsx
Normal file
64
src/components/Dashboard/Tiles/Apps/AppPing.tsx
Normal file
@@ -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 (
|
||||
<motion.div
|
||||
style={{ position: 'absolute', bottom: 20, right: 20, zIndex: 2 }}
|
||||
animate={{
|
||||
scale: isOnline ? [1, 0.7, 1] : 1,
|
||||
}}
|
||||
transition={{ repeat: Infinity, duration: 2.5, ease: 'easeInOut' }}
|
||||
>
|
||||
<Tooltip
|
||||
withinPortal
|
||||
radius="lg"
|
||||
label={
|
||||
isLoading
|
||||
? t('states.loading')
|
||||
: isOnline
|
||||
? t('states.online', { response: data.status })
|
||||
: t('states.offline', { response: data?.status })
|
||||
}
|
||||
>
|
||||
<Indicator
|
||||
size={15}
|
||||
color={isLoading ? 'yellow' : isOnline ? 'green' : 'red'}
|
||||
children={null}
|
||||
/>
|
||||
</Tooltip>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
type PingState = 'loading' | 'down' | 'online';
|
||||
103
src/components/Dashboard/Tiles/Apps/AppTile.tsx
Normal file
103
src/components/Dashboard/Tiles/Apps/AppTile.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<Text align="center" weight={500} size="md" className={classes.appName}>
|
||||
{app.name}
|
||||
</Text>
|
||||
<Center style={{ height: '85%', flex: 1 }}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<motion.img
|
||||
className={classes.image}
|
||||
src={app.appearance.iconUrl}
|
||||
alt={app.name}
|
||||
whileHover={{
|
||||
scale: 1.2,
|
||||
transition: { duration: 0.2 },
|
||||
}}
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</Center>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HomarrCardWrapper className={className}>
|
||||
<AppMenu app={app} />
|
||||
{!app.url || isEditMode ? (
|
||||
<UnstyledButton
|
||||
className={classes.button}
|
||||
style={{ pointerEvents: isEditMode ? 'none' : 'auto' }}
|
||||
>
|
||||
<Inner />
|
||||
</UnstyledButton>
|
||||
) : (
|
||||
<UnstyledButton
|
||||
style={{ pointerEvents: isEditMode ? 'none' : 'auto' }}
|
||||
component={NextLink}
|
||||
href={app.behaviour.externalUrl.length > 0 ? app.behaviour.externalUrl : app.url}
|
||||
target={app.behaviour.isOpeningNewTab ? '_blank' : '_self'}
|
||||
className={cx(classes.button)}
|
||||
>
|
||||
<Inner />
|
||||
</UnstyledButton>
|
||||
)}
|
||||
<AppPing app={app} />
|
||||
</HomarrCardWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
6
src/components/Dashboard/Tiles/EmptyTile.tsx
Normal file
6
src/components/Dashboard/Tiles/EmptyTile.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { HomarrCardWrapper } from './HomarrCardWrapper';
|
||||
import { BaseTileProps } from './type';
|
||||
|
||||
export const EmptyTile = ({ className }: BaseTileProps) => (
|
||||
<HomarrCardWrapper className={className}>Empty</HomarrCardWrapper>
|
||||
);
|
||||
57
src/components/Dashboard/Tiles/GenericTileMenu.tsx
Normal file
57
src/components/Dashboard/Tiles/GenericTileMenu.tsx
Normal file
@@ -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 (
|
||||
<Menu withinPortal withArrow position="right-start">
|
||||
<Menu.Target>
|
||||
<ActionIcon pos="absolute" top={4} right={4}>
|
||||
<IconDots />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown w={250}>
|
||||
<Menu.Label>{t('sections.settings')}</Menu.Label>
|
||||
{displayEdit && (
|
||||
<Menu.Item icon={<IconPencil size={16} stroke={1.5} />} onClick={handleClickEdit}>
|
||||
{t('edit')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
icon={<IconLayoutKanban size={16} stroke={1.5} />}
|
||||
onClick={handleClickChangePosition}
|
||||
>
|
||||
{t('changePosition')}
|
||||
</Menu.Item>
|
||||
<Menu.Label>{t('sections.dangerZone')}</Menu.Label>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
icon={<IconTrash size={16} stroke={1.5} color="red" />}
|
||||
onClick={handleClickDelete}
|
||||
>
|
||||
{t('remove')}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
28
src/components/Dashboard/Tiles/HomarrCardWrapper.tsx
Normal file
28
src/components/Dashboard/Tiles/HomarrCardWrapper.tsx
Normal file
@@ -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 (
|
||||
<Card
|
||||
{...restProps}
|
||||
className={cx(restProps.className, cardClass)}
|
||||
withBorder
|
||||
style={{ cursor: isEditMode ? 'move' : 'default' }}
|
||||
radius="lg"
|
||||
shadow="sm"
|
||||
/>
|
||||
);
|
||||
};
|
||||
73
src/components/Dashboard/Tiles/TileWrapper.tsx
Normal file
73
src/components/Dashboard/Tiles/TileWrapper.tsx
Normal file
@@ -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<HTMLDivElement>;
|
||||
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 (
|
||||
<div
|
||||
className="grid-stack-item"
|
||||
data-type={type}
|
||||
data-id={id}
|
||||
{...locationProperties}
|
||||
gs-w={normalizedWidth}
|
||||
data-gridstack-w={normalizedWidth}
|
||||
gs-h={normalizedHeight}
|
||||
data-gridstack-h={normalizedHeight}
|
||||
gs-min-w={minWidth}
|
||||
gs-min-h={minHeight}
|
||||
gs-max-w={maxWidth}
|
||||
gs-max-h={maxHeight}
|
||||
ref={itemRef}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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(),
|
||||
};
|
||||
};
|
||||
196
src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx
Normal file
196
src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx
Normal file
@@ -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<string, any>['properties'];
|
||||
widgetOptions: IWidget<string, any>['properties'];
|
||||
};
|
||||
|
||||
type IntegrationOptionsValueType = IWidget<string, any>['properties'][string];
|
||||
|
||||
export const WidgetsEditModal = ({
|
||||
context,
|
||||
id,
|
||||
innerProps,
|
||||
}: ContextModalProps<WidgetEditModalInnerProps>) => {
|
||||
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 (
|
||||
<Stack>
|
||||
{items.map(([key, defaultValue], index) => {
|
||||
const option = (currentWidgetDefinition as any).options[key] as IWidgetOptionValue;
|
||||
const value = moduleProperties[key] ?? defaultValue;
|
||||
|
||||
if (!option) {
|
||||
return (
|
||||
<Alert icon={<IconAlertTriangle />} color="red">
|
||||
<Text>
|
||||
<Trans
|
||||
i18nKey="modules/common:errors.unmappedOptions.text"
|
||||
values={{ key }}
|
||||
components={{ b: <b />, code: <code /> }}
|
||||
/>
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
return WidgetOptionTypeSwitch(
|
||||
option,
|
||||
index,
|
||||
t,
|
||||
key,
|
||||
value,
|
||||
handleChange,
|
||||
getMutliselectData
|
||||
);
|
||||
})}
|
||||
<Group position="right">
|
||||
<Button onClick={() => context.closeModal(id)} variant="light">
|
||||
{t('common:cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave}>{t('common:save')}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<Switch
|
||||
key={`${option.type}-${index}`}
|
||||
label={t(`descriptor.settings.${key}.label`)}
|
||||
checked={value as boolean}
|
||||
onChange={(ev) => handleChange(key, ev.currentTarget.checked)}
|
||||
/>
|
||||
);
|
||||
case 'text':
|
||||
return (
|
||||
<TextInput
|
||||
color={primaryColor}
|
||||
key={`${option.type}-${index}`}
|
||||
label={t(`descriptor.settings.${key}.label`)}
|
||||
value={value as string}
|
||||
onChange={(ev) => handleChange(key, ev.currentTarget.value)}
|
||||
/>
|
||||
);
|
||||
case 'multi-select':
|
||||
return (
|
||||
<MultiSelect
|
||||
color={primaryColor}
|
||||
key={`${option.type}-${index}`}
|
||||
data={getMutliselectData(key)}
|
||||
label={t(`descriptor.settings.${key}.label`)}
|
||||
value={value as string[]}
|
||||
onChange={(v) => handleChange(key, v)}
|
||||
/>
|
||||
);
|
||||
case 'number':
|
||||
return (
|
||||
<NumberInput
|
||||
color={primaryColor}
|
||||
key={`${option.type}-${index}`}
|
||||
label={t(`descriptor.settings.${key}.label`)}
|
||||
value={value as number}
|
||||
onChange={(v) => handleChange(key, v!)}
|
||||
/>
|
||||
);
|
||||
case 'slider':
|
||||
return (
|
||||
<Stack spacing="xs">
|
||||
<Text>{t(`descriptor.settings.${key}.label`)}</Text>
|
||||
<Slider
|
||||
color={primaryColor}
|
||||
key={`${option.type}-${index}`}
|
||||
value={value as number}
|
||||
min={option.min}
|
||||
max={option.max}
|
||||
step={option.step}
|
||||
onChange={(v) => handleChange(key, v)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
83
src/components/Dashboard/Tiles/Widgets/WidgetsMenu.tsx
Normal file
83
src/components/Dashboard/Tiles/Widgets/WidgetsMenu.tsx
Normal file
@@ -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<string, any>;
|
||||
wrapperColumnCount: number;
|
||||
};
|
||||
|
||||
interface WidgetsMenuProps {
|
||||
integration: string;
|
||||
widget: IWidget<string, any> | 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<WidgetsRemoveModalInnerProps>({
|
||||
modal: 'integrationRemove',
|
||||
title: <Title order={4}>{t('descriptor.remove.title')}</Title>,
|
||||
innerProps: {
|
||||
widgetId: integration,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleChangeSizeClick = () => {
|
||||
openContextModalGeneric<WidgetChangePositionModalInnerProps>({
|
||||
modal: 'changeIntegrationPositionModal',
|
||||
size: 'xl',
|
||||
title: null,
|
||||
innerProps: {
|
||||
widgetId: integration,
|
||||
widget,
|
||||
wrapperColumnCount,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditClick = () => {
|
||||
openContextModalGeneric<WidgetEditModalInnerProps>({
|
||||
modal: 'integrationOptions',
|
||||
title: <Title order={4}>{t('descriptor.settings.title')}</Title>,
|
||||
innerProps: {
|
||||
widgetId: integration,
|
||||
options: widget.properties,
|
||||
// Cast as the right type for the correct widget
|
||||
widgetOptions: widgetDefinitionObject.options as any,
|
||||
},
|
||||
zIndex: 5,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<GenericTileMenu
|
||||
handleClickEdit={handleEditClick}
|
||||
handleClickChangePosition={handleChangeSizeClick}
|
||||
handleClickDelete={handleDeleteClick}
|
||||
displayEdit={
|
||||
typeof widget.properties !== 'undefined' && Object.keys(widget.properties).length !== 0
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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<WidgetsRemoveModalInnerProps>) => {
|
||||
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 (
|
||||
<Stack>
|
||||
<Text>{t('descriptor.remove.confirm')}</Text>
|
||||
<Group position="right">
|
||||
<Button onClick={() => context.closeModal(id)} variant="light">
|
||||
{t('common:cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => handleDeletion()}>{t('common:ok')}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
3
src/components/Dashboard/Tiles/type.ts
Normal file
3
src/components/Dashboard/Tiles/type.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface BaseTileProps {
|
||||
className?: string;
|
||||
}
|
||||
94
src/components/Dashboard/Views/DashboardView.tsx
Normal file
94
src/components/Dashboard/Views/DashboardView.tsx
Normal file
@@ -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 (
|
||||
<Group align="top" h="100%">
|
||||
{sidebarsVisible.isLoading ? (
|
||||
<Center w="100%">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : (
|
||||
<>
|
||||
{sidebarsVisible.left ? (
|
||||
<DashboardSidebar location="left" isGridstackReady={isReady} />
|
||||
) : null}
|
||||
|
||||
<Stack ref={mainAreaRef} mx={-10} style={{ flexGrow: 1 }}>
|
||||
{!isReady
|
||||
? null
|
||||
: wrappers.map((item) =>
|
||||
item.type === 'category' ? (
|
||||
<DashboardCategory key={item.id} category={item as unknown as CategoryType} />
|
||||
) : (
|
||||
<DashboardWrapper key={item.id} wrapper={item as WrapperType} />
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{sidebarsVisible.right ? (
|
||||
<DashboardSidebar location="right" isGridstackReady={isReady} />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const usePrepareGridstack = () => {
|
||||
const mainAreaRef = useRef<HTMLDivElement>(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]
|
||||
);
|
||||
};
|
||||
3
src/components/Dashboard/Views/DetailView.tsx
Normal file
3
src/components/Dashboard/Views/DetailView.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { DashboardView } from './DashboardView';
|
||||
|
||||
export const DashboardDetailView = () => <DashboardView />;
|
||||
3
src/components/Dashboard/Views/EditView.tsx
Normal file
3
src/components/Dashboard/Views/EditView.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { DashboardView } from './DashboardView';
|
||||
|
||||
export const DashboardEditView = () => <DashboardView />;
|
||||
39
src/components/Dashboard/Views/ViewToggleButton.tsx
Normal file
39
src/components/Dashboard/Views/ViewToggleButton.tsx
Normal file
@@ -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 (
|
||||
<Tooltip width={100} label={<Text align="center">{t('description')}</Text>}>
|
||||
{screenLargerThanMd ? (
|
||||
<Button
|
||||
variant={isEditMode ? 'filled' : 'default'}
|
||||
h={44}
|
||||
w={180}
|
||||
leftIcon={isEditMode ? <IconEditOff /> : <IconEdit />}
|
||||
onClick={() => toggleEditMode()}
|
||||
color={isEditMode ? 'red' : undefined}
|
||||
radius="md"
|
||||
>
|
||||
<Text>{isEditMode ? t('button.enabled') : t('button.disabled')}</Text>
|
||||
</Button>
|
||||
) : (
|
||||
<ActionIcon
|
||||
onClick={() => toggleEditMode()}
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="xl"
|
||||
color="blue"
|
||||
>
|
||||
{isEditMode ? <IconEditOff /> : <IconEdit />}
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
11
src/components/Dashboard/Views/useEditModeStore.ts
Normal file
11
src/components/Dashboard/Views/useEditModeStore.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import create from 'zustand';
|
||||
|
||||
interface EditModeState {
|
||||
enabled: boolean;
|
||||
toggleEditMode: () => void;
|
||||
}
|
||||
|
||||
export const useEditModeStore = create<EditModeState>((set) => ({
|
||||
enabled: false,
|
||||
toggleEditMode: () => set((state) => ({ enabled: !state.enabled })),
|
||||
}));
|
||||
32
src/components/Dashboard/Wrappers/Category/Category.tsx
Normal file
32
src/components/Dashboard/Wrappers/Category/Category.tsx
Normal file
@@ -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 (
|
||||
<HomarrCardWrapper pt={10} mx={10} isCategory>
|
||||
<Group position="apart" align="center">
|
||||
<Title order={3}>{category.name}</Title>
|
||||
{isEditMode ? <CategoryEditMenu category={category} /> : null}
|
||||
</Group>
|
||||
<div
|
||||
className="grid-stack grid-stack-category"
|
||||
data-category={category.id}
|
||||
ref={refs.wrapper}
|
||||
>
|
||||
<WrapperContent apps={apps} refs={refs} widgets={widgets} />
|
||||
</div>
|
||||
</HomarrCardWrapper>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<Menu withinPortal position="left-start" withArrow>
|
||||
<Menu.Target>
|
||||
<ActionIcon>
|
||||
<IconDots />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item icon={<IconEdit size={20} />} onClick={edit}>
|
||||
Edit
|
||||
</Menu.Item>
|
||||
<Menu.Item icon={<IconTrash size={20} />} onClick={remove}>
|
||||
Remove
|
||||
</Menu.Item>
|
||||
<Menu.Label>Change positon</Menu.Label>
|
||||
<Menu.Item icon={<IconTransitionTop size={20} />} onClick={moveCategoryUp}>
|
||||
Move up
|
||||
</Menu.Item>
|
||||
<Menu.Item icon={<IconTransitionBottom size={20} />} onClick={moveCategoryDown}>
|
||||
Move down
|
||||
</Menu.Item>
|
||||
<Menu.Label>Add category</Menu.Label>
|
||||
<Menu.Item icon={<IconRowInsertTop size={20} />} onClick={addCategoryAbove}>
|
||||
Add category above
|
||||
</Menu.Item>
|
||||
<Menu.Item icon={<IconRowInsertBottom size={20} />} onClick={addCategoryBelow}>
|
||||
Add category below
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -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<void>;
|
||||
};
|
||||
|
||||
export const CategoryEditModal = ({
|
||||
context,
|
||||
innerProps,
|
||||
id,
|
||||
}: ContextModalProps<CategoryEditModalInnerProps>) => {
|
||||
const { name: configName } = useConfigContext();
|
||||
const updateConfig = useConfigStore((x) => x.updateConfig);
|
||||
const form = useForm<FormType>({
|
||||
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 (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<TextInput data-autoFocus {...form.getInputProps('name')} autoFocus />
|
||||
|
||||
<Group mt="md" grow>
|
||||
<Button onClick={() => context.closeModal(id)} variant="filled" color="gray">
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button type="submit">{t('save')}</Button>
|
||||
</Group>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = {
|
||||
name: string;
|
||||
};
|
||||
@@ -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<CategoryEditModalInnerProps>({
|
||||
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<CategoryEditModalInnerProps>({
|
||||
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<CategoryEditModalInnerProps>({
|
||||
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,
|
||||
};
|
||||
};
|
||||
55
src/components/Dashboard/Wrappers/Sidebar/Sidebar.tsx
Normal file
55
src/components/Dashboard/Wrappers/Sidebar/Sidebar.tsx
Normal file
@@ -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) => (
|
||||
<Card
|
||||
withBorder
|
||||
w={300}
|
||||
style={{
|
||||
background: 'none',
|
||||
borderStyle: 'dashed',
|
||||
}}
|
||||
>
|
||||
{isGridstackReady && <SidebarInner location={location} />}
|
||||
</Card>
|
||||
);
|
||||
|
||||
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 (
|
||||
<Card withBorder mih="100%" p={0} radius="lg" className={cardClass} ref={refs.wrapper}>
|
||||
<div
|
||||
className="grid-stack grid-stack-sidebar"
|
||||
style={{ transitionDuration: '0s', height: '100%' }}
|
||||
data-sidebar={location}
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
gs-min-row={minRow}
|
||||
>
|
||||
<WrapperContent apps={apps} refs={refs} widgets={widgets} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const useMinRowForFullHeight = (wrapperRef: RefObject<HTMLDivElement>) =>
|
||||
wrapperRef.current ? Math.floor(wrapperRef.current!.offsetHeight / 128) : 2;
|
||||
22
src/components/Dashboard/Wrappers/Wrapper/Wrapper.tsx
Normal file
22
src/components/Dashboard/Wrappers/Wrapper/Wrapper.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className="grid-stack grid-stack-wrapper"
|
||||
style={{ transitionDuration: '0s' }}
|
||||
data-wrapper={wrapper.id}
|
||||
ref={refs.wrapper}
|
||||
>
|
||||
<WrapperContent apps={apps} refs={refs} widgets={widgets} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
68
src/components/Dashboard/Wrappers/WrapperContent.tsx
Normal file
68
src/components/Dashboard/Wrappers/WrapperContent.tsx
Normal file
@@ -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<string, any>[];
|
||||
refs: {
|
||||
wrapper: RefObject<HTMLDivElement>;
|
||||
items: MutableRefObject<Record<string, RefObject<HTMLDivElement>>>;
|
||||
gridstack: MutableRefObject<GridStack | undefined>;
|
||||
};
|
||||
}
|
||||
|
||||
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 (
|
||||
<GridstackTileWrapper
|
||||
id={app.id}
|
||||
type="app"
|
||||
key={app.id}
|
||||
itemRef={refs.items.current[app.id]}
|
||||
{...tile}
|
||||
{...(app.shape[shapeSize]?.location ?? {})}
|
||||
{...(app.shape[shapeSize]?.size ?? {})}
|
||||
>
|
||||
<TileComponent className="grid-stack-item-content" app={app} />
|
||||
</GridstackTileWrapper>
|
||||
);
|
||||
})}
|
||||
{widgets.map((widget) => {
|
||||
const definition = Widgets[widget.id as keyof typeof Widgets] as
|
||||
| IWidgetDefinition
|
||||
| undefined;
|
||||
if (!definition) return null;
|
||||
|
||||
return (
|
||||
<GridstackTileWrapper
|
||||
type="widget"
|
||||
key={widget.id}
|
||||
itemRef={refs.items.current[widget.id]}
|
||||
id={definition.id}
|
||||
{...definition.gridstack}
|
||||
{...widget.shape[shapeSize]?.location}
|
||||
{...widget.shape[shapeSize]?.size}
|
||||
>
|
||||
<WidgetWrapper className="grid-stack-item-content" widget={widget} widgetId={widget.id}>
|
||||
<definition.component className="grid-stack-item-content" widget={widget} />
|
||||
</WidgetWrapper>
|
||||
</GridstackTileWrapper>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
110
src/components/Dashboard/Wrappers/gridstack/init-gridstack.ts
Normal file
110
src/components/Dashboard/Wrappers/gridstack/init-gridstack.ts
Normal file
@@ -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<HTMLDivElement>,
|
||||
gridRef: MutableRefObject<GridStack | undefined>,
|
||||
itemRefs: MutableRefObject<Record<string, RefObject<HTMLDivElement>>>,
|
||||
areaId: string,
|
||||
items: AppType[],
|
||||
widgets: IWidget<string, any>[],
|
||||
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;
|
||||
};
|
||||
31
src/components/Dashboard/Wrappers/gridstack/store.tsx
Normal file
31
src/components/Dashboard/Wrappers/gridstack/store.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useMantineTheme } from '@mantine/core';
|
||||
import create from 'zustand';
|
||||
|
||||
export const useGridstackStore = create<GridstackStoreType>((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';
|
||||
}
|
||||
314
src/components/Dashboard/Wrappers/gridstack/use-gridstack.ts
Normal file
314
src/components/Dashboard/Wrappers/gridstack/use-gridstack.ts
Normal file
@@ -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<string, any>[];
|
||||
refs: {
|
||||
wrapper: RefObject<HTMLDivElement>;
|
||||
items: MutableRefObject<Record<string, RefObject<HTMLDivElement>>>;
|
||||
gridstack: MutableRefObject<GridStack | undefined>;
|
||||
};
|
||||
}
|
||||
|
||||
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<HTMLDivElement>(null);
|
||||
// references to the diffrent items contained in the gridstack
|
||||
const itemRefs = useRef<Record<string, RefObject<HTMLDivElement>>>({});
|
||||
// reference of the gridstack object for modifications after initialization
|
||||
const gridRef = useRef<GridStack>();
|
||||
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<string, any>) },
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
: () => {};
|
||||
|
||||
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<string, any>) },
|
||||
],
|
||||
};
|
||||
},
|
||||
(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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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<boolean>(defaultPosition);
|
||||
const { t } = useTranslation('settings/general/search-engine');
|
||||
const toggleOpenInNewTab = () => {
|
||||
setOpenInNewTab(!openInNewTab);
|
||||
setConfig({
|
||||
...config,
|
||||
settings: {
|
||||
...config.settings,
|
||||
searchNewTab: !openInNewTab,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<div className={classes.root}>
|
||||
<Switch checked={openInNewTab} onChange={() => toggleOpenInNewTab()} size="md" />
|
||||
</div>
|
||||
{t('searchNewTab.label')}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Stack mb="md" mr="sm" mt="xs">
|
||||
<form onSubmit={form.onSubmit((values) => saveChanges(values))}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t('pageTitle.label')}
|
||||
placeholder="Homarr 🦞"
|
||||
{...form.getInputProps('title')}
|
||||
/>
|
||||
<TextInput
|
||||
label={t('logo.label')}
|
||||
placeholder="/imgs/logo.png"
|
||||
{...form.getInputProps('logo')}
|
||||
/>
|
||||
<TextInput
|
||||
label={t('favicon.label')}
|
||||
placeholder="/imgs/favicon/favicon.png"
|
||||
{...form.getInputProps('favicon')}
|
||||
/>
|
||||
<TextInput
|
||||
label={t('background.label')}
|
||||
placeholder="/img/background.png"
|
||||
{...form.getInputProps('background')}
|
||||
/>
|
||||
<Textarea
|
||||
minRows={5}
|
||||
label={t('customCSS.label')}
|
||||
placeholder={t('customCSS.placeholder')}
|
||||
{...form.getInputProps('customCSS')}
|
||||
/>
|
||||
<Button type="submit">{t('buttons.submit')}</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
<GrowthSelector />
|
||||
<ColorSelector type="primary" />
|
||||
<ColorSelector type="secondary" />
|
||||
<ShadeSelector />
|
||||
<OpacitySelector />
|
||||
<AppCardWidthSelector />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Text, Slider, Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useConfig } from '../../tools/state';
|
||||
|
||||
export function AppCardWidthSelector() {
|
||||
const { config, setConfig } = useConfig();
|
||||
const { t } = useTranslation('settings/customization/app-width');
|
||||
|
||||
const setappCardWidth = (appCardWidth: number) => {
|
||||
setConfig({
|
||||
...config,
|
||||
settings: {
|
||||
...config.settings,
|
||||
appCardWidth,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing="xs">
|
||||
<Text>{t('label')}</Text>
|
||||
<Slider
|
||||
label={config.settings.appCardWidth?.toFixed(1)}
|
||||
defaultValue={config.settings.appCardWidth ?? 0.7}
|
||||
step={0.1}
|
||||
min={0.3}
|
||||
max={1.2}
|
||||
styles={{ markLabel: { fontSize: 'xx-small' } }}
|
||||
onChange={(value) => setappCardWidth(value)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ColorSwatch, Grid, Group, Popover, Text, useMantineTheme } from '@mantine/core';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useConfig } from '../../tools/state';
|
||||
import { useColorTheme } from '../../tools/color';
|
||||
|
||||
interface ColorControlProps {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export function ColorSelector({ type }: ColorControlProps) {
|
||||
const { config, setConfig } = useConfig();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const { primaryColor, secondaryColor, setPrimaryColor, setSecondaryColor } = useColorTheme();
|
||||
const { t } = useTranslation('settings/customization/color-selector');
|
||||
|
||||
const theme = useMantineTheme();
|
||||
const colors = Object.keys(theme.colors).map((color) => ({
|
||||
swatch: theme.colors[color][6],
|
||||
color,
|
||||
}));
|
||||
|
||||
const configColor = type === 'primary' ? primaryColor : secondaryColor;
|
||||
|
||||
const setConfigColor = (color: string) => {
|
||||
if (type === 'primary') {
|
||||
setPrimaryColor(color);
|
||||
setConfig({
|
||||
...config,
|
||||
settings: {
|
||||
...config.settings,
|
||||
primaryColor: color,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
setSecondaryColor(color);
|
||||
setConfig({
|
||||
...config,
|
||||
settings: {
|
||||
...config.settings,
|
||||
secondaryColor: color,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const swatches = colors.map(({ color, swatch }) => (
|
||||
<Grid.Col span={2} key={color}>
|
||||
<ColorSwatch
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => setConfigColor(color)}
|
||||
color={swatch}
|
||||
size={22}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</Grid.Col>
|
||||
));
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<Popover
|
||||
width={250}
|
||||
withinPortal
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
position="left"
|
||||
withArrow
|
||||
>
|
||||
<Popover.Target>
|
||||
<ColorSwatch
|
||||
component="button"
|
||||
type="button"
|
||||
color={theme.colors[configColor][6]}
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
size={22}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Grid gutter="lg" columns={14}>
|
||||
{swatches}
|
||||
</Grid>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
<Text>
|
||||
{t('suffix', {
|
||||
color: type[0].toUpperCase() + type.slice(1),
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
31
src/components/Settings/Common/CommonSettings.tsx
Normal file
31
src/components/Settings/Common/CommonSettings.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ScrollArea, Space, Stack, Text } from '@mantine/core';
|
||||
import { useViewportSize } from '@mantine/hooks';
|
||||
import { useConfigContext } from '../../../config/provider';
|
||||
import ConfigChanger from '../../Config/ConfigChanger';
|
||||
import ConfigActions from './Config/ConfigActions';
|
||||
import LanguageSelect from './Language/LanguageSelect';
|
||||
import { SearchEngineSelector } from './SearchEngine/SearchEngineSelector';
|
||||
|
||||
export default function CommonSettings() {
|
||||
const { config } = useConfigContext();
|
||||
const { height, width } = useViewportSize();
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<Text color="red" align="center">
|
||||
No active config
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ScrollArea style={{ height: height - 100 }} offsetScrollbars>
|
||||
<Stack>
|
||||
<SearchEngineSelector searchEngine={config.settings.common.searchEngine} />
|
||||
<Space />
|
||||
<LanguageSelect />
|
||||
<ConfigChanger />
|
||||
<ConfigActions />
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
80
src/components/Settings/Common/Config/ConfigActions.tsx
Normal file
80
src/components/Settings/Common/Config/ConfigActions.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { ActionIcon, Center, createStyles, Flex, Text, useMantineTheme } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconCopy, IconDownload, IconTrash } from '@tabler/icons';
|
||||
import fileDownload from 'js-file-download';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useConfigContext } from '../../../../config/provider';
|
||||
import { useDeleteConfigMutation } from '../../../../tools/config/mutations/useDeleteConfigMutation';
|
||||
import Tip from '../../../layout/Tip';
|
||||
import { CreateConfigCopyModal } from './CreateCopyModal';
|
||||
|
||||
export default function ConfigActions() {
|
||||
const { t } = useTranslation(['settings/general/config-changer', 'settings/common']);
|
||||
const [createCopyModalOpened, createCopyModal] = useDisclosure(false);
|
||||
const { config } = useConfigContext();
|
||||
const { mutateAsync } = useDeleteConfigMutation(config?.configProperties.name ?? 'default');
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
const handleDownload = () => {
|
||||
// TODO: remove secrets
|
||||
fileDownload(JSON.stringify(config, null, '\t'), `${config?.configProperties.name}.json`);
|
||||
};
|
||||
|
||||
const handleDeletion = async () => {
|
||||
await mutateAsync();
|
||||
};
|
||||
|
||||
const { classes } = useStyles();
|
||||
const { colors } = useMantineTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateConfigCopyModal
|
||||
opened={createCopyModalOpened}
|
||||
closeModal={createCopyModal.close}
|
||||
initialConfigName={config.configProperties.name}
|
||||
/>
|
||||
<Flex gap="xs" mt="xs" justify="stretch">
|
||||
<ActionIcon className={classes.actionIcon} onClick={handleDownload} variant="default">
|
||||
<IconDownload size={20} />
|
||||
<Text size="sm">{t('buttons.download')}</Text>
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
className={classes.actionIcon}
|
||||
onClick={handleDeletion}
|
||||
color="red"
|
||||
variant="light"
|
||||
>
|
||||
<IconTrash color={colors.red[2]} size={20} />
|
||||
<Text size="sm">{t('buttons.delete.text')}</Text>
|
||||
</ActionIcon>
|
||||
<ActionIcon className={classes.actionIcon} onClick={createCopyModal.open} variant="default">
|
||||
<IconCopy size={20} />
|
||||
<Text size="sm">{t('buttons.saveCopy')}</Text>
|
||||
</ActionIcon>
|
||||
</Flex>
|
||||
|
||||
<Center>
|
||||
<Tip>{t('settings/common:tips.configTip')}</Tip>
|
||||
</Center>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
actionIcon: {
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
maxWidth: 'auto',
|
||||
maxHeight: 'auto',
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
rowGap: 10,
|
||||
padding: 10,
|
||||
},
|
||||
}));
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user