Merge pull request #1491 from acelaya-forks/feature/initial-tailwind-components

Initial tailwind components
This commit is contained in:
Alejandro Celaya
2025-04-05 13:12:05 +02:00
committed by GitHub
58 changed files with 1380 additions and 711 deletions

View File

@@ -22,3 +22,12 @@ afterEach(() => {
HTMLCanvasElement.prototype.getContext = (() => {}) as any; HTMLCanvasElement.prototype.getContext = (() => {}) as any;
(global as any).scrollTo = () => {}; (global as any).scrollTo = () => {};
(global as any).matchMedia = () => ({ matches: false }); (global as any).matchMedia = () => ({ matches: false });
HTMLDialogElement.prototype.showModal = function() {
this.setAttribute('open', '');
};
HTMLDialogElement.prototype.close = function() {
this.removeAttribute('open');
this.dispatchEvent(new CloseEvent('close'));
this.dispatchEvent(new CloseEvent('cancel'));
};

789
package-lock.json generated
View File

@@ -41,6 +41,7 @@
"devDependencies": { "devDependencies": {
"@shlinkio/eslint-config-js-coding-standard": "~3.5.0", "@shlinkio/eslint-config-js-coding-standard": "~3.5.0",
"@stylistic/eslint-plugin": "^4.2.0", "@stylistic/eslint-plugin": "^4.2.0",
"@tailwindcss/vite": "^4.0.17",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
@@ -62,6 +63,7 @@
"history": "^5.3.0", "history": "^5.3.0",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"sass": "^1.86.3", "sass": "^1.86.3",
"tailwindcss": "^4.0.17",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.29.0", "typescript-eslint": "^8.29.0",
"vite": "^6.2.5", "vite": "^6.2.5",
@@ -3532,6 +3534,244 @@
"sourcemap-codec": "^1.4.8" "sourcemap-codec": "^1.4.8"
} }
}, },
"node_modules/@tailwindcss/node": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.17.tgz",
"integrity": "sha512-LIdNwcqyY7578VpofXyqjH6f+3fP4nrz7FBLki5HpzqjYfXdF2m/eW18ZfoKePtDGg90Bvvfpov9d2gy5XVCbg==",
"dev": true,
"license": "MIT",
"dependencies": {
"enhanced-resolve": "^5.18.1",
"jiti": "^2.4.2",
"tailwindcss": "4.0.17"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.17.tgz",
"integrity": "sha512-B4OaUIRD2uVrULpAD1Yksx2+wNarQr2rQh65nXqaqbLY1jCd8fO+3KLh/+TH4Hzh2NTHQvgxVbPdUDOtLk7vAw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.0.17",
"@tailwindcss/oxide-darwin-arm64": "4.0.17",
"@tailwindcss/oxide-darwin-x64": "4.0.17",
"@tailwindcss/oxide-freebsd-x64": "4.0.17",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.17",
"@tailwindcss/oxide-linux-arm64-gnu": "4.0.17",
"@tailwindcss/oxide-linux-arm64-musl": "4.0.17",
"@tailwindcss/oxide-linux-x64-gnu": "4.0.17",
"@tailwindcss/oxide-linux-x64-musl": "4.0.17",
"@tailwindcss/oxide-win32-arm64-msvc": "4.0.17",
"@tailwindcss/oxide-win32-x64-msvc": "4.0.17"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.17.tgz",
"integrity": "sha512-3RfO0ZK64WAhop+EbHeyxGThyDr/fYhxPzDbEQjD2+v7ZhKTb2svTWy+KK+J1PHATus2/CQGAGp7pHY/8M8ugg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.17.tgz",
"integrity": "sha512-e1uayxFQCCDuzTk9s8q7MC5jFN42IY7nzcr5n0Mw/AcUHwD6JaBkXnATkD924ZsHyPDvddnusIEvkgLd2CiREg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.17.tgz",
"integrity": "sha512-d6z7HSdOKfXQ0HPlVx1jduUf/YtBuCCtEDIEFeBCzgRRtDsUuRtofPqxIVaSCUTOk5+OfRLonje6n9dF6AH8wQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.17.tgz",
"integrity": "sha512-EjrVa6lx3wzXz3l5MsdOGtYIsRjgs5Mru6lDv4RuiXpguWeOb3UzGJ7vw7PEzcFadKNvNslEQqoAABeMezprxQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.17.tgz",
"integrity": "sha512-65zXfCOdi8wuaY0Ye6qMR5LAXokHYtrGvo9t/NmxvSZtCCitXV/gzJ/WP5ksXPhff1SV5rov0S+ZIZU+/4eyCQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.17.tgz",
"integrity": "sha512-+aaq6hJ8ioTdbJV5IA1WjWgLmun4T7eYLTvJIToiXLHy5JzUERRbIZjAcjgK9qXMwnvuu7rqpxzej+hGoEcG5g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.17.tgz",
"integrity": "sha512-/FhWgZCdUGAeYHYnZKekiOC0aXFiBIoNCA0bwzkICiMYS5Rtx2KxFfMUXQVnl4uZRblG5ypt5vpPhVaXgGk80w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.17.tgz",
"integrity": "sha512-gELJzOHK6GDoIpm/539Golvk+QWZjxQcbkKq9eB2kzNkOvrP0xc5UPgO9bIMNt1M48mO8ZeNenCMGt6tfkvVBg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.17.tgz",
"integrity": "sha512-68NwxcJrZn94IOW4TysMIbYv5AlM6So1luTlbYUDIGnKma1yTFGBRNEJ+SacJ3PZE2rgcTBNRHX1TB4EQ/XEHw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.17.tgz",
"integrity": "sha512-AkBO8efP2/7wkEXkNlXzRD4f/7WerqKHlc6PWb5v0jGbbm22DFBLbIM19IJQ3b+tNewQZa+WnPOaGm0SmwMNjw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.17.tgz",
"integrity": "sha512-7/DTEvXcoWlqX0dAlcN0zlmcEu9xSermuo7VNGX9tJ3nYMdo735SHvbrHDln1+LYfF6NhJ3hjbpbjkMOAGmkDg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.0.17.tgz",
"integrity": "sha512-HJbBYDlDVg5cvYZzECb6xwc1IDCEM3uJi3hEZp3BjZGCNGJcTsnCpan+z+VMW0zo6gR0U6O6ElqU1OoZ74Dhww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tailwindcss/node": "4.0.17",
"@tailwindcss/oxide": "4.0.17",
"lightningcss": "1.29.2",
"tailwindcss": "4.0.17"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6"
}
},
"node_modules/@testing-library/dom": { "node_modules/@testing-library/dom": {
"version": "10.0.0", "version": "10.0.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.0.0.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.0.0.tgz",
@@ -4762,9 +5002,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001680", "version": "1.0.30001709",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001680.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001709.tgz",
"integrity": "sha512-rPQy70G6AGUMnbwS1z6Xg+RkHYPAi18ihs47GH0jcxIG7wArmPgY3XbS2sRdBbxJljp3thdT8BIqv9ccCypiPA==", "integrity": "sha512-NgL3vUTnDrPCZ3zTahp4fsugQ4dc7EKTSzwQDPEel6DMoMnfH2jhry9n2Zm8onbSR+f/QtKHFOA+iAQu4kbtWA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -4779,7 +5019,8 @@
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
] ],
"license": "CC-BY-4.0"
}, },
"node_modules/chai": { "node_modules/chai": {
"version": "5.2.0", "version": "5.2.0",
@@ -5400,6 +5641,20 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/enhanced-resolve": {
"version": "5.18.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
"integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.2.0"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/entities": { "node_modules/entities": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@@ -7339,6 +7594,16 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/jiti": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"license": "MIT" "license": "MIT"
@@ -7533,6 +7798,255 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/lightningcss": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz",
"integrity": "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-darwin-arm64": "1.29.2",
"lightningcss-darwin-x64": "1.29.2",
"lightningcss-freebsd-x64": "1.29.2",
"lightningcss-linux-arm-gnueabihf": "1.29.2",
"lightningcss-linux-arm64-gnu": "1.29.2",
"lightningcss-linux-arm64-musl": "1.29.2",
"lightningcss-linux-x64-gnu": "1.29.2",
"lightningcss-linux-x64-musl": "1.29.2",
"lightningcss-win32-arm64-msvc": "1.29.2",
"lightningcss-win32-x64-msvc": "1.29.2"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz",
"integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.2.tgz",
"integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.2.tgz",
"integrity": "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.2.tgz",
"integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.2.tgz",
"integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.2.tgz",
"integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.2.tgz",
"integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.2.tgz",
"integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.2.tgz",
"integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.2.tgz",
"integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss/node_modules/detect-libc": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
"integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/locate-path": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"dev": true, "dev": true,
@@ -9308,6 +9822,23 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/tailwindcss": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.17.tgz",
"integrity": "sha512-OErSiGzRa6rLiOvaipsDZvLMSpsBZ4ysB4f0VKGXUrjw2jfkJRd6kjRKV2+ZmTCNvwtvgdDam5D7w6WXsdLJZw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
"integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/temp-dir": { "node_modules/temp-dir": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
@@ -12847,6 +13378,125 @@
} }
} }
}, },
"@tailwindcss/node": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.17.tgz",
"integrity": "sha512-LIdNwcqyY7578VpofXyqjH6f+3fP4nrz7FBLki5HpzqjYfXdF2m/eW18ZfoKePtDGg90Bvvfpov9d2gy5XVCbg==",
"dev": true,
"requires": {
"enhanced-resolve": "^5.18.1",
"jiti": "^2.4.2",
"tailwindcss": "4.0.17"
}
},
"@tailwindcss/oxide": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.17.tgz",
"integrity": "sha512-B4OaUIRD2uVrULpAD1Yksx2+wNarQr2rQh65nXqaqbLY1jCd8fO+3KLh/+TH4Hzh2NTHQvgxVbPdUDOtLk7vAw==",
"dev": true,
"requires": {
"@tailwindcss/oxide-android-arm64": "4.0.17",
"@tailwindcss/oxide-darwin-arm64": "4.0.17",
"@tailwindcss/oxide-darwin-x64": "4.0.17",
"@tailwindcss/oxide-freebsd-x64": "4.0.17",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.17",
"@tailwindcss/oxide-linux-arm64-gnu": "4.0.17",
"@tailwindcss/oxide-linux-arm64-musl": "4.0.17",
"@tailwindcss/oxide-linux-x64-gnu": "4.0.17",
"@tailwindcss/oxide-linux-x64-musl": "4.0.17",
"@tailwindcss/oxide-win32-arm64-msvc": "4.0.17",
"@tailwindcss/oxide-win32-x64-msvc": "4.0.17"
}
},
"@tailwindcss/oxide-android-arm64": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.17.tgz",
"integrity": "sha512-3RfO0ZK64WAhop+EbHeyxGThyDr/fYhxPzDbEQjD2+v7ZhKTb2svTWy+KK+J1PHATus2/CQGAGp7pHY/8M8ugg==",
"dev": true,
"optional": true
},
"@tailwindcss/oxide-darwin-arm64": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.17.tgz",
"integrity": "sha512-e1uayxFQCCDuzTk9s8q7MC5jFN42IY7nzcr5n0Mw/AcUHwD6JaBkXnATkD924ZsHyPDvddnusIEvkgLd2CiREg==",
"dev": true,
"optional": true
},
"@tailwindcss/oxide-darwin-x64": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.17.tgz",
"integrity": "sha512-d6z7HSdOKfXQ0HPlVx1jduUf/YtBuCCtEDIEFeBCzgRRtDsUuRtofPqxIVaSCUTOk5+OfRLonje6n9dF6AH8wQ==",
"dev": true,
"optional": true
},
"@tailwindcss/oxide-freebsd-x64": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.17.tgz",
"integrity": "sha512-EjrVa6lx3wzXz3l5MsdOGtYIsRjgs5Mru6lDv4RuiXpguWeOb3UzGJ7vw7PEzcFadKNvNslEQqoAABeMezprxQ==",
"dev": true,
"optional": true
},
"@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.17.tgz",
"integrity": "sha512-65zXfCOdi8wuaY0Ye6qMR5LAXokHYtrGvo9t/NmxvSZtCCitXV/gzJ/WP5ksXPhff1SV5rov0S+ZIZU+/4eyCQ==",
"dev": true,
"optional": true
},
"@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.17.tgz",
"integrity": "sha512-+aaq6hJ8ioTdbJV5IA1WjWgLmun4T7eYLTvJIToiXLHy5JzUERRbIZjAcjgK9qXMwnvuu7rqpxzej+hGoEcG5g==",
"dev": true,
"optional": true
},
"@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.17.tgz",
"integrity": "sha512-/FhWgZCdUGAeYHYnZKekiOC0aXFiBIoNCA0bwzkICiMYS5Rtx2KxFfMUXQVnl4uZRblG5ypt5vpPhVaXgGk80w==",
"dev": true,
"optional": true
},
"@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.17.tgz",
"integrity": "sha512-gELJzOHK6GDoIpm/539Golvk+QWZjxQcbkKq9eB2kzNkOvrP0xc5UPgO9bIMNt1M48mO8ZeNenCMGt6tfkvVBg==",
"dev": true,
"optional": true
},
"@tailwindcss/oxide-linux-x64-musl": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.17.tgz",
"integrity": "sha512-68NwxcJrZn94IOW4TysMIbYv5AlM6So1luTlbYUDIGnKma1yTFGBRNEJ+SacJ3PZE2rgcTBNRHX1TB4EQ/XEHw==",
"dev": true,
"optional": true
},
"@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.17.tgz",
"integrity": "sha512-AkBO8efP2/7wkEXkNlXzRD4f/7WerqKHlc6PWb5v0jGbbm22DFBLbIM19IJQ3b+tNewQZa+WnPOaGm0SmwMNjw==",
"dev": true,
"optional": true
},
"@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.17.tgz",
"integrity": "sha512-7/DTEvXcoWlqX0dAlcN0zlmcEu9xSermuo7VNGX9tJ3nYMdo735SHvbrHDln1+LYfF6NhJ3hjbpbjkMOAGmkDg==",
"dev": true,
"optional": true
},
"@tailwindcss/vite": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.0.17.tgz",
"integrity": "sha512-HJbBYDlDVg5cvYZzECb6xwc1IDCEM3uJi3hEZp3BjZGCNGJcTsnCpan+z+VMW0zo6gR0U6O6ElqU1OoZ74Dhww==",
"dev": true,
"requires": {
"@tailwindcss/node": "4.0.17",
"@tailwindcss/oxide": "4.0.17",
"lightningcss": "1.29.2",
"tailwindcss": "4.0.17"
}
},
"@testing-library/dom": { "@testing-library/dom": {
"version": "10.0.0", "version": "10.0.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.0.0.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.0.0.tgz",
@@ -13696,9 +14346,9 @@
"dev": true "dev": true
}, },
"caniuse-lite": { "caniuse-lite": {
"version": "1.0.30001680", "version": "1.0.30001709",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001680.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001709.tgz",
"integrity": "sha512-rPQy70G6AGUMnbwS1z6Xg+RkHYPAi18ihs47GH0jcxIG7wArmPgY3XbS2sRdBbxJljp3thdT8BIqv9ccCypiPA==", "integrity": "sha512-NgL3vUTnDrPCZ3zTahp4fsugQ4dc7EKTSzwQDPEel6DMoMnfH2jhry9n2Zm8onbSR+f/QtKHFOA+iAQu4kbtWA==",
"dev": true "dev": true
}, },
"chai": { "chai": {
@@ -14130,6 +14780,16 @@
"version": "8.0.0", "version": "8.0.0",
"dev": true "dev": true
}, },
"enhanced-resolve": {
"version": "5.18.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
"integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==",
"dev": true,
"requires": {
"graceful-fs": "^4.2.4",
"tapable": "^2.2.0"
}
},
"entities": { "entities": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@@ -15507,6 +16167,12 @@
} }
} }
}, },
"jiti": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
"dev": true
},
"js-tokens": { "js-tokens": {
"version": "4.0.0" "version": "4.0.0"
}, },
@@ -15653,6 +16319,103 @@
"type-check": "~0.4.0" "type-check": "~0.4.0"
} }
}, },
"lightningcss": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz",
"integrity": "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==",
"dev": true,
"requires": {
"detect-libc": "^2.0.3",
"lightningcss-darwin-arm64": "1.29.2",
"lightningcss-darwin-x64": "1.29.2",
"lightningcss-freebsd-x64": "1.29.2",
"lightningcss-linux-arm-gnueabihf": "1.29.2",
"lightningcss-linux-arm64-gnu": "1.29.2",
"lightningcss-linux-arm64-musl": "1.29.2",
"lightningcss-linux-x64-gnu": "1.29.2",
"lightningcss-linux-x64-musl": "1.29.2",
"lightningcss-win32-arm64-msvc": "1.29.2",
"lightningcss-win32-x64-msvc": "1.29.2"
},
"dependencies": {
"detect-libc": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
"integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==",
"dev": true
}
}
},
"lightningcss-darwin-arm64": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz",
"integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==",
"dev": true,
"optional": true
},
"lightningcss-darwin-x64": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.2.tgz",
"integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==",
"dev": true,
"optional": true
},
"lightningcss-freebsd-x64": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.2.tgz",
"integrity": "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==",
"dev": true,
"optional": true
},
"lightningcss-linux-arm-gnueabihf": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.2.tgz",
"integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==",
"dev": true,
"optional": true
},
"lightningcss-linux-arm64-gnu": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.2.tgz",
"integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==",
"dev": true,
"optional": true
},
"lightningcss-linux-arm64-musl": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.2.tgz",
"integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==",
"dev": true,
"optional": true
},
"lightningcss-linux-x64-gnu": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.2.tgz",
"integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==",
"dev": true,
"optional": true
},
"lightningcss-linux-x64-musl": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.2.tgz",
"integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==",
"dev": true,
"optional": true
},
"lightningcss-win32-arm64-msvc": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.2.tgz",
"integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==",
"dev": true,
"optional": true
},
"lightningcss-win32-x64-msvc": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.2.tgz",
"integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==",
"dev": true,
"optional": true
},
"locate-path": { "locate-path": {
"version": "6.0.0", "version": "6.0.0",
"dev": true, "dev": true,
@@ -16861,6 +17624,18 @@
"version": "3.2.4", "version": "3.2.4",
"dev": true "dev": true
}, },
"tailwindcss": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.17.tgz",
"integrity": "sha512-OErSiGzRa6rLiOvaipsDZvLMSpsBZ4ysB4f0VKGXUrjw2jfkJRd6kjRKV2+ZmTCNvwtvgdDam5D7w6WXsdLJZw==",
"devOptional": true
},
"tapable": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
"integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
"dev": true
},
"temp-dir": { "temp-dir": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",

View File

@@ -54,6 +54,7 @@
"devDependencies": { "devDependencies": {
"@shlinkio/eslint-config-js-coding-standard": "~3.5.0", "@shlinkio/eslint-config-js-coding-standard": "~3.5.0",
"@stylistic/eslint-plugin": "^4.2.0", "@stylistic/eslint-plugin": "^4.2.0",
"@tailwindcss/vite": "^4.0.17",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
@@ -75,6 +76,7 @@
"history": "^5.3.0", "history": "^5.3.0",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"sass": "^1.86.3", "sass": "^1.86.3",
"tailwindcss": "^4.0.17",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.29.0", "typescript-eslint": "^8.29.0",
"vite": "^6.2.5", "vite": "^6.2.5",

View File

@@ -1,26 +0,0 @@
@use '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
.app-container {
height: 100%;
}
.app {
padding-top: base.$headerHeight;
height: 100%;
}
.shlink-wrapper {
min-height: 100%;
padding-bottom: base.$footer-height + base.$footer-margin;
margin-bottom: -(base.$footer-height + base.$footer-margin);
}
.shlink-footer {
height: base.$footer-height;
margin-top: base.$footer-margin;
padding: 0;
@media (min-width: base.$mdMin) {
padding: 0 15px;
}
}

View File

@@ -10,7 +10,6 @@ import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils'; import { componentFactory, useDependencies } from '../container/utils';
import type { ServersMap } from '../servers/data'; import type { ServersMap } from '../servers/data';
import { forceUpdate } from '../utils/helpers/sw'; import { forceUpdate } from '../utils/helpers/sw';
import './App.scss';
type AppProps = { type AppProps = {
fetchServers: () => void; fetchServers: () => void;
@@ -62,11 +61,17 @@ const App: FCWithDeps<AppProps, AppDeps> = (
}, [settings.ui?.theme]); }, [settings.ui?.theme]);
return ( return (
<div className="container-fluid app-container"> <div className="tw:px-3 tw:h-full">
<MainHeader /> <MainHeader />
<div className="app"> <div className="tw:h-full tw:pt-(--header-height)">
<div className={clsx('shlink-wrapper', { 'd-flex align-items-center pt-3': isHome })}> <div
data-testid="shlink-wrapper"
className={clsx(
'tw:min-h-full tw:pb-[calc(var(--footer-height)+var(--footer-margin))] tw:-mb-[calc(var(--footer-height)+var(--footer-margin))]',
{ 'tw:flex tw:items-center tw:pt-4': isHome },
)}
>
<Routes> <Routes>
<Route index element={<Home />} /> <Route index element={<Home />} />
<Route path="/settings"> <Route path="/settings">
@@ -82,12 +87,12 @@ const App: FCWithDeps<AppProps, AppDeps> = (
</Routes> </Routes>
</div> </div>
<div className="shlink-footer"> <div className="tw:h-(--footer-height) tw:mt-(--footer-margin) tw:md:px-4">
<ShlinkVersionsContainer /> <ShlinkVersionsContainer />
</div> </div>
</div> </div>
<AppUpdateBanner isOpen={appUpdated} toggle={resetAppUpdate} forceUpdate={forceUpdate} /> <AppUpdateBanner isOpen={appUpdated} onClose={resetAppUpdate} forceUpdate={forceUpdate} />
</div> </div>
); );
}; };

View File

@@ -1,17 +0,0 @@
@use '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
@use '../utils/mixins/horizontal-align';
.app-update-banner.app-update-banner {
@include horizontal-align.horizontal-align();
position: fixed;
top: base.$headerHeight - 25px;
padding: 0 4rem 0 0;
z-index: 1040;
margin: 0;
color: var(--text-color);
text-align: center;
width: 700px;
max-width: calc(100% - 30px);
box-shadow: 0 0 1rem var(--brand-color);
}

View File

@@ -1,34 +1,47 @@
import { faSyncAlt as reloadIcon } from '@fortawesome/free-solid-svg-icons'; import { faSyncAlt as reloadIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { SimpleCard, useToggle } from '@shlinkio/shlink-frontend-kit'; import { useToggle } from '@shlinkio/shlink-frontend-kit';
import type { MouseEventHandler } from 'react'; import { Button, Card, CloseButton } from '@shlinkio/shlink-frontend-kit/tailwind';
import { forwardRef, useCallback } from 'react'; import { clsx } from 'clsx';
import { Alert, Button } from 'reactstrap'; import type { FC } from 'react';
import './AppUpdateBanner.scss'; import { useCallback } from 'react';
interface AppUpdateBannerProps { interface AppUpdateBannerProps {
isOpen: boolean; isOpen: boolean;
toggle: MouseEventHandler<any>; onClose: () => void;
forceUpdate: () => void; forceUpdate: () => void;
} }
export const AppUpdateBanner = forwardRef<HTMLElement, AppUpdateBannerProps>(({ isOpen, toggle, forceUpdate }, ref) => { export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, onClose, forceUpdate }) => {
const [isUpdating,, setUpdating] = useToggle(); const [isUpdating,, setUpdating] = useToggle();
const update = useCallback(() => { const update = useCallback(() => {
setUpdating(); setUpdating();
forceUpdate(); forceUpdate();
}, [forceUpdate, setUpdating]); }, [forceUpdate, setUpdating]);
if (!isOpen) {
return null;
}
return ( return (
<Alert className="app-update-banner" isOpen={isOpen} toggle={toggle} tag={SimpleCard} color="secondary" innerRef={ref}> <Card
<h4 className="mb-4">This app has just been updated!</h4> role="alert"
<p className="mb-0"> className={clsx(
'tw:w-[700px] tw:max-w-[calc(100%-30px)]',
'tw:fixed tw:top-[35px] tw:left-[50%] tw:translate-x-[-50%] tw:z-[1040]',
)}
>
<Card.Header className="tw:flex tw:items-center tw:justify-between">
<h5>This app has just been updated!</h5>
<CloseButton onClick={onClose} />
</Card.Header>
<Card.Body className="tw:flex tw:gap-4 tw:items-center tw:justify-between tw:max-md:flex-col">
Restart it to enjoy the new features. Restart it to enjoy the new features.
<Button role="button" disabled={isUpdating} className="ms-2" color="secondary" size="sm" onClick={update}> <Button disabled={isUpdating} variant="secondary" solid onClick={update}>
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} className="ms-1" /></>} {!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} /></>}
{isUpdating && <>Restarting...</>} {isUpdating && <>Restarting...</>}
</Button> </Button>
</p> </Card.Body>
</Alert> </Card>
); );
}); };

