Merge v0.11 to dev

This commit is contained in:
Thomas Camlong
2023-01-08 14:28:14 +09:00
committed by GitHub
263 changed files with 11497 additions and 5842 deletions

View File

@@ -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,
},
};

View File

@@ -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

View File

@@ -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
}
}
}

View File

@@ -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;

View File

@@ -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,
});

View File

@@ -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": {

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}
}
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -1,12 +0,0 @@
{
"accordions": {
"downloads": {
"text": "Your downloads",
"torrents": "Your Torrent downloads",
"usenet": "Your Usenet downloads"
},
"others": {
"text": "Others"
}
}
}

View 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"
}
}

View File

@@ -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"
}
}

View File

@@ -0,0 +1,3 @@
{
"title": "{{position}} sidebar"
}

View 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"
}

View 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"
}
}
}

View 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}}"
}

View 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"
}
}

View File

@@ -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."
}
}
}

View File

@@ -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 @@
}
}
}
}
}

View File

@@ -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)"
}

View File

@@ -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"
}

View File

@@ -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..."
}
}
}

View File

@@ -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"
}
}
}
}

View File

@@ -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"
}
}

View File

@@ -1,6 +1,10 @@
{
"pageTitle": {
"label": "Page Title",
"placeholder": "Homarr"
},
"metaTitle": {
"label": "Meta Title",
"placeholder": "Homarr 🦞"
},
"logo": {

View File

@@ -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."
}
}

View File

@@ -1,3 +1,3 @@
{
"title": "Module enabler"
"title": "Enabled modules"
}

View File

@@ -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"
}
}

View 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',
},
}));

View File

@@ -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>
</>
);
}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -1,9 +0,0 @@
{
"Jellyseerr": "settings",
"Overseerr": "settings",
"Sonarr": "settings/general",
"Radarr": "settings/general",
"Readarr": "settings/general",
"Lidarr": "settings/general",
"Sabnzbd": "sabnzbd/config/general"
}

View File

@@ -1,2 +0,0 @@
export { default as AppShelf } from './AppShelf';
export * from './AppShelfItem';

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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[];

View File

@@ -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>
);
}
};

View File

@@ -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>
);
}

View 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 />
</>
);
};

View 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,
},
}));

View File

@@ -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>
);
};

View File

@@ -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)}%`,
};
});
};

View File

@@ -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;
};

View File

@@ -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`,
}));
};

View 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>
</>
);
};

View File

@@ -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,
},
}));

View File

@@ -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',
},
}));

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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',
},
}));

View File

@@ -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>
)
);

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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',
},
}));

View File

@@ -0,0 +1 @@
export type EditAppModalTab = 'general' | 'behaviour' | 'network' | 'appereance' | 'integration';

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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',
},
}));

View File

@@ -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&apos;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>
</>
);
};

View File

@@ -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>
</>
);
};

View File

@@ -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}
/>
);
};

View File

@@ -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 <></>;
}
};

View File

@@ -0,0 +1,5 @@
interface ServiceIconProps {
size: '100%' | number;
}
export const AppIcon = ({ size }: ServiceIconProps) => null;

View 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
/>
);
};

View 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';

View 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,
};

View File

@@ -0,0 +1,6 @@
import { HomarrCardWrapper } from './HomarrCardWrapper';
import { BaseTileProps } from './type';
export const EmptyTile = ({ className }: BaseTileProps) => (
<HomarrCardWrapper className={className}>Empty</HomarrCardWrapper>
);

View 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>
);
};

View 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"
/>
);
};

View 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(),
};
};

View 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;
}
}

View 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
}
/>
);
};

View File

@@ -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>
);
};

View File

@@ -0,0 +1,3 @@
export interface BaseTileProps {
className?: string;
}

View 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]
);
};

View File

@@ -0,0 +1,3 @@
import { DashboardView } from './DashboardView';
export const DashboardDetailView = () => <DashboardView />;

View File

@@ -0,0 +1,3 @@
import { DashboardView } from './DashboardView';
export const DashboardEditView = () => <DashboardView />;

View 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>
);
};

View 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 })),
}));

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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;
};

View File

@@ -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,
};
};

View 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;

View 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>
);
};

View 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>
);
})}
</>
);
}

View 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;
};

View 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';
}

View 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,
},
};
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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