🔀 Add basic authentication

This commit is contained in:
Manuel
2023-09-24 18:48:48 +02:00
committed by GitHub
285 changed files with 10887 additions and 5198 deletions

View File

@@ -7,3 +7,7 @@ npm-debug.log
.github
LICENSE
docs/
*.sqlite
*.env
.env
.next/standalone/.env

23
.env.example Normal file
View 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=""

View File

@@ -65,7 +65,7 @@ jobs:
- run: yarn install --immutable
- run: yarn build
- run: yarn turbo build
- name: Docker meta
id: meta
@@ -83,6 +83,9 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
with:
driver: docker
buildkitd-flags: --debug
- name: Login to GHCR
uses: docker/login-action@v2
@@ -101,3 +104,4 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
network: host

View File

@@ -41,6 +41,7 @@ jobs:
permissions:
packages: write
contents: read
pull-requests: write
steps:
- name: Setup
@@ -74,7 +75,11 @@ jobs:
- 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
if: github.event_name != 'pull_request'
@@ -114,3 +119,4 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
network: host

9
.gitignore vendored
View File

@@ -55,4 +55,11 @@ data/configs
#Languages other than 'en'
public/locales/*
!public/locales/en
!public/locales/en
#database
prisma/db.sqlite
database/*.sqlite
# IDE
.idea/*

View File

@@ -1,6 +1,7 @@
FROM node:20-alpine
FROM node:20.5-slim
WORKDIR /app
# Define node.js environment variables
ARG PORT=7575
ENV NEXT_TELEMETRY_DISABLED 1
@@ -10,16 +11,28 @@ ENV NODE_OPTIONS '--no-experimental-fetch'
COPY next.config.js ./
COPY public ./public
COPY package.json ./package.json
COPY yarn.lock ./yarn.lock
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY .next/standalone ./
COPY .next/static ./.next/static
COPY 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
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 \
CMD wget --no-verbose --tries=1 --spider http://localhost:${PORT} || exit 1
CMD ["node", "server.js"]
CMD ["sh", "./scripts/run.sh"]

View File

@@ -492,7 +492,7 @@
"pageTitle": "Homarr ⭐️",
"logoImageUrl": "/imgs/logo/logo.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": "",
"colors": {
"primary": "red",

View File

@@ -1,2 +1,4 @@
export const REPO_URL = 'ajnart/homarr';
export const ICON_PICKER_SLICE_LIMIT = 36;
export const COOKIE_LOCALE_KEY = 'config-locale';
export const COOKIE_COLOR_SCHEME_KEY = 'color-scheme';

View File

@@ -29,8 +29,8 @@ module.exports = {
'no',
'tr',
'lv',
'hu',
'hr'
'hr',
'hu'
],
localeDetection: true,

View File

@@ -1,3 +1,4 @@
require('./src/env');
const { i18n } = require('./next-i18next.config');
const withBundleAnalyzer = require('@next/bundle-analyzer')({
@@ -12,4 +13,14 @@ module.exports = withBundleAnalyzer({
output: 'standalone',
i18n,
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'
},
});

View File

@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "0.13.4",
"version": "0.13.2",
"description": "Homarr - A homepage for your server.",
"license": "MIT",
"repository": {
@@ -9,9 +9,9 @@
},
"scripts": {
"dev": "next dev",
"build": "next build",
"build": "NEXTAUTH_SECRET=WILL_BE_OVERWRITTEN 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",
"typecheck": "tsc --noEmit",
"export": "next build && next export",
@@ -22,8 +22,9 @@
"test:ui": "vitest --ui",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"docker:build": "turbo build && docker build . -t homarr:dev",
"docker:start": "docker run --env-file ./.env -p 7575:7575 homarr:dev "
"docker:build": "turbo build && docker build . -t homarr:local-dev",
"docker:start": "docker run -p 7575:7575 --name homarr-development homarr:local-dev",
"postinstall": "prisma generate"
},
"dependencies": {
"@ctrl/deluge": "^4.1.0",
@@ -41,10 +42,14 @@
"@mantine/modals": "^6.0.0",
"@mantine/next": "^6.0.0",
"@mantine/notifications": "^6.0.0",
"@mantine/prism": "^6.0.19",
"@mantine/tiptap": "^6.0.17",
"@next-auth/prisma-adapter": "^1.0.7",
"@nivo/core": "^0.83.0",
"@nivo/line": "^0.83.0",
"@prisma/client": "^5.0.0",
"@react-native-async-storage/async-storage": "^1.18.1",
"@t3-oss/env-nextjs": "^0.6.0",
"@tabler/icons-react": "^2.20.0",
"@tanstack/query-async-storage-persister": "^4.27.1",
"@tanstack/query-sync-storage-persister": "^4.27.1",
@@ -59,22 +64,32 @@
"@trpc/next": "^10.29.1",
"@trpc/react-query": "^10.29.1",
"@trpc/server": "^10.29.1",
"@types/bcryptjs": "^2.4.2",
"@vitejs/plugin-react": "^4.0.0",
"axios": "^1.0.0",
"bcryptjs": "^2.4.3",
"browser-geo-tz": "^0.0.4",
"consola": "^3.0.0",
"cookies": "^0.8.0",
"cookies-next": "^2.1.1",
"dayjs": "^1.11.7",
"dockerode": "^3.3.2",
"fily-publish-gridstack": "^0.0.13",
"flag-icons": "^6.9.2",
"framer-motion": "^10.0.0",
"generate-password": "^1.7.0",
"geo-tz": "^7.0.7",
"html-entities": "^2.3.3",
"i18next": "^22.5.1",
"immer": "^10.0.2",
"js-file-download": "^0.4.12",
"moment": "^2.29.4",
"moment-timezone": "^0.5.43",
"next": "13.4.12",
"next-i18next": "^13.0.0",
"next-auth": "^4.22.3",
"next-i18next": "^14.0.0",
"nzbget-api": "^0.0.3",
"prisma": "^5.0.0",
"prismjs": "^1.29.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -82,6 +97,7 @@
"react-simple-code-editor": "^0.13.1",
"rss-parser": "^3.12.0",
"sabnzbd-api": "^1.5.0",
"sharp": "^0.32.4",
"uuid": "^9.0.0",
"xml-js": "^1.6.11",
"xss": "^1.0.14",
@@ -94,6 +110,7 @@
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0",
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
"@types/cookies": "^0.7.7",
"@types/dockerode": "^3.3.9",
"@types/node": "18.17.8",
"@types/prismjs": "^1.26.0",
@@ -103,7 +120,8 @@
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.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-config-next": "^13.4.5",
"eslint-plugin-promise": "^6.0.0",
@@ -117,7 +135,7 @@
"prettier": "^3.0.0",
"sass": "^1.56.1",
"ts-node": "latest",
"turbo": "latest",
"turbo": "^1.10.12",
"typescript": "^5.1.0",
"video.js": "^8.0.3",
"vite-tsconfig-paths": "^4.2.0",

93
prisma/schema.prisma Normal file
View 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])
}

View 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

View 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

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

View File

@@ -1,27 +1,20 @@
{
"metaTitle": "Login",
"title": "Welcome back!",
"text": "Please enter your password",
"text": "Please enter your credentials",
"form": {
"fields": {
"username": {
"label": "Username"
},
"password": {
"label": "Password",
"placeholder": "Your password"
"label": "Password"
}
},
"buttons": {
"submit": "Sign in"
}
},
"afterLoginRedirection": "After login, you'll be redirected to {{url}}"
},
"notifications": {
"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."
}
}
}
"alert": "Your credentials are incorrect or this account doesn't exist. Please try again."
}

View File

@@ -0,0 +1,5 @@
{
"header": {
"customize": "Customize board"
}
}

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

View File

@@ -3,9 +3,13 @@
"about": "About",
"cancel": "Cancel",
"close": "Close",
"back": "Back",
"delete": "Delete",
"ok": "OK",
"edit": "Edit",
"next": "Next",
"previous": "Previous",
"confirm": "Confirm",
"enabled": "Enabled",
"disabled": "Disabled",
"enableAll": "Enable all",

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

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

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

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

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

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

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

View File

@@ -7,9 +7,6 @@
"useSonarrv4": {
"label": "Use Sonarr v4 API"
},
"sundayStart": {
"label": "Start the week on Sunday"
},
"radarrReleaseType": {
"label": "Radarr release type",
"data":{

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

View File

@@ -0,0 +1,6 @@
{
"allowGuests": {
"label": "Allow anonymous",
"description": "Allow users that are not logged in to view your board"
}
}

View File

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

View File

@@ -1,3 +0,0 @@
{
"label": "App Width"
}

View File

@@ -1,6 +0,0 @@
{
"colors": "Colors",
"suffix": "{{color}} color",
"primary": "Primary",
"secondary": "Secondary"
}

View File

@@ -20,6 +20,10 @@
"accessibility": {
"name": "Accessibility",
"description": "Configure Homarr for disabled and handicapped users"
},
"access": {
"name": "Acccess",
"description": "Configure who has access to your board"
}
}
}

View File

@@ -23,8 +23,5 @@
"description": "Further, customize your dashboard using CSS, only recommended for experienced users",
"placeholder": "Custom CSS will be applied last",
"applying": "Applying CSS..."
},
"buttons": {
"submit": "Submit"
}
}
}

View File

@@ -1,3 +0,0 @@
{
"label": "Switch to {{scheme}} mode"
}

View File

@@ -1,3 +0,0 @@
{
"label": "Switch to {{theme}} mode"
}

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

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

View File

@@ -27,6 +27,10 @@
},
"population": {
"fallback": "Unknown"
},
"nothingFound": {
"title": "Nothing found",
"description": "Please try another search term"
}
}
}

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

@@ -2,8 +2,8 @@ import { ActionIcon, Space, createStyles } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
import { useConfigContext } from '../../../../config/provider';
import { useScreenLargerThan } from '../../../../hooks/useScreenLargerThan';
import { useConfigContext } from '~/config/provider';
import { useScreenLargerThan } from '~/hooks/useScreenLargerThan';
import { MobileRibbonSidebarDrawer } from './MobileRibbonSidebarDrawer';
export const MobileRibbons = () => {

View File

@@ -1,9 +1,9 @@
import { SelectItem } from '@mantine/core';
import { ContextModalProps, closeModal } from '@mantine/modals';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
import { AppType } from '../../../../types/app';
import { useConfigContext } from '~/config/provider';
import { useConfigStore } from '~/config/store';
import { AppType } from '~/types/app';
import { useGridstackStore, useWrapperColumnCount } from '../../Wrappers/gridstack/store';
import { ChangePositionModal } from './ChangePositionModal';

View File

@@ -2,7 +2,7 @@ import { Button, Flex, Grid, NumberInput, Select, SelectItem } from '@mantine/co
import { useForm } from '@mantine/form';
import { useTranslation } from 'next-i18next';
import { useConfigContext } from '../../../../config/provider';
import { useConfigContext } from '~/config/provider';
interface ChangePositionModalProps {
initialX?: number;

View File

@@ -1,8 +1,8 @@
import { SelectItem } from '@mantine/core';
import { ContextModalProps, closeModal } from '@mantine/modals';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
import { useConfigContext } from '~/config/provider';
import { useConfigStore } from '~/config/store';
import widgets from '../../../../widgets';
import { WidgetChangePositionModalInnerProps } from '../../Tiles/Widgets/WidgetsMenu';
import { useGridstackStore, useWrapperColumnCount } from '../../Wrappers/gridstack/store';

View File

@@ -13,9 +13,9 @@ import {
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
import { AppType } from '../../../../types/app';
import { useConfigContext } from '~/config/provider';
import { useConfigStore } from '~/config/store';
import { AppType } from '~/types/app';
import { DebouncedImage } from '../../../IconSelector/DebouncedImage';
import { useEditModeStore } from '../../Views/useEditModeStore';
import { AppearanceTab } from './Tabs/AppereanceTab/AppereanceTab';

View File

@@ -4,8 +4,8 @@ import { useDebouncedValue } from '@mantine/hooks';
import { useTranslation } from 'next-i18next';
import { useEffect, useRef } from 'react';
import { AppType } from '../../../../../../types/app';
import { IconSelector } from '../../../../../IconSelector/IconSelector';
import { AppType } from '~/types/app';
import { IconSelector } from '~/components/IconSelector/IconSelector';
interface AppearanceTabProps {
form: UseFormReturnType<AppType, (values: AppType) => AppType>;

View File

@@ -2,7 +2,7 @@ import { Text, TextInput, Tooltip, Stack, Switch, Tabs, Group, useMantineTheme,
import { UseFormReturnType } from '@mantine/form';
import { useTranslation } from 'next-i18next';
import { AppType } from '../../../../../../types/app';
import { AppType } from '~/types/app';
import { InfoCard } from '~/components/InfoCard/InfoCard'
interface BehaviourTabProps {

View File

@@ -3,7 +3,7 @@ import { UseFormReturnType } from '@mantine/form';
import { IconClick, IconCursorText, IconLink } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { AppType } from '../../../../../../types/app';
import { AppType } from '~/types/app';
import { EditAppModalTab } from '../type';
interface GeneralTabProps {

View File

@@ -16,7 +16,7 @@ import { Icon } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { AppIntegrationPropertyAccessabilityType } from '../../../../../../../../types/app';
import { AppIntegrationPropertyAccessabilityType } from '~/types/app';
interface GenericSecretInputProps {
label: string;

View File

@@ -11,7 +11,7 @@ import {
IntegrationField,
integrationFieldDefinitions,
integrationFieldProperties,
} from '../../../../../../../../types/app';
} from '~/types/app';
interface IntegrationSelectorProps {
form: UseFormReturnType<AppType, (item: AppType) => AppType>;
@@ -20,83 +20,9 @@ interface IntegrationSelectorProps {
export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
const { t } = useTranslation('layout/modals/add-app');
const data: SelectItem[] = [
{
value: 'sabnzbd',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sabnzbd.png',
label: 'SABnzbd',
},
{
value: '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 data = availableIntegrations.filter((x) =>
Object.keys(integrationFieldProperties).includes(x.value)
);
const getNewProperties = (value: string | null): AppIntegrationPropertyType[] => {
if (!value) return [];
@@ -181,3 +107,81 @@ const SelectItemComponent = forwardRef<HTMLDivElement, ItemProps>(
</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[]>;

View File

@@ -8,7 +8,7 @@ import {
IntegrationField,
integrationFieldDefinitions,
integrationFieldProperties,
} from '../../../../../../../../types/app';
} from '~/types/app';
import { GenericSecretInput } from '../InputElements/GenericSecretInput';
interface IntegrationOptionsRendererProps {

View File

@@ -3,7 +3,7 @@ import { UseFormReturnType } from '@mantine/form';
import { IconAlertTriangle } from '@tabler/icons-react';
import { Trans, useTranslation } from 'next-i18next';
import { AppType } from '../../../../../../types/app';
import { AppType } from '~/types/app';
import { IntegrationSelector } from './Components/InputElements/IntegrationSelector';
import { IntegrationOptionsRenderer } from './Components/IntegrationOptionsRenderer/IntegrationOptionsRenderer';

View File

@@ -2,8 +2,8 @@ import { MultiSelect, Stack, Switch, Tabs } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { useTranslation } from 'next-i18next';
import { StatusCodes } from '../../../../../../tools/acceptableStatusCodes';
import { AppType } from '../../../../../../types/app';
import { StatusCodes } from '~/tools/acceptableStatusCodes';
import { AppType } from '~/types/app';
interface NetworkTabProps {
form: UseFormReturnType<AppType, (values: AppType) => AppType>;

View File

@@ -6,11 +6,12 @@ import { motion } from 'framer-motion';
import { useTranslation } from 'next-i18next';
import { ReactNode } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { useConfigContext } from '~/config/provider';
import { useConfigStore } from '~/config/store';
import { openContextModalGeneric } from '~/tools/mantineModalManagerExtensions';
import { 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 { useStyles } from '../Shared/styles';
@@ -66,7 +67,7 @@ export const AvailableElementTypes = ({
closeModal(modalId);
showNotification({
title: t('category.created.title'),
message: t('category.created.message', { name: category.name}),
message: t('category.created.message', { name: category.name }),
color: 'teal',
});
});
@@ -87,39 +88,8 @@ export const AvailableElementTypes = ({
openContextModalGeneric<{ app: AppType; allowAppNamePropagation: boolean }>({
modal: 'editApp',
innerProps: {
app: {
id: uuidv4(),
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: [],
},
},
app: generateDefaultApp(getLowestWrapper()?.id ?? 'default'),
// TODO: Add translation? t('app.defaultName')
allowAppNamePropagation: true,
},
size: 'xl',
@@ -168,4 +138,4 @@ const ElementItem = ({ name, icon, onClick }: ElementItemProps) => {
</Stack>
</UnstyledButton>
);
};
};

View File

@@ -4,9 +4,9 @@ import { Icon, IconChecks } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { v4 as uuidv4 } from 'uuid';
import { useConfigContext } from '../../../../../../config/provider';
import { useConfigStore } from '../../../../../../config/store';
import { IWidget, IWidgetDefinition } from '../../../../../../widgets/widgets';
import { useConfigContext } from '~/config/provider';
import { useConfigStore } from '~/config/store';
import { IWidget, IWidgetDefinition } from '~/widgets/widgets';
import { useEditModeStore } from '../../../../Views/useEditModeStore';
import { GenericAvailableElementType } from '../Shared/GenericElementType';

View File

@@ -1,7 +1,7 @@
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
import { openContextModalGeneric } from '../../../../tools/mantineModalManagerExtensions';
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 { GenericTileMenu } from '../GenericTileMenu';
interface TileMenuProps {

View File

@@ -2,28 +2,32 @@ import { Box, Indicator, Tooltip } from '@mantine/core';
import { IconCheck, IconLoader, IconX } from '@tabler/icons-react';
import Consola from 'consola';
import { TargetAndTransition, Transition, motion } from 'framer-motion';
import { useSession } from 'next-auth/react';
import { useTranslation } from 'next-i18next';
import { RouterOutputs, api } from '~/utils/api';
import { useConfigContext } from '../../../../config/provider';
import { AppType } from '../../../../types/app';
import { useConfigContext } from '~/config/provider';
import { AppType } from '~/types/app';
interface AppPingProps {
app: AppType;
}
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 tooltipLabel = useTooltipLabel({ isFetching, isError, data, errorMessage: error?.message });
const isOnline = isError ? false : data?.state === 'online';
const pulse = usePingPulse({ isOnline });
const pulse = usePingPulse({ isOnline, settings: userWithSettings?.settings });
if (!isActive) return null;
const replaceDotWithIcon =
config?.settings.customization.accessibility?.replacePingDotsWithIcons ?? false;
const replaceDotWithIcon = userWithSettings?.settings.replacePingWithIcons ?? false;
return (
<motion.div
@@ -106,6 +110,7 @@ const usePing = (app: AppType) => {
},
{
retry: false,
enabled: isActive,
refetchOnWindowFocus: false,
retryDelay(failureCount, error) {
// TODO: Add logic to retry on timeout
@@ -115,7 +120,6 @@ const usePing = (app: AppType) => {
cacheTime: 1000 * 60 * 5,
staleTime: 1000 * 60 * 5,
retryOnMount: true,
enabled: isActive,
select: (data) => {
const isOk = isStatusOk(app, data.status);
@@ -143,9 +147,13 @@ type PingPulse = {
transition?: Transition;
};
const usePingPulse = ({ isOnline }: { isOnline: boolean }): PingPulse => {
const { config } = useConfigContext();
const disablePulse = config?.settings.customization.accessibility?.disablePingPulse ?? false;
type UsePingPulseProps = {
isOnline: boolean;
settings?: RouterOutputs['user']['withSettings']['settings'];
};
const usePingPulse = ({ isOnline, settings }: UsePingPulseProps): PingPulse => {
const disablePulse = settings?.disablePingPulse ?? false;
if (disablePulse) {
return {};

View File

@@ -3,7 +3,7 @@ import { createStyles, useMantineTheme } from '@mantine/styles';
import { motion } from 'framer-motion';
import Link from 'next/link';
import { AppType } from '../../../../types/app';
import { AppType } from '~/types/app';
import { useEditModeStore } from '../../Views/useEditModeStore';
import { HomarrCardWrapper } from '../HomarrCardWrapper';
import { BaseTileProps } from '../type';

View File

@@ -2,7 +2,7 @@ import { ActionIcon, Menu } from '@mantine/core';
import { IconLayoutKanban, IconPencil, IconSettings, IconTrash } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { useColorTheme } from '../../../tools/color';
import { useColorTheme } from '~/tools/color';
import { useEditModeStore } from '../Views/useEditModeStore';
interface GenericTileMenuProps {

View File

@@ -1,7 +1,7 @@
import { Card, CardProps } from '@mantine/core';
import { ReactNode } from 'react';
import { useCardStyles } from '../../layout/useCardStyles';
import { useCardStyles } from '../../layout/Common/useCardStyles';
import { useEditModeStore } from '../Views/useEditModeStore';
interface HomarrCardWrapperProps extends CardProps {

View File

@@ -4,7 +4,7 @@ import { IconChevronDown, IconGripVertical } from '@tabler/icons-react';
import { Reorder, useDragControls } from 'framer-motion';
import { FC, useEffect, useRef } from 'react';
import { IDraggableEditableListInputValue } from '../../../../../widgets/widgets';
import { IDraggableEditableListInputValue } from '~/widgets/widgets';
interface DraggableListProps {
items: {

View File

@@ -18,13 +18,13 @@ import {
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconAlertTriangle, IconClick, IconListSearch } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { InfoCard } from '~/components/InfoCard/InfoCard';
import { City } from '~/server/api/routers/weather';
import { api } from '~/utils/api';
import { IntegrationOptionsValueType } from '../WidgetsEditModal';
import { InfoCard } from '~/components/InfoCard/InfoCard';
type LocationSelectionProps = {
widgetId: string;
@@ -65,7 +65,12 @@ export const LocationSelection = ({
<Stack spacing="xs">
<Flex direction="row" justify="space-between" wrap="nowrap">
<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>
<Group noWrap align="end">
@@ -174,16 +179,16 @@ const CitySelectModal = ({ opened, closeModal, query, onCitySelected }: CitySele
onClose={closeModal}
zIndex={250}
>
<Center>
<Stack align="center">
<IconAlertTriangle />
<Title order={6}>Nothing found</Title>
<Text>Nothing was found, please try again</Text>
</Stack>
</Center>
<Center>
<Stack align="center">
<IconAlertTriangle />
<Title order={6}>{t('modal.table.nothingFound.title')}</Title>
<Text>{t('modal.table.nothingFound.description')}</Text>
</Stack>
</Center>
</Modal>
);
const formatter = Intl.NumberFormat('en', { notation: 'compact' });
return (
@@ -228,15 +233,20 @@ const CitySelectModal = ({ opened, closeModal, query, onCitySelected }: CitySele
<Text style={{ whiteSpace: 'nowrap' }}>{city.country}</Text>
</td>
<td>
<Anchor target='_blank' href={`https://www.google.com/maps/place/${city.latitude},${city.longitude}`}>
<Text style={{ whiteSpace: 'nowrap' }}>
{city.latitude}, {city.longitude}
</Text>
<Anchor
target="_blank"
href={`https://www.google.com/maps/place/${city.latitude},${city.longitude}`}
>
<Text style={{ whiteSpace: 'nowrap' }}>
{city.latitude}, {city.longitude}
</Text>
</Anchor>
</td>
<td>
{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>
)}

View File

@@ -4,7 +4,7 @@ import { IconChevronDown, IconGripVertical } from '@tabler/icons-react';
import { Reorder, useDragControls } from 'framer-motion';
import { FC, ReactNode, useEffect, useRef } from 'react';
import { IDraggableListInputValue } from '../../../../../widgets/widgets';
import { IDraggableListInputValue } from '~/widgets/widgets';
const useStyles = createStyles((theme) => ({
container: {

View File

@@ -19,12 +19,12 @@ import { IconAlertTriangle, IconPlaylistX, IconPlus } from '@tabler/icons-react'
import { Trans, useTranslation } from 'next-i18next';
import { FC, useState } from 'react';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
import { mapObject } from '../../../../tools/client/objects';
import { useConfigContext } from '~/config/provider';
import { useConfigStore } from '~/config/store';
import { mapObject } from '~/tools/client/objects';
import Widgets from '../../../../widgets';
import type { IDraggableListInputValue, IWidgetOptionValue } from '../../../../widgets/widgets';
import { IWidget } from '../../../../widgets/widgets';
import type { IDraggableListInputValue, IWidgetOptionValue } from '~/widgets/widgets';
import { IWidget } from '~/widgets/widgets';
import { InfoCard } from '../../../InfoCard/InfoCard';
import { DraggableList } from './Inputs/DraggableList';
import { LocationSelection } from './Inputs/LocationSelection';

View File

@@ -1,9 +1,9 @@
import { Title } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { openContextModalGeneric } from '../../../../tools/mantineModalManagerExtensions';
import { openContextModalGeneric } from '~/tools/mantineModalManagerExtensions';
import WidgetsDefinitions from '../../../../widgets';
import { IWidget } from '../../../../widgets/widgets';
import { IWidget } from '~/widgets/widgets';
import { useWrapperColumnCount } from '../../Wrappers/gridstack/store';
import { GenericTileMenu } from '../GenericTileMenu';
import { WidgetEditModalInnerProps } from './WidgetsEditModal';

View File

@@ -1,10 +1,9 @@
import { Button, Group, Stack, Text } from '@mantine/core';
import { ContextModalProps } from '@mantine/modals';
import { Trans, useTranslation } from 'next-i18next';
import React from 'react';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
import { useConfigContext } from '~/config/provider';
import { useConfigStore } from '~/config/store';
export type WidgetsRemoveModalInnerProps = {
widgetId: string;

View File

@@ -1,11 +1,11 @@
import { Group, Stack } from '@mantine/core';
import { useEffect, useMemo, useRef } from 'react';
import { useConfigContext } from '../../../config/provider';
import { useResize } from '../../../hooks/use-resize';
import { useScreenLargerThan } from '../../../hooks/useScreenLargerThan';
import { CategoryType } from '../../../types/category';
import { WrapperType } from '../../../types/wrapper';
import { useConfigContext } from '~/config/provider';
import { useResize } from '~/hooks/use-resize';
import { useScreenLargerThan } from '~/hooks/useScreenLargerThan';
import { CategoryType } from '~/types/category';
import { WrapperType } from '~/types/wrapper';
import { DashboardCategory } from '../Wrappers/Category/Category';
import { DashboardSidebar } from '../Wrappers/Sidebar/Sidebar';
import { DashboardWrapper } from '../Wrappers/Wrapper/Wrapper';

View File

@@ -2,7 +2,7 @@ import { ActionIcon, Button, Text, Tooltip } from '@mantine/core';
import { IconEdit, IconEditOff } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { useScreenLargerThan } from '../../../hooks/useScreenLargerThan';
import { useScreenLargerThan } from '~/hooks/useScreenLargerThan';
import { useEditModeStore } from './useEditModeStore';
export const ViewToggleButton = () => {

View File

@@ -1,11 +1,14 @@
import { create } from 'zustand';
import { createWithEqualityFn } from 'zustand/traditional';
interface EditModeState {
enabled: boolean;
toggleEditMode: () => void;
}
export const useEditModeStore = create<EditModeState>((set) => ({
enabled: false,
toggleEditMode: () => set((state) => ({ enabled: !state.enabled })),
}));
export const useEditModeStore = createWithEqualityFn<EditModeState>(
(set) => ({
enabled: false,
toggleEditMode: () => set((state) => ({ enabled: !state.enabled })),
}),
Object.is
);

View File

@@ -14,9 +14,9 @@ import { modals } from '@mantine/modals';
import { IconDotsVertical, IconShare3 } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { useConfigContext } from '../../../../config/provider';
import { CategoryType } from '../../../../types/category';
import { useCardStyles } from '../../../layout/useCardStyles';
import { useConfigContext } from '~/config/provider';
import { CategoryType } from '~/types/category';
import { useCardStyles } from '../../../layout/Common/useCardStyles';
import { useEditModeStore } from '../../Views/useEditModeStore';
import { WrapperContent } from '../WrapperContent';
import { useGridstack } from '../gridstack/use-gridstack';

View File

@@ -9,8 +9,8 @@ import {
IconTrash,
} from '@tabler/icons-react';
import { useConfigContext } from '../../../../config/provider';
import { CategoryType } from '../../../../types/category';
import { useConfigContext } from '~/config/provider';
import { CategoryType } from '~/types/category';
import { useCategoryActions } from './useCategoryActions';
import { useTranslation } from 'next-i18next';

View File

@@ -3,9 +3,9 @@ import { useForm } from '@mantine/form';
import { ContextModalProps } from '@mantine/modals';
import { useTranslation } from 'next-i18next';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
import { CategoryType } from '../../../../types/category';
import { useConfigContext } from '~/config/provider';
import { useConfigStore } from '~/config/store';
import { CategoryType } from '~/types/category';
export type CategoryEditModalInnerProps = {
category: CategoryType;

View File

@@ -1,11 +1,11 @@
import { v4 as uuidv4 } from 'uuid';
import { useConfigStore } from '../../../../config/store';
import { openContextModalGeneric } from '../../../../tools/mantineModalManagerExtensions';
import { AppType } from '../../../../types/app';
import { CategoryType } from '../../../../types/category';
import { WrapperType } from '../../../../types/wrapper';
import { IWidget } from '../../../../widgets/widgets';
import { useConfigStore } from '~/config/store';
import { openContextModalGeneric } from '~/tools/mantineModalManagerExtensions';
import { AppType } from '~/types/app';
import { CategoryType } from '~/types/category';
import { WrapperType } from '~/types/wrapper';
import { IWidget } from '~/widgets/widgets';
import { CategoryEditModalInnerProps } from './CategoryEditModal';
export const useCategoryActions = (configName: string | undefined, category: CategoryType) => {

View File

@@ -1,7 +1,7 @@
import { Card } from '@mantine/core';
import { RefObject } from 'react';
import { useCardStyles } from '../../../layout/useCardStyles';
import { useCardStyles } from '../../../layout/Common/useCardStyles';
import { WrapperContent } from '../WrapperContent';
import { useGridstack } from '../gridstack/use-gridstack';

View File

@@ -1,4 +1,4 @@
import { WrapperType } from '../../../../types/wrapper';
import { WrapperType } from '~/types/wrapper';
import { useEditModeStore } from '../../Views/useEditModeStore';
import { WrapperContent } from '../WrapperContent';
import { useGridstack } from '../gridstack/use-gridstack';

View File

@@ -1,10 +1,10 @@
import { GridStack } from 'fily-publish-gridstack';
import { MutableRefObject, RefObject } from 'react';
import { AppType } from '../../../types/app';
import { AppType } from '~/types/app';
import Widgets from '../../../widgets';
import { WidgetWrapper } from '../../../widgets/WidgetWrapper';
import { IWidget, IWidgetDefinition } from '../../../widgets/widgets';
import { WidgetWrapper } from '~/widgets/WidgetWrapper';
import { IWidget, IWidgetDefinition } from '~/widgets/widgets';
import { appTileDefinition } from '../Tiles/Apps/AppTile';
import { GridstackTileWrapper } from '../Tiles/TileWrapper';
import { useGridstackStore } from './gridstack/store';

View File

@@ -1,9 +1,9 @@
import { GridItemHTMLElement, GridStack, GridStackNode } from 'fily-publish-gridstack';
import { MutableRefObject, RefObject } from 'react';
import { AppType } from '../../../../types/app';
import { ShapeType } from '../../../../types/shape';
import { IWidget } from '../../../../widgets/widgets';
import { AppType } from '~/types/app';
import { ShapeType } from '~/types/shape';
import { IWidget } from '~/widgets/widgets';
export const initializeGridstack = (
areaType: 'wrapper' | 'category' | 'sidebar',

View File

@@ -1,14 +1,17 @@
import { create } from 'zustand';
import { createWithEqualityFn } from 'zustand/traditional';
import { useConfigContext } from '../../../../config/provider';
import { GridstackBreakpoints } from '../../../../constants/gridstack-breakpoints';
import { useConfigContext } from '~/config/provider';
import { GridstackBreakpoints } from '~/constants/gridstack-breakpoints';
export const useGridstackStore = create<GridstackStoreType>((set, get) => ({
mainAreaWidth: null,
currentShapeSize: null,
setMainAreaWidth: (w: number) =>
set((v) => ({ ...v, mainAreaWidth: w, currentShapeSize: getCurrentShapeSize(w) })),
}));
export const useGridstackStore = createWithEqualityFn<GridstackStoreType>(
(set, get) => ({
mainAreaWidth: null,
currentShapeSize: null,
setMainAreaWidth: (w: number) =>
set((v) => ({ ...v, mainAreaWidth: w, currentShapeSize: getCurrentShapeSize(w) })),
}),
Object.is
);
interface GridstackStoreType {
mainAreaWidth: null | number;

View File

@@ -1,11 +1,11 @@
import { GridStack, GridStackNode } from 'fily-publish-gridstack';
import { MutableRefObject, RefObject, createRef, useEffect, useMemo, useRef } from 'react';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
import { AppType } from '../../../../types/app';
import { AreaType } from '../../../../types/area';
import { IWidget } from '../../../../widgets/widgets';
import { useConfigContext } from '~/config/provider';
import { useConfigStore } from '~/config/store';
import { AppType } from '~/types/app';
import { AreaType } from '~/types/area';
import { IWidget } from '~/widgets/widgets';
import { useEditModeStore } from '../../Views/useEditModeStore';
import { TileWithUnknownLocation, initializeGridstack } from './init-gridstack';
import { useGridstackStore, useWrapperColumnCount } from './store';

View File

@@ -17,7 +17,7 @@ import { useTranslation } from 'next-i18next';
import { forwardRef, useImperativeHandle, useState } from 'react';
import { api } from '~/utils/api';
import { humanFileSize } from '../../tools/humanFileSize';
import { humanFileSize } from '~/tools/humanFileSize';
import { DebouncedImage } from './DebouncedImage';
export const IconSelector = forwardRef(

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

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

View File

@@ -11,46 +11,29 @@ import {
} from '@tabler/icons-react';
import Dockerode from 'dockerode';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { RouterInputs, api } from '~/utils/api';
import { useConfigContext } from '../../config/provider';
import { openContextModalGeneric } from '../../tools/mantineModalManagerExtensions';
import { AppType } from '../../types/app';
import { openDockerSelectBoardModal } from './docker-select-board.modal';
export interface ContainerActionBarProps {
selected: Dockerode.ContainerInfo[];
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 [isLoading, setLoading] = useState(false);
const { config } = useConfigContext();
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 (
<Group spacing="xs">
<Button
leftIcon={<IconRefresh />}
onClick={() => {
setLoading(true);
setTimeout(() => {
reload();
setLoading(false);
}, 750);
}}
onClick={reload}
variant="light"
color="violet"
loading={isLoading}
@@ -112,53 +95,7 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
variant="light"
radius="md"
disabled={selected.length !== 1}
onClick={() => {
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',
});
}}
onClick={() => openDockerSelectBoardModal({ containers: selected })}
>
{t('actionBar.addToHomarr.title')}
</Button>

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

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

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

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

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

View 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