View File

@@ -1,7 +1,7 @@
import { SimpleCard } from '@shlinkio/shlink-frontend-kit'; import { Button } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { PropsWithChildren, ReactNode } from 'react'; import type { PropsWithChildren, ReactNode } from 'react';
import { Component } from 'react'; import { Component } from 'react';
import { Button } from 'reactstrap'; import { ErrorLayout } from './ErrorLayout';
type ErrorHandlerProps = PropsWithChildren<{ type ErrorHandlerProps = PropsWithChildren<{
location?: typeof window.location; location?: typeof window.location;
@@ -33,14 +33,11 @@ export class ErrorHandler extends Component<ErrorHandlerProps, ErrorHandlerState
if (hasError) { if (hasError) {
return ( return (
<div className="home"> <ErrorLayout title="Oops! This is awkward :S">
<SimpleCard className="p-4"> <p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
<h1>Oops! This is awkward :S</h1> <br />
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p> <Button size="lg" onClick={() => location.reload()}>Take me back</Button>
<br /> </ErrorLayout>
<Button outline color="primary" onClick={() => location.reload()}>Take me back</Button>
</SimpleCard>
</div>
); );
} }

View File

@@ -0,0 +1,15 @@
import { SimpleCard } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC, PropsWithChildren } from 'react';
export type ErrorLayoutProps = PropsWithChildren<{
title: string;
}>;
export const ErrorLayout: FC<ErrorLayoutProps> = ({ children, title }) => (
<div className="tw:pt-4">
<SimpleCard className="tw:p-4 tw:w-full tw:lg:w-[65%] tw:m-auto">
<h2>{title}</h2>
{children}
</SimpleCard>
</div>
);

View File

@@ -1,15 +0,0 @@
@use '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
.home__title {
font-size: 1.75rem;
@media (min-width: base.$mdMin) {
font-size: 2.2rem;
}
}
.home__servers-container {
@media (min-width: base.$mdMin) {
border-left: 1px solid var(--border-color);
}
}

View File

@@ -1,18 +1,17 @@
import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons'; import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Button, Card } from '@shlinkio/shlink-frontend-kit/tailwind';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { ExternalLink } from 'react-external-link'; import { ExternalLink } from 'react-external-link';
import { Link, useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { Card } from 'reactstrap';
import type { ServersMap } from '../servers/data'; import type { ServersMap } from '../servers/data';
import { ServersListGroup } from '../servers/ServersListGroup'; import { ServersListGroup } from '../servers/ServersListGroup';
import { ShlinkLogo } from './img/ShlinkLogo'; import { ShlinkLogo } from './img/ShlinkLogo';
import './Home.scss';
interface HomeProps { export type HomeProps = {
servers: ServersMap; servers: ServersMap;
} };
export const Home = ({ servers }: HomeProps) => { export const Home = ({ servers }: HomeProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -28,42 +27,42 @@ export const Home = ({ servers }: HomeProps) => {
}, [serversList, navigate]); }, [serversList, navigate]);
return ( return (
<div className="w-100"> <div className="tw:w-full">
<Card className="mx-auto" style={{ maxWidth: '720px' }}> <Card className="tw:mx-auto tw:max-w-[720px] tw:overflow-hidden">
<div className="d-flex flex-column flex-md-row"> <div className="tw:flex tw:flex-col tw:md:flex-row">
<div className="p-4 d-none d-md-flex align-items-center" style={{ width: '40%' }}> <div className="tw:p-6 tw:hidden tw:md:flex tw:items-center tw:w-[40%]">
<div className="w-100"> <div className="tw:w-full">
<ShlinkLogo /> <ShlinkLogo />
</div> </div>
</div> </div>
<div className="home__servers-container flex-grow-1"> <div className="tw:md:border-l tw:border-lm-border tw:dark:border-dm-border tw:flex-grow">
<h1 <h1
className={clsx('home__title p-4 text-center m-0', { 'border-bottom': !hasServers })} className={clsx(
style={{ borderColor: 'var(--border-color) !important' }} 'tw:p-4 tw:text-center tw:border-lm-border tw:dark:border-dm-border',
{ 'tw:border-b': !hasServers },
)}
> >
Welcome! Welcome!
</h1> </h1>
<ServersListGroup embedded servers={serversList}> {hasServers ? <ServersListGroup servers={serversList} /> : (
{!hasServers && ( <div className="tw:p-6 tw:text-center tw:flex tw:flex-col tw:gap-12 tw:text-xl">
<div className="p-4 text-center d-flex flex-column gap-5"> <p>This application will help you manage your Shlink servers.</p>
<p className="mb-0">This application will help you manage your Shlink servers.</p> <p>
<p className="mb-0"> <Button to="/server/create" size="lg" inline>
<Link to="/server/create" className="btn btn-outline-primary btn-lg me-2"> <FontAwesomeIcon icon={faPlus} /> Add a server
<FontAwesomeIcon icon={faPlus} /> <span className="ms-1">Add a server</span> </Button>
</Link> </p>
</p> <p>
<p className="mb-0"> <ExternalLink href="https://shlink.io/documentation">
<ExternalLink href="https://shlink.io/documentation"> <small>
<small> <span className="tw:mr-2">Learn more about Shlink</span>
<span className="me-2">Learn more about Shlink</span> <FontAwesomeIcon icon={faExternalLinkAlt} />
<FontAwesomeIcon icon={faExternalLinkAlt} /> </small>
</small> </ExternalLink>
</ExternalLink> </p>
</p> </div>
</div> )}
)}
</ServersListGroup>
</div> </div>
</div> </div>
</Card> </Card>

View File

@@ -1,22 +0,0 @@
.main-header.main-header {
color: white;
background-color: var(--brand-color) !important;
.navbar-brand {
color: inherit !important;
}
}
.main-header__brand-logo {
width: 26px;
margin-right: 5px;
}
.main-header__toggle-icon {
width: 20px;
transition: transform 300ms;
}
.main-header__toggle-icon--opened {
transform: rotate(180deg);
}

View File

@@ -9,7 +9,6 @@ import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } f
import type { FCWithDeps } from '../container/utils'; import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils'; import { componentFactory, useDependencies } from '../container/utils';
import { ShlinkLogo } from './img/ShlinkLogo'; import { ShlinkLogo } from './img/ShlinkLogo';
import './MainHeader.scss';
type MainHeaderDeps = { type MainHeaderDeps = {
ServersDropdown: FC; ServersDropdown: FC;
@@ -25,20 +24,22 @@ const MainHeader: FCWithDeps<unknown, MainHeaderDeps> = () => {
useEffect(collapse, [location, collapse]); useEffect(collapse, [location, collapse]);
const settingsPath = '/settings'; const settingsPath = '/settings';
const toggleClass = clsx('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isNotCollapsed });
return ( return (
<Navbar color="primary" dark fixed="top" className="main-header" expand="md"> <Navbar color="primary" dark fixed="top" expand="md" className="tw:text-white tw:bg-lm-brand tw:dark:bg-dm-brand">
<NavbarBrand tag={Link} to="/"> <NavbarBrand tag={Link} to="/">
<ShlinkLogo className="main-header__brand-logo" color="white" /> Shlink <ShlinkLogo className="tw:inline tw:w-7 tw:mr-1" color="white" /> Shlink
</NavbarBrand> </NavbarBrand>
<NavbarToggler onClick={toggleCollapse}> <NavbarToggler onClick={toggleCollapse}>
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} /> <FontAwesomeIcon
icon={arrowIcon}
className={clsx('tw:transition-transform tw:duration-300', { 'tw:rotate-180': isNotCollapsed })}
/>
</NavbarToggler> </NavbarToggler>
<Collapse navbar isOpen={isNotCollapsed}> <Collapse navbar isOpen={isNotCollapsed}>
<Nav navbar className="ms-auto"> <Nav navbar className="tw:ml-auto">
<NavItem> <NavItem>
<NavLink tag={Link} to={settingsPath} active={pathname.startsWith(settingsPath)}> <NavLink tag={Link} to={settingsPath} active={pathname.startsWith(settingsPath)}>
<FontAwesomeIcon icon={cogsIcon} />&nbsp; Settings <FontAwesomeIcon icon={cogsIcon} />&nbsp; Settings

View File

@@ -1,9 +0,0 @@
@use '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
.no-menu-wrapper {
padding: 15px 0 0;
@media (min-width: base.$mdMin) {
padding: 30px 20px 20px;
}
}

View File

@@ -1,11 +1,12 @@
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import type { FC, PropsWithChildren } from 'react'; import type { FC, PropsWithChildren } from 'react';
import './NoMenuLayout.scss';
export type NoMenuLayoutProps = PropsWithChildren & { export type NoMenuLayoutProps = PropsWithChildren & {
className?: string; className?: string;
}; };
export const NoMenuLayout: FC<NoMenuLayoutProps> = ({ children, className }) => ( export const NoMenuLayout: FC<NoMenuLayoutProps> = ({ children, className }) => (
<div className={clsx('no-menu-wrapper container-xl', className)}>{children}</div> <div className={clsx('tw:container tw:mx-auto tw:p-5 tw:pt-8 tw:max-md:p-0 tw:max-md:py-4', className)}>
{children}
</div>
); );

View File

@@ -1,19 +1,16 @@
import { SimpleCard } from '@shlinkio/shlink-frontend-kit'; import { Button } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC, PropsWithChildren } from 'react'; import type { FC, PropsWithChildren } from 'react';
import { Link } from 'react-router'; import { ErrorLayout } from './ErrorLayout';
type NotFoundProps = PropsWithChildren<{ to?: string }>; type NotFoundProps = PropsWithChildren<{ to?: string }>;
export const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => ( export const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
<div className="home"> <ErrorLayout title="Oops! We could not find requested route.">
<SimpleCard className="p-4"> <p>
<h2>Oops! We could not find requested route.</h2> Use your browser&apos;s back button to navigate to the page you have previously come from, or just press this
<p> button.
Use your browser&apos;s back button to navigate to the page you have previously come from, or just press this </p>
button. <br />
</p> <Button inline to={to} size="lg">{children}</Button>
<br /> </ErrorLayout>
<Link to={to} className="btn btn-outline-primary btn-lg">{children}</Link>
</SimpleCard>
</div>
); );

View File

@@ -12,7 +12,7 @@ export interface ShlinkVersionsProps {
} }
const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-client'; version: string }) => ( const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-client'; version: string }) => (
<ExternalLink href={`https://github.com/shlinkio/${project}/releases/${version}`} className="text-muted"> <ExternalLink href={`https://github.com/shlinkio/${project}/releases/${version}`} className="tw:text-gray-500">
<b>{version}</b> <b>{version}</b>
</ExternalLink> </ExternalLink>
); );
@@ -21,7 +21,7 @@ export const ShlinkVersions = ({ selectedServer, clientVersion = SHLINK_WEB_CLIE
const normalizedClientVersion = normalizeVersion(clientVersion); const normalizedClientVersion = normalizeVersion(clientVersion);
return ( return (
<small className="text-muted"> <small className="tw:text-gray-500">
{isReachableServer(selectedServer) && ( {isReachableServer(selectedServer) && (
<>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </> <>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </>
)} )}

View File

@@ -1,9 +0,0 @@
@use '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
.shlink-versions-container--with-sidebar {
margin-left: 0;
@media (min-width: base.$mdMin) {
margin-left: base.$asideMenuWidth;
}
}

View File

@@ -2,7 +2,6 @@ import { clsx } from 'clsx';
import type { SelectedServer } from '../servers/data'; import type { SelectedServer } from '../servers/data';
import { isReachableServer } from '../servers/data'; import { isReachableServer } from '../servers/data';
import { ShlinkVersions } from './ShlinkVersions'; import { ShlinkVersions } from './ShlinkVersions';
import './ShlinkVersionsContainer.scss';
export type ShlinkVersionsContainerProps = { export type ShlinkVersionsContainerProps = {
selectedServer: SelectedServer; selectedServer: SelectedServer;
@@ -10,9 +9,7 @@ export type ShlinkVersionsContainerProps = {
export const ShlinkVersionsContainer = ({ selectedServer }: ShlinkVersionsContainerProps) => ( export const ShlinkVersionsContainer = ({ selectedServer }: ShlinkVersionsContainerProps) => (
<div <div
className={clsx('text-center', { className={clsx('tw:text-center', { 'tw:md:ml-(--aside-menu-width)': isReachableServer(selectedServer) })}
'shlink-versions-container--with-sidebar': isReachableServer(selectedServer),
})}
> >
<ShlinkVersions selectedServer={selectedServer} /> <ShlinkVersions selectedServer={selectedServer} />
</div> </div>

View File

@@ -5,6 +5,7 @@ import pack from '../package.json';
import { container } from './container'; import { container } from './container';
import { setUpStore } from './container/store'; import { setUpStore } from './container/store';
import { register as registerServiceWorker } from './serviceWorkerRegistration'; import { register as registerServiceWorker } from './serviceWorkerRegistration';
import './tailwind.css';
import './index.scss'; import './index.scss';
const store = setUpStore(container); const store = setUpStore(container);

View File

@@ -1,9 +1,10 @@
import type { TimeoutToggle } from '@shlinkio/shlink-frontend-kit'; import type { TimeoutToggle } from '@shlinkio/shlink-frontend-kit';
import { Result, useToggle } from '@shlinkio/shlink-frontend-kit'; import { useToggle } from '@shlinkio/shlink-frontend-kit';
import type { ResultProps } from '@shlinkio/shlink-frontend-kit/tailwind';
import { Button, Result } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC } from 'react'; import type { FC } from 'react';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { Button } from 'reactstrap';
import { NoMenuLayout } from '../common/NoMenuLayout'; import { NoMenuLayout } from '../common/NoMenuLayout';
import type { FCWithDeps } from '../container/utils'; import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils'; import { componentFactory, useDependencies } from '../container/utils';
@@ -26,11 +27,11 @@ type CreateServerDeps = {
useTimeoutToggle: TimeoutToggle; useTimeoutToggle: TimeoutToggle;
}; };
const ImportResult = ({ type }: { type: 'error' | 'success' }) => ( const ImportResult = ({ variant }: Pick<ResultProps, 'variant'>) => (
<div className="mt-3"> <div className="tw:mt-4">
<Result type={type}> <Result variant={variant}>
{type === 'success' && 'Servers properly imported. You can now select one from the list :)'} {variant === 'success' && 'Servers properly imported. You can now select one from the list :)'}
{type === 'error' && 'The servers could not be imported. Make sure the format is correct.'} {variant === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
</Result> </Result>
</div> </div>
); );
@@ -68,22 +69,22 @@ const CreateServer: FCWithDeps<CreateServerProps, CreateServerDeps> = ({ servers
return ( return (
<NoMenuLayout> <NoMenuLayout>
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={onSubmit}> <ServerForm title="Add new server" onSubmit={onSubmit}>
{!hasServers && ( {!hasServers && (
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} /> <ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onError={setErrorImporting} />
)} )}
{hasServers && <Button outline onClick={goBack}>Cancel</Button>} {hasServers && <Button type="button" variant="secondary" onClick={goBack}>Cancel</Button>}
<Button outline color="primary" className="ms-2">Create server</Button> <Button type="submit">Create server</Button>
</ServerForm> </ServerForm>
{serversImported && <ImportResult type="success" />} {serversImported && <ImportResult variant="success" />}
{errorImporting && <ImportResult type="error" />} {errorImporting && <ImportResult variant="error" />}
<DuplicatedServersModal <DuplicatedServersModal
isOpen={isConfirmModalOpen} open={isConfirmModalOpen}
duplicatedServers={serverData ? [serverData] : []} duplicatedServers={serverData ? [serverData] : []}
onDiscard={goBack} onClose={goBack}
onSave={() => serverData && saveNewServer(serverData)} onConfirm={() => serverData && saveNewServer(serverData)}
/> />
</NoMenuLayout> </NoMenuLayout>
); );

View File

@@ -1,8 +1,7 @@
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useToggle } from '@shlinkio/shlink-frontend-kit'; import { useToggle } from '@shlinkio/shlink-frontend-kit';
import { clsx } from 'clsx';
import type { FC, PropsWithChildren } from 'react'; import type { FC, PropsWithChildren } from 'react';
import { useCallback } from 'react';
import { useNavigate } from 'react-router';
import type { FCWithDeps } from '../container/utils'; import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils'; import { componentFactory, useDependencies } from '../container/utils';
import type { ServerWithId } from './data'; import type { ServerWithId } from './data';
@@ -10,28 +9,29 @@ import type { DeleteServerModalProps } from './DeleteServerModal';
export type DeleteServerButtonProps = PropsWithChildren<{ export type DeleteServerButtonProps = PropsWithChildren<{
server: ServerWithId; server: ServerWithId;
className?: string;
textClassName?: string;
}>; }>;
type DeleteServerButtonDeps = { type DeleteServerButtonDeps = {
DeleteServerModal: FC<DeleteServerModalProps>; DeleteServerModal: FC<DeleteServerModalProps>;
}; };
const DeleteServerButton: FCWithDeps<DeleteServerButtonProps, DeleteServerButtonDeps> = ( const DeleteServerButton: FCWithDeps<DeleteServerButtonProps, DeleteServerButtonDeps> = ({ server, children }) => {
{ server, className, children, textClassName },
) => {
const { DeleteServerModal } = useDependencies(DeleteServerButton); const { DeleteServerModal } = useDependencies(DeleteServerButton);
const [isModalOpen, , showModal, hideModal] = useToggle(); const [isModalOpen, , showModal, hideModal] = useToggle();
const navigate = useNavigate();
const onClose = useCallback((confirmed: boolean) => {
hideModal();
if (confirmed) {
navigate('/');
}
}, [hideModal, navigate]);
return ( return (
<> <>
<button type="button" className={clsx(className, 'p-0 bg-transparent border-0')} onClick={showModal}> <button type="button" className="tw:text-danger tw:hover:underline" onClick={showModal}>
{!children && <FontAwesomeIcon fixedWidth icon={deleteIcon} />} {children}
<span className={textClassName}>{children ?? 'Remove this server'}</span>
</button> </button>
<DeleteServerModal server={server} open={isModalOpen} onClose={onClose} />
<DeleteServerModal server={server} isOpen={isModalOpen} toggle={hideModal} />
</> </>
); );
}; };

View File

@@ -1,44 +1,37 @@
import type { ExitAction } from '@shlinkio/shlink-frontend-kit/tailwind';
import { CardModal } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC } from 'react'; import type { FC } from 'react';
import { useRef } from 'react'; import { useCallback } from 'react';
import { useNavigate } from 'react-router';
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import type { ServerWithId } from './data'; import type { ServerWithId } from './data';
export interface DeleteServerModalProps { export type DeleteServerModalProps = {
server: ServerWithId; server: ServerWithId;
toggle: () => void; onClose: (confirmed: boolean) => void;
isOpen: boolean; open: boolean;
redirectHome?: boolean; };
}
interface DeleteServerModalConnectProps extends DeleteServerModalProps { type DeleteServerModalConnectProps = DeleteServerModalProps & {
deleteServer: (server: ServerWithId) => void; deleteServer: (server: ServerWithId) => void;
} };
export const DeleteServerModal: FC<DeleteServerModalConnectProps> = ( export const DeleteServerModal: FC<DeleteServerModalConnectProps> = ({ server, onClose, open, deleteServer }) => {
{ server, toggle, isOpen, deleteServer, redirectHome = true }, const onClosed = useCallback((exitAction: ExitAction) => {
) => { if (exitAction === 'confirm') {
const navigate = useNavigate(); deleteServer(server);
const doDelete = useRef<boolean>(false);
const toggleAndDelete = () => {
doDelete.current = true;
toggle();
};
const onClosed = () => {
if (!doDelete.current) {
return;
} }
}, [deleteServer, server]);
deleteServer(server);
if (redirectHome) {
navigate('/');
}
};
return ( return (
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={onClosed}> <CardModal
<ModalHeader toggle={toggle} className="text-danger">Remove server</ModalHeader> open={open}
<ModalBody> title="Remove server"
variant="danger"
onClose={() => onClose(false)}
onConfirm={() => onClose(true)}
onClosed={onClosed}
confirmText="Delete"
>
<div className="tw:flex tw:flex-col tw:gap-y-4">
<p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p> <p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p>
<p> <p>
<i> <i>
@@ -46,11 +39,7 @@ export const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
You can create it again at any moment. You can create it again at any moment.
</i> </i>
</p> </p>
</ModalBody> </div>
<ModalFooter> </CardModal>
<Button color="link" onClick={toggle}>Cancel</Button>
<Button color="danger" onClick={toggleAndDelete}>Delete</Button>
</ModalFooter>
</Modal>
); );
}; };

View File

@@ -1,6 +1,6 @@
import { useParsedQuery } from '@shlinkio/shlink-frontend-kit'; import { useParsedQuery } from '@shlinkio/shlink-frontend-kit';
import { Button } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC } from 'react'; import type { FC } from 'react';
import { Button } from 'reactstrap';
import { NoMenuLayout } from '../common/NoMenuLayout'; import { NoMenuLayout } from '../common/NoMenuLayout';
import type { FCWithDeps } from '../container/utils'; import type { FCWithDeps } from '../container/utils';
import { componentFactory } from '../container/utils'; import { componentFactory } from '../container/utils';
@@ -40,12 +40,12 @@ const EditServer: FCWithDeps<EditServerProps, EditServerDeps> = withSelectedServ
return ( return (
<NoMenuLayout> <NoMenuLayout>
<ServerForm <ServerForm
title={<h5 className="mb-0">Edit &quot;{selectedServer.name}&quot;</h5>} title={<>Edit &quot;{selectedServer.name}&quot;</>}
initialValues={selectedServer} initialValues={selectedServer}
onSubmit={handleSubmit} onSubmit={handleSubmit}
> >
<Button outline className="me-2" onClick={goBack}>Cancel</Button> <Button type="button" variant="secondary" onClick={goBack}>Cancel</Button>
<Button outline color="primary">Save</Button> <Button>Save</Button>
</ServerForm> </ServerForm>
</NoMenuLayout> </NoMenuLayout>
); );

View File

@@ -1,11 +1,9 @@
import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons'; import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { TimeoutToggle } from '@shlinkio/shlink-frontend-kit'; import type { TimeoutToggle } from '@shlinkio/shlink-frontend-kit';
import { Result, SearchField, SimpleCard } from '@shlinkio/shlink-frontend-kit'; import { Button, Result, SearchInput, SimpleCard, Table } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC } from 'react'; import type { FC } from 'react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Link } from 'react-router';
import { Button } from 'reactstrap';
import { NoMenuLayout } from '../common/NoMenuLayout'; import { NoMenuLayout } from '../common/NoMenuLayout';
import type { FCWithDeps } from '../container/utils'; import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils'; import { componentFactory, useDependencies } from '../container/utils';
@@ -45,45 +43,46 @@ const ManageServers: FCWithDeps<ManageServersProps, ManageServersDeps> = ({ serv
const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME); const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
return ( return (
<NoMenuLayout className="d-flex flex-column gap-3"> <NoMenuLayout className="tw:flex tw:flex-col tw:gap-y-4">
<SearchField onChange={setSearchTerm} /> <SearchInput onChange={setSearchTerm} />
<div className="d-flex flex-column flex-md-row gap-2"> <div className="tw:flex tw:flex-col tw:md:flex-row tw:gap-2">
<div className="d-flex gap-2"> <div className="tw:flex tw:gap-2">
<ImportServersBtn className="flex-fill" onImportError={setErrorImporting}>Import servers</ImportServersBtn> <ImportServersBtn className="tw:flex-grow" onError={setErrorImporting}>Import servers</ImportServersBtn>
{filteredServers.length > 0 && ( {filteredServers.length > 0 && (
<Button outline className="flex-fill" onClick={async () => serversExporter.exportServers()}> <Button variant="secondary" className="tw:flex-grow" onClick={async () => serversExporter.exportServers()}>
<FontAwesomeIcon icon={exportIcon} fixedWidth /> Export servers <FontAwesomeIcon icon={exportIcon} /> Export servers
</Button> </Button>
)} )}
</div> </div>
<Button outline color="primary" className="ms-md-auto" tag={Link} to="/server/create"> <Button className="tw:md:ml-auto" to="/server/create">
<FontAwesomeIcon icon={plusIcon} fixedWidth /> Add a server <FontAwesomeIcon icon={plusIcon} /> Add a server
</Button> </Button>
</div> </div>
<SimpleCard> <SimpleCard className="card">
<table className="table table-hover responsive-table mb-0"> <Table header={(
<thead className="responsive-table__header"> <Table.Row>
<tr> {hasAutoConnect && (
{hasAutoConnect && <th style={{ width: '50px' }}><span className="sr-only">Auto-connect</span></th>} <Table.Cell className="tw:w-[35px]"><span className="tw:sr-only">Auto-connect</span></Table.Cell>
<th>Name</th> )}
<th>Base URL</th> <Table.Cell>Name</Table.Cell>
<th><span className="sr-only">Options</span></th> <Table.Cell>Base URL</Table.Cell>
</tr> <Table.Cell><span className="sr-only">Options</span></Table.Cell>
</thead> </Table.Row>
<tbody> )}>
{!filteredServers.length && <tr className="text-center"><td colSpan={4}>No servers found.</td></tr>} {!filteredServers.length && (
{filteredServers.map((server) => ( <Table.Row className="tw:text-center"><Table.Cell colSpan={4}>No servers found.</Table.Cell></Table.Row>
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} /> )}
))} {filteredServers.map((server) => (
</tbody> <ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />
</table> ))}
</Table>
</SimpleCard> </SimpleCard>
{errorImporting && ( {errorImporting && (
<div> <div>
<Result type="error">The servers could not be imported. Make sure the format is correct.</Result> <Result variant="error">The servers could not be imported. Make sure the format is correct.</Result>
</div> </div>
)} )}
</NoMenuLayout> </NoMenuLayout>

View File

@@ -1,5 +1,6 @@
import { faCheck as checkIcon } from '@fortawesome/free-solid-svg-icons'; import { faCheck as checkIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Table } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC } from 'react'; import type { FC } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { UncontrolledTooltip } from 'reactstrap'; import { UncontrolledTooltip } from 'reactstrap';
@@ -21,27 +22,27 @@ const ManageServersRow: FCWithDeps<ManageServersRowProps, ManageServersRowDeps>
const { ManageServersRowDropdown } = useDependencies(ManageServersRow); const { ManageServersRowDropdown } = useDependencies(ManageServersRow);
return ( return (
<tr className="responsive-table__row"> <Table.Row className="tw:relative">
{hasAutoConnect && ( {hasAutoConnect && (
<td className="responsive-table__cell" data-th="Auto-connect"> <Table.Cell columnName="Auto-connect">
{server.autoConnect && ( {server.autoConnect && (
<> <>
<FontAwesomeIcon icon={checkIcon} className="text-primary" id="autoConnectIcon" /> <FontAwesomeIcon icon={checkIcon} className="tw:text-brand" id="autoConnectIcon" />
<UncontrolledTooltip target="autoConnectIcon" placement="right"> <UncontrolledTooltip target="autoConnectIcon" placement="right">
Auto-connect to this server Auto-connect to this server
</UncontrolledTooltip> </UncontrolledTooltip>
</> </>
)} )}
</td> </Table.Cell>
)} )}
<th className="responsive-table__cell" data-th="Name"> <Table.Cell className="tw:font-bold" columnName="Name">
<Link to={`/server/${server.id}`}>{server.name}</Link> <Link to={`/server/${server.id}`}>{server.name}</Link>
</th> </Table.Cell>
<td className="responsive-table__cell" data-th="Base URL">{server.url}</td> <Table.Cell columnName="Base URL" className="tw:max-lg:border-b-0">{server.url}</Table.Cell>
<td className="responsive-table__cell text-end"> <Table.Cell className="tw:text-right tw:max-lg:absolute tw:right-0 tw:-top-1 tw:mx-lg:pt-0">
<ManageServersRowDropdown server={server} /> <ManageServersRowDropdown server={server} />
</td> </Table.Cell>
</tr> </Table.Row>
); );
}; };

View File

@@ -48,11 +48,11 @@ const ManageServersRowDropdown: FCWithDeps<ManageServersRowDropdownConnectProps,
<FontAwesomeIcon icon={autoConnectIcon} fixedWidth /> {isAutoConnect ? 'Do not a' : 'A'}uto-connect <FontAwesomeIcon icon={autoConnectIcon} fixedWidth /> {isAutoConnect ? 'Do not a' : 'A'}uto-connect
</DropdownItem> </DropdownItem>
<DropdownItem divider tag="hr" /> <DropdownItem divider tag="hr" />
<DropdownItem className="dropdown-item--danger" onClick={showModal}> <DropdownItem className="tw:text-danger" onClick={showModal}>
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Remove server <FontAwesomeIcon icon={deleteIcon} fixedWidth /> Remove server
</DropdownItem> </DropdownItem>
<DeleteServerModal redirectHome={false} server={server} isOpen={isModalOpen} toggle={hideModal} /> <DeleteServerModal server={server} open={isModalOpen} onClose={hideModal} />
</RowDropdownBtn> </RowDropdownBtn>
); );
}; };

View File

@@ -17,7 +17,7 @@ export const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProp
if (serversList.length === 0) { if (serversList.length === 0) {
return ( return (
<DropdownItem tag={Link} to="/server/create"> <DropdownItem tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon} /> <span className="ms-1">Add a server</span> <FontAwesomeIcon icon={plusIcon} /> <span className="tw:ml-1">Add a server</span>
</DropdownItem> </DropdownItem>
); );
} }
@@ -31,7 +31,7 @@ export const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProp
))} ))}
<DropdownItem divider tag="hr" /> <DropdownItem divider tag="hr" />
<DropdownItem tag={Link} to="/manage-servers"> <DropdownItem tag={Link} to="/manage-servers">
<FontAwesomeIcon icon={serverIcon} /> <span className="ms-1">Manage servers</span> <FontAwesomeIcon icon={serverIcon} /> <span className="tw:ml-1">Manage servers</span>
</DropdownItem> </DropdownItem>
</> </>
); );
@@ -40,9 +40,9 @@ export const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProp
return ( return (
<UncontrolledDropdown nav inNavbar> <UncontrolledDropdown nav inNavbar>
<DropdownToggle nav caret> <DropdownToggle nav caret>
<FontAwesomeIcon icon={serverIcon} /> <span className="ms-1">Servers</span> <FontAwesomeIcon icon={serverIcon} /> <span className="tw:ml-1">Servers</span>
</DropdownToggle> </DropdownToggle>
<DropdownMenu end style={{ right: 0 }}>{renderServers()}</DropdownMenu> <DropdownMenu end classNam="tw:right-0">{renderServers()}</DropdownMenu>
</UncontrolledDropdown> </UncontrolledDropdown>
); );
}; };

