mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-14 01:15:47 +01:00
🔀 Add basic authentication
This commit is contained in:
@@ -7,3 +7,7 @@ npm-debug.log
|
|||||||
.github
|
.github
|
||||||
LICENSE
|
LICENSE
|
||||||
docs/
|
docs/
|
||||||
|
*.sqlite
|
||||||
|
*.env
|
||||||
|
.env
|
||||||
|
.next/standalone/.env
|
||||||
23
.env.example
Normal file
23
.env.example
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Since the ".env" file is gitignored, you can use the ".env.example" file to
|
||||||
|
# build a new ".env" file when you clone the repo. Keep this file up-to-date
|
||||||
|
# when you add new variables to `.env`.
|
||||||
|
|
||||||
|
# This file will be committed to version control, so make sure not to have any
|
||||||
|
# secrets in it. If you are cloning this repo, create a copy of this file named
|
||||||
|
# ".env" and populate it with your secrets.
|
||||||
|
|
||||||
|
# When adding additional environment variables, the schema in "/src/env.js"
|
||||||
|
# should be updated accordingly.
|
||||||
|
|
||||||
|
# Prisma
|
||||||
|
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
|
||||||
|
DATABASE_URL="file:../database/db.sqlite"
|
||||||
|
|
||||||
|
# Next Auth
|
||||||
|
# You can generate a new secret on the command line with:
|
||||||
|
# openssl rand -base64 32
|
||||||
|
# https://next-auth.js.org/configuration/options#secret
|
||||||
|
# NEXTAUTH_SECRET=""
|
||||||
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
NEXTAUTH_SECRET=""
|
||||||
6
.github/workflows/docker.yml
vendored
6
.github/workflows/docker.yml
vendored
@@ -65,7 +65,7 @@ jobs:
|
|||||||
|
|
||||||
- run: yarn install --immutable
|
- run: yarn install --immutable
|
||||||
|
|
||||||
- run: yarn build
|
- run: yarn turbo build
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
@@ -83,6 +83,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v2
|
||||||
|
with:
|
||||||
|
driver: docker
|
||||||
|
buildkitd-flags: --debug
|
||||||
|
|
||||||
- name: Login to GHCR
|
- name: Login to GHCR
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
@@ -101,3 +104,4 @@ jobs:
|
|||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
network: host
|
||||||
|
|||||||
8
.github/workflows/docker_dev.yml
vendored
8
.github/workflows/docker_dev.yml
vendored
@@ -41,6 +41,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
contents: read
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
- name: Setup
|
- name: Setup
|
||||||
@@ -74,7 +75,11 @@ jobs:
|
|||||||
|
|
||||||
- run: yarn turbo build
|
- run: yarn turbo build
|
||||||
|
|
||||||
- run: yarn test:run
|
- run: yarn test:coverage
|
||||||
|
|
||||||
|
- name: Report coverage
|
||||||
|
if: always()
|
||||||
|
uses: davelosert/vitest-coverage-report-action@v2
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
@@ -114,3 +119,4 @@ jobs:
|
|||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
network: host
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -55,4 +55,11 @@ data/configs
|
|||||||
|
|
||||||
#Languages other than 'en'
|
#Languages other than 'en'
|
||||||
public/locales/*
|
public/locales/*
|
||||||
!public/locales/en
|
!public/locales/en
|
||||||
|
|
||||||
|
#database
|
||||||
|
prisma/db.sqlite
|
||||||
|
database/*.sqlite
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/*
|
||||||
19
Dockerfile
19
Dockerfile
@@ -1,6 +1,7 @@
|
|||||||
FROM node:20-alpine
|
FROM node:20.5-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Define node.js environment variables
|
||||||
ARG PORT=7575
|
ARG PORT=7575
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
@@ -10,16 +11,28 @@ ENV NODE_OPTIONS '--no-experimental-fetch'
|
|||||||
COPY next.config.js ./
|
COPY next.config.js ./
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
COPY package.json ./package.json
|
COPY package.json ./package.json
|
||||||
|
COPY yarn.lock ./yarn.lock
|
||||||
# Automatically leverage output traces to reduce image size
|
# Automatically leverage output traces to reduce image size
|
||||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||||
COPY .next/standalone ./
|
COPY .next/standalone ./
|
||||||
COPY .next/static ./.next/static
|
COPY .next/static ./.next/static
|
||||||
|
COPY prisma/schema.prisma prisma/schema.prisma
|
||||||
|
COPY ./scripts/run.sh ./scripts/run.sh
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN apt-get update -y && apt-get install -y openssl
|
||||||
|
RUN yarn global add prisma
|
||||||
|
|
||||||
|
# Expose the default application port
|
||||||
EXPOSE $PORT
|
EXPOSE $PORT
|
||||||
ENV PORT=${PORT}
|
ENV PORT=${PORT}
|
||||||
|
|
||||||
|
ENV DATABASE_URL "file:../database/db.sqlite"
|
||||||
|
ENV NEXTAUTH_URL "http://localhost:3000"
|
||||||
|
ENV PORT 7575
|
||||||
|
ENV NEXTAUTH_SECRET NOT_IN_USE_BECAUSE_JWTS_ARE_UNUSED
|
||||||
|
|
||||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=10s --timeout=5s --start-period=5s --retries=3 \
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:${PORT} || exit 1
|
CMD wget --no-verbose --tries=1 --spider http://localhost:${PORT} || exit 1
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
CMD ["sh", "./scripts/run.sh"]
|
||||||
|
|||||||
@@ -492,7 +492,7 @@
|
|||||||
"pageTitle": "Homarr ⭐️",
|
"pageTitle": "Homarr ⭐️",
|
||||||
"logoImageUrl": "/imgs/logo/logo.png",
|
"logoImageUrl": "/imgs/logo/logo.png",
|
||||||
"faviconUrl": "/imgs/favicon/favicon-squared.png",
|
"faviconUrl": "/imgs/favicon/favicon-squared.png",
|
||||||
"backgroundImageUrl": "",
|
"backgroundImageUrl": "https://images.unsplash.com/32/Mc8kW4x9Q3aRR3RkP5Im_IMG_4417.jpg?ixid=M3wxMjA3fDB8MXxzZWFyY2h8MTV8fGJhY2tncm91bmQlMjBpbWFnZXxlbnwwfHx8fDE2OTE0NDQ5NjF8MA&ixlib=rb-4.0.3",
|
||||||
"customCss": "",
|
"customCss": "",
|
||||||
"colors": {
|
"colors": {
|
||||||
"primary": "red",
|
"primary": "red",
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
export const REPO_URL = 'ajnart/homarr';
|
export const REPO_URL = 'ajnart/homarr';
|
||||||
export const ICON_PICKER_SLICE_LIMIT = 36;
|
export const ICON_PICKER_SLICE_LIMIT = 36;
|
||||||
|
export const COOKIE_LOCALE_KEY = 'config-locale';
|
||||||
|
export const COOKIE_COLOR_SCHEME_KEY = 'color-scheme';
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ module.exports = {
|
|||||||
'no',
|
'no',
|
||||||
'tr',
|
'tr',
|
||||||
'lv',
|
'lv',
|
||||||
'hu',
|
'hr',
|
||||||
'hr'
|
'hu'
|
||||||
],
|
],
|
||||||
|
|
||||||
localeDetection: true,
|
localeDetection: true,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
require('./src/env');
|
||||||
const { i18n } = require('./next-i18next.config');
|
const { i18n } = require('./next-i18next.config');
|
||||||
|
|
||||||
const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
||||||
@@ -12,4 +13,14 @@ module.exports = withBundleAnalyzer({
|
|||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
i18n,
|
i18n,
|
||||||
transpilePackages: ['@jellyfin/sdk'],
|
transpilePackages: ['@jellyfin/sdk'],
|
||||||
|
redirects: async () => [
|
||||||
|
{
|
||||||
|
source: '/',
|
||||||
|
destination: '/board',
|
||||||
|
permanent: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
env: {
|
||||||
|
NEXTAUTH_URL_INTERNAL: process.env.NEXTAUTH_URL_INTERNAL || process.env.HOSTNAME || 'http://localhost:3000'
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
34
package.json
34
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homarr",
|
"name": "homarr",
|
||||||
"version": "0.13.4",
|
"version": "0.13.2",
|
||||||
"description": "Homarr - A homepage for your server.",
|
"description": "Homarr - A homepage for your server.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -9,9 +9,9 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "NEXTAUTH_SECRET=WILL_BE_OVERWRITTEN next build",
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
"turbo": "turbo run build",
|
"turbo": "DATABASE_URL=file:WILL_BE_OVERWRITTEN.sqlite NEXTAUTH_URL=http://WILL_BE_OVERWRITTEN turbo build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"export": "next build && next export",
|
"export": "next build && next export",
|
||||||
@@ -22,8 +22,9 @@
|
|||||||
"test:ui": "vitest --ui",
|
"test:ui": "vitest --ui",
|
||||||
"test:run": "vitest run",
|
"test:run": "vitest run",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"docker:build": "turbo build && docker build . -t homarr:dev",
|
"docker:build": "turbo build && docker build . -t homarr:local-dev",
|
||||||
"docker:start": "docker run --env-file ./.env -p 7575:7575 homarr:dev "
|
"docker:start": "docker run -p 7575:7575 --name homarr-development homarr:local-dev",
|
||||||
|
"postinstall": "prisma generate"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ctrl/deluge": "^4.1.0",
|
"@ctrl/deluge": "^4.1.0",
|
||||||
@@ -41,10 +42,14 @@
|
|||||||
"@mantine/modals": "^6.0.0",
|
"@mantine/modals": "^6.0.0",
|
||||||
"@mantine/next": "^6.0.0",
|
"@mantine/next": "^6.0.0",
|
||||||
"@mantine/notifications": "^6.0.0",
|
"@mantine/notifications": "^6.0.0",
|
||||||
|
"@mantine/prism": "^6.0.19",
|
||||||
"@mantine/tiptap": "^6.0.17",
|
"@mantine/tiptap": "^6.0.17",
|
||||||
|
"@next-auth/prisma-adapter": "^1.0.7",
|
||||||
"@nivo/core": "^0.83.0",
|
"@nivo/core": "^0.83.0",
|
||||||
"@nivo/line": "^0.83.0",
|
"@nivo/line": "^0.83.0",
|
||||||
|
"@prisma/client": "^5.0.0",
|
||||||
"@react-native-async-storage/async-storage": "^1.18.1",
|
"@react-native-async-storage/async-storage": "^1.18.1",
|
||||||
|
"@t3-oss/env-nextjs": "^0.6.0",
|
||||||
"@tabler/icons-react": "^2.20.0",
|
"@tabler/icons-react": "^2.20.0",
|
||||||
"@tanstack/query-async-storage-persister": "^4.27.1",
|
"@tanstack/query-async-storage-persister": "^4.27.1",
|
||||||
"@tanstack/query-sync-storage-persister": "^4.27.1",
|
"@tanstack/query-sync-storage-persister": "^4.27.1",
|
||||||
@@ -59,22 +64,32 @@
|
|||||||
"@trpc/next": "^10.29.1",
|
"@trpc/next": "^10.29.1",
|
||||||
"@trpc/react-query": "^10.29.1",
|
"@trpc/react-query": "^10.29.1",
|
||||||
"@trpc/server": "^10.29.1",
|
"@trpc/server": "^10.29.1",
|
||||||
|
"@types/bcryptjs": "^2.4.2",
|
||||||
"@vitejs/plugin-react": "^4.0.0",
|
"@vitejs/plugin-react": "^4.0.0",
|
||||||
"axios": "^1.0.0",
|
"axios": "^1.0.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
"browser-geo-tz": "^0.0.4",
|
"browser-geo-tz": "^0.0.4",
|
||||||
"consola": "^3.0.0",
|
"consola": "^3.0.0",
|
||||||
|
"cookies": "^0.8.0",
|
||||||
"cookies-next": "^2.1.1",
|
"cookies-next": "^2.1.1",
|
||||||
"dayjs": "^1.11.7",
|
"dayjs": "^1.11.7",
|
||||||
"dockerode": "^3.3.2",
|
"dockerode": "^3.3.2",
|
||||||
"fily-publish-gridstack": "^0.0.13",
|
"fily-publish-gridstack": "^0.0.13",
|
||||||
|
"flag-icons": "^6.9.2",
|
||||||
"framer-motion": "^10.0.0",
|
"framer-motion": "^10.0.0",
|
||||||
|
"generate-password": "^1.7.0",
|
||||||
|
"geo-tz": "^7.0.7",
|
||||||
"html-entities": "^2.3.3",
|
"html-entities": "^2.3.3",
|
||||||
"i18next": "^22.5.1",
|
"i18next": "^22.5.1",
|
||||||
"immer": "^10.0.2",
|
"immer": "^10.0.2",
|
||||||
"js-file-download": "^0.4.12",
|
"js-file-download": "^0.4.12",
|
||||||
|
"moment": "^2.29.4",
|
||||||
|
"moment-timezone": "^0.5.43",
|
||||||
"next": "13.4.12",
|
"next": "13.4.12",
|
||||||
"next-i18next": "^13.0.0",
|
"next-auth": "^4.22.3",
|
||||||
|
"next-i18next": "^14.0.0",
|
||||||
"nzbget-api": "^0.0.3",
|
"nzbget-api": "^0.0.3",
|
||||||
|
"prisma": "^5.0.0",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
@@ -82,6 +97,7 @@
|
|||||||
"react-simple-code-editor": "^0.13.1",
|
"react-simple-code-editor": "^0.13.1",
|
||||||
"rss-parser": "^3.12.0",
|
"rss-parser": "^3.12.0",
|
||||||
"sabnzbd-api": "^1.5.0",
|
"sabnzbd-api": "^1.5.0",
|
||||||
|
"sharp": "^0.32.4",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
"xml-js": "^1.6.11",
|
"xml-js": "^1.6.11",
|
||||||
"xss": "^1.0.14",
|
"xss": "^1.0.14",
|
||||||
@@ -94,6 +110,7 @@
|
|||||||
"@testing-library/jest-dom": "^5.16.5",
|
"@testing-library/jest-dom": "^5.16.5",
|
||||||
"@testing-library/react": "^14.0.0",
|
"@testing-library/react": "^14.0.0",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
|
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
|
||||||
|
"@types/cookies": "^0.7.7",
|
||||||
"@types/dockerode": "^3.3.9",
|
"@types/dockerode": "^3.3.9",
|
||||||
"@types/node": "18.17.8",
|
"@types/node": "18.17.8",
|
||||||
"@types/prismjs": "^1.26.0",
|
"@types/prismjs": "^1.26.0",
|
||||||
@@ -103,7 +120,8 @@
|
|||||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||||
"@typescript-eslint/parser": "^6.0.0",
|
"@typescript-eslint/parser": "^6.0.0",
|
||||||
"@vitest/coverage-c8": "^0.33.0",
|
"@vitest/coverage-c8": "^0.33.0",
|
||||||
"@vitest/ui": "^0.33.0",
|
"@vitest/coverage-v8": "^0.34.5",
|
||||||
|
"@vitest/ui": "^0.34.4",
|
||||||
"eslint": "^8.0.1",
|
"eslint": "^8.0.1",
|
||||||
"eslint-config-next": "^13.4.5",
|
"eslint-config-next": "^13.4.5",
|
||||||
"eslint-plugin-promise": "^6.0.0",
|
"eslint-plugin-promise": "^6.0.0",
|
||||||
@@ -117,7 +135,7 @@
|
|||||||
"prettier": "^3.0.0",
|
"prettier": "^3.0.0",
|
||||||
"sass": "^1.56.1",
|
"sass": "^1.56.1",
|
||||||
"ts-node": "latest",
|
"ts-node": "latest",
|
||||||
"turbo": "latest",
|
"turbo": "^1.10.12",
|
||||||
"typescript": "^5.1.0",
|
"typescript": "^5.1.0",
|
||||||
"video.js": "^8.0.3",
|
"video.js": "^8.0.3",
|
||||||
"vite-tsconfig-paths": "^4.2.0",
|
"vite-tsconfig-paths": "^4.2.0",
|
||||||
|
|||||||
93
prisma/schema.prisma
Normal file
93
prisma/schema.prisma
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
binaryTargets = ["native", "linux-musl-openssl-3.0.x", "linux-musl-arm64-openssl-3.0.x", "debian-openssl-3.0.x"]
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
// NOTE: When using mysql or sqlserver, uncomment the @db.Text annotations in model Account below
|
||||||
|
// Further reading:
|
||||||
|
// https://next-auth.js.org/adapters/prisma#create-the-prisma-schema
|
||||||
|
// https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#string
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Necessary for Next auth
|
||||||
|
model Account {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
type String
|
||||||
|
provider String
|
||||||
|
providerAccountId String
|
||||||
|
refresh_token String? // @db.Text
|
||||||
|
access_token String? // @db.Text
|
||||||
|
expires_at Int?
|
||||||
|
token_type String?
|
||||||
|
scope String?
|
||||||
|
id_token String? // @db.Text
|
||||||
|
session_state String?
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([provider, providerAccountId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sessionToken String @unique
|
||||||
|
userId String
|
||||||
|
expires DateTime
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String?
|
||||||
|
email String? @unique
|
||||||
|
emailVerified DateTime?
|
||||||
|
image String?
|
||||||
|
password String?
|
||||||
|
salt String?
|
||||||
|
isAdmin Boolean @default(false)
|
||||||
|
isOwner Boolean @default(false)
|
||||||
|
accounts Account[]
|
||||||
|
sessions Session[]
|
||||||
|
settings UserSettings?
|
||||||
|
createdInvites Invite[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model VerificationToken {
|
||||||
|
identifier String
|
||||||
|
token String @unique
|
||||||
|
expires DateTime
|
||||||
|
|
||||||
|
@@unique([identifier, token])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Invite {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
token String @unique
|
||||||
|
expires DateTime
|
||||||
|
createdById String
|
||||||
|
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserSettings {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
colorScheme String @default("environment") // environment, light, dark
|
||||||
|
language String @default("en")
|
||||||
|
defaultBoard String @default("default")
|
||||||
|
firstDayOfWeek String @default("monday") // monday, saturnday, sunday
|
||||||
|
searchTemplate String @default("https://google.com/search?q=%s")
|
||||||
|
openSearchInNewTab Boolean @default(true)
|
||||||
|
disablePingPulse Boolean @default(false)
|
||||||
|
replacePingWithIcons Boolean @default(false)
|
||||||
|
useDebugLanguage Boolean @default(false)
|
||||||
|
autoFocusSearch Boolean @default(false)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId])
|
||||||
|
}
|
||||||
1
public/imgs/app-icons/truenas.svg
Normal file
1
public/imgs/app-icons/truenas.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 90 90" xmlns="http://www.w3.org/2000/svg"><g fill="none"><path fill="#31BEEC" d="M90 38.197v19.137L48.942 80.999V61.864z"/><path d="M41.086 61.863V81L0 57.333V38.197l18.566 10.687c.02.016.043.03.067.04l22.453 12.94Z" fill="#0095D5"/><path fill="#AEADAE" d="m61.621 45.506-16.607 9.576-16.622-9.576 16.622-9.575z"/><path fill="#0095D5" d="M86.086 31.416 69.464 40.99 48.942 29.15V10z"/><path fill="#31BEEC" d="M41.086 10v19.15l-20.55 11.827-16.621-9.561z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 484 B |
1
public/imgs/app-icons/unraid-alt.svg
Normal file
1
public/imgs/app-icons/unraid-alt.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><linearGradient id="a" x1="100%" x2="0" y1="0" y2="100%" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ff8d30"/><stop offset="1" stop-color="#e32929"/></linearGradient></defs><circle cx="50%" cy="50%" r="50%" fill="url(#a)"/><path fill="#fff" d="M246.6 200.8h18.7v110.6h-18.7zm-182.3 0H83v110.7H64.3zm91.1 123.9h18.7V367h-18.7zm-45.7-47.5h18.7v68.5h-18.7zm91.2 0h18.6v68.4h-18.6zm228.2-76.5h18.7v110.7h-18.7zM338 145.5h18.7v42.3H338zm45.7 21.2h18.7v68.2h-18.7zm-91.5 0h18.7v68.1h-18.7z"/></svg>
|
||||||
|
After Width: | Height: | Size: 577 B |
35
public/locales/en/authentication/invite.json
Normal file
35
public/locales/en/authentication/invite.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"metaTitle": "Create Account",
|
||||||
|
"title": "Create Account",
|
||||||
|
"text": "Please define your credentials below",
|
||||||
|
"form": {
|
||||||
|
"fields": {
|
||||||
|
"username": {
|
||||||
|
"label": "Username"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"label": "Password"
|
||||||
|
},
|
||||||
|
"passwordConfirmation": {
|
||||||
|
"label": "Confirm password"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"submit": "Create account"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"loading": {
|
||||||
|
"title": "Creating account",
|
||||||
|
"text": "Please wait"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"title": "Account created",
|
||||||
|
"text": "Your account has been created successfully"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"title": "Error",
|
||||||
|
"text": "Something went wrong, got the following error: {{error}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,20 @@
|
|||||||
{
|
{
|
||||||
|
"metaTitle": "Login",
|
||||||
"title": "Welcome back!",
|
"title": "Welcome back!",
|
||||||
"text": "Please enter your password",
|
"text": "Please enter your credentials",
|
||||||
"form": {
|
"form": {
|
||||||
"fields": {
|
"fields": {
|
||||||
|
"username": {
|
||||||
|
"label": "Username"
|
||||||
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"label": "Password",
|
"label": "Password"
|
||||||
"placeholder": "Your password"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"submit": "Sign in"
|
"submit": "Sign in"
|
||||||
}
|
},
|
||||||
|
"afterLoginRedirection": "After login, you'll be redirected to {{url}}"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"alert": "Your credentials are incorrect or this account doesn't exist. Please try again."
|
||||||
"checking": {
|
}
|
||||||
"title": "Checking your password",
|
|
||||||
"message": "Your password is being checked..."
|
|
||||||
},
|
|
||||||
"correct": {
|
|
||||||
"title": "Sign in successful, redirecting..."
|
|
||||||
},
|
|
||||||
"wrong": {
|
|
||||||
"title": "The password you entered is incorrect, please try again."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
5
public/locales/en/boards/common.json
Normal file
5
public/locales/en/boards/common.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"header": {
|
||||||
|
"customize": "Customize board"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
public/locales/en/boards/customize.json
Normal file
29
public/locales/en/boards/customize.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"metaTitle": "Customize {{name}} Board",
|
||||||
|
"pageTitle": "Customization for {{name}} Board",
|
||||||
|
"backToBoard": "Back to board",
|
||||||
|
"settings": {
|
||||||
|
"appearance": {
|
||||||
|
"primaryColor": "Primary color",
|
||||||
|
"secondaryColor": "Secondary color"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"save": {
|
||||||
|
"button": "Save changes",
|
||||||
|
"note": "Careful, you have unsaved changes!"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"pending": {
|
||||||
|
"title": "Saving customization",
|
||||||
|
"message": "Please wait while we save your customization"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"title": "Customization saved",
|
||||||
|
"message": "Your customization has been saved successfully"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"title": "Error",
|
||||||
|
"message": "Unable to save changes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,13 @@
|
|||||||
"about": "About",
|
"about": "About",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
|
"back": "Back",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
|
"next": "Next",
|
||||||
|
"previous": "Previous",
|
||||||
|
"confirm": "Confirm",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"enableAll": "Enable all",
|
"enableAll": "Enable all",
|
||||||
|
|||||||
34
public/locales/en/layout/header.json
Normal file
34
public/locales/en/layout/header.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"experimentalNote": {
|
||||||
|
"label": "This is an experimental feature of Homarr. Please report any issues on <gh>GitHub</gh> or <dc>Discord</dc>."
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"label": "Search",
|
||||||
|
"engines": {
|
||||||
|
"web": "Search for {{query}} on the web",
|
||||||
|
"youtube": "Search for {{query}} on YouTube",
|
||||||
|
"torrent": "Search for {{query}} torrents",
|
||||||
|
"movie": "Search for {{query}} on {{app}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"avatar": {
|
||||||
|
"switchTheme": "Switch theme",
|
||||||
|
"preferences": "User preferences",
|
||||||
|
"defaultBoard": "Default dashboard",
|
||||||
|
"manage": "Manage",
|
||||||
|
"about": {
|
||||||
|
"label": "About",
|
||||||
|
"new": "New"
|
||||||
|
},
|
||||||
|
"logout": "Logout from {{username}}",
|
||||||
|
"login": "Login"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"modals": {
|
||||||
|
"movie": {
|
||||||
|
"title": "",
|
||||||
|
"topResults": "Top {{count}} results for <b>{{search}}</b>."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
public/locales/en/layout/manage.json
Normal file
32
public/locales/en/layout/manage.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"navigation": {
|
||||||
|
"home": {
|
||||||
|
"title": "Home"
|
||||||
|
},
|
||||||
|
"boards": {
|
||||||
|
"title": "Boards"
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"title": "Users",
|
||||||
|
"items": {
|
||||||
|
"manage": "Manage",
|
||||||
|
"invites": "Invites"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"help": {
|
||||||
|
"title": "Help",
|
||||||
|
"items": {
|
||||||
|
"documentation": "Documentation",
|
||||||
|
"report": "Report an issue / bug",
|
||||||
|
"discord": "Community Discord",
|
||||||
|
"contribute": "Contribute"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"title": "Tools",
|
||||||
|
"items": {
|
||||||
|
"docker": "Docker"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
public/locales/en/manage/boards.json
Normal file
44
public/locales/en/manage/boards.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"metaTitle": "Boards",
|
||||||
|
"pageTitle": "Boards",
|
||||||
|
"cards": {
|
||||||
|
"statistics": {
|
||||||
|
"apps": "Apps",
|
||||||
|
"widgets": "Widgets",
|
||||||
|
"categories": "Categories"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"view": "View board"
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"setAsDefault": "Set as your default board",
|
||||||
|
"delete": {
|
||||||
|
"label": "Delete permanently",
|
||||||
|
"disabled": "Deletion disabled, because older Homarr components do not allow the deletion of the default config. Deletion will be possible in the future."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"fileSystem": "File system",
|
||||||
|
"default": "Default"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"create": "Create new board"
|
||||||
|
},
|
||||||
|
"modals": {
|
||||||
|
"delete": {
|
||||||
|
"title": "Delete board",
|
||||||
|
"text": "Are you sure, that you want to delete this board? This action cannot be undone and your data will be lost permanently."
|
||||||
|
},
|
||||||
|
"create": {
|
||||||
|
"title": "Create board",
|
||||||
|
"text": "The name cannot be changed after a board has been created.",
|
||||||
|
"form": {
|
||||||
|
"name": {
|
||||||
|
"label": "Name"
|
||||||
|
},
|
||||||
|
"submit": "Create"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
public/locales/en/manage/index.json
Normal file
23
public/locales/en/manage/index.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"metaTitle": "Manage",
|
||||||
|
"hero": {
|
||||||
|
"title": "Welcome back, {{username}}",
|
||||||
|
"fallbackUsername": "Anonymous",
|
||||||
|
"subtitle": "Welcome to Your Application Hub. Organize, Optimize and Conquer!"
|
||||||
|
},
|
||||||
|
"quickActions": {
|
||||||
|
"title": "Quick actions",
|
||||||
|
"boards": {
|
||||||
|
"title": "Your boards",
|
||||||
|
"subtitle": "Create and manage your boards"
|
||||||
|
},
|
||||||
|
"inviteUsers": {
|
||||||
|
"title": "Invite a new user",
|
||||||
|
"subtitle": "Create and send an invitation for registration"
|
||||||
|
},
|
||||||
|
"manageUsers": {
|
||||||
|
"title": "Manage users",
|
||||||
|
"subtitle": "Delete and manage your users"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
public/locales/en/manage/users.json
Normal file
36
public/locales/en/manage/users.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"metaTitle": "Users",
|
||||||
|
"pageTitle": "Manage users",
|
||||||
|
"text": "Using users, you can configure who can edit your dashboards. Future versions of Homarr will have even more granular control over permissions and boards.",
|
||||||
|
"buttons": {
|
||||||
|
"create": "Create"
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"header": {
|
||||||
|
"user": "User"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tooltips": {
|
||||||
|
"deleteUser": "Delete user",
|
||||||
|
"demoteAdmin": "Demote administrator",
|
||||||
|
"promoteToAdmin": "Promote to administrator"
|
||||||
|
},
|
||||||
|
"modals": {
|
||||||
|
"delete": {
|
||||||
|
"title": "Delete user {{name}}",
|
||||||
|
"text": "Are you sure, that you want to delete the user {{name}}? This will delete data associated with this account, but not any created dashboards by this user."
|
||||||
|
},
|
||||||
|
"change-role": {
|
||||||
|
"promote": {
|
||||||
|
"title": "Promote user {{name}} to admin",
|
||||||
|
"text": "Are you sure, that you want to promote the user {{name}} to admin? This will give the user access to all resources on your Homarr instance."
|
||||||
|
},
|
||||||
|
"demote": {
|
||||||
|
"title": "Demote user {{name}} to user",
|
||||||
|
"text": "Are you sure, that you want to demote the user {{name}} to user? This will remove the user's access to all resources on your Homarr instance."
|
||||||
|
},
|
||||||
|
"confirm": "Confirm"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"searchDoesntMatch": "Your search does not match any entries. Please adjust your filter."
|
||||||
|
}
|
||||||
52
public/locales/en/manage/users/create.json
Normal file
52
public/locales/en/manage/users/create.json
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"metaTitle": "Create user",
|
||||||
|
"steps": {
|
||||||
|
"account": {
|
||||||
|
"title": "First step",
|
||||||
|
"text": "Create account",
|
||||||
|
"username": {
|
||||||
|
"label": "Username"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"label": "E-Mail"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"title": "Second step",
|
||||||
|
"text": "Password",
|
||||||
|
"password": {
|
||||||
|
"label": "Password"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"finish": {
|
||||||
|
"title": "Confirmation",
|
||||||
|
"text": "Save to database",
|
||||||
|
"card": {
|
||||||
|
"title": "Review your inputs",
|
||||||
|
"text": "After you submit your data to the database, the user will be able to log in. Are you sure that you want to store this user in the database and activate the login?"
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"header": {
|
||||||
|
"property": "Property",
|
||||||
|
"value": "Value",
|
||||||
|
"username": "Username",
|
||||||
|
"email": "E-Mail",
|
||||||
|
"password": "Password"
|
||||||
|
},
|
||||||
|
"notSet": "Not set",
|
||||||
|
"valid": "Valid"
|
||||||
|
},
|
||||||
|
"failed": "User creation has failed: {{error}}"
|
||||||
|
},
|
||||||
|
"completed": {
|
||||||
|
"alert": {
|
||||||
|
"title": "User was created",
|
||||||
|
"text": "The user was created in the database. They can now log in."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"generateRandomPassword": "Generate random",
|
||||||
|
"createAnother": "Create another"
|
||||||
|
}
|
||||||
|
}
|
||||||
48
public/locales/en/manage/users/invites.json
Normal file
48
public/locales/en/manage/users/invites.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"metaTitle": "User invites",
|
||||||
|
"pageTitle": "Manage user invites",
|
||||||
|
"description": "Using invites, you can invite users to your Homarr instance. An invitation will only be valid for a certain time-span and can be used once. The expiration must be between 5 minutes and 12 months upon creation.",
|
||||||
|
"button": {
|
||||||
|
"createInvite": "Create invitation",
|
||||||
|
"deleteInvite": "Delete invite"
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"header": {
|
||||||
|
"id": "ID",
|
||||||
|
"creator": "Creator",
|
||||||
|
"expires": "Expires",
|
||||||
|
"action": "Actions"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"expiresAt": "expired {{at}}",
|
||||||
|
"expiresIn": "in {{in}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"modals": {
|
||||||
|
"create": {
|
||||||
|
"title": "Create invite",
|
||||||
|
"description": "After the expiration, an invite will no longer be valid and the recipient of the invite won't be able to create an account.",
|
||||||
|
"form": {
|
||||||
|
"expires": "Expiration date",
|
||||||
|
"submit": "Create"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"copy": {
|
||||||
|
"title": "Copy invitation",
|
||||||
|
"description": "Your invitation has been generated. After this modal closes, <b>you'll not be able to copy this link anymore</b>. If you do no longer wish to invite said person, you can delete this invitation any time.",
|
||||||
|
"invitationLink": "Invitation link",
|
||||||
|
"details": {
|
||||||
|
"id": "ID",
|
||||||
|
"token": "Token"
|
||||||
|
},
|
||||||
|
"button": {
|
||||||
|
"close": "Copy & Dismiss"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"title": "Delete invite",
|
||||||
|
"description": "Are you sure, that you want to delete this invitation? Users with this link will no longer be able to create an account using that link."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"noInvites": "There are no invitations yet."
|
||||||
|
}
|
||||||
@@ -7,9 +7,6 @@
|
|||||||
"useSonarrv4": {
|
"useSonarrv4": {
|
||||||
"label": "Use Sonarr v4 API"
|
"label": "Use Sonarr v4 API"
|
||||||
},
|
},
|
||||||
"sundayStart": {
|
|
||||||
"label": "Start the week on Sunday"
|
|
||||||
},
|
|
||||||
"radarrReleaseType": {
|
"radarrReleaseType": {
|
||||||
"label": "Radarr release type",
|
"label": "Radarr release type",
|
||||||
"data":{
|
"data":{
|
||||||
|
|||||||
7
public/locales/en/password-requirements.json
Normal file
7
public/locales/en/password-requirements.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"number": "Includes number",
|
||||||
|
"lowercase": "Includes lowercase letter",
|
||||||
|
"uppercase": "Includes uppercase letter",
|
||||||
|
"special": "Includes special character",
|
||||||
|
"length": "Includes at least {{count}} characters"
|
||||||
|
}
|
||||||
6
public/locales/en/settings/customization/access.json
Normal file
6
public/locales/en/settings/customization/access.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"allowGuests": {
|
||||||
|
"label": "Allow anonymous",
|
||||||
|
"description": "Allow users that are not logged in to view your board"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"disablePulse": {
|
|
||||||
"label": "Disable ping pulse",
|
|
||||||
"description": "By default, ping indicators in Homarr will pulse. This may be irritating. This slider will deactivate the animation"
|
|
||||||
},
|
|
||||||
"replaceIconsWithDots": {
|
|
||||||
"label": "Replace ping dots with icons",
|
|
||||||
"description": "For colorblind users, ping dots may be unrecognizable. This will replace indicators with icons"
|
|
||||||
},
|
|
||||||
"alert": "Are you missing something? We'll gladly extend the accessibility of Homarr"
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"label": "App Width"
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"colors": "Colors",
|
|
||||||
"suffix": "{{color}} color",
|
|
||||||
"primary": "Primary",
|
|
||||||
"secondary": "Secondary"
|
|
||||||
}
|
|
||||||
@@ -20,6 +20,10 @@
|
|||||||
"accessibility": {
|
"accessibility": {
|
||||||
"name": "Accessibility",
|
"name": "Accessibility",
|
||||||
"description": "Configure Homarr for disabled and handicapped users"
|
"description": "Configure Homarr for disabled and handicapped users"
|
||||||
|
},
|
||||||
|
"access": {
|
||||||
|
"name": "Acccess",
|
||||||
|
"description": "Configure who has access to your board"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,5 @@
|
|||||||
"description": "Further, customize your dashboard using CSS, only recommended for experienced users",
|
"description": "Further, customize your dashboard using CSS, only recommended for experienced users",
|
||||||
"placeholder": "Custom CSS will be applied last",
|
"placeholder": "Custom CSS will be applied last",
|
||||||
"applying": "Applying CSS..."
|
"applying": "Applying CSS..."
|
||||||
},
|
|
||||||
"buttons": {
|
|
||||||
"submit": "Submit"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"label": "Switch to {{scheme}} mode"
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"label": "Switch to {{theme}} mode"
|
|
||||||
}
|
|
||||||
32
public/locales/en/tools/docker.json
Normal file
32
public/locales/en/tools/docker.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"title": "Docker",
|
||||||
|
"alerts": {
|
||||||
|
"notConfigured": {
|
||||||
|
"text": "Your Homarr instance does not have Docker configured or it has falied to fetch containers. Please check the documentation on how to set up the integration."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"modals": {
|
||||||
|
"selectBoard": {
|
||||||
|
"title": "Choose a board",
|
||||||
|
"text": "Choose the board where you want to add the apps for the selected Docker containers.",
|
||||||
|
"form": {
|
||||||
|
"board": {
|
||||||
|
"label": "Board"
|
||||||
|
},
|
||||||
|
"submit": "Add apps"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"selectBoard": {
|
||||||
|
"success": {
|
||||||
|
"title": "Added apps to board",
|
||||||
|
"message": "The apps for the selected Docker containers have been added to the board."
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"title": "Failed to add apps to board",
|
||||||
|
"message": "The apps for the selected Docker containers could not be added to the board."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
public/locales/en/user/preferences.json
Normal file
48
public/locales/en/user/preferences.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"metaTitle": "Preferences",
|
||||||
|
"pageTitle": "Your preferences",
|
||||||
|
"boards": {
|
||||||
|
"defaultBoard": {
|
||||||
|
"label": "Default board"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"accessibility": {
|
||||||
|
"title": "Accessibility",
|
||||||
|
"disablePulse": {
|
||||||
|
"label": "Disable ping pulse",
|
||||||
|
"description": "By default, ping indicators in Homarr will pulse. This may be irritating. This slider will deactivate the animation"
|
||||||
|
},
|
||||||
|
"replaceIconsWithDots": {
|
||||||
|
"label": "Replace ping dots with icons",
|
||||||
|
"description": "For colorblind users, ping dots may be unrecognizable. This will replace indicators with icons"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"localization": {
|
||||||
|
"language": {
|
||||||
|
"label": "Language"
|
||||||
|
},
|
||||||
|
"firstDayOfWeek": {
|
||||||
|
"label": "First day of the week",
|
||||||
|
"options": {
|
||||||
|
"monday": "Monday",
|
||||||
|
"saturday": "Saturday",
|
||||||
|
"sunday": "Sunday"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"searchEngine": {
|
||||||
|
"title": "Search engine",
|
||||||
|
"custom": "Custom",
|
||||||
|
"newTab": {
|
||||||
|
"label": "Open search results in a new tab"
|
||||||
|
},
|
||||||
|
"autoFocus": {
|
||||||
|
"label": "Focus search bar on page load.",
|
||||||
|
"description": "This will automatically focus the search bar, when you navigate to the board pages. It will only work on desktop devices."
|
||||||
|
},
|
||||||
|
"template": {
|
||||||
|
"label": "Query URL",
|
||||||
|
"description": "Use %s as a placeholder for the query"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,10 @@
|
|||||||
},
|
},
|
||||||
"population": {
|
"population": {
|
||||||
"fallback": "Unknown"
|
"fallback": "Unknown"
|
||||||
|
},
|
||||||
|
"nothingFound": {
|
||||||
|
"title": "Nothing found",
|
||||||
|
"description": "Please try another search term"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
public/locales/en/zod.json
Normal file
22
public/locales/en/zod.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"errors": {
|
||||||
|
"default": "This field is invalid",
|
||||||
|
"required": "This field is required",
|
||||||
|
"string": {
|
||||||
|
"startsWith": "This field must start with {{startsWith}}",
|
||||||
|
"endsWith": "This field must end with {{endsWith}}",
|
||||||
|
"includes": "This field must include {{includes}}"
|
||||||
|
},
|
||||||
|
"tooSmall": {
|
||||||
|
"string": "This field must be at least {{minimum}} characters long",
|
||||||
|
"number": "This field must be greater than or equal to {{minimum}}"
|
||||||
|
},
|
||||||
|
"tooBig": {
|
||||||
|
"string": "This field must be at most {{maximum}} characters long",
|
||||||
|
"number": "This field must be less than or equal to {{maximum}}"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"passwordMatch": "Passwords must match"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
scripts/run.sh
Normal file
10
scripts/run.sh
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo "Exporting hostname..."
|
||||||
|
export NEXTAUTH_URL_INTERNAL="http://$HOSTNAME:7575"
|
||||||
|
|
||||||
|
echo "Pushing database changes..."
|
||||||
|
prisma db push --skip-generate
|
||||||
|
|
||||||
|
echo "Starting production server..."
|
||||||
|
node /app/server.js
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Stack, Switch } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'next-i18next';
|
||||||
|
import { useBoardCustomizationFormContext } from '~/components/Board/Customize/form';
|
||||||
|
|
||||||
|
export const AccessCustomization = () => {
|
||||||
|
const { t } = useTranslation('settings/customization/access');
|
||||||
|
const form = useBoardCustomizationFormContext();
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Switch label={t('allowGuests.label')} description={t('allowGuests.description')} {...form.getInputProps('access.allowGuests', { type: 'checkbox' })} />
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
ColorSwatch,
|
||||||
|
Group,
|
||||||
|
Input,
|
||||||
|
MantineTheme,
|
||||||
|
Slider,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
createStyles,
|
||||||
|
rem,
|
||||||
|
useMantineTheme,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useTranslation } from 'next-i18next';
|
||||||
|
import { highlight, languages } from 'prismjs';
|
||||||
|
import Editor from 'react-simple-code-editor';
|
||||||
|
import { useColorTheme } from '~/tools/color';
|
||||||
|
|
||||||
|
import { useBoardCustomizationFormContext } from '../form';
|
||||||
|
|
||||||
|
export const AppearanceCustomization = () => {
|
||||||
|
const { t } = useTranslation('settings/customization/page-appearance');
|
||||||
|
const form = useBoardCustomizationFormContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing="sm">
|
||||||
|
<TextInput
|
||||||
|
label={t('background.label')}
|
||||||
|
placeholder="/imgs/backgrounds/background.png"
|
||||||
|
{...form.getInputProps('appearance.backgroundSrc')}
|
||||||
|
/>
|
||||||
|
<ColorSelector type="primaryColor" />
|
||||||
|
<ColorSelector type="secondaryColor" />
|
||||||
|
<ShadeSelector />
|
||||||
|
<OpacitySlider />
|
||||||
|
<CustomCssInput />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ColorSelectorProps = {
|
||||||
|
type: 'primaryColor' | 'secondaryColor';
|
||||||
|
};
|
||||||
|
const ColorSelector = ({ type }: ColorSelectorProps) => {
|
||||||
|
const { t } = useTranslation('boards/customize');
|
||||||
|
const theme = useMantineTheme();
|
||||||
|
const form = useBoardCustomizationFormContext();
|
||||||
|
const { setPrimaryColor, setSecondaryColor } = useColorTheme();
|
||||||
|
|
||||||
|
const colors = Object.keys(theme.colors).map((color) => ({
|
||||||
|
swatch: theme.colors[color][6],
|
||||||
|
color,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input.Wrapper label={t(`settings.appearance.${type}`)}>
|
||||||
|
<Group>
|
||||||
|
{colors.map(({ color, swatch }) => (
|
||||||
|
<ColorSwatch
|
||||||
|
key={color}
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
form.getInputProps(`appearance.${type}`).onChange(color);
|
||||||
|
if (type === 'primaryColor') {
|
||||||
|
setPrimaryColor(color);
|
||||||
|
} else {
|
||||||
|
setSecondaryColor(color);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
color={swatch}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{color === form.values.appearance[type] && <CheckIcon width={rem(10)} />}
|
||||||
|
</ColorSwatch>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Input.Wrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ShadeSelector = () => {
|
||||||
|
const form = useBoardCustomizationFormContext();
|
||||||
|
const theme = useMantineTheme();
|
||||||
|
const { setPrimaryShade } = useColorTheme();
|
||||||
|
|
||||||
|
const primaryColor = form.values.appearance.primaryColor;
|
||||||
|
const primaryShades = theme.colors[primaryColor].map((_, shade) => ({
|
||||||
|
swatch: theme.colors[primaryColor][shade],
|
||||||
|
shade,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input.Wrapper label="Shade">
|
||||||
|
<Group>
|
||||||
|
{primaryShades.map(({ shade, swatch }) => (
|
||||||
|
<ColorSwatch
|
||||||
|
key={shade}
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
form.getInputProps(`appearance.shade`).onChange(shade);
|
||||||
|
setPrimaryShade(shade as MantineTheme['primaryShade']);
|
||||||
|
}}
|
||||||
|
color={swatch}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{shade === form.values.appearance.shade && <CheckIcon width={rem(10)} />}
|
||||||
|
</ColorSwatch>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Input.Wrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OpacitySlider = () => {
|
||||||
|
const { t } = useTranslation('settings/customization/opacity-selector');
|
||||||
|
const form = useBoardCustomizationFormContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input.Wrapper label={t('label')} mb="sm">
|
||||||
|
<Slider
|
||||||
|
step={10}
|
||||||
|
min={10}
|
||||||
|
marks={opacityMarks}
|
||||||
|
styles={{ markLabel: { fontSize: 'xx-small' } }}
|
||||||
|
{...form.getInputProps('appearance.opacity')}
|
||||||
|
/>
|
||||||
|
</Input.Wrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const opacityMarks = [
|
||||||
|
{ 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 CustomCssInput = () => {
|
||||||
|
const { t } = useTranslation('settings/customization/page-appearance');
|
||||||
|
const { classes } = useStyles();
|
||||||
|
const form = useBoardCustomizationFormContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input.Wrapper
|
||||||
|
label={t('customCSS.label')}
|
||||||
|
description={t('customCSS.description')}
|
||||||
|
inputWrapperOrder={['label', 'description', 'input', 'error']}
|
||||||
|
>
|
||||||
|
<div className={classes.codeEditorRoot}>
|
||||||
|
<Editor
|
||||||
|
{...form.getInputProps('appearance.customCss')}
|
||||||
|
onValueChange={(code) => form.getInputProps('appearance.customCss').onChange(code)}
|
||||||
|
highlight={(code) => highlight(code, languages.extend('css', {}), 'css')}
|
||||||
|
padding={10}
|
||||||
|
style={{
|
||||||
|
fontFamily: '"Fira code", "Fira Mono", monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
minHeight: 250,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Input.Wrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useStyles = createStyles(({ colors, colorScheme, radius }) => ({
|
||||||
|
codeEditorFooter: {
|
||||||
|
borderBottomLeftRadius: radius.sm,
|
||||||
|
borderBottomRightRadius: radius.sm,
|
||||||
|
backgroundColor: colorScheme === 'dark' ? colors.dark[7] : undefined,
|
||||||
|
},
|
||||||
|
codeEditorRoot: {
|
||||||
|
marginTop: 4,
|
||||||
|
borderColor: colorScheme === 'dark' ? colors.dark[4] : colors.gray[4],
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: 'solid',
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
},
|
||||||
|
codeEditor: {
|
||||||
|
backgroundColor: colorScheme === 'dark' ? colors.dark[6] : 'white',
|
||||||
|
fontSize: 12,
|
||||||
|
|
||||||
|
'& ::placeholder': {
|
||||||
|
color: colorScheme === 'dark' ? colors.dark[3] : colors.gray[5],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Input, Slider } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'next-i18next';
|
||||||
|
import { GridstackBreakpoints } from '~/constants/gridstack-breakpoints';
|
||||||
|
|
||||||
|
import { useBoardCustomizationFormContext } from '../form';
|
||||||
|
|
||||||
|
export const GridstackCustomization = () => {
|
||||||
|
const { t } = useTranslation('settings/customization/gridstack');
|
||||||
|
const form = useBoardCustomizationFormContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input.Wrapper
|
||||||
|
label={t('columnsCount.labelPreset', { size: t('common:breakPoints.small') })}
|
||||||
|
description={t('columnsCount.descriptionPreset', { pixels: GridstackBreakpoints.medium })}
|
||||||
|
mb="md"
|
||||||
|
>
|
||||||
|
<Slider min={1} max={8} mt="xs" {...form.getInputProps('gridstack.sm')} />
|
||||||
|
</Input.Wrapper>
|
||||||
|
<Input.Wrapper
|
||||||
|
label={t('columnsCount.labelPreset', { size: t('common:breakPoints.medium') })}
|
||||||
|
description={t('columnsCount.descriptionPreset', { pixels: GridstackBreakpoints.large })}
|
||||||
|
mb="md"
|
||||||
|
>
|
||||||
|
<Slider min={3} max={16} mt="xs" {...form.getInputProps('gridstack.md')} />
|
||||||
|
</Input.Wrapper>
|
||||||
|
<Input.Wrapper
|
||||||
|
label={t('columnsCount.labelPreset', { size: t('common:breakPoints.large') })}
|
||||||
|
description={t('columnsCount.descriptionExceedsPreset', {
|
||||||
|
pixels: GridstackBreakpoints.large,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Slider min={5} max={20} mt="xs" {...form.getInputProps('gridstack.lg')} />
|
||||||
|
</Input.Wrapper>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Checkbox, Grid, Stack } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'next-i18next';
|
||||||
|
|
||||||
|
import { useBoardCustomizationFormContext } from '../form';
|
||||||
|
import { LayoutPreview } from './LayoutPreview';
|
||||||
|
|
||||||
|
export const LayoutCustomization = () => {
|
||||||
|
const { t } = useTranslation('settings/common');
|
||||||
|
const form = useBoardCustomizationFormContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid gutter="xl" align="stretch">
|
||||||
|
<Grid.Col span={12} sm={6}>
|
||||||
|
<LayoutPreview
|
||||||
|
showLeftSidebar={form.values.layout.leftSidebarEnabled}
|
||||||
|
showRightSidebar={form.values.layout.rightSidebarEnabled}
|
||||||
|
showPings={form.values.layout.pingsEnabled}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={12} sm={6}>
|
||||||
|
<Stack spacing="sm" h="100%" justify="space-between">
|
||||||
|
<Stack spacing="xs">
|
||||||
|
<Checkbox
|
||||||
|
label={t('layout.enablelsidebar')}
|
||||||
|
description={t('layout.enablelsidebardesc')}
|
||||||
|
{...form.getInputProps('layout.leftSidebarEnabled', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label={t('layout.enablersidebar')}
|
||||||
|
description={t('layout.enablersidebardesc')}
|
||||||
|
{...form.getInputProps('layout.rightSidebarEnabled', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label={t('layout.enableping')}
|
||||||
|
{...form.getInputProps('layout.pingsEnabled', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
};
|
||||||
124
src/components/Board/Customize/Layout/LayoutPreview.tsx
Normal file
124
src/components/Board/Customize/Layout/LayoutPreview.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { Flex, Group, Indicator, Paper, Stack, createStyles } from '@mantine/core';
|
||||||
|
import { Logo } from '~/components/layout/Common/Logo';
|
||||||
|
import { createDummyArray } from '~/tools/client/arrays';
|
||||||
|
|
||||||
|
type LayoutPreviewProps = {
|
||||||
|
showLeftSidebar: boolean;
|
||||||
|
showRightSidebar: boolean;
|
||||||
|
showPings: boolean;
|
||||||
|
};
|
||||||
|
export const LayoutPreview = ({
|
||||||
|
showLeftSidebar,
|
||||||
|
showRightSidebar,
|
||||||
|
showPings,
|
||||||
|
}: LayoutPreviewProps) => {
|
||||||
|
const { classes } = useStyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing="xs">
|
||||||
|
<Paper px="xs" py={4} withBorder>
|
||||||
|
<Group position="apart">
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Logo size="xs" />
|
||||||
|
</div>
|
||||||
|
<BaseElement width={60} height={10} />
|
||||||
|
<Group style={{ flex: 1 }} position="right">
|
||||||
|
<BaseElement width={10} height={10} />
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Flex gap={6}>
|
||||||
|
{showLeftSidebar && (
|
||||||
|
<Paper className={classes.secondaryWrapper} p="xs" withBorder>
|
||||||
|
<Flex gap={5} wrap="wrap" align="end" w={65}>
|
||||||
|
{createDummyArray(5).map((_item, index) => (
|
||||||
|
<PlaceholderElement
|
||||||
|
height={index % 4 === 0 ? 60 + 5 : 30}
|
||||||
|
width={30}
|
||||||
|
key={`example-item-right-sidebard-${index}`}
|
||||||
|
index={index}
|
||||||
|
showPing={showPings}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paper className={classes.primaryWrapper} p="xs" withBorder>
|
||||||
|
<Flex gap={5} wrap="wrap">
|
||||||
|
{createDummyArray(10).map((_item, index) => (
|
||||||
|
<PlaceholderElement
|
||||||
|
height={30}
|
||||||
|
width={index % 5 === 0 ? 60 + 5 : 30}
|
||||||
|
key={`example-item-main-${index}`}
|
||||||
|
index={index}
|
||||||
|
showPing={showPings}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{showRightSidebar && (
|
||||||
|
<Paper className={classes.secondaryWrapper} p="xs" withBorder>
|
||||||
|
<Flex gap={5} align="start" wrap="wrap" w={65}>
|
||||||
|
{createDummyArray(5).map((_item, index) => (
|
||||||
|
<PlaceholderElement
|
||||||
|
height={30}
|
||||||
|
width={index % 4 === 0 ? 60 + 5 : 30}
|
||||||
|
key={`example-item-right-sidebard-${index}`}
|
||||||
|
index={index}
|
||||||
|
showPing={showPings}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useStyles = createStyles((theme) => ({
|
||||||
|
primaryWrapper: {
|
||||||
|
flexGrow: 2,
|
||||||
|
},
|
||||||
|
secondaryWrapper: {
|
||||||
|
flexGrow: 1,
|
||||||
|
maxWidth: 100,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const BaseElement = ({ height, width }: { height: number; width: number }) => (
|
||||||
|
<Paper
|
||||||
|
sx={(theme) => ({
|
||||||
|
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.gray[8] : theme.colors.gray[1],
|
||||||
|
})}
|
||||||
|
h={height}
|
||||||
|
p={2}
|
||||||
|
w={width}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
type PlaceholderElementProps = {
|
||||||
|
height: number;
|
||||||
|
width: number;
|
||||||
|
showPing: boolean;
|
||||||
|
index: number;
|
||||||
|
};
|
||||||
|
const PlaceholderElement = ({ height, width, showPing, index }: PlaceholderElementProps) => {
|
||||||
|
if (showPing) {
|
||||||
|
return (
|
||||||
|
<Indicator
|
||||||
|
position="bottom-end"
|
||||||
|
size={5}
|
||||||
|
offset={10}
|
||||||
|
color={index % 4 === 0 ? 'red' : 'green'}
|
||||||
|
>
|
||||||
|
<BaseElement width={width} height={height} />
|
||||||
|
</Indicator>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BaseElement width={width} height={height} />;
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Grid, Stack, TextInput } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'next-i18next';
|
||||||
|
|
||||||
|
import { useBoardCustomizationFormContext } from '../form';
|
||||||
|
|
||||||
|
export const PageMetadataCustomization = () => {
|
||||||
|
const { t } = useTranslation('settings/customization/page-appearance');
|
||||||
|
const form = useBoardCustomizationFormContext();
|
||||||
|
return (
|
||||||
|
<Grid gutter="md" align="stretch">
|
||||||
|
<Grid.Col span={12} sm={6}>
|
||||||
|
<TextInput
|
||||||
|
label={t('pageTitle.label')}
|
||||||
|
description={t('pageTitle.description')}
|
||||||
|
placeholder="homarr"
|
||||||
|
{...form.getInputProps('pageMetadata.pageTitle')}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={12} sm={6}>
|
||||||
|
<TextInput
|
||||||
|
label={t('metaTitle.label')}
|
||||||
|
description={t('metaTitle.description')}
|
||||||
|
placeholder="homarr - the best dashboard"
|
||||||
|
{...form.getInputProps('pageMetadata.metaTitle')}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={12} sm={6}>
|
||||||
|
<TextInput
|
||||||
|
label={t('logo.label')}
|
||||||
|
description={t('logo.description')}
|
||||||
|
placeholder="/imgs/logo/logo.png"
|
||||||
|
{...form.getInputProps('pageMetadata.logoSrc')}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={12} sm={6}>
|
||||||
|
<TextInput
|
||||||
|
label={t('favicon.label')}
|
||||||
|
description={t('favicon.description')}
|
||||||
|
placeholder="/imgs/favicon/favicon.svg"
|
||||||
|
{...form.getInputProps('pageMetadata.faviconSrc')}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
};
|
||||||
9
src/components/Board/Customize/form.ts
Normal file
9
src/components/Board/Customize/form.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createFormContext } from '@mantine/form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { boardCustomizationSchema } from '~/validations/boards';
|
||||||
|
|
||||||
|
export const [
|
||||||
|
BoardCustomizationFormProvider,
|
||||||
|
useBoardCustomizationFormContext,
|
||||||
|
useBoardCustomizationForm,
|
||||||
|
] = createFormContext<z.infer<typeof boardCustomizationSchema>>();
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import { Center, Dialog, Loader, Notification, Select, Tooltip } from '@mantine/core';
|
|
||||||
import { useToggle } from '@mantine/hooks';
|
|
||||||
import { notifications } from '@mantine/notifications';
|
|
||||||
import { IconCheck } from '@tabler/icons-react';
|
|
||||||
import { setCookie } from 'cookies-next';
|
|
||||||
import { useTranslation } from 'next-i18next';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { api } from '~/utils/api';
|
|
||||||
|
|
||||||
import { useConfigContext } from '../../config/provider';
|
|
||||||
|
|
||||||
export default function ConfigChanger() {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { t } = useTranslation('settings/general/config-changer');
|
|
||||||
const { name: configName, setConfigName } = useConfigContext();
|
|
||||||
|
|
||||||
const { data: configs, isLoading } = useConfigsQuery();
|
|
||||||
const [activeConfig, setActiveConfig] = useState(configName);
|
|
||||||
const [isRefreshing, toggle] = useToggle();
|
|
||||||
|
|
||||||
const onConfigChange = (value: string) => {
|
|
||||||
setCookie('config-name', value ?? 'default', {
|
|
||||||
maxAge: 60 * 60 * 24 * 30,
|
|
||||||
sameSite: 'strict',
|
|
||||||
});
|
|
||||||
setActiveConfig(value);
|
|
||||||
|
|
||||||
notifications.show({
|
|
||||||
id: 'load-data',
|
|
||||||
loading: true,
|
|
||||||
title: t('configSelect.loadingNew'),
|
|
||||||
radius: 'md',
|
|
||||||
withCloseButton: false,
|
|
||||||
message: t('configSelect.pleaseWait'),
|
|
||||||
autoClose: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
notifications.update({
|
|
||||||
id: 'load-data',
|
|
||||||
color: 'teal',
|
|
||||||
radius: 'md',
|
|
||||||
withCloseButton: false,
|
|
||||||
title: t('configSelect.loadingNew'),
|
|
||||||
message: t('configSelect.pleaseWait'),
|
|
||||||
icon: <IconCheck size={25} />,
|
|
||||||
autoClose: 2000,
|
|
||||||
});
|
|
||||||
}, 3000);
|
|
||||||
setTimeout(() => {
|
|
||||||
router.push(`/${value}`);
|
|
||||||
setConfigName(value);
|
|
||||||
}, 500);
|
|
||||||
};
|
|
||||||
|
|
||||||
// If configlist is empty, return a loading indicator
|
|
||||||
if (isLoading || !configs || configs.length === 0 || !configName) {
|
|
||||||
return (
|
|
||||||
<Tooltip label={"Loading your configs. This doesn't load in vercel."}>
|
|
||||||
<Center>
|
|
||||||
<Loader />
|
|
||||||
</Center>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Select
|
|
||||||
label={t('configSelect.label')}
|
|
||||||
description={t('configSelect.description', { configCount: configs.length })}
|
|
||||||
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"
|
|
||||||
withCloseButton={false}
|
|
||||||
>
|
|
||||||
{t('configSelect.pleaseWait')}
|
|
||||||
</Notification>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const useConfigsQuery = () => api.config.all.useQuery();
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
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-react';
|
|
||||||
import { setCookie } from 'cookies-next';
|
|
||||||
import { useTranslation } from 'next-i18next';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import { api } from '~/utils/api';
|
|
||||||
|
|
||||||
import { useConfigStore } from '../../config/store';
|
|
||||||
import { ConfigType } from '../../types/config';
|
|
||||||
|
|
||||||
export const LoadConfigComponent = () => {
|
|
||||||
const theme = useMantineTheme();
|
|
||||||
const { t } = useTranslation('settings/general/config-changer');
|
|
||||||
const { mutateAsync: loadAsync } = useLoadConfig();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropzone.FullScreen
|
|
||||||
onDrop={async (files) => {
|
|
||||||
const configName = files[0].name.replaceAll('.json', '');
|
|
||||||
const fileText = await files[0].text();
|
|
||||||
|
|
||||||
try {
|
|
||||||
JSON.parse(fileText) as ConfigType;
|
|
||||||
} 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: ConfigType = JSON.parse(fileText);
|
|
||||||
await loadAsync({ name: configName, config: newConfig });
|
|
||||||
}}
|
|
||||||
accept={['application/json']}
|
|
||||||
>
|
|
||||||
<Group position="center" spacing="xl" style={{ minHeight: 220, pointerEvents: 'none' }}>
|
|
||||||
<Dropzone.Accept>
|
|
||||||
<Stack align="center">
|
|
||||||
<IconUpload
|
|
||||||
size={50}
|
|
||||||
stroke={1.5}
|
|
||||||
color={theme.colors[theme.primaryColor][theme.colorScheme === 'dark' ? 4 : 6]}
|
|
||||||
/>
|
|
||||||
<Title>{t('dropzone.accept.title')}</Title>
|
|
||||||
<Text size="xl" inline>
|
|
||||||
{t('dropzone.accept.text')}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Dropzone.Accept>
|
|
||||||
<Dropzone.Reject>
|
|
||||||
<Stack align="center">
|
|
||||||
<IconX
|
|
||||||
size={50}
|
|
||||||
stroke={1.5}
|
|
||||||
color={theme.colors.red[theme.colorScheme === 'dark' ? 4 : 6]}
|
|
||||||
/>
|
|
||||||
<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} />
|
|
||||||
</Dropzone.Idle>
|
|
||||||
</Group>
|
|
||||||
</Dropzone.FullScreen>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const useLoadConfig = () => {
|
|
||||||
const { t } = useTranslation('settings/general/config-changer');
|
|
||||||
const { addConfig } = useConfigStore();
|
|
||||||
const router = useRouter();
|
|
||||||
return api.config.save.useMutation({
|
|
||||||
async onSuccess(_data, variables) {
|
|
||||||
await addConfig(variables.name, variables.config);
|
|
||||||
|
|
||||||
showNotification({
|
|
||||||
autoClose: 5000,
|
|
||||||
radius: 'md',
|
|
||||||
title: (
|
|
||||||
<Text>
|
|
||||||
{t('dropzone.notifications.loadedSuccessfully.title', {
|
|
||||||
configName: variables.name,
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
color: 'green',
|
|
||||||
icon: <Check />,
|
|
||||||
message: undefined,
|
|
||||||
});
|
|
||||||
setCookie('config-name', variables.name, {
|
|
||||||
maxAge: 60 * 60 * 24 * 30,
|
|
||||||
sameSite: 'strict',
|
|
||||||
});
|
|
||||||
router.push(`/${variables.name}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -2,8 +2,8 @@ import { ActionIcon, Space, createStyles } from '@mantine/core';
|
|||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
|
import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
|
||||||
|
|
||||||
import { useConfigContext } from '../../../../config/provider';
|
import { useConfigContext } from '~/config/provider';
|
||||||
import { useScreenLargerThan } from '../../../../hooks/useScreenLargerThan';
|
import { useScreenLargerThan } from '~/hooks/useScreenLargerThan';
|
||||||
import { MobileRibbonSidebarDrawer } from './MobileRibbonSidebarDrawer';
|
import { MobileRibbonSidebarDrawer } from './MobileRibbonSidebarDrawer';
|
||||||
|
|
||||||
export const MobileRibbons = () => {
|
export const MobileRibbons = () => {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { SelectItem } from '@mantine/core';
|
import { SelectItem } from '@mantine/core';
|
||||||
import { ContextModalProps, closeModal } from '@mantine/modals';
|
import { ContextModalProps, closeModal } from '@mantine/modals';
|
||||||
|
|
||||||
import { useConfigContext } from '../../../../config/provider';
|
import { useConfigContext } from '~/config/provider';
|
||||||
import { useConfigStore } from '../../../../config/store';
|
import { useConfigStore } from '~/config/store';
|
||||||
import { AppType } from '../../../../types/app';
|
import { AppType } from '~/types/app';
|
||||||
import { useGridstackStore, useWrapperColumnCount } from '../../Wrappers/gridstack/store';
|
import { useGridstackStore, useWrapperColumnCount } from '../../Wrappers/gridstack/store';
|
||||||
import { ChangePositionModal } from './ChangePositionModal';
|
import { ChangePositionModal } from './ChangePositionModal';
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Button, Flex, Grid, NumberInput, Select, SelectItem } from '@mantine/co
|
|||||||
import { useForm } from '@mantine/form';
|
import { useForm } from '@mantine/form';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
|
|
||||||
import { useConfigContext } from '../../../../config/provider';
|
import { useConfigContext } from '~/config/provider';
|
||||||
|
|
||||||
interface ChangePositionModalProps {
|
interface ChangePositionModalProps {
|
||||||
initialX?: number;
|
initialX?: number;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { SelectItem } from '@mantine/core';
|
import { SelectItem } from '@mantine/core';
|
||||||
import { ContextModalProps, closeModal } from '@mantine/modals';
|
import { ContextModalProps, closeModal } from '@mantine/modals';
|
||||||
|
|
||||||
import { useConfigContext } from '../../../../config/provider';
|
import { useConfigContext } from '~/config/provider';
|
||||||
import { useConfigStore } from '../../../../config/store';
|
import { useConfigStore } from '~/config/store';
|
||||||
import widgets from '../../../../widgets';
|
import widgets from '../../../../widgets';
|
||||||
import { WidgetChangePositionModalInnerProps } from '../../Tiles/Widgets/WidgetsMenu';
|
import { WidgetChangePositionModalInnerProps } from '../../Tiles/Widgets/WidgetsMenu';
|
||||||
import { useGridstackStore, useWrapperColumnCount } from '../../Wrappers/gridstack/store';
|
import { useGridstackStore, useWrapperColumnCount } from '../../Wrappers/gridstack/store';
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import {
|
|||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useConfigContext } from '../../../../config/provider';
|
import { useConfigContext } from '~/config/provider';
|
||||||
import { useConfigStore } from '../../../../config/store';
|
import { useConfigStore } from '~/config/store';
|
||||||
import { AppType } from '../../../../types/app';
|
import { AppType } from '~/types/app';
|
||||||
import { DebouncedImage } from '../../../IconSelector/DebouncedImage';
|
import { DebouncedImage } from '../../../IconSelector/DebouncedImage';
|
||||||
import { useEditModeStore } from '../../Views/useEditModeStore';
|
import { useEditModeStore } from '../../Views/useEditModeStore';
|
||||||
import { AppearanceTab } from './Tabs/AppereanceTab/AppereanceTab';
|
import { AppearanceTab } from './Tabs/AppereanceTab/AppereanceTab';
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { useDebouncedValue } from '@mantine/hooks';
|
|||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { AppType } from '../../../../../../types/app';
|
import { AppType } from '~/types/app';
|
||||||
import { IconSelector } from '../../../../../IconSelector/IconSelector';
|
import { IconSelector } from '~/components/IconSelector/IconSelector';
|
||||||
|
|
||||||
interface AppearanceTabProps {
|
interface AppearanceTabProps {
|
||||||
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
|
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Text, TextInput, Tooltip, Stack, Switch, Tabs, Group, useMantineTheme,
|
|||||||
import { UseFormReturnType } from '@mantine/form';
|
import { UseFormReturnType } from '@mantine/form';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
|
|
||||||
import { AppType } from '../../../../../../types/app';
|
import { AppType } from '~/types/app';
|
||||||
import { InfoCard } from '~/components/InfoCard/InfoCard'
|
import { InfoCard } from '~/components/InfoCard/InfoCard'
|
||||||
|
|
||||||
interface BehaviourTabProps {
|
interface BehaviourTabProps {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { UseFormReturnType } from '@mantine/form';
|
|||||||
import { IconClick, IconCursorText, IconLink } from '@tabler/icons-react';
|
import { IconClick, IconCursorText, IconLink } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
|
|
||||||
import { AppType } from '../../../../../../types/app';
|
import { AppType } from '~/types/app';
|
||||||
import { EditAppModalTab } from '../type';
|
import { EditAppModalTab } from '../type';
|
||||||
|
|
||||||
interface GeneralTabProps {
|
interface GeneralTabProps {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { Icon } from '@tabler/icons-react';
|
|||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { AppIntegrationPropertyAccessabilityType } from '../../../../../../../../types/app';
|
import { AppIntegrationPropertyAccessabilityType } from '~/types/app';
|
||||||
|
|
||||||
interface GenericSecretInputProps {
|
interface GenericSecretInputProps {
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
IntegrationField,
|
IntegrationField,
|
||||||
integrationFieldDefinitions,
|
integrationFieldDefinitions,
|
||||||
integrationFieldProperties,
|
integrationFieldProperties,
|
||||||
} from '../../../../../../../../types/app';
|
} from '~/types/app';
|
||||||
|
|
||||||
interface IntegrationSelectorProps {
|
interface IntegrationSelectorProps {
|
||||||
form: UseFormReturnType<AppType, (item: AppType) => AppType>;
|
form: UseFormReturnType<AppType, (item: AppType) => AppType>;
|
||||||
@@ -20,83 +20,9 @@ interface IntegrationSelectorProps {
|
|||||||
export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
|
export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
|
||||||
const { t } = useTranslation('layout/modals/add-app');
|
const { t } = useTranslation('layout/modals/add-app');
|
||||||
|
|
||||||
const data: SelectItem[] = [
|
const data = availableIntegrations.filter((x) =>
|
||||||
{
|
Object.keys(integrationFieldProperties).includes(x.value)
|
||||||
value: 'sabnzbd',
|
);
|
||||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sabnzbd.png',
|
|
||||||
label: 'SABnzbd',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'nzbGet',
|
|
||||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/nzbget.png',
|
|
||||||
label: 'NZBGet',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'jellyfin',
|
|
||||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyfin.png',
|
|
||||||
label: 'Jellyfin',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'plex',
|
|
||||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png',
|
|
||||||
label: 'Plex',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'pihole',
|
|
||||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/pi-hole.png',
|
|
||||||
label: 'PiHole',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'adGuardHome',
|
|
||||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/adguard-home.png',
|
|
||||||
label: 'AdGuard Home',
|
|
||||||
},
|
|
||||||
].filter((x) => Object.keys(integrationFieldProperties).includes(x.value));
|
|
||||||
|
|
||||||
const getNewProperties = (value: string | null): AppIntegrationPropertyType[] => {
|
const getNewProperties = (value: string | null): AppIntegrationPropertyType[] => {
|
||||||
if (!value) return [];
|
if (!value) return [];
|
||||||
@@ -181,3 +107,81 @@ const SelectItemComponent = forwardRef<HTMLDivElement, ItemProps>(
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const availableIntegrations = [
|
||||||
|
{
|
||||||
|
value: 'sabnzbd',
|
||||||
|
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sabnzbd.png',
|
||||||
|
label: 'SABnzbd',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'nzbGet',
|
||||||
|
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/nzbget.png',
|
||||||
|
label: 'NZBGet',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'jellyfin',
|
||||||
|
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyfin.png',
|
||||||
|
label: 'Jellyfin',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'plex',
|
||||||
|
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png',
|
||||||
|
label: 'Plex',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'pihole',
|
||||||
|
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/pi-hole.png',
|
||||||
|
label: 'PiHole',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'adGuardHome',
|
||||||
|
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/adguard-home.png',
|
||||||
|
label: 'AdGuard Home',
|
||||||
|
},
|
||||||
|
] as const satisfies Readonly<SelectItem[]>;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
IntegrationField,
|
IntegrationField,
|
||||||
integrationFieldDefinitions,
|
integrationFieldDefinitions,
|
||||||
integrationFieldProperties,
|
integrationFieldProperties,
|
||||||
} from '../../../../../../../../types/app';
|
} from '~/types/app';
|
||||||
import { GenericSecretInput } from '../InputElements/GenericSecretInput';
|
import { GenericSecretInput } from '../InputElements/GenericSecretInput';
|
||||||
|
|
||||||
interface IntegrationOptionsRendererProps {
|
interface IntegrationOptionsRendererProps {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { UseFormReturnType } from '@mantine/form';
|
|||||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||||
import { Trans, useTranslation } from 'next-i18next';
|
import { Trans, useTranslation } from 'next-i18next';
|
||||||
|
|
||||||
import { AppType } from '../../../../../../types/app';
|
import { AppType } from '~/types/app';
|
||||||
import { IntegrationSelector } from './Components/InputElements/IntegrationSelector';
|
import { IntegrationSelector } from './Components/InputElements/IntegrationSelector';
|
||||||
import { IntegrationOptionsRenderer } from './Components/IntegrationOptionsRenderer/IntegrationOptionsRenderer';
|
import { IntegrationOptionsRenderer } from './Components/IntegrationOptionsRenderer/IntegrationOptionsRenderer';
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { MultiSelect, Stack, Switch, Tabs } from '@mantine/core';
|
|||||||
import { UseFormReturnType } from '@mantine/form';
|
import { UseFormReturnType } from '@mantine/form';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
|
|
||||||
import { StatusCodes } from '../../../../../../tools/acceptableStatusCodes';
|
import { StatusCodes } from '~/tools/acceptableStatusCodes';
|
||||||
import { AppType } from '../../../../../../types/app';
|
import { AppType } from '~/types/app';
|
||||||
|
|
||||||
interface NetworkTabProps {
|
interface NetworkTabProps {
|
||||||
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
|
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import { motion } from 'framer-motion';
|
|||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { useConfigContext } from '~/config/provider';
|
||||||
|
import { useConfigStore } from '~/config/store';
|
||||||
|
import { openContextModalGeneric } from '~/tools/mantineModalManagerExtensions';
|
||||||
|
import { generateDefaultApp } from '~/tools/shared/app';
|
||||||
|
import { AppType } from '~/types/app';
|
||||||
|
|
||||||
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 { CategoryEditModalInnerProps } from '../../../../Wrappers/Category/CategoryEditModal';
|
||||||
import { useStyles } from '../Shared/styles';
|
import { useStyles } from '../Shared/styles';
|
||||||
|
|
||||||
@@ -66,7 +67,7 @@ export const AvailableElementTypes = ({
|
|||||||
closeModal(modalId);
|
closeModal(modalId);
|
||||||
showNotification({
|
showNotification({
|
||||||
title: t('category.created.title'),
|
title: t('category.created.title'),
|
||||||
message: t('category.created.message', { name: category.name}),
|
message: t('category.created.message', { name: category.name }),
|
||||||
color: 'teal',
|
color: 'teal',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -87,39 +88,8 @@ export const AvailableElementTypes = ({
|
|||||||
openContextModalGeneric<{ app: AppType; allowAppNamePropagation: boolean }>({
|
openContextModalGeneric<{ app: AppType; allowAppNamePropagation: boolean }>({
|
||||||
modal: 'editApp',
|
modal: 'editApp',
|
||||||
innerProps: {
|
innerProps: {
|
||||||
app: {
|
app: generateDefaultApp(getLowestWrapper()?.id ?? 'default'),
|
||||||
id: uuidv4(),
|
// TODO: Add translation? t('app.defaultName')
|
||||||
name: t('app.defaultName'),
|
|
||||||
url: 'https://homarr.dev',
|
|
||||||
appearance: {
|
|
||||||
iconUrl: '/imgs/logo/logo.png',
|
|
||||||
appNameStatus: 'normal',
|
|
||||||
appNameFontSize: 16,
|
|
||||||
positionAppName: 'column',
|
|
||||||
lineClampAppName: 1,
|
|
||||||
},
|
|
||||||
network: {
|
|
||||||
enabledStatusChecker: true,
|
|
||||||
statusCodes: ['200', '301', '302', '304', '307', '308'],
|
|
||||||
okStatus: [200, 301, 302, 304, 307, 308],
|
|
||||||
},
|
|
||||||
behaviour: {
|
|
||||||
isOpeningNewTab: true,
|
|
||||||
externalUrl: 'https://homarr.dev',
|
|
||||||
},
|
|
||||||
|
|
||||||
area: {
|
|
||||||
type: 'wrapper',
|
|
||||||
properties: {
|
|
||||||
id: getLowestWrapper()?.id ?? 'default',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
shape: {},
|
|
||||||
integration: {
|
|
||||||
type: null,
|
|
||||||
properties: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
allowAppNamePropagation: true,
|
allowAppNamePropagation: true,
|
||||||
},
|
},
|
||||||
size: 'xl',
|
size: 'xl',
|
||||||
@@ -168,4 +138,4 @@ const ElementItem = ({ name, icon, onClick }: ElementItemProps) => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { Icon, IconChecks } from '@tabler/icons-react';
|
|||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import { useConfigContext } from '../../../../../../config/provider';
|
import { useConfigContext } from '~/config/provider';
|
||||||
import { useConfigStore } from '../../../../../../config/store';
|
import { useConfigStore } from '~/config/store';
|
||||||
import { IWidget, IWidgetDefinition } from '../../../../../../widgets/widgets';
|
import { IWidget, IWidgetDefinition } from '~/widgets/widgets';
|
||||||
import { useEditModeStore } from '../../../../Views/useEditModeStore';
|
import { useEditModeStore } from '../../../../Views/useEditModeStore';
|
||||||
import { GenericAvailableElementType } from '../Shared/GenericElementType';
|
import { GenericAvailableElementType } from '../Shared/GenericElementType';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useConfigContext } from '../../../../config/provider';
|
import { useConfigContext } from '~/config/provider';
|
||||||
import { useConfigStore } from '../../../../config/store';
|
import { useConfigStore } from '~/config/store';
|
||||||
import { openContextModalGeneric } from '../../../../tools/mantineModalManagerExtensions';
|
import { openContextModalGeneric } from '~/tools/mantineModalManagerExtensions';
|
||||||
import { AppType } from '../../../../types/app';
|
import { AppType } from '~/types/app';
|
||||||
import { GenericTileMenu } from '../GenericTileMenu';
|
import { GenericTileMenu } from '../GenericTileMenu';
|
||||||
|
|
||||||
interface TileMenuProps {
|
interface TileMenuProps {
|
||||||
|
|||||||
@@ -2,28 +2,32 @@ import { Box, Indicator, Tooltip } from '@mantine/core';
|
|||||||
import { IconCheck, IconLoader, IconX } from '@tabler/icons-react';
|
import { IconCheck, IconLoader, IconX } from '@tabler/icons-react';
|
||||||
import Consola from 'consola';
|
import Consola from 'consola';
|
||||||
import { TargetAndTransition, Transition, motion } from 'framer-motion';
|
import { TargetAndTransition, Transition, motion } from 'framer-motion';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import { RouterOutputs, api } from '~/utils/api';
|
import { RouterOutputs, api } from '~/utils/api';
|
||||||
|
|
||||||
import { useConfigContext } from '../../../../config/provider';
|
import { useConfigContext } from '~/config/provider';
|
||||||
import { AppType } from '../../../../types/app';
|
import { AppType } from '~/types/app';
|
||||||
|
|
||||||
interface AppPingProps {
|
interface AppPingProps {
|
||||||
app: AppType;
|
app: AppType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AppPing = ({ app }: AppPingProps) => {
|
export const AppPing = ({ app }: AppPingProps) => {
|
||||||
const { config } = useConfigContext();
|
const { data: sessionData } = useSession();
|
||||||
|
const { data: userWithSettings } = api.user.withSettings.useQuery(undefined, {
|
||||||
|
enabled: app.network.enabledStatusChecker && !!sessionData?.user,
|
||||||
|
});
|
||||||
|
|
||||||
const { data, isFetching, isError, error, isActive } = usePing(app);
|
const { data, isFetching, isError, error, isActive } = usePing(app);
|
||||||
const tooltipLabel = useTooltipLabel({ isFetching, isError, data, errorMessage: error?.message });
|
const tooltipLabel = useTooltipLabel({ isFetching, isError, data, errorMessage: error?.message });
|
||||||
const isOnline = isError ? false : data?.state === 'online';
|
const isOnline = isError ? false : data?.state === 'online';
|
||||||
const pulse = usePingPulse({ isOnline });
|
|
||||||
|
const pulse = usePingPulse({ isOnline, settings: userWithSettings?.settings });
|
||||||
|
|
||||||
if (!isActive) return null;
|
if (!isActive) return null;
|
||||||
|
|
||||||
const replaceDotWithIcon =
|
const replaceDotWithIcon = userWithSettings?.settings.replacePingWithIcons ?? false;
|
||||||
config?.settings.customization.accessibility?.replacePingDotsWithIcons ?? false;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -106,6 +110,7 @@ const usePing = (app: AppType) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
retry: false,
|
retry: false,
|
||||||
|
enabled: isActive,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
retryDelay(failureCount, error) {
|
retryDelay(failureCount, error) {
|
||||||
// TODO: Add logic to retry on timeout
|
// TODO: Add logic to retry on timeout
|
||||||
@@ -115,7 +120,6 @@ const usePing = (app: AppType) => {
|
|||||||
cacheTime: 1000 * 60 * 5,
|
cacheTime: 1000 * 60 * 5,
|
||||||
staleTime: 1000 * 60 * 5,
|
staleTime: 1000 * 60 * 5,
|
||||||
retryOnMount: true,
|
retryOnMount: true,
|
||||||
enabled: isActive,
|
|
||||||
|
|
||||||
select: (data) => {
|
select: (data) => {
|
||||||
const isOk = isStatusOk(app, data.status);
|
const isOk = isStatusOk(app, data.status);
|
||||||
@@ -143,9 +147,13 @@ type PingPulse = {
|
|||||||
transition?: Transition;
|
transition?: Transition;
|
||||||
};
|
};
|
||||||
|
|
||||||
const usePingPulse = ({ isOnline }: { isOnline: boolean }): PingPulse => {
|
type UsePingPulseProps = {
|
||||||
const { config } = useConfigContext();
|
isOnline: boolean;
|
||||||
const disablePulse = config?.settings.customization.accessibility?.disablePingPulse ?? false;
|
settings?: RouterOutputs['user']['withSettings']['settings'];
|
||||||
|
};
|
||||||
|
|
||||||
|
const usePingPulse = ({ isOnline, settings }: UsePingPulseProps): PingPulse => {
|
||||||
|
const disablePulse = settings?.disablePingPulse ?? false;
|
||||||
|
|
||||||
if (disablePulse) {
|
if (disablePulse) {
|
||||||
return {};
|
return {};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createStyles, useMantineTheme } from '@mantine/styles';
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { AppType } from '../../../../types/app';
|
import { AppType } from '~/types/app';
|
||||||
import { useEditModeStore } from '../../Views/useEditModeStore';
|
import { useEditModeStore } from '../../Views/useEditModeStore';
|
||||||
import { HomarrCardWrapper } from '../HomarrCardWrapper';
|
import { HomarrCardWrapper } from '../HomarrCardWrapper';
|
||||||
import { BaseTileProps } from '../type';
|
import { BaseTileProps } from '../type';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ActionIcon, Menu } from '@mantine/core';
|
|||||||
import { IconLayoutKanban, IconPencil, IconSettings, IconTrash } from '@tabler/icons-react';
|
import { IconLayoutKanban, IconPencil, IconSettings, IconTrash } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
|
|
||||||
import { useColorTheme } from '../../../tools/color';
|
import { useColorTheme } from '~/tools/color';
|
||||||
import { useEditModeStore } from '../Views/useEditModeStore';
|
import { useEditModeStore } from '../Views/useEditModeStore';
|
||||||
|
|
||||||
interface GenericTileMenuProps {
|
interface GenericTileMenuProps {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Card, CardProps } from '@mantine/core';
|
import { Card, CardProps } from '@mantine/core';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
import { useCardStyles } from '../../layout/useCardStyles';
|
import { useCardStyles } from '../../layout/Common/useCardStyles';
|
||||||
import { useEditModeStore } from '../Views/useEditModeStore';
|
import { useEditModeStore } from '../Views/useEditModeStore';
|
||||||
|
|
||||||
interface HomarrCardWrapperProps extends CardProps {
|
interface HomarrCardWrapperProps extends CardProps {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { IconChevronDown, IconGripVertical } from '@tabler/icons-react';
|
|||||||
import { Reorder, useDragControls } from 'framer-motion';
|
import { Reorder, useDragControls } from 'framer-motion';
|
||||||
import { FC, useEffect, useRef } from 'react';
|
import { FC, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { IDraggableEditableListInputValue } from '../../../../../widgets/widgets';
|
import { IDraggableEditableListInputValue } from '~/widgets/widgets';
|
||||||
|
|
||||||
interface DraggableListProps {
|
interface DraggableListProps {
|
||||||
items: {
|
items: {
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ import {
|
|||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { IconAlertTriangle, IconClick, IconListSearch } from '@tabler/icons-react';
|
import { IconAlertTriangle, IconClick, IconListSearch } from '@tabler/icons-react';
|
||||||
|
import { useTranslation } from 'next-i18next';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { InfoCard } from '~/components/InfoCard/InfoCard';
|
||||||
import { City } from '~/server/api/routers/weather';
|
import { City } from '~/server/api/routers/weather';
|
||||||
import { api } from '~/utils/api';
|
import { api } from '~/utils/api';
|
||||||
|
|
||||||
import { IntegrationOptionsValueType } from '../WidgetsEditModal';
|
import { IntegrationOptionsValueType } from '../WidgetsEditModal';
|
||||||
import { InfoCard } from '~/components/InfoCard/InfoCard';
|
|
||||||
|
|
||||||
type LocationSelectionProps = {
|
type LocationSelectionProps = {
|
||||||
widgetId: string;
|
widgetId: string;
|
||||||
@@ -65,7 +65,12 @@ export const LocationSelection = ({
|
|||||||
<Stack spacing="xs">
|
<Stack spacing="xs">
|
||||||
<Flex direction="row" justify="space-between" wrap="nowrap">
|
<Flex direction="row" justify="space-between" wrap="nowrap">
|
||||||
<Title order={5}>{t(`modules/${widgetId}:descriptor.settings.${key}.label`)}</Title>
|
<Title order={5}>{t(`modules/${widgetId}:descriptor.settings.${key}.label`)}</Title>
|
||||||
{info && <InfoCard message={t(`modules/${widgetId}:descriptor.settings.${key}.info`)} link={infoLink}/>}
|
{info && (
|
||||||
|
<InfoCard
|
||||||
|
message={t(`modules/${widgetId}:descriptor.settings.${key}.info`)}
|
||||||
|
link={infoLink}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<Group noWrap align="end">
|
<Group noWrap align="end">
|
||||||
@@ -174,16 +179,16 @@ const CitySelectModal = ({ opened, closeModal, query, onCitySelected }: CitySele
|
|||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
zIndex={250}
|
zIndex={250}
|
||||||
>
|
>
|
||||||
<Center>
|
<Center>
|
||||||
<Stack align="center">
|
<Stack align="center">
|
||||||
<IconAlertTriangle />
|
<IconAlertTriangle />
|
||||||
<Title order={6}>Nothing found</Title>
|
<Title order={6}>{t('modal.table.nothingFound.title')}</Title>
|
||||||
<Text>Nothing was found, please try again</Text>
|
<Text>{t('modal.table.nothingFound.description')}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|
||||||
const formatter = Intl.NumberFormat('en', { notation: 'compact' });
|
const formatter = Intl.NumberFormat('en', { notation: 'compact' });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -228,15 +233,20 @@ const CitySelectModal = ({ opened, closeModal, query, onCitySelected }: CitySele
|
|||||||
<Text style={{ whiteSpace: 'nowrap' }}>{city.country}</Text>
|
<Text style={{ whiteSpace: 'nowrap' }}>{city.country}</Text>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Anchor target='_blank' href={`https://www.google.com/maps/place/${city.latitude},${city.longitude}`}>
|
<Anchor
|
||||||
<Text style={{ whiteSpace: 'nowrap' }}>
|
target="_blank"
|
||||||
{city.latitude}, {city.longitude}
|
href={`https://www.google.com/maps/place/${city.latitude},${city.longitude}`}
|
||||||
</Text>
|
>
|
||||||
|
<Text style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{city.latitude}, {city.longitude}
|
||||||
|
</Text>
|
||||||
</Anchor>
|
</Anchor>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{city.population ? (
|
{city.population ? (
|
||||||
<Text style={{ whiteSpace: 'nowrap' }}>{formatter.format(city.population)}</Text>
|
<Text style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{formatter.format(city.population)}
|
||||||
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Text color="dimmed"> {t('modal.table.population.fallback')}</Text>
|
<Text color="dimmed"> {t('modal.table.population.fallback')}</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { IconChevronDown, IconGripVertical } from '@tabler/icons-react';
|
|||||||
import { Reorder, useDragControls } from 'framer-motion';
|
import { Reorder, useDragControls } from 'framer-motion';
|
||||||
import { FC, ReactNode, useEffect, useRef } from 'react';
|
import { FC, ReactNode, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { IDraggableListInputValue } from '../../../../../widgets/widgets';
|
import { IDraggableListInputValue } from '~/widgets/widgets';
|
||||||
|
|
||||||
const useStyles = createStyles((theme) => ({
|
const useStyles = createStyles((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ import { IconAlertTriangle, IconPlaylistX, IconPlus } from '@tabler/icons-react'
|
|||||||
import { Trans, useTranslation } from 'next-i18next';
|
import { Trans, useTranslation } from 'next-i18next';
|
||||||
import { FC, useState } from 'react';
|
import { FC, useState } from 'react';
|
||||||
|
|
||||||
import { useConfigContext } from '../../../../config/provider';
|
import { useConfigContext } from '~/config/provider';
|
||||||
import { useConfigStore } from '../../../../config/store';
|
import { useConfigStore } from '~/config/store';
|
||||||
import { mapObject } from '../../../../tools/client/objects';
|
import { mapObject } from '~/tools/client/objects';
|
||||||
import Widgets from '../../../../widgets';
|
import Widgets from '../../../../widgets';
|
||||||
import type { IDraggableListInputValue, IWidgetOptionValue } from '../../../../widgets/widgets';
|
import type { IDraggableListInputValue, IWidgetOptionValue } from '~/widgets/widgets';
|
||||||
import { IWidget } from '../../../../widgets/widgets';
|
import { IWidget } from '~/widgets/widgets';
|
||||||
import { InfoCard } from '../../../InfoCard/InfoCard';
|
import { InfoCard } from '../../../InfoCard/InfoCard';
|
||||||
import { DraggableList } from './Inputs/DraggableList';
|
import { DraggableList } from './Inputs/DraggableList';
|
||||||
import { LocationSelection } from './Inputs/LocationSelection';
|
import { LocationSelection } from './Inputs/LocationSelection';
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Title } from '@mantine/core';
|
import { Title } from '@mantine/core';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
|
|
||||||
import { openContextModalGeneric } from '../../../../tools/mantineModalManagerExtensions';
|
import { openContextModalGeneric } from '~/tools/mantineModalManagerExtensions';
|
||||||
import WidgetsDefinitions from '../../../../widgets';
|
import WidgetsDefinitions from '../../../../widgets';
|
||||||
import { IWidget } from '../../../../widgets/widgets';
|
import { IWidget } from '~/widgets/widgets';
|
||||||
import { useWrapperColumnCount } from '../../Wrappers/gridstack/store';
|
import { useWrapperColumnCount } from '../../Wrappers/gridstack/store';
|
||||||
import { GenericTileMenu } from '../GenericTileMenu';
|
import { GenericTileMenu } from '../GenericTileMenu';
|
||||||
import { WidgetEditModalInnerProps } from './WidgetsEditModal';
|
import { WidgetEditModalInnerProps } from './WidgetsEditModal';
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Button, Group, Stack, Text } from '@mantine/core';
|
import { Button, Group, Stack, Text } from '@mantine/core';
|
||||||
import { ContextModalProps } from '@mantine/modals';
|
import { ContextModalProps } from '@mantine/modals';
|
||||||
import { Trans, useTranslation } from 'next-i18next';
|
import { Trans, useTranslation } from 'next-i18next';
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { useConfigContext } from '../../../../config/provider';
|
import { useConfigContext } from '~/config/provider';
|
||||||
import { useConfigStore } from '../../../../config/store';
|
import { useConfigStore } from '~/config/store';
|
||||||
|
|
||||||
export type WidgetsRemoveModalInnerProps = {
|
export type WidgetsRemoveModalInnerProps = {
|
||||||
widgetId: string;
|
widgetId: string;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Group, Stack } from '@mantine/core';
|
import { Group, Stack } from '@mantine/core';
|
||||||
import { useEffect, useMemo, useRef } from 'react';
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { useConfigContext } from '../../../config/provider';
|
import { useConfigContext } from '~/config/provider';
|
||||||
import { useResize } from '../../../hooks/use-resize';
|
import { useResize } from '~/hooks/use-resize';
|
||||||
import { useScreenLargerThan } from '../../../hooks/useScreenLargerThan';
|
import { useScreenLargerThan } from '~/hooks/useScreenLargerThan';
|
||||||
import { CategoryType } from '../../../types/category';
|
import { CategoryType } from '~/types/category';
|
||||||
import { WrapperType } from '../../../types/wrapper';
|
import { WrapperType } from '~/types/wrapper';
|
||||||
import { DashboardCategory } from '../Wrappers/Category/Category';
|
import { DashboardCategory } from '../Wrappers/Category/Category';
|
||||||
import { DashboardSidebar } from '../Wrappers/Sidebar/Sidebar';
|
import { DashboardSidebar } from '../Wrappers/Sidebar/Sidebar';
|
||||||
import { DashboardWrapper } from '../Wrappers/Wrapper/Wrapper';
|
import { DashboardWrapper } from '../Wrappers/Wrapper/Wrapper';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ActionIcon, Button, Text, Tooltip } from '@mantine/core';
|
|||||||
import { IconEdit, IconEditOff } from '@tabler/icons-react';
|
import { IconEdit, IconEditOff } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
|
|
||||||
import { useScreenLargerThan } from '../../../hooks/useScreenLargerThan';
|
import { useScreenLargerThan } from '~/hooks/useScreenLargerThan';
|
||||||
import { useEditModeStore } from './useEditModeStore';
|
import { useEditModeStore } from './useEditModeStore';
|
||||||
|
|
||||||
export const ViewToggleButton = () => {
|
export const ViewToggleButton = () => {
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { create } from 'zustand';
|
import { createWithEqualityFn } from 'zustand/traditional';
|
||||||
|
|
||||||
interface EditModeState {
|
interface EditModeState {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
toggleEditMode: () => void;
|
toggleEditMode: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useEditModeStore = create<EditModeState>((set) => ({
|
export const useEditModeStore = createWithEqualityFn<EditModeState>(
|
||||||
enabled: false,
|
(set) => ({
|
||||||
toggleEditMode: () => set((state) => ({ enabled: !state.enabled })),
|
enabled: false,
|
||||||
}));
|
toggleEditMode: () => set((state) => ({ enabled: !state.enabled })),
|
||||||
|
}),
|
||||||
|
Object.is
|
||||||
|
);
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ import { modals } from '@mantine/modals';
|
|||||||
import { IconDotsVertical, IconShare3 } from '@tabler/icons-react';
|
import { IconDotsVertical, IconShare3 } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
|
|
||||||
import { useConfigContext } from '../../../../config/provider';
|
import { useConfigContext } from '~/config/provider';
|
||||||
import { CategoryType } from '../../../../types/category';
|
import { CategoryType } from '~/types/category';
|
||||||
import { useCardStyles } from '../../../layout/useCardStyles';
|
import { useCardStyles } from '../../../layout/Common/useCardStyles';
|
||||||
import { useEditModeStore } from '../../Views/useEditModeStore';
|
import { useEditModeStore } from '../../Views/useEditModeStore';
|
||||||
import { WrapperContent } from '../WrapperContent';
|
import { WrapperContent } from '../WrapperContent';
|
||||||
import { useGridstack } from '../gridstack/use-gridstack';
|
import { useGridstack } from '../gridstack/use-gridstack';
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
IconTrash,
|
IconTrash,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
import { useConfigContext } from '../../../../config/provider';
|
import { useConfigContext } from '~/config/provider';
|
||||||
import { CategoryType } from '../../../../types/category';
|
import { CategoryType } from '~/types/category';
|
||||||
import { useCategoryActions } from './useCategoryActions';
|
import { useCategoryActions } from './useCategoryActions';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { useForm } from '@mantine/form';
|
|||||||
import { ContextModalProps } from '@mantine/modals';
|
import { ContextModalProps } from '@mantine/modals';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
|
|
||||||
import { useConfigContext } from '../../../../config/provider';
|
import { useConfigContext } from '~/config/provider';
|
||||||
import { useConfigStore } from '../../../../config/store';
|
import { useConfigStore } from '~/config/store';
|
||||||
import { CategoryType } from '../../../../types/category';
|
import { CategoryType } from '~/types/category';
|
||||||
|
|
||||||
export type CategoryEditModalInnerProps = {
|
export type CategoryEditModalInnerProps = {
|
||||||
category: CategoryType;
|
category: CategoryType;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import { useConfigStore } from '../../../../config/store';
|
import { useConfigStore } from '~/config/store';
|
||||||
import { openContextModalGeneric } from '../../../../tools/mantineModalManagerExtensions';
|
import { openContextModalGeneric } from '~/tools/mantineModalManagerExtensions';
|
||||||
import { AppType } from '../../../../types/app';
|
import { AppType } from '~/types/app';
|
||||||
import { CategoryType } from '../../../../types/category';
|
import { CategoryType } from '~/types/category';
|
||||||
import { WrapperType } from '../../../../types/wrapper';
|
import { WrapperType } from '~/types/wrapper';
|
||||||
import { IWidget } from '../../../../widgets/widgets';
|
import { IWidget } from '~/widgets/widgets';
|
||||||
import { CategoryEditModalInnerProps } from './CategoryEditModal';
|
import { CategoryEditModalInnerProps } from './CategoryEditModal';
|
||||||
|
|
||||||
export const useCategoryActions = (configName: string | undefined, category: CategoryType) => {
|
export const useCategoryActions = (configName: string | undefined, category: CategoryType) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Card } from '@mantine/core';
|
import { Card } from '@mantine/core';
|
||||||
import { RefObject } from 'react';
|
import { RefObject } from 'react';
|
||||||
|
|
||||||
import { useCardStyles } from '../../../layout/useCardStyles';
|
import { useCardStyles } from '../../../layout/Common/useCardStyles';
|
||||||
import { WrapperContent } from '../WrapperContent';
|
import { WrapperContent } from '../WrapperContent';
|
||||||
import { useGridstack } from '../gridstack/use-gridstack';
|
import { useGridstack } from '../gridstack/use-gridstack';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { WrapperType } from '../../../../types/wrapper';
|
import { WrapperType } from '~/types/wrapper';
|
||||||
import { useEditModeStore } from '../../Views/useEditModeStore';
|
import { useEditModeStore } from '../../Views/useEditModeStore';
|
||||||
import { WrapperContent } from '../WrapperContent';
|
import { WrapperContent } from '../WrapperContent';
|
||||||
import { useGridstack } from '../gridstack/use-gridstack';
|
import { useGridstack } from '../gridstack/use-gridstack';
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { GridStack } from 'fily-publish-gridstack';
|
import { GridStack } from 'fily-publish-gridstack';
|
||||||
import { MutableRefObject, RefObject } from 'react';
|
import { MutableRefObject, RefObject } from 'react';
|
||||||
|
|
||||||
import { AppType } from '../../../types/app';
|
import { AppType } from '~/types/app';
|
||||||
import Widgets from '../../../widgets';
|
import Widgets from '../../../widgets';
|
||||||
import { WidgetWrapper } from '../../../widgets/WidgetWrapper';
|
import { WidgetWrapper } from '~/widgets/WidgetWrapper';
|
||||||
import { IWidget, IWidgetDefinition } from '../../../widgets/widgets';
|
import { IWidget, IWidgetDefinition } from '~/widgets/widgets';
|
||||||
import { appTileDefinition } from '../Tiles/Apps/AppTile';
|
import { appTileDefinition } from '../Tiles/Apps/AppTile';
|
||||||
import { GridstackTileWrapper } from '../Tiles/TileWrapper';
|
import { GridstackTileWrapper } from '../Tiles/TileWrapper';
|
||||||
import { useGridstackStore } from './gridstack/store';
|
import { useGridstackStore } from './gridstack/store';
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { GridItemHTMLElement, GridStack, GridStackNode } from 'fily-publish-gridstack';
|
import { GridItemHTMLElement, GridStack, GridStackNode } from 'fily-publish-gridstack';
|
||||||
import { MutableRefObject, RefObject } from 'react';
|
import { MutableRefObject, RefObject } from 'react';
|
||||||
|
|
||||||
import { AppType } from '../../../../types/app';
|
import { AppType } from '~/types/app';
|
||||||
import { ShapeType } from '../../../../types/shape';
|
import { ShapeType } from '~/types/shape';
|
||||||
import { IWidget } from '../../../../widgets/widgets';
|
import { IWidget } from '~/widgets/widgets';
|
||||||
|
|
||||||
export const initializeGridstack = (
|
export const initializeGridstack = (
|
||||||
areaType: 'wrapper' | 'category' | 'sidebar',
|
areaType: 'wrapper' | 'category' | 'sidebar',
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { create } from 'zustand';
|
import { createWithEqualityFn } from 'zustand/traditional';
|
||||||
|
|
||||||
import { useConfigContext } from '../../../../config/provider';
|
import { useConfigContext } from '~/config/provider';
|
||||||
import { GridstackBreakpoints } from '../../../../constants/gridstack-breakpoints';
|
import { GridstackBreakpoints } from '~/constants/gridstack-breakpoints';
|
||||||
|
|
||||||
export const useGridstackStore = create<GridstackStoreType>((set, get) => ({
|
export const useGridstackStore = createWithEqualityFn<GridstackStoreType>(
|
||||||
mainAreaWidth: null,
|
(set, get) => ({
|
||||||
currentShapeSize: null,
|
mainAreaWidth: null,
|
||||||
setMainAreaWidth: (w: number) =>
|
currentShapeSize: null,
|
||||||
set((v) => ({ ...v, mainAreaWidth: w, currentShapeSize: getCurrentShapeSize(w) })),
|
setMainAreaWidth: (w: number) =>
|
||||||
}));
|
set((v) => ({ ...v, mainAreaWidth: w, currentShapeSize: getCurrentShapeSize(w) })),
|
||||||
|
}),
|
||||||
|
Object.is
|
||||||
|
);
|
||||||
|
|
||||||
interface GridstackStoreType {
|
interface GridstackStoreType {
|
||||||
mainAreaWidth: null | number;
|
mainAreaWidth: null | number;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { GridStack, GridStackNode } from 'fily-publish-gridstack';
|
import { GridStack, GridStackNode } from 'fily-publish-gridstack';
|
||||||
import { MutableRefObject, RefObject, createRef, useEffect, useMemo, useRef } from 'react';
|
import { MutableRefObject, RefObject, createRef, useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { useConfigContext } from '../../../../config/provider';
|
import { useConfigContext } from '~/config/provider';
|
||||||
import { useConfigStore } from '../../../../config/store';
|
import { useConfigStore } from '~/config/store';
|
||||||
import { AppType } from '../../../../types/app';
|
import { AppType } from '~/types/app';
|
||||||
import { AreaType } from '../../../../types/area';
|
import { AreaType } from '~/types/area';
|
||||||
import { IWidget } from '../../../../widgets/widgets';
|
import { IWidget } from '~/widgets/widgets';
|
||||||
import { useEditModeStore } from '../../Views/useEditModeStore';
|
import { useEditModeStore } from '../../Views/useEditModeStore';
|
||||||
import { TileWithUnknownLocation, initializeGridstack } from './init-gridstack';
|
import { TileWithUnknownLocation, initializeGridstack } from './init-gridstack';
|
||||||
import { useGridstackStore, useWrapperColumnCount } from './store';
|
import { useGridstackStore, useWrapperColumnCount } from './store';
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { useTranslation } from 'next-i18next';
|
|||||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||||
import { api } from '~/utils/api';
|
import { api } from '~/utils/api';
|
||||||
|
|
||||||
import { humanFileSize } from '../../tools/humanFileSize';
|
import { humanFileSize } from '~/tools/humanFileSize';
|
||||||
import { DebouncedImage } from './DebouncedImage';
|
import { DebouncedImage } from './DebouncedImage';
|
||||||
|
|
||||||
export const IconSelector = forwardRef(
|
export const IconSelector = forwardRef(
|
||||||
|
|||||||
84
src/components/Manage/Board/create-board.modal.tsx
Normal file
84
src/components/Manage/Board/create-board.modal.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { Button, Group, Stack, Text, TextInput, Title } from '@mantine/core';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { ContextModalProps, modals } from '@mantine/modals';
|
||||||
|
import { Trans, useTranslation } from 'next-i18next';
|
||||||
|
import { getStaticFallbackConfig } from '~/tools/config/getFallbackConfig';
|
||||||
|
import { api } from '~/utils/api';
|
||||||
|
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
|
||||||
|
import { createBoardSchemaValidation } from '~/validations/boards';
|
||||||
|
|
||||||
|
export const CreateBoardModal = ({ id }: ContextModalProps<{}>) => {
|
||||||
|
const { t } = useTranslation('manage/boards');
|
||||||
|
const utils = api.useContext();
|
||||||
|
const { isLoading, mutate } = api.config.save.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await utils.boards.all.invalidate();
|
||||||
|
modals.close(id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { i18nZodResolver } = useI18nZodResolver();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
name: '',
|
||||||
|
},
|
||||||
|
validate: i18nZodResolver(createBoardSchemaValidation),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const fallbackConfig = getStaticFallbackConfig(form.values.name);
|
||||||
|
mutate({
|
||||||
|
name: form.values.name,
|
||||||
|
config: fallbackConfig,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<Stack>
|
||||||
|
<Text>{t('modals.create.text')}</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label={t('modals.create.form.name.label')}
|
||||||
|
withAsterisk
|
||||||
|
{...form.getInputProps('name')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group grow>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
modals.close(id);
|
||||||
|
}}
|
||||||
|
variant="light"
|
||||||
|
color="gray"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t('common:cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
onClick={async () => {}}
|
||||||
|
disabled={isLoading}
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
>
|
||||||
|
{t('modals.create.form.submit')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const openCreateBoardModal = () => {
|
||||||
|
modals.openContextModal({
|
||||||
|
modal: 'createBoardModal',
|
||||||
|
title: (
|
||||||
|
<Title order={4}>
|
||||||
|
<Trans i18nKey="manage/boards:modals.create.title" />
|
||||||
|
</Title>
|
||||||
|
),
|
||||||
|
innerProps: {},
|
||||||
|
});
|
||||||
|
};
|
||||||
61
src/components/Manage/Board/delete-board.modal.tsx
Normal file
61
src/components/Manage/Board/delete-board.modal.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Button, Group, Stack, Text, Title } from '@mantine/core';
|
||||||
|
import { ContextModalProps, modals } from '@mantine/modals';
|
||||||
|
import { Trans, useTranslation } from 'next-i18next';
|
||||||
|
import { api } from '~/utils/api';
|
||||||
|
|
||||||
|
type InnerProps = { boardName: string; onConfirm: () => Promise<void> };
|
||||||
|
|
||||||
|
export const DeleteBoardModal = ({ id, innerProps }: ContextModalProps<InnerProps>) => {
|
||||||
|
const { t } = useTranslation('manage/boards');
|
||||||
|
const utils = api.useContext();
|
||||||
|
const { isLoading, mutateAsync } = api.config.delete.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await utils.boards.all.invalidate();
|
||||||
|
modals.close(id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Text>{t('modals.delete.text')}</Text>
|
||||||
|
|
||||||
|
<Group grow>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
modals.close(id);
|
||||||
|
}}
|
||||||
|
variant="light"
|
||||||
|
color="gray"
|
||||||
|
>
|
||||||
|
{t('common:cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
modals.close(id);
|
||||||
|
await innerProps.onConfirm();
|
||||||
|
await mutateAsync({
|
||||||
|
name: innerProps.boardName,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
>
|
||||||
|
{t('common:delete')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const openDeleteBoardModal = (innerProps: InnerProps) => {
|
||||||
|
modals.openContextModal({
|
||||||
|
modal: 'deleteBoardModal',
|
||||||
|
title: (
|
||||||
|
<Title order={4}>
|
||||||
|
<Trans i18nKey="manage/boards:modals.delete.title" />
|
||||||
|
</Title>
|
||||||
|
),
|
||||||
|
innerProps,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -11,46 +11,29 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import Dockerode from 'dockerode';
|
import Dockerode from 'dockerode';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import { useState } from 'react';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
import { RouterInputs, api } from '~/utils/api';
|
import { RouterInputs, api } from '~/utils/api';
|
||||||
|
|
||||||
import { useConfigContext } from '../../config/provider';
|
import { openDockerSelectBoardModal } from './docker-select-board.modal';
|
||||||
import { openContextModalGeneric } from '../../tools/mantineModalManagerExtensions';
|
|
||||||
import { AppType } from '../../types/app';
|
|
||||||
|
|
||||||
export interface ContainerActionBarProps {
|
export interface ContainerActionBarProps {
|
||||||
selected: Dockerode.ContainerInfo[];
|
selected: Dockerode.ContainerInfo[];
|
||||||
reload: () => void;
|
reload: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ContainerActionBar({ selected, reload }: ContainerActionBarProps) {
|
export default function ContainerActionBar({
|
||||||
|
selected,
|
||||||
|
reload,
|
||||||
|
isLoading,
|
||||||
|
}: ContainerActionBarProps) {
|
||||||
const { t } = useTranslation('modules/docker');
|
const { t } = useTranslation('modules/docker');
|
||||||
const [isLoading, setLoading] = useState(false);
|
|
||||||
const { config } = useConfigContext();
|
|
||||||
|
|
||||||
const sendDockerCommand = useDockerActionMutation();
|
const sendDockerCommand = useDockerActionMutation();
|
||||||
if (!config) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const getLowestWrapper = () =>
|
|
||||||
config.wrappers.sort((wrapper1, wrapper2) => wrapper1.position - wrapper2.position)[0];
|
|
||||||
|
|
||||||
if (process.env.DISABLE_EDIT_MODE?.toLowerCase() === 'true') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group spacing="xs">
|
<Group spacing="xs">
|
||||||
<Button
|
<Button
|
||||||
leftIcon={<IconRefresh />}
|
leftIcon={<IconRefresh />}
|
||||||
onClick={() => {
|
onClick={reload}
|
||||||
setLoading(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
reload();
|
|
||||||
setLoading(false);
|
|
||||||
}, 750);
|
|
||||||
}}
|
|
||||||
variant="light"
|
variant="light"
|
||||||
color="violet"
|
color="violet"
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
@@ -112,53 +95,7 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
|
|||||||
variant="light"
|
variant="light"
|
||||||
radius="md"
|
radius="md"
|
||||||
disabled={selected.length !== 1}
|
disabled={selected.length !== 1}
|
||||||
onClick={() => {
|
onClick={() => openDockerSelectBoardModal({ containers: selected })}
|
||||||
const containerInfo = selected[0];
|
|
||||||
|
|
||||||
const port = containerInfo.Ports.at(0)?.PublicPort;
|
|
||||||
const address = port ? `http://localhost:${port}` : `http://localhost`;
|
|
||||||
const name = containerInfo.Names.at(0) ?? 'App';
|
|
||||||
|
|
||||||
openContextModalGeneric<{ app: AppType; allowAppNamePropagation: boolean }>({
|
|
||||||
modal: 'editApp',
|
|
||||||
zIndex: 202,
|
|
||||||
innerProps: {
|
|
||||||
app: {
|
|
||||||
id: uuidv4(),
|
|
||||||
name: name,
|
|
||||||
url: address,
|
|
||||||
appearance: {
|
|
||||||
iconUrl: '/imgs/logo/logo.png',
|
|
||||||
appNameStatus: 'normal',
|
|
||||||
appNameFontSize: 16,
|
|
||||||
positionAppName: 'column',
|
|
||||||
lineClampAppName: 1,
|
|
||||||
},
|
|
||||||
network: {
|
|
||||||
enabledStatusChecker: true,
|
|
||||||
statusCodes: ['200', '301', '302']
|
|
||||||
},
|
|
||||||
behaviour: {
|
|
||||||
isOpeningNewTab: true,
|
|
||||||
externalUrl: address
|
|
||||||
},
|
|
||||||
area: {
|
|
||||||
type: 'wrapper',
|
|
||||||
properties: {
|
|
||||||
id: getLowestWrapper()?.id ?? 'default',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
shape: {},
|
|
||||||
integration: {
|
|
||||||
type: null,
|
|
||||||
properties: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
allowAppNamePropagation: true,
|
|
||||||
},
|
|
||||||
size: 'xl',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t('actionBar.addToHomarr.title')}
|
{t('actionBar.addToHomarr.title')}
|
||||||
</Button>
|
</Button>
|
||||||
171
src/components/Manage/Tools/Docker/ContainerTable.tsx
Normal file
171
src/components/Manage/Tools/Docker/ContainerTable.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Checkbox,
|
||||||
|
Group,
|
||||||
|
ScrollArea,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
createStyles,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useElementSize } from '@mantine/hooks';
|
||||||
|
import { IconSearch } from '@tabler/icons-react';
|
||||||
|
import Dockerode, { ContainerInfo } from 'dockerode';
|
||||||
|
import { useTranslation } from 'next-i18next';
|
||||||
|
import { Dispatch, SetStateAction, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { MIN_WIDTH_MOBILE } from '../../../../constants/constants';
|
||||||
|
import ContainerState from './ContainerState';
|
||||||
|
|
||||||
|
const useStyles = createStyles((theme) => ({
|
||||||
|
rowSelected: {
|
||||||
|
backgroundColor:
|
||||||
|
theme.colorScheme === 'dark'
|
||||||
|
? theme.fn.rgba(theme.colors[theme.primaryColor][7], 0.2)
|
||||||
|
: theme.colors[theme.primaryColor][0],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default function ContainerTable({
|
||||||
|
containers,
|
||||||
|
selection,
|
||||||
|
setSelection,
|
||||||
|
}: {
|
||||||
|
setSelection: Dispatch<SetStateAction<ContainerInfo[]>>;
|
||||||
|
containers: ContainerInfo[];
|
||||||
|
selection: ContainerInfo[];
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation('modules/docker');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const { ref, width } = useElementSize();
|
||||||
|
|
||||||
|
const filteredContainers = useMemo(
|
||||||
|
() => filterContainers(containers, search),
|
||||||
|
[containers, search]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearch(event.currentTarget.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleRow = (container: ContainerInfo) =>
|
||||||
|
setSelection((selected: ContainerInfo[]) =>
|
||||||
|
selected.includes(container)
|
||||||
|
? selected.filter((c) => c !== container)
|
||||||
|
: [...selected, container]
|
||||||
|
);
|
||||||
|
const toggleAll = () =>
|
||||||
|
setSelection((selected: ContainerInfo[]) =>
|
||||||
|
selected.length === filteredContainers.length ? [] : filteredContainers.map((c) => c)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea style={{ height: '100%' }} offsetScrollbars>
|
||||||
|
<TextInput
|
||||||
|
placeholder={t('search.placeholder') ?? undefined}
|
||||||
|
mr="md"
|
||||||
|
icon={<IconSearch size={14} />}
|
||||||
|
value={search}
|
||||||
|
autoFocus
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
/>
|
||||||
|
<Table ref={ref} captionSide="bottom" highlightOnHover verticalSpacing="sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 40 }}>
|
||||||
|
<Checkbox
|
||||||
|
onChange={toggleAll}
|
||||||
|
checked={selection.length === filteredContainers.length && selection.length > 0}
|
||||||
|
indeterminate={
|
||||||
|
selection.length > 0 && selection.length !== filteredContainers.length
|
||||||
|
}
|
||||||
|
transitionDuration={0}
|
||||||
|
disabled={filteredContainers.length === 0}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th>{t('table.header.name')}</th>
|
||||||
|
{width > MIN_WIDTH_MOBILE ? <th>{t('table.header.image')}</th> : null}
|
||||||
|
{width > MIN_WIDTH_MOBILE ? <th>{t('table.header.ports')}</th> : null}
|
||||||
|
<th>{t('table.header.state')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredContainers.map((container) => {
|
||||||
|
const selected = selection.includes(container);
|
||||||
|
return (
|
||||||
|
<Row
|
||||||
|
key={container.Id}
|
||||||
|
container={container}
|
||||||
|
selected={selected}
|
||||||
|
toggleRow={toggleRow}
|
||||||
|
width={width}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type RowProps = {
|
||||||
|
container: ContainerInfo;
|
||||||
|
selected: boolean;
|
||||||
|
toggleRow: (container: ContainerInfo) => void;
|
||||||
|
width: number;
|
||||||
|
};
|
||||||
|
const Row = ({ container, selected, toggleRow, width }: RowProps) => {
|
||||||
|
const { t } = useTranslation('modules/docker');
|
||||||
|
const { classes, cx } = useStyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className={cx({ [classes.rowSelected]: selected })}>
|
||||||
|
<td>
|
||||||
|
<Checkbox checked={selected} onChange={() => toggleRow(container)} transitionDuration={0} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Text size="lg" weight={600}>
|
||||||
|
{container.Names[0].replace('/', '')}
|
||||||
|
</Text>
|
||||||
|
</td>
|
||||||
|
{width > MIN_WIDTH_MOBILE && (
|
||||||
|
<td>
|
||||||
|
<Text size="lg">{container.Image}</Text>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{width > MIN_WIDTH_MOBILE && (
|
||||||
|
<td>
|
||||||
|
<Group>
|
||||||
|
{container.Ports.sort((a, b) => a.PrivatePort - b.PrivatePort)
|
||||||
|
// Remove duplicates with filter function
|
||||||
|
.filter(
|
||||||
|
(port, index, self) =>
|
||||||
|
index === self.findIndex((t) => t.PrivatePort === port.PrivatePort)
|
||||||
|
)
|
||||||
|
.slice(-3)
|
||||||
|
.map((port) => (
|
||||||
|
<Badge key={port.PrivatePort} variant="outline">
|
||||||
|
{port.PrivatePort}:{port.PublicPort}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{container.Ports.length > 3 && (
|
||||||
|
<Badge variant="filled">
|
||||||
|
{t('table.body.portCollapse', { ports: container.Ports.length - 3 })}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td>
|
||||||
|
<ContainerState state={container.State} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function filterContainers(data: Dockerode.ContainerInfo[], search: string) {
|
||||||
|
const query = search.toLowerCase().trim();
|
||||||
|
return data.filter((item) =>
|
||||||
|
item.Names.some((name) => name.toLowerCase().includes(query) || item.Image.includes(query))
|
||||||
|
);
|
||||||
|
}
|
||||||
120
src/components/Manage/Tools/Docker/docker-select-board.modal.tsx
Normal file
120
src/components/Manage/Tools/Docker/docker-select-board.modal.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { Button, Group, Select, Stack, Text, TextInput, Title } from '@mantine/core';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { ContextModalProps, modals } from '@mantine/modals';
|
||||||
|
import { showNotification } from '@mantine/notifications';
|
||||||
|
import { IconCheck, IconX } from '@tabler/icons-react';
|
||||||
|
import { ContainerInfo } from 'dockerode';
|
||||||
|
import { Trans, useTranslation } from 'next-i18next';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { api } from '~/utils/api';
|
||||||
|
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
|
||||||
|
|
||||||
|
const dockerSelectBoardSchema = z.object({
|
||||||
|
board: z.string().nonempty(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type InnerProps = {
|
||||||
|
containers: ContainerInfo[];
|
||||||
|
};
|
||||||
|
type FormType = z.infer<typeof dockerSelectBoardSchema>;
|
||||||
|
|
||||||
|
export const DockerSelectBoardModal = ({ id, innerProps }: ContextModalProps<InnerProps>) => {
|
||||||
|
const { t } = useTranslation('tools/docker');
|
||||||
|
const { mutateAsync, isLoading } = api.boards.addAppsForContainers.useMutation();
|
||||||
|
const { i18nZodResolver } = useI18nZodResolver();
|
||||||
|
const handleSubmit = async (values: FormType) => {
|
||||||
|
await mutateAsync(
|
||||||
|
{
|
||||||
|
apps: innerProps.containers.map((container) => ({
|
||||||
|
name: container.Names.at(0) ?? 'App',
|
||||||
|
port: container.Ports.at(0)?.PublicPort,
|
||||||
|
})),
|
||||||
|
boardName: values.board,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
showNotification({
|
||||||
|
title: t('notifications.selectBoard.success.title'),
|
||||||
|
message: t('notifications.selectBoard.success.message'),
|
||||||
|
icon: <IconCheck />,
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
|
|
||||||
|
modals.close(id);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showNotification({
|
||||||
|
title: t('notifications.selectBoard.error.title'),
|
||||||
|
message: t('notifications.selectBoard.error.message'),
|
||||||
|
icon: <IconX />,
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = useForm<FormType>({
|
||||||
|
initialValues: {
|
||||||
|
board: '',
|
||||||
|
},
|
||||||
|
validate: i18nZodResolver(dockerSelectBoardSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: boards } = api.boards.all.useQuery();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<Stack>
|
||||||
|
<Text>{t('modals.selectBoard.text')}</Text>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label={t('modals.selectBoard.form.board.label')}
|
||||||
|
withAsterisk
|
||||||
|
withinPortal
|
||||||
|
data={
|
||||||
|
boards?.map((board) => ({
|
||||||
|
value: board.name,
|
||||||
|
label: board.name,
|
||||||
|
})) ?? []
|
||||||
|
}
|
||||||
|
{...form.getInputProps('board')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group grow>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
modals.close(id);
|
||||||
|
}}
|
||||||
|
variant="light"
|
||||||
|
color="gray"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t('common:cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
onClick={async () => {}}
|
||||||
|
disabled={isLoading}
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
>
|
||||||
|
{t('modals.selectBoard.form.submit')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const openDockerSelectBoardModal = (innerProps: InnerProps) => {
|
||||||
|
modals.openContextModal({
|
||||||
|
modal: 'dockerSelectBoardModal',
|
||||||
|
title: (
|
||||||
|
<Title order={4}>
|
||||||
|
<Trans i18nKey="tools/docker:modals.selectBoard.title" />
|
||||||
|
</Title>
|
||||||
|
),
|
||||||
|
innerProps,
|
||||||
|
});
|
||||||
|
};
|
||||||
73
src/components/Manage/User/Create/create-account-step.tsx
Normal file
73
src/components/Manage/User/Create/create-account-step.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Button, Card, Flex, TextInput } from '@mantine/core';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { IconArrowRight, IconAt, IconUser } from '@tabler/icons-react';
|
||||||
|
import { useTranslation } from 'next-i18next';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
|
||||||
|
|
||||||
|
interface CreateAccountStepProps {
|
||||||
|
nextStep: ({ eMail, username }: { username: string; eMail: string }) => void;
|
||||||
|
defaultUsername: string;
|
||||||
|
defaultEmail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateAccountStep = ({
|
||||||
|
defaultEmail,
|
||||||
|
defaultUsername,
|
||||||
|
nextStep,
|
||||||
|
}: CreateAccountStepProps) => {
|
||||||
|
const { t } = useTranslation('manage/users/create');
|
||||||
|
|
||||||
|
const { i18nZodResolver } = useI18nZodResolver();
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
username: defaultUsername,
|
||||||
|
eMail: defaultEmail,
|
||||||
|
},
|
||||||
|
validateInputOnBlur: true,
|
||||||
|
validateInputOnChange: true,
|
||||||
|
validate: i18nZodResolver(createAccountStepValidationSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card mih={400}>
|
||||||
|
<TextInput
|
||||||
|
icon={<IconUser size="0.8rem" />}
|
||||||
|
label={t('steps.account.username.label')}
|
||||||
|
variant="filled"
|
||||||
|
mb="md"
|
||||||
|
withAsterisk
|
||||||
|
{...form.getInputProps('username')}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
icon={<IconAt size="0.8rem" />}
|
||||||
|
label={t('steps.account.email.label')}
|
||||||
|
variant="filled"
|
||||||
|
mb="md"
|
||||||
|
{...form.getInputProps('eMail')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Flex justify="end" wrap="nowrap">
|
||||||
|
<Button
|
||||||
|
rightIcon={<IconArrowRight size="1rem" />}
|
||||||
|
disabled={!form.isValid()}
|
||||||
|
onClick={() => {
|
||||||
|
nextStep({
|
||||||
|
username: form.values.username,
|
||||||
|
eMail: form.values.eMail,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
variant="light"
|
||||||
|
px="xl"
|
||||||
|
>
|
||||||
|
{t('common:next')}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createAccountStepValidationSchema = z.object({
|
||||||
|
username: z.string().min(1).max(100),
|
||||||
|
eMail: z.string().email().or(z.literal('')),
|
||||||
|
});
|
||||||
120
src/components/Manage/User/Create/review-input-step.tsx
Normal file
120
src/components/Manage/User/Create/review-input-step.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { Alert, Button, Card, Group, Table, Text, Title } from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconAlertTriangle,
|
||||||
|
IconAlertTriangleFilled,
|
||||||
|
IconArrowLeft,
|
||||||
|
IconCheck,
|
||||||
|
IconInfoCircle,
|
||||||
|
IconKey,
|
||||||
|
IconMail,
|
||||||
|
IconUser,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useTranslation } from 'next-i18next';
|
||||||
|
import { CreateAccountSchema } from '~/pages/manage/users/create';
|
||||||
|
import { api } from '~/utils/api';
|
||||||
|
|
||||||
|
type ReviewInputStepProps = {
|
||||||
|
values: CreateAccountSchema;
|
||||||
|
prevStep: () => void;
|
||||||
|
nextStep: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReviewInputStep = ({ values, prevStep, nextStep }: ReviewInputStepProps) => {
|
||||||
|
const { t } = useTranslation('manage/users/create');
|
||||||
|
|
||||||
|
const utils = api.useContext();
|
||||||
|
const { mutateAsync: createAsync, isLoading, isError, error } = api.user.create.useMutation({
|
||||||
|
onSettled: () => {
|
||||||
|
void utils.user.all.invalidate();
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
nextStep();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card mih={400}>
|
||||||
|
<Title order={5}>{t('steps.finish.card.title')}</Title>
|
||||||
|
<Text mb="xl">{t('steps.finish.card.text')}</Text>
|
||||||
|
|
||||||
|
<Table mb="lg" withBorder highlightOnHover>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('steps.finish.table.header.property')}</th>
|
||||||
|
<th>{t('steps.finish.table.header.value')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<Group spacing="xs">
|
||||||
|
<IconUser size="1rem" />
|
||||||
|
<Text>{t('steps.finish.table.header.username')}</Text>
|
||||||
|
</Group>
|
||||||
|
</td>
|
||||||
|
<td>{values.account.username}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<Group spacing="xs">
|
||||||
|
<IconMail size="1rem" />
|
||||||
|
<Text>{t('steps.finish.table.header.email')}</Text>
|
||||||
|
</Group>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{values.account.eMail ? (
|
||||||
|
<Text>{values.account.eMail}</Text>
|
||||||
|
) : (
|
||||||
|
<Group spacing="xs">
|
||||||
|
<IconInfoCircle size="1rem" color="orange" />
|
||||||
|
<Text color="orange">{t('steps.finish.table.notSet')}</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<Group spacing="xs">
|
||||||
|
<IconKey size="1rem" />
|
||||||
|
<Text>{t('steps.finish.table.header.password')}</Text>
|
||||||
|
</Group>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Group spacing="xs">
|
||||||
|
<IconCheck size="1rem" color="green" />
|
||||||
|
<Text color="green">{t('steps.finish.table.valid')}</Text>
|
||||||
|
</Group>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<Alert color="red" icon={<IconAlertTriangleFilled size="0.9rem" />} mb="lg">
|
||||||
|
<Text color="red">{t('steps.finish.failed', { error: error.message })}</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group position="apart" noWrap>
|
||||||
|
<Button leftIcon={<IconArrowLeft size="1rem" />} onClick={prevStep} variant="light" px="xl">
|
||||||
|
{t('common:previous')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
await createAsync({
|
||||||
|
username: values.account.username,
|
||||||
|
password: values.security.password,
|
||||||
|
email: values.account.eMail === '' ? undefined : values.account.eMail,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
loading={isLoading}
|
||||||
|
rightIcon={<IconCheck size="1rem" />}
|
||||||
|
variant="light"
|
||||||
|
px="xl"
|
||||||
|
>
|
||||||
|
{t('common:confirm')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
108
src/components/Manage/User/Create/security-step.tsx
Normal file
108
src/components/Manage/User/Create/security-step.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { Button, Card, Flex, Group, PasswordInput, Popover } from '@mantine/core';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { IconArrowLeft, IconArrowRight, IconDice, IconKey } from '@tabler/icons-react';
|
||||||
|
import { useTranslation } from 'next-i18next';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { PasswordRequirements } from '~/components/Password/password-requirements';
|
||||||
|
import { api } from '~/utils/api';
|
||||||
|
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
|
||||||
|
import { passwordSchema } from '~/validations/user';
|
||||||
|
|
||||||
|
interface CreateAccountSecurityStepProps {
|
||||||
|
defaultPassword: string;
|
||||||
|
nextStep: ({ password }: { password: string }) => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateAccountSecurityStep = ({
|
||||||
|
defaultPassword,
|
||||||
|
nextStep,
|
||||||
|
prevStep,
|
||||||
|
}: CreateAccountSecurityStepProps) => {
|
||||||
|
const { t } = useTranslation('manage/users/create');
|
||||||
|
|
||||||
|
const { i18nZodResolver } = useI18nZodResolver();
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
password: defaultPassword,
|
||||||
|
},
|
||||||
|
validateInputOnBlur: true,
|
||||||
|
validateInputOnChange: true,
|
||||||
|
validate: i18nZodResolver(createAccountSecurityStepValidationSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync, isLoading } = api.password.generate.useMutation();
|
||||||
|
|
||||||
|
const [popoverOpened, setPopoverOpened] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card mih={400}>
|
||||||
|
<Popover
|
||||||
|
opened={popoverOpened}
|
||||||
|
position="bottom"
|
||||||
|
width="target"
|
||||||
|
transitionProps={{ transition: 'pop' }}
|
||||||
|
>
|
||||||
|
<Popover.Target>
|
||||||
|
<div
|
||||||
|
onFocusCapture={() => setPopoverOpened(true)}
|
||||||
|
onBlurCapture={() => setPopoverOpened(false)}
|
||||||
|
>
|
||||||
|
<Flex columnGap={10} align="start">
|
||||||
|
<PasswordInput
|
||||||
|
icon={<IconKey size="0.8rem" />}
|
||||||
|
style={{
|
||||||
|
flexGrow: 1,
|
||||||
|
}}
|
||||||
|
label={t('steps.security.password.label')}
|
||||||
|
variant="filled"
|
||||||
|
mb="md"
|
||||||
|
withAsterisk
|
||||||
|
{...form.getInputProps('password')}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
leftIcon={<IconDice size="1rem" />}
|
||||||
|
onClick={async () => {
|
||||||
|
const randomPassword = await mutateAsync();
|
||||||
|
form.setFieldValue('password', randomPassword);
|
||||||
|
}}
|
||||||
|
loading={isLoading}
|
||||||
|
variant="default"
|
||||||
|
mt="xl"
|
||||||
|
>
|
||||||
|
{t('buttons.generateRandomPassword')}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown>
|
||||||
|
<PasswordRequirements value={form.values.password} />
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<Group position="apart" noWrap>
|
||||||
|
<Button leftIcon={<IconArrowLeft size="1rem" />} onClick={prevStep} variant="light" px="xl">
|
||||||
|
{t('common:previous')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
rightIcon={<IconArrowRight size="1rem" />}
|
||||||
|
onClick={() => {
|
||||||
|
nextStep({
|
||||||
|
password: form.values.password,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
variant="light"
|
||||||
|
px="xl"
|
||||||
|
disabled={!form.isValid()}
|
||||||
|
>
|
||||||
|
{t('common:next')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createAccountSecurityStepValidationSchema = z.object({
|
||||||
|
password: passwordSchema,
|
||||||
|
});
|
||||||
75
src/components/Manage/User/Invite/copy-invite.modal.tsx
Normal file
75
src/components/Manage/User/Invite/copy-invite.modal.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { Button, CopyButton, Mark, Stack, Text, Title } from '@mantine/core';
|
||||||
|
import { ContextModalProps, modals } from '@mantine/modals';
|
||||||
|
import { Trans, useTranslation } from 'next-i18next';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { RouterOutputs } from '~/utils/api';
|
||||||
|
|
||||||
|
type InnerProps = RouterOutputs['invites']['create'];
|
||||||
|
|
||||||
|
export const CopyInviteModal = ({ id, innerProps }: ContextModalProps<InnerProps>) => {
|
||||||
|
const { t } = useTranslation('manage/users/invites');
|
||||||
|
const inviteUrl = useInviteUrl(innerProps.id, innerProps.token);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Text>
|
||||||
|
<Trans
|
||||||
|
i18nKey="manage/users/invites:modals.copy.description"
|
||||||
|
components={{
|
||||||
|
b: <b />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Link href={`/auth/invite/${innerProps.id}?token=${innerProps.token}`}>
|
||||||
|
{t('modals.copy.invitationLink')}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Stack spacing="xs">
|
||||||
|
<Text weight="bold">{t('modals.copy.details.id')}:</Text>
|
||||||
|
<Mark style={{ borderRadius: 4 }} color="gray" px={5}>
|
||||||
|
{innerProps.id}
|
||||||
|
</Mark>
|
||||||
|
|
||||||
|
<Text weight="bold">{t('modals.copy.details.token')}:</Text>
|
||||||
|
<Mark style={{ borderRadius: 4 }} color="gray" px={5}>
|
||||||
|
{innerProps.token}
|
||||||
|
</Mark>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<CopyButton value={inviteUrl}>
|
||||||
|
{({ copy }) => (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
copy();
|
||||||
|
modals.close(id);
|
||||||
|
}}
|
||||||
|
variant="default"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{t('modals.copy.button.close')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CopyButton>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useInviteUrl = (id: string, token: string) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return `${window.location.href.replace(router.pathname, `/auth/invite/${id}?token=${token}`)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const openCopyInviteModal = (data: InnerProps) => {
|
||||||
|
modals.openContextModal({
|
||||||
|
modal: 'copyInviteModal',
|
||||||
|
title: (
|
||||||
|
<Title order={4}>
|
||||||
|
<Trans i18nKey="manage/users/invites:modals.copy.title" />
|
||||||
|
</Title>
|
||||||
|
),
|
||||||
|
innerProps: data,
|
||||||
|
});
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user