mirror of
https://github.com/diced/zipline.git
synced 2026-06-17 12:21:41 -07:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0bcb4a019 | |||
| 4c86b7fc38 | |||
| 9b7759520c | |||
| e3e77c7916 | |||
| 13282988e8 | |||
| 00f4254227 | |||
| 669c61eae0 | |||
| 1ee1aca589 | |||
| d49fd6a1f0 | |||
| 8128e3deb0 | |||
| 67a9fe34b4 | |||
| 7a3c4223ec | |||
| cb2590aae5 | |||
| 93ff18a120 | |||
| 4343f130fb | |||
| 5e9778d18a | |||
| 9bcccbc8aa | |||
| 00ddf86ea8 | |||
| cc582f6d20 | |||
| 318b09feae | |||
| d55e36375d | |||
| 40b917df30 | |||
| 053a50d1bc | |||
| 78b554cbe8 | |||
| bf7a4e92e3 | |||
| 3b56e7f1ce | |||
| 1d91a008e1 | |||
| ff1fc0eb75 | |||
| 430774082c | |||
| ef67fdd553 | |||
| 73b5528586 | |||
| 3f65029464 | |||
| 6da1719fda | |||
| 4d85b41ec3 | |||
| ac5c0a1cb3 | |||
| eb22598f20 | |||
| 7a4c29d9d4 | |||
| 255336d74f | |||
| dc625fc682 | |||
| d457cb8693 | |||
| 331c4b4a4e | |||
| d8ca9dc9b5 | |||
| 0eee082035 | |||
| 3e287e8ad7 | |||
| 5ec471050e | |||
| 842dac2660 | |||
| dee86aaa86 | |||
| 13e3a58035 | |||
| f4382d5bd9 | |||
| 8990801268 | |||
| 01b9c06513 | |||
| fc180de616 | |||
| f907133d3a | |||
| 9ae9734a3d | |||
| 770b5cf706 | |||
| 56625c664d | |||
| 056a19b946 | |||
| 281ab666c1 | |||
| 31df5341b5 | |||
| ec7024242f | |||
| ef6e0e00a0 |
@@ -78,13 +78,12 @@ jobs:
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Run app
|
||||
- name: Run generator
|
||||
env:
|
||||
DATABASE_URL: postgres://zipline:zipline@localhost:5432/zipline
|
||||
CORE_SECRET: ${{ steps.secret.outputs.secret }}
|
||||
ZIPLINE_OUTPUT_OPENAPI: true
|
||||
|
||||
run: pnpm start
|
||||
NODE_ENV: production
|
||||
run: pnpm openapi
|
||||
|
||||
- name: Verify openapi.json exists
|
||||
run: |
|
||||
@@ -47,7 +47,7 @@ Visit [the docs](https://zipline.diced.sh/docs/get-started/docker) for a more in
|
||||
|
||||
This is the recommended way to run Zipline:
|
||||
|
||||
```yml
|
||||
```yaml
|
||||
services:
|
||||
postgresql:
|
||||
image: postgres:16
|
||||
@@ -91,6 +91,9 @@ volumes:
|
||||
pgdata:
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> Zipline requires a cpu with AVX support. We don't provide binaries or images that have support for non-AVX cpus
|
||||
|
||||
### Volumes
|
||||
|
||||
- `./uploads` - The folder where all the user uploads are stored (the default is `./uploads`)
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@ export default defineConfig(
|
||||
},
|
||||
|
||||
settings: {
|
||||
react: { version: 'detect' },
|
||||
react: { version: '19' },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
Generated
+645
-39
@@ -6,7 +6,8 @@
|
||||
"devenv"
|
||||
],
|
||||
"flake-compat": [
|
||||
"devenv"
|
||||
"devenv",
|
||||
"flake-compat"
|
||||
],
|
||||
"git-hooks": [
|
||||
"devenv",
|
||||
@@ -18,11 +19,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1748883665,
|
||||
"narHash": "sha256-R0W7uAg+BLoHjMRMQ8+oiSbTq8nkGz5RDpQ+ZfxxP3A=",
|
||||
"lastModified": 1767714506,
|
||||
"narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
|
||||
"owner": "cachix",
|
||||
"repo": "cachix",
|
||||
"rev": "f707778d902af4d62d8dd92c269f8e70de09acbe",
|
||||
"rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -32,22 +33,142 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"cachix_2": {
|
||||
"inputs": {
|
||||
"devenv": [
|
||||
"devenv",
|
||||
"crate2nix"
|
||||
],
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"crate2nix"
|
||||
],
|
||||
"git-hooks": "git-hooks",
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1767714506,
|
||||
"narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
|
||||
"owner": "cachix",
|
||||
"repo": "cachix",
|
||||
"rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "latest",
|
||||
"repo": "cachix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"cachix_3": {
|
||||
"inputs": {
|
||||
"devenv": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable"
|
||||
],
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable"
|
||||
],
|
||||
"git-hooks": "git-hooks_2",
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1767714506,
|
||||
"narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
|
||||
"owner": "cachix",
|
||||
"repo": "cachix",
|
||||
"rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "latest",
|
||||
"repo": "cachix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"crate2nix": {
|
||||
"inputs": {
|
||||
"cachix": "cachix_2",
|
||||
"crate2nix_stable": "crate2nix_stable",
|
||||
"devshell": "devshell_2",
|
||||
"flake-compat": "flake-compat_2",
|
||||
"flake-parts": "flake-parts_2",
|
||||
"nix-test-runner": "nix-test-runner_2",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
],
|
||||
"pre-commit-hooks": "pre-commit-hooks_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1773440526,
|
||||
"narHash": "sha256-OcX1MYqUdoalY3/vU67PEx8m6RvqGxX0LwKonjzXn7I=",
|
||||
"owner": "nix-community",
|
||||
"repo": "crate2nix",
|
||||
"rev": "e697d3049c909580128caa856ab8eb709556a97b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "crate2nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"crate2nix_stable": {
|
||||
"inputs": {
|
||||
"cachix": "cachix_3",
|
||||
"crate2nix_stable": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable"
|
||||
],
|
||||
"devshell": "devshell",
|
||||
"flake-compat": "flake-compat",
|
||||
"flake-parts": "flake-parts",
|
||||
"nix-test-runner": "nix-test-runner",
|
||||
"nixpkgs": "nixpkgs_3",
|
||||
"pre-commit-hooks": "pre-commit-hooks"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1769627083,
|
||||
"narHash": "sha256-SUuruvw1/moNzCZosHaa60QMTL+L9huWdsCBN6XZIic=",
|
||||
"owner": "nix-community",
|
||||
"repo": "crate2nix",
|
||||
"rev": "7c33e664668faecf7655fa53861d7a80c9e464a2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"ref": "0.15.0",
|
||||
"repo": "crate2nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"devenv": {
|
||||
"inputs": {
|
||||
"cachix": "cachix",
|
||||
"flake-compat": "flake-compat",
|
||||
"git-hooks": "git-hooks",
|
||||
"crate2nix": "crate2nix",
|
||||
"flake-compat": "flake-compat_3",
|
||||
"flake-parts": "flake-parts_3",
|
||||
"git-hooks": "git-hooks_3",
|
||||
"nix": "nix",
|
||||
"nixd": "nixd",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
],
|
||||
"rust-overlay": "rust-overlay"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1753888869,
|
||||
"narHash": "sha256-VRYrrUmvXnBzfzuJVoI3os1H/0l8cJQ2KnrrxWkTB3E=",
|
||||
"lastModified": 1774134162,
|
||||
"narHash": "sha256-pGjE0Agjnh8FmymDi3hiOy/pflcnbS8kpkfkL5/QKAc=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "bdf26a4453eff6bae835f33d519a36f77e0ca257",
|
||||
"rev": "b24c9b58457396a9a6fe275b87555ba6e8f0a5fb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -68,14 +189,87 @@
|
||||
"url": "file:///dev/null"
|
||||
}
|
||||
},
|
||||
"devshell": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1768818222,
|
||||
"narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=",
|
||||
"owner": "numtide",
|
||||
"repo": "devshell",
|
||||
"rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "devshell",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"devshell_2": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1768818222,
|
||||
"narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=",
|
||||
"owner": "numtide",
|
||||
"repo": "devshell",
|
||||
"rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "devshell",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"locked": {
|
||||
"lastModified": 1733328505,
|
||||
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
|
||||
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
|
||||
"revCount": 69,
|
||||
"type": "tarball",
|
||||
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
"url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz"
|
||||
}
|
||||
},
|
||||
"flake-compat_2": {
|
||||
"locked": {
|
||||
"lastModified": 1733328505,
|
||||
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
|
||||
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
|
||||
"revCount": 69,
|
||||
"type": "tarball",
|
||||
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
"url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz"
|
||||
}
|
||||
},
|
||||
"flake-compat_3": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1747046372,
|
||||
"narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=",
|
||||
"lastModified": 1767039857,
|
||||
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
|
||||
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -88,16 +282,17 @@
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"devenv",
|
||||
"nix",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1733312601,
|
||||
"narHash": "sha256-4pDvzqnegAfRkPwO3wmwBhVi/Sye1mzps0zHWYnP88c=",
|
||||
"lastModified": 1768135262,
|
||||
"narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "205b12d8b7cd4802fbcb8e8ef6a0f1408781a4f9",
|
||||
"rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -107,15 +302,58 @@
|
||||
}
|
||||
},
|
||||
"flake-parts_2": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1768135262,
|
||||
"narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts_3": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772408722,
|
||||
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts_4": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1753121425,
|
||||
"narHash": "sha256-TVcTNvOeWWk1DXljFxVRp+E0tzG1LhrVjOGGoMHuXio=",
|
||||
"lastModified": 1772408722,
|
||||
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "644e0fc48951a860279da645ba77fe4a6e814c5e",
|
||||
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -128,20 +366,82 @@
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"cachix",
|
||||
"flake-compat"
|
||||
],
|
||||
"gitignore": "gitignore",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"cachix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1765404074,
|
||||
"narHash": "sha256-+ZDU2d+vzWkEJiqprvV5PR26DVFN2vgddwG5SnPZcUM=",
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "2d6f58930fbcd82f6f9fd59fb6d13e37684ca529",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks_2": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"cachix",
|
||||
"flake-compat"
|
||||
],
|
||||
"gitignore": "gitignore_2",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"cachix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1765404074,
|
||||
"narHash": "sha256-+ZDU2d+vzWkEJiqprvV5PR26DVFN2vgddwG5SnPZcUM=",
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "2d6f58930fbcd82f6f9fd59fb6d13e37684ca529",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks_3": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"flake-compat"
|
||||
],
|
||||
"gitignore": "gitignore_5",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1750779888,
|
||||
"narHash": "sha256-wibppH3g/E2lxU43ZQHC5yA/7kIKLGxVEnsnVK1BtRg=",
|
||||
"lastModified": 1772893680,
|
||||
"narHash": "sha256-JDqZMgxUTCq85ObSaFw0HhE+lvdOre1lx9iI6vYyOEs=",
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "16ec914f6fb6f599ce988427d9d94efddf25fe6d",
|
||||
"rev": "8baab586afc9c9b57645a734c820e4ac0a604af9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -151,6 +451,102 @@
|
||||
}
|
||||
},
|
||||
"gitignore": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"cachix",
|
||||
"git-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore_2": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"cachix",
|
||||
"git-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore_3": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"pre-commit-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore_4": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"pre-commit-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore_5": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
@@ -178,7 +574,10 @@
|
||||
"devenv",
|
||||
"flake-compat"
|
||||
],
|
||||
"flake-parts": "flake-parts",
|
||||
"flake-parts": [
|
||||
"devenv",
|
||||
"flake-parts"
|
||||
],
|
||||
"git-hooks-nix": [
|
||||
"devenv",
|
||||
"git-hooks"
|
||||
@@ -195,43 +594,101 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1752773918,
|
||||
"narHash": "sha256-dOi/M6yNeuJlj88exI+7k154z+hAhFcuB8tZktiW7rg=",
|
||||
"lastModified": 1774103430,
|
||||
"narHash": "sha256-MRNVInSmvhKIg3y0UdogQJXe+omvKijGszFtYpd5r9k=",
|
||||
"owner": "cachix",
|
||||
"repo": "nix",
|
||||
"rev": "031c3cf42d2e9391eee373507d8c12e0f9606779",
|
||||
"rev": "e127c1c94cefe02d8ca4cca79ef66be4c527510e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "devenv-2.30",
|
||||
"ref": "devenv-2.32",
|
||||
"repo": "nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-test-runner": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1588761593,
|
||||
"narHash": "sha256-FKJykltAN/g3eIceJl4SfDnnyuH2jHImhMrXS2KvGIs=",
|
||||
"owner": "stoeffel",
|
||||
"repo": "nix-test-runner",
|
||||
"rev": "c45d45b11ecef3eb9d834c3b6304c05c49b06ca2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "stoeffel",
|
||||
"repo": "nix-test-runner",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-test-runner_2": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1588761593,
|
||||
"narHash": "sha256-FKJykltAN/g3eIceJl4SfDnnyuH2jHImhMrXS2KvGIs=",
|
||||
"owner": "stoeffel",
|
||||
"repo": "nix-test-runner",
|
||||
"rev": "c45d45b11ecef3eb9d834c3b6304c05c49b06ca2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "stoeffel",
|
||||
"repo": "nix-test-runner",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixd": {
|
||||
"inputs": {
|
||||
"flake-parts": [
|
||||
"devenv",
|
||||
"flake-parts"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
],
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1773634079,
|
||||
"narHash": "sha256-49qb4QNMv77VOeEux+sMd0uBhPvvHgVc0r938Bulvbo=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixd",
|
||||
"rev": "8ecf93d4d93745e05ea53534e8b94f5e9506e6bd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "nixd",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1752827260,
|
||||
"narHash": "sha256-noFjJbm/uWRcd2Lotr7ovedfhKVZT+LeJs9rU416lKQ=",
|
||||
"owner": "nixos",
|
||||
"lastModified": 1765186076,
|
||||
"narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b527e89270879aaaf584c41f26b2796be634bc9d",
|
||||
"rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b527e89270879aaaf584c41f26b2796be634bc9d",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1751159883,
|
||||
"narHash": "sha256-urW/Ylk9FIfvXfliA1ywh75yszAbiTEVgpPeinFyVZo=",
|
||||
"lastModified": 1772328832,
|
||||
"narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "14a40a1d7fb9afa4739275ac642ed7301a9ba1ab",
|
||||
"rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -240,12 +697,161 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1765186076,
|
||||
"narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1769433173,
|
||||
"narHash": "sha256-Gf1dFYgD344WZ3q0LPlRoWaNdNQq8kSBDLEWulRQSEs=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "13b0f9e6ac78abbbb736c635d87845c4f4bee51b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_4": {
|
||||
"locked": {
|
||||
"lastModified": 1773964973,
|
||||
"narHash": "sha256-NV/J+tTER0P5iJhUDL/8HO5MDjDceLQPRUYgdmy5wXw=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "812b3986fd1568f7a858f97fcf425ad996ba7d25",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "812b3986fd1568f7a858f97fcf425ad996ba7d25",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"pre-commit-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"flake-compat"
|
||||
],
|
||||
"gitignore": "gitignore_3",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1769069492,
|
||||
"narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=",
|
||||
"owner": "cachix",
|
||||
"repo": "pre-commit-hooks.nix",
|
||||
"rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "pre-commit-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"pre-commit-hooks_2": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"flake-compat"
|
||||
],
|
||||
"gitignore": "gitignore_4",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1769069492,
|
||||
"narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=",
|
||||
"owner": "cachix",
|
||||
"repo": "pre-commit-hooks.nix",
|
||||
"rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "pre-commit-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"devenv-root": "devenv-root",
|
||||
"flake-parts": "flake-parts_2",
|
||||
"nixpkgs": "nixpkgs"
|
||||
"flake-parts": "flake-parts_4",
|
||||
"nixpkgs": "nixpkgs_4"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1773630837,
|
||||
"narHash": "sha256-zJhgAGnbVKeBMJOb9ctZm4BGS/Rnrz+5lfSXTVah4HQ=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "f600ea449c7b5bb596fa1cf21c871cc5b9e31316",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"treefmt-nix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixd",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772660329,
|
||||
"narHash": "sha256-IjU1FxYqm+VDe5qIOxoW+pISBlGvVApRjiw/Y/ttJzY=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "3710e0e1218041bbad640352a0440114b1e10428",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
flake = false;
|
||||
};
|
||||
|
||||
# node 24.4.1, postgres 17
|
||||
nixpkgs.url = "github:nixos/nixpkgs/b527e89270879aaaf584c41f26b2796be634bc9d";
|
||||
# node 24.14, postgres 17
|
||||
nixpkgs.url = "github:nixos/nixpkgs/812b3986fd1568f7a858f97fcf425ad996ba7d25";
|
||||
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||
|
||||
devenv.url = "github:cachix/devenv";
|
||||
@@ -58,7 +58,6 @@
|
||||
ffmpeg
|
||||
|
||||
# for testing docker
|
||||
colima
|
||||
docker
|
||||
docker-compose
|
||||
];
|
||||
@@ -75,11 +74,6 @@
|
||||
downall.exec = ''
|
||||
process-compose down
|
||||
'';
|
||||
|
||||
# ensure that volumes are mounted with write access for docker containers
|
||||
start_colima.exec = ''
|
||||
colima start --mount $PWD/themes:w --mount $PWD/uploads:w --mount $PWD/public:w
|
||||
'';
|
||||
};
|
||||
|
||||
enterShell = ''
|
||||
|
||||
+59
-57
@@ -2,16 +2,17 @@
|
||||
"name": "zipline",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"version": "4.4.2",
|
||||
"version": "4.5.3",
|
||||
"scripts": {
|
||||
"build": "tsx scripts/build.ts",
|
||||
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --enable-source-maps ./src/server",
|
||||
"dev:nd": "cross-env NODE_ENV=development tsx --require dotenv/config --enable-source-maps ./src/server",
|
||||
"dev:inspector": "cross-env NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./src/server",
|
||||
"start": "cross-env NODE_ENV=production node --trace-warnings --require dotenv/config ./build/server",
|
||||
"start:inspector": "cross-env NODE_ENV=production node --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./build/server",
|
||||
"ctl": "NODE_ENV=production node --require dotenv/config --enable-source-maps ./build/ctl",
|
||||
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require ./src/dotenv.js --enable-source-maps ./src/server",
|
||||
"dev:nd": "cross-env NODE_ENV=development tsx --require ./src/dotenv.js --enable-source-maps ./src/server",
|
||||
"dev:inspector": "cross-env NODE_ENV=development DEBUG=zipline tsx --require ./src/dotenv.js --inspect=0.0.0.0:9229 --enable-source-maps ./src/server",
|
||||
"start": "cross-env NODE_ENV=production node --trace-warnings --require ./src/dotenv.js ./build/server",
|
||||
"start:inspector": "cross-env NODE_ENV=production node --require ./src/dotenv.js --inspect=0.0.0.0:9229 --enable-source-maps ./build/server",
|
||||
"ctl": "NODE_ENV=production node --require ./src/dotenv.js --enable-source-maps ./build/ctl",
|
||||
"validate": "tsx scripts/validate.ts",
|
||||
"openapi": "tsx scripts/openapi.ts",
|
||||
"db:prototype": "prisma db push --skip-generate && prisma generate --no-hints",
|
||||
"db:migrate": "prisma migrate dev --create-only",
|
||||
"docker:engine": "colima start --mount $PWD/themes:w --mount $PWD/uploads:w --mount $PWD/public:w",
|
||||
@@ -21,106 +22,107 @@
|
||||
"docker:compose:dev:logs": "docker compose --file docker-compose.dev.yml logs -f"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.726.1",
|
||||
"@aws-sdk/lib-storage": "3.726.1",
|
||||
"@aws-sdk/client-s3": "3.1025.0",
|
||||
"@aws-sdk/lib-storage": "3.1025.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.1.0",
|
||||
"@fastify/multipart": "^9.3.0",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/sensible": "^6.0.4",
|
||||
"@fastify/static": "^8.3.0",
|
||||
"@fastify/swagger": "^9.6.1",
|
||||
"@mantine/charts": "^8.3.9",
|
||||
"@mantine/code-highlight": "^8.3.9",
|
||||
"@mantine/core": "^8.3.9",
|
||||
"@mantine/dates": "^8.3.9",
|
||||
"@mantine/dropzone": "^8.3.9",
|
||||
"@mantine/form": "^8.3.9",
|
||||
"@mantine/hooks": "^8.3.9",
|
||||
"@mantine/modals": "^8.3.9",
|
||||
"@mantine/notifications": "^8.3.9",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@fastify/swagger": "^9.7.0",
|
||||
"@mantine/charts": "^9.0.1",
|
||||
"@mantine/code-highlight": "^9.0.1",
|
||||
"@mantine/core": "^9.0.1",
|
||||
"@mantine/dates": "^9.0.1",
|
||||
"@mantine/dropzone": "^9.0.1",
|
||||
"@mantine/form": "^9.0.1",
|
||||
"@mantine/hooks": "^9.0.1",
|
||||
"@mantine/modals": "^9.0.1",
|
||||
"@mantine/notifications": "^9.0.1",
|
||||
"@prisma/adapter-pg": "6.13.0",
|
||||
"@prisma/client": "6.13.0",
|
||||
"@prisma/engines": "6.13.0",
|
||||
"@prisma/internals": "6.13.0",
|
||||
"@prisma/migrate": "6.13.0",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@smithy/node-http-handler": "^4.1.1",
|
||||
"@tabler/icons-react": "^3.35.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.0",
|
||||
"@smithy/node-http-handler": "^4.5.2",
|
||||
"@tabler/icons-react": "^3.41.1",
|
||||
"archiver": "^7.0.1",
|
||||
"argon2": "^0.44.0",
|
||||
"asciinema-player": "^3.12.1",
|
||||
"asciinema-player": "^3.15.1",
|
||||
"bytes": "^3.1.2",
|
||||
"clsx": "^2.1.1",
|
||||
"colorette": "^2.0.20",
|
||||
"commander": "^14.0.2",
|
||||
"commander": "^14.0.3",
|
||||
"cookie": "^1.1.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"dayjs": "^1.11.20",
|
||||
"detect-browser": "^5.3.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"devalue": "^5.7.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"fastify": "^5.6.2",
|
||||
"fastify": "^5.8.4",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"fastify-type-provider-zod": "^6.1.0",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"he": "^1.2.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"iron-session": "^8.0.4",
|
||||
"isomorphic-dompurify": "^2.33.0",
|
||||
"katex": "^0.16.27",
|
||||
"mantine-datatable": "^8.3.9",
|
||||
"isomorphic-dompurify": "^3.7.1",
|
||||
"katex": "^0.16.45",
|
||||
"mantine-datatable": "^8.3.13",
|
||||
"ms": "^2.1.3",
|
||||
"multer": "2.0.2",
|
||||
"otplib": "^12.0.1",
|
||||
"multer": "2.1.1",
|
||||
"otplib": "^13.4.0",
|
||||
"prisma": "6.13.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.10.1",
|
||||
"react-window": "1.8.11",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"react-virtuoso": "^4.18.4",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sharp": "^0.34.5",
|
||||
"swr": "^2.3.7",
|
||||
"typescript-eslint": "^8.48.1",
|
||||
"vite": "^7.2.7",
|
||||
"zod": "^4.1.13",
|
||||
"zustand": "^5.0.9"
|
||||
"swr": "^2.4.1",
|
||||
"vite": "^8.0.5",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/bytes": "^3.1.5",
|
||||
"@types/fluent-ffmpeg": "^2.1.28",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/he": "^1.2.3",
|
||||
"@types/katex": "^0.16.8",
|
||||
"@types/ms": "^2.1.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"eslint-plugin-unused-imports": "^4.3.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.7.4",
|
||||
"sass": "^1.94.2",
|
||||
"prettier": "^3.8.1",
|
||||
"sass": "^1.98.0",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
|
||||
Generated
+2474
-2734
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Zipline" ADD COLUMN "filesMaxFilesPerUpload" INTEGER NOT NULL DEFAULT 1000;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."File" ADD COLUMN "anonymous" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Zipline" ADD COLUMN "featuresThumbnailsInstantaneous" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -46,6 +46,7 @@ model Zipline {
|
||||
filesRandomWordsNumAdjectives Int @default(2)
|
||||
filesRandomWordsSeparator String @default("-")
|
||||
filesDefaultCompressionFormat String? @default("jpg")
|
||||
filesMaxFilesPerUpload Int @default(1000)
|
||||
|
||||
urlsRoute String @default("/go")
|
||||
urlsLength Int @default(6)
|
||||
@@ -57,9 +58,10 @@ model Zipline {
|
||||
featuresOauthRegistration Boolean @default(false)
|
||||
featuresDeleteOnMaxViews Boolean @default(true)
|
||||
|
||||
featuresThumbnailsEnabled Boolean @default(true)
|
||||
featuresThumbnailsNumberThreads Int @default(4)
|
||||
featuresThumbnailsFormat String @default("jpg")
|
||||
featuresThumbnailsEnabled Boolean @default(true)
|
||||
featuresThumbnailsNumberThreads Int @default(4)
|
||||
featuresThumbnailsFormat String @default("jpg")
|
||||
featuresThumbnailsInstantaneous Boolean @default(false)
|
||||
|
||||
featuresMetricsEnabled Boolean @default(true)
|
||||
featuresMetricsAdminOnly Boolean @default(false)
|
||||
@@ -282,6 +284,7 @@ model File {
|
||||
maxViews Int?
|
||||
favorite Boolean @default(false)
|
||||
password String?
|
||||
anonymous Boolean @default(false)
|
||||
|
||||
tags Tag[]
|
||||
|
||||
|
||||
+8
-2
@@ -1,4 +1,6 @@
|
||||
export function step(name: string, command: string, condition: () => boolean = () => true) {
|
||||
type StepCommand = string | (() => void | Promise<void>);
|
||||
|
||||
export function step(name: string, command: StepCommand, condition: () => boolean = () => true) {
|
||||
return {
|
||||
name,
|
||||
command,
|
||||
@@ -35,7 +37,11 @@ export async function run(name: string, ...steps: Step[]) {
|
||||
|
||||
try {
|
||||
log(`> Running step "${name}/${step.name}"...`);
|
||||
execSync(step.command, { stdio: 'inherit' });
|
||||
if (typeof step.command === 'string') {
|
||||
execSync(step.command, { stdio: 'inherit' });
|
||||
} else {
|
||||
await step.command();
|
||||
}
|
||||
} catch {
|
||||
console.error(`x Step "${name}/${step.name}" failed.`);
|
||||
process.exit(1);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { run, step } from '.';
|
||||
import { API_ERRORS, ApiError, ApiErrorCode } from '../src/lib/api/errors';
|
||||
|
||||
const ALL_METHODS = ['delete', 'get', 'head', 'patch', 'post', 'put'];
|
||||
const GEN_PATH = path.resolve(__dirname, '..', 'openapi.json');
|
||||
|
||||
const ALL_ERRORS = Object.keys(API_ERRORS)
|
||||
.map((code) => new ApiError(Number(code) as ApiErrorCode).toJSON())
|
||||
.sort((a, b) => a.code - b.code);
|
||||
|
||||
const ERROR_SCHEMA = {
|
||||
type: 'object',
|
||||
description: 'Generic error for API endpoints.',
|
||||
properties: {
|
||||
error: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Message for the error. This may differ from the standard message for the error code, but the error code should be used to figure out the type of error.',
|
||||
},
|
||||
code: {
|
||||
type: 'integer',
|
||||
format: 'int32',
|
||||
description:
|
||||
'Zipline API error code. Ranges: 1xxx validation, 2xxx session, 3xxx permission, 4xxx not-found, 5xxx constraint, 6xxx internal, 9xxx generic.',
|
||||
enum: ALL_ERRORS.map((entry) => entry.code),
|
||||
'x-enumDescriptions': ALL_ERRORS.map((entry) => entry.message),
|
||||
},
|
||||
statusCode: {
|
||||
type: 'integer',
|
||||
format: 'int32',
|
||||
description: 'HTTP status code returned alongside this error payload.',
|
||||
},
|
||||
},
|
||||
required: ['error', 'code', 'statusCode'],
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const ERROR_EXAMPLES = ALL_ERRORS.reduce<Record<string, unknown>>((examples, entry) => {
|
||||
examples[`E${entry.code}`] = {
|
||||
summary: `${entry.error}`,
|
||||
value: entry,
|
||||
};
|
||||
|
||||
return examples;
|
||||
}, {});
|
||||
|
||||
const generic4xxResponse = {
|
||||
description: 'API error response (4xx)',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: ERROR_SCHEMA,
|
||||
examples: ERROR_EXAMPLES,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function addErrorResponse(responses: Record<string, any>): void {
|
||||
const response = (responses['4xx'] ??= structuredClone(generic4xxResponse));
|
||||
|
||||
response.description ??= generic4xxResponse.description;
|
||||
response.content ??= {};
|
||||
|
||||
const jsonContent = (response.content['application/json'] ??= {});
|
||||
jsonContent.schema ??= structuredClone(ERROR_SCHEMA);
|
||||
jsonContent.examples ??= structuredClone(generic4xxResponse.content['application/json'].examples);
|
||||
}
|
||||
|
||||
function filterRoutes(paths = {}): Record<string, any> {
|
||||
return Object.fromEntries(Object.entries(paths).filter(([route]) => route.startsWith('/api')));
|
||||
}
|
||||
|
||||
async function fixSpec() {
|
||||
const spec = JSON.parse(await readFile(GEN_PATH, 'utf8'));
|
||||
|
||||
spec.paths = filterRoutes(spec.paths);
|
||||
|
||||
for (const [, pathItem] of Object.entries(spec.paths ?? {})) {
|
||||
if (!pathItem) continue;
|
||||
|
||||
for (const method of ALL_METHODS) {
|
||||
const operation = (<any>pathItem)[method];
|
||||
if (!operation) continue;
|
||||
|
||||
operation.responses ??= {};
|
||||
addErrorResponse(operation.responses);
|
||||
}
|
||||
}
|
||||
|
||||
await writeFile(GEN_PATH, JSON.stringify(spec));
|
||||
}
|
||||
|
||||
process.env.ZIPLINE_OUTPUT_OPENAPI = 'true';
|
||||
|
||||
run(
|
||||
'openapi',
|
||||
step('run-prod', 'pnpm start', () => process.env.NODE_ENV === 'production'),
|
||||
step('run-dev', 'pnpm dev', () => process.env.NODE_ENV !== 'production'),
|
||||
step('check', async () => {
|
||||
try {
|
||||
await readFile(GEN_PATH);
|
||||
} catch (e) {
|
||||
console.error('\nSomething went wrong...', e);
|
||||
|
||||
throw new Error('No OpenAPI spec found at ./openapi.json');
|
||||
}
|
||||
}),
|
||||
step('fix', fixSpec),
|
||||
);
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useRouteError } from 'react-router-dom';
|
||||
import GenericError from './GenericError';
|
||||
import ReloadPage from './ReloadPage';
|
||||
|
||||
export default function DashboardErrorBoundary(props: Record<string, any>) {
|
||||
const error = useRouteError();
|
||||
if (error instanceof Error && error.message.startsWith('Failed to fetch dynamically imported module:')) {
|
||||
return <ReloadPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<GenericError
|
||||
title='Dashboard Client Error'
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Button, Collapse, Container, Text, Title } from '@mantine/core';
|
||||
import { IconReload } from '@tabler/icons-react';
|
||||
import GenericError from './GenericError';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function ReloadPage() {
|
||||
const [view, setView] = useState(false);
|
||||
|
||||
return (
|
||||
<Container my='lg'>
|
||||
<Title order={3}>Update available</Title>
|
||||
|
||||
<Text size='lg'>A new version of the app is available. Please reload the page to update.</Text>
|
||||
|
||||
<Button
|
||||
leftSection={<IconReload size='1rem' />}
|
||||
mr='sm'
|
||||
mt='md'
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload Page
|
||||
</Button>
|
||||
|
||||
<Button variant='subtle' mt='md' onClick={() => setView((v) => !v)}>
|
||||
Why am I seeing this?
|
||||
</Button>
|
||||
|
||||
<Collapse expanded={view}>
|
||||
<GenericError
|
||||
title='Failed to fetch dynamically imported module'
|
||||
message='This error can occur when a new version of the app is deployed while you have the page open. Please reload the page to update to the latest version.'
|
||||
details={{}}
|
||||
/>
|
||||
</Collapse>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
+11
-11
@@ -1,14 +1,14 @@
|
||||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="manifest" href="manifest.json">
|
||||
|
||||
<title>Zipline</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
<title>Zipline</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import { Button, Center, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconArrowLeft } from '@tabler/icons-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
@@ -4,10 +4,11 @@ import PasskeyAuthButton from '@/components/pages/login/PasskeyAuthButton';
|
||||
import SecureWarningModal from '@/components/pages/login/SecureWarningModal';
|
||||
import TotpModal from '@/components/pages/login/TotpModal';
|
||||
import { getWebClient } from '@/lib/api/detect';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import useLogin from '@/lib/hooks/useLogin';
|
||||
import useObjectState from '@/lib/hooks/useObjectState';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import useLogin from '@/lib/client/hooks/useLogin';
|
||||
import useObjectState from '@/lib/client/hooks/useObjectState';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import {
|
||||
Anchor,
|
||||
Box,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { browserSupportsWebAuthn } from '@simplewebauthn/browser';
|
||||
import {
|
||||
IconBrandDiscordFilled,
|
||||
@@ -34,7 +36,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
import GenericError from '../../error/GenericError';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { eitherTrue } from '@/lib/primitive';
|
||||
|
||||
export default function Login() {
|
||||
useTitle('Login');
|
||||
@@ -103,7 +105,7 @@ export default function Login() {
|
||||
);
|
||||
|
||||
if (error) {
|
||||
if (error.error === 'Invalid username or password') {
|
||||
if (ApiError.check(error, 1044)) {
|
||||
form.setFieldError('username', 'Invalid username');
|
||||
form.setFieldError('password', 'Invalid password');
|
||||
} else {
|
||||
@@ -204,30 +206,45 @@ export default function Login() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider label='or' />
|
||||
{eitherTrue(
|
||||
config.mfa.passkeys && browserSupportsWebAuthn(),
|
||||
config.oauthEnabled.discord,
|
||||
config.oauthEnabled.github,
|
||||
config.oauthEnabled.google,
|
||||
config.oauthEnabled.oidc,
|
||||
) && (
|
||||
<>
|
||||
<Divider label='or' />
|
||||
|
||||
{config.mfa.passkeys && browserSupportsWebAuthn() && <PasskeyAuthButton onAuthSuccess={mutate} />}
|
||||
{config.mfa.passkeys && browserSupportsWebAuthn() && (
|
||||
<PasskeyAuthButton onAuthSuccess={mutate} />
|
||||
)}
|
||||
|
||||
<Group grow>
|
||||
{config.oauthEnabled.discord && (
|
||||
<ExternalAuthButton
|
||||
provider='Discord'
|
||||
leftSection={<IconBrandDiscordFilled stroke={4} size='1.1rem' />}
|
||||
/>
|
||||
)}
|
||||
{config.oauthEnabled.github && (
|
||||
<ExternalAuthButton provider='GitHub' leftSection={<IconBrandGithubFilled size='1.1rem' />} />
|
||||
)}
|
||||
{config.oauthEnabled.google && (
|
||||
<ExternalAuthButton
|
||||
provider='Google'
|
||||
leftSection={<IconBrandGoogleFilled stroke={4} size='1.1rem' />}
|
||||
/>
|
||||
)}
|
||||
{config.oauthEnabled.oidc && (
|
||||
<ExternalAuthButton provider='OIDC' leftSection={<IconCircleKeyFilled size='1.1rem' />} />
|
||||
)}
|
||||
</Group>
|
||||
<Group grow>
|
||||
{config.oauthEnabled.discord && (
|
||||
<ExternalAuthButton
|
||||
provider='Discord'
|
||||
leftSection={<IconBrandDiscordFilled stroke={4} size='1.1rem' />}
|
||||
/>
|
||||
)}
|
||||
{config.oauthEnabled.github && (
|
||||
<ExternalAuthButton
|
||||
provider='GitHub'
|
||||
leftSection={<IconBrandGithubFilled size='1.1rem' />}
|
||||
/>
|
||||
)}
|
||||
{config.oauthEnabled.google && (
|
||||
<ExternalAuthButton
|
||||
provider='Google'
|
||||
leftSection={<IconBrandGoogleFilled stroke={4} size='1.1rem' />}
|
||||
/>
|
||||
)}
|
||||
{config.oauthEnabled.oidc && (
|
||||
<ExternalAuthButton provider='OIDC' leftSection={<IconCircleKeyFilled size='1.1rem' />} />
|
||||
)}
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Center>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import {
|
||||
Button,
|
||||
Center,
|
||||
@@ -23,6 +23,7 @@ import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import GenericError from '../../error/GenericError';
|
||||
import { getWebClient } from '@/lib/api/detect';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Register');
|
||||
@@ -114,7 +115,7 @@ export function Component() {
|
||||
);
|
||||
|
||||
if (error) {
|
||||
if (error.error === 'Username is taken') {
|
||||
if (ApiError.check(error, 1039)) {
|
||||
form.setFieldError('username', 'Username is taken');
|
||||
} else {
|
||||
notifications.show({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type Response } from '@/lib/api/response';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Response } from '@/lib/api/response';
|
||||
import { Container, LoadingOverlay } from '@mantine/core';
|
||||
import useSWR from 'swr';
|
||||
import GenericError from '../../error/GenericError';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Terms of Service');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardServerActions from '@/components/pages/serverActions';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Server Actions');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardInvites from '@/components/pages/invites';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Invites');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardServerSettings from '@/components/pages/serverSettings';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Server Settings');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import ViewUserFiles from '@/components/pages/users/ViewUserFiles';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import { Params, redirect, useLoaderData } from 'react-router-dom';
|
||||
|
||||
export async function loader({ params }: { params: Params<string> }) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardUsers from '@/components/pages/users';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Users');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardFiles from '@/components/pages/files';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Files');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardFolders from '@/components/pages/folders';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Folders');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardHome from '@/components/pages/dashboard';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardMetrics from '@/components/pages/metrics';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import { isAdministrator } from '@/lib/role';
|
||||
import { redirect } from 'react-router-dom';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardSettings from '@/components/pages/settings';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Settings');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import UploadFile from '@/components/pages/upload/File';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Upload File');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import UploadText from '@/components/pages/upload/Text';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Upload Text');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardURLs from '@/components/pages/urls';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('URLs');
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { type Response } from '@/lib/api/response';
|
||||
import { useQueryState } from '@/lib/client/hooks/useQueryState';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { FolderBreadcrumb } from '@/lib/folderHierarchy';
|
||||
import {
|
||||
@@ -8,6 +10,8 @@ import {
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Pagination,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
@@ -15,7 +19,7 @@ import {
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconFolder, IconUpload } from '@tabler/icons-react';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { lazy, Suspense, useMemo, useState } from 'react';
|
||||
import { Link, Params, useLoaderData, useNavigate } from 'react-router-dom';
|
||||
|
||||
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
|
||||
@@ -57,10 +61,14 @@ function PublicFolderCard({ folder }: { folder: Partial<Folder> }) {
|
||||
);
|
||||
}
|
||||
|
||||
const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45];
|
||||
|
||||
export function Component() {
|
||||
const { folder } = useLoaderData<typeof loader>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useTitle(folder.name);
|
||||
|
||||
const buildBreadcrumbs = () => {
|
||||
const items: FolderBreadcrumb[] = [];
|
||||
|
||||
@@ -78,6 +86,21 @@ export function Component() {
|
||||
const breadcrumbs = buildBreadcrumbs();
|
||||
const children = (folder.children ?? []) as Partial<Folder>[];
|
||||
|
||||
const [perpage, setPerpage] = useState(15);
|
||||
const [page, setPage] = useQueryState('page', 1);
|
||||
|
||||
const from = (page - 1) * perpage + 1;
|
||||
const to = Math.min(page * perpage, folder.files?.length ?? 0);
|
||||
const totalRecords = folder.files?.length ?? 0;
|
||||
const cachedPages = Math.ceil(totalRecords / perpage);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (!folder.files) return [];
|
||||
|
||||
const start = (page - 1) * perpage;
|
||||
return folder.files.slice(start, start + perpage);
|
||||
}, [folder.files, page, perpage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container my='lg'>
|
||||
@@ -100,7 +123,7 @@ export function Component() {
|
||||
<Title order={1}>{folder.name}</Title>
|
||||
|
||||
{folder.allowUploads && (
|
||||
<Link to={`/folder/${folder.id}/upload`}>
|
||||
<Link to={`/folder/${folder.id}/upload`} reloadDocument>
|
||||
<ActionIcon variant='outline'>
|
||||
<IconUpload size='1rem' />
|
||||
</ActionIcon>
|
||||
@@ -129,7 +152,7 @@ export function Component() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{(folder.files?.length ?? 0) > 0 && (
|
||||
{(visible.length ?? 0) > 0 && (
|
||||
<>
|
||||
<Title order={3} mt='md' mb='sm'>
|
||||
Files
|
||||
@@ -142,7 +165,7 @@ export function Component() {
|
||||
}}
|
||||
spacing='md'
|
||||
>
|
||||
{folder.files?.map((file: any) => (
|
||||
{visible.map((file: any) => (
|
||||
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
|
||||
<DashboardFile file={file} reduce />
|
||||
</Suspense>
|
||||
@@ -156,6 +179,33 @@ export function Component() {
|
||||
This folder is empty.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group justify='space-between' align='center' mt='md'>
|
||||
<Text size='sm'>{`${from} - ${to} / ${totalRecords} files`}</Text>
|
||||
|
||||
<Group gap='sm'>
|
||||
<Select
|
||||
value={perpage.toString()}
|
||||
data={PER_PAGE_OPTIONS.map((val) => ({ value: val.toString(), label: `${val}` }))}
|
||||
onChange={(value) => {
|
||||
setPerpage(Number(value));
|
||||
setPage(1);
|
||||
}}
|
||||
w={80}
|
||||
size='xs'
|
||||
variant='filled'
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={setPage}
|
||||
total={cachedPages}
|
||||
size='sm'
|
||||
withControls
|
||||
withEdges
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,16 +2,14 @@ import ConfigProvider from '@/components/ConfigProvider';
|
||||
import UploadFile from '@/components/pages/upload/File';
|
||||
import { type Response } from '@/lib/api/response';
|
||||
import { SafeConfig } from '@/lib/config/safe';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import { Anchor, Center, Container, Text } from '@mantine/core';
|
||||
import { data, Link, Params, useLoaderData } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
||||
export async function loader({ params }: { params: Params<string> }) {
|
||||
const res = await fetch(`/api/server/folder/${params.id}?upload=true`);
|
||||
if (!res.ok) {
|
||||
throw data('Folder not found', { status: 404 });
|
||||
}
|
||||
const res = await fetch(`/api/server/folder/${params.id}`);
|
||||
if (!res.ok) throw data('Folder not found', { status: 404 });
|
||||
|
||||
return {
|
||||
folder: (await res.json()) as Response['/api/server/folder/[id]'],
|
||||
@@ -40,7 +38,7 @@ export function Component() {
|
||||
{folder.public ? (
|
||||
<>
|
||||
This folder is{' '}
|
||||
<Anchor component={Link} to={`/folder/${folder.id}`}>
|
||||
<Anchor component={Link} to={`/folder/${folder.id}`} reloadDocument>
|
||||
public
|
||||
</Anchor>
|
||||
. Anyone with the link can view its contents and upload files.
|
||||
|
||||
@@ -26,7 +26,7 @@ import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useSsrData } from '../../../components/ZiplineSSRProvider';
|
||||
import { getFile } from '../../ssr-view/server';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
type SsrData = {
|
||||
file: Partial<NonNullable<Awaited<ReturnType<typeof getFile>>>>;
|
||||
@@ -45,13 +45,6 @@ export default function ViewFileId() {
|
||||
|
||||
const { file, password, code, user, host, metrics, filesRoute, pw } = data;
|
||||
|
||||
// Fix dates that were stringified during SSR
|
||||
if (file?.createdAt) (file as any).createdAt = new Date(file.createdAt);
|
||||
if (file?.updatedAt) (file as any).updatedAt = new Date(file.updatedAt);
|
||||
if (file?.deletesAt) (file as any).deletesAt = new Date(file.deletesAt);
|
||||
if (user?.createdAt) (user as any).createdAt = new Date(user.createdAt);
|
||||
if (user?.updatedAt) (user as any).updatedAt = new Date(user.updatedAt);
|
||||
|
||||
const [passwordValue, setPassword] = useState<string>('');
|
||||
const [passwordError, setPasswordError] = useState<string>('');
|
||||
const [detailsOpen, setDetailsOpen] = useState<boolean>(false);
|
||||
@@ -112,7 +105,7 @@ export default function ViewFileId() {
|
||||
size='md'
|
||||
variant='outline'
|
||||
component={Link}
|
||||
to={`/raw/${file.name}?download=true${pw ? `&pw=${pw}` : ''}`}
|
||||
to={`/raw/${file.name}?download=true${pw ? `&pw=${encodeURIComponent(pw)}` : ''}`}
|
||||
target='_blank'
|
||||
>
|
||||
<IconDownload size='1rem' />
|
||||
@@ -121,7 +114,7 @@ export default function ViewFileId() {
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Collapse in={detailsOpen}>
|
||||
<Collapse expanded={detailsOpen}>
|
||||
<Paper m='md' p='md' withBorder>
|
||||
{user?.view!.content && (
|
||||
<Typography>
|
||||
@@ -180,7 +173,13 @@ export default function ViewFileId() {
|
||||
file.Folder &&
|
||||
(file.Folder.public ? (
|
||||
<Tooltip label='View folder'>
|
||||
<Anchor component={Link} ml='sm' to={`/folder/${file.Folder.id}`}>
|
||||
<Anchor
|
||||
component={Link}
|
||||
ml='sm'
|
||||
to={`/folder/${file.Folder.id}`}
|
||||
target='_blank'
|
||||
reloadDocument
|
||||
>
|
||||
{file.Folder.name}
|
||||
</Anchor>
|
||||
</Tooltip>
|
||||
@@ -202,7 +201,7 @@ export default function ViewFileId() {
|
||||
size='md'
|
||||
variant='outline'
|
||||
component={Link}
|
||||
to={`/raw/${file.name}${pw ? `?pw=${pw}` : ''}`}
|
||||
to={`/raw/${file.name}${pw ? `?pw=${encodeURIComponent(pw)}` : ''}`}
|
||||
target='_blank'
|
||||
>
|
||||
<IconExternalLink size='1rem' />
|
||||
@@ -213,7 +212,7 @@ export default function ViewFileId() {
|
||||
size='md'
|
||||
variant='outline'
|
||||
component={Link}
|
||||
to={`/raw/${file.name}?download=true${pw ? `&pw=${pw}` : ''}`}
|
||||
to={`/raw/${file.name}?download=true${pw ? `&pw=${encodeURIComponent(pw)}` : ''}`}
|
||||
target='_blank'
|
||||
>
|
||||
<IconDownload size='1rem' />
|
||||
|
||||
+22
-28
@@ -8,6 +8,11 @@ import FourOhFour from './pages/404';
|
||||
import Login from './pages/auth/login';
|
||||
import Root from './Root';
|
||||
|
||||
const fourOhFourCatchall = {
|
||||
path: '*',
|
||||
Component: FourOhFour,
|
||||
};
|
||||
|
||||
export async function dashboardLoader() {
|
||||
try {
|
||||
const res = await fetch('/api/server/settings/web');
|
||||
@@ -28,21 +33,21 @@ export const router = createBrowserRouter([
|
||||
{
|
||||
Component: Root,
|
||||
path: '/',
|
||||
HydrateFallback: () => null,
|
||||
children: [
|
||||
{
|
||||
ErrorBoundary: RootErrorBoundary,
|
||||
children: [
|
||||
{ path: '*', Component: FourOhFour },
|
||||
fourOhFourCatchall,
|
||||
{
|
||||
path: '/auth',
|
||||
children: [
|
||||
{ path: 'login', Component: Login },
|
||||
{ path: 'register', lazy: () => import('./pages/auth/register') },
|
||||
{ path: 'auth/login', Component: Login },
|
||||
{ path: 'auth/register', lazy: () => import('./pages/auth/register') },
|
||||
{
|
||||
path: 'setup',
|
||||
path: 'auth/setup',
|
||||
lazy: () => import('./pages/auth/setup'),
|
||||
},
|
||||
{ path: 'tos', lazy: () => import('./pages/auth/tos') },
|
||||
{ path: 'auth/tos', lazy: () => import('./pages/auth/tos') },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -59,37 +64,26 @@ export const router = createBrowserRouter([
|
||||
{ path: 'files', lazy: () => import('./pages/dashboard/files') },
|
||||
{ path: 'folders/*', lazy: () => import('./pages/dashboard/folders') },
|
||||
{ path: 'urls', lazy: () => import('./pages/dashboard/urls') },
|
||||
{ path: 'upload/file', lazy: () => import('./pages/dashboard/upload/file') },
|
||||
{ path: 'upload/text', lazy: () => import('./pages/dashboard/upload/text') },
|
||||
|
||||
// admin routes
|
||||
{
|
||||
path: 'upload',
|
||||
children: [
|
||||
{ path: 'file', lazy: () => import('./pages/dashboard/upload/file') },
|
||||
{ path: 'text', lazy: () => import('./pages/dashboard/upload/text') },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'admin',
|
||||
loader: async () => {
|
||||
const res = await fetch('/api/user');
|
||||
if (!res.ok) {
|
||||
return redirect('/auth/login');
|
||||
}
|
||||
if (!res.ok) return redirect('/auth/login');
|
||||
|
||||
const { user } = await res.json();
|
||||
if (!isAdministrator(user.role)) return redirect('/dashboard');
|
||||
},
|
||||
children: [
|
||||
{ path: 'invites', lazy: () => import('./pages/dashboard/admin/invites') },
|
||||
{ path: 'settings', lazy: () => import('./pages/dashboard/admin/settings') },
|
||||
{ path: 'actions', lazy: () => import('./pages/dashboard/admin/actions') },
|
||||
{ path: 'admin/invites', lazy: () => import('./pages/dashboard/admin/invites') },
|
||||
{ path: 'admin/settings/*', lazy: () => import('./pages/dashboard/admin/settings') },
|
||||
{ path: 'admin/actions', lazy: () => import('./pages/dashboard/admin/actions') },
|
||||
{ path: 'admin/users', lazy: () => import('./pages/dashboard/admin/users') },
|
||||
{
|
||||
path: 'users',
|
||||
children: [
|
||||
{ index: true, lazy: () => import('./pages/dashboard/admin/users') },
|
||||
{
|
||||
path: ':id/files',
|
||||
lazy: () => import('./pages/dashboard/admin/users/[id]/files'),
|
||||
},
|
||||
],
|
||||
path: 'admin/users/:id/files',
|
||||
lazy: () => import('./pages/dashboard/admin/users/[id]/files'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@ import { FastifyRequest } from 'fastify';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { createStaticHandler, createStaticRouter, StaticRouterProvider } from 'react-router-dom';
|
||||
import { createRoutes } from './routes';
|
||||
import { stripHtml } from '@/lib/stripHtml';
|
||||
|
||||
export const getFile = async (id: string) =>
|
||||
prisma.file.findFirst({
|
||||
@@ -166,49 +167,53 @@ export async function render(
|
||||
const router = createStaticRouter(routes, context);
|
||||
const html = renderToString(<StaticRouterProvider context={context} router={router} />);
|
||||
|
||||
const safeFilename = stripHtml(file.name);
|
||||
const safeOriginalName = stripHtml(file.originalName || '');
|
||||
const safeType = stripHtml(file.type || '');
|
||||
|
||||
const meta = `
|
||||
${
|
||||
user?.view?.embedTitle && user.view.embed
|
||||
? `<meta property="og:title" content="${
|
||||
? `<meta property="og:title" content="${stripHtml(
|
||||
parseString(user.view.embedTitle, {
|
||||
file: file as unknown as File,
|
||||
user: user as User,
|
||||
...metrics,
|
||||
}) ?? ''
|
||||
}" />`
|
||||
}) ?? '',
|
||||
)}" />`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
user?.view?.embedDescription && user.view.embed
|
||||
? `<meta property="og:description" content="${
|
||||
? `<meta property="og:description" content="${stripHtml(
|
||||
parseString(user.view.embedDescription, {
|
||||
file: file as unknown as File,
|
||||
user: user as User,
|
||||
...metrics,
|
||||
}) ?? ''
|
||||
}" />`
|
||||
}) ?? '',
|
||||
)}" />`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
user?.view?.embedSiteName && user.view.embed
|
||||
? `<meta property="og:site_name" content="${
|
||||
? `<meta property="og:site_name" content="${stripHtml(
|
||||
parseString(user.view.embedSiteName, {
|
||||
file: file as unknown as File,
|
||||
user: user as User,
|
||||
...metrics,
|
||||
}) ?? ''
|
||||
}" />`
|
||||
}) ?? '',
|
||||
)}" />`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
user?.view?.embedColor && user.view.embed
|
||||
? `<meta property="theme-color" content="${
|
||||
? `<meta property="theme-color" content="${stripHtml(
|
||||
parseString(user.view.embedColor, {
|
||||
file: file as unknown as File,
|
||||
user: user as User,
|
||||
...metrics,
|
||||
}) ?? ''
|
||||
}" />`
|
||||
}) ?? '',
|
||||
)}" />`
|
||||
: ''
|
||||
}
|
||||
|
||||
@@ -216,11 +221,11 @@ export async function render(
|
||||
file.type?.startsWith('image')
|
||||
? `
|
||||
<meta property="og:type" content="image" />
|
||||
<meta property="og:image" itemProp="image" content="${host}/raw/${file.name}" />
|
||||
<meta property="og:url" content="${host}/raw/${file.name}" />
|
||||
<meta property="og:image" itemProp="image" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:url" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:image" content="${host}/raw/${file.name}" />
|
||||
<meta property="twitter:title" content="${file.name}" />
|
||||
<meta property="twitter:image" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="twitter:title" content="${safeFilename}" />
|
||||
`
|
||||
: ''
|
||||
}
|
||||
@@ -230,7 +235,7 @@ export async function render(
|
||||
? `
|
||||
${file.thumbnail ? `<meta property="og:image" content="${host}/raw/${file.thumbnail.path}" />` : ''}
|
||||
<meta property="og:type" content="video.other" />
|
||||
<meta property="og:video:url" content="${host}/raw/${file.name}" />
|
||||
<meta property="og:video:url" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:video:width" content="1920" />
|
||||
<meta property="og:video:height" content="1080" />
|
||||
`
|
||||
@@ -241,18 +246,18 @@ export async function render(
|
||||
file.type?.startsWith('audio')
|
||||
? `
|
||||
<meta name="twitter:card" content="player" />
|
||||
<meta name="twitter:player" content="${host}/raw/${file.name}" />
|
||||
<meta name="twitter:player:stream" content="${host}/raw/${file.name}" />
|
||||
<meta name="twitter:player:stream:content_type" content="${file.type}" />
|
||||
<meta name="twitter:title" content="${file.name}" />
|
||||
<meta name="twitter:player" content="${host}/raw/${safeFilename}" />
|
||||
<meta name="twitter:player:stream" content="${host}/raw/${safeFilename}" />
|
||||
<meta name="twitter:player:stream:content_type" content="${safeType}" />
|
||||
<meta name="twitter:title" content="${safeFilename}" />
|
||||
<meta name="twitter:player:width" content="720" />
|
||||
<meta name="twitter:player:height" content="480" />
|
||||
|
||||
<meta property="og:type" content="music.song" />
|
||||
<meta property="og:url" content="${host}/raw/${file.name}" />
|
||||
<meta property="og:audio" content="${host}/raw/${file.name}" />
|
||||
<meta property="og:audio:secure_url" content="${host}/raw/${file.name}" />
|
||||
<meta property="og:audio:type" content="${file.type}" />
|
||||
<meta property="og:url" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:audio" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:audio:secure_url" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:audio:type" content="${safeType}" />
|
||||
`
|
||||
: ''
|
||||
}
|
||||
@@ -260,12 +265,12 @@ export async function render(
|
||||
${
|
||||
!file.type?.startsWith('video') && !file.type?.startsWith('image')
|
||||
? `
|
||||
<meta property="og:url" content="${host}/raw/${file.name}" />
|
||||
<meta property="og:url" content="${host}/raw/${safeFilename}" />
|
||||
`
|
||||
: ''
|
||||
}
|
||||
|
||||
<title>${file.originalName ?? file.name}</title>
|
||||
<title>${file.originalName ? safeOriginalName : safeFilename}</title>
|
||||
`;
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ViewStore, ViewType, useViewStore } from '@/lib/store/view';
|
||||
import { ViewStore, ViewType, useViewStore } from '@/lib/client/store/view';
|
||||
import { Center, SegmentedControl } from '@mantine/core';
|
||||
import { IconLayoutGrid, IconLayoutList } from '@tabler/icons-react';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
|
||||
+86
-57
@@ -1,11 +1,11 @@
|
||||
import type { Response } from '@/lib/api/response';
|
||||
import useAvatar from '@/lib/client/hooks/useAvatar';
|
||||
import useLogin from '@/lib/client/hooks/useLogin';
|
||||
import { useLogout } from '@/lib/client/hooks/useLogout';
|
||||
import { useUserStore } from '@/lib/client/store/user';
|
||||
import type { SafeConfig } from '@/lib/config/safe';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import useAvatar from '@/lib/hooks/useAvatar';
|
||||
import useLogin from '@/lib/hooks/useLogin';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { isAdministrator } from '@/lib/role';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import {
|
||||
AppShell,
|
||||
Avatar,
|
||||
@@ -47,11 +47,11 @@ import {
|
||||
IconUsersGroup,
|
||||
} from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { Link, NavigateFunction, Outlet, useLoaderData, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { dashboardLoader } from '../client/routes';
|
||||
import ConfigProvider from './ConfigProvider';
|
||||
import VersionBadge from './VersionBadge';
|
||||
import { Link, useLoaderData } from 'react-router-dom';
|
||||
import { dashboardLoader } from '../client/routes';
|
||||
import { useLogout } from '@/lib/hooks/useLogout';
|
||||
import { SETTINGS_EXTERNAL_LINKS } from './pages/serverSettings';
|
||||
|
||||
type NavLinks = {
|
||||
label: string;
|
||||
@@ -124,9 +124,15 @@ const navLinks: NavLinks[] = [
|
||||
{
|
||||
label: 'Settings',
|
||||
icon: <IconAdjustments size='1rem' />,
|
||||
active: (path: string) => path === '/dashboard/admin/settings',
|
||||
active: (path: string) => path.startsWith('/dashboard/admin/settings'),
|
||||
if: (user) => user?.role === 'SUPERADMIN',
|
||||
href: '/dashboard/admin/settings',
|
||||
links: SETTINGS_EXTERNAL_LINKS.map(({ name, url, icon }) => ({
|
||||
label: name,
|
||||
icon,
|
||||
active: (path: string) => path === url,
|
||||
href: url,
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: 'Actions',
|
||||
@@ -151,6 +157,66 @@ const navLinks: NavLinks[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const renderLinks = (
|
||||
links: NavLinks[],
|
||||
pathname: string,
|
||||
user: Response['/api/user']['user'],
|
||||
config: SafeConfig,
|
||||
navigate: NavigateFunction,
|
||||
) => {
|
||||
const visible = (link: NavLinks) => !link.if || link.if(user as Response['/api/user']['user'], config);
|
||||
|
||||
const active = (link: NavLinks): boolean => {
|
||||
if (!visible(link)) return false;
|
||||
if (link.active(pathname)) return true;
|
||||
|
||||
return (link.links || []).some((child) => active(child));
|
||||
};
|
||||
|
||||
return links.map((link) => {
|
||||
if (visible(link)) {
|
||||
const sublinks = link.links;
|
||||
const isActive = link.active(pathname);
|
||||
|
||||
if (!sublinks) {
|
||||
return (
|
||||
<NavLink
|
||||
key={link.label}
|
||||
label={link.label}
|
||||
leftSection={link.icon}
|
||||
variant='light'
|
||||
rightSection={<IconChevronRight size='0.7rem' />}
|
||||
active={isActive}
|
||||
component={Link}
|
||||
to={link.href || ''}
|
||||
prefetch='intent'
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<NavLink
|
||||
key={link.label}
|
||||
label={link.label}
|
||||
leftSection={link.icon}
|
||||
variant='light'
|
||||
rightSection={<IconChevronRight size='0.7rem' />}
|
||||
active={isActive && !sublinks.some((child) => active(child))}
|
||||
defaultOpened={isActive || sublinks.some((child) => active(child))}
|
||||
onClick={(event) => {
|
||||
if (!link.href) return;
|
||||
event.preventDefault();
|
||||
navigate(link.href);
|
||||
}}
|
||||
>
|
||||
{renderLinks(sublinks, pathname, user as Response['/api/user']['user'], config, navigate)}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
export default function Layout() {
|
||||
const theme = useMantineTheme();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
@@ -159,6 +225,7 @@ export default function Layout() {
|
||||
const clipboard = useClipboard();
|
||||
const setUser = useUserStore((s) => s.setUser);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const logout = useLogout();
|
||||
|
||||
const loaderData = useLoaderData<typeof dashboardLoader>();
|
||||
@@ -167,6 +234,12 @@ export default function Layout() {
|
||||
const { user, mutate } = useLogin();
|
||||
const { avatar } = useAvatar();
|
||||
|
||||
const [prev, setPrev] = useState(location.pathname);
|
||||
if (prev !== location.pathname) {
|
||||
setPrev(location.pathname);
|
||||
setOpened(false);
|
||||
}
|
||||
|
||||
const copyToken = () => {
|
||||
modals.openConfirmModal({
|
||||
title: 'Copy token?',
|
||||
@@ -241,6 +314,7 @@ export default function Layout() {
|
||||
color={theme.colors.gray[6]}
|
||||
mr='xl'
|
||||
hiddenFrom='sm'
|
||||
bdrs='md'
|
||||
/>
|
||||
|
||||
{config.website.titleLogo && (
|
||||
@@ -321,54 +395,9 @@ export default function Layout() {
|
||||
</Title>
|
||||
<Divider hiddenFrom='sm' />
|
||||
|
||||
{navLinks
|
||||
.filter((link) => !link.if || link.if(user as Response['/api/user']['user'], config))
|
||||
.map((link) => {
|
||||
if (!link.links) {
|
||||
return (
|
||||
<NavLink
|
||||
key={link.label}
|
||||
label={link.label}
|
||||
leftSection={link.icon}
|
||||
variant='light'
|
||||
rightSection={<IconChevronRight size='0.7rem' />}
|
||||
active={location.pathname === link.href}
|
||||
component={Link}
|
||||
to={link.href || ''}
|
||||
prefetch='intent'
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<NavLink
|
||||
key={link.label}
|
||||
label={link.label}
|
||||
leftSection={link.icon}
|
||||
variant='light'
|
||||
rightSection={<IconChevronRight size='0.7rem' />}
|
||||
defaultOpened={link.active(location.pathname)}
|
||||
>
|
||||
{link.links
|
||||
.filter(
|
||||
(sublink) => !sublink.if || sublink.if(user as Response['/api/user']['user'], config),
|
||||
)
|
||||
.map((sublink) => (
|
||||
<NavLink
|
||||
key={sublink.label}
|
||||
label={sublink.label}
|
||||
leftSection={sublink.icon}
|
||||
rightSection={<IconChevronRight size='0.7rem' />}
|
||||
variant='light'
|
||||
active={location.pathname === sublink.href}
|
||||
component={Link}
|
||||
to={sublink.href || ''}
|
||||
prefetch='intent'
|
||||
/>
|
||||
))}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
})}
|
||||
<ScrollArea mah='calc(100vh - 200px)'>
|
||||
{renderLinks(navLinks, location.pathname, user as Response['/api/user']['user'], config, navigate)}
|
||||
</ScrollArea>
|
||||
|
||||
<div style={{ marginTop: 'auto' }}>
|
||||
<VersionBadge />
|
||||
@@ -395,7 +424,7 @@ export default function Layout() {
|
||||
|
||||
<AppShell.Main>
|
||||
<ConfigProvider data={loaderData}>
|
||||
<Paper m='lg' withBorder p='xs'>
|
||||
<Paper withBorder m='md' p='xs' radius='md'>
|
||||
<Outlet />
|
||||
</Paper>
|
||||
</ConfigProvider>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { getDomain } from '@/lib/client/webDomain';
|
||||
import { Button, Group, Image, Modal, Select, Text, Tooltip } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconClipboardCheck, IconClipboardX, IconCopy, IconDownload } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type Type = 'image/png' | 'image/jpeg' | 'image/webp';
|
||||
|
||||
const UNSUPPORTED_COPY = ['image/jpeg', 'image/webp'];
|
||||
|
||||
export default function QRCodeModal({
|
||||
opened,
|
||||
onClose,
|
||||
url,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
url: string;
|
||||
}) {
|
||||
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||
const [type, setType] = useState<Type>('image/png');
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
|
||||
import('qrcode')
|
||||
.then((QRCode) => QRCode.toDataURL(getDomain(url), { width: 500, type }))
|
||||
.then(setDataUrl)
|
||||
.catch(() => setDataUrl(null));
|
||||
}, [opened, url, type]);
|
||||
|
||||
const copyImageToClipboard = async () => {
|
||||
if (!dataUrl) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(dataUrl);
|
||||
const blob = await response.blob();
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[blob.type]: blob,
|
||||
}),
|
||||
]);
|
||||
showNotification({
|
||||
message: 'QR code image copied to clipboard',
|
||||
color: 'green',
|
||||
icon: <IconClipboardCheck size='1rem' />,
|
||||
});
|
||||
} catch (error) {
|
||||
showNotification({
|
||||
title: 'Failed to copy QR code image',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
color: 'red',
|
||||
icon: <IconClipboardX size='1rem' />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const downloadImage = () => {
|
||||
if (!dataUrl) return;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = dataUrl;
|
||||
link.style.display = 'none';
|
||||
link.download = `qr-code.${type.split('/')[1]}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title='QR Code' opened={opened} onClose={onClose} size='sm' centered>
|
||||
{dataUrl ? (
|
||||
<Image src={dataUrl} alt='QR Code' />
|
||||
) : (
|
||||
<Text c='red' ta='center'>
|
||||
Failed to generate QR code.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Select
|
||||
mt='md'
|
||||
label='Format'
|
||||
value={type}
|
||||
onChange={(value) => setType(value as Type)}
|
||||
data={[
|
||||
{ value: 'image/png', label: 'png' },
|
||||
{ value: 'image/jpeg', label: 'jpeg' },
|
||||
{ value: 'image/webp', label: 'webp' },
|
||||
]}
|
||||
size='xs'
|
||||
/>
|
||||
|
||||
{dataUrl && (
|
||||
<Group gap='xs' mt='md' grow>
|
||||
<Tooltip
|
||||
label={
|
||||
UNSUPPORTED_COPY.includes(type)
|
||||
? 'Copying this format is not supported in some browsers. You can copy the image normally via right-click or holding it.'
|
||||
: ''
|
||||
}
|
||||
hidden={!UNSUPPORTED_COPY.includes(type)}
|
||||
>
|
||||
<Button
|
||||
onClick={copyImageToClipboard}
|
||||
leftSection={<IconCopy size='1rem' />}
|
||||
disabled={UNSUPPORTED_COPY.includes(type)}
|
||||
>
|
||||
Copy Image
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button onClick={downloadImage} leftSection={<IconDownload size='1rem' />}>
|
||||
Download
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Config } from '@/lib/config/validate';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import { useSettingsStore } from '@/lib/client/store/settings';
|
||||
import { useUserStore } from '@/lib/client/store/user';
|
||||
import { ZiplineTheme, findTheme, themeComponents } from '@/lib/theme';
|
||||
import dark_blue from '@/lib/theme/builtins/dark_blue';
|
||||
import { MantineProvider, createTheme } from '@mantine/core';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import useVersion from '@/lib/hooks/useVersion';
|
||||
import useVersion from '@/lib/client/hooks/useVersion';
|
||||
import {
|
||||
Anchor,
|
||||
Badge,
|
||||
@@ -14,7 +14,11 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
|
||||
function DataDisplay({ items }: { items: { label: string; value: string; href?: string }[] }) {
|
||||
function DataDisplay({
|
||||
items,
|
||||
}: {
|
||||
items: { label: string; value: string; href?: string; color?: string }[];
|
||||
}) {
|
||||
return (
|
||||
<Paper withBorder p='sm'>
|
||||
<Stack gap='xs'>
|
||||
@@ -29,7 +33,7 @@ function DataDisplay({ items }: { items: { label: string; value: string; href?:
|
||||
{item.value}
|
||||
</Anchor>
|
||||
) : (
|
||||
<Text>{item.value}</Text>
|
||||
<Text c={item.color ?? undefined}>{item.value}</Text>
|
||||
)}
|
||||
</Flex>
|
||||
))}
|
||||
@@ -105,10 +109,14 @@ export default function VersionBadge() {
|
||||
},
|
||||
{
|
||||
label: 'Commit',
|
||||
value: version.version.sha!,
|
||||
value: version.version.sha!.slice(0, 7)!,
|
||||
href: `https://github.com/diced/zipline/commit/${version.version.sha}`,
|
||||
},
|
||||
{ label: 'Upstream?', value: version.isUpstream ? 'Yes' : 'No' },
|
||||
{
|
||||
label: 'Upstream?',
|
||||
value: version.isUpstream ? 'Yes' : 'No',
|
||||
color: version.isUpstream ? 'orange' : 'green',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -131,6 +139,7 @@ export default function VersionBadge() {
|
||||
{
|
||||
label: 'Available to update',
|
||||
value: version.latest.commit.pull ? 'Yes' : 'No',
|
||||
color: version.latest.commit.pull ? 'green' : 'red',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { File } from '@/lib/db/models/file';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import useObjectState from '@/lib/hooks/useObjectState';
|
||||
import useObjectState from '@/lib/client/hooks/useObjectState';
|
||||
import { Button, Divider, Modal, NumberInput, PasswordInput, Stack, TextInput } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconEye, IconKey, IconPencil, IconPencilOff, IconTrashFilled } from '@tabler/icons-react';
|
||||
|
||||
@@ -6,8 +6,8 @@ import { File } from '@/lib/db/models/file';
|
||||
import { Tag } from '@/lib/db/models/tag';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
|
||||
import { useFolders } from '@/lib/hooks/useFolders';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import { useFolders } from '@/lib/client/hooks/useFolders';
|
||||
import { useSettingsStore } from '@/lib/client/store/settings';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
IconTextRecognition,
|
||||
IconTrashFilled,
|
||||
IconUpload,
|
||||
IconUserQuestion,
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
@@ -229,6 +230,7 @@ export default function FileModal({
|
||||
{file.originalName && (
|
||||
<FileStat Icon={IconTextRecognition} title='Original Name' value={file.originalName} />
|
||||
)}
|
||||
{file.anonymous && <FileStat Icon={IconUserQuestion} title='Anonymous' value='Yes' />}
|
||||
</SimpleGrid>
|
||||
|
||||
{!reduce && (
|
||||
@@ -237,12 +239,7 @@ export default function FileModal({
|
||||
<Title order={4} mt='lg' mb='xs'>
|
||||
Tags
|
||||
</Title>
|
||||
<Combobox
|
||||
zIndex={90000}
|
||||
withinPortal={false}
|
||||
store={tagsCombobox}
|
||||
onOptionSubmit={handleValueSelect}
|
||||
>
|
||||
<Combobox zIndex={90000} store={tagsCombobox} onOptionSubmit={handleValueSelect}>
|
||||
<Combobox.DropdownTarget>
|
||||
<PillsInput
|
||||
onBlur={() => triggerSave()}
|
||||
@@ -318,7 +315,7 @@ export default function FileModal({
|
||||
</Button>
|
||||
) : (
|
||||
<Combobox
|
||||
withinPortal={false}
|
||||
zIndex={90000}
|
||||
store={folderCombobox}
|
||||
onOptionSubmit={(value) => handleAdd(value)}
|
||||
>
|
||||
@@ -349,6 +346,12 @@ export default function FileModal({
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
{folders?.length === 0 && (
|
||||
<Combobox.Empty>
|
||||
You have no folders. Start typing to create a new folder for this file.
|
||||
</Combobox.Empty>
|
||||
)}
|
||||
|
||||
<FolderComboboxOptions
|
||||
folderOptions={folderOptions}
|
||||
searchValue={search}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useSettingsStore } from '@/lib/client/store/settings';
|
||||
import { useUserStore } from '@/lib/client/store/user';
|
||||
import type { File as DbFile } from '@/lib/db/models/file';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import {
|
||||
Box,
|
||||
Center,
|
||||
@@ -11,13 +12,23 @@ import {
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { Icon, IconFileUnknown, IconPlayerPlay, IconShieldLockFilled } from '@tabler/icons-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { renderMode } from '../pages/upload/renderMode';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Asciinema from '../render/Asciinema';
|
||||
import Pdf from '../render/Pdf';
|
||||
import Render from '../render/Render';
|
||||
import { renderMode } from '../render/renderMode';
|
||||
import fileIcon from './fileIcon';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
|
||||
const MAX_BYTES = 1 * 1024 * 1024;
|
||||
const FILE_BIG = '\n...\nThe file is too big to display click the download icon to view/download it.';
|
||||
|
||||
function appendPassword(url: string, password?: string | null) {
|
||||
return `${url}${password ? `?pw=${encodeURIComponent(password)}` : ''}`;
|
||||
}
|
||||
|
||||
function isDbFile(file: DbFile | File): file is DbFile {
|
||||
return typeof globalThis.File !== 'undefined' ? !(file instanceof globalThis.File) : 'thumbnail' in file;
|
||||
}
|
||||
|
||||
function PlaceholderContent({ text, Icon }: { text: string; Icon: Icon }) {
|
||||
return (
|
||||
@@ -82,16 +93,37 @@ export default function DashboardFileType({
|
||||
}) {
|
||||
const user = useUserStore((state) => state.user);
|
||||
const disableMediaPreview = useSettingsStore((state) => state.settings.disableMediaPreview);
|
||||
const fileRoute = user ? `/api/user/files/${(file as DbFile).id}/raw` : `/raw/${file.name}`;
|
||||
const thumbnailRoute = user
|
||||
? `/api/user/files/${(file as DbFile).thumbnail?.path}/raw`
|
||||
: `/raw/${(file as DbFile).thumbnail?.path}`;
|
||||
const dbFile = 'id' in file;
|
||||
const renderIn = useMemo(() => renderMode(file.name.split('.').pop() || ''), [file.name]);
|
||||
const dbFile = isDbFile(file);
|
||||
|
||||
const fileRoute = dbFile ? (user ? `/api/user/files/${file.id}/raw` : `/raw/${file.name}`) : '';
|
||||
|
||||
const thumbnailRoute = dbFile
|
||||
? file.thumbnail?.path
|
||||
? user
|
||||
? `/api/user/files/${file.thumbnail.path}/raw`
|
||||
: `/raw/${file.thumbnail.path}`
|
||||
: null
|
||||
: null;
|
||||
|
||||
const dbFileUrl = dbFile ? appendPassword(fileRoute, password) : '';
|
||||
const [blobUrl, setBlobUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (dbFile) return setBlobUrl('');
|
||||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
setBlobUrl(objectUrl);
|
||||
|
||||
return () => URL.revokeObjectURL(objectUrl);
|
||||
}, [dbFile, file]);
|
||||
|
||||
const fileUrl = dbFile ? dbFileUrl : blobUrl;
|
||||
|
||||
const extension = file.name.split('.').pop() || '';
|
||||
const renderIn = renderMode(extension);
|
||||
const type = code ? 'text' : file.type.split('/')[0];
|
||||
|
||||
const [fileContent, setFileContent] = useState('');
|
||||
const [type, setType] = useState(file.type.split('/')[0]);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const getText = useCallback(async () => {
|
||||
@@ -99,52 +131,41 @@ export default function DashboardFileType({
|
||||
if (!dbFile) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if ((reader.result! as string).length > 1 * 1024 * 1024) {
|
||||
setFileContent(
|
||||
reader.result!.slice(0, 1 * 1024 * 1024) +
|
||||
'\n...\nThe file is too big to display click the download icon to view/download it.',
|
||||
);
|
||||
const content = reader.result as string;
|
||||
if (content.length > MAX_BYTES) {
|
||||
setFileContent(content.slice(0, MAX_BYTES) + FILE_BIG);
|
||||
} else {
|
||||
setFileContent(reader.result as string);
|
||||
setFileContent(content);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 1 * 1024 * 1024) {
|
||||
const res = await fetch(`${fileRoute}${password ? `?pw=${password}` : ''}`, {
|
||||
if (file.size > MAX_BYTES) {
|
||||
const res = await fetch(fileUrl, {
|
||||
headers: {
|
||||
Range: 'bytes=0-' + 1 * 1024 * 1024, // 0 mb to 1 mb
|
||||
Range: `bytes=0-${MAX_BYTES}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to fetch file');
|
||||
const text = await res.text();
|
||||
setFileContent(
|
||||
text + '\n...\nThe file is too big to display click the download icon to view/download it.',
|
||||
);
|
||||
setFileContent(text + FILE_BIG);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(`${fileRoute}${password ? `?pw=${password}` : ''}`);
|
||||
const res = await fetch(fileUrl);
|
||||
if (!res.ok) throw new Error('Failed to fetch file');
|
||||
const text = await res.text();
|
||||
setFileContent(text);
|
||||
} catch {
|
||||
setFileContent('Error loading file.');
|
||||
}
|
||||
}, [dbFile, file, password]);
|
||||
}, [dbFile, file, fileUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (code) {
|
||||
setType('text');
|
||||
getText();
|
||||
} else if (type === 'text') {
|
||||
getText();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
if (type === 'text') getText();
|
||||
}, [type, getText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -152,6 +173,10 @@ export default function DashboardFileType({
|
||||
} else {
|
||||
document.body.style.overflow = 'auto';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = 'auto';
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (disableMediaPreview && !show)
|
||||
@@ -166,7 +191,7 @@ export default function DashboardFileType({
|
||||
<Placeholder
|
||||
text={`Click to view protected ${file.name}`}
|
||||
Icon={IconShieldLockFilled}
|
||||
onClick={() => window.open(`/view/${file.name}${password ? `?pw=${password}` : ''}`)}
|
||||
onClick={() => window.open(appendPassword(`/view/${file.name}`, password))}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
@@ -175,16 +200,17 @@ export default function DashboardFileType({
|
||||
|
||||
switch (true) {
|
||||
case type === 'video':
|
||||
if (!fileUrl) return <Loader />;
|
||||
return show ? (
|
||||
<video
|
||||
width='100%'
|
||||
autoPlay
|
||||
muted
|
||||
controls
|
||||
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||
src={fileUrl}
|
||||
style={{ cursor: 'pointer', maxWidth: '85vw', maxHeight: '85vh' }}
|
||||
/>
|
||||
) : (file as DbFile).thumbnail && dbFile ? (
|
||||
) : thumbnailRoute ? (
|
||||
<Box pos='relative'>
|
||||
<MantineImage src={thumbnailRoute} alt={file.name || 'Video thumbnail'} />
|
||||
|
||||
@@ -209,22 +235,23 @@ export default function DashboardFileType({
|
||||
);
|
||||
|
||||
case type === 'image':
|
||||
if (!fileUrl) return <Loader />;
|
||||
return show ? (
|
||||
<Center>
|
||||
<MantineImage
|
||||
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||
src={fileUrl}
|
||||
alt={file.name || 'Image'}
|
||||
style={{
|
||||
cursor: allowZoom ? 'zoom-in' : 'default',
|
||||
maxWidth: '70vw',
|
||||
maxHeight: '70vw',
|
||||
}}
|
||||
onClick={() => setOpen(true)}
|
||||
onClick={() => allowZoom && setOpen(true)}
|
||||
/>
|
||||
{allowZoom && open && (
|
||||
<FileZoomModal setOpen={setOpen}>
|
||||
<MantineImage
|
||||
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||
src={fileUrl}
|
||||
alt={file.name || 'Image'}
|
||||
style={{
|
||||
maxWidth: '95vw',
|
||||
@@ -238,23 +265,13 @@ export default function DashboardFileType({
|
||||
)}
|
||||
</Center>
|
||||
) : (
|
||||
<MantineImage
|
||||
fit='contain'
|
||||
mah={400}
|
||||
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||
alt={file.name || 'Image'}
|
||||
/>
|
||||
<MantineImage fit='contain' mah={400} src={fileUrl} alt={file.name || 'Image'} />
|
||||
);
|
||||
|
||||
case type === 'audio':
|
||||
if (!fileUrl) return <Loader />;
|
||||
return show ? (
|
||||
<audio
|
||||
autoPlay
|
||||
muted
|
||||
controls
|
||||
style={{ width: '100%' }}
|
||||
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||
/>
|
||||
<audio autoPlay muted controls style={{ width: '100%' }} src={fileUrl} />
|
||||
) : (
|
||||
<Placeholder text={`Click to play audio ${file.name}`} Icon={fileIcon(file.type)} />
|
||||
);
|
||||
@@ -278,15 +295,16 @@ export default function DashboardFileType({
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Render mode={renderIn} language={file.name.split('.').pop() || ''} code={fileContent} />
|
||||
<Render mode={renderIn} language={extension} code={fileContent} />
|
||||
)
|
||||
) : (
|
||||
<Placeholder text={`Click to view text ${file.name}`} Icon={fileIcon(file.type)} />
|
||||
);
|
||||
|
||||
case isAsciicast === true:
|
||||
return show && dbFile ? (
|
||||
<Asciinema src={`${fileRoute}${password ? `?pw=${password}` : ''}`} />
|
||||
if (!fileUrl) return <Loader />;
|
||||
return show ? (
|
||||
<Asciinema src={fileUrl} />
|
||||
) : (
|
||||
<Placeholder
|
||||
text={`Click to download asciinema cast ${file.name}`}
|
||||
@@ -295,26 +313,27 @@ export default function DashboardFileType({
|
||||
);
|
||||
|
||||
case file.type === 'application/pdf':
|
||||
return show && dbFile ? (
|
||||
<Pdf src={`${fileRoute}${password ? `?pw=${password}` : ''}`} />
|
||||
if (!fileUrl) return <Loader />;
|
||||
return show ? (
|
||||
<Pdf src={fileUrl} />
|
||||
) : (
|
||||
<Placeholder text={`Click to view PDF ${file.name}`} Icon={fileIcon(file.type)} />
|
||||
);
|
||||
|
||||
default:
|
||||
if (dbFile && !show)
|
||||
return <Placeholder text={`Click to view file ${file.name}`} Icon={fileIcon(file.type)} />;
|
||||
if (!show) return <Placeholder text={`Click to view file ${file.name}`} Icon={fileIcon(file.type)} />;
|
||||
|
||||
if (dbFile && show)
|
||||
if (show)
|
||||
return (
|
||||
<Paper withBorder p='xs' style={{ cursor: 'pointer' }}>
|
||||
<Placeholder
|
||||
onClick={() => window.open(`${fileRoute}${password ? `?pw=${password}` : ''}`)}
|
||||
onClick={() => window.open(fileUrl)}
|
||||
text={`Click to view file ${file.name} in a new tab`}
|
||||
Icon={fileIcon(file.type)}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
else return <IconFileUnknown size={48} />;
|
||||
|
||||
return <IconFileUnknown size={48} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Response } from '@/lib/api/response';
|
||||
import type { File } from '@/lib/db/models/file';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { conditionalWarning } from '@/lib/warningModal';
|
||||
import { conditionalWarning } from '@/lib/client/warningModal';
|
||||
import { getDomain } from '@/lib/client/webDomain';
|
||||
import { Anchor } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
@@ -29,13 +30,11 @@ export function downloadFile(file: File) {
|
||||
}
|
||||
|
||||
export function copyFile(file: File, clipboard: ReturnType<typeof useClipboard>, raw: boolean = false) {
|
||||
const domain = `${window.location.protocol}//${window.location.host}`;
|
||||
|
||||
const url = raw
|
||||
? `${domain}/raw/${file.name}`
|
||||
? getDomain(`/raw/${file.name}`)
|
||||
: file.url
|
||||
? `${domain}${file.url}`
|
||||
: `${domain}/view/${file.name}`;
|
||||
? getDomain(`${file.url}`)
|
||||
: getDomain(`/view/${file.name}`);
|
||||
|
||||
clipboard.copy(url);
|
||||
|
||||
@@ -141,14 +140,10 @@ export async function createFolderAndAdd(file: File, folderName: string | null)
|
||||
}
|
||||
|
||||
export async function removeFromFolder(file: File) {
|
||||
const { data, error } = await fetchApi<Response['/api/user/files/[id]']>(
|
||||
`/api/user/folders/${file.folderId}`,
|
||||
'DELETE',
|
||||
{
|
||||
delete: 'file',
|
||||
id: file.id,
|
||||
},
|
||||
);
|
||||
const { data, error } = await fetchApi<{ folder: Folder }>(`/api/user/folders/${file.folderId}`, 'DELETE', {
|
||||
delete: 'file',
|
||||
id: file.id,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
notifications.show({
|
||||
@@ -160,7 +155,7 @@ export async function removeFromFolder(file: File) {
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'File removed from folder',
|
||||
message: `${file.name} has been removed from ${data!.name}`,
|
||||
message: `${file.name} has been removed from ${data?.folder.name}`,
|
||||
color: 'green',
|
||||
icon: <IconFolderMinus size='1rem' />,
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useConfig } from '@/components/ConfigProvider';
|
||||
import Stat from '@/components/Stat';
|
||||
import type { Response } from '@/lib/api/response';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import useLogin from '@/lib/hooks/useLogin';
|
||||
import useLogin from '@/lib/client/hooks/useLogin';
|
||||
import { isAdministrator } from '@/lib/role';
|
||||
import { Button, Group, Paper, ScrollArea, SimpleGrid, Skeleton, Table, Text, Title } from '@mantine/core';
|
||||
import {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { IncompleteFile } from '@/lib/db/models/incompleteFile';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { UpdateFn } from '@/lib/hooks/useObjectState';
|
||||
import { UpdateFn } from '@/lib/client/hooks/useObjectState';
|
||||
import { IncompleteFileStatus } from '@/prisma/client';
|
||||
import { Badge, Button, Card, Group, Modal, Paper, Stack, Text } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
@@ -72,7 +72,7 @@ export default function PendingFilesModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={modals.pending} onClose={() => setModals('pending', false)}>
|
||||
<Modal opened={modals.pending} onClose={() => setModals('pending', false)} title='Pending Files'>
|
||||
<Stack gap='xs'>
|
||||
{incompleteFiles?.map((incompleteFile) => (
|
||||
<Card key={incompleteFile.id} withBorder>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FieldSettings, useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
|
||||
import { FieldSettings, NAMES, useFileTableSettingsStore } from '@/lib/client/store/fileTableSettings';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
@@ -14,17 +14,6 @@ import { Button, Checkbox, Group, Modal, Paper, Text } from '@mantine/core';
|
||||
import { IconGripVertical } from '@tabler/icons-react';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
|
||||
export const NAMES = {
|
||||
name: 'Name',
|
||||
originalName: 'Original Name',
|
||||
tags: 'Tags',
|
||||
type: 'Type',
|
||||
size: 'Size',
|
||||
createdAt: 'Created At',
|
||||
favorite: 'Favorite',
|
||||
views: 'Views',
|
||||
};
|
||||
|
||||
function SortableTableField({ item }: { item: FieldSettings }) {
|
||||
const setVisible = useFileTableSettingsStore((state) => state.setVisible);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import GridTableSwitcher from '@/components/GridTableSwitcher';
|
||||
import useObjectState from '@/lib/hooks/useObjectState';
|
||||
import { useViewStore } from '@/lib/store/view';
|
||||
import useObjectState, { type UpdateFn } from '@/lib/client/hooks/useObjectState';
|
||||
import { useViewStore } from '@/lib/client/store/view';
|
||||
import { ActionIcon, Group, Menu, Title, Tooltip } from '@mantine/core';
|
||||
import {
|
||||
IconDots,
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
IconTableOptions,
|
||||
IconTags,
|
||||
} from '@tabler/icons-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import PendingFilesModal from './PendingFilesModal';
|
||||
import TagsModal from './tags/TagsModal';
|
||||
import FavoriteFiles from './views/FavoriteFiles';
|
||||
@@ -26,14 +26,47 @@ export type DashboardFilesModals = {
|
||||
|
||||
export default function DashboardFiles() {
|
||||
const view = useViewStore((state) => state.files);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const modalKeys: Array<keyof DashboardFilesModals> = ['table', 'idSearch', 'tags', 'pending'];
|
||||
|
||||
const [modals, setModals] = useObjectState<DashboardFilesModals>({
|
||||
table: false,
|
||||
idSearch: false,
|
||||
tags: false,
|
||||
pending: false,
|
||||
const modalQS = (key: keyof DashboardFilesModals) => searchParams.get(key) === 'true';
|
||||
|
||||
const [modals, setModalState] = useObjectState<DashboardFilesModals>({
|
||||
table: modalQS('table'),
|
||||
idSearch: modalQS('idSearch'),
|
||||
tags: modalQS('tags'),
|
||||
pending: modalQS('pending'),
|
||||
});
|
||||
|
||||
const updateModalQuery = (updates: Partial<DashboardFilesModals>) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
|
||||
for (const key of modalKeys) {
|
||||
if (!(key in updates)) continue;
|
||||
|
||||
if (updates[key]) next.set(key, 'true');
|
||||
else next.delete(key);
|
||||
}
|
||||
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
};
|
||||
|
||||
const setModals: UpdateFn<DashboardFilesModals> = (keyOrObj: any, value?: any) => {
|
||||
if (typeof keyOrObj === 'object' && value === undefined) {
|
||||
setModalState(keyOrObj);
|
||||
updateModalQuery(keyOrObj);
|
||||
return;
|
||||
}
|
||||
|
||||
setModalState(keyOrObj, value);
|
||||
updateModalQuery({ [keyOrObj]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TagsModal modals={modals} setModals={setModals} />
|
||||
|
||||
@@ -11,8 +11,13 @@ export default function TagPill({
|
||||
if (!tag) return null;
|
||||
|
||||
return (
|
||||
<Pill bg={tag.color || undefined} c={isLightColor(tag.color) ? 'black' : 'white'} {...other}>
|
||||
{tag.name}
|
||||
<Pill
|
||||
bg={tag.color || undefined}
|
||||
c={isLightColor(tag.color) ? 'black' : 'white'}
|
||||
title={tag.name}
|
||||
{...other}
|
||||
>
|
||||
{tag.name.length <= 24 ? tag.name : tag.name.slice(0, 21) + '...'}
|
||||
</Pill>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mutateFiles } from '@/components/file/actions';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Tag } from '@/lib/db/models/tag';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { UpdateFn } from '@/lib/hooks/useObjectState';
|
||||
import { UpdateFn } from '@/lib/client/hooks/useObjectState';
|
||||
import { ActionIcon, Group, Modal, Paper, Stack, Text, Title, Tooltip } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconPencil, IconPlus, IconTagOff, IconTrashFilled } from '@tabler/icons-react';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQueryState } from '@/lib/hooks/useQueryState';
|
||||
import { useQueryState } from '@/lib/client/hooks/useQueryState';
|
||||
import {
|
||||
Accordion,
|
||||
Button,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQueryState } from '@/lib/hooks/useQueryState';
|
||||
import { useQueryState } from '@/lib/client/hooks/useQueryState';
|
||||
import {
|
||||
Button,
|
||||
Center,
|
||||
|
||||
@@ -3,13 +3,13 @@ import { addMultipleToFolder, copyFile, deleteFile, downloadFile } from '@/compo
|
||||
import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { useFolders } from '@/lib/client/hooks/useFolders';
|
||||
import { useQueryState } from '@/lib/client/hooks/useQueryState';
|
||||
import { NAMES, useFileTableSettingsStore } from '@/lib/client/store/fileTableSettings';
|
||||
import { useSettingsStore } from '@/lib/client/store/settings';
|
||||
import { type File } from '@/lib/db/models/file';
|
||||
import { Tag } from '@/lib/db/models/tag';
|
||||
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
|
||||
import { useFolders } from '@/lib/hooks/useFolders';
|
||||
import { useQueryState } from '@/lib/hooks/useQueryState';
|
||||
import { useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
@@ -44,9 +44,9 @@ import { lazy, useEffect, useMemo, useReducer, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import { UpdateFn } from '@/lib/hooks/useObjectState';
|
||||
import { UpdateFn } from '@/lib/client/hooks/useObjectState';
|
||||
import { DashboardFilesModals } from '..';
|
||||
import TableEditModal, { NAMES } from '../TableEditModal';
|
||||
import TableEditModal from '../TableEditModal';
|
||||
import { bulkDelete, bulkFavorite } from '../bulk';
|
||||
import TagPill from '../tags/TagPill';
|
||||
import { useApiPagination } from '../useApiPagination';
|
||||
@@ -342,6 +342,7 @@ export default function FileTable({
|
||||
{
|
||||
accessor: 'favorite',
|
||||
sortable: true,
|
||||
title: 'Favorite?',
|
||||
render: (file: File) => (file.favorite ? <Text c='yellow'>Yes</Text> : 'No'),
|
||||
},
|
||||
{
|
||||
@@ -354,6 +355,12 @@ export default function FileTable({
|
||||
hidden: searchField !== 'id' || searchQuery.id.trim() === '',
|
||||
filtering: searchField === 'id' && searchQuery.id.trim() !== '',
|
||||
},
|
||||
{
|
||||
accessor: 'anonymous',
|
||||
sortable: true,
|
||||
title: 'Anonymous?',
|
||||
render: (file: File) => (file.anonymous ? <Text c='green'>Yes</Text> : 'No'),
|
||||
},
|
||||
];
|
||||
|
||||
const visibleFields = fields.filter((f) => f.visible).map((f) => f.field);
|
||||
@@ -384,12 +391,12 @@ export default function FileTable({
|
||||
user={id}
|
||||
/>
|
||||
|
||||
{modals && setModals && modals.table && (
|
||||
<TableEditModal opened={modals.table} onClose={() => setModals('table', false)} />
|
||||
{modals && setModals && (
|
||||
<TableEditModal opened={!!modals.table} onClose={() => setModals('table', false)} />
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Collapse in={selectedFiles.length > 0}>
|
||||
<Collapse expanded={selectedFiles.length > 0}>
|
||||
<Paper withBorder p='sm' my='sm'>
|
||||
<Text size='sm' c='dimmed' mb='xs'>
|
||||
Selections are saved across page changes
|
||||
@@ -480,7 +487,7 @@ export default function FileTable({
|
||||
</Collapse>
|
||||
|
||||
{modals && setModals && modals.idSearch && (
|
||||
<Collapse in={modals.idSearch}>
|
||||
<Collapse expanded={modals.idSearch}>
|
||||
<Paper withBorder p='sm' mt='sm'>
|
||||
<TextInput
|
||||
placeholder='Search by ID'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQueryState } from '@/lib/hooks/useQueryState';
|
||||
import { useQueryState } from '@/lib/client/hooks/useQueryState';
|
||||
import {
|
||||
Accordion,
|
||||
Button,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { getDomain } from '@/lib/client/webDomain';
|
||||
import { Anchor } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
@@ -9,13 +10,14 @@ import { Link } from 'react-router-dom';
|
||||
import { mutate } from 'swr';
|
||||
|
||||
export function copyFolderUrl(folder: Folder, clipboard: ReturnType<typeof useClipboard>) {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}/folder/${folder.id}`);
|
||||
const url = getDomain(`/folder/${folder.id}`);
|
||||
clipboard.copy(url);
|
||||
|
||||
notifications.show({
|
||||
title: 'Copied link',
|
||||
message: (
|
||||
<Anchor component={Link} to={`/folder/${folder.id}`}>
|
||||
{`${window.location.protocol}//${window.location.host}/folder/${folder.id}`}
|
||||
{url}
|
||||
</Anchor>
|
||||
),
|
||||
color: 'green',
|
||||
@@ -48,7 +50,7 @@ export async function editFolderVisibility(folder: Folder, isPublic: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
mutateFolder(folder.id);
|
||||
mutateFolder();
|
||||
}
|
||||
|
||||
export async function editFolderUploads(folder: Folder, allowUploads: boolean) {
|
||||
@@ -76,11 +78,11 @@ export async function editFolderUploads(folder: Folder, allowUploads: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
mutateFolder(folder.id);
|
||||
mutateFolder();
|
||||
}
|
||||
|
||||
export async function mutateFolder(folderId?: string) {
|
||||
if (!folderId) return mutate(`/api/user/folders/${folderId}`);
|
||||
if (folderId) return mutate(`/api/user/folders/${folderId}`);
|
||||
|
||||
return mutate((key) => typeof key === 'string' && key.startsWith('/api/user/folders'));
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Response } from '@/lib/api/response';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { FolderBreadcrumb } from '@/lib/folderHierarchy';
|
||||
import { SEPARATOR, useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useViewStore } from '@/lib/store/view';
|
||||
import { SEPARATOR, useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import { useViewStore } from '@/lib/client/store/view';
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
@@ -236,7 +236,7 @@ export default function DashboardFolders() {
|
||||
{filesOpen ? '▼' : '▶'} {currentFolder.name}'s files{' '}
|
||||
{currentFolder._count ? `(${currentFolder._count.files})` : ''}
|
||||
</Text>
|
||||
<Collapse in={filesOpen}>
|
||||
<Collapse expanded={filesOpen}>
|
||||
{view === 'grid' ? (
|
||||
<Paper withBorder p='sm'>
|
||||
<FilesGridView folderId={currentFolderId} />
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Response } from '@/lib/api/response';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
|
||||
import { useFolders } from '@/lib/hooks/useFolders';
|
||||
import { useFolders } from '@/lib/client/hooks/useFolders';
|
||||
import { Button, Combobox, InputBase, Modal, Radio, Stack, Text, useCombobox } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconTrashFilled } from '@tabler/icons-react';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Response } from '@/lib/api/response';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { buildFolderHierarchy, getDescendantIds } from '@/lib/folderHierarchy';
|
||||
import { useFolders } from '@/lib/hooks/useFolders';
|
||||
import { useFolders } from '@/lib/client/hooks/useFolders';
|
||||
import { Button, Combobox, InputBase, Modal, Stack, Text, useCombobox } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconFolderSymlink } from '@tabler/icons-react';
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import RelativeDate from '@/components/RelativeDate';
|
||||
import { Invite } from '@/lib/db/models/invite';
|
||||
import { useSettingsStore } from '@/lib/client/store/settings';
|
||||
import { ActionIcon, Anchor, Card, Group, Menu, Stack, Text } from '@mantine/core';
|
||||
import { IconCopy, IconDots, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { copyInviteUrl, deleteInvite } from './actions';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import { IconCopy, IconDots, IconQrcode, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { copyInviteUrl, deleteInvite } from './actions';
|
||||
|
||||
export default function InviteCard({ invite }: { invite: Invite }) {
|
||||
export default function InviteCard({
|
||||
invite,
|
||||
setQrOpen,
|
||||
}: {
|
||||
invite: Invite;
|
||||
setQrOpen: (invite: Invite) => void;
|
||||
}) {
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
|
||||
@@ -36,6 +42,9 @@ export default function InviteCard({ invite }: { invite: Invite }) {
|
||||
>
|
||||
Copy URL
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconQrcode size='1rem' />} onClick={() => setQrOpen(invite)}>
|
||||
Show QR Code
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconTrashFilled size='1rem' />}
|
||||
color='red'
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Invite } from '@/lib/db/models/invite';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { conditionalWarning } from '@/lib/warningModal';
|
||||
import { conditionalWarning } from '@/lib/client/warningModal';
|
||||
import { getDomain } from '@/lib/client/webDomain';
|
||||
import { Anchor } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
@@ -18,13 +19,14 @@ export async function deleteInvite(warnDeletion: boolean, invite: Invite) {
|
||||
}
|
||||
|
||||
export function copyInviteUrl(invite: Invite, clipboard: ReturnType<typeof useClipboard>) {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}/invite/${invite.code}`);
|
||||
const url = getDomain(`/invite/${invite.code}`);
|
||||
clipboard.copy(url);
|
||||
|
||||
notifications.show({
|
||||
title: 'Copied link',
|
||||
message: (
|
||||
<Anchor component={Link} to={`/invite/${invite.code}`}>
|
||||
{`${window.location.protocol}//${window.location.host}/invite/${invite.code}`}
|
||||
{url}
|
||||
</Anchor>
|
||||
),
|
||||
color: 'green',
|
||||
|
||||
@@ -2,7 +2,7 @@ import GridTableSwitcher from '@/components/GridTableSwitcher';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Invite } from '@/lib/db/models/invite';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { useViewStore } from '@/lib/store/view';
|
||||
import { useViewStore } from '@/lib/client/store/view';
|
||||
import { Button, Group, Modal, NumberInput, Select, Stack, Title } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
@@ -4,13 +4,23 @@ import { Center, Group, Paper, SimpleGrid, Skeleton, Stack, Text, Title } from '
|
||||
import { IconLink } from '@tabler/icons-react';
|
||||
import useSWR from 'swr';
|
||||
import InviteCard from '../InviteCard';
|
||||
import { useState } from 'react';
|
||||
import QRCodeModal from '@/components/QRCodeModal';
|
||||
|
||||
export default function InviteGridView() {
|
||||
const { data: folders, isLoading } =
|
||||
useSWR<Extract<Response['/api/auth/invites'], Invite[]>>('/api/auth/invites');
|
||||
|
||||
const [qrOpen, setQrOpen] = useState<Invite | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<QRCodeModal
|
||||
opened={!!qrOpen}
|
||||
onClose={() => setQrOpen(null)}
|
||||
url={qrOpen ? `/invite/${qrOpen.code}` : ''}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<SimpleGrid
|
||||
my='sm'
|
||||
@@ -38,7 +48,7 @@ export default function InviteGridView() {
|
||||
pos='relative'
|
||||
>
|
||||
{folders?.map((invite) => (
|
||||
<InviteCard key={invite.id} invite={invite} />
|
||||
<InviteCard setQrOpen={setQrOpen} key={invite.id} invite={invite} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import RelativeDate from '@/components/RelativeDate';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Invite } from '@/lib/db/models/invite';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import { useSettingsStore } from '@/lib/client/store/settings';
|
||||
import { ActionIcon, Anchor, Box, Group, Tooltip } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { IconCopy, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { IconCopy, IconQrcode, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { copyInviteUrl, deleteInvite } from '../actions';
|
||||
import QRCodeModal from '@/components/QRCodeModal';
|
||||
|
||||
export default function InviteTableView() {
|
||||
const clipboard = useClipboard();
|
||||
@@ -36,8 +37,16 @@ export default function InviteTableView() {
|
||||
});
|
||||
}, [data, sortStatus]);
|
||||
|
||||
const [qrOpen, setQrOpen] = useState<Invite | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<QRCodeModal
|
||||
opened={!!qrOpen}
|
||||
onClose={() => setQrOpen(null)}
|
||||
url={qrOpen ? `/invite/${qrOpen.code}` : ''}
|
||||
/>
|
||||
|
||||
<Box my='sm'>
|
||||
<DataTable
|
||||
borderRadius='sm'
|
||||
@@ -101,6 +110,16 @@ export default function InviteTableView() {
|
||||
<IconCopy size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label='Show QR code'>
|
||||
<ActionIcon
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setQrOpen(invite);
|
||||
}}
|
||||
>
|
||||
<IconQrcode size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label='Delete invite'>
|
||||
<ActionIcon
|
||||
color='red'
|
||||
|
||||
@@ -5,14 +5,7 @@ const ICON_SIZE = '1.75rem';
|
||||
|
||||
export default function ActionButton({ onClick, Icon }: { onClick: () => void; Icon?: React.FC<any> }) {
|
||||
return (
|
||||
<ActionIcon
|
||||
onClick={onClick}
|
||||
variant='filled'
|
||||
color='blue'
|
||||
radius='md'
|
||||
size='xl'
|
||||
className='zip-click-action-button'
|
||||
>
|
||||
<ActionIcon onClick={onClick} variant='filled' radius='md' size='xl' className='zip-click-action-button'>
|
||||
{Icon ? <Icon size={ICON_SIZE} /> : <IconPlayerPlayFilled size={ICON_SIZE} />}
|
||||
</ActionIcon>
|
||||
);
|
||||
|
||||
+2
-2
@@ -139,7 +139,7 @@ export default function Export3Details({ export3 }: { export3: Export3 }) {
|
||||
{envOpened ? 'Hide' : 'Show'} OS Details
|
||||
</Button>
|
||||
|
||||
<Collapse in={osOpened}>
|
||||
<Collapse expanded={osOpened}>
|
||||
<HighlightCode language='json' code={JSON.stringify(export3.request.os, null, 2)} />
|
||||
</Collapse>
|
||||
|
||||
@@ -147,7 +147,7 @@ export default function Export3Details({ export3 }: { export3: Export3 }) {
|
||||
{envOpened ? 'Hide' : 'Show'} Environment
|
||||
</Button>
|
||||
|
||||
<Collapse in={envOpened}>
|
||||
<Collapse expanded={envOpened}>
|
||||
<Paper withBorder>
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
|
||||
+2
-2
@@ -195,7 +195,7 @@ export default function Export3Details({ export4 }: { export4: Export4 }) {
|
||||
{envOpened ? 'Hide' : 'Show'} OS Details
|
||||
</Button>
|
||||
|
||||
<Collapse in={osOpened}>
|
||||
<Collapse expanded={osOpened}>
|
||||
<Paper withBorder>
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
@@ -217,7 +217,7 @@ export default function Export3Details({ export4 }: { export4: Export4 }) {
|
||||
{envOpened ? 'Hide' : 'Show'} Environment
|
||||
</Button>
|
||||
|
||||
<Collapse in={envOpened}>
|
||||
<Collapse expanded={envOpened}>
|
||||
<Paper withBorder>
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ export default function Export4ImportSettings({
|
||||
{showSettings ? 'Hide' : 'Show'} Settings to be Imported
|
||||
</Button>
|
||||
|
||||
<Collapse in={showSettings}>
|
||||
<Collapse expanded={showSettings}>
|
||||
<Paper withBorder>
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { Export4 } from '@/lib/import/version4/validateExport';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import { useUserStore } from '@/lib/client/store/user';
|
||||
import { Box, Checkbox, Group, Text } from '@mantine/core';
|
||||
|
||||
export function detectSameInstance(export4?: Export4 | null, currentUserId?: string) {
|
||||
|
||||
+6
-6
@@ -1,17 +1,17 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { useUserStore } from '@/lib/client/store/user';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { Export4, validateExport } from '@/lib/import/version4/validateExport';
|
||||
import { Button, FileButton, Modal, Pill, Text } from '@mantine/core';
|
||||
import { modals } from '@mantine/modals';
|
||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||
import { IconDatabaseImport, IconDatabaseOff, IconUpload, IconX } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { mutate } from 'swr';
|
||||
import Export4Details from './Export4Details';
|
||||
import Export4ImportSettings from './Export4ImportSettings';
|
||||
import Export4WarningSameInstance, { detectSameInstance } from './Export4WarningSameInstance';
|
||||
import Export4UserChoose from './Export4UserChoose';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import { modals } from '@mantine/modals';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { mutate } from 'swr';
|
||||
import Export4WarningSameInstance, { detectSameInstance } from './Export4WarningSameInstance';
|
||||
|
||||
export default function ImportV4Button() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -1,8 +1,42 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Alert, Anchor, Collapse, Group, SimpleGrid, Skeleton, Stack, Title } from '@mantine/core';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Collapse,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {
|
||||
IconAdjustmentsHorizontalFilled,
|
||||
IconAppWindowFilled,
|
||||
IconArrowBack,
|
||||
IconAuth2fa,
|
||||
IconBrandDiscordFilled,
|
||||
IconClickFilled,
|
||||
IconClockPause,
|
||||
IconDatabase,
|
||||
IconExclamationMark,
|
||||
IconFiles,
|
||||
IconHttpPost,
|
||||
IconKeyFilled,
|
||||
IconLayoutGrid,
|
||||
IconLink,
|
||||
IconSubtask,
|
||||
IconTagsFilled,
|
||||
IconWorldPlus,
|
||||
} from '@tabler/icons-react';
|
||||
import { lazy, Suspense, useCallback } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
import { lazy, Suspense, useMemo } from 'react';
|
||||
|
||||
const Core = lazy(() => import('./parts/Core'));
|
||||
const Chunks = lazy(() => import('./parts/Chunks'));
|
||||
@@ -20,112 +54,309 @@ const Tasks = lazy(() => import('./parts/Tasks'));
|
||||
const Urls = lazy(() => import('./parts/Urls'));
|
||||
const Website = lazy(() => import('./parts/Website'));
|
||||
|
||||
function SettingsSkeleton() {
|
||||
return Array(17)
|
||||
.fill(null)
|
||||
.map((_, index) => <Skeleton key={index} height={280} animate />);
|
||||
}
|
||||
const InvalidSettingsSection = () => <Text>Invalid settings section</Text>;
|
||||
|
||||
const SETTINGS_COMPONENTS = {
|
||||
core: {
|
||||
component: Core,
|
||||
name: 'Core',
|
||||
key: 'core',
|
||||
desc: 'General server settings',
|
||||
Icon: IconDatabase,
|
||||
},
|
||||
chunks: {
|
||||
component: Chunks,
|
||||
name: 'Chunks',
|
||||
key: 'chunks',
|
||||
desc: 'Partial uploading',
|
||||
Icon: IconLayoutGrid,
|
||||
},
|
||||
discord: {
|
||||
component: Discord,
|
||||
name: 'Discord',
|
||||
key: 'discord',
|
||||
desc: 'Discord webhook integration',
|
||||
Icon: IconBrandDiscordFilled,
|
||||
},
|
||||
domains: {
|
||||
component: Domains,
|
||||
name: 'Domains',
|
||||
key: 'domains',
|
||||
desc: 'Add custom domains',
|
||||
Icon: IconWorldPlus,
|
||||
},
|
||||
features: {
|
||||
component: Features,
|
||||
name: 'Features',
|
||||
key: 'features',
|
||||
desc: 'Configure various features',
|
||||
Icon: IconAdjustmentsHorizontalFilled,
|
||||
},
|
||||
files: {
|
||||
component: Files,
|
||||
name: 'Files',
|
||||
key: 'files',
|
||||
desc: 'File uploading settings',
|
||||
Icon: IconFiles,
|
||||
},
|
||||
httpWebhook: {
|
||||
component: HttpWebhook,
|
||||
name: 'HTTP Webhook',
|
||||
key: 'httpWebhook',
|
||||
desc: 'Send POST requests to a URL on certain events',
|
||||
Icon: IconHttpPost,
|
||||
},
|
||||
invites: {
|
||||
component: Invites,
|
||||
name: 'Invites',
|
||||
key: 'invites',
|
||||
desc: 'Invite settings',
|
||||
Icon: IconTagsFilled,
|
||||
},
|
||||
mfa: {
|
||||
component: Mfa,
|
||||
name: 'Multi-Factor Authentication',
|
||||
key: 'mfa',
|
||||
desc: 'Enable or disable passkeys and TOTP authentication',
|
||||
Icon: IconAuth2fa,
|
||||
},
|
||||
oauth: {
|
||||
component: Oauth,
|
||||
name: 'OAuth',
|
||||
key: 'oauth',
|
||||
desc: 'Configure OAuth providers for authentication',
|
||||
Icon: IconKeyFilled,
|
||||
},
|
||||
pwa: {
|
||||
component: PWA,
|
||||
name: 'PWA',
|
||||
key: 'pwa',
|
||||
desc: 'Progressive Web App settings',
|
||||
Icon: IconAppWindowFilled,
|
||||
},
|
||||
ratelimit: {
|
||||
component: Ratelimit,
|
||||
name: 'Rate Limit',
|
||||
key: 'ratelimit',
|
||||
desc: 'Configure API rate limits',
|
||||
Icon: IconClockPause,
|
||||
},
|
||||
tasks: {
|
||||
component: Tasks,
|
||||
name: 'Tasks',
|
||||
key: 'tasks',
|
||||
desc: 'Background task intervals',
|
||||
Icon: IconSubtask,
|
||||
},
|
||||
urls: {
|
||||
component: Urls,
|
||||
name: 'URL Shortening',
|
||||
key: 'urls',
|
||||
desc: 'Configure URL shortening settings',
|
||||
Icon: IconLink,
|
||||
},
|
||||
website: {
|
||||
component: Website,
|
||||
name: 'Website',
|
||||
key: 'website',
|
||||
desc: 'Website related settings like title and description',
|
||||
Icon: IconClickFilled,
|
||||
},
|
||||
|
||||
// placeholder
|
||||
settings: {
|
||||
component: null,
|
||||
name: 'Server Settings',
|
||||
key: '',
|
||||
desc: '',
|
||||
Icon: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const SETTINGS_EXTERNAL_LINKS = Object.values(SETTINGS_COMPONENTS)
|
||||
.filter((setting) => setting.component !== null)
|
||||
.map((setting) => ({
|
||||
name: setting.name,
|
||||
url: `/dashboard/admin/settings/${setting.key}`,
|
||||
icon: setting.Icon ? <setting.Icon size='1rem' /> : <IconAdjustmentsHorizontalFilled size='1rem' />,
|
||||
}));
|
||||
|
||||
const SETTINGS_PART_KEYS = Object.keys(SETTINGS_COMPONENTS)
|
||||
.filter((key) => key !== 'settings')
|
||||
.sort((a, b) => b.length - a.length);
|
||||
|
||||
export default function DashboardServerSettings() {
|
||||
const { data, isLoading, error } = useSWR<Response['/api/server/settings']>('/api/server/settings');
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data, isLoading } = useSWR<Response['/api/server/settings']>('/api/server/settings');
|
||||
const [opened, { toggle }] = useDisclosure(false);
|
||||
|
||||
const scrollToSetting = useMemo(() => {
|
||||
return (setting: string) => {
|
||||
const input = document.querySelector<HTMLInputElement>(`[data-path="${setting}"]`);
|
||||
if (input) {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
observer.disconnect();
|
||||
const parent = input.parentElement?.parentElement;
|
||||
if (parent) {
|
||||
parent.style.transition = 'transform 0.35s';
|
||||
parent.style.transform = 'scale(1.2)';
|
||||
setTimeout(() => {
|
||||
parent.style.transform = 'scale(1)';
|
||||
}, 350);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 1.0 },
|
||||
);
|
||||
observer.observe(input);
|
||||
const toSettingSection = useCallback((settingKey: string) => {
|
||||
const normalizedSetting = settingKey.toLowerCase();
|
||||
const matched = SETTINGS_PART_KEYS.find((key) => normalizedSetting.startsWith(key.toLowerCase()));
|
||||
|
||||
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
input.focus();
|
||||
}
|
||||
};
|
||||
return matched ?? 'settings';
|
||||
}, []);
|
||||
|
||||
const onTamperedClick = (e: React.MouseEvent<HTMLAnchorElement>, setting: string) => {
|
||||
e.preventDefault();
|
||||
const scrollToSetting = useCallback((setting: string) => {
|
||||
const input = document.querySelector<HTMLElement>(`[data-path="${setting}"]`);
|
||||
const parent = input?.parentElement?.parentElement;
|
||||
if (!input || !parent) return false;
|
||||
|
||||
scrollToSetting(setting);
|
||||
};
|
||||
parent.style.transition = 'all 0.4s ease';
|
||||
parent.style.borderRadius = 'var(--mantine-radius-xs)';
|
||||
parent.style.outline = '2px solid var(--mantine-primary-color-filled)';
|
||||
parent.style.outlineOffset = 'var(--mantine-spacing-xs)';
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.length === 0) return;
|
||||
if (!entries[0].isIntersecting) return;
|
||||
|
||||
observer.disconnect();
|
||||
setTimeout(() => {
|
||||
parent.style.outline = '0 solid transparent';
|
||||
parent.style.outlineOffset = '0';
|
||||
parent.style.borderRadius = '0';
|
||||
}, 2000);
|
||||
},
|
||||
{ threshold: 1.0 },
|
||||
);
|
||||
observer.observe(input);
|
||||
|
||||
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
input.focus();
|
||||
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const scrollToSettingWithRetry = useCallback(
|
||||
(setting: string, attemptsLeft = 18) => {
|
||||
const tryScroll = (remainingAttempts: number) => {
|
||||
if (scrollToSetting(setting)) return;
|
||||
if (remainingAttempts <= 0) return;
|
||||
|
||||
window.setTimeout(() => tryScroll(remainingAttempts - 1), 80);
|
||||
};
|
||||
|
||||
tryScroll(attemptsLeft);
|
||||
},
|
||||
[scrollToSetting],
|
||||
);
|
||||
|
||||
const onTamperedClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLAnchorElement>, setting: string) => {
|
||||
e.preventDefault();
|
||||
|
||||
const section = toSettingSection(setting);
|
||||
const url = `/dashboard/admin/settings/${section}`;
|
||||
|
||||
if (location.pathname === url) return scrollToSettingWithRetry(setting);
|
||||
|
||||
navigate(url);
|
||||
setTimeout(() => {
|
||||
scrollToSettingWithRetry(setting);
|
||||
}, 0);
|
||||
},
|
||||
[location.pathname, navigate, scrollToSettingWithRetry, toSettingSection],
|
||||
);
|
||||
|
||||
const pathPart = location.pathname.split('/')[4];
|
||||
let part = 'settings';
|
||||
if (pathPart && SETTINGS_COMPONENTS[pathPart as keyof typeof SETTINGS_COMPONENTS]) {
|
||||
part = pathPart;
|
||||
}
|
||||
|
||||
const setting = SETTINGS_COMPONENTS[part as keyof typeof SETTINGS_COMPONENTS];
|
||||
const SettingsComponent = setting.component ?? InvalidSettingsSection;
|
||||
|
||||
useTitle(setting.name);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group gap='sm'>
|
||||
<Title order={1}>Server Settings</Title>
|
||||
<Group gap='sm' align='center' wrap='wrap'>
|
||||
{part !== 'settings' && (
|
||||
<ActionIcon component={Link} to='/dashboard/admin/settings' variant='outline'>
|
||||
<IconArrowBack size='1rem' />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<Title order={1}>{setting.name}</Title>
|
||||
|
||||
{(data?.tampered?.length ?? 0) > 0 && (
|
||||
<Button
|
||||
variant='outline'
|
||||
color={opened ? 'red' : 'blue'}
|
||||
size='xs'
|
||||
onClick={toggle}
|
||||
leftSection={<IconExclamationMark size='1rem' />}
|
||||
>
|
||||
{opened ? 'Hide' : 'Show'} Tampered ({data!.tampered.length})
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{(data?.tampered?.length ?? 0) > 0 && (
|
||||
<Alert color='red' title='Environment Variable Settings' mt='md'>
|
||||
<strong>{data!.tampered.length}</strong> setting{data!.tampered.length > 1 ? 's' : ''} have been set
|
||||
via environment variables, therefore any changes made to them on this page will not take effect
|
||||
unless the environment variable corresponding to the setting is removed. If you prefer using
|
||||
environment variables, you can ignore this message. Click{' '}
|
||||
<Anchor onClick={toggle} size='sm'>
|
||||
here
|
||||
</Anchor>{' '}
|
||||
to {opened ? 'close' : 'view'} the list of overridden settings.
|
||||
<Collapse in={opened} transitionDuration={200}>
|
||||
<ul>
|
||||
<Collapse expanded={opened} transitionDuration={180}>
|
||||
<Alert
|
||||
color='red'
|
||||
title='Environment Variable Settings'
|
||||
mb='md'
|
||||
icon={<IconExclamationMark size='1rem' />}
|
||||
>
|
||||
<Text size='sm' mb='xs'>
|
||||
These settings are controlled by environment variables:
|
||||
</Text>
|
||||
<Group gap='xs'>
|
||||
{data!.tampered.map((setting) => (
|
||||
<li key={setting}>
|
||||
<Anchor onClick={(e) => onTamperedClick(e, setting)}>{setting}</Anchor>
|
||||
</li>
|
||||
<Anchor key={setting} onClick={(e) => onTamperedClick(e, setting)} size='sm'>
|
||||
{setting}
|
||||
</Anchor>
|
||||
))}
|
||||
</ul>
|
||||
</Collapse>
|
||||
</Alert>
|
||||
</Group>
|
||||
</Alert>
|
||||
</Collapse>
|
||||
)}
|
||||
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
{error ? (
|
||||
<div>Error loading server settings</div>
|
||||
) : (
|
||||
<Suspense fallback={<SettingsSkeleton />}>
|
||||
<Core swr={{ data, isLoading }} />
|
||||
<Chunks swr={{ data, isLoading }} />
|
||||
<Tasks swr={{ data, isLoading }} />
|
||||
<Mfa swr={{ data, isLoading }} />
|
||||
|
||||
<Features swr={{ data, isLoading }} />
|
||||
<Files swr={{ data, isLoading }} />
|
||||
<Stack>
|
||||
<Urls swr={{ data, isLoading }} />
|
||||
<Invites swr={{ data, isLoading }} />
|
||||
</Stack>
|
||||
|
||||
<Ratelimit swr={{ data, isLoading }} />
|
||||
<Stack>
|
||||
<Website swr={{ data, isLoading }} />
|
||||
<PWA swr={{ data, isLoading }} />
|
||||
</Stack>
|
||||
<Oauth swr={{ data, isLoading }} />
|
||||
|
||||
<HttpWebhook swr={{ data, isLoading }} />
|
||||
|
||||
<Domains swr={{ data, isLoading }} />
|
||||
{part !== 'settings' ? (
|
||||
<Box my='sm' p='xs' pos='relative' bdrs='lg'>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Box h={400} pos='relative'>
|
||||
<LoadingOverlay visible bdrs='md' />
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<SettingsComponent swr={{ data, isLoading }} />
|
||||
</Suspense>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
) : (
|
||||
<Stack mt='md' gap='md'>
|
||||
{Object.entries(SETTINGS_COMPONENTS)
|
||||
.filter(([key]) => key !== 'settings')
|
||||
.map(([k, { key, Icon, name, desc }]) => (
|
||||
<Anchor
|
||||
key={k}
|
||||
component={Link}
|
||||
to={`/dashboard/admin/settings/${key}`}
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
<Paper withBorder p='sm'>
|
||||
<Group gap='md'>
|
||||
<ActionIcon variant='filled' radius='md' size='xl'>
|
||||
{Icon ? <Icon size='1.75rem' /> : <IconAdjustmentsHorizontalFilled size='1.75rem' />}
|
||||
</ActionIcon>
|
||||
|
||||
<Stack mt='md' gap='md'>
|
||||
{error ? null : <Discord swr={{ data, isLoading }} />}
|
||||
</Stack>
|
||||
<div>
|
||||
<Title order={4}>{name}</Title>
|
||||
<Text c='dimmed'>{desc}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Anchor>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Button, LoadingOverlay, Paper, SimpleGrid, Switch, TextInput, Title } from '@mantine/core';
|
||||
import { Button, LoadingOverlay, Stack, Switch, TextInput } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useEffect } from 'react';
|
||||
@@ -40,20 +40,17 @@ export default function Chunks({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>Chunks</Title>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} bdrs='md' />
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Switch
|
||||
mt='md'
|
||||
label='Enable Chunks'
|
||||
description='Enable chunked uploads.'
|
||||
{...form.getInputProps('chunksEnabled', { type: 'checkbox' })}
|
||||
/>
|
||||
<Stack gap='lg'>
|
||||
<Switch
|
||||
label='Enable Chunks'
|
||||
description='Enable chunked uploads.'
|
||||
{...form.getInputProps('chunksEnabled', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<TextInput
|
||||
label='Max Chunk Size'
|
||||
description='Maximum size of an upload before it is split into chunks.'
|
||||
@@ -69,12 +66,12 @@ export default function Chunks({
|
||||
disabled={!form.values.chunksEnabled}
|
||||
{...form.getInputProps('chunksSize')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Button, LoadingOverlay, Paper, SimpleGrid, Switch, TextInput, Title } from '@mantine/core';
|
||||
import { Button, LoadingOverlay, Stack, Switch, TextInput } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useEffect } from 'react';
|
||||
@@ -52,13 +52,11 @@ export default function Core({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>Core</Title>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<Stack gap='lg'>
|
||||
<Switch
|
||||
mt='md'
|
||||
label='Return HTTPS URLs'
|
||||
@@ -85,12 +83,12 @@ export default function Core({
|
||||
placeholder='/tmp/zipline'
|
||||
{...form.getInputProps('coreTempDirectory')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -170,14 +170,11 @@ export default function Discord({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>Discord Webhook</Title>
|
||||
|
||||
<form onSubmit={formMain.onSubmit(onSubmitMain)}>
|
||||
<TextInput
|
||||
mt='md'
|
||||
label='Webhook URL'
|
||||
description='The Discord webhook URL to send notifications to'
|
||||
placeholder='https://discord.com/api/webhooks/...'
|
||||
@@ -248,7 +245,7 @@ export default function Discord({
|
||||
{...formOnUpload.getInputProps('discordOnUploadEmbed', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<Collapse in={formOnUpload.values.discordOnUploadEmbed}>
|
||||
<Collapse expanded={formOnUpload.values.discordOnUploadEmbed}>
|
||||
<Paper withBorder p='sm' mt='md'>
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<TextInput
|
||||
@@ -351,7 +348,7 @@ export default function Discord({
|
||||
{...formOnShorten.getInputProps('discordOnShortenEmbed', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<Collapse in={formOnShorten.values.discordOnShortenEmbed}>
|
||||
<Collapse expanded={formOnShorten.values.discordOnShortenEmbed}>
|
||||
<Paper withBorder p='sm' mt='md'>
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<TextInput
|
||||
@@ -399,6 +396,6 @@ export default function Discord({
|
||||
</form>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { ActionIcon, Group, LoadingOverlay, Paper, Table, Text, TextInput, Title } from '@mantine/core';
|
||||
import { ActionIcon, LoadingOverlay, Paper, Table, Text, TextInput } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconPlus, IconTrash } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
@@ -39,7 +39,7 @@ export default function Domains({
|
||||
}
|
||||
}
|
||||
|
||||
const addDomain = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
const addDomain = async (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const domain = form.values.domains.trim();
|
||||
@@ -55,23 +55,20 @@ export default function Domains({
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading || submitting} />
|
||||
|
||||
<Title order={2}>Domains</Title>
|
||||
|
||||
<form onSubmit={addDomain}>
|
||||
<Group mt='md' align='flex-end'>
|
||||
<TextInput
|
||||
description='Enter a domain name'
|
||||
placeholder='example.com'
|
||||
flex={1}
|
||||
{...form.getInputProps('domains')}
|
||||
/>
|
||||
<ActionIcon type='submit' color='blue' size='lg' variant='filled' disabled={submitting}>
|
||||
<IconPlus size='1.25rem' />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<TextInput
|
||||
description='Enter a domain name'
|
||||
placeholder='example.com'
|
||||
rightSection={
|
||||
<ActionIcon type='submit' variant='transparent' disabled={submitting}>
|
||||
<IconPlus size='1.25rem' />
|
||||
</ActionIcon>
|
||||
}
|
||||
{...form.getInputProps('domains')}
|
||||
/>
|
||||
</form>
|
||||
|
||||
{domains.length > 0 ? (
|
||||
@@ -106,6 +103,6 @@ export default function Domains({
|
||||
No domains added yet.
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@ import { Response } from '@/lib/api/response';
|
||||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
Divider,
|
||||
LoadingOverlay,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Switch,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
@@ -35,6 +35,7 @@ export default function Features({
|
||||
featuresThumbnailsEnabled: true,
|
||||
featuresThumbnailsNumberThreads: 4,
|
||||
featuresThumbnailsFormat: 'jpg',
|
||||
featuresThumbnailsInstantaneous: false,
|
||||
featuresMetricsEnabled: true,
|
||||
featuresMetricsAdminOnly: false,
|
||||
featuresMetricsShowUserSpecific: true,
|
||||
@@ -61,6 +62,7 @@ export default function Features({
|
||||
featuresThumbnailsEnabled: data.settings.featuresThumbnailsEnabled ?? true,
|
||||
featuresThumbnailsNumberThreads: data.settings.featuresThumbnailsNumberThreads ?? 4,
|
||||
featuresThumbnailsFormat: data.settings.featuresThumbnailsFormat ?? 'jpg',
|
||||
featuresThumbnailsInstantaneous: data.settings.featuresThumbnailsInstantaneous ?? false,
|
||||
featuresMetricsEnabled: data.settings.featuresMetricsEnabled ?? true,
|
||||
featuresMetricsAdminOnly: data.settings.featuresMetricsAdminOnly ?? false,
|
||||
featuresMetricsShowUserSpecific: data.settings.featuresMetricsShowUserSpecific ?? true,
|
||||
@@ -70,13 +72,11 @@ export default function Features({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>Features</Title>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<Stack gap='lg'>
|
||||
<Switch
|
||||
label='Image Compression'
|
||||
description='Allows the ability for users to compress images.'
|
||||
@@ -130,12 +130,21 @@ export default function Features({
|
||||
description='Shows metrics specific to each user, for all users.'
|
||||
{...form.getInputProps('featuresMetricsShowUserSpecific', { type: 'checkbox' })}
|
||||
/>
|
||||
<div />
|
||||
<Switch
|
||||
label='Enable Thumbnails'
|
||||
description='Enables thumbnail generation for images. Requires a server restart.'
|
||||
{...form.getInputProps('featuresThumbnailsEnabled', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<Divider label='Thumbnails' />
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<Switch
|
||||
label='Enable Thumbnails'
|
||||
description='Enables thumbnail generation for images. Requires a server restart.'
|
||||
{...form.getInputProps('featuresThumbnailsEnabled', { type: 'checkbox' })}
|
||||
/>
|
||||
<Switch
|
||||
label='Instantaneous Thumbnails'
|
||||
description='Generates thumbnails immediately after a file is uploaded, instead of waiting for the task to run.'
|
||||
{...form.getInputProps('featuresThumbnailsInstantaneous', { type: 'checkbox' })}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<NumberInput
|
||||
label='Thumbnails Number Threads'
|
||||
@@ -157,19 +166,20 @@ export default function Features({
|
||||
{...form.getInputProps('featuresThumbnailsFormat')}
|
||||
/>
|
||||
|
||||
<div />
|
||||
<Divider label='Version Checking' />
|
||||
|
||||
<Switch
|
||||
label='Version Checking'
|
||||
description='Enable version checking for the server. This will check for updates and display the status on the sidebar to all users.'
|
||||
{...form.getInputProps('featuresVersionChecking', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label='Version API URL'
|
||||
description={
|
||||
<>
|
||||
The URL of the version checking server. The default is{' '}
|
||||
<Anchor size='xs' href='zipline-version.diced.sh' target='_blank'>
|
||||
<Anchor size='xs' href='https://zipline-version.diced.sh' target='_blank'>
|
||||
https://zipline-version.diced.sh
|
||||
</Anchor>
|
||||
. Visit the{' '}
|
||||
@@ -182,12 +192,12 @@ export default function Features({
|
||||
placeholder='https://zipline-version.diced.sh/'
|
||||
{...form.getInputProps('featuresVersionAPI')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import {
|
||||
Button,
|
||||
LoadingOverlay,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Switch,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { Button, LoadingOverlay, NumberInput, Select, Stack, Switch, TextInput } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useEffect } from 'react';
|
||||
@@ -37,6 +27,7 @@ export default function Files({
|
||||
filesRandomWordsNumAdjectives: number;
|
||||
filesRandomWordsSeparator: string;
|
||||
filesDefaultCompressionFormat: string;
|
||||
filesMaxFilesPerUpload: number;
|
||||
}>({
|
||||
initialValues: {
|
||||
filesRoute: '/u',
|
||||
@@ -52,6 +43,7 @@ export default function Files({
|
||||
filesRandomWordsNumAdjectives: 3,
|
||||
filesRandomWordsSeparator: '-',
|
||||
filesDefaultCompressionFormat: 'jpg',
|
||||
filesMaxFilesPerUpload: 1000,
|
||||
},
|
||||
enhanceGetInputProps: (payload) => ({
|
||||
disabled: data?.tampered?.includes(payload.field) || false,
|
||||
@@ -110,17 +102,28 @@ export default function Files({
|
||||
filesRandomWordsNumAdjectives: data.settings.filesRandomWordsNumAdjectives ?? 3,
|
||||
filesRandomWordsSeparator: data.settings.filesRandomWordsSeparator ?? '-',
|
||||
filesDefaultCompressionFormat: data.settings.filesDefaultCompressionFormat ?? 'jpg',
|
||||
filesMaxFilesPerUpload: data.settings.filesMaxFilesPerUpload ?? 1000,
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>Files</Title>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<Stack gap='lg'>
|
||||
<Switch
|
||||
label='Assume Mimetypes'
|
||||
description='Assume the mimetype of a file for its extension.'
|
||||
{...form.getInputProps('filesAssumeMimetypes', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label='Remove GPS Metadata'
|
||||
description='Remove GPS metadata from files.'
|
||||
{...form.getInputProps('filesRemoveGpsMetadata', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label='Route'
|
||||
description='The route to use for file uploads. Requires a server restart.'
|
||||
@@ -136,18 +139,6 @@ export default function Files({
|
||||
{...form.getInputProps('filesLength')}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label='Assume Mimetypes'
|
||||
description='Assume the mimetype of a file for its extension.'
|
||||
{...form.getInputProps('filesAssumeMimetypes', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label='Remove GPS Metadata'
|
||||
description='Remove GPS metadata from files.'
|
||||
{...form.getInputProps('filesRemoveGpsMetadata', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label='Default Format'
|
||||
description='The default format to use for file names.'
|
||||
@@ -218,12 +209,19 @@ export default function Files({
|
||||
]}
|
||||
{...form.getInputProps('filesDefaultCompressionFormat')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<NumberInput
|
||||
label='Max Files Per Upload'
|
||||
description='The maximum number of files allowed per upload. Requires a server restart.'
|
||||
min={1}
|
||||
{...form.getInputProps('filesMaxFilesPerUpload')}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Button, LoadingOverlay, Paper, SimpleGrid, TextInput, Title } from '@mantine/core';
|
||||
import { Button, LoadingOverlay, Stack, TextInput } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useEffect } from 'react';
|
||||
@@ -47,13 +47,11 @@ export default function HttpWebhook({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>HTTP Webhooks</Title>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<Stack gap='lg'>
|
||||
<TextInput
|
||||
label='On Upload'
|
||||
description='The URL to send a POST request to when a file is uploaded.'
|
||||
@@ -67,12 +65,12 @@ export default function HttpWebhook({
|
||||
placeholder='https://example.com/shorten'
|
||||
{...form.getInputProps('httpWebhookOnShorten')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Button, LoadingOverlay, NumberInput, Paper, SimpleGrid, Switch, Title } from '@mantine/core';
|
||||
import { Button, LoadingOverlay, NumberInput, Stack, Switch } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useEffect } from 'react';
|
||||
@@ -38,13 +38,11 @@ export default function Invites({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative' h='100%'>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>Invites</Title>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<Stack gap='lg'>
|
||||
<Switch
|
||||
label='Enable Invites'
|
||||
description='Enable the use of invite links to register new users.'
|
||||
@@ -59,12 +57,12 @@ export default function Invites({
|
||||
max={64}
|
||||
{...form.getInputProps('invitesLength')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Button, LoadingOverlay, Paper, SimpleGrid, Switch, TextInput, Title } from '@mantine/core';
|
||||
import { Button, Divider, LoadingOverlay, Stack, Switch, TextInput } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useEffect } from 'react';
|
||||
@@ -41,13 +41,11 @@ export default function Mfa({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>Multi-Factor Authentication</Title>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<Stack gap='lg'>
|
||||
<Switch
|
||||
label='Passkeys'
|
||||
description='Enable the use of passwordless login with the use of WebAuthn passkeys like your phone, security keys, etc.'
|
||||
@@ -68,6 +66,8 @@ export default function Mfa({
|
||||
{...form.getInputProps('mfaPasskeysOrigin')}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Switch
|
||||
label='Enable TOTP'
|
||||
description='Enable Time-based One-Time Passwords with the use of an authenticator app.'
|
||||
@@ -79,12 +79,12 @@ export default function Mfa({
|
||||
placeholder='Zipline'
|
||||
{...form.getInputProps('mfaTotpIssuer')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
LoadingOverlay,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||
|
||||
export default function Oauth({
|
||||
@@ -124,21 +125,22 @@ export default function Oauth({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>OAuth</Title>
|
||||
|
||||
<Text size='sm' c='dimmed'>
|
||||
For OAuth to work, the "OAuth Registration" setting must be enabled in the Features section.
|
||||
If you have issues, try restarting Zipline after saving.
|
||||
<Text size='sm' c='dimmed' mb='md'>
|
||||
For OAuth to work, the "OAuth Registration" setting must be enabled in the{' '}
|
||||
<Anchor component={Link} to='/dashboard/admin/settings/features'>
|
||||
Features
|
||||
</Anchor>{' '}
|
||||
section. If you have issues, try restarting Zipline after saving.
|
||||
</Text>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<Stack gap='lg'>
|
||||
<Switch
|
||||
label='Bypass Local Login'
|
||||
description='Skips the local login page and redirects to the OAuth provider, this only works with one provider enabled.'
|
||||
description='Skips the local login page and redirects to the OAuth provider, this will only work with one provider enabled.'
|
||||
{...form.getInputProps('oauthBypassLocalLogin', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
@@ -147,35 +149,33 @@ export default function Oauth({
|
||||
description='Disables registration and only allows login with OAuth, existing users can link providers for example.'
|
||||
{...form.getInputProps('oauthLoginOnly', { type: 'checkbox' })}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Paper withBorder p='sm' my='sm'>
|
||||
<Anchor href='https://discord.com/developers/applications' target='_blank'>
|
||||
<Title order={4} mb='sm'>
|
||||
Discord
|
||||
</Title>
|
||||
</Anchor>
|
||||
<Paper withBorder p='sm'>
|
||||
<Anchor href='https://discord.com/developers/applications' target='_blank'>
|
||||
<Title order={4} mb='sm'>
|
||||
Discord
|
||||
</Title>
|
||||
</Anchor>
|
||||
|
||||
<TextInput label='Discord Client ID' {...form.getInputProps('oauthDiscordClientId')} />
|
||||
<TextInput label='Discord Client Secret' {...form.getInputProps('oauthDiscordClientSecret')} />
|
||||
<TextInput
|
||||
label='Discord Allowed IDs'
|
||||
description='A comma-separated list of Discord user IDs that are allowed to log in. Leave empty to disable allow list.'
|
||||
{...form.getInputProps('oauthDiscordAllowedIds')}
|
||||
/>
|
||||
<TextInput
|
||||
label='Discord Denied IDs'
|
||||
description='A comma-separated list of Discord user IDs that are denied from logging in. Leave empty to disable deny list.'
|
||||
{...form.getInputProps('oauthDiscordDeniedIds')}
|
||||
/>
|
||||
<TextInput
|
||||
label='Discord Redirect URL'
|
||||
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.'
|
||||
{...form.getInputProps('oauthDiscordRedirectUri')}
|
||||
/>
|
||||
</Paper>
|
||||
<TextInput label='Discord Client ID' {...form.getInputProps('oauthDiscordClientId')} />
|
||||
<TextInput label='Discord Client Secret' {...form.getInputProps('oauthDiscordClientSecret')} />
|
||||
<TextInput
|
||||
label='Discord Allowed IDs'
|
||||
description='A comma-separated list of Discord user IDs that are allowed to log in. Leave empty to disable allow list.'
|
||||
{...form.getInputProps('oauthDiscordAllowedIds')}
|
||||
/>
|
||||
<TextInput
|
||||
label='Discord Denied IDs'
|
||||
description='A comma-separated list of Discord user IDs that are denied from logging in. Leave empty to disable deny list.'
|
||||
{...form.getInputProps('oauthDiscordDeniedIds')}
|
||||
/>
|
||||
<TextInput
|
||||
label='Discord Redirect URL'
|
||||
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.'
|
||||
{...form.getInputProps('oauthDiscordRedirectUri')}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<Paper withBorder p='sm'>
|
||||
<Anchor href='https://console.developers.google.com/' target='_blank'>
|
||||
<Title order={4} mb='sm'>
|
||||
@@ -207,29 +207,29 @@ export default function Oauth({
|
||||
{...form.getInputProps('oauthGithubRedirectUri')}
|
||||
/>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
|
||||
<Paper withBorder p='sm' my='md'>
|
||||
<Title order={4}>OpenID Connect</Title>
|
||||
<Paper withBorder p='sm'>
|
||||
<Title order={4}>OpenID Connect</Title>
|
||||
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<TextInput label='OIDC Client ID' {...form.getInputProps('oauthOidcClientId')} />
|
||||
<TextInput label='OIDC Client Secret' {...form.getInputProps('oauthOidcClientSecret')} />
|
||||
<TextInput label='OIDC Authorize URL' {...form.getInputProps('oauthOidcAuthorizeUrl')} />
|
||||
<TextInput label='OIDC Token URL' {...form.getInputProps('oauthOidcTokenUrl')} />
|
||||
<TextInput label='OIDC Userinfo URL' {...form.getInputProps('oauthOidcUserinfoUrl')} />
|
||||
<TextInput
|
||||
label='OIDC Redirect URL'
|
||||
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.'
|
||||
{...form.getInputProps('oauthOidcRedirectUri')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<TextInput label='OIDC Client ID' {...form.getInputProps('oauthOidcClientId')} />
|
||||
<TextInput label='OIDC Client Secret' {...form.getInputProps('oauthOidcClientSecret')} />
|
||||
<TextInput label='OIDC Authorize URL' {...form.getInputProps('oauthOidcAuthorizeUrl')} />
|
||||
<TextInput label='OIDC Token URL' {...form.getInputProps('oauthOidcTokenUrl')} />
|
||||
<TextInput label='OIDC Userinfo URL' {...form.getInputProps('oauthOidcUserinfoUrl')} />
|
||||
<TextInput
|
||||
label='OIDC Redirect URL'
|
||||
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.'
|
||||
{...form.getInputProps('oauthOidcRedirectUri')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import {
|
||||
Button,
|
||||
ColorInput,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { Button, ColorInput, Group, LoadingOverlay, Stack, Switch, Text, TextInput } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconDeviceFloppy, IconRefresh } from '@tabler/icons-react';
|
||||
import { useEffect } from 'react';
|
||||
@@ -73,24 +62,21 @@ export default function PWA({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative' h='100%'>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>PWA</Title>
|
||||
|
||||
<Text size='sm' c='dimmed'>
|
||||
<Text size='sm' c='dimmed' mb='md'>
|
||||
Refresh the page after enabling PWA to see any changes.
|
||||
</Text>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Switch
|
||||
mt='md'
|
||||
label='PWA Enabled'
|
||||
description='Allow users to install the Zipline PWA on their devices.'
|
||||
{...form.getInputProps('pwaEnabled', { type: 'checkbox' })}
|
||||
/>
|
||||
<Stack gap='lg'>
|
||||
<Switch
|
||||
label='PWA Enabled'
|
||||
description='Allow users to install the Zipline PWA on their devices.'
|
||||
{...form.getInputProps('pwaEnabled', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<TextInput
|
||||
label='Title'
|
||||
description='The title for the PWA'
|
||||
@@ -125,8 +111,7 @@ export default function PWA({
|
||||
placeholder='#ffffff'
|
||||
{...form.getInputProps('pwaBackgroundColor')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
</Stack>
|
||||
<Group mt='md'>
|
||||
<Button type='submit' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
Save
|
||||
@@ -136,6 +121,6 @@ export default function PWA({
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import {
|
||||
Button,
|
||||
LoadingOverlay,
|
||||
NumberInput,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { Button, LoadingOverlay, NumberInput, Stack, Switch, Text, TextInput } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useEffect } from 'react';
|
||||
@@ -78,17 +68,15 @@ export default function Ratelimit({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>Ratelimit</Title>
|
||||
|
||||
<Text c='dimmed' size='sm'>
|
||||
<Text size='sm' c='dimmed' mb='md'>
|
||||
All options require a restart to take effect.
|
||||
</Text>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<Stack gap='lg'>
|
||||
<Switch
|
||||
label='Enable Ratelimit'
|
||||
description='Enable ratelimiting for the server.'
|
||||
@@ -123,12 +111,12 @@ export default function Ratelimit({
|
||||
placeholder='192.168.1.1, 127.0.0.1, 0.0.0.0'
|
||||
{...form.getInputProps('ratelimitAllowList')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Button, LoadingOverlay, Paper, SimpleGrid, Text, TextInput, Title } from '@mantine/core';
|
||||
import { Button, Code, LoadingOverlay, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useEffect } from 'react';
|
||||
@@ -43,17 +43,15 @@ export default function Tasks({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>Tasks</Title>
|
||||
|
||||
<Text c='dimmed' size='sm'>
|
||||
All options require a restart to take effect.
|
||||
<Text size='sm' c='dimmed' mb='md'>
|
||||
All options require a restart to take effect. Setting a value of <Code>0</Code> will disable the task.
|
||||
</Text>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<Stack gap='lg'>
|
||||
<TextInput
|
||||
label='Delete Files Interval'
|
||||
description='How often to check and delete expired files.'
|
||||
@@ -88,12 +86,12 @@ export default function Tasks({
|
||||
placeholder='1d'
|
||||
{...form.getInputProps('tasksCleanThumbnailsInterval')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Button, LoadingOverlay, NumberInput, Paper, SimpleGrid, TextInput, Title } from '@mantine/core';
|
||||
import { Button, LoadingOverlay, NumberInput, Stack, TextInput } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useEffect } from 'react';
|
||||
@@ -35,13 +35,11 @@ export default function Urls({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>URL Shortener</Title>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<Stack gap='lg'>
|
||||
<TextInput
|
||||
label='Route'
|
||||
description='The route to use for short URLs. Requires a server restart.'
|
||||
@@ -57,12 +55,12 @@ export default function Urls({
|
||||
max={64}
|
||||
{...form.getInputProps('urlsLength')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Button, Grid, JsonInput, Paper, Switch, TextInput, Title } from '@mantine/core';
|
||||
import { Button, JsonInput, LoadingOverlay, Stack, Switch, TextInput } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useEffect } from 'react';
|
||||
@@ -13,7 +13,7 @@ const defaultExternalLinks = [
|
||||
},
|
||||
{
|
||||
name: 'Documentation',
|
||||
url: 'https://zipline.diced.tech',
|
||||
url: 'https://zipline.diced.sh',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -98,110 +98,88 @@ export default function Website({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm'>
|
||||
<Title order={2}>Website</Title>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
{/* <SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'> */}
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label='Title'
|
||||
description='The title of the website in browser tabs and at the top.'
|
||||
placeholder='Zipline'
|
||||
{...form.getInputProps('websiteTitle')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Stack gap='lg'>
|
||||
<TextInput
|
||||
label='Title'
|
||||
description='The title of the website in browser tabs and at the top.'
|
||||
placeholder='Zipline'
|
||||
{...form.getInputProps('websiteTitle')}
|
||||
/>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label='Title Logo'
|
||||
description='The URL to use for the title logo. This is placed to the left of the title.'
|
||||
placeholder='https://example.com/logo.png'
|
||||
{...form.getInputProps('websiteTitleLogo')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<TextInput
|
||||
label='Title Logo'
|
||||
description='The URL to use for the title logo. This is placed to the left of the title.'
|
||||
placeholder='https://example.com/logo.png'
|
||||
{...form.getInputProps('websiteTitleLogo')}
|
||||
/>
|
||||
|
||||
<Grid.Col span={12}>
|
||||
<JsonInput
|
||||
label='External Links'
|
||||
description='The external links to show in the footer. This must be valid JSON.'
|
||||
formatOnBlur
|
||||
minRows={1}
|
||||
maxRows={7}
|
||||
autosize
|
||||
placeholder={JSON.stringify(defaultExternalLinks, null, 2)}
|
||||
{...form.getInputProps('websiteExternalLinks')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<JsonInput
|
||||
label='External Links'
|
||||
description='The external links to show in the footer. This must be valid JSON in the format of an array of objects with "name" and "url" properties. For example: [{"name": "GitHub", "url": "https://github.com/diced/zipline"}]'
|
||||
formatOnBlur
|
||||
minRows={1}
|
||||
maxRows={7}
|
||||
autosize
|
||||
placeholder={JSON.stringify(defaultExternalLinks, null, 2)}
|
||||
{...form.getInputProps('websiteExternalLinks')}
|
||||
/>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label='Login Background'
|
||||
description='The URL to use for the login background.'
|
||||
placeholder='https://example.com/background.png'
|
||||
{...form.getInputProps('websiteLoginBackground')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<TextInput
|
||||
label='Login Background'
|
||||
description='The URL to use for the login background.'
|
||||
placeholder='https://example.com/background.png'
|
||||
{...form.getInputProps('websiteLoginBackground')}
|
||||
/>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Switch
|
||||
label='Login Background Blur'
|
||||
description='Whether to blur the login background.'
|
||||
{...form.getInputProps('websiteLoginBackgroundBlur', { type: 'checkbox' })}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Switch
|
||||
label='Login Background Blur'
|
||||
description='Whether to blur the login background.'
|
||||
{...form.getInputProps('websiteLoginBackgroundBlur', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label='Default Avatar'
|
||||
description='The path to use for the default avatar. This must be a path to an image, not a URL.'
|
||||
placeholder='/zipline/avatar.png'
|
||||
{...form.getInputProps('websiteDefaultAvatar')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<TextInput
|
||||
label='Default Avatar'
|
||||
description='The path to use for the default avatar. This must be a path to an image, not a URL.'
|
||||
placeholder='/zipline/avatar.png'
|
||||
{...form.getInputProps('websiteDefaultAvatar')}
|
||||
/>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label='Terms of Service'
|
||||
description='Path to a Markdown (.md) file to use for the terms of service.'
|
||||
placeholder='/zipline/TOS.md'
|
||||
{...form.getInputProps('websiteTos')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<TextInput
|
||||
label='Terms of Service'
|
||||
description='Path to a Markdown (.md) file to use for the terms of service.'
|
||||
placeholder='/zipline/TOS.md'
|
||||
{...form.getInputProps('websiteTos')}
|
||||
/>
|
||||
|
||||
<Grid.Col span={12}>
|
||||
<TextInput
|
||||
label='Default Theme'
|
||||
description='The default theme to use for the website.'
|
||||
placeholder='system'
|
||||
{...form.getInputProps('websiteThemeDefault')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<TextInput
|
||||
label='Default Theme'
|
||||
description='The default theme to use for the website.'
|
||||
placeholder='system'
|
||||
{...form.getInputProps('websiteThemeDefault')}
|
||||
/>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label='Dark Theme'
|
||||
description='The dark theme to use for the website when the default theme is "system".'
|
||||
placeholder='builtin:dark_gray'
|
||||
{...form.getInputProps('websiteThemeDark')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label='Light Theme'
|
||||
description='The light theme to use for the website when the default theme is "system".'
|
||||
placeholder='builtin:light_gray'
|
||||
{...form.getInputProps('websiteThemeLight')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<TextInput
|
||||
label='Dark Theme'
|
||||
description='The dark theme to use for the website when the default theme is "system".'
|
||||
placeholder='builtin:dark_gray'
|
||||
{...form.getInputProps('websiteThemeDark')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label='Light Theme'
|
||||
description='The light theme to use for the website when the default theme is "system".'
|
||||
placeholder='builtin:light_gray'
|
||||
{...form.getInputProps('websiteThemeLight')}
|
||||
/>
|
||||
</Stack>
|
||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
@@ -44,7 +42,7 @@ export function settingsOnSubmit(navigate: NavigateFunction, form: ReturnType<ty
|
||||
mutate('/api/server/settings', data);
|
||||
mutate('/api/server/settings/web');
|
||||
mutate('/api/server/public');
|
||||
navigate('/dashboard/admin/settings', { replace: true });
|
||||
navigate(window.location.pathname, { replace: true });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function DashboardSettings() {
|
||||
config.oauthEnabled.oidc,
|
||||
) && <SettingsOAuth />}
|
||||
|
||||
{eitherTrue(config.mfa.totp.enabled, config.mfa.passkeys) && <SettingsMfa />}
|
||||
{eitherTrue(config.mfa.totp.enabled, config.mfa.passkeys.enabled) && <SettingsMfa />}
|
||||
|
||||
<SettingsExports />
|
||||
<SettingsGenerators />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { readToDataURL } from '@/lib/base64';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import useAvatar from '@/lib/hooks/useAvatar';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import useAvatar from '@/lib/client/hooks/useAvatar';
|
||||
import { useUserStore } from '@/lib/client/store/user';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import DomainSelect from '@/components/DomainSelect';
|
||||
import { useThemes } from '@/components/ThemeProvider';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import { useSettingsStore } from '@/lib/client/store/settings';
|
||||
import { Group, Paper, Select, Stack, Switch, Text, Title } from '@mantine/core';
|
||||
import { IconMoonFilled, IconPaintFilled, IconSunFilled } from '@tabler/icons-react';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
@@ -52,6 +53,13 @@ export default function SettingsDashboard() {
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<DomainSelect
|
||||
label='Default Domain'
|
||||
description='Set the default domain used for copied links anywhere in the dashboard. Leave blank or select "Default domain" to use the current domain that serves the dashboard.'
|
||||
value={settings.domain}
|
||||
onChange={(value) => update('domain', (value as string) ?? '')}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label='Theme'
|
||||
description='The theme to use for the dashboard. This is only a visual change on your browser and does not change the theme for other users.'
|
||||
|
||||
@@ -82,7 +82,6 @@ export default function SettingsExports() {
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{isLoading && <Table.Tr>Loading...</Table.Tr>}
|
||||
{data?.map((exportDb) => (
|
||||
<Table.Tr key={exportDb.id}>
|
||||
<Table.Td maw={140}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import { useUserStore } from '@/lib/client/store/user';
|
||||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { IconDownload, IconEyeFilled, IconGlobe, IconPercentage, IconWriting } from '@tabler/icons-react';
|
||||
import React, { useReducer, useState } from 'react';
|
||||
import { useReducer, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { flameshot } from './generators/flameshot';
|
||||
import { sharex } from './generators/sharex';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import RelativeDate from '@/components/RelativeDate';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import useObjectState from '@/lib/hooks/useObjectState';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import useObjectState from '@/lib/client/hooks/useObjectState';
|
||||
import { useUserStore } from '@/lib/client/store/user';
|
||||
import { UserPasskey } from '@/prisma/client';
|
||||
import { ActionIcon, Button, Group, Modal, Paper, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { modals } from '@mantine/modals';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { User } from '@/lib/db/models/user';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import { useUserStore } from '@/lib/client/store/user';
|
||||
import {
|
||||
Anchor,
|
||||
Box,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useConfig } from '@/components/ConfigProvider';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { useUserStore } from '@/lib/client/store/user';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { findProvider } from '@/lib/oauth/providerUtil';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import { findProvider } from '@/lib/oauth/providers';
|
||||
import { darken } from '@/lib/theme/color';
|
||||
import type { OAuthProviderType } from '@/prisma/client';
|
||||
import { Button, ButtonProps, Paper, SimpleGrid, Text, Title, useMantineTheme } from '@mantine/core';
|
||||
@@ -68,6 +68,12 @@ function OAuthButton({ provider, linked }: { provider: OAuthProviderType; linked
|
||||
'--z-bol-color': darken(t.colors?.[provider.toLowerCase()]?.[0] ?? '', 0.2, t),
|
||||
},
|
||||
className: !linked ? styles.button : undefined,
|
||||
styles: {
|
||||
label: {
|
||||
whiteSpace: 'normal',
|
||||
textAlign: 'center',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return linked ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { useLogout } from '@/lib/hooks/useLogout';
|
||||
import { useLogout } from '@/lib/client/hooks/useLogout';
|
||||
import { ActionIcon, Button, Modal, Paper, SimpleGrid, Skeleton, Table, Text, Title } from '@mantine/core';
|
||||
import { modals } from '@mantine/modals';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user