View File

@@ -1,49 +0,0 @@
@use '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
@use '../utils/mixins/vertical-align';
@use '../utils/mixins/thin-scroll';
.servers-list__list-group.servers-list__list-group {
width: 100%;
}
.servers-list__list-group:not(.servers-list__list-group--embedded) {
max-width: 400px;
box-shadow: 0 .125rem .25rem rgb(0 0 0 / .075);
}
.servers-list__server-item.servers-list__server-item {
text-align: left;
position: relative;
padding: .75rem 2.5rem .75rem 1rem;
}
.servers-list__server-item:not(:hover) {
color: base.$mainColor;
}
.servers-list__server-item:hover {
background-color: var(--secondary-color);
}
.servers-list__server-item-icon {
@include vertical-align.vertical-align();
right: 1rem;
}
.servers-list__list-group--embedded.servers-list__list-group--embedded {
border-radius: 0;
border-top: 1px solid var(--border-color);
@media (min-width: base.$mdMin) {
max-height: 220px;
overflow-x: auto;
@include thin-scroll.thin-scroll();
}
.servers-list__server-item {
border: none;
border-bottom: 1px solid var(--border-color);
}
}

View File

@@ -1,35 +1,43 @@
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons'; import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import type { FC, PropsWithChildren } from 'react'; import type { FC } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { ListGroup, ListGroupItem } from 'reactstrap';
import type { ServerWithId } from './data'; import type { ServerWithId } from './data';
import './ServersListGroup.scss';
type ServersListGroupProps = PropsWithChildren<{ type ServersListGroupProps = {
servers: ServerWithId[]; servers: ServerWithId[];
embedded?: boolean; borderless?: boolean;
}>; };
const ServerListItem = ({ id, name }: { id: string; name: string }) => ( const ServerListItem = ({ id, name }: { id: string; name: string }) => (
<ListGroupItem tag={Link} to={`/server/${id}`} className="servers-list__server-item"> <Link
{name} to={`/server/${id}`}
<FontAwesomeIcon icon={chevronIcon} className="servers-list__server-item-icon" /> className={clsx(
</ListGroupItem> 'servers-list__server-item',
'tw:flex tw:items-center tw:justify-between tw:gap-x-2 tw:px-4 tw:py-3',
'tw:rounded-none tw:hover:bg-lm-secondary tw:hover:dark:bg-dm-secondary',
'tw:border-b tw:last:border-0 tw:border-lm-border tw:dark:border-dm-border',
)}
>
<span className="tw:truncate">{name}</span>
<FontAwesomeIcon icon={chevronIcon} />
</Link>
); );
export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, children, embedded = false }) => ( export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, borderless }) => (
<> <>
{children && <div data-testid="title" className="mb-0 fs-5 fw-normal lh-sm">{children}</div>}
{servers.length > 0 && ( {servers.length > 0 && (
<ListGroup <div
data-testid="list" data-testid="list"
tag="div" className={clsx(
className={clsx('servers-list__list-group', { 'servers-list__list-group--embedded': embedded })} 'tw:w-full tw:border-lm-border tw:dark:border-dm-border',
'tw:md:max-h-56 tw:md:overflow-y-auto tw:-mb-1 tw:scroll-thin',
{ 'tw:border-y': !borderless },
)}
> >
{servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)} {servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)}
</ListGroup> </div>
)} )}
</> </>
); );

