diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..17acbbb
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,12 @@
+root = true
+
+[*]
+charset = utf-8
+indent_style = space
+indent_size = 4
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.md]
+trim_trailing_whitespace = false
+insert_final_newline = false
diff --git a/.gitignore b/.gitignore
index c9641c2..72e2137 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,8 @@
!db.json
!private.json
!public.json
+!package.json
+!tsconfig*.json
# Byte-compiled / optimized / DLL files
@@ -22,6 +24,7 @@ downloads/
eggs/
.eggs/
lib/
+!/admin/frontend/src/lib/
lib64/
parts/
sdist/
diff --git a/admin/frontend/.gitignore b/admin/frontend/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/admin/frontend/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/admin/frontend/Dockerfile b/admin/frontend/Dockerfile
new file mode 100644
index 0000000..b4d77d6
--- /dev/null
+++ b/admin/frontend/Dockerfile
@@ -0,0 +1,11 @@
+FROM node:22-slim
+ENV PNPM_HOME="/pnpm"
+ENV PATH="$PNPM_HOME:$PATH"
+RUN corepack enable
+
+WORKDIR /app
+COPY package.json pnpm-lock.yaml /app/
+
+RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
+
+CMD [ "pnpm", "dev", "--host", "0.0.0.0" ]
\ No newline at end of file
diff --git a/admin/frontend/index.html b/admin/frontend/index.html
new file mode 100644
index 0000000..eaa1731
--- /dev/null
+++ b/admin/frontend/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/admin/frontend/package.json b/admin/frontend/package.json
new file mode 100644
index 0000000..3d4817c
--- /dev/null
+++ b/admin/frontend/package.json
@@ -0,0 +1,25 @@
+{
+ "private": true,
+ "type": "module",
+ "contributors": [
+ "Simon Lagerlöf "
+ ],
+ "scripts": {
+ "dev": "vite",
+ "build": "vue-tsc -b && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "lucide-vue-next": "^0.544.0",
+ "valibot": "^1.1.0",
+ "vue": "^3.5.18",
+ "vue-router": "^4.5.1"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^6.0.1",
+ "@vue/tsconfig": "^0.7.0",
+ "typescript": "~5.8.3",
+ "vite": "^7.1.2",
+ "vue-tsc": "^3.0.5"
+ }
+}
diff --git a/admin/frontend/pnpm-lock.yaml b/admin/frontend/pnpm-lock.yaml
new file mode 100644
index 0000000..5a3de56
--- /dev/null
+++ b/admin/frontend/pnpm-lock.yaml
@@ -0,0 +1,959 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ lucide-vue-next:
+ specifier: ^0.544.0
+ version: 0.544.0(vue@3.5.21(typescript@5.8.3))
+ valibot:
+ specifier: ^1.1.0
+ version: 1.1.0(typescript@5.8.3)
+ vue:
+ specifier: ^3.5.18
+ version: 3.5.21(typescript@5.8.3)
+ vue-router:
+ specifier: ^4.5.1
+ version: 4.5.1(vue@3.5.21(typescript@5.8.3))
+ devDependencies:
+ '@vitejs/plugin-vue':
+ specifier: ^6.0.1
+ version: 6.0.1(vite@7.1.4)(vue@3.5.21(typescript@5.8.3))
+ '@vue/tsconfig':
+ specifier: ^0.7.0
+ version: 0.7.0(typescript@5.8.3)(vue@3.5.21(typescript@5.8.3))
+ typescript:
+ specifier: ~5.8.3
+ version: 5.8.3
+ vite:
+ specifier: ^7.1.2
+ version: 7.1.4
+ vue-tsc:
+ specifier: ^3.0.5
+ version: 3.0.6(typescript@5.8.3)
+
+packages:
+
+ '@babel/helper-string-parser@7.27.1':
+ resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.27.1':
+ resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.28.3':
+ resolution: {integrity: sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/types@7.28.2':
+ resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==}
+ engines: {node: '>=6.9.0'}
+
+ '@esbuild/aix-ppc64@0.25.9':
+ resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.25.9':
+ resolution: {integrity: sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.25.9':
+ resolution: {integrity: sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.25.9':
+ resolution: {integrity: sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.25.9':
+ resolution: {integrity: sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.25.9':
+ resolution: {integrity: sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.25.9':
+ resolution: {integrity: sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.25.9':
+ resolution: {integrity: sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.25.9':
+ resolution: {integrity: sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.25.9':
+ resolution: {integrity: sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.25.9':
+ resolution: {integrity: sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.25.9':
+ resolution: {integrity: sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.25.9':
+ resolution: {integrity: sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.25.9':
+ resolution: {integrity: sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.25.9':
+ resolution: {integrity: sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.25.9':
+ resolution: {integrity: sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.25.9':
+ resolution: {integrity: sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.25.9':
+ resolution: {integrity: sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.25.9':
+ resolution: {integrity: sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.25.9':
+ resolution: {integrity: sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.25.9':
+ resolution: {integrity: sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.25.9':
+ resolution: {integrity: sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.25.9':
+ resolution: {integrity: sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.25.9':
+ resolution: {integrity: sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.25.9':
+ resolution: {integrity: sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.25.9':
+ resolution: {integrity: sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@jridgewell/sourcemap-codec@1.5.5':
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+ '@rolldown/pluginutils@1.0.0-beta.29':
+ resolution: {integrity: sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==}
+
+ '@rollup/rollup-android-arm-eabi@4.50.0':
+ resolution: {integrity: sha512-lVgpeQyy4fWN5QYebtW4buT/4kn4p4IJ+kDNB4uYNT5b8c8DLJDg6titg20NIg7E8RWwdWZORW6vUFfrLyG3KQ==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.50.0':
+ resolution: {integrity: sha512-2O73dR4Dc9bp+wSYhviP6sDziurB5/HCym7xILKifWdE9UsOe2FtNcM+I4xZjKrfLJnq5UR8k9riB87gauiQtw==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.50.0':
+ resolution: {integrity: sha512-vwSXQN8T4sKf1RHr1F0s98Pf8UPz7pS6P3LG9NSmuw0TVh7EmaE+5Ny7hJOZ0M2yuTctEsHHRTMi2wuHkdS6Hg==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.50.0':
+ resolution: {integrity: sha512-cQp/WG8HE7BCGyFVuzUg0FNmupxC+EPZEwWu2FCGGw5WDT1o2/YlENbm5e9SMvfDFR6FRhVCBePLqj0o8MN7Vw==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.50.0':
+ resolution: {integrity: sha512-UR1uTJFU/p801DvvBbtDD7z9mQL8J80xB0bR7DqW7UGQHRm/OaKzp4is7sQSdbt2pjjSS72eAtRh43hNduTnnQ==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.50.0':
+ resolution: {integrity: sha512-G/DKyS6PK0dD0+VEzH/6n/hWDNPDZSMBmqsElWnCRGrYOb2jC0VSupp7UAHHQ4+QILwkxSMaYIbQ72dktp8pKA==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.50.0':
+ resolution: {integrity: sha512-u72Mzc6jyJwKjJbZZcIYmd9bumJu7KNmHYdue43vT1rXPm2rITwmPWF0mmPzLm9/vJWxIRbao/jrQmxTO0Sm9w==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.50.0':
+ resolution: {integrity: sha512-S4UefYdV0tnynDJV1mdkNawp0E5Qm2MtSs330IyHgaccOFrwqsvgigUD29uT+B/70PDY1eQ3t40+xf6wIvXJyg==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-gnu@4.50.0':
+ resolution: {integrity: sha512-1EhkSvUQXJsIhk4msxP5nNAUWoB4MFDHhtc4gAYvnqoHlaL9V3F37pNHabndawsfy/Tp7BPiy/aSa6XBYbaD1g==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-musl@4.50.0':
+ resolution: {integrity: sha512-EtBDIZuDtVg75xIPIK1l5vCXNNCIRM0OBPUG+tbApDuJAy9mKago6QxX+tfMzbCI6tXEhMuZuN1+CU8iDW+0UQ==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-loongarch64-gnu@4.50.0':
+ resolution: {integrity: sha512-BGYSwJdMP0hT5CCmljuSNx7+k+0upweM2M4YGfFBjnFSZMHOLYR0gEEj/dxyYJ6Zc6AiSeaBY8dWOa11GF/ppQ==}
+ cpu: [loong64]
+ os: [linux]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.50.0':
+ resolution: {integrity: sha512-I1gSMzkVe1KzAxKAroCJL30hA4DqSi+wGc5gviD0y3IL/VkvcnAqwBf4RHXHyvH66YVHxpKO8ojrgc4SrWAnLg==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.50.0':
+ resolution: {integrity: sha512-bSbWlY3jZo7molh4tc5dKfeSxkqnf48UsLqYbUhnkdnfgZjgufLS/NTA8PcP/dnvct5CCdNkABJ56CbclMRYCA==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-musl@4.50.0':
+ resolution: {integrity: sha512-LSXSGumSURzEQLT2e4sFqFOv3LWZsEF8FK7AAv9zHZNDdMnUPYH3t8ZlaeYYZyTXnsob3htwTKeWtBIkPV27iQ==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-s390x-gnu@4.50.0':
+ resolution: {integrity: sha512-CxRKyakfDrsLXiCyucVfVWVoaPA4oFSpPpDwlMcDFQvrv3XY6KEzMtMZrA+e/goC8xxp2WSOxHQubP8fPmmjOQ==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-gnu@4.50.0':
+ resolution: {integrity: sha512-8PrJJA7/VU8ToHVEPu14FzuSAqVKyo5gg/J8xUerMbyNkWkO9j2ExBho/68RnJsMGNJq4zH114iAttgm7BZVkA==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-musl@4.50.0':
+ resolution: {integrity: sha512-SkE6YQp+CzpyOrbw7Oc4MgXFvTw2UIBElvAvLCo230pyxOLmYwRPwZ/L5lBe/VW/qT1ZgND9wJfOsdy0XptRvw==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-openharmony-arm64@4.50.0':
+ resolution: {integrity: sha512-PZkNLPfvXeIOgJWA804zjSFH7fARBBCpCXxgkGDRjjAhRLOR8o0IGS01ykh5GYfod4c2yiiREuDM8iZ+pVsT+Q==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.50.0':
+ resolution: {integrity: sha512-q7cIIdFvWQoaCbLDUyUc8YfR3Jh2xx3unO8Dn6/TTogKjfwrax9SyfmGGK6cQhKtjePI7jRfd7iRYcxYs93esg==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.50.0':
+ resolution: {integrity: sha512-XzNOVg/YnDOmFdDKcxxK410PrcbcqZkBmz+0FicpW5jtjKQxcW1BZJEQOF0NJa6JO7CZhett8GEtRN/wYLYJuw==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.50.0':
+ resolution: {integrity: sha512-xMmiWRR8sp72Zqwjgtf3QbZfF1wdh8X2ABu3EaozvZcyHJeU0r+XAnXdKgs4cCAp6ORoYoCygipYP1mjmbjrsg==}
+ cpu: [x64]
+ os: [win32]
+
+ '@types/estree@1.0.8':
+ resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+
+ '@vitejs/plugin-vue@6.0.1':
+ resolution: {integrity: sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ peerDependencies:
+ vite: ^5.0.0 || ^6.0.0 || ^7.0.0
+ vue: ^3.2.25
+
+ '@volar/language-core@2.4.23':
+ resolution: {integrity: sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ==}
+
+ '@volar/source-map@2.4.23':
+ resolution: {integrity: sha512-Z1Uc8IB57Lm6k7q6KIDu/p+JWtf3xsXJqAX/5r18hYOTpJyBn0KXUR8oTJ4WFYOcDzWC9n3IflGgHowx6U6z9Q==}
+
+ '@volar/typescript@2.4.23':
+ resolution: {integrity: sha512-lAB5zJghWxVPqfcStmAP1ZqQacMpe90UrP5RJ3arDyrhy4aCUQqmxPPLB2PWDKugvylmO41ljK7vZ+t6INMTag==}
+
+ '@vue/compiler-core@3.5.21':
+ resolution: {integrity: sha512-8i+LZ0vf6ZgII5Z9XmUvrCyEzocvWT+TeR2VBUVlzIH6Tyv57E20mPZ1bCS+tbejgUgmjrEh7q/0F0bibskAmw==}
+
+ '@vue/compiler-dom@3.5.21':
+ resolution: {integrity: sha512-jNtbu/u97wiyEBJlJ9kmdw7tAr5Vy0Aj5CgQmo+6pxWNQhXZDPsRr1UWPN4v3Zf82s2H3kF51IbzZ4jMWAgPlQ==}
+
+ '@vue/compiler-sfc@3.5.21':
+ resolution: {integrity: sha512-SXlyk6I5eUGBd2v8Ie7tF6ADHE9kCR6mBEuPyH1nUZ0h6Xx6nZI29i12sJKQmzbDyr2tUHMhhTt51Z6blbkTTQ==}
+
+ '@vue/compiler-ssr@3.5.21':
+ resolution: {integrity: sha512-vKQ5olH5edFZdf5ZrlEgSO1j1DMA4u23TVK5XR1uMhvwnYvVdDF0nHXJUblL/GvzlShQbjhZZ2uvYmDlAbgo9w==}
+
+ '@vue/compiler-vue2@2.7.16':
+ resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==}
+
+ '@vue/devtools-api@6.6.4':
+ resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
+
+ '@vue/language-core@3.0.6':
+ resolution: {integrity: sha512-e2RRzYWm+qGm8apUHW1wA5RQxzNhkqbbKdbKhiDUcmMrNAZGyM8aTiL3UrTqkaFI5s7wJRGGrp4u3jgusuBp2A==}
+ peerDependencies:
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ '@vue/reactivity@3.5.21':
+ resolution: {integrity: sha512-3ah7sa+Cwr9iiYEERt9JfZKPw4A2UlbY8RbbnH2mGCE8NwHkhmlZt2VsH0oDA3P08X3jJd29ohBDtX+TbD9AsA==}
+
+ '@vue/runtime-core@3.5.21':
+ resolution: {integrity: sha512-+DplQlRS4MXfIf9gfD1BOJpk5RSyGgGXD/R+cumhe8jdjUcq/qlxDawQlSI8hCKupBlvM+3eS1se5xW+SuNAwA==}
+
+ '@vue/runtime-dom@3.5.21':
+ resolution: {integrity: sha512-3M2DZsOFwM5qI15wrMmNF5RJe1+ARijt2HM3TbzBbPSuBHOQpoidE+Pa+XEaVN+czbHf81ETRoG1ltztP2em8w==}
+
+ '@vue/server-renderer@3.5.21':
+ resolution: {integrity: sha512-qr8AqgD3DJPJcGvLcJKQo2tAc8OnXRcfxhOJCPF+fcfn5bBGz7VCcO7t+qETOPxpWK1mgysXvVT/j+xWaHeMWA==}
+ peerDependencies:
+ vue: 3.5.21
+
+ '@vue/shared@3.5.21':
+ resolution: {integrity: sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==}
+
+ '@vue/tsconfig@0.7.0':
+ resolution: {integrity: sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg==}
+ peerDependencies:
+ typescript: 5.x
+ vue: ^3.4.0
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+ vue:
+ optional: true
+
+ alien-signals@2.0.7:
+ resolution: {integrity: sha512-wE7y3jmYeb0+h6mr5BOovuqhFv22O/MV9j5p0ndJsa7z1zJNPGQ4ph5pQk/kTTCWRC3xsA4SmtwmkzQO+7NCNg==}
+
+ csstype@3.1.3:
+ resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
+
+ de-indent@1.0.2:
+ resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
+
+ entities@4.5.0:
+ resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
+ engines: {node: '>=0.12'}
+
+ esbuild@0.25.9:
+ resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ estree-walker@2.0.2:
+ resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ he@1.2.0:
+ resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
+ hasBin: true
+
+ lucide-vue-next@0.544.0:
+ resolution: {integrity: sha512-mDp/AdGOPIDkpFHnFiTWgQgCST9aBXHVaiobZfOMIvv7nrOukzF/TP+7KoOwrngdWRaH9TMiepMBIX1vsgKJ3g==}
+ peerDependencies:
+ vue: '>=3.0.1'
+
+ magic-string@0.30.18:
+ resolution: {integrity: sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==}
+
+ muggle-string@0.4.1:
+ resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
+
+ nanoid@3.3.11:
+ resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ path-browserify@1.0.1:
+ resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@4.0.3:
+ resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
+ engines: {node: '>=12'}
+
+ postcss@8.5.6:
+ resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ rollup@4.50.0:
+ resolution: {integrity: sha512-/Zl4D8zPifNmyGzJS+3kVoyXeDeT/GrsJM94sACNg9RtUE0hrHa1bNPtRSrfHTMH5HjRzce6K7rlTh3Khiw+pw==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ tinyglobby@0.2.14:
+ resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
+ engines: {node: '>=12.0.0'}
+
+ typescript@5.8.3:
+ resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ valibot@1.1.0:
+ resolution: {integrity: sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==}
+ peerDependencies:
+ typescript: '>=5'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ vite@7.1.4:
+ resolution: {integrity: sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ jiti: '>=1.21.0'
+ less: ^4.0.0
+ lightningcss: ^1.21.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: '>=0.54.8'
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ vscode-uri@3.1.0:
+ resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
+
+ vue-router@4.5.1:
+ resolution: {integrity: sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==}
+ peerDependencies:
+ vue: ^3.2.0
+
+ vue-tsc@3.0.6:
+ resolution: {integrity: sha512-Tbs8Whd43R2e2nxez4WXPvvdjGbW24rOSgRhLOHXzWiT4pcP4G7KeWh0YCn18rF4bVwv7tggLLZ6MJnO6jXPBg==}
+ hasBin: true
+ peerDependencies:
+ typescript: '>=5.0.0'
+
+ vue@3.5.21:
+ resolution: {integrity: sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==}
+ peerDependencies:
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+snapshots:
+
+ '@babel/helper-string-parser@7.27.1': {}
+
+ '@babel/helper-validator-identifier@7.27.1': {}
+
+ '@babel/parser@7.28.3':
+ dependencies:
+ '@babel/types': 7.28.2
+
+ '@babel/types@7.28.2':
+ dependencies:
+ '@babel/helper-string-parser': 7.27.1
+ '@babel/helper-validator-identifier': 7.27.1
+
+ '@esbuild/aix-ppc64@0.25.9':
+ optional: true
+
+ '@esbuild/android-arm64@0.25.9':
+ optional: true
+
+ '@esbuild/android-arm@0.25.9':
+ optional: true
+
+ '@esbuild/android-x64@0.25.9':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.25.9':
+ optional: true
+
+ '@esbuild/darwin-x64@0.25.9':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.25.9':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.25.9':
+ optional: true
+
+ '@esbuild/linux-arm64@0.25.9':
+ optional: true
+
+ '@esbuild/linux-arm@0.25.9':
+ optional: true
+
+ '@esbuild/linux-ia32@0.25.9':
+ optional: true
+
+ '@esbuild/linux-loong64@0.25.9':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.25.9':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.25.9':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.25.9':
+ optional: true
+
+ '@esbuild/linux-s390x@0.25.9':
+ optional: true
+
+ '@esbuild/linux-x64@0.25.9':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.25.9':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.25.9':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.25.9':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.25.9':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.25.9':
+ optional: true
+
+ '@esbuild/sunos-x64@0.25.9':
+ optional: true
+
+ '@esbuild/win32-arm64@0.25.9':
+ optional: true
+
+ '@esbuild/win32-ia32@0.25.9':
+ optional: true
+
+ '@esbuild/win32-x64@0.25.9':
+ optional: true
+
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
+ '@rolldown/pluginutils@1.0.0-beta.29': {}
+
+ '@rollup/rollup-android-arm-eabi@4.50.0':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.50.0':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.50.0':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.50.0':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.50.0':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.50.0':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.50.0':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.50.0':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.50.0':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.50.0':
+ optional: true
+
+ '@rollup/rollup-linux-loongarch64-gnu@4.50.0':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.50.0':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.50.0':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.50.0':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.50.0':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.50.0':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.50.0':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.50.0':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.50.0':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.50.0':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.50.0':
+ optional: true
+
+ '@types/estree@1.0.8': {}
+
+ '@vitejs/plugin-vue@6.0.1(vite@7.1.4)(vue@3.5.21(typescript@5.8.3))':
+ dependencies:
+ '@rolldown/pluginutils': 1.0.0-beta.29
+ vite: 7.1.4
+ vue: 3.5.21(typescript@5.8.3)
+
+ '@volar/language-core@2.4.23':
+ dependencies:
+ '@volar/source-map': 2.4.23
+
+ '@volar/source-map@2.4.23': {}
+
+ '@volar/typescript@2.4.23':
+ dependencies:
+ '@volar/language-core': 2.4.23
+ path-browserify: 1.0.1
+ vscode-uri: 3.1.0
+
+ '@vue/compiler-core@3.5.21':
+ dependencies:
+ '@babel/parser': 7.28.3
+ '@vue/shared': 3.5.21
+ entities: 4.5.0
+ estree-walker: 2.0.2
+ source-map-js: 1.2.1
+
+ '@vue/compiler-dom@3.5.21':
+ dependencies:
+ '@vue/compiler-core': 3.5.21
+ '@vue/shared': 3.5.21
+
+ '@vue/compiler-sfc@3.5.21':
+ dependencies:
+ '@babel/parser': 7.28.3
+ '@vue/compiler-core': 3.5.21
+ '@vue/compiler-dom': 3.5.21
+ '@vue/compiler-ssr': 3.5.21
+ '@vue/shared': 3.5.21
+ estree-walker: 2.0.2
+ magic-string: 0.30.18
+ postcss: 8.5.6
+ source-map-js: 1.2.1
+
+ '@vue/compiler-ssr@3.5.21':
+ dependencies:
+ '@vue/compiler-dom': 3.5.21
+ '@vue/shared': 3.5.21
+
+ '@vue/compiler-vue2@2.7.16':
+ dependencies:
+ de-indent: 1.0.2
+ he: 1.2.0
+
+ '@vue/devtools-api@6.6.4': {}
+
+ '@vue/language-core@3.0.6(typescript@5.8.3)':
+ dependencies:
+ '@volar/language-core': 2.4.23
+ '@vue/compiler-dom': 3.5.21
+ '@vue/compiler-vue2': 2.7.16
+ '@vue/shared': 3.5.21
+ alien-signals: 2.0.7
+ muggle-string: 0.4.1
+ path-browserify: 1.0.1
+ picomatch: 4.0.3
+ optionalDependencies:
+ typescript: 5.8.3
+
+ '@vue/reactivity@3.5.21':
+ dependencies:
+ '@vue/shared': 3.5.21
+
+ '@vue/runtime-core@3.5.21':
+ dependencies:
+ '@vue/reactivity': 3.5.21
+ '@vue/shared': 3.5.21
+
+ '@vue/runtime-dom@3.5.21':
+ dependencies:
+ '@vue/reactivity': 3.5.21
+ '@vue/runtime-core': 3.5.21
+ '@vue/shared': 3.5.21
+ csstype: 3.1.3
+
+ '@vue/server-renderer@3.5.21(vue@3.5.21(typescript@5.8.3))':
+ dependencies:
+ '@vue/compiler-ssr': 3.5.21
+ '@vue/shared': 3.5.21
+ vue: 3.5.21(typescript@5.8.3)
+
+ '@vue/shared@3.5.21': {}
+
+ '@vue/tsconfig@0.7.0(typescript@5.8.3)(vue@3.5.21(typescript@5.8.3))':
+ optionalDependencies:
+ typescript: 5.8.3
+ vue: 3.5.21(typescript@5.8.3)
+
+ alien-signals@2.0.7: {}
+
+ csstype@3.1.3: {}
+
+ de-indent@1.0.2: {}
+
+ entities@4.5.0: {}
+
+ esbuild@0.25.9:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.25.9
+ '@esbuild/android-arm': 0.25.9
+ '@esbuild/android-arm64': 0.25.9
+ '@esbuild/android-x64': 0.25.9
+ '@esbuild/darwin-arm64': 0.25.9
+ '@esbuild/darwin-x64': 0.25.9
+ '@esbuild/freebsd-arm64': 0.25.9
+ '@esbuild/freebsd-x64': 0.25.9
+ '@esbuild/linux-arm': 0.25.9
+ '@esbuild/linux-arm64': 0.25.9
+ '@esbuild/linux-ia32': 0.25.9
+ '@esbuild/linux-loong64': 0.25.9
+ '@esbuild/linux-mips64el': 0.25.9
+ '@esbuild/linux-ppc64': 0.25.9
+ '@esbuild/linux-riscv64': 0.25.9
+ '@esbuild/linux-s390x': 0.25.9
+ '@esbuild/linux-x64': 0.25.9
+ '@esbuild/netbsd-arm64': 0.25.9
+ '@esbuild/netbsd-x64': 0.25.9
+ '@esbuild/openbsd-arm64': 0.25.9
+ '@esbuild/openbsd-x64': 0.25.9
+ '@esbuild/openharmony-arm64': 0.25.9
+ '@esbuild/sunos-x64': 0.25.9
+ '@esbuild/win32-arm64': 0.25.9
+ '@esbuild/win32-ia32': 0.25.9
+ '@esbuild/win32-x64': 0.25.9
+
+ estree-walker@2.0.2: {}
+
+ fdir@6.5.0(picomatch@4.0.3):
+ optionalDependencies:
+ picomatch: 4.0.3
+
+ fsevents@2.3.3:
+ optional: true
+
+ he@1.2.0: {}
+
+ lucide-vue-next@0.544.0(vue@3.5.21(typescript@5.8.3)):
+ dependencies:
+ vue: 3.5.21(typescript@5.8.3)
+
+ magic-string@0.30.18:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ muggle-string@0.4.1: {}
+
+ nanoid@3.3.11: {}
+
+ path-browserify@1.0.1: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.3: {}
+
+ postcss@8.5.6:
+ dependencies:
+ nanoid: 3.3.11
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ rollup@4.50.0:
+ dependencies:
+ '@types/estree': 1.0.8
+ optionalDependencies:
+ '@rollup/rollup-android-arm-eabi': 4.50.0
+ '@rollup/rollup-android-arm64': 4.50.0
+ '@rollup/rollup-darwin-arm64': 4.50.0
+ '@rollup/rollup-darwin-x64': 4.50.0
+ '@rollup/rollup-freebsd-arm64': 4.50.0
+ '@rollup/rollup-freebsd-x64': 4.50.0
+ '@rollup/rollup-linux-arm-gnueabihf': 4.50.0
+ '@rollup/rollup-linux-arm-musleabihf': 4.50.0
+ '@rollup/rollup-linux-arm64-gnu': 4.50.0
+ '@rollup/rollup-linux-arm64-musl': 4.50.0
+ '@rollup/rollup-linux-loongarch64-gnu': 4.50.0
+ '@rollup/rollup-linux-ppc64-gnu': 4.50.0
+ '@rollup/rollup-linux-riscv64-gnu': 4.50.0
+ '@rollup/rollup-linux-riscv64-musl': 4.50.0
+ '@rollup/rollup-linux-s390x-gnu': 4.50.0
+ '@rollup/rollup-linux-x64-gnu': 4.50.0
+ '@rollup/rollup-linux-x64-musl': 4.50.0
+ '@rollup/rollup-openharmony-arm64': 4.50.0
+ '@rollup/rollup-win32-arm64-msvc': 4.50.0
+ '@rollup/rollup-win32-ia32-msvc': 4.50.0
+ '@rollup/rollup-win32-x64-msvc': 4.50.0
+ fsevents: 2.3.3
+
+ source-map-js@1.2.1: {}
+
+ tinyglobby@0.2.14:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.3)
+ picomatch: 4.0.3
+
+ typescript@5.8.3: {}
+
+ valibot@1.1.0(typescript@5.8.3):
+ optionalDependencies:
+ typescript: 5.8.3
+
+ vite@7.1.4:
+ dependencies:
+ esbuild: 0.25.9
+ fdir: 6.5.0(picomatch@4.0.3)
+ picomatch: 4.0.3
+ postcss: 8.5.6
+ rollup: 4.50.0
+ tinyglobby: 0.2.14
+ optionalDependencies:
+ fsevents: 2.3.3
+
+ vscode-uri@3.1.0: {}
+
+ vue-router@4.5.1(vue@3.5.21(typescript@5.8.3)):
+ dependencies:
+ '@vue/devtools-api': 6.6.4
+ vue: 3.5.21(typescript@5.8.3)
+
+ vue-tsc@3.0.6(typescript@5.8.3):
+ dependencies:
+ '@volar/typescript': 2.4.23
+ '@vue/language-core': 3.0.6(typescript@5.8.3)
+ typescript: 5.8.3
+
+ vue@3.5.21(typescript@5.8.3):
+ dependencies:
+ '@vue/compiler-dom': 3.5.21
+ '@vue/compiler-sfc': 3.5.21
+ '@vue/runtime-dom': 3.5.21
+ '@vue/server-renderer': 3.5.21(vue@3.5.21(typescript@5.8.3))
+ '@vue/shared': 3.5.21
+ optionalDependencies:
+ typescript: 5.8.3
diff --git a/admin/frontend/src/App.vue b/admin/frontend/src/App.vue
new file mode 100644
index 0000000..7a86ddf
--- /dev/null
+++ b/admin/frontend/src/App.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/admin/frontend/src/components/TrustMarkTypesList.vue b/admin/frontend/src/components/TrustMarkTypesList.vue
new file mode 100644
index 0000000..cdeff2a
--- /dev/null
+++ b/admin/frontend/src/components/TrustMarkTypesList.vue
@@ -0,0 +1,38 @@
+
+
+
+ Loading trust mark types...
+ {{ error }}
+
+ No trust mark types available.
+
+
+ -
+ {{ item.tmtype }}
+
+
+
diff --git a/admin/frontend/src/components/base/Heading.vue b/admin/frontend/src/components/base/Heading.vue
new file mode 100644
index 0000000..5657359
--- /dev/null
+++ b/admin/frontend/src/components/base/Heading.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
diff --git a/admin/frontend/src/components/site/Layout.vue b/admin/frontend/src/components/site/Layout.vue
new file mode 100644
index 0000000..8d708b6
--- /dev/null
+++ b/admin/frontend/src/components/site/Layout.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/admin/frontend/src/components/site/Sidebar.vue b/admin/frontend/src/components/site/Sidebar.vue
new file mode 100644
index 0000000..8f3375e
--- /dev/null
+++ b/admin/frontend/src/components/site/Sidebar.vue
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
diff --git a/admin/frontend/src/lib/admin-sdk/errors.ts b/admin/frontend/src/lib/admin-sdk/errors.ts
new file mode 100644
index 0000000..119fb33
--- /dev/null
+++ b/admin/frontend/src/lib/admin-sdk/errors.ts
@@ -0,0 +1,64 @@
+import type { BaseIssue, BaseSchema, SafeParseResult } from 'valibot';
+
+export class AdminSDKError extends Error {};
+
+
+type TSchema = BaseSchema>;
+
+type Issues = SafeParseResult['issues']
+
+type ValidationErrorProps = {
+ message?: string;
+ issues?: Issues;
+}
+
+export class ValidationError extends AdminSDKError {
+ readonly issues?: Issues;
+ readonly message: string;
+
+ constructor({ message, issues }: ValidationErrorProps){
+ super(`Validation error: ${message}`);
+ this.issues = issues;
+ this.message = message || 'An unexpected error occurred.';
+ }
+};
+
+type FetchErrorProps = {
+ status?: number;
+ message?: string;
+};
+export type FetchErrorType = 'client_error' | 'auth_error' | 'server_error' | 'unknown_error';
+export class FetchError extends AdminSDKError {
+ readonly status?: number;
+ readonly type: FetchErrorType;
+ readonly message: string;
+
+ constructor({ status, message }: FetchErrorProps) {
+ const errors: Record = {
+ 400: { type: 'client_error', message: 'Request is invalid.' },
+ 405: { type: 'client_error', message: 'Method not allowed.' },
+ 401: { type: 'auth_error', message: 'Authorization failed.' },
+ 403: { type: 'auth_error', message: 'Access is forbidden.' },
+ 500: { type: 'server_error', message: 'Internal server error.' },
+ 501: { type: 'server_error', message: 'Requested functionality is not supported.' },
+ 502: { type: 'server_error', message: 'Invalid response received from upstream server.' },
+ 503: { type: 'server_error', message: 'Service unavailable.' },
+ 504: { type: 'server_error', message: 'Gateway timeout: server did not respond in time.' },
+ };
+
+ const error = status && errors[status] || {
+ type: 'unknown_error',
+ message: 'An unexpected error occurred.'
+ };
+
+ if (message) {
+ error.message = message;
+ }
+
+
+ super(`Fetch error: ${error.message}`);
+ this.status = status;
+ this.type = error.type;
+ this.message = error.message;
+ }
+}
diff --git a/admin/frontend/src/lib/admin-sdk/index.ts b/admin/frontend/src/lib/admin-sdk/index.ts
new file mode 100644
index 0000000..5d22397
--- /dev/null
+++ b/admin/frontend/src/lib/admin-sdk/index.ts
@@ -0,0 +1,3 @@
+export * from './sdk';
+export * from './resources';
+export * from './errors';
\ No newline at end of file
diff --git a/admin/frontend/src/lib/admin-sdk/resources.ts b/admin/frontend/src/lib/admin-sdk/resources.ts
new file mode 100644
index 0000000..6d474b7
--- /dev/null
+++ b/admin/frontend/src/lib/admin-sdk/resources.ts
@@ -0,0 +1,108 @@
+import {
+ array,
+ boolean,
+ number,
+ object,
+ record,
+ string,
+ unknown,
+ type InferOutput
+} from 'valibot';
+
+export type HttpMethod = 'GET'|'POST'|'DELETE'|'PATCH'|'PUT';
+
+export type TrustMarkTypeCreateOptions = InferOutput;
+export const TrustMarkTypeCreateOptionsSchema = object({
+ tmtype: string(),
+ autorenew: boolean(),
+ valid_for: number(),
+ renewal_time: number(),
+ active: boolean(),
+})
+
+export type TrustMarkTypeUpdateOptions = InferOutput;
+export const TrustMarkTypeUpdateOptionsSchema = object({
+ autorenew: boolean(),
+ valid_for: number(),
+ renewal_time: number(),
+ active: boolean(),
+});
+
+export type TrustMarkType = InferOutput;
+export const TrustMarkTypeSchema = object({
+ id: number(),
+ tmtype: string(),
+ autorenew: boolean(),
+ valid_for: number(),
+ renewal_time: number(),
+ active: boolean(),
+});
+
+export type TrustMarkTypes = InferOutput;
+export const TrustMarkTypesSchema = object({
+ items: array(TrustMarkTypeSchema),
+ count: number(),
+});
+
+export type TrustMarkCreateOptions = InferOutput;
+export const TrustMarkCreateOptionsSchema = object({
+ tmt: number(),
+ domain: string(),
+ autorenew: boolean(),
+ valid_for: number(),
+ renewal_time: number(),
+ active: boolean(),
+});
+
+export type TrustMarkUpdateOptions = InferOutput;
+export const TrustMarkUpdateOptionsSchema = object({
+ autorenew: boolean(),
+ active: boolean(),
+})
+
+export type TrustMark = InferOutput;
+export const TrustMarkSchema = object({
+ id: number(),
+ domain: string(),
+ expire_at: string(),
+ autorenew: boolean(),
+ valid_for: number(),
+ renewal_time: number(),
+ active: boolean(),
+ mark: string(),
+});
+
+export type TrustMarks = InferOutput;
+export const TrustMarksSchema = object({
+ items: array(TrustMarkSchema),
+ count: number(),
+});
+
+export type SubordinateCreateOptions = InferOutput;
+export const SubordinateCreateOptionsSchema = object({
+ entityid: string(),
+ metadata: record(string(), unknown()),
+ jwks: record(string(), unknown()),
+ required_trustmarks: string(),
+ valid_for: number(),
+ autorenew: boolean(),
+ active: boolean(),
+});
+
+export type Subordinate = InferOutput;
+export const SubordinateSchema = object({
+ id: number(),
+ entityid: string(),
+ metadata: record(string(), unknown()),
+ jwks: record(string(), unknown()),
+ required_trustmarks: string(),
+ valid_for: number(),
+ autorenew: boolean(),
+ active: boolean(),
+})
+
+export type Subordinates = InferOutput;
+export const SubordinatesSchema = object({
+ items: array(SubordinateSchema),
+ count: number(),
+});
diff --git a/admin/frontend/src/lib/admin-sdk/sdk.ts b/admin/frontend/src/lib/admin-sdk/sdk.ts
new file mode 100644
index 0000000..ef791d2
--- /dev/null
+++ b/admin/frontend/src/lib/admin-sdk/sdk.ts
@@ -0,0 +1,347 @@
+import { safeParse } from 'valibot';
+import { FetchError, ValidationError } from './errors';
+import {
+ SubordinateCreateOptionsSchema,
+ SubordinateSchema,
+ SubordinatesSchema,
+ TrustMarkCreateOptionsSchema,
+ TrustMarkSchema,
+ TrustMarksSchema,
+ TrustMarkTypeCreateOptionsSchema,
+ TrustMarkTypeSchema,
+ TrustMarkTypesSchema,
+ TrustMarkTypeUpdateOptionsSchema,
+ TrustMarkUpdateOptionsSchema,
+ type HttpMethod,
+ type Subordinate,
+ type SubordinateCreateOptions,
+ type Subordinates,
+ type TrustMark,
+ type TrustMarkCreateOptions,
+ type TrustMarks,
+ type TrustMarkType,
+ type TrustMarkTypeCreateOptions,
+ type TrustMarkTypes,
+ type TrustMarkTypeUpdateOptions,
+ type TrustMarkUpdateOptions
+} from './resources';
+
+type Config = {
+ apiUrl: URL;
+};
+
+export class AdminSDK {
+ readonly #apiUrl: URL;
+
+ constructor(config: Config) {
+ const { apiUrl } = config;
+ this.#apiUrl = apiUrl;
+ }
+
+ /**
+ * Create Trust Mark Type.
+ */
+ async createTrustMarkType(options: TrustMarkTypeCreateOptions): Promise {
+ const body = safeParse(TrustMarkTypeCreateOptionsSchema, options);
+ if (!body.success) {
+ throw new ValidationError({
+ message: 'Failed to validate trustmark type creation options',
+ issues: body.issues,
+ });
+ }
+
+ const res = await this.#fetch('POST', '/trustmarktypes', {
+ body: JSON.stringify(body.output),
+ });
+
+ const data = safeParse(TrustMarkTypeSchema, res);
+ if (!data.success) {
+ throw new ValidationError({
+ message: 'Invalid response when creating trustmark type',
+ issues: data.issues,
+ });
+ }
+
+ return data.output;
+ }
+
+ /**
+ * Lists all existing TrustMarkType(s).
+ */
+ async listTrustMarkTypes(filters?: { limit?: number; offset?: number; }): Promise {
+ const res = await this.#fetch('GET', '/trustmarktypes', {
+ filters,
+ });
+
+ const data = safeParse(TrustMarkTypesSchema, res);
+ if (!data.success) {
+ throw new ValidationError({
+ message: 'Invalid response when listing trustmark types',
+ issues: data.issues,
+ });
+ }
+
+ return data.output;
+ }
+
+ /**
+ * Gets a TrustMarkType by ID.
+ */
+ async getTrustMarkTypeById(id: number): Promise {
+ const res = await this.#fetch('GET', `/trustmarktypes/${id}`);
+
+ const data = safeParse(TrustMarkTypeSchema, res);
+ if (!data.success) {
+ throw new ValidationError({
+ message: `Invalid reponse when getting trustmark type by id ${id}`,
+ issues: data.issues,
+ });
+ }
+
+ return data.output;
+ }
+
+ /**
+ * Update a TrustMarkType by ID.
+ */
+ async updateTrustMarkType(id: number, options: TrustMarkTypeUpdateOptions): Promise {
+ const body = safeParse(TrustMarkTypeUpdateOptionsSchema, options);
+ if (!body.success) {
+ throw new ValidationError({
+ message: 'Failed to validate trustmark type update options',
+ issues: body.issues,
+ });
+ }
+
+ const res = await this.#fetch('PUT', `/trustmarktypes/${id}`, {
+ body: JSON.stringify(body.output)
+ });
+
+ const data = safeParse(TrustMarkTypeSchema, res);
+ if (!data.success) {
+ throw new ValidationError({
+ message: `Invalid response when updating trustmark with id ${id}`,
+ issues: data.issues,
+ });
+ }
+
+ return data.output;
+ }
+
+ /**
+ * Create Trust Mark.
+ */
+ async createTrustMark(options: TrustMarkCreateOptions): Promise {
+ const body = safeParse(TrustMarkCreateOptionsSchema, options);
+ if (!body.success) {
+ throw new ValidationError({
+ message: 'Failed to validate trustmark creation options',
+ issues: body.issues,
+ });
+ }
+
+ const res = await this.#fetch('POST', '/trustmarks', {
+ body: JSON.stringify(body.output),
+ });
+
+ const data = safeParse(TrustMarkSchema, res);
+ if (!data.success) {
+ throw new ValidationError({
+ message: 'Invalid response when creating trustmark',
+ issues: data.issues,
+ });
+ }
+
+ return data.output;
+ }
+
+ /**
+ * Lists all existing TrustMarks.
+ */
+ async listTrustMarks(filters?: { limit?: number; offset?: number; }): Promise {
+ const res = await this.#fetch('GET', '/trustmarks', {
+ filters,
+ });
+
+ const data = safeParse(TrustMarksSchema, res);
+ if (!data.success) {
+ throw new ValidationError({
+ message: 'Invalid response when listing trustmarks',
+ issues: data.issues,
+ });
+ }
+
+ return data.output;
+ }
+
+ /**
+ * Returns a list of existing TrustMarks for a given domain.
+ */
+ async listTrustMarksByDomain(domain: string, filters?: { limit?: number; offset?: number; }): Promise {
+ const res = await this.#fetch('POST', '/trustmarks/list', {
+ filters,
+ body: JSON.stringify({ domain })
+ });
+
+ const data = safeParse(TrustMarksSchema, res);
+ if (!data.success) {
+ throw new ValidationError({
+ message: `Invalid reponse when listing trustmarks by domain ${domain}`,
+ issues: data.issues,
+ });
+ }
+
+ return data.output;
+ }
+
+ /**
+ * Renews a TrustMark.
+ */
+ async renewTrustMark(id: number): Promise {
+ const res = await this.#fetch('POST', `/trustmarks/${id}/renew`);
+
+ const data = safeParse(TrustMarkSchema, res);
+ if (!data.success) {
+ throw new ValidationError({
+ message: `Invalid response when renewing trustmark with id ${id}`,
+ issues: data.issues,
+ });
+ }
+
+ return data.output;
+ }
+
+ /**
+ * Update a TrustMark.
+ */
+ async updateTrustMark(id: number, options: TrustMarkUpdateOptions): Promise {
+ const body = safeParse(TrustMarkUpdateOptionsSchema, options);
+ if (!body.success) {
+ throw new ValidationError({
+ message: 'Failed to validate trustmark update options',
+ issues: body.issues,
+ });
+ }
+
+ const res = await this.#fetch('POST', `/trustmarks/${id}`);
+
+ const data = safeParse(TrustMarkSchema, res);
+ if (!data.success) {
+ throw new ValidationError({
+ message: `Invalid response when updating trust mark with id ${id}`,
+ issues: data.issues,
+ });
+ }
+
+ return data.output;
+ }
+
+ /**
+ * Create Subordinate.
+ */
+ async createSubordinate(options: SubordinateCreateOptions): Promise {
+ const body = safeParse(SubordinateCreateOptionsSchema, options);
+ if (!body.success) {
+ throw new ValidationError({
+ message: 'Failed to validate subordinate creation options',
+ issues: body.issues,
+ });
+ }
+
+ const res = await this.#fetch('POST', '/subordinates', {
+ body: JSON.stringify(body.output),
+ });
+
+ const data = safeParse(SubordinateSchema, res);
+ if (!data.success) {
+ throw new ValidationError({
+ message: 'Invalid response when creating subordinate',
+ issues: data.issues,
+ });
+ }
+
+ return data.output;
+ }
+
+ /**
+ * Lists all existing subordinates.
+ */
+ async listSubordinates(filters?: { limit?: number; offset?: number; }): Promise {
+ const res = await this.#fetch('GET', '/subordinates', {
+ filters,
+ });
+
+ const data = safeParse(SubordinatesSchema, res);
+ if (!data.success) {
+ throw new ValidationError({
+ message: 'Invalid response when listing subordinates',
+ issues: data.issues,
+ });
+ }
+
+ return data.output;
+ }
+
+ /**
+ * Gets a subordinate by ID.
+ */
+ async getSubordinateById(id: number): Promise {
+ const res = await this.#fetch('GET', `/subordinates/${id}`);
+
+ const data = safeParse(SubordinateSchema, res);
+ if (!data.success) {
+ throw new ValidationError({
+ message: `Invalid response when getting subordinate by id ${id}`,
+ issues: data.issues,
+ });
+ }
+
+ return data.output;
+ }
+
+ /**
+ * @throws {FetchError}
+ */
+ async #fetch(method: HttpMethod, path: string|URL, options: RequestInit & { filters?: Record } = {}): Promise {
+ const input = new URL(path, this.#apiUrl);
+ input.pathname = `/api/v1/${input.pathname.replace(/^\/|\/$/g, '')}`; // Prepend api base path.
+
+ if (options.filters) {
+ for (const [key, value] of Object.entries(options.filters)) {
+ input.searchParams.append(key, String(value));
+ }
+
+ // We don't want to pass this along to the RequestInit.
+ delete options.filters;
+ }
+
+ const init: RequestInit = {
+ ...options,
+ method,
+ headers: {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ ...options.headers,
+ },
+ };
+
+ try {
+ const res = await fetch(input, init);
+ const data = await res.json();
+
+ if (!res.ok) {
+ const message = 'message' in data && typeof data.message === 'string' ? data.message : undefined;
+ throw new FetchError({ status: res.status, message: message });
+ }
+
+ return data;
+ } catch (error) {
+ if (error instanceof FetchError) throw error;
+ throw new FetchError({
+ message: error instanceof Error ? error.message : 'An unexpected error occurred.',
+ });
+ }
+
+ }
+}
+
diff --git a/admin/frontend/src/lib/icons.ts b/admin/frontend/src/lib/icons.ts
new file mode 100644
index 0000000..b28b04f
--- /dev/null
+++ b/admin/frontend/src/lib/icons.ts
@@ -0,0 +1,3 @@
+
+
+
diff --git a/admin/frontend/src/main.ts b/admin/frontend/src/main.ts
new file mode 100644
index 0000000..97550b9
--- /dev/null
+++ b/admin/frontend/src/main.ts
@@ -0,0 +1,21 @@
+import { createApp } from 'vue'
+import { createRouter, createWebHistory } from 'vue-router';
+import { AdminSDK } from './lib/admin-sdk';
+import './styles/reset.css';
+import './styles/variables.css';
+import './styles/global.css';
+import { routes } from './routes';
+import App from './App.vue'
+
+const app = createApp(App);
+
+app.config.globalProperties.$sdk = new AdminSDK({
+ apiUrl: new URL('http://localhost:8000'),
+});
+
+app.use(createRouter({
+ history: createWebHistory(),
+ routes,
+}));
+
+app.mount('#app');
diff --git a/admin/frontend/src/routes.ts b/admin/frontend/src/routes.ts
new file mode 100644
index 0000000..125edb9
--- /dev/null
+++ b/admin/frontend/src/routes.ts
@@ -0,0 +1,9 @@
+import SubordinatesView from "./views/SubordinatesView.vue";
+import TrustMarksView from "./views/TrustMarksView.vue";
+import TrustMarkTypesView from "./views/TrustMarkTypesView.vue";
+
+export const routes = [
+ { path: '/trustmark-types', component: TrustMarkTypesView },
+ { path: '/trustmarks', component: TrustMarksView },
+ { path: '/subordinates', component: SubordinatesView },
+]
diff --git a/admin/frontend/src/styles/global.css b/admin/frontend/src/styles/global.css
new file mode 100644
index 0000000..fc4b525
--- /dev/null
+++ b/admin/frontend/src/styles/global.css
@@ -0,0 +1,46 @@
+* {
+ box-sizing: border-box;
+}
+
+::selection {
+ background-color: var(--ir--color--black);
+ color: var(--ir--color--white);
+}
+
+html,
+body {
+ height: 100%;
+}
+
+body {
+ font-family: var(--ir--font-family);
+ font-size: var(--ir--font-size);
+ line-height: var(--ir--line-height);
+ background-color: var(--ir--color--white);
+ color: var(--ir--color--black);
+}
+
+body.disable-scroll {
+ height: auto;
+ overflow-y: hidden;
+}
+
+picture {
+ display: block;
+}
+
+img {
+ width: 100%;
+
+ &[height] {
+ height: auto;
+ }
+}
+
+:is(h1, h2, h3, h4, h5) {
+ font-weight: var(--ir--font-weight--bold);
+}
+
+a {
+ color: currentColor;
+}
diff --git a/admin/frontend/src/styles/reset.css b/admin/frontend/src/styles/reset.css
new file mode 100644
index 0000000..ed11813
--- /dev/null
+++ b/admin/frontend/src/styles/reset.css
@@ -0,0 +1,48 @@
+/* http://meyerweb.com/eric/tools/css/reset/
+ v2.0 | 20110126
+ License: none (public domain)
+*/
+
+html, body, div, span, applet, object, iframe,
+h1, h2, h3, h4, h5, h6, p, blockquote, pre,
+a, abbr, acronym, address, big, cite, code,
+del, dfn, em, img, ins, kbd, q, s, samp,
+small, strike, strong, sub, sup, tt, var,
+b, u, i, center,
+dl, dt, dd, ol, ul, li,
+fieldset, form, label, legend,
+table, caption, tbody, tfoot, thead, tr, th, td,
+article, aside, canvas, details, embed,
+figure, figcaption, footer, header, hgroup,
+menu, nav, output, ruby, section, summary,
+time, mark, audio, video {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ font-size: 100%;
+ font: inherit;
+ vertical-align: baseline;
+}
+/* HTML5 display-role reset for older browsers */
+article, aside, details, figcaption, figure,
+footer, header, hgroup, menu, nav, section {
+ display: block;
+}
+body {
+ line-height: 1;
+}
+ol, ul {
+ list-style: none;
+}
+blockquote, q {
+ quotes: none;
+}
+blockquote:before, blockquote:after,
+q:before, q:after {
+ content: '';
+ content: none;
+}
+table {
+ border-collapse: collapse;
+ border-spacing: 0;
+}
diff --git a/admin/frontend/src/styles/variables.css b/admin/frontend/src/styles/variables.css
new file mode 100644
index 0000000..784ded1
--- /dev/null
+++ b/admin/frontend/src/styles/variables.css
@@ -0,0 +1,50 @@
+:root {
+ --ir--font-family: Cantarell, Roboto, sans-serif, system-ui;
+
+ --ir--font-size--xs: .875rem; /* 14px */
+ --ir--font-size--s: 1rem; /* 16px */
+ --ir--font-size: 1.125rem; /* 18px */
+ --ir--font-size--m: 1.5625rem; /* 25px */
+ --ir--font-size--l: 2.1875rem; /* 35px */
+ --ir--font-size--xl: 3.125rem; /* 50px */
+ --ir--font-size--xxl: 3.75rem; /* 60px */
+
+ --ir--font-weight: 400;
+ --ir--font-weight--bold: 800;
+
+ --ir--line-height: 1.5;
+ --ir--line-height--m: 1.3;
+ --ir--line-height--l: 1.2;
+ --ir--line-height--xl: 1.1;
+ --ir--line-height--xxl: 1;
+
+ --ir--space--1: 4px;
+ --ir--space--2: 8px;
+ --ir--space--3: 16px;
+ --ir--space--4: 32px;
+ --ir--space--5: 40px;
+ --ir--space--6: 48px;
+
+ --ir--border-radius: var(--ir--space--3);
+ --ir--border: 1px solid #e2e8f0;
+
+ --ir--color--black: #141414;
+ --ir--color--white: #ffffff;
+
+ --ir--box-shadow: 0px 5px 17px rgba(89, 89, 89, 0.1);
+}
+
+@media (max-width: 768px) {
+ :root {
+ --ir--font-size--l: 1.875rem; /* 30px */
+ --ir--font-size--xl: 2.5rem; /* 40px */
+ --ir--font-size--xxl: 3.125rem; /* 50px */
+ }
+}
+
+@media (max-width: 456px) {
+ :root {
+ --ir--font-size--xl: 2.5rem; /* 40px */
+ --ir--font-size--xxl: 2.8125rem; /* 45px */
+ }
+}
diff --git a/admin/frontend/src/views/SubordinatesView.vue b/admin/frontend/src/views/SubordinatesView.vue
new file mode 100644
index 0000000..82e4c5b
--- /dev/null
+++ b/admin/frontend/src/views/SubordinatesView.vue
@@ -0,0 +1,12 @@
+
+
+
+ Subordinates
+
diff --git a/admin/frontend/src/views/TrustMarkTypesView.vue b/admin/frontend/src/views/TrustMarkTypesView.vue
new file mode 100644
index 0000000..ab10def
--- /dev/null
+++ b/admin/frontend/src/views/TrustMarkTypesView.vue
@@ -0,0 +1,14 @@
+
+
+
+ Trust mark types
+
+
diff --git a/admin/frontend/src/views/TrustMarksView.vue b/admin/frontend/src/views/TrustMarksView.vue
new file mode 100644
index 0000000..a48320e
--- /dev/null
+++ b/admin/frontend/src/views/TrustMarksView.vue
@@ -0,0 +1,12 @@
+
+
+
+ Trust marks
+
diff --git a/admin/frontend/src/vite-env.d.ts b/admin/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..c10809e
--- /dev/null
+++ b/admin/frontend/src/vite-env.d.ts
@@ -0,0 +1,8 @@
+///
+import { AdminSDK } from "./lib/admin-sdk/sdk";
+
+declare module 'vue' {
+ interface ComponentCustomProperties {
+ $sdk: AdminSDK
+ }
+}
\ No newline at end of file
diff --git a/admin/frontend/tsconfig.app.json b/admin/frontend/tsconfig.app.json
new file mode 100644
index 0000000..83c9d39
--- /dev/null
+++ b/admin/frontend/tsconfig.app.json
@@ -0,0 +1,15 @@
+{
+ "extends": "@vue/tsconfig/tsconfig.dom.json",
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
+}
diff --git a/admin/frontend/tsconfig.json b/admin/frontend/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/admin/frontend/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/admin/frontend/tsconfig.node.json b/admin/frontend/tsconfig.node.json
new file mode 100644
index 0000000..f85a399
--- /dev/null
+++ b/admin/frontend/tsconfig.node.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/admin/frontend/vite.config.ts b/admin/frontend/vite.config.ts
new file mode 100644
index 0000000..bbcf80c
--- /dev/null
+++ b/admin/frontend/vite.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [vue()],
+})