diff --git a/.dockerignore b/.dockerignore index 13609ca43..5c26793e4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,3 +7,7 @@ npm-debug.log .github LICENSE docs/ +*.sqlite +*.env +.env +.next/standalone/.env \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..444562c2d --- /dev/null +++ b/.env.example @@ -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="" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index c51306dff..3bd205420 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -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 diff --git a/.github/workflows/docker_dev.yml b/.github/workflows/docker_dev.yml index ca8b16563..221fe189e 100644 --- a/.github/workflows/docker_dev.yml +++ b/.github/workflows/docker_dev.yml @@ -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 diff --git a/.gitignore b/.gitignore index a7129a95d..d2f58d56e 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,11 @@ data/configs #Languages other than 'en' public/locales/* -!public/locales/en \ No newline at end of file +!public/locales/en + +#database +prisma/db.sqlite +database/*.sqlite + +# IDE +.idea/* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 68fa85489..fe87a02b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] \ No newline at end of file +CMD ["sh", "./scripts/run.sh"] diff --git a/data/configs/default.json b/data/configs/default.json index 84954d73e..dafaf530b 100644 --- a/data/configs/default.json +++ b/data/configs/default.json @@ -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", diff --git a/data/constants.ts b/data/constants.ts index b10f63d7e..6c43f365d 100644 --- a/data/constants.ts +++ b/data/constants.ts @@ -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'; diff --git a/next-i18next.config.js b/next-i18next.config.js index f6f772e9d..779df3a63 100644 --- a/next-i18next.config.js +++ b/next-i18next.config.js @@ -29,8 +29,8 @@ module.exports = { 'no', 'tr', 'lv', - 'hu', - 'hr' + 'hr', + 'hu' ], localeDetection: true, diff --git a/next.config.js b/next.config.js index cd8c563ea..c87a8ebb2 100644 --- a/next.config.js +++ b/next.config.js @@ -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' + }, }); diff --git a/package.json b/package.json index 9ecce0d64..02ba07e21 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 000000000..b94360604 --- /dev/null +++ b/prisma/schema.prisma @@ -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]) +} diff --git a/public/imgs/app-icons/truenas.svg b/public/imgs/app-icons/truenas.svg new file mode 100644 index 000000000..c3d96ff70 --- /dev/null +++ b/public/imgs/app-icons/truenas.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/imgs/app-icons/unraid-alt.svg b/public/imgs/app-icons/unraid-alt.svg new file mode 100644 index 000000000..7d695dadc --- /dev/null +++ b/public/imgs/app-icons/unraid-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/locales/en/authentication/invite.json b/public/locales/en/authentication/invite.json new file mode 100644 index 000000000..416629e88 --- /dev/null +++ b/public/locales/en/authentication/invite.json @@ -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}}" + } + } +} \ No newline at end of file diff --git a/public/locales/en/authentication/login.json b/public/locales/en/authentication/login.json index 68f32cbe8..33fcdd9d7 100644 --- a/public/locales/en/authentication/login.json +++ b/public/locales/en/authentication/login.json @@ -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." +} \ No newline at end of file diff --git a/public/locales/en/boards/common.json b/public/locales/en/boards/common.json new file mode 100644 index 000000000..18131dfb1 --- /dev/null +++ b/public/locales/en/boards/common.json @@ -0,0 +1,5 @@ +{ + "header": { + "customize": "Customize board" + } +} \ No newline at end of file diff --git a/public/locales/en/boards/customize.json b/public/locales/en/boards/customize.json new file mode 100644 index 000000000..88b2c47fa --- /dev/null +++ b/public/locales/en/boards/customize.json @@ -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" + } + } +} \ No newline at end of file diff --git a/public/locales/en/common.json b/public/locales/en/common.json index df864b716..a28e7cf51 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -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", diff --git a/public/locales/en/layout/header.json b/public/locales/en/layout/header.json new file mode 100644 index 000000000..7ef7f92f8 --- /dev/null +++ b/public/locales/en/layout/header.json @@ -0,0 +1,34 @@ +{ + "experimentalNote": { + "label": "This is an experimental feature of Homarr. Please report any issues on GitHub or Discord." + }, + "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 {{search}}." + } + } +} \ No newline at end of file diff --git a/public/locales/en/layout/manage.json b/public/locales/en/layout/manage.json new file mode 100644 index 000000000..1af1c5bf1 --- /dev/null +++ b/public/locales/en/layout/manage.json @@ -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" + } + } + } +} \ No newline at end of file diff --git a/public/locales/en/manage/boards.json b/public/locales/en/manage/boards.json new file mode 100644 index 000000000..29b5a3012 --- /dev/null +++ b/public/locales/en/manage/boards.json @@ -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" + } + } + } +} \ No newline at end of file diff --git a/public/locales/en/manage/index.json b/public/locales/en/manage/index.json new file mode 100644 index 000000000..8fc17f7b3 --- /dev/null +++ b/public/locales/en/manage/index.json @@ -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" + } + } +} \ No newline at end of file diff --git a/public/locales/en/manage/users.json b/public/locales/en/manage/users.json new file mode 100644 index 000000000..576f072a6 --- /dev/null +++ b/public/locales/en/manage/users.json @@ -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." +} \ No newline at end of file diff --git a/public/locales/en/manage/users/create.json b/public/locales/en/manage/users/create.json new file mode 100644 index 000000000..129acdf20 --- /dev/null +++ b/public/locales/en/manage/users/create.json @@ -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" + } +} \ No newline at end of file diff --git a/public/locales/en/manage/users/invites.json b/public/locales/en/manage/users/invites.json new file mode 100644 index 000000000..de4c70cb0 --- /dev/null +++ b/public/locales/en/manage/users/invites.json @@ -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, you'll not be able to copy this link anymore. 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." +} \ No newline at end of file diff --git a/public/locales/en/modules/calendar.json b/public/locales/en/modules/calendar.json index efc03b598..454ab0390 100644 --- a/public/locales/en/modules/calendar.json +++ b/public/locales/en/modules/calendar.json @@ -7,9 +7,6 @@ "useSonarrv4": { "label": "Use Sonarr v4 API" }, - "sundayStart": { - "label": "Start the week on Sunday" - }, "radarrReleaseType": { "label": "Radarr release type", "data":{ diff --git a/public/locales/en/password-requirements.json b/public/locales/en/password-requirements.json new file mode 100644 index 000000000..605007553 --- /dev/null +++ b/public/locales/en/password-requirements.json @@ -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" +} \ No newline at end of file diff --git a/public/locales/en/settings/customization/access.json b/public/locales/en/settings/customization/access.json new file mode 100644 index 000000000..1d49bfc83 --- /dev/null +++ b/public/locales/en/settings/customization/access.json @@ -0,0 +1,6 @@ +{ + "allowGuests": { + "label": "Allow anonymous", + "description": "Allow users that are not logged in to view your board" + } +} \ No newline at end of file diff --git a/public/locales/en/settings/customization/accessibility.json b/public/locales/en/settings/customization/accessibility.json deleted file mode 100644 index ce1086664..000000000 --- a/public/locales/en/settings/customization/accessibility.json +++ /dev/null @@ -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" -} \ No newline at end of file diff --git a/public/locales/en/settings/customization/app-width.json b/public/locales/en/settings/customization/app-width.json deleted file mode 100644 index e7636eef0..000000000 --- a/public/locales/en/settings/customization/app-width.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "label": "App Width" -} \ No newline at end of file diff --git a/public/locales/en/settings/customization/color-selector.json b/public/locales/en/settings/customization/color-selector.json deleted file mode 100644 index c0555e249..000000000 --- a/public/locales/en/settings/customization/color-selector.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "colors": "Colors", - "suffix": "{{color}} color", - "primary": "Primary", - "secondary": "Secondary" -} \ No newline at end of file diff --git a/public/locales/en/settings/customization/general.json b/public/locales/en/settings/customization/general.json index 358b5158b..adbbfadaf 100644 --- a/public/locales/en/settings/customization/general.json +++ b/public/locales/en/settings/customization/general.json @@ -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" } } } diff --git a/public/locales/en/settings/customization/page-appearance.json b/public/locales/en/settings/customization/page-appearance.json index 6f2f9f204..36d24c33c 100644 --- a/public/locales/en/settings/customization/page-appearance.json +++ b/public/locales/en/settings/customization/page-appearance.json @@ -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" } -} +} \ No newline at end of file diff --git a/public/locales/en/settings/general/color-schema.json b/public/locales/en/settings/general/color-schema.json deleted file mode 100644 index 16672bf7e..000000000 --- a/public/locales/en/settings/general/color-schema.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "label": "Switch to {{scheme}} mode" -} \ No newline at end of file diff --git a/public/locales/en/settings/general/theme-selector.json b/public/locales/en/settings/general/theme-selector.json deleted file mode 100644 index 4e04d5e54..000000000 --- a/public/locales/en/settings/general/theme-selector.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "label": "Switch to {{theme}} mode" -} \ No newline at end of file diff --git a/public/locales/en/tools/docker.json b/public/locales/en/tools/docker.json new file mode 100644 index 000000000..95c67f0d8 --- /dev/null +++ b/public/locales/en/tools/docker.json @@ -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." + } + } + } +} \ No newline at end of file diff --git a/public/locales/en/user/preferences.json b/public/locales/en/user/preferences.json new file mode 100644 index 000000000..d1bbf2171 --- /dev/null +++ b/public/locales/en/user/preferences.json @@ -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" + } + } +} \ No newline at end of file diff --git a/public/locales/en/widgets/location.json b/public/locales/en/widgets/location.json index d2afe05cd..a1bad6221 100644 --- a/public/locales/en/widgets/location.json +++ b/public/locales/en/widgets/location.json @@ -27,6 +27,10 @@ }, "population": { "fallback": "Unknown" + }, + "nothingFound": { + "title": "Nothing found", + "description": "Please try another search term" } } } diff --git a/public/locales/en/zod.json b/public/locales/en/zod.json new file mode 100644 index 000000000..769317a71 --- /dev/null +++ b/public/locales/en/zod.json @@ -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" + } + } +} \ No newline at end of file diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100644 index 000000000..720a92499 --- /dev/null +++ b/scripts/run.sh @@ -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 \ No newline at end of file diff --git a/src/components/Board/Customize/Access/AccessCustomization.tsx b/src/components/Board/Customize/Access/AccessCustomization.tsx new file mode 100644 index 000000000..8ff4f3f3b --- /dev/null +++ b/src/components/Board/Customize/Access/AccessCustomization.tsx @@ -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 ( + + + + ) +} \ No newline at end of file diff --git a/src/components/Board/Customize/Appearance/AppearanceCustomization.tsx b/src/components/Board/Customize/Appearance/AppearanceCustomization.tsx new file mode 100644 index 000000000..248f4e855 --- /dev/null +++ b/src/components/Board/Customize/Appearance/AppearanceCustomization.tsx @@ -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 ( + + + + + + + + + ); +}; + +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 ( + + + {colors.map(({ color, swatch }) => ( + { + form.getInputProps(`appearance.${type}`).onChange(color); + if (type === 'primaryColor') { + setPrimaryColor(color); + } else { + setSecondaryColor(color); + } + }} + color={swatch} + style={{ cursor: 'pointer' }} + > + {color === form.values.appearance[type] && } + + ))} + + + ); +}; + +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 ( + + + {primaryShades.map(({ shade, swatch }) => ( + { + form.getInputProps(`appearance.shade`).onChange(shade); + setPrimaryShade(shade as MantineTheme['primaryShade']); + }} + color={swatch} + style={{ cursor: 'pointer' }} + > + {shade === form.values.appearance.shade && } + + ))} + + + ); +}; + +const OpacitySlider = () => { + const { t } = useTranslation('settings/customization/opacity-selector'); + const form = useBoardCustomizationFormContext(); + + return ( + + + + ); +}; + +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 ( + +
+ 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, + }} + /> +
+
+ ); +}; + +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], + }, + }, +})); diff --git a/src/components/Board/Customize/Gridstack/GridstackCustomization.tsx b/src/components/Board/Customize/Gridstack/GridstackCustomization.tsx new file mode 100644 index 000000000..4497b3b50 --- /dev/null +++ b/src/components/Board/Customize/Gridstack/GridstackCustomization.tsx @@ -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 ( + <> + + + + + + + + + + + ); +}; diff --git a/src/components/Board/Customize/Layout/LayoutCustomization.tsx b/src/components/Board/Customize/Layout/LayoutCustomization.tsx new file mode 100644 index 000000000..248e8a18a --- /dev/null +++ b/src/components/Board/Customize/Layout/LayoutCustomization.tsx @@ -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 ( + + + + + + + + + + + + + + + ); +}; diff --git a/src/components/Board/Customize/Layout/LayoutPreview.tsx b/src/components/Board/Customize/Layout/LayoutPreview.tsx new file mode 100644 index 000000000..ecae8cc6f --- /dev/null +++ b/src/components/Board/Customize/Layout/LayoutPreview.tsx @@ -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 ( + + + +
+ +
+ + + + +
+
+ + + {showLeftSidebar && ( + + + {createDummyArray(5).map((_item, index) => ( + + ))} + + + )} + + + + {createDummyArray(10).map((_item, index) => ( + + ))} + + + + {showRightSidebar && ( + + + {createDummyArray(5).map((_item, index) => ( + + ))} + + + )} + +
+ ); +}; + +const useStyles = createStyles((theme) => ({ + primaryWrapper: { + flexGrow: 2, + }, + secondaryWrapper: { + flexGrow: 1, + maxWidth: 100, + }, +})); + +const BaseElement = ({ height, width }: { height: number; width: number }) => ( + ({ + 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 ( + + + + ); + } + + return ; +}; diff --git a/src/components/Board/Customize/PageMetadata/PageMetadataCustomization.tsx b/src/components/Board/Customize/PageMetadata/PageMetadataCustomization.tsx new file mode 100644 index 000000000..a8af57b18 --- /dev/null +++ b/src/components/Board/Customize/PageMetadata/PageMetadataCustomization.tsx @@ -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 ( + + + + + + + + + + + + + + + ); +}; diff --git a/src/components/Board/Customize/form.ts b/src/components/Board/Customize/form.ts new file mode 100644 index 000000000..141eb1af4 --- /dev/null +++ b/src/components/Board/Customize/form.ts @@ -0,0 +1,9 @@ +import { createFormContext } from '@mantine/form'; +import { z } from 'zod'; +import { boardCustomizationSchema } from '~/validations/boards'; + +export const [ + BoardCustomizationFormProvider, + useBoardCustomizationFormContext, + useBoardCustomizationForm, +] = createFormContext>(); diff --git a/src/components/Config/ConfigChanger.tsx b/src/components/Config/ConfigChanger.tsx deleted file mode 100644 index 8613660ae..000000000 --- a/src/components/Config/ConfigChanger.tsx +++ /dev/null @@ -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: , - 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 ( - -
- -
-
- ); - } - - return ( - <> - ({ + value: board.name, + label: board.name, + })) ?? [] + } + {...form.getInputProps('board')} + /> + + + + + + + + ); +}; + +export const openDockerSelectBoardModal = (innerProps: InnerProps) => { + modals.openContextModal({ + modal: 'dockerSelectBoardModal', + title: ( + + <Trans i18nKey="tools/docker:modals.selectBoard.title" /> + + ), + innerProps, + }); +}; diff --git a/src/components/Manage/User/Create/create-account-step.tsx b/src/components/Manage/User/Create/create-account-step.tsx new file mode 100644 index 000000000..d6fdcdc1c --- /dev/null +++ b/src/components/Manage/User/Create/create-account-step.tsx @@ -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 ( + + } + label={t('steps.account.username.label')} + variant="filled" + mb="md" + withAsterisk + {...form.getInputProps('username')} + /> + } + label={t('steps.account.email.label')} + variant="filled" + mb="md" + {...form.getInputProps('eMail')} + /> + + + + + + ); +}; + +export const createAccountStepValidationSchema = z.object({ + username: z.string().min(1).max(100), + eMail: z.string().email().or(z.literal('')), +}); diff --git a/src/components/Manage/User/Create/review-input-step.tsx b/src/components/Manage/User/Create/review-input-step.tsx new file mode 100644 index 000000000..3395d62b6 --- /dev/null +++ b/src/components/Manage/User/Create/review-input-step.tsx @@ -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 ( + + {t('steps.finish.card.title')} + {t('steps.finish.card.text')} + + + + + + + + + + + + + + + + + + + + + + +
{t('steps.finish.table.header.property')}{t('steps.finish.table.header.value')}
+ + + {t('steps.finish.table.header.username')} + + {values.account.username}
+ + + {t('steps.finish.table.header.email')} + + + {values.account.eMail ? ( + {values.account.eMail} + ) : ( + + + {t('steps.finish.table.notSet')} + + )} +
+ + + {t('steps.finish.table.header.password')} + + + + + {t('steps.finish.table.valid')} + +
+ + {isError && ( + } mb="lg"> + {t('steps.finish.failed', { error: error.message })} + + )} + + + + + +
+ ); +}; diff --git a/src/components/Manage/User/Create/security-step.tsx b/src/components/Manage/User/Create/security-step.tsx new file mode 100644 index 000000000..5e76e5ac8 --- /dev/null +++ b/src/components/Manage/User/Create/security-step.tsx @@ -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 ( + + + +
setPopoverOpened(true)} + onBlurCapture={() => setPopoverOpened(false)} + > + + } + style={{ + flexGrow: 1, + }} + label={t('steps.security.password.label')} + variant="filled" + mb="md" + withAsterisk + {...form.getInputProps('password')} + /> + + +
+
+ + + +
+ + + + + +
+ ); +}; + +export const createAccountSecurityStepValidationSchema = z.object({ + password: passwordSchema, +}); diff --git a/src/components/Manage/User/Invite/copy-invite.modal.tsx b/src/components/Manage/User/Invite/copy-invite.modal.tsx new file mode 100644 index 000000000..44551e6b1 --- /dev/null +++ b/src/components/Manage/User/Invite/copy-invite.modal.tsx @@ -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) => { + const { t } = useTranslation('manage/users/invites'); + const inviteUrl = useInviteUrl(innerProps.id, innerProps.token); + + return ( + + + , + }} + /> + + + + {t('modals.copy.invitationLink')} + + + + {t('modals.copy.details.id')}: + + {innerProps.id} + + + {t('modals.copy.details.token')}: + + {innerProps.token} + + + + + {({ copy }) => ( + + )} + + + ); +}; + +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: ( + + <Trans i18nKey="manage/users/invites:modals.copy.title" /> + + ), + innerProps: data, + }); +}; diff --git a/src/components/Manage/User/Invite/create-invite.modal.tsx b/src/components/Manage/User/Invite/create-invite.modal.tsx new file mode 100644 index 000000000..972a210a2 --- /dev/null +++ b/src/components/Manage/User/Invite/create-invite.modal.tsx @@ -0,0 +1,89 @@ +import { Button, Group, Stack, Text, Title } from '@mantine/core'; +import { DateTimePicker } from '@mantine/dates'; +import { useForm } from '@mantine/form'; +import { ContextModalProps, modals } from '@mantine/modals'; +import dayjs from 'dayjs'; +import { Trans, useTranslation } from 'next-i18next'; +import { api } from '~/utils/api'; +import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; +import { createInviteSchema } from '~/validations/invite'; + +import { openCopyInviteModal } from './copy-invite.modal'; + +export const CreateInviteModal = ({ id }: ContextModalProps<{}>) => { + const { t } = useTranslation('manage/users/invites'); + const utils = api.useContext(); + const { isLoading, mutateAsync } = api.invites.create.useMutation({ + onSuccess: async (data) => { + await utils.invites.all.invalidate(); + modals.close(id); + + openCopyInviteModal(data); + }, + }); + + const { i18nZodResolver } = useI18nZodResolver(); + + const minDate = dayjs().add(5, 'minutes').toDate(); + const maxDate = dayjs().add(6, 'months').toDate(); + + const form = useForm({ + initialValues: { + expirationDate: dayjs().add(7, 'days').toDate(), + }, + validate: i18nZodResolver(createInviteSchema), + }); + + return ( + + {t('modals.create.description')} + + + + + + + + + ); +}; + +export const openCreateInviteModal = () => { + modals.openContextModal({ + modal: 'createInviteModal', + title: ( + + <Trans i18nKey="manage/users/invites:modals.create.title" /> + + ), + innerProps: {}, + }); +}; diff --git a/src/components/Manage/User/Invite/delete-invite.modal.tsx b/src/components/Manage/User/Invite/delete-invite.modal.tsx new file mode 100644 index 000000000..9b4cf3826 --- /dev/null +++ b/src/components/Manage/User/Invite/delete-invite.modal.tsx @@ -0,0 +1,44 @@ +import { Button, Group, Stack, Text } from '@mantine/core'; +import { ContextModalProps, modals } from '@mantine/modals'; +import { useTranslation } from 'next-i18next'; +import { api } from '~/utils/api'; + +export const DeleteInviteModal = ({ id, innerProps }: ContextModalProps<{ tokenId: string }>) => { + const { t } = useTranslation('manage/users/invites'); + const utils = api.useContext(); + const { isLoading, mutateAsync: deleteAsync } = api.invites.delete.useMutation({ + onSuccess: async () => { + await utils.invites.all.invalidate(); + modals.close(id); + }, + }); + return ( + + {t('modals.delete.description')} + + + + + + + ); +}; diff --git a/src/components/Manage/User/change-user-role.modal.tsx b/src/components/Manage/User/change-user-role.modal.tsx new file mode 100644 index 000000000..45c09a7aa --- /dev/null +++ b/src/components/Manage/User/change-user-role.modal.tsx @@ -0,0 +1,59 @@ +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 = { id: string; name: string; type: 'promote' | 'demote' }; + +export const ChangeUserRoleModal = ({ id, innerProps }: ContextModalProps) => { + const { t } = useTranslation('manage/users'); + const utils = api.useContext(); + const { isLoading, mutateAsync } = api.user.changeRole.useMutation({ + onSuccess: async () => { + await utils.user.all.invalidate(); + modals.close(id); + }, + }); + return ( + + {t(`modals.change-role.${innerProps.type}.text`, innerProps)} + + + + + + + ); +}; + +export const openRoleChangeModal = (user: InnerProps) => { + modals.openContextModal({ + modal: 'changeUserRoleModal', + title: ( + + <Trans + i18nKey={`manage/users:modals.change-role.${user.type}.title`} + values={{ name: user.name }} + /> + + ), + innerProps: user, + }); +}; diff --git a/src/components/Manage/User/delete-user.modal.tsx b/src/components/Manage/User/delete-user.modal.tsx new file mode 100644 index 000000000..adbeae1ce --- /dev/null +++ b/src/components/Manage/User/delete-user.modal.tsx @@ -0,0 +1,56 @@ +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 = { id: string; name: string }; + +export const DeleteUserModal = ({ id, innerProps }: ContextModalProps) => { + const { t } = useTranslation('manage/users'); + const utils = api.useContext(); + const { isLoading, mutateAsync } = api.user.deleteUser.useMutation({ + onSuccess: async () => { + await utils.user.all.invalidate(); + modals.close(id); + }, + }); + return ( + + {t('modals.delete.text', innerProps)} + + + + + + + ); +}; + +export const openDeleteUserModal = (user: InnerProps) => { + modals.openContextModal({ + modal: 'deleteUserModal', + title: ( + + <Trans i18nKey="manage/users:modals.delete.title" values={{ name: user.name }} /> + + ), + innerProps: user, + }); +}; diff --git a/src/components/Onboarding/common-wrapper.tsx b/src/components/Onboarding/common-wrapper.tsx new file mode 100644 index 000000000..09df0005f --- /dev/null +++ b/src/components/Onboarding/common-wrapper.tsx @@ -0,0 +1,10 @@ +import { Card } from '@mantine/core'; +import { ReactNode } from 'react'; + +export const OnboardingStepWrapper = ({ children }: { children: ReactNode }) => { + return ( + + {children} + + ); +}; diff --git a/src/components/Onboarding/onboarding-steps.tsx b/src/components/Onboarding/onboarding-steps.tsx new file mode 100644 index 000000000..f9173d201 --- /dev/null +++ b/src/components/Onboarding/onboarding-steps.tsx @@ -0,0 +1,38 @@ +import { Stack, Stepper } from '@mantine/core'; +import { useState } from 'react'; + +import { StepCreateAccount } from './step-create-account'; +import { StepOnboardingFinished } from './step-onboarding-finished'; +import { StepUpdatePathMappings } from './step-update-path-mappings'; + +export const OnboardingSteps = ({ isUpdate }: { isUpdate: boolean }) => { + const [currentStep, setCurrentStep] = useState(0); + const nextStep = () => setCurrentStep((current) => (current < 3 ? current + 1 : current)); + const prevStep = () => setCurrentStep((current) => (current > 0 ? current - 1 : current)); + + return ( + + + {isUpdate && ( + + + + )} + + + + + + + + + ); +}; diff --git a/src/components/Onboarding/step-create-account.tsx b/src/components/Onboarding/step-create-account.tsx new file mode 100644 index 000000000..b7383c4fd --- /dev/null +++ b/src/components/Onboarding/step-create-account.tsx @@ -0,0 +1,112 @@ +import { Button, Card, Group, PasswordInput, Stack, Text, TextInput, Title } from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { IconArrowLeft, IconArrowRight } from '@tabler/icons-react'; +import { signIn } from 'next-auth/react'; +import { useState } from 'react'; +import { z } from 'zod'; +import { api } from '~/utils/api'; +import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; +import { signUpFormSchema } from '~/validations/user'; + +import { PasswordRequirements } from '../Password/password-requirements'; +import { OnboardingStepWrapper } from './common-wrapper'; + +export const StepCreateAccount = ({ + previous, + next, +}: { + previous: () => void; + next: () => void; +}) => { + const [isSigninIn, setIsSigninIn] = useState(false); + const { mutateAsync } = api.user.createOwnerAccount.useMutation(); + const { i18nZodResolver } = useI18nZodResolver(); + + const form = useForm>({ + initialValues: { + password: '', + username: '', + passwordConfirmation: '', + }, + validate: i18nZodResolver(signUpFormSchema), + validateInputOnBlur: true, + }); + const handleSubmit = (values: z.infer) => { + setIsSigninIn(true); + void mutateAsync(values, { + onSuccess: () => { + signIn('credentials', { + redirect: false, + name: values.username, + password: values.password, + callbackUrl: '/', + }).then((response) => { + if (!response?.ok) { + setIsSigninIn(false); + return; + } + next(); + }); + }, + }); + }; + + return ( + + + Create your administrator account + + + Your administrator account must be secure, that's why we have so many rules surrounding it. +
Try not to make it adminadmin this time... +
+
+ + + + + + + + + + + + + + + +
+
+ ); +}; diff --git a/src/components/Onboarding/step-onboarding-finished.tsx b/src/components/Onboarding/step-onboarding-finished.tsx new file mode 100644 index 000000000..c1dc40e12 --- /dev/null +++ b/src/components/Onboarding/step-onboarding-finished.tsx @@ -0,0 +1,72 @@ +import { Divider, NavLink, Stack, Text, Title, createStyles } from '@mantine/core'; +import { + IconChevronRight, + IconDashboard, + IconExternalLink, + IconFileText, + IconManualGearbox, +} from '@tabler/icons-react'; +import Image from 'next/image'; +import Link from 'next/link'; + +import { OnboardingStepWrapper } from './common-wrapper'; + +export const StepOnboardingFinished = () => { + const { classes } = useStyles(); + return ( + + + + + Congratulations, you've set Homarr up! + + Awesome! What do you want to do next? + + + + We highly recommend you to take a look at the documentation before starting to + use Homarr if you've never used it before. + + } + className={classes.link} + icon={} + label="Check out the documentation" + variant="light" + active + /> + + } + className={classes.link} + icon={} + label="Go to your board" + variant="light" + active + /> + } + className={classes.link} + icon={} + label="Go to the management dashboard" + variant="light" + active + /> + + + + ); +}; + +const useStyles = createStyles((theme) => ({ + link: { + borderRadius: '0.4rem', + }, +})); diff --git a/src/components/Onboarding/step-update-path-mappings.tsx b/src/components/Onboarding/step-update-path-mappings.tsx new file mode 100644 index 000000000..0b96eafa9 --- /dev/null +++ b/src/components/Onboarding/step-update-path-mappings.tsx @@ -0,0 +1,207 @@ +import { Box, Button, Code, Group, List, Space, Tabs, TabsValue, Text, Title } from '@mantine/core'; +import { Prism } from '@mantine/prism'; +import { + IconArrowRight, + IconBrandDebian, + IconBrandDocker, + IconInfoSquareRounded, +} from '@tabler/icons-react'; +import Image from 'next/image'; +import { useState } from 'react'; + +import { OnboardingStepWrapper } from './common-wrapper'; + +const dockerRunCommand = `docker run \\ +--name homarr \\ +--restart unless-stopped \\ +-p 7575:7575 \\ +-v your-path/homarr/configs:/app/data/configs \\ +-v your-path/homarr/data:/app/database \\ +-v your-path/homarr/icons:/app/public/icons \\ +-d ghcr.io/ajnart/homarr:latest`; + +const dockerComposeCommand = `version: '3' +#---------------------------------------------------------------------# +# Homarr - A simple, yet powerful dashboard for your server. # +#---------------------------------------------------------------------# +services: + homarr: + container_name: homarr + image: ghcr.io/ajnart/homarr:latest + restart: unless-stopped + volumes: + - ./homarr/configs:/app/data/configs + - ./homarr/data:/app/database + - ./homarr/icons:/app/public/icons + ports: + - '7575:7575'`; + +const added = { color: 'green', label: '+' }; + +export const StepUpdatePathMappings = ({ next }: { next: () => void }) => { + const [selectedTab, setSelectedTab] = useState("standard_docker"); + return ( + + + Update path mappings + + + Homarr has updated the location of the saved data. We detected, that your instance might + need an update to function as expected. It is recommended, that you take a backup of your + .json configuration file on the file system and copy it, in case something goes wrong. + + + + setSelectedTab(tab)} mt="xs"> + + }> + Docker + + }> + Docker Compose + + }> + Standalone Linux / Windows + + } + > + Unraid + + }> + Others + + + + + + + + Back up your configuration. In case you didn't mount your configuration + correctly, you could risk loosing your dashboard. To back up, + go on your file system and copy the directory, containing your + default.json to your local machine. + + + + + Before you continue, check that you still have the command, that you set up Homarr + with. Otherwise, your configuration might not be loaded correctly or icons are + missing. + + + + + Run docker rm homarr, where homarr indicates the name of + your container + + + + + Run docker run ... again, that you used to create the Homarr container. + Note, that you need to add a new line: + + + {dockerRunCommand} + + + Refresh this page and click on "continue" + + + + + + + + Back up your configuration. In case you didn't mount your configuration + correctly, you could risk loosing your dashboard. To back up, + go on your file system and copy the directory, containing your + default.json to your local machine. + + + + + Navigate to the directory, where the docker-compose.yml for Homarr is + located. + + + + + Run docker compose down + + + + + Edit docker-compose.yml using text editor. Use Notepad or VSC on GUI + based systems. Use nano or vim on terminal systems. + + + {dockerComposeCommand} + + + Run docker compose up. + Refresh this page and click on "continue" + + + + + + You're lucky. For installation without Docker on Windows and Linux, there are no + additional steps required. However, be advised that your backups should start to include + the files located at /database too, if you run automatic backups. + + + + + + Click on your Homarr application and click "Edit" + + Scroll down and click on the link "Add another path, port, variable or device" + + + After the new modal has opened, make sure that "Path" has been selected at the top + + + In the container path, enter /app/database + + + In the host path, enter a new path on your host system. Choose a similar path, but the + innermost directory should be different, than your existing mounting points (eg.{' '} + /mnt/user/appdata/homarr/data) + + Click "Apply" and wait for the container to be restarted. + Refresh this page and click on "continue" + + + + + + We are sadly not able to include upgrade guides for all kind of systems. If your system + was not listed, you should mount this new mounting point in your container: + + /app/database + + + + {selectedTab ? ( + + + + ) : ( + + + Please select your installation method + + + )} + + ); +}; diff --git a/src/components/Password/password-requirement.tsx b/src/components/Password/password-requirement.tsx new file mode 100644 index 000000000..720fdad55 --- /dev/null +++ b/src/components/Password/password-requirement.tsx @@ -0,0 +1,24 @@ +import { Box, Text } from "@mantine/core"; +import { IconCheck, IconX } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { minPasswordLength } from "~/validations/user"; + +export const PasswordRequirement = ({ meets, label }: { meets: boolean; label: string }) => { + const { t } = useTranslation('password-requirements'); + + return ( + + {meets ? : }{' '} + + {t(`${label}`, { + count: minPasswordLength, + })} + + + ); + }; \ No newline at end of file diff --git a/src/components/Password/password-requirements.tsx b/src/components/Password/password-requirements.tsx new file mode 100644 index 000000000..fcaa8c569 --- /dev/null +++ b/src/components/Password/password-requirements.tsx @@ -0,0 +1,43 @@ +import { Progress } from '@mantine/core'; +import { minPasswordLength } from '~/validations/user'; + +import { PasswordRequirement } from './password-requirement'; + +const requirements = [ + { re: /[0-9]/, label: 'number' }, + { re: /[a-z]/, label: 'lowercase' }, + { re: /[A-Z]/, label: 'uppercase' }, + { re: /[$&+,:;=?@#|'<>.^*()%!-]/, label: 'special' }, +]; + +function getStrength(password: string) { + let score = 0; + const goal = requirements.length + 1; + + requirements.forEach((requirement) => { + if (requirement.re.test(password)) { + score += 1; + } + }); + if (password.length >= minPasswordLength) { + score += 1; + } + return (score / goal) * 100; + +} + +export const PasswordRequirements = ({ value }: { value: string }) => { + const checks = requirements.map((requirement, index) => ( + + )); + + const strength = getStrength(value); + const color = strength === 100 ? 'teal' : strength > 50 ? 'yellow' : 'red'; + return ( + <> + + = minPasswordLength} /> + {checks} + + ); +}; diff --git a/src/components/Settings/Common/CacheButtons.tsx b/src/components/Settings/Common/CacheButtons.tsx deleted file mode 100644 index 409ededfc..000000000 --- a/src/components/Settings/Common/CacheButtons.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { Button, Group, MultiSelect, Stack, Title } from '@mantine/core'; -import { notifications } from '@mantine/notifications'; -import { IconTrash } from '@tabler/icons-react'; -import { useState } from 'react'; - -import { queryClient } from '../../../tools/server/configurations/tanstack/queryClient.tool'; -import { useTranslation } from 'react-i18next'; - -export function CacheButtons() { - const [value, setValue] = useState([]); - - const { t } = useTranslation('settings/general/cache-buttons') - - const data = [ - { value: 'ping', label: t('selector.data.ping') }, - { value: 'repository-icons', label: t('selector.data.repositoryIcons') }, - { value: 'calendar/medias', label: t('selector.data.calendar&medias') }, - { value: 'weather', label: t('selector.data.weather') }, - ]; - - return ( - - {t('title')} - - - - - - - ); -} diff --git a/src/components/Settings/Common/CommonSettings.tsx b/src/components/Settings/Common/CommonSettings.tsx deleted file mode 100644 index 26ccaec6e..000000000 --- a/src/components/Settings/Common/CommonSettings.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { ScrollArea, Space, Stack, Text } from '@mantine/core'; -import { useViewportSize } from '@mantine/hooks'; - -import { useConfigContext } from '../../../config/provider'; -import ConfigChanger from '../../Config/ConfigChanger'; -import { CacheButtons } from './CacheButtons'; -import ConfigActions from './Config/ConfigActions'; -import LanguageSelect from './Language/LanguageSelect'; -import { SearchEngineSelector } from './SearchEngine/SearchEngineSelector'; - -export default function CommonSettings() { - const { config } = useConfigContext(); - const { height, width } = useViewportSize(); - - if (!config) { - return ( - - No active config - - ); - } - return ( - - - - - - - - - - - ); -} diff --git a/src/components/Settings/Common/Config/ConfigActions.tsx b/src/components/Settings/Common/Config/ConfigActions.tsx deleted file mode 100644 index e3bb2941d..000000000 --- a/src/components/Settings/Common/Config/ConfigActions.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { - ActionIcon, - Alert, - Center, - Flex, - Text, - createStyles, - useMantineTheme, -} from '@mantine/core'; -import { useDisclosure } from '@mantine/hooks'; -import { openConfirmModal } from '@mantine/modals'; -import { showNotification } from '@mantine/notifications'; -import { - IconAlertTriangle, - IconCheck, - IconCopy, - IconDownload, - IconTrash, - IconX, -} from '@tabler/icons-react'; -import fileDownload from 'js-file-download'; -import { Trans, useTranslation } from 'next-i18next'; -import { useRouter } from 'next/router'; -import { api } from '~/utils/api'; - -import { useConfigContext } from '../../../../config/provider'; -import { useConfigStore } from '../../../../config/store'; -import Tip from '../../../layout/Tip'; -import { CreateConfigCopyModal } from './CreateCopyModal'; - -export default function ConfigActions() { - const { t } = useTranslation(['settings/general/config-changer', 'settings/common', 'common']); - const [createCopyModalOpened, createCopyModal] = useDisclosure(false); - const { config } = useConfigContext(); - const { mutateAsync } = useDeleteConfigMutation(); - - if (!config) return null; - - const handleDownload = () => { - fileDownload(JSON.stringify(config, null, '\t'), `${config?.configProperties.name}.json`); - }; - - const handleDeletion = async () => { - openConfirmModal({ - title: t('modal.confirmDeletion.title'), - children: ( - <> - } mb="md"> - , code: }} - /> - - {t('modal.confirmDeletion.text')} - - ), - labels: { - confirm: ( - , code: }} - /> - ), - cancel: t('common:cancel'), - }, - zIndex: 201, - onConfirm: async () => { - const response = await mutateAsync({ - name: config?.configProperties.name ?? 'default', - }); - }, - }); - }; - - const { classes } = useStyles(); - const { colors } = useMantineTheme(); - - return ( - <> - - - - - {t('buttons.download')} - - - - {t('buttons.delete.text')} - - - - {t('buttons.saveCopy')} - - - -
- {t('settings/common:tips.configTip')} -
- - ); -} - -const useDeleteConfigMutation = () => { - const { t } = useTranslation(['settings/general/config-changer']); - const router = useRouter(); - const { removeConfig } = useConfigStore(); - - return api.config.delete.useMutation({ - onError(error) { - if (error.data?.code === 'FORBIDDEN') { - showNotification({ - title: t('buttons.delete.notifications.deleteFailedDefaultConfig.title'), - icon: , - color: 'red', - autoClose: 1500, - radius: 'md', - message: t('buttons.delete.notifications.deleteFailedDefaultConfig.message'), - }); - } - showNotification({ - title: t('buttons.delete.notifications.deleteFailed.title'), - icon: , - color: 'red', - autoClose: 1500, - radius: 'md', - message: t('buttons.delete.notifications.deleteFailed.message'), - }); - }, - onSuccess(data, variables) { - showNotification({ - title: t('buttons.delete.notifications.deleted.title'), - icon: , - color: 'green', - autoClose: 1500, - radius: 'md', - message: t('buttons.delete.notifications.deleted.message'), - }); - - removeConfig(variables.name); - - router.push('/'); - }, - }); -}; - -const useStyles = createStyles(() => ({ - actionIcon: { - width: 'auto', - height: 'auto', - maxWidth: 'auto', - maxHeight: 'auto', - flexGrow: 1, - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - textAlign: 'center', - rowGap: 10, - padding: 10, - }, -})); diff --git a/src/components/Settings/Common/Config/CreateCopyModal.tsx b/src/components/Settings/Common/Config/CreateCopyModal.tsx deleted file mode 100644 index 1acdd89cc..000000000 --- a/src/components/Settings/Common/Config/CreateCopyModal.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { Button, Group, Modal, TextInput, Title } from '@mantine/core'; -import { useForm } from '@mantine/form'; -import { showNotification } from '@mantine/notifications'; -import { IconCheck, IconX } from '@tabler/icons-react'; -import { useTranslation } from 'next-i18next'; -import { useConfigContext } from '~/config/provider'; -import { api } from '~/utils/api'; - -import { useConfigStore } from '../../../../config/store'; - -interface CreateConfigCopyModalProps { - opened: boolean; - closeModal: () => void; - initialConfigName: string; -} - -export const CreateConfigCopyModal = ({ - opened, - closeModal, - initialConfigName, -}: CreateConfigCopyModalProps) => { - const { configs } = useConfigStore(); - const { config } = useConfigContext(); - const { t } = useTranslation(['settings/general/config-changer']); - - const form = useForm({ - initialValues: { - configName: initialConfigName, - }, - validate: { - configName: (value) => { - if (!value) { - return t('modal.copy.form.configName.validation.required'); - } - - const configNames = configs.map((x) => x.value.configProperties.name); - if (configNames.includes(value)) { - return t('modal.copy.form.configName.validation.notUnique'); - } - - return undefined; - }, - }, - validateInputOnChange: true, - validateInputOnBlur: true, - }); - - const { mutateAsync } = useCopyConfigMutation(); - - const handleClose = () => { - form.setFieldValue('configName', initialConfigName); - closeModal(); - }; - - const handleSubmit = async (values: typeof form.values) => { - if (!form.isValid) return; - - if (!config) { - throw new Error('config is not defiend'); - } - - const copiedConfig = config; - copiedConfig.configProperties.name = form.values.configName; - - await mutateAsync({ - name: form.values.configName, - config: copiedConfig, - }); - closeModal(); - }; - - return ( - {t('modal.copy.title')}} - > -
- - - - - -
- ); -}; - -const useCopyConfigMutation = () => { - const { t } = useTranslation(['settings/general/config-changer']); - const utils = api.useContext(); - - return api.config.save.useMutation({ - onSuccess(_data, variables) { - showNotification({ - title: t('modal.copy.events.configCopied.title'), - icon: , - color: 'green', - autoClose: 1500, - radius: 'md', - message: t('modal.copy.events.configCopied.message', { configName: variables.name }), - }); - // Invalidate a query to fetch new config - utils.config.all.invalidate(); - }, - onError(_error, variables) { - showNotification({ - title: t('modal.events.configNotCopied.title'), - icon: , - color: 'red', - autoClose: 1500, - radius: 'md', - message: t('modal.events.configNotCopied.message', { configName: variables.name }), - }); - }, - }); -}; diff --git a/src/components/Settings/Common/SearchEngine/SearchEngineSelector.tsx b/src/components/Settings/Common/SearchEngine/SearchEngineSelector.tsx deleted file mode 100644 index 54270f0b2..000000000 --- a/src/components/Settings/Common/SearchEngine/SearchEngineSelector.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { Alert, Paper, SegmentedControl, Space, Stack, TextInput, Title } from '@mantine/core'; -import { IconInfoCircle } from '@tabler/icons-react'; -import { useTranslation } from 'next-i18next'; -import { ChangeEventHandler, useState } from 'react'; - -import { useConfigContext } from '../../../../config/provider'; -import { useConfigStore } from '../../../../config/store'; -import { - CommonSearchEngineCommonSettingsType, - SearchEngineCommonSettingsType, -} from '../../../../types/settings'; -import { SearchNewTabSwitch } from './SearchNewTabSwitch'; - -interface Props { - searchEngine: SearchEngineCommonSettingsType; -} - -export const SearchEngineSelector = ({ searchEngine }: Props) => { - const { t } = useTranslation(['settings/general/search-engine']); - const { updateSearchEngineConfig } = useUpdateSearchEngineConfig(); - - const [engine, setEngine] = useState(searchEngine.type); - const [searchUrl, setSearchUrl] = useState( - searchEngine.type === 'custom' ? searchEngine.properties.template : searchUrls.google - ); - - const onEngineChange = (value: EngineType) => { - setEngine(value); - updateSearchEngineConfig(value, searchUrl); - }; - - const onSearchUrlChange: ChangeEventHandler = (ev) => { - const url = ev.currentTarget.value; - setSearchUrl(url); - updateSearchEngineConfig(engine, url); - }; - - const searchEngineOptions: { label: string; value: EngineType }[] = [ - { label: 'Google', value: 'google' }, - { label: 'DuckDuckGo', value: 'duckDuckGo' }, - { label: 'Bing', value: 'bing' }, - { label: t('custom'), value: 'custom' }, - ]; - - return ( - - - {t('title')} - - - - - - {t('configurationName')} - - - - - {engine === 'custom' && ( - <> - - - - )} - - } color="blue"> - {t('tips.generalTip')} - - - ); -}; - -export const searchUrls: { [key in CommonSearchEngineCommonSettingsType['type']]: string } = { - google: 'https://google.com/search?q=', - duckDuckGo: 'https://duckduckgo.com/?q=', - bing: 'https://bing.com/search?q=', -}; - -type EngineType = SearchEngineCommonSettingsType['type']; - -const useUpdateSearchEngineConfig = () => { - const { name: configName } = useConfigContext(); - const updateConfig = useConfigStore((x) => x.updateConfig); - - if (!configName) { - return { - updateSearchEngineConfig: () => {}, - }; - } - - const updateSearchEngineConfig = (engine: EngineType, searchUrl: string) => { - updateConfig(configName, (prev) => ({ - ...prev, - settings: { - ...prev.settings, - common: { - ...prev.settings.common, - searchEngine: - engine === 'custom' - ? { - type: engine, - properties: { - ...prev.settings.common.searchEngine.properties, - template: searchUrl, - }, - } - : { - type: engine, - properties: { - openInNewTab: prev.settings.common.searchEngine.properties.openInNewTab, - enabled: prev.settings.common.searchEngine.properties.enabled, - }, - }, - }, - }, - })); - }; - - return { - updateSearchEngineConfig, - }; -}; diff --git a/src/components/Settings/Common/SearchEngine/SearchNewTabSwitch.tsx b/src/components/Settings/Common/SearchEngine/SearchNewTabSwitch.tsx deleted file mode 100644 index 301bad508..000000000 --- a/src/components/Settings/Common/SearchEngine/SearchNewTabSwitch.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Switch } from '@mantine/core'; -import { useTranslation } from 'next-i18next'; -import { useState } from 'react'; - -import { useConfigContext } from '../../../../config/provider'; -import { useConfigStore } from '../../../../config/store'; -import { SearchEngineCommonSettingsType } from '../../../../types/settings'; - -interface SearchNewTabSwitchProps { - defaultValue: boolean | undefined; -} - -export function SearchNewTabSwitch({ defaultValue }: SearchNewTabSwitchProps) { - const { t } = useTranslation('settings/general/search-engine'); - const { name: configName } = useConfigContext(); - const updateConfig = useConfigStore((x) => x.updateConfig); - - const [openInNewTab, setOpenInNewTab] = useState(defaultValue ?? true); - - if (!configName) return null; - - const toggleOpenInNewTab = () => { - setOpenInNewTab(!openInNewTab); - updateConfig(configName, (prev) => ({ - ...prev, - settings: { - ...prev.settings, - common: { - ...prev.settings.common, - searchEngine: { - ...prev.settings.common.searchEngine, - properties: { - ...prev.settings.common.searchEngine.properties, - openInNewTab: !openInNewTab, - }, - } as SearchEngineCommonSettingsType, - }, - }, - })); - }; - - return ( - - ); -} diff --git a/src/components/Settings/Customization/Accessibility/AccessibilitySettings.tsx b/src/components/Settings/Customization/Accessibility/AccessibilitySettings.tsx deleted file mode 100644 index fc4e92eb5..000000000 --- a/src/components/Settings/Customization/Accessibility/AccessibilitySettings.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { Alert, Stack, Switch } from '@mantine/core'; -import { IconInfoCircle } from '@tabler/icons-react'; -import { BaseSyntheticEvent } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { useConfigContext } from '../../../../config/provider'; -import { useConfigStore } from '../../../../config/store'; - -export const AccessibilitySettings = () => { - const { t } = useTranslation('settings/customization/accessibility'); - const { updateConfig } = useConfigStore(); - const { config, name: configName } = useConfigContext(); - - return ( - - { - if (!configName) { - return; - } - - updateConfig( - configName, - (previousConfig) => ({ - ...previousConfig, - settings: { - ...previousConfig.settings, - customization: { - ...previousConfig.settings.customization, - accessibility: { - ...previousConfig.settings.customization.accessibility, - disablePingPulse: value.target.checked, - }, - }, - }, - }), - false, - true - ); - }} - /> - - { - if (!configName) { - return; - } - - updateConfig( - configName, - (previousConfig) => ({ - ...previousConfig, - settings: { - ...previousConfig.settings, - customization: { - ...previousConfig.settings.customization, - accessibility: { - ...previousConfig.settings.customization.accessibility, - replacePingDotsWithIcons: value.target.checked, - }, - }, - }, - }), - false, - true - ); - }} - /> - - } color="blue"> - {t('alert')} - - - ); -}; diff --git a/src/components/Settings/Customization/CustomizationAccordeon.tsx b/src/components/Settings/Customization/CustomizationAccordeon.tsx deleted file mode 100644 index 939b8c2ac..000000000 --- a/src/components/Settings/Customization/CustomizationAccordeon.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { Accordion, Checkbox, Grid, Group, Stack, Text } from '@mantine/core'; -import { - IconAccessible, - IconBrush, - IconChartCandle, - IconCode, - IconDragDrop, - IconLayout, -} from '@tabler/icons-react'; -import { i18n, useTranslation } from 'next-i18next'; -import { ReactNode } from 'react'; - -import { AccessibilitySettings } from './Accessibility/AccessibilitySettings'; -import { GridstackConfiguration } from './Layout/GridstackConfiguration'; -import { LayoutSelector } from './Layout/LayoutSelector'; -import { BackgroundChanger } from './Meta/BackgroundChanger'; -import { FaviconChanger } from './Meta/FaviconChanger'; -import { LogoImageChanger } from './Meta/LogoImageChanger'; -import { BrowserTabTitle } from './Meta/MetaTitleChanger'; -import { DashboardTitleChanger } from './Meta/PageTitleChanger'; -import { ColorSelector } from './Theme/ColorSelector'; -import { CustomCssChanger } from './Theme/CustomCssChanger'; -import { DashboardTilesOpacitySelector } from './Theme/OpacitySelector'; -import { ShadeSelector } from './Theme/ShadeSelector'; - -export const CustomizationSettingsAccordeon = () => { - const items = getItems().map((item) => ( - - - - - - {item.content} - - - )); - return ( - - {items} - - ); -}; - -interface AccordionLabelProps { - label: string; - image: ReactNode; - description: string; -} - -const AccordionLabel = ({ label, image, description }: AccordionLabelProps) => ( - - {image} -
- {label} - - {description} - -
-
-); - -const getItems = () => { - const { t } = useTranslation([ - 'settings/customization/general', - 'settings/customization/color-selector', - ]); - const items = [ - { - id: 'layout', - image: , - label: t('accordeon.layout.name'), - description: t('accordeon.layout.description'), - content: , - }, - { - id: 'gridstack', - image: , - label: t('accordeon.gridstack.name'), - description: t('accordeon.gridstack.description'), - content: , - }, - { - id: 'accessibility', - image: , - label: t('accordeon.accessibility.name'), - description: t('accordeon.accessibility.description'), - content: , - }, - { - id: 'page_metadata', - image: , - label: t('accordeon.pageMetadata.name'), - description: t('accordeon.pageMetadata.description'), - content: ( - <> - - - - - - ), - }, - { - id: 'appereance', - image: , - label: t('accordeon.appereance.name'), - description: t('accordeon.appereance.description'), - content: ( - <> - - - - {t('settings/customization/color-selector:colors')} - - - - - - - - - - - - - - - - - ), - }, - ]; - if (process.env.NODE_ENV === 'development') { - items.push({ - id: 'dev', - image: , - label: 'Developer options', - description: 'Options to help when developing', - content: ( - - - // Change to CI mode language - i18n?.changeLanguage(e.target.checked ? 'cimode' : 'en') - } - /> - - ), - }); - } - return items; -}; diff --git a/src/components/Settings/Customization/CustomizationSettings.tsx b/src/components/Settings/Customization/CustomizationSettings.tsx deleted file mode 100644 index 84d484f20..000000000 --- a/src/components/Settings/Customization/CustomizationSettings.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { ScrollArea, Stack, Text } from '@mantine/core'; -import { useViewportSize } from '@mantine/hooks'; -import { useTranslation } from 'next-i18next'; - -import { CustomizationSettingsAccordeon } from './CustomizationAccordeon'; - -export default function CustomizationSettings() { - const { height } = useViewportSize(); - const { t } = useTranslation('settings/customization/general'); - - return ( - - - {t('text')} - - - - ); -} diff --git a/src/components/Settings/Customization/Layout/GridstackConfiguration.tsx b/src/components/Settings/Customization/Layout/GridstackConfiguration.tsx deleted file mode 100644 index 2bf967905..000000000 --- a/src/components/Settings/Customization/Layout/GridstackConfiguration.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { Alert, Button, Grid, Input, LoadingOverlay, Slider } from '@mantine/core'; -import { useForm } from '@mantine/form'; -import { IconCheck, IconReload } from '@tabler/icons-react'; -import { useTranslation } from 'next-i18next'; -import { useState } from 'react'; - -import { useConfigContext } from '../../../../config/provider'; -import { useConfigStore } from '../../../../config/store'; -import { GridstackBreakpoints } from '../../../../constants/gridstack-breakpoints'; -import { sleep } from '../../../../tools/client/time'; -import { GridstackSettingsType } from '../../../../types/settings'; - -export const GridstackConfiguration = () => { - const { t } = useTranslation(['settings/customization/gridstack', 'common']); - const { config, name: configName } = useConfigContext(); - const updateConfig = useConfigStore((x) => x.updateConfig); - - if (!config || !configName) { - return null; - } - - const initialValue = config.settings.customization?.gridstack ?? { - columnCountSmall: 3, - columnCountMedium: 6, - columnCountLarge: 12, - }; - - const form = useForm({ - initialValues: initialValue, - }); - - const [isSaving, setIsSaving] = useState(false); - - const handleSubmit = async (values: GridstackSettingsType) => { - setIsSaving(true); - - await sleep(250); - await updateConfig( - configName, - (previousConfig) => ({ - ...previousConfig, - settings: { - ...previousConfig.settings, - customization: { - ...previousConfig.settings.customization, - gridstack: values, - }, - }, - }), - true, - true - ); - - form.resetDirty(); - setIsSaving(false); - }; - - return ( -
- - - - - - - - - - - {form.isDirty() && ( - - {t('unsavedChanges')} - - )} - - - - - - - - - - ); -}; diff --git a/src/components/Settings/Customization/Layout/LayoutSelector.tsx b/src/components/Settings/Customization/Layout/LayoutSelector.tsx deleted file mode 100644 index f5bbd3c87..000000000 --- a/src/components/Settings/Customization/Layout/LayoutSelector.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import { - Checkbox, - Divider, - Flex, - Group, - Indicator, - Paper, - Stack, - Text, - Title, - createStyles, -} from '@mantine/core'; -import { useTranslation } from 'next-i18next'; -import { ChangeEvent, Dispatch, SetStateAction, useState } from 'react'; - -import { useConfigContext } from '../../../../config/provider'; -import { useConfigStore } from '../../../../config/store'; -import { createDummyArray } from '../../../../tools/client/arrays'; -import { CustomizationSettingsType } from '../../../../types/settings'; -import { Logo } from '../../../layout/Logo'; - -export const LayoutSelector = () => { - const { classes } = useStyles(); - - const { config, name: configName } = useConfigContext(); - const updateConfig = useConfigStore((x) => x.updateConfig); - - const layoutSettings = config?.settings.customization.layout; - - const [leftSidebar, setLeftSidebar] = useState(layoutSettings?.enabledLeftSidebar ?? true); - const [rightSidebar, setRightSidebar] = useState(layoutSettings?.enabledRightSidebar ?? true); - const [docker, setDocker] = useState(layoutSettings?.enabledDocker ?? false); - const [ping, setPing] = useState(layoutSettings?.enabledPing ?? false); - const [searchBar, setSearchBar] = useState(layoutSettings?.enabledSearchbar ?? false); - const { t } = useTranslation('settings/common'); - - if (!configName || !config) return null; - - const handleChange = ( - key: keyof CustomizationSettingsType['layout'], - event: ChangeEvent, - setState: Dispatch> - ) => { - const value = event.target.checked; - setState(value); - updateConfig( - configName, - (prev) => { - const { layout } = prev.settings.customization; - - layout[key] = value; - - return { - ...prev, - settings: { - ...prev.settings, - customization: { - ...prev.settings.customization, - layout, - }, - }, - }; - }, - true - ); - }; - - const enabledPing = layoutSettings?.enabledPing ?? false; - - return ( - <> - - {t('layout.preview.title')} - - {t('layout.preview.subtitle')} - - - - - - - - {searchBar && } - {docker && } - - - - - - {leftSidebar && ( - - - {createDummyArray(5).map((item, index) => ( - - ))} - - - )} - - - - {createDummyArray(10).map((item, index) => ( - - ))} - - - - {rightSidebar && ( - - - {createDummyArray(5).map((item, index) => ( - - ))} - - - )} - - - - - handleChange('enabledLeftSidebar', ev, setLeftSidebar)} - /> - handleChange('enabledRightSidebar', ev, setRightSidebar)} - /> - handleChange('enabledSearchbar', ev, setSearchBar)} - /> - handleChange('enabledDocker', ev, setDocker)} - /> - handleChange('enabledPing', ev, setPing)} - /> - - - - ); -}; - -const BaseElement = ({ height, width }: { height: number; width: number }) => ( - ({ - backgroundColor: theme.colorScheme === 'dark' ? theme.colors.gray[8] : theme.colors.gray[1], - })} - h={height} - p={2} - w={width} - /> -); - -const PlaceholderElement = (props: any) => { - const { height, width, hasPing, index } = props; - - if (hasPing) { - return ( - - - - ); - } - - return ; -}; - -const useStyles = createStyles((theme) => ({ - primaryWrapper: { - flexGrow: 2, - }, - secondaryWrapper: { - flexGrow: 1, - maxWidth: 100, - }, -})); diff --git a/src/components/Settings/Customization/Meta/BackgroundChanger.tsx b/src/components/Settings/Customization/Meta/BackgroundChanger.tsx deleted file mode 100644 index 53aaea8f3..000000000 --- a/src/components/Settings/Customization/Meta/BackgroundChanger.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { TextInput } from '@mantine/core'; -import { useTranslation } from 'next-i18next'; -import { ChangeEventHandler, useState } from 'react'; - -import { useConfigContext } from '../../../../config/provider'; -import { useConfigStore } from '../../../../config/store'; - -export const BackgroundChanger = () => { - const { t } = useTranslation('settings/customization/page-appearance'); - const updateConfig = useConfigStore((x) => x.updateConfig); - const { config, name: configName } = useConfigContext(); - const [backgroundImageUrl, setBackgroundImageUrl] = useState( - config?.settings.customization.backgroundImageUrl - ); - - if (!configName) return null; - - const handleChange: ChangeEventHandler = (ev) => { - const { value } = ev.currentTarget; - const backgroundImageUrl = value.trim().length === 0 ? undefined : value; - setBackgroundImageUrl(backgroundImageUrl); - updateConfig(configName, (prev) => ({ - ...prev, - settings: { - ...prev.settings, - customization: { - ...prev.settings.customization, - backgroundImageUrl, - }, - }, - })); - }; - - return ( - - ); -}; diff --git a/src/components/Settings/Customization/Meta/FaviconChanger.tsx b/src/components/Settings/Customization/Meta/FaviconChanger.tsx deleted file mode 100644 index 86397cd1a..000000000 --- a/src/components/Settings/Customization/Meta/FaviconChanger.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { TextInput } from '@mantine/core'; -import { useTranslation } from 'next-i18next'; -import { ChangeEventHandler, useState } from 'react'; - -import { useConfigContext } from '../../../../config/provider'; -import { useConfigStore } from '../../../../config/store'; - -export const FaviconChanger = () => { - const { t } = useTranslation('settings/customization/page-appearance'); - const updateConfig = useConfigStore((x) => x.updateConfig); - const { config, name: configName } = useConfigContext(); - const [faviconUrl, setFaviconUrl] = useState( - config?.settings.customization.faviconUrl ?? '/imgs/favicon/favicon.svg' - ); - - if (!configName) return null; - - const handleChange: ChangeEventHandler = (ev) => { - const { value: faviconUrl } = ev.currentTarget; - setFaviconUrl(faviconUrl); - updateConfig(configName, (prev) => ({ - ...prev, - settings: { - ...prev.settings, - customization: { - ...prev.settings.customization, - faviconUrl, - }, - }, - })); - }; - - return ( - - ); -}; diff --git a/src/components/Settings/Customization/Meta/LogoImageChanger.tsx b/src/components/Settings/Customization/Meta/LogoImageChanger.tsx deleted file mode 100644 index d33bb6a34..000000000 --- a/src/components/Settings/Customization/Meta/LogoImageChanger.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { TextInput } from '@mantine/core'; -import { useTranslation } from 'next-i18next'; -import { ChangeEventHandler, useState } from 'react'; - -import { useConfigContext } from '../../../../config/provider'; -import { useConfigStore } from '../../../../config/store'; - -export const LogoImageChanger = () => { - const { t } = useTranslation('settings/customization/page-appearance'); - const updateConfig = useConfigStore((x) => x.updateConfig); - const { config, name: configName } = useConfigContext(); - const [logoImageUrl, setLogoImageUrl] = useState( - config?.settings.customization.logoImageUrl ?? '/imgs/logo/logo.png' - ); - - if (!configName) return null; - - const handleChange: ChangeEventHandler = (ev) => { - const { value: logoImageUrl } = ev.currentTarget; - setLogoImageUrl(logoImageUrl); - updateConfig(configName, (prev) => ({ - ...prev, - settings: { - ...prev.settings, - customization: { - ...prev.settings.customization, - logoImageUrl, - }, - }, - })); - }; - - return ( - - ); -}; diff --git a/src/components/Settings/Customization/Meta/MetaTitleChanger.tsx b/src/components/Settings/Customization/Meta/MetaTitleChanger.tsx deleted file mode 100644 index e768b2389..000000000 --- a/src/components/Settings/Customization/Meta/MetaTitleChanger.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { TextInput } from '@mantine/core'; -import { useTranslation } from 'next-i18next'; -import { ChangeEventHandler, useState } from 'react'; - -import { useConfigContext } from '../../../../config/provider'; -import { useConfigStore } from '../../../../config/store'; - -export const BrowserTabTitle = () => { - const { t } = useTranslation('settings/customization/page-appearance'); - const updateConfig = useConfigStore((x) => x.updateConfig); - const { config, name: configName } = useConfigContext(); - const [metaTitle, setMetaTitle] = useState(config?.settings.customization.metaTitle ?? ''); - - if (!configName) return null; - - const handleChange: ChangeEventHandler = (ev) => { - const { value: metaTitle } = ev.currentTarget; - setMetaTitle(metaTitle); - updateConfig(configName, (prev) => ({ - ...prev, - settings: { - ...prev.settings, - customization: { - ...prev.settings.customization, - metaTitle, - }, - }, - })); - }; - - return ( - - ); -}; diff --git a/src/components/Settings/Customization/Meta/PageTitleChanger.tsx b/src/components/Settings/Customization/Meta/PageTitleChanger.tsx deleted file mode 100644 index 75ec626a2..000000000 --- a/src/components/Settings/Customization/Meta/PageTitleChanger.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { TextInput } from '@mantine/core'; -import { useTranslation } from 'next-i18next'; -import { ChangeEventHandler, useState } from 'react'; - -import { useConfigContext } from '../../../../config/provider'; -import { useConfigStore } from '../../../../config/store'; - -export const DashboardTitleChanger = () => { - const { t } = useTranslation('settings/customization/page-appearance'); - const updateConfig = useConfigStore((x) => x.updateConfig); - const { config, name: configName } = useConfigContext(); - const [pageTitle, setPageTitle] = useState(config?.settings.customization.pageTitle ?? ''); - - if (!configName) return null; - - const handleChange: ChangeEventHandler = (ev) => { - const { value: pageTitle } = ev.currentTarget; - setPageTitle(pageTitle); - updateConfig(configName, (prev) => ({ - ...prev, - settings: { - ...prev.settings, - customization: { - ...prev.settings.customization, - pageTitle, - }, - }, - })); - }; - - return ( - - ); -}; diff --git a/src/components/Settings/Customization/Theme/ColorSelector.tsx b/src/components/Settings/Customization/Theme/ColorSelector.tsx deleted file mode 100644 index 5c3941ace..000000000 --- a/src/components/Settings/Customization/Theme/ColorSelector.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { - ColorSwatch, - Grid, - Group, - MantineTheme, - Popover, - Text, - useMantineTheme, -} from '@mantine/core'; -import { useDisclosure } from '@mantine/hooks'; -import { useTranslation } from 'next-i18next'; -import { useState } from 'react'; - -import { useConfigContext } from '../../../../config/provider'; -import { useConfigStore } from '../../../../config/store'; -import { useColorTheme } from '../../../../tools/color'; - -interface ColorControlProps { - defaultValue: MantineTheme['primaryColor'] | undefined; - type: 'primary' | 'secondary'; -} - -export function ColorSelector({ type, defaultValue }: ColorControlProps) { - const { t } = useTranslation('settings/customization/color-selector'); - const { config, name: configName } = useConfigContext(); - const [color, setColor] = - type === 'primary' - ? useState(config?.settings.customization.colors.primary || defaultValue) - : useState(config?.settings.customization.colors.secondary || defaultValue); - const [popoverOpened, popover] = useDisclosure(false); - const { setPrimaryColor, setSecondaryColor } = useColorTheme(); - const updateConfig = useConfigStore((x) => x.updateConfig); - - const theme = useMantineTheme(); - const colors = Object.keys(theme.colors).map((color) => ({ - swatch: theme.colors[color][6], - color, - })); - - if (!color || !configName) return null; - - const handleSelection = (color: MantineTheme['primaryColor']) => { - setColor(color); - if (type === 'primary') setPrimaryColor(color); - else setSecondaryColor(color); - updateConfig(configName, (prev) => { - const { colors } = prev.settings.customization; - colors[type] = color; - return { - ...prev, - settings: { - ...prev.settings, - customization: { - ...prev.settings.customization, - colors, - }, - }, - }; - }); - }; - - const swatches = colors.map(({ color, swatch }) => ( - - handleSelection(color)} - color={swatch} - size={22} - style={{ cursor: 'pointer' }} - /> - - )); - - return ( - - - - - - - - {swatches} - - - - - {t('suffix', {color: t(type)})} - - - ); -} diff --git a/src/components/Settings/Customization/Theme/CustomCssChanger.tsx b/src/components/Settings/Customization/Theme/CustomCssChanger.tsx deleted file mode 100644 index 335c9cebd..000000000 --- a/src/components/Settings/Customization/Theme/CustomCssChanger.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { Box, Group, Loader, Stack, Text, createStyles, useMantineTheme } from '@mantine/core'; -import { useDebouncedValue } from '@mantine/hooks'; -import { useTranslation } from 'next-i18next'; -import { highlight, languages } from 'prismjs'; -import 'prismjs/components/prism-css'; -import 'prismjs/themes/prism.css'; -import { useEffect, useState } from 'react'; -import Editor from 'react-simple-code-editor'; - -import { useConfigContext } from '../../../../config/provider'; -import { useConfigStore } from '../../../../config/store'; - -export const CustomCssChanger = () => { - const { t } = useTranslation('settings/customization/page-appearance'); - const updateConfig = useConfigStore((x) => x.updateConfig); - const { colorScheme, colors } = useMantineTheme(); - const { config, name: configName } = useConfigContext(); - const [nonDebouncedCustomCSS, setNonDebouncedCustomCSS] = useState( - config?.settings.customization.customCss ?? '' - ); - const [debouncedCustomCSS] = useDebouncedValue(nonDebouncedCustomCSS, 696); - const { classes } = useStyles(); - - if (!configName) return null; - - useEffect(() => { - updateConfig(configName, (prev) => ({ - ...prev, - settings: { - ...prev.settings, - customization: { - ...prev.settings.customization, - customCss: debouncedCustomCSS, - }, - }, - })); - }, [debouncedCustomCSS]); - - const codeIsDirty = nonDebouncedCustomCSS !== debouncedCustomCSS; - const codeEditorHeight = codeIsDirty ? 250 - 42 : 250; - - return ( - - {t('customCSS.label')} - - {t('customCSS.description')} - -
- setNonDebouncedCustomCSS(code)} - highlight={(code) => highlight(code, languages.extend('css', {}), 'css')} - padding={10} - style={{ - fontFamily: '"Fira code", "Fira Mono", monospace', - fontSize: 12, - minHeight: codeEditorHeight, - }} - /> - {codeIsDirty && ( - - - - {t('customCSS.applying')} - - - )} -
-
- ); -}; - -const useStyles = createStyles(({ colors, colorScheme, radius }) => ({ - codeEditorFooter: { - borderBottomLeftRadius: radius.sm, - borderBottomRightRadius: radius.sm, - backgroundColor: colorScheme === 'dark' ? colors.dark[7] : undefined, - }, - codeEditorRoot: { - 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], - }, - }, -})); diff --git a/src/components/Settings/Customization/Theme/OpacitySelector.tsx b/src/components/Settings/Customization/Theme/OpacitySelector.tsx deleted file mode 100644 index ef25ef364..000000000 --- a/src/components/Settings/Customization/Theme/OpacitySelector.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Slider, Stack, Text } from '@mantine/core'; -import { useTranslation } from 'next-i18next'; -import { useState } from 'react'; - -import { useConfigContext } from '../../../../config/provider'; -import { useConfigStore } from '../../../../config/store'; - -export function DashboardTilesOpacitySelector() { - const { config, name: configName } = useConfigContext(); - const [opacity, setOpacity] = useState(config?.settings.customization.appOpacity || 100); - const { t } = useTranslation('settings/customization/opacity-selector'); - - const updateConfig = useConfigStore((x) => x.updateConfig); - - if (!configName) return null; - - const handleChange = (opacity: number) => { - setOpacity(opacity); - updateConfig(configName, (prev) => ({ - ...prev, - settings: { - ...prev.settings, - customization: { - ...prev.settings.customization, - appOpacity: opacity, - }, - }, - })); - }; - - return ( - - {t('label')} - - - ); -} - -const MARKS = [ - { value: 10, label: '10' }, - { value: 20, label: '20' }, - { value: 30, label: '30' }, - { value: 40, label: '40' }, - { value: 50, label: '50' }, - { value: 60, label: '60' }, - { value: 70, label: '70' }, - { value: 80, label: '80' }, - { value: 90, label: '90' }, - { value: 100, label: '100' }, -]; diff --git a/src/components/Settings/Customization/Theme/ShadeSelector.tsx b/src/components/Settings/Customization/Theme/ShadeSelector.tsx deleted file mode 100644 index 82453c611..000000000 --- a/src/components/Settings/Customization/Theme/ShadeSelector.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { - ColorSwatch, - Grid, - Group, - MantineTheme, - Popover, - Stack, - Text, - useMantineTheme, -} from '@mantine/core'; -import { useDisclosure } from '@mantine/hooks'; -import { useTranslation } from 'next-i18next'; -import { useState } from 'react'; - -import { useConfigContext } from '../../../../config/provider'; -import { useConfigStore } from '../../../../config/store'; -import { useColorTheme } from '../../../../tools/color'; - -export function ShadeSelector() { - const { t } = useTranslation('settings/customization/shade-selector'); - const { config, name: configName } = useConfigContext(); - const [shade, setShade] = useState(config?.settings.customization.colors.shade); - const [popoverOpened, popover] = useDisclosure(false); - const { primaryColor, setPrimaryShade } = useColorTheme(); - - const updateConfig = useConfigStore((x) => x.updateConfig); - - const theme = useMantineTheme(); - const primaryShades = theme.colors[primaryColor].map((s, i) => ({ - swatch: theme.colors[primaryColor][i], - shade: i as MantineTheme['primaryShade'], - })); - - if (shade === undefined || !configName) return null; - - const handleSelection = (shade: MantineTheme['primaryShade']) => { - setPrimaryShade(shade); - setShade(shade); - updateConfig(configName, (prev) => ({ - ...prev, - settings: { - ...prev.settings, - customization: { - ...prev.settings.customization, - colors: { - ...prev.settings.customization.colors, - shade, - }, - }, - }, - })); - }; - - const primarySwatches = primaryShades.map(({ swatch, shade }) => ( - - handleSelection(shade)} - color={swatch} - size={22} - style={{ cursor: 'pointer' }} - /> - - )); - - return ( - - - - - - - - - {primarySwatches} - - - - - {t('label')} - - ); -} diff --git a/src/components/Settings/SettingsDrawer.tsx b/src/components/Settings/SettingsDrawer.tsx deleted file mode 100644 index f2906947b..000000000 --- a/src/components/Settings/SettingsDrawer.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Drawer, Tabs, Title } from '@mantine/core'; -import { useTranslation } from 'next-i18next'; - -import { useConfigContext } from '../../config/provider'; -import { useConfigStore } from '../../config/store'; -import CommonSettings from './Common/CommonSettings'; -import CustomizationSettings from './Customization/CustomizationSettings'; - -function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: string }) { - const { t } = useTranslation('settings/common'); - - return ( - - - {t('tabs.common')} - {t('tabs.customizations')} - - - - - - - - - ); -} - -interface SettingsDrawerProps { - opened: boolean; - closeDrawer: () => void; -} - -export function SettingsDrawer({ - opened, - closeDrawer, - newVersionAvailable, -}: SettingsDrawerProps & { newVersionAvailable: string }) { - const { t } = useTranslation('settings/common'); - const { config, name: configName } = useConfigContext(); - const { updateConfig } = useConfigStore(); - - return ( - {t('title')}} - opened={opened} - onClose={() => { - closeDrawer(); - if (!configName || !config) { - return; - } - - updateConfig(configName, (_) => config, false, true); - }} - transitionProps={{ transition: 'slide-left' }} - > - - - ); -} diff --git a/src/components/ThemeSchemeToggle/ThemeSchemeToggle.tsx b/src/components/ThemeSchemeToggle/ThemeSchemeToggle.tsx new file mode 100644 index 000000000..079565cdb --- /dev/null +++ b/src/components/ThemeSchemeToggle/ThemeSchemeToggle.tsx @@ -0,0 +1,23 @@ +import { + ActionIcon, + ActionIconProps, +} from '@mantine/core'; +import { useColorScheme } from '~/hooks/use-colorscheme'; +import { IconMoonStars, IconSun } from '@tabler/icons-react'; + +export const ThemeSchemeToggle = (props : Partial) => { + const { colorScheme, toggleColorScheme } = useColorScheme(); + const Icon = colorScheme === 'dark' ? IconSun : IconMoonStars; + + return ( + + + + ); +}; diff --git a/src/components/User/Preferences/AccessibilitySettings.tsx b/src/components/User/Preferences/AccessibilitySettings.tsx new file mode 100644 index 000000000..1ae4d7f48 --- /dev/null +++ b/src/components/User/Preferences/AccessibilitySettings.tsx @@ -0,0 +1,25 @@ +import { Stack, Switch } from '@mantine/core'; +import { useTranslation } from 'next-i18next'; +import { useUserPreferencesFormContext } from '~/pages/user/preferences'; + +export const AccessibilitySettings = () => { + const { t } = useTranslation('user/preferences'); + + const form = useUserPreferencesFormContext(); + + return ( + + + + + + ); +}; diff --git a/src/components/Settings/Common/Language/LanguageSelect.tsx b/src/components/User/Preferences/Language/LanguageSelect.tsx similarity index 82% rename from src/components/Settings/Common/Language/LanguageSelect.tsx rename to src/components/User/Preferences/Language/LanguageSelect.tsx index 37dbdec9b..a0e269230 100644 --- a/src/components/Settings/Common/Language/LanguageSelect.tsx +++ b/src/components/User/Preferences/Language/LanguageSelect.tsx @@ -1,20 +1,25 @@ import { Group, Select, Stack, Text } from '@mantine/core'; import { showNotification } from '@mantine/notifications'; import { getCookie, setCookie } from 'cookies-next'; +import { useSession } from 'next-auth/react'; import { useTranslation } from 'next-i18next'; import { useRouter } from 'next/router'; import { forwardRef, useState } from 'react'; +import { api } from '~/utils/api'; -import { Language, getLanguageByCode } from '../../../../tools/language'; +import { COOKIE_LOCALE_KEY } from '../../../../../data/constants'; +import { Language, getLanguageByCode } from '~/tools/language'; export default function LanguageSelect() { + const { data: sessionData } = useSession(); const { t, i18n } = useTranslation('settings/general/internationalization'); const { changeLanguage } = i18n; - const configLocale = getCookie('config-locale'); + const configLocale = getCookie(COOKIE_LOCALE_KEY); const { locale, locales, pathname, query, asPath, push } = useRouter(); const [selectedLanguage, setSelectedLanguage] = useState( - (configLocale as string) ?? locale ?? 'en' + sessionData?.user.language ?? (configLocale as string) ?? locale ?? 'en' ); + const { mutateAsync } = api.user.changeLanguage.useMutation(); const data = locales ? locales.map((localeItem) => ({ @@ -30,12 +35,18 @@ export default function LanguageSelect() { const newLanguage = getLanguageByCode(value); changeLanguage(value) - .then(() => { - setCookie('config-locale', value, { + .then(async () => { + setCookie(COOKIE_LOCALE_KEY, value, { maxAge: 60 * 60 * 24 * 30, sameSite: 'strict', }); + if (sessionData?.user && new Date(sessionData.expires) > new Date()) { + await mutateAsync({ + language: value, + }); + } + push( { pathname, diff --git a/src/components/User/Preferences/SearchEngineSelector.tsx b/src/components/User/Preferences/SearchEngineSelector.tsx new file mode 100644 index 000000000..d8e301ca2 --- /dev/null +++ b/src/components/User/Preferences/SearchEngineSelector.tsx @@ -0,0 +1,66 @@ +import { Paper, SegmentedControl, Stack, Switch, TextInput } from '@mantine/core'; +import { useTranslation } from 'next-i18next'; +import { useMemo } from 'react'; +import { useUserPreferencesFormContext } from '~/pages/user/preferences'; + +export const SearchEngineSettings = () => { + const { t } = useTranslation('user/preferences'); + const form = useUserPreferencesFormContext(); + const segmentData = useSegmentData(); + const segmentValue = useMemo( + () => + searchEngineOptions.find((x) => x.value === form.values.searchTemplate)?.value ?? 'custom', + [form.values.searchTemplate] + ); + + return ( + + { + v === 'custom' + ? form.setFieldValue('searchTemplate', '') + : form.setFieldValue('searchTemplate', v); + }} + /> + + + + + + + + + + ); +}; + +const searchEngineOptions = [ + { label: 'Google', value: 'https://google.com/search?q=%s' }, + { label: 'DuckDuckGo', value: 'https://duckduckgo.com/?q=%s' }, + { label: 'Bing', value: 'https://bing.com/search?q=%s' }, + { value: 'custom' }, +] as const; + +const useSegmentData = () => { + const { t } = useTranslation('user/preferences'); + return searchEngineOptions.map((option) => ({ + label: option.value === 'custom' ? t('searchEngine.custom') : option.label, + value: option.value, + })); +}; diff --git a/src/components/layout/Background.tsx b/src/components/layout/Background.tsx deleted file mode 100644 index f37e35429..000000000 --- a/src/components/layout/Background.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Global } from '@mantine/core'; - -import { useConfigContext } from '../../config/provider'; - -export function Background() { - const { config } = useConfigContext(); - - return ( - - ); -} diff --git a/src/components/layout/Background/FloatingBackground.tsx b/src/components/layout/Background/FloatingBackground.tsx new file mode 100644 index 000000000..59140e6c1 --- /dev/null +++ b/src/components/layout/Background/FloatingBackground.tsx @@ -0,0 +1,53 @@ +import { Box, createStyles, useMantineTheme } from '@mantine/core'; +import { useMouse } from '@mantine/hooks'; + +import { PolkaElement } from './PolkaElement'; + +export const FloatingBackground = () => { + const { classes } = useStyles(); + return ( + + + + + + + + + + ); +}; + +const MouseBackdrop = () => { + const { x, y } = useMouse(); + const radius = 40; + return ( + + { + const dropColor = + theme.colorScheme === 'dark' + ? theme.fn.rgba(theme.colors.red[8], 0.05) + : theme.fn.rgba(theme.colors.red[2], 0.4); + const boxShadow = `0px 0px ${radius}px ${radius}px ${dropColor}`; + return { + width: 50, + height: 50, + borderRadius: '5rem', + boxShadow: boxShadow, + backgroundColor: dropColor, + }; + }} + top={y - 25} + left={x - 25} + pos="absolute" + > + + ); +}; + +const useStyles = createStyles(() => ({ + noOverflow: { + overflow: 'hidden', + }, +})); diff --git a/src/components/layout/Background/PolkaElement.tsx b/src/components/layout/Background/PolkaElement.tsx new file mode 100644 index 000000000..be6dc79db --- /dev/null +++ b/src/components/layout/Background/PolkaElement.tsx @@ -0,0 +1,32 @@ +import { Box } from '@mantine/core'; + +export const PolkaElement = ({ + rotation, + left, + top, + right, + bottom, +}: { + rotation: number; + top?: number; + left?: number; + right?: number; + bottom?: number; +}) => { + return ( + + ); +}; diff --git a/src/components/layout/Logo.tsx b/src/components/layout/Common/Logo.tsx similarity index 81% rename from src/components/layout/Logo.tsx rename to src/components/layout/Common/Logo.tsx index 9cc76fb7e..07116361e 100644 --- a/src/components/layout/Logo.tsx +++ b/src/components/layout/Common/Logo.tsx @@ -1,6 +1,7 @@ import { Group, Image, Text } from '@mantine/core'; +import { useScreenLargerThan } from '~/hooks/useScreenLargerThan'; -import { useConfigContext } from '../../config/provider'; +import { useConfigContext } from '~/config/provider'; import { usePrimaryGradient } from './useGradient'; interface LogoProps { @@ -11,6 +12,7 @@ interface LogoProps { export function Logo({ size = 'md', withoutText = false }: LogoProps) { const { config } = useConfigContext(); const primaryGradient = usePrimaryGradient(); + const largerThanMd = useScreenLargerThan('md'); return ( @@ -20,7 +22,7 @@ export function Logo({ size = 'md', withoutText = false }: LogoProps) { alt="Homarr Logo" className="dashboard-header-logo-image" /> - {withoutText ? null : ( + {withoutText || !largerThanMd ? null : ( { const { config } = useConfigContext(); diff --git a/src/components/layout/useGradient.tsx b/src/components/layout/Common/useGradient.tsx similarity index 61% rename from src/components/layout/useGradient.tsx rename to src/components/layout/Common/useGradient.tsx index f63e0e8bd..4ba667f1e 100644 --- a/src/components/layout/useGradient.tsx +++ b/src/components/layout/Common/useGradient.tsx @@ -1,13 +1,13 @@ import { MantineGradient } from '@mantine/core'; -import { useColorTheme } from '../../tools/color'; +import { useColorTheme } from '~/tools/color'; -export const usePrimaryGradient = (): MantineGradient => { +export const usePrimaryGradient = () => { const { primaryColor, secondaryColor } = useColorTheme(); return { from: primaryColor, to: secondaryColor, deg: 145, - }; + } satisfies MantineGradient; }; diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx deleted file mode 100644 index db05ecf5b..000000000 --- a/src/components/layout/Layout.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { AppShell, createStyles } from '@mantine/core'; - -import { useConfigContext } from '../../config/provider'; -import { Background } from './Background'; -import { Header } from './header/Header'; -import { Head } from './header/Meta/Head'; - -const useStyles = createStyles(() => ({})); - -export default function Layout({ children }: any) { - const { cx } = useStyles(); - const { config } = useConfigContext(); - - return ( - } - styles={{ - main: { - minHeight: 'calc(100vh - var(--mantine-header-height))', - }, - }} - className="dashboard-app-shell" - > - - - {children} - - - ); -} diff --git a/src/components/layout/Meta/BoardHeadOverride.tsx b/src/components/layout/Meta/BoardHeadOverride.tsx new file mode 100644 index 000000000..7009489dc --- /dev/null +++ b/src/components/layout/Meta/BoardHeadOverride.tsx @@ -0,0 +1,30 @@ +import Head from 'next/head'; +import React from 'react'; +import { firstUpperCase } from '~/tools/shared/strings'; + +import { useConfigContext } from '~/config/provider'; + +export const BoardHeadOverride = () => { + const { config, name } = useConfigContext(); + + if (!config || !name) return null; + + const { metaTitle, faviconUrl } = config.settings.customization; + const fallbackTitle = `${firstUpperCase(name)} Board • Homarr`; + const title = metaTitle && metaTitle.length > 0 ? metaTitle : fallbackTitle; + + return ( + + {title} + + + {faviconUrl && faviconUrl.length > 0 && ( + <> + + + + + )} + + ); +}; diff --git a/src/components/layout/Meta/CommonHead.tsx b/src/components/layout/Meta/CommonHead.tsx new file mode 100644 index 000000000..4651cae36 --- /dev/null +++ b/src/components/layout/Meta/CommonHead.tsx @@ -0,0 +1,26 @@ +import { useMantineTheme } from '@mantine/core'; +import Head from 'next/head'; + +export const CommonHead = () => { + const { colorScheme } = useMantineTheme(); + + return ( + + + + + + + {/* configure apple splash screen & touch icon */} + + + + + + + + ); +}; diff --git a/src/components/layout/Templates/BoardLayout.tsx b/src/components/layout/Templates/BoardLayout.tsx new file mode 100644 index 000000000..cf91750e1 --- /dev/null +++ b/src/components/layout/Templates/BoardLayout.tsx @@ -0,0 +1,239 @@ +import { Button, Global, Text, Title, Tooltip, clsx } from '@mantine/core'; +import { useHotkeys, useWindowEvent } from '@mantine/hooks'; +import { openContextModal } from '@mantine/modals'; +import { hideNotification, showNotification } from '@mantine/notifications'; +import { + IconApps, + IconBrandDocker, + IconEditCircle, + IconEditCircleOff, + IconSettings, +} from '@tabler/icons-react'; +import Consola from 'consola'; +import { useSession } from 'next-auth/react'; +import { Trans, useTranslation } from 'next-i18next'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { useEditModeStore } from '~/components/Dashboard/Views/useEditModeStore'; +import { useNamedWrapperColumnCount } from '~/components/Dashboard/Wrappers/gridstack/store'; +import { BoardHeadOverride } from '~/components/layout/Meta/BoardHeadOverride'; +import { HeaderActionButton } from '~/components/layout/header/ActionButton'; +import { useConfigContext } from '~/config/provider'; +import { useScreenLargerThan } from '~/hooks/useScreenLargerThan'; +import { api } from '~/utils/api'; + +import { MainLayout } from './MainLayout'; + +type BoardLayoutProps = { + dockerEnabled: boolean; + children: React.ReactNode; +}; + +export const BoardLayout = ({ children, dockerEnabled }: BoardLayoutProps) => { + const { config } = useConfigContext(); + const { data: session } = useSession(); + + return ( + } + > + + + {children} + + + ); +}; + +type HeaderActionProps = { + dockerEnabled: boolean; +}; + +export const HeaderActions = ({ dockerEnabled }: HeaderActionProps) => { + const { data: sessionData } = useSession(); + + if (!sessionData?.user?.isAdmin) return null; + + return ( + <> + {dockerEnabled && } + + + + ); +}; + +const DockerButton = () => { + const { t } = useTranslation('modules/docker'); + + return ( + + + + + + ); +}; + +const CustomizeBoardButton = () => { + const { name } = useConfigContext(); + const { t } = useTranslation('boards/common'); + const href = useBoardLink(`/board/${name}/customize`); + + return ( + + + + + + ); +}; + +const beforeUnloadEventText = 'Exit the edit mode to save your changes'; +const editModeNotificationId = 'toggle-edit-mode'; + +const ToggleEditModeButton = () => { + const { enabled, toggleEditMode } = useEditModeStore(); + const { config, name: configName } = useConfigContext(); + const { mutateAsync: saveConfig } = api.config.save.useMutation(); + const namedWrapperColumnCount = useNamedWrapperColumnCount(); + const { t } = useTranslation(['layout/header/actions/toggle-edit-mode', 'common']); + const translatedSize = + namedWrapperColumnCount !== null + ? t(`common:breakPoints.${namedWrapperColumnCount}`) + : t('common:loading'); + + useHotkeys([['mod+E', toggleEditMode]]); + + useWindowEvent('beforeunload', (event: BeforeUnloadEvent) => { + if (enabled) { + // eslint-disable-next-line no-param-reassign + event.returnValue = beforeUnloadEventText; + return beforeUnloadEventText; + } + + return undefined; + }); + + const save = async () => { + toggleEditMode(); + if (!config || !configName) return; + await saveConfig({ name: configName, config }); + Consola.log('Saved config to server', configName); + hideNotification(editModeNotificationId); + }; + + const enableEditMode = () => { + toggleEditMode(); + showNotification({ + styles: (theme) => ({ + root: { + backgroundColor: theme.colors.orange[7], + borderColor: theme.colors.orange[7], + + '&::before': { backgroundColor: theme.white }, + }, + title: { color: theme.white }, + description: { color: theme.white }, + closeButton: { + color: theme.white, + '&:hover': { backgroundColor: theme.colors.orange[7] }, + }, + }), + radius: 'md', + id: 'toggle-edit-mode', + autoClose: 10000, + title: ( + + <Trans + i18nKey="layout/header/actions/toggle-edit-mode:popover.title" + values={{ size: translatedSize }} + components={{ + 1: ( + <Text + component="a" + style={{ color: 'inherit', textDecoration: 'underline' }} + href="https://homarr.dev/docs/customizations/layout" + target="_blank" + /> + ), + }} + /> + + ), + message: , + }); + }; + + if (enabled) { + return ( + + + + + + + + + ); + } + return ( + + + + + + ); +}; + +const AddElementButton = () => { + const { t } = useTranslation('layout/element-selector/selector'); + + return ( + + + openContextModal({ + modal: 'selectElement', + title: t('modal.title'), + size: 'xl', + innerProps: {}, + }) + } + > + + + + ); +}; + +const BackgroundImage = () => { + const { config } = useConfigContext(); + + if (!config?.settings.customization.backgroundImageUrl) { + return null; + } + + return ( + + ); +}; + +export const useBoardLink = ( + link: '/board' | `/board/${string}/customize` | `/board/${string}` +) => { + const router = useRouter(); + + return router.asPath.startsWith('/board') ? link : link.replace('/board', '/b'); +}; diff --git a/src/components/layout/Templates/MainLayout.tsx b/src/components/layout/Templates/MainLayout.tsx new file mode 100644 index 000000000..af3c8bbad --- /dev/null +++ b/src/components/layout/Templates/MainLayout.tsx @@ -0,0 +1,41 @@ +import { AppShell, useMantineTheme } from '@mantine/core'; +import { MainHeader } from '~/components/layout/header/Header'; + +type MainLayoutProps = { + showExperimental?: boolean; + headerActions?: React.ReactNode; + contentComponents?: React.ReactNode; + children: React.ReactNode; + autoFocusSearch?: boolean; +}; + +export const MainLayout = ({ + showExperimental, + headerActions, + contentComponents, + children, + autoFocusSearch, +}: MainLayoutProps) => { + const theme = useMantineTheme(); + + return ( + + } + className="dashboard-app-shell" + > + {children} + + ); +}; diff --git a/src/components/layout/Templates/ManageLayout.tsx b/src/components/layout/Templates/ManageLayout.tsx new file mode 100644 index 000000000..5cdf063d4 --- /dev/null +++ b/src/components/layout/Templates/ManageLayout.tsx @@ -0,0 +1,271 @@ +import { + AppShell, + Burger, + Drawer, + Flex, + Footer, + Group, + NavLink, + Navbar, + Paper, + Text, + ThemeIcon, + useMantineTheme, +} from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { + IconBook2, + IconBrandDiscord, + IconBrandDocker, + IconBrandGithub, + IconGitFork, + IconHome, + IconLayoutDashboard, + IconMailForward, + IconQuestionMark, + IconTool, + IconUser, + IconUsers, + TablerIconsProps, +} from '@tabler/icons-react'; +import { useSession } from 'next-auth/react'; +import { useTranslation } from 'next-i18next'; +import Image from 'next/image'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { ReactNode, RefObject, forwardRef } from 'react'; +import { useScreenLargerThan } from '~/hooks/useScreenLargerThan'; +import { usePackageAttributesStore } from '~/tools/client/zustands/usePackageAttributesStore'; + +import { type navigation } from '../../../../public/locales/en/layout/manage.json'; +import { MainHeader } from '../header/Header'; + +interface ManageLayoutProps { + children: ReactNode; +} + +export const ManageLayout = ({ children }: ManageLayoutProps) => { + const packageVersion = usePackageAttributesStore((x) => x.attributes.packageVersion); + const theme = useMantineTheme(); + + const screenLargerThanMd = useScreenLargerThan('md'); + + const [burgerMenuOpen, { toggle: toggleBurgerMenu, close: closeBurgerMenu }] = + useDisclosure(false); + + const data = useSession(); + const isAdmin = data.data?.user.isAdmin ?? false; + + const navigationLinkComponents = Object.entries(navigationLinks).map(([name, navigationLink]) => { + if (navigationLink.onlyAdmin && !isAdmin) { + return null; + } + + return ( + + ); + }); + + const burgerMenu = screenLargerThanMd ? undefined : ( + + ); + + return ( + <> + + + {navigationLinkComponents} + + + ); +}; + +type Icon = (props: TablerIconsProps) => JSX.Element; + +type NavigationLinkHref = { + icon: Icon; + href: string; + target?: '_self' | '_blank'; + onlyAdmin?: boolean; +}; + +type NavigationLinkItems = { + icon: Icon; + items: Record; + onlyAdmin?: boolean; +}; + +type CustomNavigationLinkProps = { + name: keyof typeof navigationLinks; + navigationLink: (typeof navigationLinks)[keyof typeof navigationLinks]; +}; + +const CustomNavigationLink = forwardRef< + HTMLAnchorElement | HTMLButtonElement, + CustomNavigationLinkProps +>(({ name, navigationLink }, ref) => { + const { t } = useTranslation('layout/manage'); + const router = useRouter(); + + const commonProps = { + label: t(`navigation.${name}.title`), + icon: ( + + + + ), + defaultOpened: false, + }; + + if ('href' in navigationLink) { + const isActive = router.pathname.endsWith(navigationLink.href); + return ( + } + component={Link} + href={navigationLink.href} + active={isActive} + /> + ); + } + + const isAnyActive = Object.entries(navigationLink.items) + .map(([_, item]) => item.href) + .some((href) => router.pathname.endsWith(href)); + + return ( + }> + {Object.entries(navigationLink.items).map(([itemName, item], index) => { + const commonItemProps = { + label: t(`navigation.${name}.items.${itemName}`), + icon: , + href: item.href, + }; + + const matchesActive = router.pathname.endsWith(item.href); + + if (item.href.startsWith('http')) { + return ( + + ); + } + + return ; + })} + + ); +}); + +type NavigationLinks = { + [key in keyof typeof navigation]: (typeof navigation)[key] extends { + items: Record; + } + ? NavigationLinkItems<(typeof navigation)[key]['items']> + : NavigationLinkHref; +}; + +const navigationLinks: NavigationLinks = { + home: { + icon: IconHome, + href: '/manage', + }, + boards: { + icon: IconLayoutDashboard, + href: '/manage/boards', + }, + users: { + icon: IconUser, + onlyAdmin: true, + items: { + manage: { + icon: IconUsers, + href: '/manage/users', + }, + invites: { + icon: IconMailForward, + href: '/manage/users/invites', + }, + }, + }, + tools: { + icon: IconTool, + onlyAdmin: true, + items: { + docker: { + icon: IconBrandDocker, + href: '/manage/tools/docker', + }, + }, + }, + help: { + icon: IconQuestionMark, + items: { + documentation: { + icon: IconBook2, + href: 'https://homarr.dev/docs/about', + target: '_blank', + }, + report: { + icon: IconBrandGithub, + href: 'https://github.com/ajnart/homarr/issues/new/choose', + target: '_blank', + }, + discord: { + icon: IconBrandDiscord, + href: 'https://discord.com/invite/aCsmEV5RgA', + target: '_blank', + }, + contribute: { + icon: IconGitFork, + href: 'https://github.com/ajnart/homarr', + target: '_blank', + }, + }, + }, +}; diff --git a/src/components/Dashboard/Modals/AboutModal/AboutModal.tsx b/src/components/layout/header/About/AboutModal.tsx similarity index 84% rename from src/components/Dashboard/Modals/AboutModal/AboutModal.tsx rename to src/components/layout/header/About/AboutModal.tsx index cf499d401..e559d97ed 100644 --- a/src/components/Dashboard/Modals/AboutModal/AboutModal.tsx +++ b/src/components/layout/header/About/AboutModal.tsx @@ -8,11 +8,11 @@ import { Group, HoverCard, Kbd, + Image, Modal, Table, Text, Title, - Tooltip, createStyles, } from '@mantine/core'; import { @@ -30,17 +30,15 @@ import { import { motion } from 'framer-motion'; import { InitOptions } from 'i18next'; import { Trans, i18n, useTranslation } from 'next-i18next'; -import Image from 'next/image'; import { ReactNode } from 'react'; -import { useConfigContext } from '../../../../config/provider'; -import { useConfigStore } from '../../../../config/store'; -import { useEditModeInformationStore } from '../../../../hooks/useEditModeInformation'; -import { usePackageAttributesStore } from '../../../../tools/client/zustands/usePackageAttributesStore'; -import { useColorTheme } from '../../../../tools/color'; -import Credits from '../../../Settings/Common/Credits'; -import Tip from '../../../layout/Tip'; -import { usePrimaryGradient } from '../../../layout/useGradient'; +import { useConfigContext } from '~/config/provider'; +import { useConfigStore } from '~/config/store'; +import { usePackageAttributesStore } from '~/tools/client/zustands/usePackageAttributesStore'; +import { useColorTheme } from '~/tools/color'; +import { usePrimaryGradient } from '../../Common/useGradient'; +import Credits from './Credits'; +import Tip from './Tip'; interface AboutModalProps { opened: boolean; @@ -82,9 +80,7 @@ export const AboutModal = ({ opened, closeModal, newVersionAvailable }: AboutMod src="/imgs/logo/logo.png" width={30} height={30} - style={{ - objectFit: 'contain', - }} + fit="contain" /> {t('about')} Homarr @@ -201,7 +197,6 @@ interface ExtendedInitOptions extends InitOptions { const useInformationTableItems = (newVersionAvailable?: string): InformationTableItem[] => { const { attributes } = usePackageAttributesStore(); - const { editModeEnabled } = useEditModeInformationStore(); const { primaryColor } = useColorTheme(); const { t } = useTranslation(['layout/modals/about']); @@ -210,32 +205,7 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl let items: InformationTableItem[] = []; - if (!editModeEnabled) { - items = [ - ...items, - { - icon: <IconKey size={20} />, - label: 'experimental_disableEditMode', - content: ( - <Tooltip - color="red" - withinPortal - width={300} - multiline - withArrow - label="This is an experimental feature, where the edit mode is disabled entirely - no config - modifications are possbile anymore. All update requests for the config will be dropped - on the API. This will be removed in future versions, as Homarr will receive a proper - authentication system, which will make this obsolete." - > - <Badge color="red">WARNING</Badge> - </Tooltip> - ), - }, - ]; - } - - if (i18n !== null) { + if (i18n?.reportNamespaces) { const usedI18nNamespaces = i18n.reportNamespaces.getUsedNamespaces(); const initOptions = i18n.options as ExtendedInitOptions; diff --git a/src/components/Settings/Common/Credits.tsx b/src/components/layout/header/About/Credits.tsx similarity index 95% rename from src/components/Settings/Common/Credits.tsx rename to src/components/layout/header/About/Credits.tsx index 28f245871..61e437d29 100644 --- a/src/components/Settings/Common/Credits.tsx +++ b/src/components/layout/header/About/Credits.tsx @@ -2,7 +2,7 @@ import { Anchor, Box, Collapse, Flex, Table, Text } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { useTranslation } from 'next-i18next'; -import { usePackageAttributesStore } from '../../../tools/client/zustands/usePackageAttributesStore'; +import { usePackageAttributesStore } from '~/tools/client/zustands/usePackageAttributesStore'; export default function Credits() { const { t } = useTranslation('settings/common'); diff --git a/src/components/layout/Tip.tsx b/src/components/layout/header/About/Tip.tsx similarity index 100% rename from src/components/layout/Tip.tsx rename to src/components/layout/header/About/Tip.tsx diff --git a/src/components/layout/header/ActionButton.tsx b/src/components/layout/header/ActionButton.tsx new file mode 100644 index 000000000..5c3f2045c --- /dev/null +++ b/src/components/layout/header/ActionButton.tsx @@ -0,0 +1,50 @@ +import { Button, ButtonProps } from '@mantine/core'; +import Link from 'next/link'; +import { ForwardedRef, forwardRef } from 'react'; + +import { useCardStyles } from '../Common/useCardStyles'; + +type SpecificLinkProps = { + component: typeof Link; + href: string; +}; +type SpecificButtonProps = { + onClick: HTMLButtonElement['onclick']; +}; +type HeaderActionButtonProps = Omit<ButtonProps, 'variant' | 'className' | 'h' | 'w' | 'px'> & + (SpecificLinkProps | SpecificButtonProps); + +export const HeaderActionButton = forwardRef< + HTMLButtonElement | HTMLAnchorElement, + HeaderActionButtonProps +>(({ children, ...props }, ref) => { + const { classes } = useCardStyles(true); + + const buttonProps: ButtonProps = { + variant: 'default', + className: classes.card, + h: 38, + w: 38, + px: 0, + ...props, + }; + + if ('component' in props) { + return ( + <Button + ref={ref as ForwardedRef<HTMLAnchorElement>} + component={props.component} + href={props.href} + {...buttonProps} + > + {children} + </Button> + ); + } + + return ( + <Button ref={ref as ForwardedRef<HTMLButtonElement>} {...buttonProps}> + {children} + </Button> + ); +}); diff --git a/src/components/layout/header/Actions/AddElementAction/AddElementAction.tsx b/src/components/layout/header/Actions/AddElementAction/AddElementAction.tsx deleted file mode 100644 index b640283a1..000000000 --- a/src/components/layout/header/Actions/AddElementAction/AddElementAction.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { ActionIcon, Button, Tooltip } from '@mantine/core'; -import { openContextModal } from '@mantine/modals'; -import { IconApps } from '@tabler/icons-react'; -import { useTranslation } from 'next-i18next'; - -import { useCardStyles } from '../../../useCardStyles'; - -interface AddElementActionProps { - type: 'action-icon' | 'button'; -} - -export const AddElementAction = ({ type }: AddElementActionProps) => { - const { t } = useTranslation('layout/element-selector/selector'); - const { classes } = useCardStyles(true); - - switch (type) { - case 'button': - return ( - <Tooltip label={t('actionIcon.tooltip')} withinPortal withArrow> - <Button - radius="md" - variant="default" - style={{ height: 43 }} - className={classes.card} - onClick={() => - openContextModal({ - modal: 'selectElement', - title: t('modal.title'), - size: 'xl', - innerProps: {}, - }) - } - > - <IconApps /> - </Button> - </Tooltip> - ); - case 'action-icon': - return ( - <Tooltip label={t('actionIcon.tooltip')} withinPortal withArrow> - <ActionIcon - variant="default" - radius="md" - size="xl" - color="blue" - onClick={() => - openContextModal({ - modal: 'selectElement', - title: t('modal.title'), - size: 'xl', - innerProps: {}, - }) - } - > - <IconApps /> - </ActionIcon> - </Tooltip> - ); - default: - return null; - } -}; diff --git a/src/components/layout/header/Actions/ToggleEditMode/ToggleEditMode.tsx b/src/components/layout/header/Actions/ToggleEditMode/ToggleEditMode.tsx deleted file mode 100644 index 3efc21db5..000000000 --- a/src/components/layout/header/Actions/ToggleEditMode/ToggleEditMode.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { ActionIcon, Button, Group, Text, Title, Tooltip } from '@mantine/core'; -import { useHotkeys, useWindowEvent } from '@mantine/hooks'; -import { hideNotification, showNotification } from '@mantine/notifications'; -import { IconEditCircle, IconEditCircleOff } from '@tabler/icons-react'; -import Consola from 'consola'; -import { getCookie } from 'cookies-next'; -import { Trans, useTranslation } from 'next-i18next'; -import { api } from '~/utils/api'; - -import { useConfigContext } from '../../../../../config/provider'; -import { useScreenSmallerThan } from '../../../../../hooks/useScreenSmallerThan'; -import { useEditModeStore } from '../../../../Dashboard/Views/useEditModeStore'; -import { useNamedWrapperColumnCount } from '../../../../Dashboard/Wrappers/gridstack/store'; -import { useCardStyles } from '../../../useCardStyles'; -import { AddElementAction } from '../AddElementAction/AddElementAction'; - -export const ToggleEditModeAction = () => { - const { enabled, toggleEditMode } = useEditModeStore(); - const namedWrapperColumnCount = useNamedWrapperColumnCount(); - const { t } = useTranslation(['layout/header/actions/toggle-edit-mode', 'common']); - const translatedSize = - namedWrapperColumnCount !== null - ? t(`common:breakPoints.${namedWrapperColumnCount}`) - : t('common:loading'); - const beforeUnloadEventText = t('unloadEvent'); - const smallerThanSm = useScreenSmallerThan('sm'); - const { config } = useConfigContext(); - const { classes } = useCardStyles(true); - const { mutateAsync: saveConfig } = api.config.save.useMutation(); - - useHotkeys([['mod+E', toggleEditMode]]); - - useWindowEvent('beforeunload', (event: BeforeUnloadEvent) => { - if (enabled && process.env.NODE_ENV !== 'development') { - // eslint-disable-next-line no-param-reassign - event.returnValue = beforeUnloadEventText; - return beforeUnloadEventText; - } - - return undefined; - }); - - const toggleButtonClicked = async () => { - toggleEditMode(); - if (config === undefined || config?.schemaVersion === undefined) return; - if (enabled) { - const configName = getCookie('config-name')?.toString() ?? 'default'; - await saveConfig({ name: configName, config }); - Consola.log('Saved config to server', configName); - hideNotification('toggle-edit-mode'); - } else if (!enabled) { - showNotification({ - styles: (theme) => ({ - root: { - backgroundColor: theme.colors.orange[7], - borderColor: theme.colors.orange[7], - - '&::before': { backgroundColor: theme.white }, - }, - title: { color: theme.white }, - description: { color: theme.white }, - closeButton: { - color: theme.white, - '&:hover': { backgroundColor: theme.colors.orange[7] }, - }, - }), - radius: 'md', - id: 'toggle-edit-mode', - autoClose: 10000, - title: ( - <Title order={4}> - <Trans - i18nKey="layout/header/actions/toggle-edit-mode:popover.title" - values={{ size: translatedSize }} - components={{ - 1: ( - <Text - component="a" - style={{ color: 'inherit', textDecoration: 'underline' }} - href="https://homarr.dev/docs/customizations/layout" - target="_blank" - /> - ), - }} - /> - - ), - message: , - }); - } else { - hideNotification('toggle-edit-mode'); - } - }; - - const ToggleButtonDesktop = () => ( - - - - ); - - const ToggleActionIconMobile = () => ( - toggleButtonClicked()} - variant="default" - radius="md" - size="xl" - color="blue" - > - {enabled ? : } - - ); - - return ( - <> - {smallerThanSm ? ( - enabled ? ( - - - - - ) : ( - - ) - ) : enabled ? ( - - - {enabled && } - - ) : ( - - )} - - ); -}; diff --git a/src/components/layout/header/AvatarMenu.tsx b/src/components/layout/header/AvatarMenu.tsx new file mode 100644 index 000000000..0b7ca94c2 --- /dev/null +++ b/src/components/layout/header/AvatarMenu.tsx @@ -0,0 +1,141 @@ +import { Avatar, Badge, Menu, UnstyledButton, useMantineTheme } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { + IconDashboard, + IconHomeShare, + IconInfoCircle, + IconLogin, + IconLogout, + IconMoonStars, + IconSun, + IconUserCog, +} from '@tabler/icons-react'; +import { useQuery } from '@tanstack/react-query'; +import { User } from 'next-auth'; +import { signOut, useSession } from 'next-auth/react'; +import { useTranslation } from 'next-i18next'; +import Link from 'next/link'; +import { forwardRef } from 'react'; +import { AboutModal } from '~/components/layout/header/About/AboutModal'; +import { useColorScheme } from '~/hooks/use-colorscheme'; +import { usePackageAttributesStore } from '~/tools/client/zustands/usePackageAttributesStore'; + +import { REPO_URL } from '../../../../data/constants'; +import { useBoardLink } from '../Templates/BoardLayout'; + +export const AvatarMenu = () => { + const { t } = useTranslation('layout/header'); + const [aboutModalOpened, aboutModal] = useDisclosure(false); + const { data: sessionData } = useSession(); + const { colorScheme, toggleColorScheme } = useColorScheme(); + const newVersionAvailable = useNewVersionAvailable(); + + const Icon = colorScheme === 'dark' ? IconSun : IconMoonStars; + const defaultBoardHref = useBoardLink('/board'); + + return ( + <> + + + + + + + } onClick={toggleColorScheme}> + {t('actions.avatar.switchTheme')} + + {sessionData?.user && ( + <> + } + > + {t('actions.avatar.preferences')} + + } + > + {t('actions.avatar.defaultBoard')} + + }> + {t('actions.avatar.manage')} + + + + )} + } + rightSection={ + newVersionAvailable && ( + + {t('actions.avatar.about.new')} + + ) + } + onClick={() => aboutModal.open()} + > + {t('actions.avatar.about.label')} + + {sessionData?.user ? ( + } + color="red" + onClick={() => + signOut({ + redirect: false, + }).then(() => window.location.reload()) + } + > + {t('actions.avatar.logout', { + username: sessionData.user.name, + })} + + ) : ( + } component={Link} href="/auth/login"> + {t('actions.avatar.login')} + + )} + + + + + + + ); +}; + +type CurrentUserAvatarProps = { + user: User | null; +}; + +const CurrentUserAvatar = forwardRef( + ({ user, ...others }, ref) => { + const { primaryColor } = useMantineTheme(); + if (!user) return ; + return ( + + {user.name?.slice(0, 2).toUpperCase()} + + ); + } +); + +const useNewVersionAvailable = () => { + const { attributes } = usePackageAttributesStore(); + const { data } = useQuery({ + queryKey: ['github/latest'], + cacheTime: 1000 * 60 * 60 * 24, + staleTime: 1000 * 60 * 60 * 5, + queryFn: () => + fetch(`https://api.github.com/repos/${REPO_URL}/releases/latest`).then((res) => res.json()), + }); + return data?.tag_name > `v${attributes.packageVersion}` ? data?.tag_name : undefined; +}; diff --git a/src/components/layout/header/Header.tsx b/src/components/layout/header/Header.tsx index 935f6841f..0bc301db7 100644 --- a/src/components/layout/header/Header.tsx +++ b/src/components/layout/header/Header.tsx @@ -1,68 +1,115 @@ -import { Box, Group, Indicator, Header as MantineHeader, createStyles } from '@mantine/core'; -import { useQuery } from '@tanstack/react-query'; +import { + Anchor, + Box, + Center, + Flex, + Group, + Header, + Text, + UnstyledButton, + useMantineTheme, +} from '@mantine/core'; +import { useMediaQuery } from '@mantine/hooks'; +import { IconAlertTriangle } from '@tabler/icons-react'; +import { Trans, useTranslation } from 'next-i18next'; -import { REPO_URL } from '../../../../data/constants'; -import { useEditModeInformationStore } from '../../../hooks/useEditModeInformation'; -import DockerMenuButton from '../../../modules/Docker/DockerModule'; -import { usePackageAttributesStore } from '../../../tools/client/zustands/usePackageAttributesStore'; -import { Logo } from '../Logo'; -import { useCardStyles } from '../useCardStyles'; -import { ToggleEditModeAction } from './Actions/ToggleEditMode/ToggleEditMode'; +import { Logo } from '../Common/Logo'; +import { AvatarMenu } from './AvatarMenu'; import { Search } from './Search'; -import { SettingsMenu } from './SettingsMenu'; -export const HeaderHeight = 64; +type MainHeaderProps = { + logoHref?: string; + showExperimental?: boolean; + headerActions?: React.ReactNode; + contentComponents?: React.ReactNode; + leftIcon?: React.ReactNode; + autoFocusSearch?: boolean; +}; -export function Header(props: any) { - const { classes } = useStyles(); - const { classes: cardClasses, cx } = useCardStyles(false); - const { attributes } = usePackageAttributesStore(); - const { editModeEnabled } = useEditModeInformationStore(); - - const { data } = useQuery({ - queryKey: ['github/latest'], - cacheTime: 1000 * 60 * 60 * 24, - staleTime: 1000 * 60 * 60 * 5, - queryFn: () => - fetch(`https://api.github.com/repos/${REPO_URL}/releases/latest`).then((res) => res.json()), - }); - const newVersionAvailable = - data?.tag_name > `v${attributes.packageVersion}` ? data?.tag_name : undefined; +export const MainHeader = ({ + showExperimental = false, + logoHref = '/', + headerActions, + leftIcon, + contentComponents, + autoFocusSearch, +}: MainHeaderProps) => { + const { breakpoints } = useMantineTheme(); + const isSmallerThanMd = useMediaQuery(`(max-width: ${breakpoints.sm})`); + const experimentalHeaderNoteHeight = isSmallerThanMd ? 60 : 30; + const headerBaseHeight = isSmallerThanMd ? 60 + 46 : 60; + const headerHeight = showExperimental + ? headerBaseHeight + experimentalHeaderNoteHeight + : headerBaseHeight; return ( - - - - - - - - {editModeEnabled && } - - - - +
+ + + + {leftIcon} + + + + + + {!isSmallerThanMd && } + + + + {contentComponents} + {headerActions} + + - - ); -} -const useStyles = createStyles((theme) => ({ - hide: { - [theme.fn.smallerThan('xs')]: { - display: 'none', - }, - }, -})); + {isSmallerThanMd && ( +
+ +
+ )} +
+ ); +}; + +type ExperimentalHeaderNoteProps = { + height?: 30 | 60; + visible?: boolean; +}; +const ExperimentalHeaderNote = ({ visible = false, height = 30 }: ExperimentalHeaderNoteProps) => { + const { t } = useTranslation('layout/header'); + if (!visible) return null; + + return ( + + + + + + ), + dc: ( + + ), + }} + /> + + + + ); +}; diff --git a/src/components/layout/header/Meta/Head.tsx b/src/components/layout/header/Meta/Head.tsx deleted file mode 100644 index 89a79cb70..000000000 --- a/src/components/layout/header/Meta/Head.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/* eslint-disable react/no-invalid-html-attribute */ -import NextHead from 'next/head'; -import React from 'react'; - -import { useConfigContext } from '../../../../config/provider'; -import { SafariStatusBarStyle } from './SafariStatusBarStyle'; - -export function Head() { - const { config } = useConfigContext(); - - return ( - - {config?.settings.customization.metaTitle || 'Homarr 🦞'} - - - - - {/* configure apple splash screen & touch icon */} - - - - - - - ); -} diff --git a/src/components/layout/header/Meta/SafariStatusBarStyle.tsx b/src/components/layout/header/Meta/SafariStatusBarStyle.tsx deleted file mode 100644 index e712c871a..000000000 --- a/src/components/layout/header/Meta/SafariStatusBarStyle.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { useMantineTheme } from '@mantine/core'; - -export const SafariStatusBarStyle = () => { - const { colorScheme } = useMantineTheme(); - const isDark = colorScheme === 'dark'; - return ( - - ); -}; diff --git a/src/components/layout/header/Search.tsx b/src/components/layout/header/Search.tsx index 5e852ef8f..e0de950b2 100644 --- a/src/components/layout/header/Search.tsx +++ b/src/components/layout/header/Search.tsx @@ -1,319 +1,216 @@ +import { Autocomplete, Group, Text, useMantineTheme } from '@mantine/core'; +import { useDisclosure, useHotkeys } from '@mantine/hooks'; import { - ActionIcon, - Autocomplete, - Box, - Divider, - Kbd, - Menu, - Popover, - ScrollArea, - Tooltip, - createStyles, -} from '@mantine/core'; -import { useDebouncedValue, useHotkeys } from '@mantine/hooks'; -import { showNotification } from '@mantine/notifications'; -import { IconBrandYoutube, IconDownload, IconMovie, IconSearch } from '@tabler/icons-react'; + IconBrandYoutube, + IconDownload, + IconMovie, + IconSearch, + IconWorld, + TablerIconsProps, +} from '@tabler/icons-react'; +import { useSession } from 'next-auth/react'; import { useTranslation } from 'next-i18next'; -import React, { forwardRef, useEffect, useRef, useState } from 'react'; +import { useRouter } from 'next/router'; +import { ReactNode, forwardRef, useMemo, useRef, useState } from 'react'; +import { useConfigContext } from '~/config/provider'; import { api } from '~/utils/api'; -import { useConfigContext } from '../../../config/provider'; -import { IModule } from '../../../modules/ModuleTypes'; -import { OverseerrMediaDisplay } from '../../../modules/common'; -import { ConfigType } from '../../../types/config'; -import { searchUrls } from '../../Settings/Common/SearchEngine/SearchEngineSelector'; -import Tip from '../Tip'; -import { useCardStyles } from '../useCardStyles'; -import SmallAppItem from './SmallAppItem'; +import { MovieModal } from './Search/MovieModal'; -export const SearchModule: IModule = { - title: 'Search', - icon: IconSearch, - component: Search, - id: 'search', +type SearchProps = { + isMobile?: boolean; + autoFocus?: boolean; }; -interface ItemProps extends React.ComponentPropsWithoutRef<'div'> { - label: string; - disabled: boolean; - value: string; - description: string; - icon: React.ReactNode; - url: string; - shortcut: string; -} - -const useStyles = createStyles((theme) => ({ - item: { - '&[data-hovered]': { - backgroundColor: theme.colors[theme.primaryColor][theme.fn.primaryShade()], - color: theme.white, - }, - }, -})); - -export function Search() { - const { t } = useTranslation('modules/search'); +export const Search = ({ isMobile, autoFocus }: SearchProps) => { + const { t } = useTranslation('layout/header'); + const [search, setSearch] = useState(''); + const ref = useRef(null); + useHotkeys([['mod+K', () => ref.current?.focus()]]); + const { data: sessionData } = useSession(); + const { data: userWithSettings } = api.user.withSettings.useQuery(undefined, { + enabled: !!sessionData?.user, + }); const { config } = useConfigContext(); - const [searchQuery, setSearchQuery] = useState(''); - const [debounced] = useDebouncedValue(searchQuery, 250); - const { classes: cardClasses, cx } = useCardStyles(true); + const { colors } = useMantineTheme(); + const router = useRouter(); + const [showMovieModal, movieModal] = useDisclosure(router.query.movie === 'true'); - const isOverseerrEnabled = config?.apps.some( - (x) => x.integration.type === 'overseerr' || x.integration.type === 'jellyseerr' - ); - const overseerrApp = config?.apps.find( - (app) => app.integration?.type === 'overseerr' || app.integration?.type === 'jellyseerr' - ); - const searchEngineSettings = config?.settings.common.searchEngine; - const searchEngineUrl = !searchEngineSettings - ? searchUrls.google - : searchEngineSettings.type === 'custom' - ? searchEngineSettings.properties.template - : searchUrls[searchEngineSettings.type]; - - const searchEnginesList: ItemProps[] = [ - { - icon: , - disabled: false, - label: t('searchEngines.search.name'), - value: 'search', - description: t('searchEngines.search.description'), - url: searchEngineUrl, - shortcut: 's', - }, - { - icon: , - disabled: false, - label: t('searchEngines.torrents.name'), - value: 'torrents', - description: t('searchEngines.torrents.description'), - url: 'https://www.torrentdownloads.me/search/?search=', - shortcut: 't', - }, - { - icon: , - disabled: false, - label: t('searchEngines.youtube.name'), - value: 'youtube', - description: t('searchEngines.youtube.description'), - url: 'https://www.youtube.com/results?search_query=', - shortcut: 'y', - }, - { - icon: , - disabled: !(isOverseerrEnabled && overseerrApp), - label: t('searchEngines.overseerr.name'), - value: 'overseerr', - description: t('searchEngines.overseerr.description'), - //RegExp -> char ('/' slash) + target ($ = end of string) => remove trailing slash if there's one - url: `${overseerrApp?.url.replace(new RegExp('/' + "$"), '')}/search?query=`, - shortcut: 'm', - }, - ]; - const [selectedSearchEngine, setSearchEngine] = useState(searchEnginesList[0]); - const matchingApps = - config?.apps.filter((app) => { - if (searchQuery === '' || searchQuery === undefined) { - return false; - } - return app.name.toLowerCase().includes(searchQuery.toLowerCase()); - }) ?? []; - const autocompleteData = matchingApps.map((app) => ({ - label: app.name, - value: app.name, - icon: app.appearance.iconUrl, - url: app.behaviour.externalUrl ?? app.url, - })); - const AutoCompleteItem = forwardRef( - ({ label, value, icon, url, ...others }: any, ref) => ( -
- -
+ const apps = useConfigApps(search); + const engines = generateEngines( + search, + userWithSettings?.settings.searchTemplate ?? 'https://www.google.com/search?q=%s' + ) + .filter( + (engine) => + engine.sort !== 'movie' || config?.apps.some((app) => app.integration.type === engine.value) ) - ); - useEffect(() => { - // Refresh the default search engine every time the config for it changes #521 - setSearchEngine(searchEnginesList[0]); - }, [searchEngineUrl]); - const textInput = useRef(null); - useHotkeys([['mod+K', () => textInput.current?.focus()]]); - const { classes } = useStyles(); - const openTarget = getOpenTarget(config); - const [opened, setOpened] = useState(false); + .map((engine) => ({ + ...engine, + label: t(`search.engines.${engine.sort}`, { + app: engine.value, + query: search, + }), + })); + const data = [...apps, ...engines]; - const isOverseerrSearchEnabled = - isOverseerrEnabled === true && - selectedSearchEngine.value === 'overseerr' && - debounced.length > 3; - - const { results: overseerrResults } = useOverseerrSearchQuery(debounced, isOverseerrSearchEnabled).data?? []; - - const isModuleEnabled = config?.settings.customization.layout.enabledSearchbar; - if (!isModuleEnabled) { - return null; - } - - //TODO: Fix the bug where clicking anything inside the Modal to ask for a movie - // will close it (Because it closes the underlying Popover) return ( - - 0 && opened && searchQuery.length > 3) ?? - false - } - position="bottom" - withinPortal - shadow="md" - radius="md" - zIndex={100} - > - - setOpened(true)} - autoFocus={typeof window !== 'undefined' && window.innerWidth > 768} - rightSection={} - placeholder={t(`searchEngines.${selectedSearchEngine.value}.description`) ?? undefined} - value={searchQuery} - onChange={(currentString) => tryMatchSearchEngine(currentString, setSearchQuery)} - itemComponent={AutoCompleteItem} - data={autocompleteData} - onItemSubmit={(item) => { - setOpened(false); - if (item.url) { - setSearchQuery(''); - window.open(item.openedUrl ? item.openedUrl : item.url, openTarget); - } - }} - // Replace %s if it is in selectedSearchEngine.url with searchQuery, otherwise append searchQuery at the end of it - onKeyDown={(event) => { - if ( - event.key === 'Enter' && - searchQuery.length > 0 && - autocompleteData.length === 0 - ) { - if (selectedSearchEngine.url.includes('%s')) { - window.open(selectedSearchEngine.url.replace('%s', searchQuery), openTarget); - } else { - window.open(selectedSearchEngine.url + searchQuery, openTarget); - } - } - }} - classNames={{ - input: cx(cardClasses.card, 'dashboard-header-search-input'), - root: 'dashboard-header-search-root', - }} - radius="lg" - size="md" + <> + ref.current?.focus()} + color={colors.gray[5]} + size={16} + stroke={1.5} /> - - - - {overseerrResults && - overseerrResults.length > 0 && - searchQuery.length > 3 && - overseerrResults.slice(0, 4).map((result: any, index: number) => ( - - - {index < overseerrResults.length - 1 && index < 3 && ( - - )} - - ))} - - - - + } + limit={8} + value={search} + onChange={setSearch} + data={data} + itemComponent={SearchItemComponent} + filter={(value, item: SearchAutoCompleteItem) => + engines.some((engine) => engine.sort === item.sort) || + item.value.toLowerCase().includes(value.trim().toLowerCase()) + } + classNames={{ + input: 'dashboard-header-search-input', + root: 'dashboard-header-search-root', + }} + onItemSubmit={(item: SearchAutoCompleteItem) => { + setSearch(''); + if (item.sort === 'movie') { + const url = new URL(`${window.location.origin}${router.asPath}`); + url.searchParams.set('movie', 'true'); + url.searchParams.set('search', search); + url.searchParams.set('type', item.value); + router.push(url, undefined, { shallow: true }); + movieModal.open(); + return; + } + const target = userWithSettings?.settings.openSearchInNewTab ? '_blank' : '_self'; + window.open(item.metaData.url, target); + }} + aria-label={t('search.label') as string} + /> + { + movieModal.close(); + const url = new URL(`${window.location.origin}${router.asPath}`); + url.searchParams.delete('movie'); + url.searchParams.delete('search'); + url.searchParams.delete('type'); + router.push(url, undefined, { shallow: true }); + }} + /> + ); +}; - function tryMatchSearchEngine(query: string, setSearchQuery: (value: string) => void) { - const foundSearchEngine = searchEnginesList.find( - (engine) => query.includes(`!${engine.shortcut}`) && !engine.disabled - ); - if (foundSearchEngine) { - setSearchQuery(query.replace(`!${foundSearchEngine.shortcut}`, '')); - changeSearchEngine(foundSearchEngine); - } else { - setSearchQuery(query); - } - } +const SearchItemComponent = forwardRef( + ({ icon, label, value, sort, ...others }, ref) => { + let Icon = getItemComponent(icon); - function SearchModuleMenu() { return ( - - - {selectedSearchEngine.icon} - - - - {searchEnginesList.map((item) => ( - - !{item.shortcut}} - disabled={item.disabled} - onClick={() => { - changeSearchEngine(item); - }} - > - {item.label} - - - ))} - - - - {t('tip')} mod+k{' '} - - - - + + + {label} + ); } +); - function changeSearchEngine(item: ItemProps) { - setSearchEngine(item); - showNotification({ - radius: 'lg', - withCloseButton: false, - id: 'spotlight', - autoClose: 1000, - icon: {item.icon}, - message: t('switchedSearchEngine', { searchEngine: t(`searchEngines.${item.value}.name`) }), - }); - } -} - -const getOpenTarget = (config: ConfigType | undefined): '_blank' | '_self' => { - if (!config || config.settings.common.searchEngine.properties.openInNewTab === undefined) { - return '_blank'; +const getItemComponent = (icon: SearchAutoCompleteItem['icon']) => { + if (typeof icon !== 'string') { + return icon; } - return config.settings.common.searchEngine.properties.openInNewTab ? '_blank' : '_self'; -}; - -const useOverseerrSearchQuery = (query: string, isEnabled: boolean) => { - const { name: configName } = useConfigContext(); - return api.overseerr.all.useQuery( - { - query, - configName: configName!, - }, - { - enabled: isEnabled, - refetchOnWindowFocus: false, - refetchOnMount: false, - refetchInterval: false, - } + return (props: TablerIconsProps) => ( + ); }; + +const useConfigApps = (search: string) => { + const { config } = useConfigContext(); + return useMemo(() => { + if (search.trim().length === 0) return []; + const apps = config?.apps.filter((app) => + app.name.toLowerCase().includes(search.toLowerCase()) + ); + return ( + apps?.map((app) => ({ + icon: app.appearance.iconUrl, + label: app.name, + value: app.name, + sort: 'app', + metaData: { + url: app.behaviour.externalUrl, + }, + })) ?? [] + ); + }, [search, config]); +}; + +type SearchAutoCompleteItem = { + icon: ((props: TablerIconsProps) => ReactNode) | string; + value: string; +} & ( + | { + sort: 'web' | 'torrent' | 'youtube' | 'app'; + metaData: { + url: string; + }; + } + | { + sort: 'movie'; + } +); +const movieApps = ['overseerr', 'jellyseerr'] as const; +const generateEngines = (searchValue: string, webTemplate: string) => + searchValue.trim().length > 0 + ? ([ + { + icon: IconWorld, + value: `web`, + sort: 'web', + metaData: { + url: webTemplate.includes('%s') + ? webTemplate.replace('%s', searchValue) + : webTemplate + searchValue, + }, + }, + { + icon: IconDownload, + value: `torrent`, + sort: 'torrent', + metaData: { + url: `https://www.torrentdownloads.me/search/?search=${searchValue}`, + }, + }, + { + icon: IconBrandYoutube, + value: 'youtube', + sort: 'youtube', + metaData: { + url: `https://www.youtube.com/results?search_query=${searchValue}`, + }, + }, + ...movieApps.map( + (name) => + ({ + icon: IconMovie, + value: name, + sort: 'movie', + }) as const + ), + ] as const satisfies Readonly) + : []; diff --git a/src/components/layout/header/Search/MovieModal.tsx b/src/components/layout/header/Search/MovieModal.tsx new file mode 100644 index 000000000..2b043dd67 --- /dev/null +++ b/src/components/layout/header/Search/MovieModal.tsx @@ -0,0 +1,212 @@ +import { + Button, + Card, + Center, + Divider, + Grid, + Group, + Loader, + Image as MantineImage, + Modal, + ScrollArea, + Stack, + Text, + Title, +} from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { IconDownload, IconExternalLink, IconPlayerPlay } from '@tabler/icons-react'; +import { Trans, useTranslation } from 'next-i18next'; +import Image from 'next/image'; +import { useRouter } from 'next/router'; +import React, { useMemo } from 'react'; +import { z } from 'zod'; +import { availableIntegrations } from '~/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector'; +import { useConfigContext } from '~/config/provider'; +import { RequestModal } from '~/modules/overseerr/RequestModal'; +import { RouterOutputs, api } from '~/utils/api'; + +type MovieModalProps = { + opened: boolean; + closeModal: () => void; +}; + +const queryParamsSchema = z.object({ + movie: z.literal('true'), + search: z.string().nonempty(), + type: z.enum(['jellyseerr', 'overseerr']), +}); + +export const MovieModal = ({ opened, closeModal }: MovieModalProps) => { + const query = useRouter().query; + const queryParams = queryParamsSchema.safeParse(query); + + if (!queryParams.success) { + return null; + } + + const integration = useMemo(() => { + return availableIntegrations.find((x) => x.value === queryParams.data.type)!; + }, [queryParams.data.type]); + + return ( + + {`${integration.label} + {integration.label} search +
+ } + > + + + ); +}; + +type MovieResultsProps = Omit, 'movie'>; + +const MovieResults = ({ search, type }: MovieResultsProps) => { + const { t } = useTranslation('layout/header'); + const { name: configName } = useConfigContext(); + const { data: movies, isLoading } = api.overseerr.search.useQuery( + { + query: search, + configName: configName!, + integration: type, + limit: 12, + }, + { + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchInterval: false, + } + ); + + if (isLoading) + return ( +
+ +
+ ); + + return ( + + + , + }} + /> + + + {movies?.map((result, index: number) => ( + + + + ))} + + + ); +}; + +type MovieDisplayProps = { + movie: RouterOutputs['overseerr']['search'][number]; + type: 'jellyseerr' | 'overseerr'; +}; + +const MovieDisplay = ({ movie, type }: MovieDisplayProps) => { + const { t } = useTranslation('modules/common-media-cards'); + const { config } = useConfigContext(); + const [requestModalOpened, requestModal] = useDisclosure(false); + + if (!config) { + return null; + } + + const service = config.apps.find((service) => service.integration.type === type); + const mediaUrl = movie.mediaInfo?.plexUrl ?? movie.mediaInfo?.mediaUrl; + const serviceUrl = service?.behaviour.externalUrl ? service.behaviour.externalUrl : service?.url; + const externalUrl = movie.mediaInfo?.serviceUrl; + + return ( + + + + + + + {movie.title ?? movie.name ?? movie.originalName} + + + {movie.overview} + + + + + {!movie.mediaInfo?.mediaAddedAt && ( + <> + + + + )} + {mediaUrl && ( + + )} + {serviceUrl && ( + + )} + + + + + ); +}; diff --git a/src/components/layout/header/SettingsMenu.tsx b/src/components/layout/header/SettingsMenu.tsx deleted file mode 100644 index 19b172a10..000000000 --- a/src/components/layout/header/SettingsMenu.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { Badge, Button, Menu } from '@mantine/core'; -import { useDisclosure } from '@mantine/hooks'; -import { IconInfoCircle, IconMenu2, IconSettings } from '@tabler/icons-react'; -import { useTranslation } from 'next-i18next'; - -import { useEditModeInformationStore } from '../../../hooks/useEditModeInformation'; -import { AboutModal } from '../../Dashboard/Modals/AboutModal/AboutModal'; -import { SettingsDrawer } from '../../Settings/SettingsDrawer'; -import { useCardStyles } from '../useCardStyles'; -import { ColorSchemeSwitch } from './SettingsMenu/ColorSchemeSwitch'; -import { EditModeToggle } from './SettingsMenu/EditModeToggle'; - -export function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: string }) { - const [drawerOpened, drawer] = useDisclosure(false); - const { t } = useTranslation('common'); - const [aboutModalOpened, aboutModal] = useDisclosure(false); - const { classes } = useCardStyles(true); - const { editModeEnabled } = useEditModeInformationStore(); - - return ( - <> - - - - - - - - - {editModeEnabled && ( - } onClick={drawer.open}> - {t('sections.settings')} - - )} - } - rightSection={ - newVersionAvailable && ( - - New - - ) - } - onClick={() => aboutModal.open()} - > - {t('about')} - - - - - - - ); -} diff --git a/src/components/layout/header/SettingsMenu/ColorSchemeSwitch.tsx b/src/components/layout/header/SettingsMenu/ColorSchemeSwitch.tsx deleted file mode 100644 index fb926496a..000000000 --- a/src/components/layout/header/SettingsMenu/ColorSchemeSwitch.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Menu, useMantineColorScheme } from '@mantine/core'; -import { IconMoonStars, IconSun } from '@tabler/icons-react'; -import { useTranslation } from 'next-i18next'; - -export const ColorSchemeSwitch = () => { - const { colorScheme, toggleColorScheme } = useMantineColorScheme(); - const { t } = useTranslation('settings/general/theme-selector'); - - const Icon = colorScheme === 'dark' ? IconSun : IconMoonStars; - - return ( - } - onClick={() => toggleColorScheme()} - > - {t('label', { - theme: colorScheme === 'dark' ? 'light' : 'dark', - })} - - ); -}; diff --git a/src/components/layout/header/SettingsMenu/EditModeToggle.tsx b/src/components/layout/header/SettingsMenu/EditModeToggle.tsx deleted file mode 100644 index ae44fa48a..000000000 --- a/src/components/layout/header/SettingsMenu/EditModeToggle.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { Button, Code, Menu, PasswordInput, Stack, Text } from '@mantine/core'; -import { useForm } from '@mantine/form'; -import { openModal } from '@mantine/modals'; -import { showNotification } from '@mantine/notifications'; -import { IconEdit, IconEditOff } from '@tabler/icons-react'; -import axios from 'axios'; -import { Trans, useTranslation } from 'next-i18next'; - -import { useEditModeInformationStore } from '../../../../hooks/useEditModeInformation'; - -function ModalContent() { - const { t } = useTranslation('settings/general/edit-mode-toggle'); - const form = useForm({ - initialValues: { - triedPassword: '', - }, - }); - return ( -
{ - axios - .post('/api/configs/tryPassword', { tried: values.triedPassword, type: 'edit' }) - .then((res) => { - showNotification({ - title: t('notification.success.title'), - message: t('notification.success.message'), - color: 'green', - }); - setTimeout(() => { - window.location.reload(); - }, 500); - }) - .catch((_) => { - showNotification({ - title: t('notification.error.title'), - message: t('notification.error.message'), - color: 'red', - }); - }); - })} - > - - - }} - /> - - - - -
- ); -} - -export function EditModeToggle() { - const { editModeEnabled } = useEditModeInformationStore(); - const Icon = editModeEnabled ? IconEditOff : IconEdit; - const { t } = useTranslation('settings/general/edit-mode-toggle'); - - return ( - } - onClick={() => - openModal({ - title: t('menu.toggle'), - centered: true, - size: 'lg', - children: , - }) - } - > - {editModeEnabled ? t('menu.disable') : t('menu.enable')} - - ); -} diff --git a/src/components/layout/header/SmallAppItem.tsx b/src/components/layout/header/SmallAppItem.tsx deleted file mode 100644 index 02aeed486..000000000 --- a/src/components/layout/header/SmallAppItem.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Avatar, Group, Text } from '@mantine/core'; - -interface smallAppItem { - label: string; - icon?: string; - url?: string; -} - -export default function SmallAppItem(props: any) { - const { app }: { app: smallAppItem } = props; - return ( - - {app.icon && } - {app.label} - - ); -} diff --git a/src/config/init.ts b/src/config/init.ts index c2d98829c..05af7e514 100644 --- a/src/config/init.ts +++ b/src/config/init.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react'; -import { ConfigType } from '../types/config'; +import { ConfigType } from '~/types/config'; import { useConfigContext } from './provider'; import { useConfigStore } from './store'; @@ -8,9 +8,13 @@ export const useInitConfig = (initialConfig: ConfigType) => { const { setConfigName, increaseVersion } = useConfigContext(); const configName = initialConfig.configProperties?.name ?? 'default'; const initConfig = useConfigStore((x) => x.initConfig); + const removeConfig = useConfigStore((x) => x.removeConfig); useEffect(() => { setConfigName(configName); initConfig(configName, initialConfig, increaseVersion); + return () => { + removeConfig(configName); + }; }, [configName]); }; diff --git a/src/config/provider.tsx b/src/config/provider.tsx index 7c53308bb..f6c3af58c 100644 --- a/src/config/provider.tsx +++ b/src/config/provider.tsx @@ -1,8 +1,8 @@ import { ReactNode, createContext, useContext, useEffect, useState } from 'react'; import { shallow } from 'zustand/shallow'; -import { useColorTheme } from '../tools/color'; -import { ConfigType } from '../types/config'; +import { useColorTheme } from '~/tools/color'; +import { ConfigType } from '~/types/config'; import { useConfigStore } from './store'; export type ConfigContextType = { @@ -24,18 +24,18 @@ const ConfigContext = createContext({ export const ConfigProvider = ({ children, config: fallbackConfig, - configName: initialConfigName, }: { children: ReactNode; config?: ConfigType; - configName?: string; }) => { - const [configName, setConfigName] = useState(initialConfigName || 'default'); + const [configName, setConfigName] = useState( + fallbackConfig?.configProperties.name || 'unknown' + ); const [configVersion, setConfigVersion] = useState(0); const { configs } = useConfigStore((s) => ({ configs: s.configs }), shallow); - const { setPrimaryColor, setSecondaryColor, setPrimaryShade } = useColorTheme(); const currentConfig = configs.find((c) => c.value.configProperties.name === configName)?.value; + const { setPrimaryColor, setSecondaryColor, setPrimaryShade } = useColorTheme(); useEffect(() => { setPrimaryColor(currentConfig?.settings.customization.colors.primary || 'red'); diff --git a/src/config/store.ts b/src/config/store.ts index 6fa0611af..e03bbfe69 100644 --- a/src/config/store.ts +++ b/src/config/store.ts @@ -1,74 +1,77 @@ -import { create } from 'zustand'; +import { createWithEqualityFn } from 'zustand/traditional'; import { trcpProxyClient } from '~/utils/api'; -import { ConfigType } from '../types/config'; +import { ConfigType } from '~/types/config'; -export const useConfigStore = create((set, get) => ({ - configs: [], - initConfig: (name, config, increaseVersion) => { - set((old) => ({ - ...old, - configs: [ - ...old.configs.filter((x) => x.value.configProperties?.name !== name), - { increaseVersion, value: config }, - ], - })); - }, - addConfig: async (name: string, config: ConfigType) => { - set((old) => ({ - ...old, - configs: [ - ...old.configs.filter((x) => x.value.configProperties.name !== name), - { value: config, increaseVersion: () => {} }, - ], - })); - }, - removeConfig: (name: string) => { - set((old) => ({ - ...old, - configs: old.configs.filter((x) => x.value.configProperties.name !== name), - })); - }, - updateConfig: async ( - name, - updateCallback: (previous: ConfigType) => ConfigType, - shouldRegenerateGridstack = false, - shouldSaveConfigToFileSystem = false - ) => { - const { configs } = get(); - const currentConfig = configs.find((x) => x.value.configProperties.name === name); - if (!currentConfig) { - return; - } - // copies the value of currentConfig and creates a non reference object named previousConfig - const previousConfig: ConfigType = JSON.parse(JSON.stringify(currentConfig.value)); +export const useConfigStore = createWithEqualityFn( + (set, get) => ({ + configs: [], + initConfig: (name, config, increaseVersion) => { + set((old) => ({ + ...old, + configs: [ + ...old.configs.filter((x) => x.value.configProperties?.name !== name), + { increaseVersion, value: config }, + ], + })); + }, + addConfig: async (name: string, config: ConfigType) => { + set((old) => ({ + ...old, + configs: [ + ...old.configs.filter((x) => x.value.configProperties.name !== name), + { value: config, increaseVersion: () => {} }, + ], + })); + }, + removeConfig: (name: string) => { + set((old) => ({ + ...old, + configs: old.configs.filter((x) => x.value.configProperties.name !== name), + })); + }, + updateConfig: async ( + name, + updateCallback: (previous: ConfigType) => ConfigType, + shouldRegenerateGridstack = false, + shouldSaveConfigToFileSystem = false + ) => { + const { configs } = get(); + const currentConfig = configs.find((x) => x.value.configProperties.name === name); + if (!currentConfig) { + return; + } + // copies the value of currentConfig and creates a non reference object named previousConfig + const previousConfig: ConfigType = JSON.parse(JSON.stringify(currentConfig.value)); - const updatedConfig = updateCallback(currentConfig.value); + const updatedConfig = updateCallback(currentConfig.value); - set((old) => ({ - ...old, - configs: [ - ...old.configs.filter((x) => x.value.configProperties.name !== name), - { value: updatedConfig, increaseVersion: currentConfig.increaseVersion }, - ], - })); + set((old) => ({ + ...old, + configs: [ + ...old.configs.filter((x) => x.value.configProperties.name !== name), + { value: updatedConfig, increaseVersion: currentConfig.increaseVersion }, + ], + })); - if ( - (typeof shouldRegenerateGridstack === 'boolean' && shouldRegenerateGridstack) || - (typeof shouldRegenerateGridstack === 'function' && - shouldRegenerateGridstack(previousConfig, updatedConfig)) - ) { - currentConfig.increaseVersion(); - } + if ( + (typeof shouldRegenerateGridstack === 'boolean' && shouldRegenerateGridstack) || + (typeof shouldRegenerateGridstack === 'function' && + shouldRegenerateGridstack(previousConfig, updatedConfig)) + ) { + currentConfig.increaseVersion(); + } - if (shouldSaveConfigToFileSystem) { - trcpProxyClient.config.save.mutate({ - name, - config: updatedConfig, - }); - } - }, -})); + if (shouldSaveConfigToFileSystem) { + trcpProxyClient.config.save.mutate({ + name, + config: updatedConfig, + }); + } + }, + }), + Object.is +); interface UseConfigStoreType { configs: { increaseVersion: () => void; value: ConfigType }[]; diff --git a/src/env.js b/src/env.js new file mode 100644 index 000000000..590fd8200 --- /dev/null +++ b/src/env.js @@ -0,0 +1,66 @@ +const { z } = require('zod'); +const { createEnv } = require('@t3-oss/env-nextjs'); + +const portSchema = z.string().regex(/\d*/).transform((value) => value === undefined ? undefined : Number(value)).optional(); +const envSchema = z.enum(['development', 'test', 'production']); + +const env = createEnv({ + /** + * Specify your server-side environment variables schema here. This way you can ensure the app + * isn't built with invalid env vars. + */ + server: { + DATABASE_URL: z.string().url().default('file:../database/db.sqlite'), + NEXTAUTH_SECRET: + process.env.NODE_ENV === 'production' ? z.string().min(1) : z.string().min(1).optional(), + NEXTAUTH_URL: z.preprocess( + // This makes Vercel deployments not fail if you don't set NEXTAUTH_URL + // Since NextAuth.js automatically uses the VERCEL_URL if present. + (str) => process.env.VERCEL_URL ?? str, + // VERCEL_URL doesn't include `https` so it cant be validated as a URL + process.env.VERCEL ? z.string().min(1) : z.string().url() + ), + DOCKER_HOST: z.string().optional(), + DOCKER_PORT: portSchema, + HOSTNAME: z.string().optional() + }, + + /** + * Specify your client-side environment variables schema here. This way you can ensure the app + * isn't built with invalid env vars. To expose them to the client, prefix them with + * `NEXT_PUBLIC_`. + */ + client: { + // NEXT_PUBLIC_CLIENTVAR: z.string().min(1), + NEXT_PUBLIC_PORT: portSchema, + NEXT_PUBLIC_NODE_ENV: envSchema, + NEXT_PUBLIC_DEFAULT_COLOR_SCHEME: z + .string() + .toLowerCase() + .refine((s) => s === 'light' || s === 'dark') + .optional() + .default('light'), + NEXT_PUBLIC_DOCKER_HOST: z.string().optional(), + }, + + /** + * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g. + * middlewares) or client-side so we need to destruct manually. + */ + runtimeEnv: { + DATABASE_URL: process.env.DATABASE_URL, + NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, + NEXTAUTH_URL: process.env.NEXTAUTH_URL, + DOCKER_HOST: process.env.DOCKER_HOST, + DOCKER_PORT: process.env.DOCKER_PORT, + VERCEL_URL: process.env.VERCEL_URL, + NEXT_PUBLIC_DEFAULT_COLOR_SCHEME: process.env.DEFAULT_COLOR_SCHEME, + NEXT_PUBLIC_PORT: process.env.PORT, + NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV, + HOSTNAME: process.env.HOSTNAME + }, +}); + +module.exports = { + env, +}; diff --git a/src/hooks/use-colorscheme.tsx b/src/hooks/use-colorscheme.tsx new file mode 100644 index 000000000..c766a7bbb --- /dev/null +++ b/src/hooks/use-colorscheme.tsx @@ -0,0 +1,70 @@ +import { ColorScheme as MantineColorScheme } from '@mantine/core'; +import { useHotkeys } from '@mantine/hooks'; +import { setCookie } from 'cookies-next'; +import { Session } from 'next-auth'; +import { createContext, useContext, useState } from 'react'; +import { api } from '~/utils/api'; + +import { COOKIE_COLOR_SCHEME_KEY } from '../../data/constants'; + +export type ColorScheme = 'dark' | 'light' | 'environment'; + +export const ColorSchemeContext = createContext<{ + colorScheme: MantineColorScheme; + settings: ColorScheme; + toggleColorScheme: () => Promise; + setColorScheme: (colorScheme: ColorScheme) => void; +} | null>(null); + +type ColorSchemeProviderProps = { + activeColorScheme: ColorScheme; + environmentColorScheme: MantineColorScheme; + session: Session; + children: (colorScheme: MantineColorScheme) => React.ReactNode; +}; + +export const ColorSchemeProvider = ({ + activeColorScheme, + environmentColorScheme, + session, + children, +}: ColorSchemeProviderProps) => { + const [colorScheme, setColorScheme] = useState(activeColorScheme); + const { mutateAsync } = api.user.changeColorScheme.useMutation(); + + const toggleColorScheme = async () => { + const newColorScheme = colorScheme === 'dark' ? 'light' : 'dark'; + setColorScheme(newColorScheme); + setCookie(COOKIE_COLOR_SCHEME_KEY, newColorScheme); + if (session && new Date(session.expires) > new Date()) { + await mutateAsync({ colorScheme: newColorScheme }); + } + }; + + const changeColorScheme = (colorScheme: ColorScheme) => setColorScheme(colorScheme); + + useHotkeys([['mod+J', () => void toggleColorScheme()]]); + + const mantineColorScheme = colorScheme === 'environment' ? environmentColorScheme : colorScheme; + + return ( + + {children(mantineColorScheme)} + + ); +}; + +export const useColorScheme = () => { + const context = useContext(ColorSchemeContext); + if (!context) { + throw new Error('useColorScheme must be used within a ColorSchemeProvider'); + } + return context; +}; diff --git a/src/hooks/useEditModeInformation.ts b/src/hooks/useEditModeInformation.ts deleted file mode 100644 index 9d4af5aca..000000000 --- a/src/hooks/useEditModeInformation.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { create } from 'zustand'; - -interface EditModeInformationStore { - editModeEnabled: boolean; - setEnabled: () => void; -} - -export const useEditModeInformationStore = create((set) => ({ - editModeEnabled: false, - setEnabled: () => set(() => ({ editModeEnabled: true })), -})); diff --git a/src/middleware.ts b/src/middleware.ts index 704d76bcd..fe1b18311 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,39 +1,46 @@ import { NextRequest, NextResponse } from 'next/server'; +import { env } from 'process'; -export function middleware(req: NextRequest) { - const { cookies } = req; +import { getUrl } from './tools/server/url'; +import { client } from './utils/api'; - // Don't even bother with the middleware if there is no defined password - if (!process.env.PASSWORD) return NextResponse.next(); +const skippedUrls = [ + '/onboard', + '/api/', + '/_next/', + '/favicon.ico', + '/404', + '/pages/_app', + '/imgs/', +]; +let cachedUserCount = 0; + +export async function middleware(req: NextRequest) { const url = req.nextUrl.clone(); - const passwordCookie = cookies.get('password')?.value; - const isCorrectPassword = passwordCookie?.toString() === process.env.PASSWORD; - // Skip the middleware if the URL is 'login', 'api/configs/tryPassword', '_next/*', 'favicon.ico', '404', 'migrate' or 'pages/_app' - const skippedUrls = [ - '/login', - '/api/configs/tryPassword', - '/_next/', - '/favicon.ico', - '/404', - '/migrate', - '/pages/_app', - ]; + // Do not redirect if the url is in the skippedUrls array if (skippedUrls.some((skippedUrl) => url.pathname.startsWith(skippedUrl))) { return NextResponse.next(); } - // If the password is not correct, redirect to the login page - if (!isCorrectPassword && process.env.PASSWORD) { - url.pathname = '/login'; - /*//--- nextjs doesn't use X-Forwarded yet, if we need to update the dependency, add this code - url.host = req.headers.get('X-Forwarded-Host')?? url.host; - url.port = req.headers.get('X-Forwarded-Port')?? url.port; - url.protocol = req.headers.get('X-Forwarded-Proto')?? url.protocol; - //---*/ - - return NextResponse.redirect(url); + // Do not redirect if we are on Vercel + if (env.VERCEL) { + return NextResponse.next(); } - return NextResponse.next(); + + // Do not redirect if there are users in the database + if (cachedUserCount > 0) { + return NextResponse.next(); + } + + // is only called from when there were no users in the database in this session (Since the app started) + cachedUserCount = await client.user.count.query(); + + // Do not redirect if there are users in the database + if (cachedUserCount > 0) { + return NextResponse.next(); + } + + return NextResponse.redirect(getUrl(req) + '/onboard'); } diff --git a/src/modals.ts b/src/modals.ts new file mode 100644 index 000000000..4faea9bd3 --- /dev/null +++ b/src/modals.ts @@ -0,0 +1,40 @@ +import { ChangeAppPositionModal } from '~/components/Dashboard/Modals/ChangePosition/ChangeAppPositionModal'; +import { ChangeWidgetPositionModal } from '~/components/Dashboard/Modals/ChangePosition/ChangeWidgetPositionModal'; +import { EditAppModal } from '~/components/Dashboard/Modals/EditAppModal/EditAppModal'; +import { SelectElementModal } from '~/components/Dashboard/Modals/SelectElement/SelectElementModal'; +import { WidgetsEditModal } from '~/components/Dashboard/Tiles/Widgets/WidgetsEditModal'; +import { WidgetsRemoveModal } from '~/components/Dashboard/Tiles/Widgets/WidgetsRemoveModal'; +import { CategoryEditModal } from '~/components/Dashboard/Wrappers/Category/CategoryEditModal'; + +import { CreateBoardModal } from './components/Manage/Board/create-board.modal'; +import { DeleteBoardModal } from './components/Manage/Board/delete-board.modal'; +import { DockerSelectBoardModal } from './components/Manage/Tools/Docker/docker-select-board.modal'; +import { CopyInviteModal } from './components/Manage/User/Invite/copy-invite.modal'; +import { CreateInviteModal } from './components/Manage/User/Invite/create-invite.modal'; +import { DeleteInviteModal } from './components/Manage/User/Invite/delete-invite.modal'; +import { ChangeUserRoleModal } from './components/Manage/User/change-user-role.modal'; +import { DeleteUserModal } from './components/Manage/User/delete-user.modal'; + +export const modals = { + editApp: EditAppModal, + selectElement: SelectElementModal, + integrationOptions: WidgetsEditModal, + integrationRemove: WidgetsRemoveModal, + categoryEditModal: CategoryEditModal, + changeAppPositionModal: ChangeAppPositionModal, + changeIntegrationPositionModal: ChangeWidgetPositionModal, + deleteUserModal: DeleteUserModal, + createInviteModal: CreateInviteModal, + deleteInviteModal: DeleteInviteModal, + createBoardModal: CreateBoardModal, + copyInviteModal: CopyInviteModal, + deleteBoardModal: DeleteBoardModal, + changeUserRoleModal: ChangeUserRoleModal, + dockerSelectBoardModal: DockerSelectBoardModal, +}; + +declare module '@mantine/modals' { + export interface MantineModalsOverride { + modals: typeof modals; + } +} diff --git a/src/modules/Docker/DockerModule.tsx b/src/modules/Docker/DockerModule.tsx deleted file mode 100644 index 802b720f5..000000000 --- a/src/modules/Docker/DockerModule.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { ActionIcon, Drawer, Tooltip } from '@mantine/core'; -import { useHotkeys } from '@mantine/hooks'; -import { IconBrandDocker } from '@tabler/icons-react'; -import Docker from 'dockerode'; -import { useTranslation } from 'next-i18next'; -import { useState } from 'react'; -import { api } from '~/utils/api'; - -import { useCardStyles } from '../../components/layout/useCardStyles'; -import { useConfigContext } from '../../config/provider'; -import ContainerActionBar from './ContainerActionBar'; -import DockerTable from './DockerTable'; - -export default function DockerMenuButton(props: any) { - const [opened, setOpened] = useState(false); - const [selection, setSelection] = useState([]); - const { config } = useConfigContext(); - const { classes } = useCardStyles(true); - - const dockerEnabled = config?.settings.customization.layout.enabledDocker || false; - - const { data, refetch } = api.docker.containers.useQuery(undefined, { - enabled: dockerEnabled, - }); - useHotkeys([['mod+B', () => setOpened(!opened)]]); - - const { t } = useTranslation('modules/docker'); - - const reload = () => { - refetch(); - setSelection([]); - }; - - if (!dockerEnabled) return null; - - return ( - <> - setOpened(false)} - padding="xl" - position="right" - size="100%" - title={} - transitionProps={{ - transition: 'pop', - }} - styles={{ - content: { - display: 'flex', - flexDirection: 'column', - }, - body: { - minHeight: 0, - }, - }} - > - - - - setOpened(true)} - > - - - - - ); -} diff --git a/src/modules/Docker/DockerTable.tsx b/src/modules/Docker/DockerTable.tsx deleted file mode 100644 index 19d8ba0be..000000000 --- a/src/modules/Docker/DockerTable.tsx +++ /dev/null @@ -1,154 +0,0 @@ -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 from 'dockerode'; -import { useTranslation } from 'next-i18next'; -import { useEffect, 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 DockerTable({ - containers, - selection, - setSelection, -}: { - setSelection: any; - containers: Dockerode.ContainerInfo[]; - selection: Dockerode.ContainerInfo[]; -}) { - const [usedContainers, setContainers] = useState(containers); - const { classes, cx } = useStyles(); - const [search, setSearch] = useState(''); - const { ref, width, height } = useElementSize(); - - const { t } = useTranslation('modules/docker'); - - useEffect(() => { - setContainers(containers); - }, [containers]); - - const handleSearchChange = (event: React.ChangeEvent) => { - const { value } = event.currentTarget; - setSearch(value); - setContainers(filterContainers(containers, value)); - }; - - 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)) - ); - } - - const toggleRow = (container: Dockerode.ContainerInfo) => - setSelection((current: Dockerode.ContainerInfo[]) => - current.includes(container) ? current.filter((c) => c !== container) : [...current, container] - ); - const toggleAll = () => - setSelection((current: any) => - current.length === usedContainers.length ? [] : usedContainers.map((c) => c) - ); - - const rows = usedContainers.map((element) => { - const selected = selection.includes(element); - return ( - - - toggleRow(element)} - transitionDuration={0} - /> - - - - {element.Names[0].replace('/', '')} - - - {width > MIN_WIDTH_MOBILE && ( - - {element.Image} - - )} - {width > MIN_WIDTH_MOBILE && ( - - - {element.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) => ( - - {port.PrivatePort}:{port.PublicPort} - - ))} - {element.Ports.length > 3 && ( - - {t('table.body.portCollapse', { ports: element.Ports.length - 3 })} - - )} - - - )} - - - - - ); - }); - - return ( - - } - value={search} - autoFocus - onChange={handleSearchChange} - /> - - - - - - {width > MIN_WIDTH_MOBILE ? : null} - {width > MIN_WIDTH_MOBILE ? : null} - - - - {rows} -
- 0} - indeterminate={selection.length > 0 && selection.length !== usedContainers.length} - transitionDuration={0} - disabled={usedContainers.length === 0} - /> - {t('table.header.name')}{t('table.header.image')}{t('table.header.ports')}{t('table.header.state')}
-
- ); -} diff --git a/src/modules/common/MediaDisplay.tsx b/src/modules/common/MediaDisplay.tsx index e191286d6..b873455c5 100644 --- a/src/modules/common/MediaDisplay.tsx +++ b/src/modules/common/MediaDisplay.tsx @@ -3,8 +3,8 @@ import { IconDownload, IconExternalLink, IconPlayerPlay } from '@tabler/icons-re import { useTranslation } from 'next-i18next'; import { useState } from 'react'; -import { useConfigContext } from '../../config/provider'; -import { useColorTheme } from '../../tools/color'; +import { useConfigContext } from '~/config/provider'; +import { useColorTheme } from '~/tools/color'; import { RequestModal } from '../overseerr/RequestModal'; import { Result } from '../overseerr/SearchResult'; diff --git a/src/modules/index.ts b/src/modules/index.ts deleted file mode 100644 index 54bebdbc1..000000000 --- a/src/modules/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './overseerr'; diff --git a/src/modules/overseerr/OverseerrModule.tsx b/src/modules/overseerr/OverseerrModule.tsx deleted file mode 100644 index 9e466ce52..000000000 --- a/src/modules/overseerr/OverseerrModule.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { IconEyeglass } from '@tabler/icons-react'; - -import { IModule } from '../ModuleTypes'; -import { OverseerrMediaDisplay } from '../common'; - -export const OverseerrModule: IModule = { - title: 'Overseerr', - icon: IconEyeglass, - component: OverseerrMediaDisplay, - id: 'overseerr', -}; - -export interface OverseerSearchProps { - query: string; -} diff --git a/src/modules/overseerr/RequestModal.tsx b/src/modules/overseerr/RequestModal.tsx index 523263cd8..d1c2097ef 100644 --- a/src/modules/overseerr/RequestModal.tsx +++ b/src/modules/overseerr/RequestModal.tsx @@ -7,9 +7,9 @@ import { useState } from 'react'; import { useConfigContext } from '~/config/provider'; import { api } from '~/utils/api'; -import { useColorTheme } from '../../tools/color'; +import { useColorTheme } from '~/tools/color'; import { MovieResult } from './Movie.d'; -import { Result } from './SearchResult.d'; +import { Result } from './SearchResult'; import { TvShowResult, TvShowResultSeason } from './TvShow.d'; interface RequestModalProps { @@ -70,7 +70,7 @@ export function MovieRequestModal({ radius="lg" size="lg" trapFocus - zIndex={150} + zIndex={250} withinPortal opened={opened} title={ @@ -155,6 +155,7 @@ export function TvRequestModal({ onClose={() => setOpened(false)} radius="lg" size="lg" + zIndex={250} opened={opened} title={ diff --git a/src/modules/overseerr/SearchResult.d.ts b/src/modules/overseerr/SearchResult.ts similarity index 100% rename from src/modules/overseerr/SearchResult.d.ts rename to src/modules/overseerr/SearchResult.ts diff --git a/src/modules/overseerr/index.ts b/src/modules/overseerr/index.ts deleted file mode 100644 index bc20880a5..000000000 --- a/src/modules/overseerr/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { OverseerrModule } from './OverseerrModule'; diff --git a/src/pages/404.tsx b/src/pages/404.tsx index e0afe4192..c6cb0b8e7 100644 --- a/src/pages/404.tsx +++ b/src/pages/404.tsx @@ -1,13 +1,13 @@ import { Button, Center, Stack, Text, Title, createStyles } from '@mantine/core'; import { GetServerSidePropsContext } from 'next'; +import { useTranslation } from 'next-i18next'; import Head from 'next/head'; import Image from 'next/image'; import Link from 'next/link'; -import { useTranslation } from 'next-i18next'; import pageNotFoundImage from '~/images/undraw_page_not_found_re_e9o6.svg'; import { pageNotFoundNamespaces } from '~/tools/server/translation-namespaces'; -import { getServerSideTranslations } from '../tools/server/getServerSideTranslations'; +import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; export default function Custom404() { const { classes } = useStyles(); @@ -24,7 +24,7 @@ export default function Custom404() { {t('title')} {t('text')} - diff --git a/src/pages/[slug].tsx b/src/pages/[slug].tsx deleted file mode 100644 index 4693f9175..000000000 --- a/src/pages/[slug].tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { setCookie } from 'cookies-next'; -import fs from 'fs'; -import { GetServerSidePropsContext } from 'next'; -import path from 'path'; - -import { LoadConfigComponent } from '../components/Config/LoadConfig'; -import { Dashboard } from '../components/Dashboard/Dashboard'; -import Layout from '../components/layout/Layout'; -import { useInitConfig } from '../config/init'; -import { getFallbackConfig } from '../tools/config/getFallbackConfig'; -import { getFrontendConfig } from '../tools/config/getFrontendConfig'; -import { getServerSideTranslations } from '../tools/server/getServerSideTranslations'; -import { dashboardNamespaces } from '../tools/server/translation-namespaces'; -import { ConfigType } from '../types/config'; -import { DashboardServerSideProps } from '../types/dashboardPageType'; - -export async function getServerSideProps({ - req, - res, - locale, - query, -}: GetServerSidePropsContext): Promise<{ props: DashboardServerSideProps }> { - const configName = query.slug as string; - const configPath = path.join(process.cwd(), 'data/configs', `${configName}.json`); - const configExists = fs.existsSync(configPath); - - const translations = await getServerSideTranslations(dashboardNamespaces, locale, req, res); - - if (!configExists) { - // Redirect to 404 - res.writeHead(301, { Location: '/404' }); - res.end(); - return { - props: { - config: getFallbackConfig() as unknown as ConfigType, - configName, - ...translations, - }, - }; - } - - const config = await getFrontendConfig(configName as string); - setCookie('config-name', configName, { - req, - res, - maxAge: 60 * 60 * 24 * 30, - sameSite: 'strict', - }); - - return { - props: { - configName, - config, - ...translations, - }, - }; -} - -export default function HomePage({ config: initialConfig }: DashboardServerSideProps) { - useInitConfig(initialConfig); - - return ( - - - - - ); -} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 3ba99b43e..566e02054 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,45 +1,39 @@ -import { ColorScheme, ColorSchemeProvider, MantineProvider, MantineTheme } from '@mantine/core'; -import { useColorScheme, useHotkeys, useLocalStorage } from '@mantine/hooks'; +import { ColorScheme as MantineColorScheme, MantineProvider, MantineTheme } from '@mantine/core'; import { ModalsProvider } from '@mantine/modals'; import { Notifications } from '@mantine/notifications'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; -import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; import Consola from 'consola'; -import { getCookie } from 'cookies-next'; +import { getCookie, setCookie } from 'cookies-next'; import dayjs from 'dayjs'; -import locale from 'dayjs/plugin/localeData' -import utc from 'dayjs/plugin/utc' +import locale from 'dayjs/plugin/localeData'; +import utc from 'dayjs/plugin/utc'; +import 'flag-icons/css/flag-icons.min.css'; import { GetServerSidePropsContext } from 'next'; +import { Session } from 'next-auth'; +import { SessionProvider, getSession } from 'next-auth/react'; import { appWithTranslation } from 'next-i18next'; import { AppProps } from 'next/app'; -import Head from 'next/head'; import { useEffect, useState } from 'react'; import 'video.js/dist/video-js.css'; +import { CommonHead } from '~/components/layout/Meta/CommonHead'; +import { env } from '~/env.js'; +import { ColorSchemeProvider } from '~/hooks/use-colorscheme'; +import { modals } from '~/modals'; import { getLanguageByCode } from '~/tools/language'; import { ConfigType } from '~/types/config'; import { api } from '~/utils/api'; +import { colorSchemeParser } from '~/validations/user'; -import nextI18nextConfig from '../../next-i18next.config'; -import { ChangeAppPositionModal } from '../components/Dashboard/Modals/ChangePosition/ChangeAppPositionModal'; -import { ChangeWidgetPositionModal } from '../components/Dashboard/Modals/ChangePosition/ChangeWidgetPositionModal'; -import { EditAppModal } from '../components/Dashboard/Modals/EditAppModal/EditAppModal'; -import { SelectElementModal } from '../components/Dashboard/Modals/SelectElement/SelectElementModal'; -import { WidgetsEditModal } from '../components/Dashboard/Tiles/Widgets/WidgetsEditModal'; -import { WidgetsRemoveModal } from '../components/Dashboard/Tiles/Widgets/WidgetsRemoveModal'; -import { CategoryEditModal } from '../components/Dashboard/Wrappers/Category/CategoryEditModal'; -import { ConfigProvider } from '../config/provider'; -import { useEditModeInformationStore } from '../hooks/useEditModeInformation'; +import { COOKIE_COLOR_SCHEME_KEY, COOKIE_LOCALE_KEY } from '../../data/constants'; +import nextI18nextConfig from '../../next-i18next.config.js'; +import { ConfigProvider } from '~/config/provider'; import '../styles/global.scss'; -import { usePackageAttributesStore } from '../tools/client/zustands/usePackageAttributesStore'; -import { ColorTheme } from '../tools/color'; -import { queryClient } from '../tools/server/configurations/tanstack/queryClient.tool'; +import { ColorTheme } from '~/tools/color'; import { ServerSidePackageAttributesType, getServiceSidePackageAttributes, -} from '../tools/server/getPackageVersion'; -import { theme } from '../tools/server/theme/theme'; +} from '~/tools/server/getPackageVersion'; +import { theme } from '~/tools/server/theme/theme'; dayjs.extend(locale); dayjs.extend(utc); @@ -47,29 +41,33 @@ dayjs.extend(utc); function App( this: any, props: AppProps<{ - colorScheme: ColorScheme; + activeColorScheme: MantineColorScheme; + environmentColorScheme: MantineColorScheme; packageAttributes: ServerSidePackageAttributesType; editModeEnabled: boolean; - defaultColorScheme: ColorScheme; config?: ConfigType; + primaryColor?: MantineTheme['primaryColor']; + secondaryColor?: MantineTheme['primaryColor']; + primaryShade?: MantineTheme['primaryShade']; + session: Session; configName?: string; locale: string; }> ) { const { Component, pageProps } = props; // TODO: make mapping from our locales to moment locales - const language = getLanguageByCode(pageProps.locale); + const language = getLanguageByCode(pageProps.session?.user?.language ?? 'en'); require(`dayjs/locale/${language.locale}.js`); dayjs.locale(language.locale); const [primaryColor, setPrimaryColor] = useState( - props.pageProps.config?.settings.customization.colors.primary || 'red' + props.pageProps.primaryColor ?? 'red' ); const [secondaryColor, setSecondaryColor] = useState( - props.pageProps.config?.settings.customization.colors.secondary || 'orange' + props.pageProps.secondaryColor ?? 'orange' ); const [primaryShade, setPrimaryShade] = useState( - props.pageProps.config?.settings.customization.colors.shade || 6 + props.pageProps.primaryShade ?? 6 ); const colorTheme = { primaryColor, @@ -80,113 +78,105 @@ function App( setPrimaryShade, }; - // hook will return either 'dark' or 'light' on client - // and always 'light' during ssr as window.matchMedia is not available - const preferredColorScheme = useColorScheme(props.pageProps.defaultColorScheme); - const [colorScheme, setColorScheme] = useLocalStorage({ - key: 'mantine-color-scheme', - defaultValue: preferredColorScheme, - getInitialValueInEffect: true, - }); - - const { setInitialPackageAttributes } = usePackageAttributesStore(); - const { setEnabled } = useEditModeInformationStore(); - useEffect(() => { - setInitialPackageAttributes(props.pageProps.packageAttributes); - - if (props.pageProps.editModeEnabled) { - setEnabled(); - } - }, []); - - const toggleColorScheme = (value?: ColorScheme) => - setColorScheme(value || (colorScheme === 'dark' ? 'light' : 'dark')); - - const asyncStoragePersister = createAsyncStoragePersister({ - storage: AsyncStorage, - }); - - useHotkeys([['mod+J', () => toggleColorScheme()]]); + setPrimaryColor(props.pageProps.primaryColor ?? 'red'); + setSecondaryColor(props.pageProps.secondaryColor ?? 'orange'); + setPrimaryShade(props.pageProps.primaryShade ?? 6); + return () => { + setPrimaryColor('red'); + setSecondaryColor('orange'); + setPrimaryShade(6); + }; + }, [props.pageProps]); return ( <> - - - - - - + + + {(colorScheme) => ( + + - - - - - - - - - - + + + + + + + + + )} + + + ); } -App.getInitialProps = ({ ctx }: { ctx: GetServerSidePropsContext }) => { - const disableEditMode = process.env.DISABLE_EDIT_MODE?.toLowerCase() === 'true'; - if (disableEditMode) { - Consola.warn( - 'EXPERIMENTAL: You have disabled the edit mode. Modifications are no longer possible and any requests on the API will be dropped. If you want to disable this, unset the DISABLE_EDIT_MODE environment variable. This behaviour may be removed in future versions of Homarr' +App.getInitialProps = async ({ ctx }: { ctx: GetServerSidePropsContext }) => { + if (env.NEXT_PUBLIC_DEFAULT_COLOR_SCHEME !== 'light') { + Consola.debug( + `Overriding the default color scheme with ${env.NEXT_PUBLIC_DEFAULT_COLOR_SCHEME}` ); } - if (process.env.DEFAULT_COLOR_SCHEME !== undefined) { - Consola.debug(`Overriding the default color scheme with ${process.env.DEFAULT_COLOR_SCHEME}`); - } + const session = await getSession(ctx); - const colorScheme: ColorScheme = (process.env.DEFAULT_COLOR_SCHEME as ColorScheme) ?? 'light'; + // Set the cookie language to the user language if it is not set correctly + const cookieLanguage = getCookie(COOKIE_LOCALE_KEY, ctx); + if (session?.user && session.user.language != cookieLanguage) { + setCookie(COOKIE_LOCALE_KEY, session.user.language, ctx); + } return { pageProps: { - colorScheme: getCookie('color-scheme', ctx) || 'light', + ...getActiveColorScheme(session, ctx), packageAttributes: getServiceSidePackageAttributes(), - editModeEnabled: !disableEditMode, - defaultColorScheme: colorScheme, + session, locale: ctx.locale ?? 'en', }, }; }; export default appWithTranslation(api.withTRPC(App), nextI18nextConfig as any); + +const getActiveColorScheme = (session: Session | null, ctx: GetServerSidePropsContext) => { + const environmentColorScheme = env.NEXT_PUBLIC_DEFAULT_COLOR_SCHEME ?? 'light'; + const cookieColorScheme = getCookie(COOKIE_COLOR_SCHEME_KEY, ctx); + const activeColorScheme = colorSchemeParser.parse( + session?.user?.colorScheme ?? cookieColorScheme ?? environmentColorScheme + ); + + if (cookieColorScheme !== activeColorScheme) { + setCookie(COOKIE_COLOR_SCHEME_KEY, activeColorScheme, ctx); + } + + return { + activeColorScheme: activeColorScheme, + environmentColorScheme, + }; +}; diff --git a/src/pages/api/auth/[...nextauth].ts b/src/pages/api/auth/[...nextauth].ts new file mode 100644 index 000000000..b5a1818de --- /dev/null +++ b/src/pages/api/auth/[...nextauth].ts @@ -0,0 +1,7 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import NextAuth from 'next-auth'; +import { constructAuthOptions } from '~/server/auth'; + +export default async function auth(req: NextApiRequest, res: NextApiResponse) { + return await NextAuth(req, res, constructAuthOptions(req, res)); +} diff --git a/src/pages/api/configs/[slug].ts b/src/pages/api/configs/[slug].ts deleted file mode 100644 index 0fc639593..000000000 --- a/src/pages/api/configs/[slug].ts +++ /dev/null @@ -1,211 +0,0 @@ -import Consola from 'consola'; -import fs from 'fs'; -import { NextApiRequest, NextApiResponse } from 'next'; -import path from 'path'; - -import { getConfig } from '../../../tools/config/getConfig'; -import { BackendConfigType, ConfigType } from '../../../types/config'; -import { IRssWidget } from '../../../widgets/rss/RssWidgetTile'; - -function Put(req: NextApiRequest, res: NextApiResponse) { - if (process.env.DISABLE_EDIT_MODE?.toLowerCase() === 'true') { - return res.status(409).json({ error: 'Edit mode has been disabled by the administrator' }); - } - - // Get the slug of the request - const { slug } = req.query as { slug: string }; - - // Get the body of the request - const { body: config }: { body: ConfigType } = req; - if (!slug || !config) { - Consola.warn('Rejected configuration update because either config or slug were undefined'); - return res.status(400).json({ - error: 'Wrong request', - }); - } - - Consola.info(`Saving updated configuration of '${slug}' config.`); - - const previousConfig = getConfig(slug); - - let newConfig: BackendConfigType = { - ...config, - apps: [ - ...config.apps.map((app) => ({ - ...app, - network: { - ...app.network, - statusCodes: - app.network.okStatus === undefined - ? app.network.statusCodes - : app.network.okStatus.map((x) => x.toString()), - okStatus: undefined, - }, - integration: { - ...app.integration, - properties: app.integration.properties.map((property) => { - if (property.type === 'public') { - return { - field: property.field, - type: property.type, - value: property.value, - }; - } - - const previousApp = previousConfig.apps.find( - (previousApp) => previousApp.id === app.id - ); - - const previousProperty = previousApp?.integration?.properties.find( - (previousProperty) => previousProperty.field === property.field - ); - - if (property.value !== undefined && property.value !== null) { - Consola.info( - 'Detected credential change of private secret. Value will be overwritten in configuration' - ); - return { - field: property.field, - type: property.type, - value: property.value, - }; - } - - return { - field: property.field, - type: property.type, - value: previousProperty?.value, - }; - }), - }, - })), - ], - }; - - newConfig = { - ...newConfig, - widgets: [ - ...newConfig.widgets.map((x) => { - if (x.type !== 'rss') { - return x; - } - - const rssWidget = x as IRssWidget; - - return { - ...rssWidget, - properties: { - ...rssWidget.properties, - rssFeedUrl: - typeof rssWidget.properties.rssFeedUrl === 'string' - ? [rssWidget.properties.rssFeedUrl] - : rssWidget.properties.rssFeedUrl, - }, - } as IRssWidget; - }), - ], - }; - - // Save the body in the /data/config folder with the slug as filename - const targetPath = path.join('data/configs', `${slug}.json`); - fs.writeFileSync(targetPath, JSON.stringify(newConfig, null, 2), 'utf8'); - - Consola.debug(`Config '${slug}' has been updated and flushed to '${targetPath}'.`); - - return res.status(200).json({ - message: 'Configuration saved with success', - }); -} - -function Get(req: NextApiRequest, res: NextApiResponse) { - // Get the slug of the request - const { slug } = req.query as { slug: string }; - if (!slug) { - return res.status(400).json({ - message: 'Wrong request', - }); - } - - // Loop over all the files in the /data/configs directory - // Get all the configs in the /data/configs folder - // All the files that end in ".json" - const files = fs.readdirSync('./data/configs').filter((file) => file.endsWith('.json')); - - // Strip the .json extension from the file name - const configs = files.map((file) => file.replace('.json', '')); - - // If the target is not in the list of files, return an error - if (!configs.includes(slug)) { - return res.status(404).json({ - message: 'Target not found', - }); - } - - // Return the content of the file - return res.status(200).json(fs.readFileSync(path.join('data/configs', `${slug}.json`), 'utf8')); -} - -export default async (req: NextApiRequest, res: NextApiResponse) => { - // Filter out if the reuqest is a Put or a GET - if (req.method === 'PUT') { - return Put(req, res); - } - - if (req.method === 'DELETE') { - return Delete(req, res); - } - - if (req.method === 'GET') { - return Get(req, res); - } - - return res.status(405).json({ - statusCode: 405, - message: 'Method not allowed', - }); -}; - -function Delete(req: NextApiRequest, res: NextApiResponse) { - // Get the slug of the request - const { slug } = req.query as { slug: string }; - if (!slug) { - Consola.error('Rejected config deletion request because config slug was not present'); - return res.status(400).json({ - message: 'Wrong request', - }); - } - - if (slug.toLowerCase() === 'default') { - Consola.error("Rejected config deletion because default configuration can't be deleted"); - return res.status(403).json({ - message: "Default config can't be deleted", - }); - } - - // Loop over all the files in the /data/configs directory - // Get all the configs in the /data/configs folder - // All the files that end in ".json" - const files = fs.readdirSync('./data/configs').filter((file) => file.endsWith('.json')); - // Match one file if the configProperties.name is the same as the slug - const matchedFile = files.find((file) => { - const config = JSON.parse(fs.readFileSync(path.join('data/configs', file), 'utf8')); - return config.configProperties.name === slug; - }); - - // If the target is not in the list of files, return an error - if (!matchedFile) { - Consola.error( - `Rejected config deletion request because config name '${slug}' was not included in present configurations` - ); - return res.status(404).json({ - message: 'Target not found', - }); - } - - // Delete the file - fs.unlinkSync(path.join('data/configs', matchedFile)); - Consola.info(`Successfully deleted configuration '${slug}' from your file system`); - return res.status(200).json({ - message: 'Configuration deleted with success', - }); -} diff --git a/src/pages/api/configs/index.ts b/src/pages/api/configs/index.ts deleted file mode 100644 index ded6b0067..000000000 --- a/src/pages/api/configs/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import fs from 'fs'; -import { NextApiRequest, NextApiResponse } from 'next'; - -function Get(req: NextApiRequest, res: NextApiResponse) { - // Get all the configs in the /data/configs folder - // All the files that end in ".json" - const files = fs.readdirSync('./data/configs').filter((file) => file.endsWith('.json')); - // Strip the .json extension from the file name - const configs = files.map((file) => file.replace('.json', '')); - - return res.status(200).json(configs); -} - -export default async (req: NextApiRequest, res: NextApiResponse) => { - // Filter out if the reuqest is a POST or a GET - if (req.method === 'GET') { - return Get(req, res); - } - return res.status(405).json({ - statusCode: 405, - message: 'Method not allowed', - }); -}; diff --git a/src/pages/api/configs/tryPassword.tsx b/src/pages/api/configs/tryPassword.tsx deleted file mode 100644 index d151f36a6..000000000 --- a/src/pages/api/configs/tryPassword.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import Consola from 'consola'; -import { NextApiRequest, NextApiResponse } from 'next'; - -function Post(req: NextApiRequest, res: NextApiResponse) { - const { tried, type = 'password' } = req.body; - // If the type of password is "edit", we run this branch to check the edit password - if (type === 'edit') { - if (tried === process.env.EDIT_MODE_PASSWORD) { - if (process.env.DISABLE_EDIT_MODE?.toLowerCase() === 'true') { - process.env.DISABLE_EDIT_MODE = 'false'; - } else { - process.env.DISABLE_EDIT_MODE = 'true'; - } - return res.status(200).json({ - success: true, - }); - } - } else if (tried === process.env.PASSWORD) { - return res.status(200).json({ - success: true, - }); - } - Consola.warn( - `${new Date().toLocaleString()} : Wrong password attempt, from ${ - req.headers['x-forwarded-for'] - }` - ); - return res.status(401).json({ - success: false, - }); -} - -export default async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method === 'POST') { - return Post(req, res); - } - return res.status(405).json({ - statusCode: 405, - message: 'Method not allowed', - }); -}; diff --git a/src/pages/api/trpc/[trpc].ts b/src/pages/api/trpc/[trpc].ts index d3f7428f9..8b7cd11a4 100644 --- a/src/pages/api/trpc/[trpc].ts +++ b/src/pages/api/trpc/[trpc].ts @@ -1,5 +1,6 @@ import { createNextApiHandler } from '@trpc/server/adapters/next'; import Consola from 'consola'; +import { env } from '~/env'; import { rootRouter } from '~/server/api/root'; import { createTRPCContext } from '~/server/api/trpc'; @@ -8,7 +9,7 @@ export default createNextApiHandler({ router: rootRouter, createContext: createTRPCContext, onError: - process.env.NODE_ENV === 'development' + env.NEXT_PUBLIC_NODE_ENV === 'development' ? ({ path, error }) => { Consola.error(`❌ tRPC failed on ${path ?? ''}: ${error.message}`); } diff --git a/src/pages/auth/invite/[inviteId].tsx b/src/pages/auth/invite/[inviteId].tsx new file mode 100644 index 000000000..0302c26f4 --- /dev/null +++ b/src/pages/auth/invite/[inviteId].tsx @@ -0,0 +1,220 @@ +import { + Button, + Card, + Flex, + PasswordInput, + Popover, + Stack, + Text, + TextInput, + Title, +} from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { showNotification, updateNotification } from '@mantine/notifications'; +import { IconCheck, IconX } from '@tabler/icons-react'; +import { GetServerSideProps } from 'next'; +import { signIn } from 'next-auth/react'; +import { useTranslation } from 'next-i18next'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import { useState } from 'react'; +import { z } from 'zod'; +import { PasswordRequirements } from '~/components/Password/password-requirements'; +import { FloatingBackground } from '~/components/layout/Background/FloatingBackground'; +import { ThemeSchemeToggle } from '~/components/ThemeSchemeToggle/ThemeSchemeToggle'; +import { getServerAuthSession } from '~/server/auth'; +import { prisma } from '~/server/db'; +import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; +import { api } from '~/utils/api'; +import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; +import { signUpFormSchema } from '~/validations/user'; + +const notificationId = 'register'; + +export default function AuthInvitePage() { + const { t } = useTranslation('authentication/invite'); + const { i18nZodResolver } = useI18nZodResolver(); + const router = useRouter(); + const query = router.query as { token: string }; + const { mutateAsync, isError } = api.user.createFromInvite.useMutation(); + const [isLoading, setIsLoading] = useState(false); + + const form = useForm>({ + validateInputOnChange: true, + validateInputOnBlur: true, + validate: i18nZodResolver(signUpFormSchema), + initialValues: { + username: '', + password: '', + passwordConfirmation: '', + }, + }); + + const handleSubmit = (values: z.infer) => { + showNotification({ + id: notificationId, + title: t('notifications.loading.title'), + message: `${t('notifications.loading.text')}...`, + loading: true, + }); + setIsLoading(true); + void mutateAsync( + { + ...values, + inviteToken: query.token, + }, + { + onSuccess() { + updateNotification({ + id: notificationId, + title: t('notifications.success.title'), + message: t('notifications.success.text'), + color: 'teal', + icon: , + }); + signIn('credentials', { + redirect: false, + name: values.username, + password: values.password, + callbackUrl: '/', + }).then((response) => { + if (!response?.ok) { + // Redirect to login page if something went wrong + router.push('/auth/login'); + return; + } + router.push('/manage'); + }); + }, + onError(error) { + updateNotification({ + id: notificationId, + title: t('notifications.error.title'), + message: t('notifications.error.text', { error: error.message }), + color: 'red', + icon: , + }); + }, + } + ); + }; + + const metaTitle = `${t('metaTitle')} • Homarr`; + + return ( + <> + + {metaTitle} + + + + + + + + {t('title')} + + + + {t('text')} + + +
+ + + + + + + + + + + +
+
+
+ + ); +} + +const queryParamsSchema = z.object({ + token: z.string(), +}); +const routeParamsSchema = z.object({ + inviteId: z.string(), +}); + +export const getServerSideProps: GetServerSideProps = async ({ + locale, + req, + res, + query, + params, +}) => { + const session = await getServerAuthSession({ req, res }); + + if (session) { + return { + redirect: { + destination: '/', + permanent: false, + }, + }; + } + + const queryParams = queryParamsSchema.safeParse(query); + const routeParams = routeParamsSchema.safeParse(params); + + if (!queryParams.success || !routeParams.success) { + return { + notFound: true, + }; + } + + const token = await prisma.invite.findUnique({ + where: { + id: routeParams.data.inviteId, + token: queryParams.data.token, + }, + }); + + if (!token || token.expires < new Date()) { + return { + notFound: true, + }; + } + + return { + props: { + ...(await getServerSideTranslations( + ['authentication/invite', 'password-requirements'], + locale, + req, + res + )), + }, + }; +}; diff --git a/src/pages/auth/login.tsx b/src/pages/auth/login.tsx new file mode 100644 index 000000000..159781426 --- /dev/null +++ b/src/pages/auth/login.tsx @@ -0,0 +1,165 @@ +import { + ActionIcon, + Alert, + Button, + Card, + Flex, + PasswordInput, + Stack, + Text, + TextInput, + Title, +} from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { IconAlertTriangle } from '@tabler/icons-react'; +import { GetServerSideProps, InferGetServerSidePropsType } from 'next'; +import { signIn } from 'next-auth/react'; +import { useTranslation } from 'next-i18next'; +import Head from 'next/head'; +import Image from 'next/image'; +import { useRouter } from 'next/router'; +import { useState } from 'react'; +import { z } from 'zod'; +import { ThemeSchemeToggle } from '~/components/ThemeSchemeToggle/ThemeSchemeToggle'; +import { FloatingBackground } from '~/components/layout/Background/FloatingBackground'; +import { getServerAuthSession } from '~/server/auth'; +import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; +import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; +import { signInSchema } from '~/validations/user'; + +export default function LoginPage({ + redirectAfterLogin, +}: InferGetServerSidePropsType) { + const { t } = useTranslation('authentication/login'); + const { i18nZodResolver } = useI18nZodResolver(); + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + + const form = useForm>({ + validateInputOnChange: true, + validateInputOnBlur: true, + validate: i18nZodResolver(signInSchema), + }); + + const handleSubmit = (values: z.infer) => { + setIsLoading(true); + setIsError(false); + signIn('credentials', { + redirect: false, + name: values.name, + password: values.password, + callbackUrl: '/', + }).then((response) => { + if (!response?.ok) { + setIsLoading(false); + setIsError(true); + return; + } + router.push(redirectAfterLogin ?? '/manage'); + }); + }; + + const metaTitle = `${t('metaTitle')} • Homarr`; + + return ( + <> + + {metaTitle} + + + + + + + + + ({ + color: theme.colorScheme === 'dark' ? theme.colors.gray[5] : theme.colors.dark[6], + fontSize: '4rem', + fontWeight: 800, + lineHeight: 1, + })} + align="center" + > + Homarr + + + + + {t('title')} + + + + {t('text')} + + + {isError && ( + } color="red"> + {t('alert')} + + )} + +
+ + + + + + + + {redirectAfterLogin && ( + + {t('form.afterLoginRedirection', { url: redirectAfterLogin })} + + )} + +
+
+
+
+ + ); +} + +const regexExp = /^\/{1}[A-Za-z\/]*$/; + +export const getServerSideProps: GetServerSideProps = async ({ locale, req, res, query }) => { + const session = await getServerAuthSession({ req, res }); + + const zodResult = await z + .object({ redirectAfterLogin: z.string().regex(regexExp) }) + .safeParseAsync(query); + const redirectAfterLogin = zodResult.success ? zodResult.data.redirectAfterLogin : null; + + if (session) { + return { + redirect: { + destination: '/', + permanent: false, + }, + }; + } + + return { + props: { + ...(await getServerSideTranslations(['authentication/login'], locale, req, res)), + redirectAfterLogin, + }, + }; +}; diff --git a/src/pages/b/[slug].tsx b/src/pages/b/[slug].tsx new file mode 100644 index 000000000..84d38586a --- /dev/null +++ b/src/pages/b/[slug].tsx @@ -0,0 +1 @@ +export { default, getServerSideProps } from '../board/[slug]'; diff --git a/src/pages/b/[slug]/customize.tsx b/src/pages/b/[slug]/customize.tsx new file mode 100644 index 000000000..6b7334e77 --- /dev/null +++ b/src/pages/b/[slug]/customize.tsx @@ -0,0 +1 @@ +export { default, getServerSideProps } from '../../board/[slug]/customize'; diff --git a/src/pages/b/index.tsx b/src/pages/b/index.tsx new file mode 100644 index 000000000..364e8a44f --- /dev/null +++ b/src/pages/b/index.tsx @@ -0,0 +1 @@ +export { default, getServerSideProps } from '../board'; diff --git a/src/pages/board/[slug].tsx b/src/pages/board/[slug].tsx new file mode 100644 index 000000000..567abd141 --- /dev/null +++ b/src/pages/board/[slug].tsx @@ -0,0 +1,83 @@ +import { GetServerSideProps, InferGetServerSidePropsType } from 'next'; +import { SSRConfig } from 'next-i18next'; +import { z } from 'zod'; +import { Dashboard } from '~/components/Dashboard/Dashboard'; +import { BoardLayout } from '~/components/layout/Templates/BoardLayout'; +import { useInitConfig } from '~/config/init'; +import { env } from '~/env'; +import { getServerAuthSession } from '~/server/auth'; +import { configExists } from '~/tools/config/configExists'; +import { getFrontendConfig } from '~/tools/config/getFrontendConfig'; +import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; +import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder'; +import { boardNamespaces } from '~/tools/server/translation-namespaces'; +import { ConfigType } from '~/types/config'; + +export default function BoardPage({ + config: initialConfig, + dockerEnabled, +}: InferGetServerSidePropsType) { + useInitConfig(initialConfig); + + return ( + + + + ); +} + +type BoardGetServerSideProps = { + config: ConfigType; + dockerEnabled: boolean; + _nextI18Next?: SSRConfig['_nextI18Next']; +}; + +const routeParamsSchema = z.object({ + slug: z.string(), +}); + +export const getServerSideProps: GetServerSideProps = async (ctx) => { + const routeParams = routeParamsSchema.safeParse(ctx.params); + if (!routeParams.success) { + return { + notFound: true, + }; + } + + const isPresent = configExists(routeParams.data.slug); + if (!isPresent) { + return { + notFound: true, + }; + } + + const config = await getFrontendConfig(routeParams.data.slug); + const translations = await getServerSideTranslations( + boardNamespaces, + ctx.locale, + ctx.req, + ctx.res + ); + + const session = await getServerAuthSession({ req: ctx.req, res: ctx.res }); + + const result = checkForSessionOrAskForLogin( + ctx, + session, + () => config.settings.access.allowGuests || !session?.user + ); + if (result) { + return result; + } + + return { + props: { + config, + primaryColor: config.settings.customization.colors.primary, + secondaryColor: config.settings.customization.colors.secondary, + primaryShade: config.settings.customization.colors.shade, + dockerEnabled: !!env.DOCKER_HOST && !!env.DOCKER_PORT, + ...translations, + }, + }; +}; diff --git a/src/pages/board/[slug]/customize.tsx b/src/pages/board/[slug]/customize.tsx new file mode 100644 index 000000000..b027098be --- /dev/null +++ b/src/pages/board/[slug]/customize.tsx @@ -0,0 +1,309 @@ +import { + Affix, + Button, + Card, + Container, + Group, + Paper, + Stack, + Text, + Title, + Transition, + rem, +} from '@mantine/core'; +import { showNotification, updateNotification } from '@mantine/notifications'; +import { + IconArrowLeft, + IconBrush, + IconChartCandle, + IconCheck, + IconDragDrop, + IconLayout, IconLock, + IconX, + TablerIconsProps, +} from '@tabler/icons-react'; +import { GetServerSideProps } from 'next'; +import { useTranslation } from 'next-i18next'; +import Head from 'next/head'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { ReactNode } from 'react'; +import { z } from 'zod'; +import { AppearanceCustomization } from '~/components/Board/Customize/Appearance/AppearanceCustomization'; +import { GridstackCustomization } from '~/components/Board/Customize/Gridstack/GridstackCustomization'; +import { LayoutCustomization } from '~/components/Board/Customize/Layout/LayoutCustomization'; +import { PageMetadataCustomization } from '~/components/Board/Customize/PageMetadata/PageMetadataCustomization'; +import { + BoardCustomizationFormProvider, + useBoardCustomizationForm, +} from '~/components/Board/Customize/form'; +import { useBoardLink } from '~/components/layout/Templates/BoardLayout'; +import { MainLayout } from '~/components/layout/Templates/MainLayout'; +import { createTrpcServersideHelpers } from '~/server/api/helper'; +import { getServerAuthSession } from '~/server/auth'; +import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; +import { firstUpperCase } from '~/tools/shared/strings'; +import { api } from '~/utils/api'; +import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; +import { boardCustomizationSchema } from '~/validations/boards'; +import { AccessCustomization } from '~/components/Board/Customize/Access/AccessCustomization'; + +const notificationId = 'board-customization-notification'; + +export default function CustomizationPage() { + const query = useRouter().query as { slug: string }; + const utils = api.useContext(); + const { data: config } = api.config.byName.useQuery({ name: query.slug }); + const { mutateAsync: saveCusomization, isLoading } = api.config.saveCusomization.useMutation(); + const { i18nZodResolver } = useI18nZodResolver(); + const { t } = useTranslation('boards/customize'); + const form = useBoardCustomizationForm({ + initialValues: { + access: { + allowGuests: config?.settings.access.allowGuests ?? false + }, + layout: { + leftSidebarEnabled: config?.settings.customization.layout.enabledLeftSidebar ?? false, + rightSidebarEnabled: config?.settings.customization.layout.enabledRightSidebar ?? false, + pingsEnabled: config?.settings.customization.layout.enabledPing ?? false, + }, + appearance: { + backgroundSrc: config?.settings.customization.backgroundImageUrl ?? '', + primaryColor: config?.settings.customization.colors.primary ?? 'red', + secondaryColor: config?.settings.customization.colors.secondary ?? 'orange', + shade: (config?.settings.customization.colors.shade as number | undefined) ?? 8, + opacity: config?.settings.customization.appOpacity ?? 50, + customCss: config?.settings.customization.customCss ?? '', + }, + gridstack: { + sm: config?.settings.customization.gridstack?.columnCountSmall ?? 3, + md: config?.settings.customization.gridstack?.columnCountMedium ?? 6, + lg: config?.settings.customization.gridstack?.columnCountLarge ?? 12, + }, + pageMetadata: { + pageTitle: config?.settings.customization.pageTitle ?? '', + metaTitle: config?.settings.customization.metaTitle ?? '', + logoSrc: config?.settings.customization.logoImageUrl ?? '', + faviconSrc: config?.settings.customization.faviconUrl ?? '', + }, + }, + validate: i18nZodResolver(boardCustomizationSchema), + validateInputOnChange: true, + validateInputOnBlur: true, + }); + + const backToBoardHref = useBoardLink(`/board/${query.slug}`); + + const handleSubmit = async (values: z.infer) => { + if (isLoading) return; + showNotification({ + id: notificationId, + title: t('notifications.pending.title'), + message: t('notifications.pending.message'), + loading: true, + }); + await saveCusomization( + { + name: query.slug, + ...values, + }, + { + onSettled() { + void utils.config.byName.invalidate({ name: query.slug }); + }, + onSuccess() { + updateNotification({ + id: notificationId, + title: t('notifications.success.title'), + message: t('notifications.success.message'), + color: 'green', + icon: , + }); + }, + onError() { + updateNotification({ + id: notificationId, + title: t('notifications.error.title'), + message: t('notifications.error.message'), + color: 'red', + icon: , + }); + }, + } + ); + }; + + const metaTitle = `${t('metaTitle', { + name: firstUpperCase(query.slug), + })} • Homarr`; + + return ( + } + > + {t('backToBoard')} + + } + > + + {metaTitle} + + + + {(transitionStyles) => ( + ({ + background: + theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.colors.gray[1], + })} + shadow="md" + withBorder + > + + {t('save.note')} + + + + + + + )} + + + + + + + + {t('pageTitle', { + name: firstUpperCase(query.slug), + })} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +type SectionTitleProps = { + type: 'layout' | 'gridstack' | 'pageMetadata' | 'appereance' | 'access'; + icon: (props: TablerIconsProps) => ReactNode; +}; + +const SectionTitle = ({ type, icon: Icon }: SectionTitleProps) => { + const { t } = useTranslation('settings/customization/general'); + + return ( + + + + {t(`accordeon.${type}.name`)} + + {t(`accordeon.${type}.description`)} + + ); +}; + +const routeParamsSchema = z.object({ + slug: z.string(), +}); + +export const getServerSideProps: GetServerSideProps = async ({ req, res, locale, params }) => { + const routeParams = routeParamsSchema.safeParse(params); + if (!routeParams.success) { + return { + notFound: true, + }; + } + + const session = await getServerAuthSession({ req, res }); + if (!session?.user.isAdmin) { + return { + notFound: true, + }; + } + + const helpers = await createTrpcServersideHelpers({ req, res }); + + const config = await helpers.config.byName.fetch({ name: routeParams.data.slug }); + + const translations = await getServerSideTranslations( + [ + 'boards/customize', + 'settings/common', + 'settings/customization/general', + 'settings/customization/page-appearance', + 'settings/customization/shade-selector', + 'settings/customization/opacity-selector', + 'settings/customization/gridstack', + 'settings/customization/access' + ], + locale, + req, + res + ); + + return { + props: { + primaryColor: config.settings.customization.colors.primary, + secondaryColor: config.settings.customization.colors.secondary, + primaryShade: config.settings.customization.colors.shade, + trpcState: helpers.dehydrate(), + ...translations, + }, + }; +}; diff --git a/src/pages/board/index.tsx b/src/pages/board/index.tsx new file mode 100644 index 000000000..3736c31dc --- /dev/null +++ b/src/pages/board/index.tsx @@ -0,0 +1,71 @@ +import { GetServerSideProps, InferGetServerSidePropsType } from 'next'; +import { SSRConfig } from 'next-i18next'; +import { Dashboard } from '~/components/Dashboard/Dashboard'; +import { BoardLayout } from '~/components/layout/Templates/BoardLayout'; +import { useInitConfig } from '~/config/init'; +import { env } from '~/env'; +import { getServerAuthSession } from '~/server/auth'; +import { prisma } from '~/server/db'; +import { getFrontendConfig } from '~/tools/config/getFrontendConfig'; +import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; +import { boardNamespaces } from '~/tools/server/translation-namespaces'; +import { ConfigType } from '~/types/config'; + +export default function BoardPage({ + config: initialConfig, + dockerEnabled, +}: InferGetServerSidePropsType) { + useInitConfig(initialConfig); + + return ( + + + + ); +} + +type BoardGetServerSideProps = { + config: ConfigType; + dockerEnabled: boolean; + _nextI18Next?: SSRConfig['_nextI18Next']; +}; + +export const getServerSideProps: GetServerSideProps = async (ctx) => { + const session = await getServerAuthSession(ctx); + const currentUserSettings = await prisma.userSettings.findFirst({ + where: { + userId: session?.user?.id, + }, + }); + + const translations = await getServerSideTranslations( + boardNamespaces, + ctx.locale, + ctx.req, + ctx.res + ); + const boardName = currentUserSettings?.defaultBoard ?? 'default'; + const config = await getFrontendConfig(boardName); + + if (!config.settings.access.allowGuests && !session?.user) { + return { + notFound: true, + props: { + primaryColor: config.settings.customization.colors.primary, + secondaryColor: config.settings.customization.colors.secondary, + primaryShade: config.settings.customization.colors.shade, + } + }; + } + + return { + props: { + config, + primaryColor: config.settings.customization.colors.primary, + secondaryColor: config.settings.customization.colors.secondary, + primaryShade: config.settings.customization.colors.shade, + dockerEnabled: !!env.DOCKER_HOST && !!env.DOCKER_PORT, + ...translations, + }, + }; +}; diff --git a/src/pages/index.tsx b/src/pages/index.tsx deleted file mode 100644 index d268021ae..000000000 --- a/src/pages/index.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { getCookie, setCookie } from 'cookies-next'; -import fs from 'fs'; -import { GetServerSidePropsContext } from 'next'; - -import { LoadConfigComponent } from '../components/Config/LoadConfig'; -import { Dashboard } from '../components/Dashboard/Dashboard'; -import Layout from '../components/layout/Layout'; -import { useInitConfig } from '../config/init'; -import { getFrontendConfig } from '../tools/config/getFrontendConfig'; -import { getServerSideTranslations } from '../tools/server/getServerSideTranslations'; -import { dashboardNamespaces } from '../tools/server/translation-namespaces'; -import { DashboardServerSideProps } from '../types/dashboardPageType'; - -export async function getServerSideProps({ - req, - res, - locale, -}: GetServerSidePropsContext): Promise<{ props: DashboardServerSideProps }> { - // Get all the configs in the /data/configs folder - // All the files that end in ".json" - const configs = fs.readdirSync('./data/configs').filter((file) => file.endsWith('.json')); - - if ( - !configs.every( - (config) => JSON.parse(fs.readFileSync(`./data/configs/${config}`, 'utf8')).schemaVersion - ) - ) { - // Replace the current page with the migrate page but don't redirect - // This is to prevent the user from seeing the redirect - res.writeHead(302, { - Location: '/migrate', - }); - res.end(); - - return { props: {} as DashboardServerSideProps }; - } - - let configName = getCookie('config-name', { req, res }); - if (!configName) { - setCookie('config-name', 'default', { - req, - res, - maxAge: 60 * 60 * 24 * 30, - sameSite: 'strict', - }); - configName = 'default'; - } - - const translations = await getServerSideTranslations(dashboardNamespaces, locale, req, res); - const config = await getFrontendConfig(configName as string); - - return { - props: { - configName: configName as string, - config, - ...translations, - }, - }; -} - -export default function HomePage({ config: initialConfig }: DashboardServerSideProps) { - useInitConfig(initialConfig); - - return ( - - - - - ); -} diff --git a/src/pages/login.tsx b/src/pages/login.tsx deleted file mode 100644 index 3d2642c7e..000000000 --- a/src/pages/login.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { Button, Container, Paper, PasswordInput, Text, Title } from '@mantine/core'; -import { useForm } from '@mantine/form'; -import { showNotification, updateNotification } from '@mantine/notifications'; -import { IconCheck, IconX } from '@tabler/icons-react'; -import axios from 'axios'; -import { setCookie } from 'cookies-next'; -import { useTranslation } from 'next-i18next'; -import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; -import { useRouter } from 'next/router'; -import React from 'react'; - -import { loginNamespaces } from '../tools/server/translation-namespaces'; - -// TODO: Add links to the wiki articles about the login process. -export default function AuthenticationTitle() { - const router = useRouter(); - const { t } = useTranslation('authentication/login'); - - const form = useForm({ - initialValues: { - password: '', - }, - }); - return ( - - - ({ fontFamily: `Greycliff CF, ${theme.fontFamily}`, fontWeight: 900 })} - > - {t('title')} - - - - {t('text')} - -
{ - setCookie('password', values.password, { - maxAge: 60 * 60 * 24 * 30, - sameSite: 'lax', - }); - showNotification({ - id: 'load-data', - loading: true, - title: t('notifications.checking.title'), - message: t('notifications.checking.message'), - autoClose: false, - withCloseButton: false, - }); - axios - .post('/api/configs/tryPassword', { - tried: values.password, - }) - .then((res) => { - setTimeout(() => { - if (res.data.success === true) { - router.push('/'); - updateNotification({ - id: 'load-data', - color: 'teal', - title: t('notifications.correct.title'), - message: undefined, - icon: , - autoClose: 1000, - }); - } - if (res.data.success === false) { - updateNotification({ - id: 'load-data', - color: 'red', - title: t('notifications.wrong.title'), - message: undefined, - icon: , - autoClose: 2000, - }); - } - }, 500); - }); - })} - > - - - -
-
- ); -} - -export async function getServerSideProps({ locale }: { locale: string }) { - return { - props: { - ...(await serverSideTranslations(locale, loginNamespaces)), - // Will be passed to the page component as props - }, - }; -} diff --git a/src/pages/manage/boards/index.tsx b/src/pages/manage/boards/index.tsx new file mode 100644 index 000000000..d79e73947 --- /dev/null +++ b/src/pages/manage/boards/index.tsx @@ -0,0 +1,225 @@ +import { + ActionIcon, + Badge, + Button, + Card, + Group, + LoadingOverlay, + Menu, + SimpleGrid, + Stack, + Text, + Title, +} from '@mantine/core'; +import { useListState } from '@mantine/hooks'; +import { + IconBox, + IconCategory, + IconDeviceFloppy, + IconDotsVertical, + IconFolderFilled, + IconPlus, + IconStack, + IconStarFilled, + IconTrash, +} from '@tabler/icons-react'; +import { GetServerSideProps } from 'next'; +import { useSession } from 'next-auth/react'; +import { useTranslation } from 'next-i18next'; +import Head from 'next/head'; +import Link from 'next/link'; +import { openCreateBoardModal } from '~/components/Manage/Board/create-board.modal'; +import { openDeleteBoardModal } from '~/components/Manage/Board/delete-board.modal'; +import { ManageLayout } from '~/components/layout/Templates/ManageLayout'; +import { getServerAuthSession } from '~/server/auth'; +import { sleep } from '~/tools/client/time'; +import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; +import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder'; +import { manageNamespaces } from '~/tools/server/translation-namespaces'; +import { api } from '~/utils/api'; + +const BoardsPage = () => { + const context = api.useContext(); + const { data: sessionData } = useSession(); + const { data } = api.boards.all.useQuery(); + const { mutateAsync } = api.user.makeDefaultDashboard.useMutation({ + onSettled: () => { + void context.boards.invalidate(); + }, + }); + + const [deletingDashboards, { append, filter }] = useListState([]); + + const { t } = useTranslation('manage/boards'); + + const metaTitle = `${t('metaTitle')} • Homarr`; + + return ( + + + {metaTitle} + + + + {t('pageTitle')} + {sessionData?.user.isAdmin && ( + + )} + + + {data && ( + + {data.map((board, index) => ( + + + + + + {board.name} + + + } + color="pink" + variant="light" + > + {t('cards.badges.fileSystem')} + + {board.isDefaultForUser && ( + } + color="yellow" + variant="light" + > + {t('cards.badges.default')} + + )} + + + + + + + + {t('cards.statistics.apps')} + + {board.countApps} + + + + + + {t('cards.statistics.widgets')} + + {board.countWidgets} + + + + + + {t('cards.statistics.categories')} + + {board.countCategories} + + + + + + + + + + + + + } + onClick={async () => { + void mutateAsync({ + board: board.name, + }); + }} + > + {t('cards.menu.setAsDefault')} + + {sessionData?.user.isAdmin && ( + <> + + { + openDeleteBoardModal({ + boardName: board.name, + onConfirm: async () => { + append(board.name); + // give user feedback, that it's being deleted + await sleep(500); + filter((item, _) => item !== board.name); + }, + }); + }} + disabled={board.name === 'default'} + icon={} + color="red" + > + {t('cards.menu.delete.label')} + {board.name === 'default' && ( + {t('cards.menu.delete.disabled')} + )} + + + )} + + + + + ))} + + )} + + ); +}; + +export const getServerSideProps: GetServerSideProps = async (ctx) => { + const session = await getServerAuthSession(ctx); + + const result = checkForSessionOrAskForLogin(ctx, session, () => true); + if (result) { + return result; + } + + const translations = await getServerSideTranslations( + manageNamespaces, + ctx.locale, + ctx.req, + ctx.res + ); + return { + props: { + ...translations, + }, + }; +}; + +export default BoardsPage; diff --git a/src/pages/manage/index.tsx b/src/pages/manage/index.tsx new file mode 100644 index 000000000..6b89ef760 --- /dev/null +++ b/src/pages/manage/index.tsx @@ -0,0 +1,158 @@ +import { + Box, + Card, + Group, + SimpleGrid, + Stack, + Text, + Title, + Image, + UnstyledButton, + createStyles, +} from '@mantine/core'; +import { IconArrowRight } from '@tabler/icons-react'; +import { GetServerSideProps } from 'next'; +import { useSession } from 'next-auth/react'; +import { useTranslation } from 'next-i18next'; +import Head from 'next/head'; +import Link from 'next/link'; +import { ManageLayout } from '~/components/layout/Templates/ManageLayout'; +import { useScreenLargerThan } from '~/hooks/useScreenLargerThan'; +import { getServerAuthSession } from '~/server/auth'; +import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; +import { OnlyKeysWithStructure } from '~/types/helpers'; + +import { type quickActions } from '../../../public/locales/en/manage/index.json'; + +const ManagementPage = () => { + const { t } = useTranslation('manage/index'); + const { classes } = useStyles(); + const largerThanMd = useScreenLargerThan('md'); + const { data: sessionData } = useSession(); + + const metaTitle = `${t('metaTitle')} • Homarr`; + return ( + + + {metaTitle} + + + + + + {t('hero.title', { + username: sessionData?.user?.name ?? t('hero.fallbackUsername'), + })} + + {t('hero.subtitle')} + + + + Homarr Logo + + + + + + + {t('quickActions.title')} + + + + + + + + ); +}; + +type QuickActionType = OnlyKeysWithStructure< + typeof quickActions, + { + title: string; + subtitle: string; + } +>; + +type QuickActionCardProps = { + type: QuickActionType; + href: string; +}; + +const QuickActionCard = ({ type, href }: QuickActionCardProps) => { + const { t } = useTranslation('manage/index'); + const { classes } = useStyles(); + + return ( + + + + + {t(`quickActions.${type}.title`)} + {t(`quickActions.${type}.subtitle`)} + + + + + + ); +}; + +export const getServerSideProps: GetServerSideProps = async (ctx) => { + const session = await getServerAuthSession(ctx); + + if (!session?.user) { + return { + notFound: true, + }; + } + + const translations = await getServerSideTranslations( + ['layout/manage', 'manage/index'], + ctx.locale, + ctx.req, + ctx.res + ); + return { + props: { + ...translations, + }, + }; +}; + +export default ManagementPage; + +const useStyles = createStyles((theme) => ({ + box: { + borderRadius: theme.radius.md, + backgroundColor: + theme.colorScheme === 'dark' ? theme.fn.rgba(theme.colors.red[8], 0.1) : theme.colors.red[1], + }, + boxTitle: { + color: theme.colors.red[6], + }, + quickActionCard: { + height: '100%', + backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[2], + '&:hover': { + backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[3], + }, + }, +})); diff --git a/src/pages/manage/tools/docker.tsx b/src/pages/manage/tools/docker.tsx new file mode 100644 index 000000000..f8b0be2cb --- /dev/null +++ b/src/pages/manage/tools/docker.tsx @@ -0,0 +1,99 @@ +import { Alert, Stack, Title } from '@mantine/core'; +import { IconInfoCircle } from '@tabler/icons-react'; +import Consola from 'consola'; +import { ContainerInfo } from 'dockerode'; +import { GetServerSideProps, InferGetServerSidePropsType } from 'next'; +import { useTranslation } from 'next-i18next'; +import { useState } from 'react'; +import ContainerActionBar from '~/components/Manage/Tools/Docker/ContainerActionBar'; +import ContainerTable from '~/components/Manage/Tools/Docker/ContainerTable'; +import { ManageLayout } from '~/components/layout/Templates/ManageLayout'; +import { dockerRouter } from '~/server/api/routers/docker/router'; +import { getServerAuthSession } from '~/server/auth'; +import { prisma } from '~/server/db'; +import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; +import { boardNamespaces } from '~/tools/server/translation-namespaces'; +import { api } from '~/utils/api'; + +export default function DockerPage({ + initialContainers, + dockerIsConfigured, +}: InferGetServerSidePropsType) { + const [selection, setSelection] = useState([]); + const { data, refetch, isRefetching } = api.docker.containers.useQuery(undefined, { + initialData: initialContainers, + cacheTime: 60 * 1000 * 5, + staleTime: 60 * 1000 * 1, + enabled: dockerIsConfigured, + }); + + const { t } = useTranslation('tools/docker'); + + const reload = () => { + refetch(); + setSelection([]); + }; + + if (!dockerIsConfigured) { + return ( + + {t('title')} + } color="blue"> + {t('alerts.notConfigured.text')} + + + ); + } + + return ( + + + + + + + ); +} + +export const getServerSideProps: GetServerSideProps = async ({ locale, req, res }) => { + const session = await getServerAuthSession({ req, res }); + if (!session?.user.isAdmin) { + return { + notFound: true, + }; + } + + const caller = dockerRouter.createCaller({ + session: session, + cookies: req.cookies, + prisma: prisma, + }); + + const translations = await getServerSideTranslations( + [...boardNamespaces, 'layout/manage', 'tools/docker'], + locale, + req, + res + ); + + let containers = []; + try { + containers = await caller.containers(); + } catch (error) { + Consola.error(`The docker integration failed with the following error: ${error}`); + return { + props: { + dockerIsConfigured: false, + ...translations, + }, + }; + } + + return { + props: { + initialContainers: containers, + dockerIsConfigured: true, + ...translations, + }, + }; +}; diff --git a/src/pages/manage/users/create.tsx b/src/pages/manage/users/create.tsx new file mode 100644 index 000000000..f1c894c7f --- /dev/null +++ b/src/pages/manage/users/create.tsx @@ -0,0 +1,154 @@ +import { Alert, Button, Group, Stepper } from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { IconArrowLeft, IconKey, IconMailCheck, IconUser, IconUserPlus } from '@tabler/icons-react'; +import { GetServerSideProps } from 'next'; +import { useTranslation } from 'next-i18next'; +import Head from 'next/head'; +import Link from 'next/link'; +import { useState } from 'react'; +import { z } from 'zod'; +import { + CreateAccountStep, + createAccountStepValidationSchema, +} from '~/components/Manage/User/Create/create-account-step'; +import { ReviewInputStep } from '~/components/Manage/User/Create/review-input-step'; +import { + CreateAccountSecurityStep, + createAccountSecurityStepValidationSchema, +} from '~/components/Manage/User/Create/security-step'; +import { ManageLayout } from '~/components/layout/Templates/ManageLayout'; +import { getServerAuthSession } from '~/server/auth'; +import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; +import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder'; +import { manageNamespaces } from '~/tools/server/translation-namespaces'; +import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; + +const CreateNewUserPage = () => { + const { t } = useTranslation('manage/users/create'); + const [active, setActive] = useState(0); + const nextStep = () => setActive((current) => (current < 3 ? current + 1 : current)); + const prevStep = () => setActive((current) => (current > 0 ? current - 1 : current)); + const { i18nZodResolver } = useI18nZodResolver(); + + const form = useForm({ + initialValues: { + account: { + username: '', + eMail: '', + }, + security: { + password: '', + }, + }, + validate: i18nZodResolver(createAccountSchema), + }); + + const metaTitle = `${t('metaTitle')} • Homarr`; + return ( + + + {metaTitle} + + + + } + label={t('steps.account.title')} + description={t('steps.account.text')} + > + { + form.setFieldValue('account', value); + nextStep(); + }} + /> + + } + label={t('steps.security.title')} + description={t('steps.security.text')} + > + { + form.setFieldValue('security', value); + nextStep(); + }} + prevStep={prevStep} + /> + + } + label={t('steps.finish.title')} + description={t('steps.finish.title')} + > + + + + + {t('steps.completed.alert.text')} + + + + + + + + + + ); +}; + +const createAccountSchema = z.object({ + account: createAccountStepValidationSchema, + security: createAccountSecurityStepValidationSchema, +}); + +export type CreateAccountSchema = z.infer; + +export const getServerSideProps: GetServerSideProps = async (ctx) => { + const session = await getServerAuthSession(ctx); + + const result = checkForSessionOrAskForLogin(ctx, session, () => session?.user.isAdmin == true); + if (result) { + return result; + } + + const translations = await getServerSideTranslations( + [...manageNamespaces, 'password-requirements'], + ctx.locale, + ctx.req, + ctx.res + ); + return { + props: { + ...translations, + }, + }; +}; + +export default CreateNewUserPage; diff --git a/src/pages/manage/users/index.tsx b/src/pages/manage/users/index.tsx new file mode 100644 index 000000000..fd5940b94 --- /dev/null +++ b/src/pages/manage/users/index.tsx @@ -0,0 +1,203 @@ +import { + ActionIcon, + Autocomplete, + Avatar, + Badge, + Box, + Button, + Flex, + Group, + Pagination, + Table, + Text, + Title, + Tooltip, +} from '@mantine/core'; +import { useDebouncedValue } from '@mantine/hooks'; +import { IconPlus, IconTrash, IconUserDown, IconUserUp } from '@tabler/icons-react'; +import { GetServerSideProps } from 'next'; +import { useSession } from 'next-auth/react'; +import { useTranslation } from 'next-i18next'; +import Head from 'next/head'; +import Link from 'next/link'; +import { useState } from 'react'; +import { openRoleChangeModal } from '~/components/Manage/User/change-user-role.modal'; +import { openDeleteUserModal } from '~/components/Manage/User/delete-user.modal'; +import { ManageLayout } from '~/components/layout/Templates/ManageLayout'; +import { getServerAuthSession } from '~/server/auth'; +import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; +import { manageNamespaces } from '~/tools/server/translation-namespaces'; +import { api } from '~/utils/api'; + +const ManageUsersPage = () => { + const [activePage, setActivePage] = useState(0); + const [nonDebouncedSearch, setNonDebouncedSearch] = useState(''); + const [debouncedSearch] = useDebouncedValue(nonDebouncedSearch, 200); + const { data } = api.user.all.useQuery({ + page: activePage, + search: debouncedSearch, + }); + const { data: sessionData } = useSession(); + + const { t } = useTranslation('manage/users'); + + const metaTitle = `${t('metaTitle')} • Homarr`; + + return ( + + + {metaTitle} + + + {t('pageTitle')} + {t('text')} + + + user.name).filter((name) => name !== null) as string[]) ?? [] + } + variant="filled" + onChange={(value) => { + setNonDebouncedSearch(value); + }} + /> + + + + {data && ( + <> + + + + + + + + {data.users.map((user, index) => ( + + + + ))} + + {debouncedSearch && debouncedSearch.length > 0 && ( + + + + )} + +
{t('table.header.user')}
+ + + + {user.name} + {user.isOwner && ( + + Owner + + )} + {user.isAdmin && ( + + Admin + + )} + + + {user.isAdmin ? ( + + { + openRoleChangeModal({ + ...user, + type: 'demote', + }); + }} + > + + + + ) : ( + + { + openRoleChangeModal({ + ...user, + type: 'promote', + }); + }} + > + + + + )} + + + { + openDeleteUserModal(user); + }} + color="red" + variant="light" + > + + + + + +
+ + {t('searchDoesntMatch')} + +
+ { + setActivePage((prev) => prev + 1); + }} + onPreviousPage={() => { + setActivePage((prev) => prev - 1); + }} + onChange={(targetPage) => { + setActivePage(targetPage - 1); + }} + /> + + )} +
+ ); +}; + +export const getServerSideProps: GetServerSideProps = async (ctx) => { + const session = await getServerAuthSession(ctx); + + if (!session?.user.isAdmin) { + return { + notFound: true, + }; + } + + const translations = await getServerSideTranslations( + manageNamespaces, + ctx.locale, + undefined, + undefined + ); + return { + props: { + ...translations, + }, + }; +}; + +export default ManageUsersPage; diff --git a/src/pages/manage/users/invites.tsx b/src/pages/manage/users/invites.tsx new file mode 100644 index 000000000..5d491337f --- /dev/null +++ b/src/pages/manage/users/invites.tsx @@ -0,0 +1,176 @@ +import { + ActionIcon, + Button, + Center, + Flex, + Pagination, + Table, + Text, + Title, + createStyles, +} from '@mantine/core'; +import { modals } from '@mantine/modals'; +import { IconPlus, IconTrash } from '@tabler/icons-react'; +import dayjs from 'dayjs'; +import { GetServerSideProps } from 'next'; +import { useTranslation } from 'next-i18next'; +import Head from 'next/head'; +import { useState } from 'react'; +import { openCreateInviteModal } from '~/components/Manage/User/Invite/create-invite.modal'; +import { ManageLayout } from '~/components/layout/Templates/ManageLayout'; +import { getServerAuthSession } from '~/server/auth'; +import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; +import { manageNamespaces } from '~/tools/server/translation-namespaces'; +import { api } from '~/utils/api'; + +const ManageUserInvitesPage = () => { + const { classes } = useStyles(); + const { t } = useTranslation('manage/users/invites'); + const [activePage, setActivePage] = useState(0); + const { data: invites } = api.invites.all.useQuery({ + page: activePage, + }); + + const nextPage = () => { + setActivePage((prev) => prev + 1); + }; + + const previousPage = () => { + setActivePage((prev) => prev - 1); + }; + + const metaTitle = `${t('metaTitle')} • Homarr`; + return ( + + + {metaTitle} + + {t('pageTitle')} + {t('description')} + + + + + + {invites && ( + <> + + + + + + + + + + + {invites.invites.map((invite, index) => ( + + + + + + + ))} + {invites.invites.length === 0 && ( + + + + )} + +
{t('table.header.id')}{t('table.header.creator')}{t('table.header.expires')}{t('table.header.action')}
+ {invite.id} + + {invite.creator} + + {dayjs(dayjs()).isAfter(invite.expires) ? ( + + {t('table.data.expiresAt', { at: dayjs(invite.expires).fromNow() })} + + ) : ( + + {t('table.data.expiresIn', { in: dayjs(invite.expires).fromNow(true) })} + + )} + + { + modals.openContextModal({ + modal: 'deleteInviteModal', + title: {t('button.deleteInvite')}, + innerProps: { + tokenId: invite.id, + }, + }); + }} + color="red" + variant="light" + > + + +
+
+ {t('noInvites')} +
+
+ { + setActivePage(targetPage - 1); + }} + onNextPage={nextPage} + onPreviousPage={previousPage} + onFirstPage={() => { + setActivePage(0); + }} + onLastPage={() => { + setActivePage(invites.countPages - 1); + }} + withEdges + /> + + )} +
+ ); +}; + +const useStyles = createStyles(() => ({ + tableGrowCell: { + width: '50%', + }, + tableCell: { + whiteSpace: 'nowrap', + }, +})); + +export const getServerSideProps: GetServerSideProps = async (ctx) => { + const session = await getServerAuthSession(ctx); + + if (!session?.user.isAdmin) { + return { + notFound: true, + }; + } + + const translations = await getServerSideTranslations( + manageNamespaces, + ctx.locale, + ctx.req, + ctx.res + ); + + return { + props: { + ...translations, + }, + }; +}; + +export default ManageUserInvitesPage; diff --git a/src/pages/onboard.tsx b/src/pages/onboard.tsx new file mode 100644 index 000000000..00ff0161f --- /dev/null +++ b/src/pages/onboard.tsx @@ -0,0 +1,94 @@ +import { Box, Button, Center, Image, Stack, Text, Title, useMantineTheme } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { IconArrowRight } from '@tabler/icons-react'; +import fs from 'fs'; +import { GetServerSideProps, InferGetServerSidePropsType } from 'next'; +import Head from 'next/head'; +import { OnboardingSteps } from '~/components/Onboarding/onboarding-steps'; +import { ThemeSchemeToggle } from '~/components/ThemeSchemeToggle/ThemeSchemeToggle'; +import { FloatingBackground } from '~/components/layout/Background/FloatingBackground'; +import { prisma } from '~/server/db'; +import { getConfig } from '~/tools/config/getConfig'; +import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; + +export default function OnboardPage({ + configSchemaVersions, +}: InferGetServerSidePropsType) { + const { fn, colors, colorScheme } = useMantineTheme(); + const background = colorScheme === 'dark' ? 'dark.6' : 'gray.1'; + + const [onboardingSteps, { open: showOnboardingSteps }] = useDisclosure(false); + + const isUpgradeFromSchemaOne = configSchemaVersions.includes(1); + + return ( + <> + + Onboard • Homarr + + + + + + + +
+
+ Homarr Logo +
+
+ + {onboardingSteps ? ( + + ) : ( +
+ + + Welcome to Homarr! + + + Your favorite dashboard has received a big upgrade. +
+ We'll help you update within the next few steps +
+ + +
+
+ )} +
+ + ); +} + +export const getServerSideProps: GetServerSideProps = async (ctx) => { + const userCount = await prisma.user.count(); + if (userCount >= 1) { + return { + notFound: true, + }; + } + + const files = fs.readdirSync('./data/configs').filter((file) => file.endsWith('.json')); + const configs = files.map((file) => getConfig(file)); + const configSchemaVersions = configs.map((config) => config.schemaVersion); + + const translations = await getServerSideTranslations(['password-requirements'], ctx.locale, ctx.req, ctx.res); + + return { + props: { + ...translations, + configSchemaVersions: configSchemaVersions, + }, + }; +}; diff --git a/src/pages/user/preferences.tsx b/src/pages/user/preferences.tsx new file mode 100644 index 000000000..2dcef24e0 --- /dev/null +++ b/src/pages/user/preferences.tsx @@ -0,0 +1,222 @@ +import { + Button, + Container, + Group, + LoadingOverlay, + Paper, + Select, + Stack, + Text, + Title, +} from '@mantine/core'; +import { createFormContext } from '@mantine/form'; +import { IconArrowLeft } from '@tabler/icons-react'; +import { GetServerSideProps } from 'next'; +import { useTranslation } from 'next-i18next'; +import Head from 'next/head'; +import { forwardRef } from 'react'; +import { z } from 'zod'; +import { AccessibilitySettings } from '~/components/User/Preferences/AccessibilitySettings'; +import { SearchEngineSettings } from '~/components/User/Preferences/SearchEngineSelector'; +import { MainLayout } from '~/components/layout/Templates/MainLayout'; +import { createTrpcServersideHelpers } from '~/server/api/helper'; +import { getServerAuthSession } from '~/server/auth'; +import { languages } from '~/tools/language'; +import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; +import { RouterOutputs, api } from '~/utils/api'; +import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; +import { updateSettingsValidationSchema } from '~/validations/user'; + +const PreferencesPage = () => { + const { data } = api.user.withSettings.useQuery(); + const { data: boardsData } = api.boards.all.useQuery(); + const { t } = useTranslation('user/preferences'); + const headTitle = `${t('metaTitle')} • Homarr`; + + return ( + }> + {t('common:back')} + + } + > + + + + {headTitle} + + {t('pageTitle')} + + {data && boardsData && ( + + )} + + + + ); +}; + +export const [FormProvider, useUserPreferencesFormContext, useForm] = + createFormContext>(); + +const SettingsComponent = ({ + settings, + boardsData, +}: { + settings: RouterOutputs['user']['withSettings']['settings']; + boardsData: RouterOutputs['boards']['all']; +}) => { + const languagesData = languages.map((language) => ({ + image: 'https://img.icons8.com/clouds/256/000000/futurama-bender.png', + label: language.originalName, + description: language.translatedName, + value: language.shortName, + country: language.country, + })); + + const { t } = useTranslation(['user/preferences', 'common']); + + const { i18nZodResolver } = useI18nZodResolver(); + + const form = useForm({ + initialValues: { + defaultBoard: settings.defaultBoard, + language: settings.language, + firstDayOfWeek: settings.firstDayOfWeek, + disablePingPulse: settings.disablePingPulse, + replaceDotsWithIcons: settings.replacePingWithIcons, + searchTemplate: settings.searchTemplate, + openSearchInNewTab: settings.openSearchInNewTab, + autoFocusSearch: settings.autoFocusSearch, + }, + validate: i18nZodResolver(updateSettingsValidationSchema), + validateInputOnBlur: true, + validateInputOnChange: true, + }); + + const context = api.useContext(); + const { mutate, isLoading } = api.user.updateSettings.useMutation({ + onSettled: () => { + void context.boards.all.invalidate(); + void context.user.withSettings.invalidate(); + }, + }); + + const handleSubmit = (values: z.infer) => { + mutate(values); + }; + + return ( + +
+ + + + item.label!.toLowerCase().includes(value.toLowerCase().trim()) || + item.description.toLowerCase().includes(value.toLowerCase().trim()) + } + defaultValue={settings.language} + withAsterisk + mb="xs" + {...form.getInputProps('language')} + /> + +