View File

@@ -1,41 +1,42 @@
import { CardModal } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC } from 'react'; import type { FC } from 'react';
import { Fragment } from 'react'; import { Fragment } from 'react';
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import type { ServerData } from '../data'; import type { ServerData } from '../data';
interface DuplicatedServersModalProps { export type DuplicatedServersModalProps = {
duplicatedServers: ServerData[]; duplicatedServers: ServerData[];
isOpen: boolean; open: boolean;
onDiscard: () => void; onClose: () => void;
onSave: () => void; onConfirm: () => void;
} };
export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = ( export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
{ isOpen, duplicatedServers, onDiscard, onSave }, { open, duplicatedServers, onClose, onConfirm },
) => { ) => {
const hasMultipleServers = duplicatedServers.length > 1; const hasMultipleServers = duplicatedServers.length > 1;
return ( return (
<Modal centered isOpen={isOpen}> <CardModal
<ModalHeader>Duplicated server{hasMultipleServers && 's'}</ModalHeader> size="lg"
<ModalBody> title={`Duplicated server${hasMultipleServers ? 's' : ''}`}
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p> open={open}
<ul> onClose={onClose}
{duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? ( onConfirm={onConfirm}
<Fragment key={index}> confirmText={`Save duplicate${hasMultipleServers ? 's' : ''}`}
<li>URL: <b>{url}</b></li> cancelText={hasMultipleServers ? 'Ignore duplicates' : 'Discard'}
<li>API key: <b>{apiKey}</b></li> >
</Fragment> <p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>))} <ul className="tw:list-disc tw:mt-4">
</ul> {duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? (
<span> <Fragment key={index}>
{hasMultipleServers ? 'Do you want to ignore duplicated servers' : 'Do you want to save this server anyway'}? <li>URL: <b>{url}</b></li>
</span> <li>API key: <b>{apiKey}</b></li>
</ModalBody> </Fragment>
<ModalFooter> ) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>))}
<Button color="link" onClick={onDiscard}>{hasMultipleServers ? 'Ignore duplicates' : 'Discard'}</Button> </ul>
<Button color="primary" onClick={onSave}>Save anyway</Button> <span>
</ModalFooter> {hasMultipleServers ? 'Do you want to save duplicated servers' : 'Do you want to save this server'}?
</Modal> </span>
</CardModal>
); );
}; };

