diff --git a/.dockerignore b/.dockerignore index 125cd198c..13609ca43 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,5 +2,8 @@ Dockerfile .dockerignore node_modules npm-debug.log -README.md +*.md .git +.github +LICENSE +docs/ diff --git a/.eslintrc.js b/.eslintrc.js index abde8f7cb..16b883880 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -3,7 +3,6 @@ module.exports = { 'mantine', 'plugin:@next/next/recommended', 'plugin:jest/recommended', - 'plugin:storybook/recommended', "eslint:recommended", "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended" diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 8b973d92f..c83fcc939 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -22,13 +22,3 @@ body: - High (App breaking feature) validations: required: true - - type: checkboxes - id: idiot-check - attributes: - label: Please tick the boxes - description: Before submitting, please ensure that - options: - - label: You've read the [docs](https://github.com/ajnart/homarr#readme) - required: true - - label: You've checked for [duplicate issues](https://github.com/ajnart/homarr/issues) - required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a84bac79d..ad88029a1 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -14,10 +14,3 @@ ### Screenshot _(if applicable)_ > If you've introduced any significant UI changes, please include a screenshot. - -### Code Quality Checklist _(Please complete)_ -- [ ] All changes are backwards compatible -- [ ] There are no (new) build warnings or errors -- [ ] _(If a new config option is added)_ Attribute is outlined in the schema and documented -- [ ] _(If a new dependency is added)_ Package is essential, and has been checked out for security or performance -- [ ] Bumps version, if new feature added diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 2c8c0e1dd..40b6216ae 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,6 +1,8 @@ -name: Master docker CI -# Workflow to build and publish docker image - +name: Master CI +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. on: push: branches: [master] @@ -22,72 +24,46 @@ jobs: # Push image to GitHub Packages. # See also https://docs.docker.com/docker-hub/builds/ yarn_install_and_build: - # Will run yarn install && yarn build - runs-on: ubuntu-latest - steps: - - name: Setup - uses: actions/setup-node@v3 - - name: Checkout - uses: actions/checkout@v3 - - name: Get yarn cache directory path - # to help speed up build times - id: yarn-cache-dir-path - run: echo "::set-output name=dir::$(yarn cache dir)" - - name: Yarn cache - # to help speed up build times - uses: actions/cache@v3 - id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: ${{ runner.os }}-yarn- - - name: Nextjs cache - uses: actions/cache@v2 - with: - # See here for caching with `yarn` https://github.com/actions/cache/blob/main/examples.md#node---yarn or you can leverage caching with actions/setup-node https://github.com/actions/setup-node - path: | - ~/.npm - ${{ github.workspace }}/.next/cache - # Generate a new cache whenever packages or source files change. - key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }} - # If source files changed but packages didn't, rebuild from a prior cache. - restore-keys: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}- - - run: yarn install --immutable - - run: yarn build - - name: Cache build output - # to copy needed files to docker build job - uses: actions/cache@v2 - id: restore-build - with: - path: | - ./next.config.js - ./pages/ - ./public/ - ./.next/static/ - ./.next/standalone/ - ./packages.json - key: ${{ github.sha }} - - docker_image_build_and_push: - needs: [yarn_install_and_build] runs-on: ubuntu-latest permissions: packages: write contents: read steps: + + - name: Setup + uses: actions/setup-node@v3 + - name: Checkout - uses: actions/checkout@v2 - - uses: actions/cache@v2 - id: restore-build + uses: actions/checkout@v3 + + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "::set-output name=dir::$(yarn config get cacheFolder)" + + - uses: actions/cache@v3 + id: yarn-cache with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Restore NextJS cache + uses: actions/cache@v2 + with: + # See here for caching with `yarn` https://github.com/actions/cache/blob/main/examples.md#node---yarn or you can leverage caching with actions/setup-node https://github.com/actions/setup-node path: | - ./next.config.js - ./pages/ - ./public/ - ./.next/static/ - ./.next/standalone/ - ./packages.json - key: ${{ github.sha }} + ${{ github.workspace }}/.next/cache + # Generate a new cache whenever packages or source files change. + key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }} + # If source files changed but packages didn't, rebuild from a prior cache. + restore-keys: | + ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}- + + - run: yarn install --immutable + + - run: yarn build + - name: Docker meta id: meta uses: docker/metadata-action@v4 @@ -98,10 +74,13 @@ jobs: tags: | type=raw,value=latest type=pep440,pattern={{version}} + - name: Set up QEMU uses: docker/setup-qemu-action@v2 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 + - name: Login to GHCR uses: docker/login-action@v2 with: @@ -117,3 +96,5 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/docker_dev.yml b/.github/workflows/docker_dev.yml index 46c00e799..176ac912b 100644 --- a/.github/workflows/docker_dev.yml +++ b/.github/workflows/docker_dev.yml @@ -16,7 +16,7 @@ on: workflow_dispatch: inputs: tags: - requierd: true + required: true description: 'Tags to deploy to' env: @@ -30,6 +30,9 @@ jobs: # See also https://docs.docker.com/docker-hub/builds/ yarn_install_and_build: runs-on: ubuntu-latest + permissions: + packages: write + contents: read steps: - name: Setup @@ -40,68 +43,34 @@ jobs: - name: Get yarn cache directory path id: yarn-cache-dir-path - run: echo "::set-output name=dir::$(yarn cache dir)" - - - name: Yarn cache - uses: actions/cache@v3 - id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) + run: echo "::set-output name=dir::$(yarn config get cacheFolder)" + + - uses: actions/cache@v3 + id: yarn-cache with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: ${{ runner.os }}-yarn- + restore-keys: | + ${{ runner.os }}-yarn- - - name: Nextjs cache + - name: Restore NextJS cache uses: actions/cache@v2 with: - # See here for caching with `yarn` https://github.com/actions/cache/blob/main/examples.md#node---yarn or you can leverage caching with actions/setup-node https://github.com/actions/setup-node + # See here for caching with `yarn` https://github.com/actions/cache/blob/main/examples.md#node---yarn or you can leverage caching with actions/setup-node https://github.com/actions/setup-node path: | - ~/.npm ${{ github.workspace }}/.next/cache # Generate a new cache whenever packages or source files change. key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }} # If source files changed but packages didn't, rebuild from a prior cache. - restore-keys: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}- + restore-keys: | + ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}- - run: yarn install --immutable + - run: yarn build - - name: Cache build output - uses: actions/cache@v2 - id: restore-build - with: - path: | - ./next.config.js - ./pages/ - ./public/ - ./.next/static/ - ./.next/standalone/ - ./packages.json - key: ${{ github.sha }} - - docker_image_build_and_push: - needs: [yarn_install_and_build] - runs-on: ubuntu-latest - permissions: - packages: write - contents: read - steps: - - - name: Checkout - uses: actions/checkout@v2 - - - uses: actions/cache@v2 - id: restore-build - with: - path: | - ./next.config.js - ./pages/ - ./public/ - ./.next/static/ - ./.next/standalone/ - ./packages.json - key: ${{ github.sha }} - - name: Docker meta + if: github.event_name != 'pull_request' id: meta uses: docker/metadata-action@v4 with: @@ -127,6 +96,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push + if: github.event_name != 'pull_request' uses: docker/build-push-action@v3 with: platforms: linux/amd64,linux/arm64,linux/arm/v7 @@ -134,3 +104,5 @@ jobs: push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.storybook/main.js b/.storybook/main.js deleted file mode 100644 index e0e85fcfa..000000000 --- a/.storybook/main.js +++ /dev/null @@ -1,30 +0,0 @@ -module.exports = { - stories: ['../src/components/**/*.story.mdx', '../src/components/**/*.story.*'], - addons: [ - '@storybook/addon-links', - 'storybook-addon-mock/register', - '@storybook/addon-essentials', - ], - typescript: { - check: false, - reactDocgen: false, - }, - framework: '@storybook/react', - features: { emotionAlias: false }, - webpackFinal: async (config, { configType }) => { - // `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION' - // You can change the configuration based on that. - // 'PRODUCTION' is used when building the static version of storybook. - - // https://github.com/polkadot-js/extension/issues/621#issuecomment-759341776 - // framer-motion uses the .mjs notation and we need to include it so that webpack will - // transpile it for us correctly (enables using a CJS module inside an ESM). - config.module.rules.push({ - test: /\.mjs$/, - include: /node_modules/, - type: 'javascript/auto', - }); - // Return the altered config - return config; - }, -}; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx deleted file mode 100644 index 5023bb98f..000000000 --- a/.storybook/preview.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { MantineProvider, ColorSchemeProvider } from '@mantine/core'; -import { NotificationsProvider } from '@mantine/notifications'; - -export const parameters = { layout: 'fullscreen' }; - -function ThemeWrapper(props: { children: React.ReactNode }) { - return ( - {}}> - - {props.children} - - - ); -} - -export const decorators = [(renderStory: Function) => {renderStory()}]; diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..b826a6a46 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,28 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Next.js: debug server-side", + "type": "node-terminal", + "request": "launch", + "command": "yarn dev" + }, + { + "name": "Next.js: debug client-side", + "type": "chrome", + "request": "launch", + "url": "http://localhost:3000" + }, + { + "name": "Next.js: debug full stack", + "type": "node-terminal", + "request": "launch", + "command": "yarn dev", + "serverReadyAction": { + "pattern": "started server on .+, url: (https?://.+)", + "uriFormat": "%s", + "action": "debugWithChrome" + } + } + ] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 98fe3bdbe..9d2244dd1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,23 @@ FROM node:16-alpine WORKDIR /app + +RUN apk add tzdata + +ENV NEXT_TELEMETRY_DISABLED 1 + ENV NODE_ENV production -COPY /next.config.js ./ -COPY /public ./public -COPY /package.json ./package.json -# Automatically leverage output traces to reduce image size. https://nextjs.org/docs/advanced-features/output-file-tracing -COPY /.next/standalone ./ -COPY /.next/static ./.next/static + +COPY next.config.js ./ +COPY public ./public +COPY package.json ./package.json + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY .next/standalone ./ +COPY .next/static ./.next/static + EXPOSE 7575 + ENV PORT 7575 -VOLUME /app/data/configs + CMD ["node", "server.js"] diff --git a/README.md b/README.md index 1cdf298a9..4bc288f5b 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Join the discord! — Don't forget to star the repo if you are enjoying the project!

- Demo ↗️ Install ➡️ Read the Wiki 📄 + Demo ↗️ Install ➡️ Read the Docs 📄

--- @@ -33,7 +33,7 @@ Homarr is a simple and lightweight homepage for your server, that helps you easi It integrates with the services you use to display information on the homepage (E.g. Show upcoming Sonarr/Radarr releases). -For a full list of integrations look at: [wiki/integrations](#). +For a full list of integrations, [head over to our documentation](https://homarr.vercel.app/docs/advanced-features/integrations). If you have any questions about Homarr or want to share information with us, please go to one of the following places: @@ -42,7 +42,7 @@ If you have any questions about Homarr or want to share information with us, ple *Before you file an [issue](https://github.com/ajnart/homarr/issues/new/choose), make sure you have the read [known issues](#-known-issues) section.* -**For more information, [read the wiki!](https://github.com/ajnart/homarr/wiki)** +**For more information, [read the documentation!](https://homarr.vercel.app/docs/about)**
Table of Contents @@ -195,10 +195,7 @@ SOFTWARE. ---

- Thank you for visiting! For more information read the wiki! + Thank you for visiting! For more information read the documentation!

- - trackgit-views -

diff --git a/data/configs/default.json b/data/configs/default.json index ba6a91144..d159270a2 100644 --- a/data/configs/default.json +++ b/data/configs/default.json @@ -18,6 +18,9 @@ }, "Date": { "enabled": false + }, + "Docker": { + "enabled": true } } } \ No newline at end of file diff --git a/data/constants.ts b/data/constants.ts index 9151e0e49..cc319bc33 100644 --- a/data/constants.ts +++ b/data/constants.ts @@ -1,2 +1,2 @@ export const REPO_URL = 'ajnart/homarr'; -export const CURRENT_VERSION = 'v0.6.0'; +export const CURRENT_VERSION = 'v0.8.2'; diff --git a/next.config.js b/next.config.js index 31fc7b641..59a7bd7a8 100644 --- a/next.config.js +++ b/next.config.js @@ -6,11 +6,8 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({ module.exports = withBundleAnalyzer({ reactStrictMode: false, - eslint: { - ignoreDuringBuilds: true, - }, experimental: { outputStandalone: true, }, - basePath: env.BASE_URL, + output: 'standalone', }); diff --git a/package.json b/package.json index dd8eef488..fdd982af7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,8 @@ { "name": "homarr", - "version": "0.6.0", + "version": "0.8.2", "description": "Homarr - A homepage for your server.", + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/ajnart/homarr" @@ -19,68 +20,63 @@ "prettier:check": "prettier --check \"**/*.{ts,tsx}\"", "prettier:write": "prettier --write \"**/*.{ts,tsx}\"", "test": "npm run prettier:check && npm run lint && npm run typecheck && npm run jest", - "storybook": "start-storybook -p 7001", - "storybook:build": "build-storybook", "ci": "yarn test && yarn lint --fix && yarn typecheck && yarn prettier:write" }, "dependencies": { - "@ctrl/deluge": "^4.0.0", - "@ctrl/qbittorrent": "^4.0.0", - "@ctrl/shared-torrent": "^4.1.0", - "@dnd-kit/core": "^6.0.1", - "@dnd-kit/sortable": "^7.0.0", + "@ctrl/deluge": "^4.1.0", + "@ctrl/qbittorrent": "^4.1.0", + "@ctrl/shared-torrent": "^4.1.1", + "@ctrl/transmission": "^4.1.1", + "@dnd-kit/core": "^6.0.5", + "@dnd-kit/sortable": "^7.0.1", "@dnd-kit/utilities": "^3.2.0", - "@mantine/core": "^4.2.6", - "@mantine/dates": "^4.2.6", - "@mantine/dropzone": "^4.2.6", - "@mantine/form": "^4.2.6", - "@mantine/hooks": "^4.2.6", - "@mantine/next": "^4.2.6", - "@mantine/notifications": "^4.2.6", - "@mantine/prism": "^4.2.6", + "@mantine/core": "^4.2.12", + "@mantine/dates": "^4.2.12", + "@mantine/dropzone": "^4.2.12", + "@mantine/form": "^4.2.12", + "@mantine/hooks": "^4.2.12", + "@mantine/next": "^4.2.12", + "@mantine/notifications": "^4.2.12", + "@mantine/prism": "^4.2.12", "@nivo/core": "^0.79.0", "@nivo/line": "^0.79.1", - "@tabler/icons": "^1.68.0", + "@tabler/icons": "^1.78.0", "axios": "^0.27.2", - "cookies-next": "^2.0.4", - "dayjs": "^1.11.2", - "framer-motion": "^6.3.1", + "cookies-next": "^2.1.1", + "dayjs": "^1.11.4", + "dockerode": "^3.3.2", + "framer-motion": "^6.5.1", "js-file-download": "^0.4.12", "next": "12.1.6", - "prism-react-renderer": "^1.3.1", + "prism-react-renderer": "^1.3.5", "react": "^17.0.1", "react-dom": "^17.0.1", + "systeminformation": "^5.12.1", "uuid": "^8.3.2" }, "devDependencies": { - "@babel/core": "^7.17.8", "@next/bundle-analyzer": "^12.1.4", "@next/eslint-plugin-next": "^12.1.4", - "@storybook/react": "^6.5.4", - "@types/node": "^17.0.23", - "@types/react": "17.0.43", + "@types/dockerode": "^3.3.9", + "@types/node": "17.0.1", + "@types/react": "17.0.1", "@types/uuid": "^8.3.4", - "@typescript-eslint/eslint-plugin": "^5.16.0", - "@typescript-eslint/parser": "^5.16.0", - "eslint": "^8.11.0", + "@typescript-eslint/eslint-plugin": "^5.30.7", + "@typescript-eslint/parser": "^5.30.7", + "eslint": "^8.20.0", "eslint-config-airbnb": "^19.0.4", - "eslint-config-airbnb-typescript": "^16.1.0", - "eslint-config-mantine": "1.1.0", - "eslint-plugin-import": "^2.25.4", - "eslint-plugin-jest": "^26.1.3", - "eslint-plugin-jsx-a11y": "^6.5.1", - "eslint-plugin-react": "^7.29.4", - "eslint-plugin-react-hooks": "^4.3.0", - "eslint-plugin-storybook": "^0.5.11", - "eslint-plugin-testing-library": "^5.2.0", + "eslint-config-airbnb-typescript": "^17.0.0", + "eslint-config-mantine": "^2.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-jest": "^26.6.0", + "eslint-plugin-jsx-a11y": "^6.6.1", + "eslint-plugin-react": "^7.30.1", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-testing-library": "^5.5.1", "eslint-plugin-unused-imports": "^2.0.0", - "jest": "^28.1.0", - "prettier": "^2.6.2", - "require-from-string": "^2.0.2", - "typescript": "4.6.4" - }, - "resolutions": { - "@types/react": "17.0.30" + "jest": "^28.1.3", + "prettier": "^2.7.1", + "typescript": "^4.7.4" }, "packageManager": "yarn@3.2.1" } diff --git a/src/components/AppShelf/AddAppShelfItem.tsx b/src/components/AppShelf/AddAppShelfItem.tsx index ba0cca0a1..6926356e0 100644 --- a/src/components/AppShelf/AddAppShelfItem.tsx +++ b/src/components/AppShelf/AddAppShelfItem.tsx @@ -1,25 +1,30 @@ import { - Modal, + ActionIcon, + Anchor, + Button, Center, Group, - TextInput, Image, - Button, - Select, LoadingOverlay, - ActionIcon, - Tooltip, + Modal, + MultiSelect, + ScrollArea, + Select, + Switch, + Tabs, + TextInput, Title, - Anchor, - Text, + Tooltip, } from '@mantine/core'; import { useForm } from '@mantine/form'; -import { useEffect, useState } from 'react'; +import { useDebouncedValue } from '@mantine/hooks'; import { IconApps as Apps } from '@tabler/icons'; +import { useEffect, useState } from 'react'; import { v4 as uuidv4 } from 'uuid'; import { useDebouncedValue } from '@mantine/hooks'; import { useConfig } from '../../tools/state'; -import { ServiceTypeList } from '../../tools/types'; +import { tryMatchPort, ServiceTypeList, StatusCodes } from '../../tools/types'; +import Tip from '../layout/Tip'; export function AddItemShelfButton(props: any) { const [opened, setOpened] = useState(false); @@ -50,11 +55,13 @@ export function AddItemShelfButton(props: any) { ); } -function MatchIcon(name: string, form: any) { +function MatchIcon(name: string | undefined, form: any) { + if (name === undefined || name === '') return null; fetch( `https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/${name .replace(/\s+/g, '-') - .toLowerCase()}.png` + .toLowerCase() + .replace(/^dash\.$/, 'dashdot')}.png` ).then((res) => { if (res.ok) { form.setFieldValue('icon', res.url); @@ -65,28 +72,13 @@ function MatchIcon(name: string, form: any) { } function MatchService(name: string, form: any) { - const service = ServiceTypeList.find((s) => s === name); + const service = ServiceTypeList.find((s) => s.toLowerCase() === name.toLowerCase()); if (service) { form.setFieldValue('type', service); } } -function MatchPort(name: string, form: any) { - const portmap = [ - { name: 'qBittorrent', value: '8080' }, - { name: 'Sonarr', value: '8989' }, - { name: 'Radarr', value: '7878' }, - { name: 'Lidarr', value: '8686' }, - { name: 'Readarr', value: '8686' }, - { name: 'Deluge', value: '8112' }, - { name: 'Transmission', value: '9091' }, - ]; - // Match name with portmap key - const port = portmap.find((p) => p.name === name); - if (port) { - form.setFieldValue('url', `http://localhost:${port.value}`); - } -} +const DEFAULT_ICON = '/favicon.svg'; export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } & any) { const { setOpened } = props; @@ -107,22 +99,21 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } & type: props.type ?? 'Other', category: props.category ?? undefined, name: props.name ?? '', - icon: props.icon ?? '/favicon.svg', + icon: props.icon ?? DEFAULT_ICON, url: props.url ?? '', apiKey: props.apiKey ?? (undefined as unknown as string), username: props.username ?? (undefined as unknown as string), password: props.password ?? (undefined as unknown as string), + openedUrl: props.openedUrl ?? (undefined as unknown as string), + status: props.status ?? ['200'], + newTab: props.newTab ?? true, }, validate: { apiKey: () => null, // Validate icon with a regex - icon: (value: string) => { - // Regex to match everything that ends with and icon extension - if (!value.match(/\.(png|jpg|jpeg|gif|svg)$/)) { - return 'Please enter a valid icon URL'; - } - return null; - }, + icon: (value: string) => + // Disable matching to allow any values + null, // Validate url with a regex http/https url: (value: string) => { try { @@ -132,15 +123,21 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } & } return null; }, + status: (value: string[]) => { + if (!value.length) { + return 'Please select a status code'; + } + return null; + }, }, }); const [debounced, cancel] = useDebouncedValue(form.values.name, 250); useEffect(() => { - if (form.values.name !== debounced) return; + if (form.values.name !== debounced || form.values.icon !== DEFAULT_ICON) return; MatchIcon(form.values.name, form); MatchService(form.values.name, form); - MatchPort(form.values.name, form); + tryMatchPort(form.values.name, form); }, [debounced]); // Try to set const hostname to new URL(form.values.url).hostname) @@ -166,6 +163,12 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
{ + if (JSON.stringify(form.values.status) === JSON.stringify(['200'])) { + form.values.status = undefined; + } + if (form.values.newTab === true) { + form.values.newTab = undefined; + } // If service already exists, update it. if (config.services && config.services.find((s) => s.id === form.values.id)) { setConfig({ @@ -190,126 +193,171 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } & form.reset(); })} > - - + + + + + - - - { - e.preventDefault(); - }} - getCreateLabel={(query) => `+ Create "${query}"`} - onCreate={(query) => {}} - {...form.getInputProps('category')} - /> - - {(form.values.type === 'Sonarr' || - form.values.type === 'Radarr' || - form.values.type === 'Lidarr' || - form.values.type === 'Readarr') && ( - <> - + + + { + e.preventDefault(); + }} + getCreateLabel={(query) => `+ Create "${query}"`} + onCreate={(query) => {}} + {...form.getInputProps('category')} + /> + + {(form.values.type === 'Sonarr' || + form.values.type === 'Radarr' || + form.values.type === 'Lidarr' || + form.values.type === 'Readarr') && ( + <> + { + form.setFieldValue('apiKey', event.currentTarget.value); + }} + error={form.errors.apiKey && 'Invalid API key'} + /> + + Get your API key{' '} + + here. + + + + )} + {form.values.type === 'qBittorrent' && ( + <> + { + form.setFieldValue('username', event.currentTarget.value); + }} + error={form.errors.username && 'Invalid username'} + /> + { + form.setFieldValue('password', event.currentTarget.value); + }} + error={form.errors.password && 'Invalid password'} + /> + + )} + {form.values.type === 'Deluge' && ( + <> + { + form.setFieldValue('password', event.currentTarget.value); + }} + error={form.errors.password && 'Invalid password'} + /> + + )} + {form.values.type === 'Transmission' && ( + <> + { + form.setFieldValue('username', event.currentTarget.value); + }} + error={form.errors.username && 'Invalid username'} + /> + { + form.setFieldValue('password', event.currentTarget.value); + }} + error={form.errors.password && 'Invalid password'} + /> + + )} + + + + + + { - form.setFieldValue('apiKey', event.currentTarget.value); - }} - error={form.errors.apiKey && 'Invalid API key'} + label="HTTP Status Codes" + data={StatusCodes} + placeholder="Select valid status codes" + clearButtonLabel="Clear selection" + nothingFound="Nothing found" + defaultValue={['200']} + clearable + searchable + {...form.getInputProps('status')} /> - - tip: Get your API key{' '} - - here - - - - )} - {form.values.type === 'qBittorrent' && ( - <> - { - form.setFieldValue('username', event.currentTarget.value); - }} - error={form.errors.username && 'Invalid username'} + - { - form.setFieldValue('password', event.currentTarget.value); - }} - error={form.errors.password && 'Invalid password'} - /> - - )} - {form.values.type === 'Deluge' && ( - <> - { - form.setFieldValue('password', event.currentTarget.value); - }} - error={form.errors.password && 'Invalid password'} - /> - - )} - - + + + diff --git a/src/components/AppShelf/AppShelf.story.tsx b/src/components/AppShelf/AppShelf.story.tsx deleted file mode 100644 index c73a42f19..000000000 --- a/src/components/AppShelf/AppShelf.story.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { SimpleGrid } from '@mantine/core'; -import AppShelf from './AppShelf'; -import { AppShelfItem } from './AppShelfItem'; - -export default { - title: 'Item Shelf', - component: AppShelf, - args: { - service: { - name: 'qBittorrent', - url: 'http://', - icon: 'https://cdn.jsdelivr.net/gh/IceWhaleTech/CasaOS-AppStore@main/Apps/qBittorrent/icon.png', - type: 'qBittorrent', - apiKey: '', - }, - }, -}; - -export const Default = (args: any) => ; -export const One = (args: any) => ; -export const Ten = (args: any) => ( - - {Array.from(Array(10)).map((_, i) => ( - - ))} - -); diff --git a/src/components/AppShelf/AppShelf.tsx b/src/components/AppShelf/AppShelf.tsx index 21793972d..2d461663f 100644 --- a/src/components/AppShelf/AppShelf.tsx +++ b/src/components/AppShelf/AppShelf.tsx @@ -1,28 +1,74 @@ import React, { useState } from 'react'; -import { Grid, Group, Title } from '@mantine/core'; +import { Accordion, createStyles, Grid, Group, Paper, 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 { useConfig } from '../../tools/state'; import { SortableAppShelfItem, AppShelfItem } from './AppShelfItem'; -import { ModuleWrapper } from '../modules/moduleWrapper'; -import { DownloadsModule } from '../modules'; +import { ModuleMenu, ModuleWrapper } from '../../modules/moduleWrapper'; +import { DownloadsModule } from '../../modules'; +import DownloadComponent from '../../modules/downloads/DownloadsModule'; + +const useStyles = createStyles((theme, _params) => ({ + item: { + overflow: 'hidden', + borderLeft: '3px solid transparent', + borderRight: '3px solid transparent', + borderBottom: '3px solid transparent', + borderRadius: '20px', + borderColor: theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1], + marginTop: theme.spacing.md, + }, + + control: { + backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1], + borderRadius: theme.spacing.md, + + '&:hover': { + backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1], + }, + }, + + content: { + margin: theme.spacing.md, + }, + + label: { + overflow: 'visible', + }, +})); const AppShelf = (props: any) => { + const { classes, cx } = useStyles(props); + const [toggledCategories, settoggledCategories] = useLocalStorage({ + key: 'app-shelf-toggled', + // This is a bit of a hack to get the 5 first categories to be toggled on by default + defaultValue: { 0: true, 1: true, 2: true, 3: true, 4: true } as Record, + }); const [activeId, setActiveId] = useState(null); const { config, setConfig } = useConfig(); + const { colorScheme } = useMantineColorScheme(); + const sensors = useSensors( + useSensor(TouchSensor, { + activationConstraint: { + delay: 500, + tolerance: 5, + }, + }), useSensor(MouseSensor, { // Require the mouse to move by 10 pixels before activating activationConstraint: { - delay: 250, + delay: 500, tolerance: 5, }, }) @@ -75,7 +121,14 @@ const AppShelf = (props: any) => { {filtered.map((service) => ( - + ))} @@ -99,26 +152,49 @@ const AppShelf = (props: any) => { const noCategory = config.services.filter( (e) => e.category === undefined || e.category === null ); - + const downloadEnabled = config.modules?.[DownloadsModule.title]?.enabled ?? false; + // Create an item with 0: true, 1: true, 2: true... For each category return ( // Return one item for each category - {categoryList.map((category) => ( - <> - - {category} - - {item(category)} - - ))} - {/* Return the item for all services without category */} - {noCategory && noCategory.length > 0 ? ( - <> - Other - {item()} - - ) : null} - + settoggledCategories(idx)} + > + {categoryList.map((category, idx) => ( + + {item(category)} + + ))} + {/* Return the item for all services without category */} + {noCategory && noCategory.length > 0 ? ( + + {item()} + + ) : null} + {downloadEnabled ? ( + + + + + + + ) : null} + ); } diff --git a/src/components/AppShelf/AppShelfItem.tsx b/src/components/AppShelf/AppShelfItem.tsx index 047caa9dc..e611166c3 100644 --- a/src/components/AppShelf/AppShelfItem.tsx +++ b/src/components/AppShelf/AppShelfItem.tsx @@ -1,11 +1,21 @@ -import { Text, Card, Anchor, AspectRatio, Image, Center, createStyles } from '@mantine/core'; +import { + Text, + Card, + Anchor, + AspectRatio, + Image, + Center, + createStyles, + useMantineColorScheme, +} from '@mantine/core'; import { motion } from 'framer-motion'; import { useState } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { serviceItem } from '../../tools/types'; -import PingComponent from '../modules/ping/PingModule'; +import PingComponent from '../../modules/ping/PingModule'; import AppShelfMenu from './AppShelfMenu'; +import { useConfig } from '../../tools/state'; const useStyles = createStyles((theme) => ({ item: { @@ -15,6 +25,9 @@ const useStyles = createStyles((theme) => ({ boxShadow: `${theme.shadows.md} !important`, transform: 'scale(1.05)', }, + [theme.fn.smallerThan('sm')]: { + WebkitUserSelect: 'none', + }, }, })); @@ -38,7 +51,9 @@ export function SortableAppShelfItem(props: any) { export function AppShelfItem(props: any) { const { service }: { service: serviceItem } = props; const [hovering, setHovering] = useState(false); - const { classes, theme } = useStyles(); + const { config } = useConfig(); + const { colorScheme } = useMantineColorScheme(); + const { classes } = useStyles(); return ( - + @@ -91,22 +117,24 @@ export function AppShelfItem(props: any) { > { - window.open(service.url); + if (service.openedUrl) { + window.open(service.openedUrl, service.newTab === false ? '_top' : '_blank'); + } else window.open(service.url, service.newTab === false ? '_top' : '_blank'); }} /> - + diff --git a/src/components/AppShelf/AppShelfMenu.tsx b/src/components/AppShelf/AppShelfMenu.tsx index 5f75c5257..78f9b10ad 100644 --- a/src/components/AppShelf/AppShelfMenu.tsx +++ b/src/components/AppShelf/AppShelfMenu.tsx @@ -20,19 +20,7 @@ export default function AppShelfMenu(props: any) { onClose={() => setOpened(false)} title="Modify a service" > - + - Service {service.name} removed successfully + Service {service.name} removed successfully! ), color: 'green', diff --git a/src/components/ColorSchemeToggle/ColorSchemeSwitch.tsx b/src/components/ColorSchemeToggle/ColorSchemeSwitch.tsx index 0da7ae998..410dcbaf2 100644 --- a/src/components/ColorSchemeToggle/ColorSchemeSwitch.tsx +++ b/src/components/ColorSchemeToggle/ColorSchemeSwitch.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { createStyles, Switch, Group, useMantineColorScheme, Kbd } from '@mantine/core'; import { IconSun as Sun, IconMoonStars as MoonStars } from '@tabler/icons'; +import { useConfig } from '../../tools/state'; const useStyles = createStyles((theme) => ({ root: { @@ -29,6 +30,7 @@ const useStyles = createStyles((theme) => ({ })); export function ColorSchemeSwitch() { + const { config } = useConfig(); const { colorScheme, toggleColorScheme } = useMantineColorScheme(); const { classes, cx } = useStyles(); diff --git a/src/components/Config/ConfigChanger.tsx b/src/components/Config/ConfigChanger.tsx index 5b8836803..14e8cf5e5 100644 --- a/src/components/Config/ConfigChanger.tsx +++ b/src/components/Config/ConfigChanger.tsx @@ -1,5 +1,5 @@ import { Center, Loader, Select, Tooltip } from '@mantine/core'; -import { setCookies } from 'cookies-next'; +import { setCookie } from 'cookies-next'; import { useEffect, useState } from 'react'; import { useConfig } from '../../tools/state'; @@ -26,7 +26,10 @@ export default function ConfigChanger() { label="Config loader" onChange={(e) => { loadConfig(e ?? 'default'); - setCookies('config-name', e ?? 'default', { maxAge: 60 * 60 * 24 * 30 }); + setCookie('config-name', e ?? 'default', { + maxAge: 60 * 60 * 24 * 30, + sameSite: 'strict', + }); }} data={ // If config list is empty, return the current config diff --git a/src/components/Config/LoadConfig.tsx b/src/components/Config/LoadConfig.tsx index 1dbed5988..6935c1f72 100644 --- a/src/components/Config/LoadConfig.tsx +++ b/src/components/Config/LoadConfig.tsx @@ -10,7 +10,7 @@ import { DropzoneStatus, FullScreenDropzone } from '@mantine/dropzone'; import { showNotification } from '@mantine/notifications'; import { useRef } from 'react'; import { useRouter } from 'next/router'; -import { setCookies } from 'cookies-next'; +import { setCookie } from 'cookies-next'; import { useConfig } from '../../tools/state'; import { Config } from '../../tools/types'; import { migrateToIdConfig } from '../../tools/migrate'; @@ -90,7 +90,10 @@ export default function LoadConfigComponent(props: any) { icon: , message: undefined, }); - setCookies('config-name', newConfig.name, { maxAge: 60 * 60 * 24 * 30 }); + setCookie('config-name', newConfig.name, { + maxAge: 60 * 60 * 24 * 30, + sameSite: 'strict', + }); const migratedConfig = migrateToIdConfig(newConfig); setConfig(migratedConfig); }); diff --git a/src/components/Config/SaveConfig.tsx b/src/components/Config/SaveConfig.tsx index cb9dab76d..d18b38ae2 100644 --- a/src/components/Config/SaveConfig.tsx +++ b/src/components/Config/SaveConfig.tsx @@ -27,7 +27,7 @@ export default function SaveConfigComponent(props: any) { } } return ( - + - - ); diff --git a/src/components/Settings/AdvancedSettings.tsx b/src/components/Settings/AdvancedSettings.tsx new file mode 100644 index 000000000..4c7d6a50e --- /dev/null +++ b/src/components/Settings/AdvancedSettings.tsx @@ -0,0 +1,65 @@ +import { TextInput, Group, Button } from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { useConfig } from '../../tools/state'; +import { ColorSelector } from './ColorSelector'; +import { OpacitySelector } from './OpacitySelector'; +import { AppCardWidthSelector } from './AppCardWidthSelector'; +import { ShadeSelector } from './ShadeSelector'; + +export default function TitleChanger() { + const { config, setConfig } = useConfig(); + + const form = useForm({ + initialValues: { + title: config.settings.title, + logo: config.settings.logo, + favicon: config.settings.favicon, + background: config.settings.background, + }, + }); + + const saveChanges = (values: { + title?: string; + logo?: string; + favicon?: string; + background?: string; + }) => { + setConfig({ + ...config, + settings: { + ...config.settings, + title: values.title, + logo: values.logo, + favicon: values.favicon, + background: values.background, + }, + }); + }; + + return ( + +
saveChanges(values))}> + + + + + + + +
+ + + + + +
+ ); +} diff --git a/src/components/Settings/AppCardWidthSelector.tsx b/src/components/Settings/AppCardWidthSelector.tsx new file mode 100644 index 000000000..945778e67 --- /dev/null +++ b/src/components/Settings/AppCardWidthSelector.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Group, Text, Slider } from '@mantine/core'; +import { useConfig } from '../../tools/state'; + +export function AppCardWidthSelector() { + const { config, setConfig } = useConfig(); + + const setappCardWidth = (appCardWidth: number) => { + setConfig({ + ...config, + settings: { + ...config.settings, + appCardWidth, + }, + }); + }; + + return ( + + App Width + setappCardWidth(value)} + /> + + ); +} diff --git a/src/components/Settings/ColorSelector.tsx b/src/components/Settings/ColorSelector.tsx new file mode 100644 index 000000000..e7f175b3d --- /dev/null +++ b/src/components/Settings/ColorSelector.tsx @@ -0,0 +1,96 @@ +import React, { useState } from 'react'; +import { ColorSwatch, Group, Popover, Text, useMantineTheme } from '@mantine/core'; +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 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 }) => ( + setConfigColor(color)} + key={color} + color={swatch} + size={22} + style={{ color: theme.white, cursor: 'pointer' }} + /> + )); + + return ( + + setOpened(false)} + transitionDuration={0} + target={ + setOpened((o) => !o)} + size={22} + style={{ display: 'block', cursor: 'pointer' }} + /> + } + styles={{ + root: { + marginRight: theme.spacing.xs, + }, + body: { + width: 152, + backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.white, + }, + arrow: { + backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.white, + }, + }} + position="bottom" + placement="end" + withArrow + arrowSize={3} + > + {swatches} + + {type[0].toUpperCase() + type.slice(1)} color + + ); +} diff --git a/src/components/Settings/CommonSettings.tsx b/src/components/Settings/CommonSettings.tsx new file mode 100644 index 000000000..4d55eee18 --- /dev/null +++ b/src/components/Settings/CommonSettings.tsx @@ -0,0 +1,86 @@ +import { Group, Text, SegmentedControl, TextInput } from '@mantine/core'; +import { useState } from 'react'; +import { useConfig } from '../../tools/state'; +import { ColorSchemeSwitch } from '../ColorSchemeToggle/ColorSchemeSwitch'; +import { WidgetsPositionSwitch } from '../WidgetsPositionSwitch/WidgetsPositionSwitch'; +import ConfigChanger from '../Config/ConfigChanger'; +import SaveConfigComponent from '../Config/SaveConfig'; +import ModuleEnabler from './ModuleEnabler'; +import Tip from '../layout/Tip'; + +export default function CommonSettings(args: any) { + const { config, setConfig } = useConfig(); + + const matches = [ + { label: 'Google', value: 'https://google.com/search?q=' }, + { label: 'DuckDuckGo', value: 'https://duckduckgo.com/?q=' }, + { label: 'Bing', value: 'https://bing.com/search?q=' }, + { label: 'Custom', value: 'Custom' }, + ]; + + const [customSearchUrl, setCustomSearchUrl] = useState(config.settings.searchUrl); + const [searchUrl, setSearchUrl] = useState( + matches.find((match) => match.value === config.settings.searchUrl)?.value ?? 'Custom' + ); + + return ( + + + Search engine + + Use the prefixes !yt and !t in front of your query to search on YouTube or + for a Torrent respectively. + + { + setSearchUrl(e); + setConfig({ + ...config, + settings: { + ...config.settings, + searchUrl: e, + }, + }); + } + } + data={matches} + /> + {searchUrl === 'Custom' && ( + <> + %s can be used as a placeholder for the query. + { + setCustomSearchUrl(event.currentTarget.value); + setConfig({ + ...config, + settings: { + ...config.settings, + searchUrl: event.currentTarget.value, + }, + }); + }} + /> + + )} + + + + + + + Upload your config file by dragging and dropping it onto the page! + + ); +} diff --git a/src/components/Settings/Credits.tsx b/src/components/Settings/Credits.tsx new file mode 100644 index 000000000..1d6271479 --- /dev/null +++ b/src/components/Settings/Credits.tsx @@ -0,0 +1,44 @@ +import { Group, ActionIcon, Anchor, Text } from '@mantine/core'; +import { IconBrandDiscord, IconBrandGithub } from '@tabler/icons'; +import { CURRENT_VERSION } from '../../../data/constants'; + +export default function Credits(props: any) { + return ( + + + component="a" href="https://github.com/ajnart/homarr" size="lg"> + + + + {CURRENT_VERSION} + + + + + Made with ❤️ by @ + + ajnart + + + component="a" href="https://discord.gg/aCsmEV5RgA" size="lg"> + + + + + ); +} diff --git a/src/components/Settings/ModuleEnabler.tsx b/src/components/Settings/ModuleEnabler.tsx index c3b69f7e7..4e11e6065 100644 --- a/src/components/Settings/ModuleEnabler.tsx +++ b/src/components/Settings/ModuleEnabler.tsx @@ -1,5 +1,5 @@ -import { Group, Switch } from '@mantine/core'; -import * as Modules from '../modules'; +import { Checkbox, Group, SimpleGrid, Title } from '@mantine/core'; +import * as Modules from '../../modules'; import { useConfig } from '../../tools/state'; export default function ModuleEnabler(props: any) { @@ -7,26 +7,29 @@ export default function ModuleEnabler(props: any) { const modules = Object.values(Modules).map((module) => module); return ( - {modules.map((module) => ( - { - setConfig({ - ...config, - modules: { - ...config.modules, - [module.title]: { - ...config.modules?.[module.title], - enabled: e.currentTarget.checked, + Module enabler + + {modules.map((module) => ( + { + setConfig({ + ...config, + modules: { + ...config.modules, + [module.title]: { + ...config.modules?.[module.title], + enabled: e.currentTarget.checked, + }, }, - }, - }); - }} - /> - ))} + }); + }} + /> + ))} + ); } diff --git a/src/components/Settings/OpacitySelector.tsx b/src/components/Settings/OpacitySelector.tsx new file mode 100644 index 000000000..f94225cd8 --- /dev/null +++ b/src/components/Settings/OpacitySelector.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { Group, Text, Slider } from '@mantine/core'; +import { useConfig } from '../../tools/state'; + +export function OpacitySelector() { + const { config, setConfig } = useConfig(); + + const MARKS = [ + { value: 10, label: '10' }, + { value: 20, label: '20' }, + { value: 30, label: '30' }, + { value: 40, label: '40' }, + { value: 50, label: '50' }, + { value: 60, label: '60' }, + { value: 70, label: '70' }, + { value: 80, label: '80' }, + { value: 90, label: '90' }, + { value: 100, label: '100' }, + ]; + + const setConfigOpacity = (opacity: number) => { + setConfig({ + ...config, + settings: { + ...config.settings, + appOpacity: opacity, + }, + }); + }; + + return ( + + App Opacity + setConfigOpacity(value)} + /> + + ); +} diff --git a/src/components/Settings/SettingsMenu.story.tsx b/src/components/Settings/SettingsMenu.story.tsx deleted file mode 100644 index de3d13c54..000000000 --- a/src/components/Settings/SettingsMenu.story.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { SettingsMenuButton } from './SettingsMenu'; - -export default { - title: ' menu', - args: { - opened: false, - }, -}; - -export const Default = (args: any) => ; diff --git a/src/components/Settings/SettingsMenu.tsx b/src/components/Settings/SettingsMenu.tsx index 9bdf29b3e..fcd6d1b91 100644 --- a/src/components/Settings/SettingsMenu.tsx +++ b/src/components/Settings/SettingsMenu.tsx @@ -1,131 +1,25 @@ -import { - ActionIcon, - Group, - Title, - Text, - Tooltip, - SegmentedControl, - TextInput, - Drawer, - Anchor, -} from '@mantine/core'; -import { useColorScheme, useHotkeys } from '@mantine/hooks'; +import { ActionIcon, Title, Tooltip, Drawer, Tabs, ScrollArea } from '@mantine/core'; +import { useHotkeys } from '@mantine/hooks'; import { useState } from 'react'; -import { IconBrandGithub as BrandGithub, IconSettings } from '@tabler/icons'; -import { CURRENT_VERSION } from '../../../data/constants'; -import { useConfig } from '../../tools/state'; -import { ColorSchemeSwitch } from '../ColorSchemeToggle/ColorSchemeSwitch'; -import ConfigChanger from '../Config/ConfigChanger'; -import SaveConfigComponent from '../Config/SaveConfig'; -import ModuleEnabler from './ModuleEnabler'; +import { IconSettings } from '@tabler/icons'; +import AdvancedSettings from './AdvancedSettings'; +import CommonSettings from './CommonSettings'; +import Credits from './Credits'; function SettingsMenu(props: any) { - const { config, setConfig } = useConfig(); - const colorScheme = useColorScheme(); - const { current, latest } = props; - - const matches = [ - { label: 'Google', value: 'https://google.com/search?q=' }, - { label: 'DuckDuckGo', value: 'https://duckduckgo.com/?q=' }, - { label: 'Bing', value: 'https://bing.com/search?q=' }, - { label: 'Custom', value: 'Custom' }, - ]; - - const [customSearchUrl, setCustomSearchUrl] = useState(config.settings.searchUrl); - const [searchUrl, setSearchUrl] = useState( - matches.find((match) => match.value === config.settings.searchUrl)?.value ?? 'Custom' - ); - return ( - - - Search engine - { - setSearchUrl(e); - setConfig({ - ...config, - settings: { - ...config.settings, - searchUrl: e, - }, - }); - } - } - data={matches} - /> - {searchUrl === 'Custom' && ( - { - setCustomSearchUrl(event.currentTarget.value); - setConfig({ - ...config, - settings: { - ...config.settings, - searchUrl: event.currentTarget.value, - }, - }); - }} - /> - )} - - - - - - - tip: You can upload your config file by dragging and dropping it onto the page - - - - component="a" href="https://github.com/ajnart/homarr" size="lg"> - - - - {CURRENT_VERSION} - - - - Made with ❤️ by @ - - ajnart - - - - + + + + + + + + + + + + ); } @@ -136,14 +30,15 @@ export function SettingsMenuButton(props: any) { return ( <> Settings} + title={Settings} opened={props.opened || opened} onClose={() => setOpened(false)} > + ({ + swatch: theme.colors[primaryColor][i], + shade: i as MantineTheme['primaryShade'], + })); + const secondaryShades = theme.colors[secondaryColor].map((s, i) => ({ + swatch: theme.colors[secondaryColor][i], + shade: i as MantineTheme['primaryShade'], + })); + + const setConfigShade = (shade: MantineTheme['primaryShade']) => { + setPrimaryShade(shade); + setConfig({ + ...config, + settings: { + ...config.settings, + primaryShade: shade, + }, + }); + }; + + const primarySwatches = primaryShades.map(({ swatch, shade }) => ( + setConfigShade(shade)} + key={Number(shade)} + color={swatch} + size={22} + style={{ color: theme.white, cursor: 'pointer' }} + /> + )); + + const secondarySwatches = secondaryShades.map(({ swatch, shade }) => ( + setConfigShade(shade)} + key={Number(shade)} + color={swatch} + size={22} + style={{ color: theme.white, cursor: 'pointer' }} + /> + )); + + return ( + + setOpened(false)} + transitionDuration={0} + target={ + setOpened((o) => !o)} + size={22} + style={{ display: 'block', cursor: 'pointer' }} + /> + } + styles={{ + root: { + marginRight: theme.spacing.xs, + }, + body: { + backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.white, + }, + arrow: { + backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.white, + }, + }} + position="bottom" + placement="end" + withArrow + arrowSize={3} + > + + {primarySwatches} + {secondarySwatches} + + + Shade + + ); +} diff --git a/src/components/WidgetsPositionSwitch/WidgetsPositionSwitch.tsx b/src/components/WidgetsPositionSwitch/WidgetsPositionSwitch.tsx new file mode 100644 index 000000000..5fe24efe9 --- /dev/null +++ b/src/components/WidgetsPositionSwitch/WidgetsPositionSwitch.tsx @@ -0,0 +1,60 @@ +import React, { useState } from 'react'; +import { createStyles, Switch, Group } from '@mantine/core'; +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 WidgetsPositionSwitch() { + const { config, setConfig } = useConfig(); + const { classes, cx } = useStyles(); + const defaultPosition = config?.settings?.widgetPosition || 'right'; + const [widgetPosition, setWidgetPosition] = useState(defaultPosition); + const toggleWidgetPosition = () => { + const position = widgetPosition === 'right' ? 'left' : 'right'; + setWidgetPosition(position); + setConfig({ + ...config, + settings: { + ...config.settings, + widgetPosition: position, + }, + }); + }; + + return ( + +
+ toggleWidgetPosition()} + size="md" + /> +
+ Position widgets on left +
+ ); +} diff --git a/src/components/layout/Aside.tsx b/src/components/layout/Aside.tsx index 36aad4bbd..6c4810572 100644 --- a/src/components/layout/Aside.tsx +++ b/src/components/layout/Aside.tsx @@ -1,26 +1,36 @@ -import { Aside as MantineAside, Group } from '@mantine/core'; -import { WeatherModule, DateModule, CalendarModule, TotalDownloadsModule } from '../modules'; -import { ModuleWrapper } from '../modules/moduleWrapper'; +import { Aside as MantineAside, createStyles } from '@mantine/core'; +import Widgets from './Widgets'; + +const useStyles = createStyles((theme) => ({ + hide: { + [theme.fn.smallerThan('xs')]: { + display: 'none', + }, + }, + burger: { + [theme.fn.largerThan('sm')]: { + display: 'none', + }, + }, +})); export default function Aside(props: any) { + const { classes, cx } = useStyles(); return ( ); } diff --git a/src/components/layout/Background.tsx b/src/components/layout/Background.tsx new file mode 100644 index 000000000..741bf9389 --- /dev/null +++ b/src/components/layout/Background.tsx @@ -0,0 +1,20 @@ +import { Global } from '@mantine/core'; +import { useConfig } from '../../tools/state'; + +export function Background() { + const { config } = useConfig(); + + return ( + + ); +} diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index 1ecd33ceb..f1b58cd2a 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -1,6 +1,7 @@ import React, { useEffect } from 'react'; import { createStyles, Footer as FooterComponent } from '@mantine/core'; import { showNotification } from '@mantine/notifications'; +import { IconAlertCircle as AlertCircle } from '@tabler/icons'; import { CURRENT_VERSION, REPO_URL } from '../../../data/constants'; const useStyles = createStyles((theme) => ({ @@ -38,12 +39,21 @@ export function Footer({ links }: FooterCenteredProps) { // Fetch Data here when component first mounted fetch(`https://api.github.com/repos/${REPO_URL}/releases/latest`).then((res) => { res.json().then((data) => { - if (data.tag_name !== CURRENT_VERSION) { + if (data.tag_name > CURRENT_VERSION) { showNotification({ color: 'yellow', autoClose: false, title: 'New version available', - message: `Version ${data.tag_name} is available, update now! 😡`, + icon: , + message: `Version ${data.tag_name} is available, update now!`, + }); + } else if (data.tag_name < CURRENT_VERSION) { + showNotification({ + color: 'orange', + autoClose: 5000, + title: 'You are using a development version', + icon: , + message: 'This version of Homarr is still in development! Bugs are expected 🐛', }); } }); diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 28ed95f46..be3120a8d 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,9 +1,11 @@ -import React from 'react'; -import { createStyles, Header as Head, Group, Box } from '@mantine/core'; -import { Logo } from './Logo'; -import SearchBar from '../modules/search/SearchModule'; +import { Box, createStyles, Group, Header as Head } from '@mantine/core'; +import { useBooleanToggle } from '@mantine/hooks'; import { AddItemShelfButton } from '../AppShelf/AddAppShelfItem'; + +import DockerMenuButton from '../../modules/docker/DockerModule'; +import SearchBar from '../../modules/search/SearchModule'; import { SettingsMenuButton } from '../Settings/SettingsMenu'; +import { Logo } from './Logo'; const HEADER_HEIGHT = 60; @@ -13,19 +15,27 @@ const useStyles = createStyles((theme) => ({ display: 'none', }, }, + burger: { + [theme.fn.largerThan('sm')]: { + display: 'none', + }, + }, })); export function Header(props: any) { + const [opened, toggleOpened] = useBooleanToggle(false); const { classes, cx } = useStyles(); + const [hidden, toggleHidden] = useBooleanToggle(true); return ( - + + diff --git a/src/components/layout/HeaderConfig.tsx b/src/components/layout/HeaderConfig.tsx new file mode 100644 index 000000000..ed5a7804f --- /dev/null +++ b/src/components/layout/HeaderConfig.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import Head from 'next/head'; +import { useConfig } from '../../tools/state'; + +export function HeaderConfig(props: any) { + const { config } = useConfig(); + + return ( + + {config.settings.title || 'Homarr 🦞'} + + + ); +} diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index ac2c3c742..0df7c4374 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -2,6 +2,10 @@ import { AppShell, createStyles } from '@mantine/core'; import { Header } from './Header'; import { Footer } from './Footer'; import Aside from './Aside'; +import Navbar from './Navbar'; +import { HeaderConfig } from './HeaderConfig'; +import { Background } from './Background'; +import { useConfig } from '../../tools/state'; const useStyles = createStyles((theme) => ({ main: {}, @@ -9,8 +13,18 @@ const useStyles = createStyles((theme) => ({ export default function Layout({ children, style }: any) { const { classes, cx } = useStyles(); + const { config } = useConfig(); + const widgetPosition = config?.settings?.widgetPosition === 'left'; + return ( - } header={
} footer={