View File

@@ -1,9 +1,10 @@
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons'; import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useElementRef, useToggle } from '@shlinkio/shlink-frontend-kit'; import { useElementRef, useToggle } from '@shlinkio/shlink-frontend-kit';
import { Button } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { ChangeEvent, PropsWithChildren } from 'react'; import type { ChangeEvent, PropsWithChildren } from 'react';
import { useCallback, useRef, useState } from 'react'; import { useCallback, useRef , useState } from 'react';
import { Button, UncontrolledTooltip } from 'reactstrap'; import { UncontrolledTooltip } from 'reactstrap';
import type { FCWithDeps } from '../../container/utils'; import type { FCWithDeps } from '../../container/utils';
import { componentFactory, useDependencies } from '../../container/utils'; import { componentFactory, useDependencies } from '../../container/utils';
import type { ServerData, ServersMap, ServerWithId } from '../data'; import type { ServerData, ServersMap, ServerWithId } from '../data';
@@ -13,7 +14,7 @@ import { dedupServers, ensureUniqueIds } from './index';
export type ImportServersBtnProps = PropsWithChildren<{ export type ImportServersBtnProps = PropsWithChildren<{
onImport?: () => void; onImport?: () => void;
onImportError?: (error: Error) => void; onError?: (error: Error) => void;
tooltipPlacement?: 'top' | 'bottom'; tooltipPlacement?: 'top' | 'bottom';
className?: string; className?: string;
}>; }>;
@@ -31,8 +32,8 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
createServers, createServers,
servers, servers,
children, children,
onImport = () => {}, onImport,
onImportError = () => {}, onError = () => {},
tooltipPlacement = 'bottom', tooltipPlacement = 'bottom',
className = '', className = '',
}) => { }) => {
@@ -40,50 +41,56 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
const ref = useElementRef<HTMLInputElement>(); const ref = useElementRef<HTMLInputElement>();
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]); const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
const [isModalOpen,, showModal, hideModal] = useToggle(); const [isModalOpen,, showModal, hideModal] = useToggle();
const newServersCreatedRef = useRef(false);
const importedServersRef = useRef<ServerWithId[]>([]);
const newServersRef = useRef<ServerWithId[]>([]);
const create = useCallback((serversData: ServerWithId[]) => {
createServers(serversData);
onImport();
}, [createServers, onImport]);
const onFile = useCallback( const onFile = useCallback(
async ({ target }: ChangeEvent<HTMLInputElement>) => async ({ target }: ChangeEvent<HTMLInputElement>) =>
serversImporter.importServersFromFile(target.files?.[0]) serversImporter.importServersFromFile(target.files?.[0])
.then((importedServers) => { .then((importedServers) => {
const { duplicatedServers, newServers } = dedupServers(servers, importedServers); const { duplicatedServers, newServers } = dedupServers(servers, importedServers);
importedServersRef.current = ensureUniqueIds(servers, importedServers); // Immediately create new servers
newServersRef.current = ensureUniqueIds(servers, newServers); newServersCreatedRef.current = newServers.length > 0;
createServers(ensureUniqueIds(servers, newServers));
if (duplicatedServers.length === 0) { // For duplicated servers, ask for confirmation
create(importedServersRef.current); if (duplicatedServers.length > 0) {
} else {
setDuplicatedServers(duplicatedServers); setDuplicatedServers(duplicatedServers);
showModal(); showModal();
} else {
onImport?.();
} }
}) })
.then(() => { .then(() => {
// Reset file input after processing file // Reset file input after processing file
(target as { value: string | null }).value = null; (target as { value: string | null }).value = null;
}) })
.catch(onImportError), .catch(onError),
[create, onImportError, servers, serversImporter, showModal], [createServers, onError, onImport, servers, serversImporter, showModal],
); );
const createAllServers = useCallback(() => { const createDuplicatedServers = useCallback(() => {
create(importedServersRef.current); createServers(ensureUniqueIds(servers, duplicatedServers));
hideModal(); hideModal();
}, [create, hideModal]); onImport?.();
const createNonDuplicatedServers = useCallback(() => { }, [createServers, duplicatedServers, hideModal, onImport, servers]);
create(newServersRef.current); const discardDuplicatedServers = useCallback(() => {
hideModal(); hideModal();
}, [create, hideModal]); // If duplicated servers were discarded but some non-duplicated servers were created, call onImport
if (newServersCreatedRef.current) {
onImport?.();
}
}, [hideModal, onImport]);
return ( return (
<> <>
<Button outline id="importBtn" className={className} onClick={() => ref.current?.click()}> <Button
type="button"
variant="secondary"
id="importBtn"
className={className}
onClick={() => ref.current?.click()}
>
<FontAwesomeIcon icon={importIcon} fixedWidth /> {children ?? 'Import from file'} <FontAwesomeIcon icon={importIcon} fixedWidth /> {children ?? 'Import from file'}
</Button> </Button>
<UncontrolledTooltip placement={tooltipPlacement} target="importBtn"> <UncontrolledTooltip placement={tooltipPlacement} target="importBtn">
@@ -93,7 +100,7 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
<input <input
type="file" type="file"
accept=".csv" accept=".csv"
className="d-none" className="tw:hidden"
aria-hidden aria-hidden
ref={ref as any /* TODO Remove After updating to React 19 */} ref={ref as any /* TODO Remove After updating to React 19 */}
onChange={onFile} onChange={onFile}
@@ -101,10 +108,10 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
/> />
<DuplicatedServersModal <DuplicatedServersModal
isOpen={isModalOpen} open={isModalOpen}
duplicatedServers={duplicatedServers} duplicatedServers={duplicatedServers}
onDiscard={createNonDuplicatedServers} onClose={discardDuplicatedServers}
onSave={createAllServers} onConfirm={createDuplicatedServers}
/> />
</> </>
); );

View File

@@ -1,17 +0,0 @@
@use '../../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
.server-error__container {
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.server-error__delete-btn {
color: base.$dangerColor;
font-weight: inherit;
}
.server-error__delete-btn:hover {
text-decoration: underline;
}

View File

@@ -1,4 +1,4 @@
import { Message } from '@shlinkio/shlink-frontend-kit'; import { Card, Message } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC } from 'react'; import type { FC } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { NoMenuLayout } from '../../common/NoMenuLayout'; import { NoMenuLayout } from '../../common/NoMenuLayout';
@@ -8,7 +8,6 @@ import type { SelectedServer, ServersMap } from '../data';
import { isServerWithId } from '../data'; import { isServerWithId } from '../data';
import type { DeleteServerButtonProps } from '../DeleteServerButton'; import type { DeleteServerButtonProps } from '../DeleteServerButton';
import { ServersListGroup } from '../ServersListGroup'; import { ServersListGroup } from '../ServersListGroup';
import './ServerError.scss';
type ServerErrorProps = { type ServerErrorProps = {
servers: ServersMap; servers: ServersMap;
@@ -24,8 +23,8 @@ const ServerError: FCWithDeps<ServerErrorProps, ServerErrorDeps> = ({ servers, s
return ( return (
<NoMenuLayout> <NoMenuLayout>
<div className="server-error__container flex-column"> <div className="tw:flex tw:flex-col tw:items-center tw:gap-y-4 tw:md:gap-y-8">
<Message className="w-100 mb-3 mb-md-5" type="error" fullWidth> <Message className="tw:w-full tw:lg:w-[80%]" variant="error">
{!isServerWithId(selectedServer) && 'Could not find this Shlink server.'} {!isServerWithId(selectedServer) && 'Could not find this Shlink server.'}
{isServerWithId(selectedServer) && ( {isServerWithId(selectedServer) && (
<> <>
@@ -35,21 +34,21 @@ const ServerError: FCWithDeps<ServerErrorProps, ServerErrorDeps> = ({ servers, s
)} )}
</Message> </Message>
<ServersListGroup servers={Object.values(servers)}> <p className="tw:text-xl">
<p className="mb-md-3"> These are the Shlink servers currently configured. Choose one of
These are the Shlink servers currently configured. Choose one of them or <Link to="/server/create">add a new one</Link>.
them or <Link to="/server/create">add a new one</Link>. </p>
</p> <Card className="tw:w-full tw:max-w-100 tw:overflow-hidden">
</ServersListGroup> <ServersListGroup borderless servers={Object.values(servers)} />
</Card>
{isServerWithId(selectedServer) && ( {isServerWithId(selectedServer) && (
<div className="container mt-3 mt-md-5"> <p className="tw:text-xl">
<p className="fs-5 fw-normal lh-sm"> Alternatively, if you think you may have misconfigured this server, you
Alternatively, if you think you may have misconfigured this server, you can <DeleteServerButton server={selectedServer}>remove
can <DeleteServerButton server={selectedServer} className="server-error__delete-btn">remove it</DeleteServerButton> or&nbsp; it</DeleteServerButton> or&nbsp;
<Link to={`/server/${selectedServer.id}/edit?reconnect=true`}>edit it</Link>. <Link to={`/server/${selectedServer.id}/edit?reconnect=true`}>edit it</Link>.
</p> </p>
</div>
)} )}
</div> </div>
</NoMenuLayout> </NoMenuLayout>

View File

@@ -1,4 +1,4 @@
import { InputFormGroup, SimpleCard } from '@shlinkio/shlink-frontend-kit'; import { LabelledInput, SimpleCard } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC, PropsWithChildren, ReactNode } from 'react'; import type { FC, PropsWithChildren, ReactNode } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { handleEventPreventingDefault } from '../../utils/utils'; import { handleEventPreventingDefault } from '../../utils/utils';
@@ -17,14 +17,20 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
const handleSubmit = handleEventPreventingDefault(() => onSubmit({ name, url, apiKey })); const handleSubmit = handleEventPreventingDefault(() => onSubmit({ name, url, apiKey }));
return ( return (
<form className="server-form" name="serverForm" onSubmit={handleSubmit}> <form name="serverForm" onSubmit={handleSubmit}>
<SimpleCard className="mb-3" title={title}> <SimpleCard className="tw:mb-4" bodyClassName="tw:flex tw:flex-col tw:gap-y-3" title={title}>
<InputFormGroup value={name} onChange={setName}>Name</InputFormGroup> <LabelledInput label="Name" value={name} onChange={(e) => setName(e.target.value)} required />
<InputFormGroup type="url" value={url} onChange={setUrl}>URL</InputFormGroup> <LabelledInput label="URL" type="url" value={url} onChange={(e) => setUrl(e.target.value)} required />
<InputFormGroup value={apiKey} onChange={setApiKey}>API key</InputFormGroup> <LabelledInput
label="API key"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
required
/>
</SimpleCard> </SimpleCard>
<div className="text-end">{children}</div> <div className="tw:flex tw:items-center tw:justify-end tw:gap-x-2">{children}</div>
</form> </form>
); );
}; };

View File

@@ -1,4 +1,4 @@
import { Message } from '@shlinkio/shlink-frontend-kit'; import { Message } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC } from 'react'; import type { FC } from 'react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useParams } from 'react-router'; import { useParams } from 'react-router';

27
src/tailwind.css Normal file
View File

@@ -0,0 +1,27 @@
@import 'tailwindcss' prefix(tw) important;
@source '../node_modules/@shlinkio/shlink-frontend-kit';
@import '@shlinkio/shlink-frontend-kit/tailwind.preset.css';
@utility scroll-thin {
/* Standard. New browsers */
scrollbar-width: thin;
/* Fallback */
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
border-radius: .5rem;
}
}
@layer base {
:root {
--header-height: 56px;
--footer-height: 2.3rem;
--footer-margin: .8rem;
/* Width of ShlinkWebComponent's side menu when not collapsed */
--aside-menu-width: 260px;
}
}

View File

@@ -1,5 +0,0 @@
@mixin horizontal-align {
position: absolute;
left: 50%;
transform: translateX(-50%);
}

View File

@@ -1,16 +0,0 @@
@mixin thin-scroll() {
/* Forefox scrollbar */
scrollbar-color: rgba(0, 0, 0, .2) #f5f5f5;
scrollbar-width: thin;
/* Chrome webkit scrollbar */
&::-webkit-scrollbar {
width: 6px;
background-color: #f5f5f5;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, .2);
border-radius: .5rem;
}
}

View File

@@ -1,5 +0,0 @@
@mixin vertical-align($extraTransforms: null) {
position: absolute;
top: 50%;
transform: translateY(-50%) $extraTransforms;
}

View File

@@ -1,14 +1,24 @@
import { useToggle } from '@shlinkio/shlink-frontend-kit';
import type { FC, ReactElement } from 'react'; import type { FC, ReactElement } from 'react';
import { useCallback, useEffect , useState } from 'react';
interface RenderModalArgs { export type RenderModalArgs = {
isOpen: boolean; open: boolean;
toggle: () => void; onClose: () => void;
} };
export const TestModalWrapper: FC<{ renderModal: (args: RenderModalArgs) => ReactElement }> = ( export const TestModalWrapper: FC<{ renderModal: (args: RenderModalArgs) => ReactElement }> = (
{ renderModal }, { renderModal },
) => { ) => {
const [isOpen, toggle] = useToggle(true); const [open, setOpen] = useState(true);
return renderModal({ isOpen, toggle }); const onClose = useCallback(() => setOpen(false), []);
// Workaround to ensure CardModals from shlink-frontend-shared can be closed, as they depend on CSS transitions
// Since JSDOM does not support them, this dispatches the event right after the listener has been set-up
useEffect(() => {
if (!open) {
document.querySelector('[data-testid="transition-container"]')?.dispatchEvent(new Event('transitionend'));
}
}, [open]);
return renderModal({ open, onClose });
}; };

View File

@@ -54,13 +54,17 @@ describe('<App />', () => {
}); });
it.each([ it.each([
['/foo', 'shlink-wrapper'], ['/foo', false],
['/bar', 'shlink-wrapper'], ['/bar', false],
['/', 'shlink-wrapper d-flex align-items-center pt-3'], ['/', true],
])('renders expected classes on shlink-wrapper based on current pathname', async (pathname, expectedClasses) => { ])('renders expected classes on shlink-wrapper based on current pathname', async (pathname, isFlex) => {
const { container } = await setUp(pathname); await setUp(pathname);
const shlinkWrapper = container.querySelector('.shlink-wrapper'); const shlinkWrapper = screen.getByTestId('shlink-wrapper');
expect(shlinkWrapper).toHaveAttribute('class', expectedClasses); if (isFlex) {
expect(shlinkWrapper).toHaveClass('tw:flex');
} else {
expect(shlinkWrapper).not.toHaveClass('tw:flex');
}
}); });
}); });

View File

@@ -4,11 +4,11 @@ import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<AppUpdateBanner />', () => { describe('<AppUpdateBanner />', () => {
const toggle = vi.fn(); const onClose = vi.fn();
const forceUpdate = vi.fn(); const forceUpdate = vi.fn();
const setUp = async () => { const setUp = async () => {
const result = await act( const result = await act(
() => renderWithEvents(<AppUpdateBanner isOpen toggle={toggle} forceUpdate={forceUpdate} />), () => renderWithEvents(<AppUpdateBanner isOpen onClose={onClose} forceUpdate={forceUpdate} />),
); );
await waitFor(() => screen.getByRole('alert')); await waitFor(() => screen.getByRole('alert'));
@@ -28,9 +28,9 @@ describe('<AppUpdateBanner />', () => {
it('invokes toggle when alert is closed', async () => { it('invokes toggle when alert is closed', async () => {
const { user } = await setUp(); const { user } = await setUp();
expect(toggle).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled();
await user.click(screen.getByLabelText('Close')); await user.click(screen.getByLabelText('Close'));
expect(toggle).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();
}); });
it('triggers the update when clicking the button', async () => { it('triggers the update when clicking the button', async () => {

View File

@@ -51,14 +51,12 @@ describe('<MainHeader />', () => {
const toggle = screen.getByLabelText('Toggle navigation'); const toggle = screen.getByLabelText('Toggle navigation');
const icon = toggle.firstChild; const icon = toggle.firstChild;
expect(icon).toHaveAttribute('class', expect.stringMatching(/main-header__toggle-icon$/)); expect(icon).not.toHaveClass('tw:rotate-180');
await user.click(toggle); await user.click(toggle);
expect(icon).toHaveAttribute(
'class', expect(icon).toHaveClass('tw:rotate-180');
expect.stringMatching(/main-header__toggle-icon main-header__toggle-icon--opened$/),
);
await user.click(toggle); await user.click(toggle);
expect(icon).toHaveAttribute('class', expect.stringMatching(/main-header__toggle-icon$/)); expect(icon).not.toHaveClass('tw:rotate-180');
}); });
it('opens Collapse when clicking toggle', async () => { it('opens Collapse when clicking toggle', async () => {

View File

@@ -30,6 +30,5 @@ describe('<NotFound />', () => {
expect(link).toHaveAttribute('href', expectedLink); expect(link).toHaveAttribute('href', expectedLink);
expect(link).toHaveTextContent(expectedText); expect(link).toHaveTextContent(expectedText);
expect(link).toHaveAttribute('class', 'btn btn-outline-primary btn-lg');
}); });
}); });

View File

@@ -16,11 +16,16 @@ describe('<ShlinkVersionsContainer />', () => {
])('passes a11y checks', (selectedServer) => checkAccessibility(setUp(selectedServer))); ])('passes a11y checks', (selectedServer) => checkAccessibility(setUp(selectedServer)));
it.each([ it.each([
[null, 'text-center'], [null, false],
[fromPartial<SelectedServer>({}), 'text-center'], [fromPartial<SelectedServer>({}), false],
[fromPartial<ReachableServer>({ version: '1.0.0' }), 'text-center shlink-versions-container--with-sidebar'], [fromPartial<ReachableServer>({ version: '1.0.0' }), true],
])('renders proper col classes based on sidebar status', (selectedServer, expectedClasses) => { ])('renders proper col classes based on sidebar status', (selectedServer, shouldAddMargin) => {
const { container } = setUp(selectedServer); const { container } = setUp(selectedServer);
expect(container.firstChild).toHaveAttribute('class', `${expectedClasses}`);
if (shouldAddMargin) {
expect(container.firstChild).toHaveClass('tw:md:ml-(--aside-menu-width)');
} else {
expect(container.firstChild).not.toHaveClass('tw:md:ml-(--aside-menu-width)');
}
}); });
}); });

View File

@@ -1,18 +1,28 @@
import { screen, waitFor } from '@testing-library/react'; import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import { createMemoryHistory } from 'history';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Router } from 'react-router';
import { DeleteServerButtonFactory } from '../../src/servers/DeleteServerButton'; import { DeleteServerButtonFactory } from '../../src/servers/DeleteServerButton';
import type { DeleteServerModalProps } from '../../src/servers/DeleteServerModal'; import type { DeleteServerModalProps } from '../../src/servers/DeleteServerModal';
import { DeleteServerModal } from '../../src/servers/DeleteServerModal';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<DeleteServerButton />', () => { describe('<DeleteServerButton />', () => {
const DeleteServerButton = DeleteServerButtonFactory(fromPartial({ const DeleteServerButton = DeleteServerButtonFactory(fromPartial({
DeleteServerModal: ({ isOpen }: DeleteServerModalProps) => <>DeleteServerModal {isOpen ? '[Open]' : '[Closed]'}</>, DeleteServerModal: (props: DeleteServerModalProps) => <DeleteServerModal {...props} deleteServer={vi.fn()} />,
})); }));
const setUp = (children?: ReactNode) => renderWithEvents( const setUp = (children: ReactNode = 'Remove this server') => {
<DeleteServerButton server={fromPartial({})} textClassName="button">{children}</DeleteServerButton>, const history = createMemoryHistory({ initialEntries: ['/foo'] });
); const result = renderWithEvents(
<Router location={history.location} navigator={history}>
<DeleteServerButton server={fromPartial({})}>{children}</DeleteServerButton>
</Router>,
);
return { history, ...result };
};
it('passes a11y checks', () => checkAccessibility(setUp('Delete me'))); it('passes a11y checks', () => checkAccessibility(setUp('Delete me')));
@@ -20,7 +30,6 @@ describe('<DeleteServerButton />', () => {
['Foo bar'], ['Foo bar'],
['baz'], ['baz'],
['something'], ['something'],
[undefined],
])('renders expected content', (children) => { ])('renders expected content', (children) => {
const { container } = setUp(children); const { container } = setUp(children);
expect(container.firstChild).toBeTruthy(); expect(container.firstChild).toBeTruthy();
@@ -28,14 +37,21 @@ describe('<DeleteServerButton />', () => {
}); });
it('displays modal when button is clicked', async () => { it('displays modal when button is clicked', async () => {
const { user, container } = setUp(); const { user } = setUp();
expect(screen.getByText(/DeleteServerModal/)).toHaveTextContent(/Closed/); expect(screen.queryByText(/Are you sure you want to remove/)).not.toBeInTheDocument();
expect(screen.getByText(/DeleteServerModal/)).not.toHaveTextContent(/Open/); await user.click(screen.getByText('Remove this server'));
if (container.firstElementChild) { expect(screen.getByText(/Are you sure you want to remove/)).toBeInTheDocument();
await user.click(container.firstElementChild); });
}
await waitFor(() => expect(screen.getByText(/DeleteServerModal/)).toHaveTextContent(/Open/)); it('navigates to home when deletion is confirmed', async () => {
const { user, history } = setUp();
// Open modal
await user.click(screen.getByText('Remove this server'));
expect(history.location.pathname).toEqual('/foo');
await user.click(screen.getByRole('button', { name: 'Delete' }));
expect(history.location.pathname).toEqual('/');
}); });
}); });

View File

@@ -1,7 +1,5 @@
import { act, screen, waitFor } from '@testing-library/react'; import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import { createMemoryHistory } from 'history';
import { Router } from 'react-router';
import { DeleteServerModal } from '../../src/servers/DeleteServerModal'; import { DeleteServerModal } from '../../src/servers/DeleteServerModal';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithEvents } from '../__helpers__/setUpTest';
@@ -10,62 +8,51 @@ import { TestModalWrapper } from '../__helpers__/TestModalWrapper';
describe('<DeleteServerModal />', () => { describe('<DeleteServerModal />', () => {
const deleteServerMock = vi.fn(); const deleteServerMock = vi.fn();
const serverName = 'the_server_name'; const serverName = 'the_server_name';
const setUp = async () => { const setUp = () => renderWithEvents(
const history = createMemoryHistory({ initialEntries: ['/foo'] }); <TestModalWrapper
const result = await act(() => renderWithEvents( renderModal={(args) => (
<Router location={history.location} navigator={history}> <DeleteServerModal
<TestModalWrapper {...args}
renderModal={(args) => ( server={fromPartial({ name: serverName })}
<DeleteServerModal deleteServer={deleteServerMock}
{...args}
server={fromPartial({ name: serverName })}
deleteServer={deleteServerMock}
/>
)}
/> />
</Router>, )}
)); />,
);
return { history, ...result };
};
it('passes a11y checks', () => checkAccessibility(setUp())); it('passes a11y checks', () => checkAccessibility(setUp()));
it('renders a modal window', async () => { it('renders a modal window', () => {
await setUp(); setUp();
expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByRole('heading')).toHaveTextContent('Remove server'); expect(screen.getByRole('heading')).toHaveTextContent('Remove server');
}); });
it('displays the name of the server as part of the content', async () => { it('displays the name of the server as part of the content', () => {
await setUp(); setUp();
expect(screen.getByText(/^Are you sure you want to remove/)).toBeInTheDocument(); expect(screen.getByText(/^Are you sure you want to remove/)).toBeInTheDocument();
expect(screen.getByText(serverName)).toBeInTheDocument(); expect(screen.getByText(serverName)).toBeInTheDocument();
}); });
it.each([ it.only.each([
[() => screen.getByRole('button', { name: 'Cancel' })], [() => screen.getByRole('button', { name: 'Cancel' })],
[() => screen.getByLabelText('Close')], [() => screen.getByLabelText('Close dialog')],
])('toggles when clicking cancel button', async (getButton) => { ])('closes dialog when clicking cancel button', async (getButton) => {
const { user, history } = await setUp(); const { user } = setUp();
expect(history.location.pathname).toEqual('/foo'); expect(screen.getByRole('dialog')).toBeInTheDocument();
await user.click(getButton()); await user.click(getButton());
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(deleteServerMock).not.toHaveBeenCalled(); expect(deleteServerMock).not.toHaveBeenCalled();
expect(history.location.pathname).toEqual('/foo'); // No navigation happens, keeping initial pathname
}); });
it('deletes server when clicking accept button', async () => { it('deletes server when clicking accept button', async () => {
const { user, history } = await setUp(); const { user } = setUp();
expect(deleteServerMock).not.toHaveBeenCalled(); expect(deleteServerMock).not.toHaveBeenCalled();
expect(history.location.pathname).toEqual('/foo');
await user.click(screen.getByRole('button', { name: 'Delete' })); await user.click(screen.getByRole('button', { name: 'Delete' }));
expect(deleteServerMock).toHaveBeenCalledOnce();
await waitFor(() => expect(deleteServerMock).toHaveBeenCalledTimes(1));
await waitFor(() => expect(history.location.pathname).toEqual('/'));
}); });
}); });

View File

@@ -1,3 +1,4 @@
import { Table } from '@shlinkio/shlink-frontend-kit/tailwind';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router'; import { MemoryRouter } from 'react-router';
@@ -17,11 +18,9 @@ describe('<ManageServersRow />', () => {
}; };
const setUp = (hasAutoConnect = false, autoConnect = false) => render( const setUp = (hasAutoConnect = false, autoConnect = false) => render(
<MemoryRouter> <MemoryRouter>
<table> <Table header={<Table.Row />}>
<tbody> <ManageServersRow server={{ ...server, autoConnect }} hasAutoConnect={hasAutoConnect} />
<ManageServersRow server={{ ...server, autoConnect }} hasAutoConnect={hasAutoConnect} /> </Table>
</tbody>
</table>
</MemoryRouter>, </MemoryRouter>,
); );
@@ -32,11 +31,7 @@ describe('<ManageServersRow />', () => {
[false, 3], [false, 3],
])('renders expected amount of columns', (hasAutoConnect, expectedCols) => { ])('renders expected amount of columns', (hasAutoConnect, expectedCols) => {
setUp(hasAutoConnect); setUp(hasAutoConnect);
expect(screen.getAllByRole('cell')).toHaveLength(expectedCols);
const td = screen.getAllByRole('cell');
const th = screen.getAllByRole('columnheader');
expect(td.length + th.length).toEqual(expectedCols);
}); });
it('renders a dropdown', () => { it('renders a dropdown', () => {

View File

@@ -9,8 +9,8 @@ import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<ManageServersRowDropdown />', () => { describe('<ManageServersRowDropdown />', () => {
const ManageServersRowDropdown = ManageServersRowDropdownFactory(fromPartial({ const ManageServersRowDropdown = ManageServersRowDropdownFactory(fromPartial({
DeleteServerModal: ({ isOpen }: { isOpen: boolean }) => ( DeleteServerModal: ({ open }: { open: boolean }) => (
<span>DeleteServerModal {isOpen ? '[OPEN]' : '[CLOSED]'}</span> <span>DeleteServerModal {open ? '[OPEN]' : '[CLOSED]'}</span>
), ),
})); }));
const setAutoConnect = vi.fn(); const setAutoConnect = vi.fn();

View File

@@ -10,30 +10,18 @@ describe('<ServersListGroup />', () => {
fromPartial({ name: 'foo', id: '123' }), fromPartial({ name: 'foo', id: '123' }),
fromPartial({ name: 'bar', id: '456' }), fromPartial({ name: 'bar', id: '456' }),
]; ];
const setUp = (params: { servers?: ServerWithId[]; withChildren?: boolean; embedded?: boolean } = {}) => { const setUp = (params: { servers?: ServerWithId[]; borderless?: boolean } = {}) => {
const { servers = [], withChildren = true, embedded } = params; const { servers = [], borderless } = params;
return render( return render(
<MemoryRouter> <MemoryRouter>
<ServersListGroup servers={servers} embedded={embedded}> <ServersListGroup servers={servers} borderless={borderless} />
{withChildren ? 'The list of servers' : undefined}
</ServersListGroup>
</MemoryRouter>, </MemoryRouter>,
); );
}; };
it('passes a11y checks', () => checkAccessibility(setUp())); it('passes a11y checks', () => checkAccessibility(setUp()));
it('renders title', () => {
setUp({});
expect(screen.getByTestId('title')).toHaveTextContent('The list of servers');
});
it('does not render title when children is not provided', () => {
setUp({ withChildren: false });
expect(screen.queryByTestId('title')).not.toBeInTheDocument();
});
it.each([ it.each([
[servers], [servers],
[[]], [[]],
@@ -45,11 +33,17 @@ describe('<ServersListGroup />', () => {
}); });
it.each([ it.each([
[true, 'servers-list__list-group servers-list__list-group--embedded'], [true],
[false, 'servers-list__list-group'], [false],
[undefined, 'servers-list__list-group'], [undefined],
])('renders proper classes for embedded', (embedded, expectedClasses) => { ])('renders proper classes for embedded', (borderless) => {
setUp({ servers, embedded }); setUp({ servers, borderless });
expect(screen.getByTestId('list')).toHaveAttribute('class', `${expectedClasses} list-group`); const list = screen.getByTestId('list');
if (!borderless) {
expect(list).toHaveClass('tw:border-y');
} else {
expect(list).not.toHaveClass('tw:border-y');
}
}); });
}); });

View File

@@ -2,67 +2,27 @@
exports[`<DeleteServerButton /> > renders expected content 1`] = ` exports[`<DeleteServerButton /> > renders expected content 1`] = `
<button <button
class="p-0 bg-transparent border-0" class="tw:text-danger tw:hover:underline"
type="button" type="button"
> >
<span Foo bar
class="button"
>
Foo bar
</span>
</button> </button>
`; `;
exports[`<DeleteServerButton /> > renders expected content 2`] = ` exports[`<DeleteServerButton /> > renders expected content 2`] = `
<button <button
class="p-0 bg-transparent border-0" class="tw:text-danger tw:hover:underline"
type="button" type="button"
> >
<span baz
class="button"
>
baz
</span>
</button> </button>
`; `;
exports[`<DeleteServerButton /> > renders expected content 3`] = ` exports[`<DeleteServerButton /> > renders expected content 3`] = `
<button <button
class="p-0 bg-transparent border-0" class="tw:text-danger tw:hover:underline"
type="button" type="button"
> >
<span something
class="button"
>
something
</span>
</button>
`;
exports[`<DeleteServerButton /> > renders expected content 4`] = `
<button
class="p-0 bg-transparent border-0"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-circle-minus fa-fw "
data-icon="circle-minus"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"
fill="currentColor"
/>
</svg>
<span
class="button"
>
Remove this server
</span>
</button> </button>
`; `;

View File

@@ -2,18 +2,29 @@
exports[`<ManageServersRow /> > renders auto-connect icon only if server is autoConnect 1`] = ` exports[`<ManageServersRow /> > renders auto-connect icon only if server is autoConnect 1`] = `
<div> <div>
<table> <table
<tbody> class="tw:w-full"
>
<thead
class="tw:hidden tw:lg:table-header-group"
>
<tr <tr
class="responsive-table__row" class="tw:group tw:lg:table-row tw:flex tw:flex-col tw:lg:border-0 tw:border-y-2 tw:border-lm-border tw:dark:border-dm-border"
/>
</thead>
<tbody
class="tw:lg:table-row-group tw:flex tw:flex-col tw:gap-y-3"
>
<tr
class="tw:group tw:lg:table-row tw:flex tw:flex-col tw:lg:border-0 tw:border-y-2 tw:border-lm-border tw:dark:border-dm-border tw:hover:bg-lm-primary tw:dark:hover:bg-dm-primary tw:group-[&]/card:hover:bg-lm-secondary tw:dark:group-[&]/card:hover:bg-dm-secondary tw:relative"
> >
<td <td
class="responsive-table__cell" class="tw:p-2 tw:border-lm-border tw:dark:border-dm-border tw:block tw:lg:table-cell tw:not-last:border-b-1 tw:lg:border-b-1 tw:before:lg:hidden tw:before:content-[attr(data-column)] tw:before:font-bold tw:before:mr-1"
data-th="Auto-connect" data-column="Auto-connect"
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
class="svg-inline--fa fa-check text-primary" class="svg-inline--fa fa-check tw:text-brand"
data-icon="check" data-icon="check"
data-prefix="fas" data-prefix="fas"
focusable="false" focusable="false"
@@ -28,9 +39,9 @@ exports[`<ManageServersRow /> > renders auto-connect icon only if server is auto
/> />
</svg> </svg>
</td> </td>
<th <td
class="responsive-table__cell" class="tw:p-2 tw:border-lm-border tw:dark:border-dm-border tw:block tw:lg:table-cell tw:not-last:border-b-1 tw:lg:border-b-1 tw:before:lg:hidden tw:before:content-[attr(data-column)] tw:before:font-bold tw:before:mr-1 tw:font-bold"
data-th="Name" data-column="Name"
> >
<a <a
data-discover="true" data-discover="true"
@@ -38,15 +49,15 @@ exports[`<ManageServersRow /> > renders auto-connect icon only if server is auto
> >
My server My server
</a> </a>
</th> </td>
<td <td
class="responsive-table__cell" class="tw:p-2 tw:border-lm-border tw:dark:border-dm-border tw:block tw:lg:table-cell tw:not-last:border-b-1 tw:lg:border-b-1 tw:before:lg:hidden tw:before:content-[attr(data-column)] tw:before:font-bold tw:before:mr-1 tw:max-lg:border-b-0"
data-th="Base URL" data-column="Base URL"
> >
https://example.com https://example.com
</td> </td>
<td <td
class="responsive-table__cell text-end" class="tw:p-2 tw:border-lm-border tw:dark:border-dm-border tw:block tw:lg:table-cell tw:not-last:border-b-1 tw:lg:border-b-1 tw:before:lg:hidden tw:before:content-[attr(data-column)] tw:before:font-bold tw:before:mr-1 tw:text-right tw:max-lg:absolute tw:right-0 tw:-top-1 tw:mx-lg:pt-0"
> >
<span> <span>
ManageServersRowDropdown ManageServersRowDropdown
@@ -60,18 +71,29 @@ exports[`<ManageServersRow /> > renders auto-connect icon only if server is auto
exports[`<ManageServersRow /> > renders auto-connect icon only if server is autoConnect 2`] = ` exports[`<ManageServersRow /> > renders auto-connect icon only if server is autoConnect 2`] = `
<div> <div>
<table> <table
<tbody> class="tw:w-full"
>
<thead
class="tw:hidden tw:lg:table-header-group"
>
<tr <tr
class="responsive-table__row" class="tw:group tw:lg:table-row tw:flex tw:flex-col tw:lg:border-0 tw:border-y-2 tw:border-lm-border tw:dark:border-dm-border"
/>
</thead>
<tbody
class="tw:lg:table-row-group tw:flex tw:flex-col tw:gap-y-3"
>
<tr
class="tw:group tw:lg:table-row tw:flex tw:flex-col tw:lg:border-0 tw:border-y-2 tw:border-lm-border tw:dark:border-dm-border tw:hover:bg-lm-primary tw:dark:hover:bg-dm-primary tw:group-[&]/card:hover:bg-lm-secondary tw:dark:group-[&]/card:hover:bg-dm-secondary tw:relative"
> >
<td <td
class="responsive-table__cell" class="tw:p-2 tw:border-lm-border tw:dark:border-dm-border tw:block tw:lg:table-cell tw:not-last:border-b-1 tw:lg:border-b-1 tw:before:lg:hidden tw:before:content-[attr(data-column)] tw:before:font-bold tw:before:mr-1"
data-th="Auto-connect" data-column="Auto-connect"
/> />
<th <td
class="responsive-table__cell" class="tw:p-2 tw:border-lm-border tw:dark:border-dm-border tw:block tw:lg:table-cell tw:not-last:border-b-1 tw:lg:border-b-1 tw:before:lg:hidden tw:before:content-[attr(data-column)] tw:before:font-bold tw:before:mr-1 tw:font-bold"
data-th="Name" data-column="Name"
> >
<a <a
data-discover="true" data-discover="true"
@@ -79,15 +101,15 @@ exports[`<ManageServersRow /> > renders auto-connect icon only if server is auto
> >
My server My server
</a> </a>
</th> </td>
<td <td
class="responsive-table__cell" class="tw:p-2 tw:border-lm-border tw:dark:border-dm-border tw:block tw:lg:table-cell tw:not-last:border-b-1 tw:lg:border-b-1 tw:before:lg:hidden tw:before:content-[attr(data-column)] tw:before:font-bold tw:before:mr-1 tw:max-lg:border-b-0"
data-th="Base URL" data-column="Base URL"
> >
https://example.com https://example.com
</td> </td>
<td <td
class="responsive-table__cell text-end" class="tw:p-2 tw:border-lm-border tw:dark:border-dm-border tw:block tw:lg:table-cell tw:not-last:border-b-1 tw:lg:border-b-1 tw:before:lg:hidden tw:before:content-[attr(data-column)] tw:before:font-bold tw:before:mr-1 tw:text-right tw:max-lg:absolute tw:right-0 tw:-top-1 tw:mx-lg:pt-0"
> >
<span> <span>
ManageServersRowDropdown ManageServersRowDropdown

View File

@@ -114,7 +114,7 @@ exports[`<ManageServersRowDropdown /> > renders expected size and icon 1`] = `
tabindex="-1" tabindex="-1"
/> />
<button <button
class="dropdown-item--danger dropdown-item" class="tw:text-danger dropdown-item"
role="menuitem" role="menuitem"
tabindex="0" tabindex="0"
type="button" type="button"
@@ -259,7 +259,7 @@ exports[`<ManageServersRowDropdown /> > renders expected size and icon 2`] = `
tabindex="-1" tabindex="-1"
/> />
<button <button
class="dropdown-item--danger dropdown-item" class="tw:text-danger dropdown-item"
role="menuitem" role="menuitem"
tabindex="0" tabindex="0"
type="button" type="button"

View File

@@ -6,10 +6,10 @@ import { checkAccessibility } from '../../__helpers__/accessibility';
import { renderWithEvents } from '../../__helpers__/setUpTest'; import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<DuplicatedServersModal />', () => { describe('<DuplicatedServersModal />', () => {
const onDiscard = vi.fn(); const onClose = vi.fn();
const onSave = vi.fn(); const onConfirm = vi.fn();
const setUp = (duplicatedServers: ServerData[] = []) => act(() => renderWithEvents( const setUp = (duplicatedServers: ServerData[] = []) => act(() => renderWithEvents(
<DuplicatedServersModal isOpen duplicatedServers={duplicatedServers} onDiscard={onDiscard} onSave={onSave} />, <DuplicatedServersModal open duplicatedServers={duplicatedServers} onClose={onClose} onConfirm={onConfirm} />,
)); ));
const mockServer = (data: Partial<ServerData> = {}) => fromPartial<ServerData>(data); const mockServer = (data: Partial<ServerData> = {}) => fromPartial<ServerData>(data);
@@ -32,8 +32,9 @@ describe('<DuplicatedServersModal />', () => {
{ {
header: 'Duplicated server', header: 'Duplicated server',
firstParagraph: 'There is already a server with:', firstParagraph: 'There is already a server with:',
lastParagraph: 'Do you want to save this server anyway?', lastParagraph: 'Do you want to save this server?',
discardBtn: 'Discard', discardBtn: 'Discard',
confirmButton: 'Save duplicate',
}, },
], ],
[ [
@@ -41,8 +42,9 @@ describe('<DuplicatedServersModal />', () => {
{ {
header: 'Duplicated servers', header: 'Duplicated servers',
firstParagraph: 'The next servers already exist:', firstParagraph: 'The next servers already exist:',
lastParagraph: 'Do you want to ignore duplicated servers?', lastParagraph: 'Do you want to save duplicated servers?',
discardBtn: 'Ignore duplicates', discardBtn: 'Ignore duplicates',
confirmButton: 'Save duplicates',
}, },
], ],
])('renders expected texts based on amount of servers', async (duplicatedServers, assertions) => { ])('renders expected texts based on amount of servers', async (duplicatedServers, assertions) => {
@@ -52,6 +54,7 @@ describe('<DuplicatedServersModal />', () => {
expect(screen.getByText(assertions.firstParagraph)).toBeInTheDocument(); expect(screen.getByText(assertions.firstParagraph)).toBeInTheDocument();
expect(screen.getByText(assertions.lastParagraph)).toBeInTheDocument(); expect(screen.getByText(assertions.lastParagraph)).toBeInTheDocument();
expect(screen.getByRole('button', { name: assertions.discardBtn })).toBeInTheDocument(); expect(screen.getByRole('button', { name: assertions.discardBtn })).toBeInTheDocument();
expect(screen.getByRole('button', { name: assertions.confirmButton })).toBeInTheDocument();
}); });
it.each([ it.each([
@@ -80,19 +83,19 @@ describe('<DuplicatedServersModal />', () => {
} }
}); });
it('invokes onDiscard when appropriate button is clicked', async () => { it('invokes onClose when appropriate button is clicked', async () => {
const { user } = await setUp(); const { user } = await setUp();
expect(onDiscard).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: 'Discard' })); await user.click(screen.getByRole('button', { name: 'Discard' }));
expect(onDiscard).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();
}); });
it('invokes onSave when appropriate button is clicked', async () => { it('invokes onConfirm when appropriate button is clicked', async () => {
const { user } = await setUp(); const { user } = await setUp();
expect(onSave).not.toHaveBeenCalled(); expect(onConfirm).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: 'Save anyway' })); await user.click(screen.getByRole('button', { name: 'Save duplicate' }));
expect(onSave).toHaveBeenCalled(); expect(onConfirm).toHaveBeenCalled();
}); });
}); });

View File

@@ -65,9 +65,9 @@ describe('<ImportServersBtn />', () => {
}); });
it.each([ it.each([
{ btnName: 'Save anyway',savesDuplicatedServers: true }, { btnName: 'Save duplicate', savesDuplicatedServers: true },
{ btnName: 'Discard', savesDuplicatedServers: false }, { btnName: 'Discard', savesDuplicatedServers: false },
])('creates expected servers depending on selected option in modal', async ({ btnName, savesDuplicatedServers }) => { ])('creates duplicated servers depending on selected option in modal', async ({ btnName, savesDuplicatedServers }) => {
const existingServerData: ServerData = { const existingServerData: ServerData = {
name: 'existingServer', name: 'existingServer',
url: 'http://s.test/existingUrl', url: 'http://s.test/existingUrl',
@@ -84,14 +84,20 @@ describe('<ImportServersBtn />', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
await user.upload(screen.getByTestId('csv-file-input'), csvFile); await user.upload(screen.getByTestId('csv-file-input'), csvFile);
// Once the file is uploaded, non-duplicated servers are immediately created
expect(createServersMock).toHaveBeenCalledExactlyOnceWith([expect.objectContaining(newServer)]);
expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: btnName })); await user.click(screen.getByRole('button', { name: btnName }));
expect(createServersMock).toHaveBeenCalledWith( // If duplicated servers are saved, there's one extra call
savesDuplicatedServers if (savesDuplicatedServers) {
? [expect.objectContaining(existingServerData), expect.objectContaining(newServer)] expect(createServersMock).toHaveBeenLastCalledWith([expect.objectContaining(existingServerData)]);
: [expect.objectContaining(newServer)], }
);
expect(onImportMock).toHaveBeenCalledTimes(1); // On import is called only once, no matter what
expect(onImportMock).toHaveBeenCalledOnce();
expect(createServersMock).toHaveBeenCalledTimes(savesDuplicatedServers ? 2 : 1);
}); });
}); });

View File

@@ -8,10 +8,12 @@ describe('<ServerForm />', () => {
it('passes a11y checks', () => checkAccessibility(setUp())); it('passes a11y checks', () => checkAccessibility(setUp()));
it('renders components', () => { it('renders inputs', () => {
setUp(); setUp();
expect(screen.getAllByRole('textbox')).toHaveLength(3); expect(screen.getByLabelText(/^Name/)).toBeInTheDocument();
expect(screen.getByLabelText(/^URL/)).toBeInTheDocument();
expect(screen.getByLabelText(/^API key/)).toBeInTheDocument();
expect(screen.getByText('Something')).toBeInTheDocument(); expect(screen.getByText('Something')).toBeInTheDocument();
}); });

View File

@@ -1,3 +1,4 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { resolve } from 'path'; import { resolve } from 'path';
import { VitePWA } from 'vite-plugin-pwa'; import { VitePWA } from 'vite-plugin-pwa';
@@ -11,7 +12,7 @@ const homepage = pack.homepage?.trim();
/* eslint-disable-next-line no-restricted-exports */ /* eslint-disable-next-line no-restricted-exports */
export default defineConfig({ export default defineConfig({
plugins: [react(), VitePWA({ plugins: [react(), tailwindcss(), VitePWA({
mode: process.env.NODE_ENV === 'development' ? 'development' : 'production', mode: process.env.NODE_ENV === 'development' ? 'development' : 'production',
strategies: 'injectManifest', strategies: 'injectManifest',
srcDir: './src', srcDir: './src',
@@ -25,6 +26,10 @@ export default defineConfig({
}, },
server: { server: {
port: 3000, port: 3000,
watch: {
// Do not watch test files or generated files, avoiding the dev server to constantly reload when not needed
ignored: ['**/.idea/**', '**/.git/**', '**/build/**', '**/coverage/**', '**/test/**'],
},
}, },
base: !homepage ? undefined : homepage, // Not using just homepage because empty string should be discarded base: !homepage ? undefined : homepage, // Not using just homepage because empty string should be discarded
@@ -55,6 +60,9 @@ export default defineConfig({
}, },
}, },
// Silent warnings triggered by reactstrap components, as it's getting removed
onConsoleLog: (log) => !log.includes('`transition.timeout` is marked as required'),
// Workaround for bug in react-router (or vitest module resolution) which causes different react-router versions to // Workaround for bug in react-router (or vitest module resolution) which causes different react-router versions to
// be resolved for the main package and dependencies who have a peer dependency in react-router. // be resolved for the main package and dependencies who have a peer dependency in react-router.
// This ensures always the same version is resolved. // This ensures always the same version is resolved.