Compare commits

...

26 Commits

Author SHA1 Message Date
Eli Bosley
81d8d3ef62 commit changes 2025-09-03 15:50:01 -04:00
Michael Datelle
5d4a16fe8f feat: build docker card layout (#1572)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Introduced a comprehensive Docker management interface with detail and
card views, including new components for container overview, logs,
console, editing, and web preview.
* Added a new layout and navigation system for detailed item views with
tabbed content and groupable card layouts.
* Enabled dynamic loading and registration of Unraid UI web components,
including a new `<unraid-detail-test />` web component.
  * Added new page and layout components for enhanced UI flexibility.

* **Enhancements**
* Updated environment variable handling and documentation, including
production license key support.
* Switched to "@nuxt/ui-pro" for advanced UI features and updated
related configuration.
  * Improved theme initialization and UI configuration injection.

* **Chores**
* Added development dependencies and updated ignore rules for
environment files.
  * Adjusted ESLint configuration for component definition checks.

* **Style**
  * Minor improvements to import statements and style tag formatting.

* **Documentation**
  * Updated example environment variable files and comments for clarity.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: mdatelle <mike@datelle.net>
2025-09-03 15:48:50 -04:00
mdatelle
8a3c1b3ba8 fix: include nuxt ui configuration files in web-components-plugins 2025-09-03 15:48:50 -04:00
mdatelle
59b257a50f refactor: update dropdown menus 2025-09-03 15:48:38 -04:00
mdatelle
4f8fd18a39 refactor: use Drawer component for mobile view navigation 2025-09-03 15:48:38 -04:00
mdatelle
95eb841110 feat: add responsive styles 2025-09-03 15:48:38 -04:00
mdatelle
d06b0db923 refactor: consolidate interfaces, fix typescript error, and include styles in unraid-next layout 2025-09-03 15:48:38 -04:00
mdatelle
1c4dc154e8 refactor: remove old Detail component and update test component 2025-09-03 15:48:38 -04:00
mdatelle
1f67f63513 refactor: use more generic naming with item prop 2025-09-03 15:48:36 -04:00
mdatelle
761b3964a9 fix: fix typescript error 2025-09-03 15:47:10 -04:00
mdatelle
b9a4d4a864 refactor: break up Detail component into seperate components 2025-09-03 15:47:10 -04:00
mdatelle
c82e3d8427 feat: set up base layout and content test 2025-09-03 15:47:07 -04:00
Eli Bosley
3211312b0e chore: update dependencies and clean up unused imports in components 2025-09-03 15:44:47 -04:00
mdatelle
fb575acc4f chore: register DetailTest in config 2025-09-03 15:44:43 -04:00
mdatelle
4a0b481a2d test: create proper test setup for Detail layout 2025-09-03 15:44:16 -04:00
mdatelle
cd15e12cdd refactor: update main.yml to include .env.production 2025-09-03 15:44:16 -04:00
mdatelle
0e20fd0ab0 update main.yml and remove cat command 2025-09-03 15:44:16 -04:00
mdatelle
f44b4a87e9 test: create detail page and web component to test in webgui 2025-09-03 15:44:16 -04:00
mdatelle
4986f4251d feat: add navigation header controls, custom group dropdown, and spacing adjustments 2025-09-03 15:44:16 -04:00
mdatelle
8213738e26 refactor: get menu children working 2025-09-03 15:44:16 -04:00
mdatelle
f32493e728 feat: use and customize NavigationMenu and update status badges 2025-09-03 15:44:16 -04:00
mdatelle
78ce64e357 feat: create base Detail component and placeholder tab components 2025-09-03 15:44:16 -04:00
mdatelle
bb8c4a133e chore: fix typo 2025-09-03 15:44:16 -04:00
mdatelle
c3222cc6c4 chore: ignore all .env unless force tracked and align example with production 2025-09-03 15:44:16 -04:00
mdatelle
71621072f8 chore: update example andn remove env.production from tracking 2025-09-03 15:44:16 -04:00
mdatelle
d4a8edab49 feat: set up base layout and content test 2025-09-03 15:44:13 -04:00
50 changed files with 2920 additions and 129 deletions

View File

@@ -333,7 +333,9 @@ jobs:
echo VITE_CONNECT=${{ secrets.VITE_CONNECT }} >> .env
echo VITE_UNRAID_NET=${{ secrets.VITE_UNRAID_NET }} >> .env
echo VITE_CALLBACK_KEY=${{ secrets.VITE_CALLBACK_KEY }} >> .env
cat .env
touch .env.production
echo NUXT_UI_PRO_LICENSE=${{ secrets.NUXT_UI_PRO_LICENSE }} >> .env.production
- name: Install Node
uses: actions/setup-node@v4

3
.gitignore vendored
View File

@@ -118,3 +118,6 @@ api/dev/Unraid.net/myservers.cfg
# local Mise settings
.mise.toml
# environment variables
web/.env.production

View File

@@ -161,3 +161,4 @@ Enables GraphQL playground at `http://tower.local/graphql`
- Never use the `any` type. Always prefer proper typing
- Avoid using casting whenever possible, prefer proper typing from the start
- **IMPORTANT:** cache-manager v7 expects TTL values in **milliseconds**, not seconds. Always use milliseconds when setting cache TTL (e.g., 600000 for 10 minutes, not 600)
- Use the nuxt UI library in VUE MODE NOT IN NUXT MODE

View File

@@ -21,7 +21,8 @@
"@nestjs/core": "11.1.6",
"@nestjs/graphql": "13.1.0",
"nest-authz": "2.17.0",
"typescript": "5.9.2"
"typescript": "5.9.2",
"pify": "^6.1.0"
},
"peerDependencies": {
"@nestjs/common": "11.1.6",

View File

@@ -44,6 +44,7 @@
"graphql-ws": "6.0.6",
"lodash-es": "4.17.21",
"nest-authz": "2.17.0",
"pify": "^6.1.0",
"rimraf": "6.0.1",
"type-fest": "4.41.0",
"typescript": "5.9.2",

View File

@@ -0,0 +1,6 @@
Menu="UNRAID-OS"
Title="Detail Layout"
Icon="icon-u-globe"
Tag="globe"
---
<unraid-detail-test />

334
pnpm-lock.yaml generated
View File

@@ -712,6 +712,9 @@ importers:
nest-authz:
specifier: 2.17.0
version: 2.17.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2)
pify:
specifier: ^6.1.0
version: 6.1.0
typescript:
specifier: 5.9.2
version: 5.9.2
@@ -782,6 +785,9 @@ importers:
nest-authz:
specifier: 2.17.0
version: 2.17.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2)
pify:
specifier: ^6.1.0
version: 6.1.0
rimraf:
specifier: 6.0.1
version: 6.0.1
@@ -1071,8 +1077,8 @@ importers:
specifier: 3.6.0
version: 3.6.0(@jsonforms/core@3.6.0)(@jsonforms/vue@3.6.0(@jsonforms/core@3.6.0)(vue@3.5.20(typescript@5.9.2)))(ajv@8.17.1)(dayjs@1.11.14)(lodash@4.17.21)(maska@2.1.11)(vue@3.5.20(typescript@5.9.2))(vuetify@3.9.6)
'@nuxt/ui':
specifier: 3.3.2
version: 3.3.2(@babel/parser@7.28.3)(@netlify/blobs@9.1.2)(change-case@5.4.4)(db0@0.3.2)(embla-carousel@8.6.0)(focus-trap@7.6.5)(ioredis@5.6.1)(jwt-decode@4.0.0)(magicast@0.3.5)(typescript@5.9.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vue-router@4.5.1(vue@3.5.20(typescript@5.9.2)))(vue@3.5.20(typescript@5.9.2))(zod@3.25.76)
specifier: 4.0.0-alpha.0
version: 4.0.0-alpha.0(@babel/parser@7.28.3)(@netlify/blobs@9.1.2)(change-case@5.4.4)(db0@0.3.2)(embla-carousel@8.6.0)(focus-trap@7.6.5)(ioredis@5.6.1)(jwt-decode@4.0.0)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vue-router@4.5.1(vue@3.5.20(typescript@5.9.2)))(vue@3.5.20(typescript@5.9.2))(zod@3.25.76)
'@nuxtjs/color-mode':
specifier: 3.5.2
version: 3.5.2(magicast@0.3.5)
@@ -1315,6 +1321,31 @@ packages:
'@adobe/css-tools@4.4.3':
resolution: {integrity: sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==}
'@ai-sdk/gateway@1.0.15':
resolution: {integrity: sha512-xySXoQ29+KbGuGfmDnABx+O6vc7Gj7qugmj1kGpn0rW0rQNn6UKUuvscKMzWyv1Uv05GyC1vqHq8ZhEOLfXscQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4
'@ai-sdk/provider-utils@3.0.7':
resolution: {integrity: sha512-o3BS5/t8KnBL3ubP8k3w77AByOypLm+pkIL/DCw0qKkhDbvhCy+L3hRTGPikpdb8WHcylAeKsjgwOxhj4cqTUA==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4
'@ai-sdk/provider@2.0.0':
resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==}
engines: {node: '>=18'}
'@ai-sdk/vue@2.0.26':
resolution: {integrity: sha512-QNaG+kbIZMN8xW5JMlDSCPVtnDm3SP7g5i8/yRJGI4skEVVWiscRqEfleLeBWNrUtZbHSxaV1+4EqFJAP70/dg==}
engines: {node: '>=18'}
peerDependencies:
vue: ^3.3.4
peerDependenciesMeta:
vue:
optional: true
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
@@ -1329,8 +1360,8 @@ packages:
'@antfu/utils@0.7.10':
resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==}
'@antfu/utils@8.1.1':
resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==}
'@antfu/utils@9.2.0':
resolution: {integrity: sha512-Oq1d9BGZakE/FyoEtcNeSwM7MpDO2vUBi11RWBZXf75zPsbUVWmUs03EqkRFrcgbXyKTas0BdZWC1wcuSoqSAw==}
'@apidevtools/json-schema-ref-parser@14.1.1':
resolution: {integrity: sha512-uGF1YGOzzD50L7HLNWclXmsEhQflw8/zZHIz0/AzkJrKL5r9PceUipZxR/cp/8veTk4TVfdDJLyIwXLjaP5ePg==}
@@ -1365,7 +1396,6 @@ packages:
'@apollo/server-gateway-interface@1.1.1':
resolution: {integrity: sha512-pGwCl/po6+rxRmDMFgozKQo2pbsSwE91TpsDBAOgf74CRDPXHHtM88wbwjab0wMMZh95QfR45GGyDIdhY24bkQ==}
deprecated: '@apollo/server-gateway-interface v1 is part of Apollo Server v4, which is deprecated and will transition to end-of-life on January 26, 2026. As long as you are already using a non-EOL version of Node.js, upgrading to v2 should take only a few minutes. See https://www.apollographql.com/docs/apollo-server/previous-versions for details.'
peerDependencies:
graphql: 16.11.0
@@ -1379,7 +1409,6 @@ packages:
'@apollo/server@4.12.2':
resolution: {integrity: sha512-jKRlf+sBMMdKYrjMoiWKne42Eb6paBfDOr08KJnUaeaiyWFj+/040FjVPQI7YGLfdwnYIsl1NUUqS2UdgezJDg==}
engines: {node: '>=14.16.0'}
deprecated: Apollo Server v4 is deprecated and will transition to end-of-life on January 26, 2026. As long as you are already using a non-EOL version of Node.js, upgrading to v5 should take only a few minutes. See https://www.apollographql.com/docs/apollo-server/previous-versions for details.
peerDependencies:
graphql: 16.11.0
@@ -3054,14 +3083,14 @@ packages:
prettier-plugin-ember-template-tag:
optional: true
'@iconify/collections@1.0.569':
resolution: {integrity: sha512-PclOVcAlvv55Fv5kRJmxk/KMoFLNBMLh0q9LDMlonIPJMUu958VsNw7F7CVurfyEbCf/54i7eF+q6LHqJxeQvg==}
'@iconify/collections@1.0.588':
resolution: {integrity: sha512-K6jijh3aEZ937R+ES5Swd62NOCZ868PNCyHNg+R7c9Kn9yurtuiLM/zkpN8KxRwVvTX8w83EkBqhUjqo+wFgDw==}
'@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
'@iconify/utils@2.3.0':
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
'@iconify/utils@3.0.1':
resolution: {integrity: sha512-A78CUEnFGX8I/WlILxJCuIJXloL0j/OJ9PSchPAfCargEIKmUBWvvEMmKWB5oONwiUqlNt+5eRufdkLxeHIWYw==}
'@iconify/vue@5.0.0':
resolution: {integrity: sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==}
@@ -3745,8 +3774,8 @@ packages:
'@nuxt/fonts@0.11.4':
resolution: {integrity: sha512-GbLavsC+9FejVwY+KU4/wonJsKhcwOZx/eo4EuV57C4osnF/AtEmev8xqI0DNlebMEhEGZbu1MGwDDDYbeR7Bw==}
'@nuxt/icon@1.15.0':
resolution: {integrity: sha512-kA0rxqr1B601zNJNcOXera8CyYcxUCEcT7dXEC7rwAz71PRCN5emf7G656eKEQgtqrD4JSj6NQqWDgrmFcf/GQ==}
'@nuxt/icon@2.0.0':
resolution: {integrity: sha512-sy8+zkKMYp+H09S0cuTteL3zPTmktqzYPpPXV9ZkLNjrQsaPH08n7s/9wjr+C/K/w2R3u18E3+P1VIQi3xaq1A==}
'@nuxt/kit@3.17.5':
resolution: {integrity: sha512-NdCepmA+S/SzgcaL3oYUeSlXGYO6BXGr9K/m1D0t0O9rApF8CSq/QQ+ja5KYaYMO1kZAEWH4s2XVcE3uPrrAVg==}
@@ -3818,8 +3847,8 @@ packages:
vitest:
optional: true
'@nuxt/ui@3.3.2':
resolution: {integrity: sha512-LN8axCK/0zCqWC/m0nN5R4vQyGmv6Viu9K1ZyzApgAg4vsyRYKXLtr2ta/vXv2y4/CtKfncry1zs/IfsktDyuw==}
'@nuxt/ui@4.0.0-alpha.0':
resolution: {integrity: sha512-Gvjfoyw2VkyovMddhUhu+ixHpcCHb/MDlqlcYt29knxvVcJqMGNW/BvSgcmAVrttNb9xUnL6rvg0bneFXU48Gg==}
hasBin: true
peerDependencies:
'@inertiajs/vue3': ^2.0.7
@@ -3858,6 +3887,10 @@ packages:
'@one-ini/wasm@0.1.1':
resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==}
'@opentelemetry/api@1.9.0':
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
engines: {node: '>=8.0.0'}
'@originjs/vite-plugin-commonjs@1.0.3':
resolution: {integrity: sha512-KuEXeGPptM2lyxdIEJ4R11+5ztipHoE7hy8ClZt3PYaOVQ/pyngd2alaSrPnwyFeOW1UagRBaQ752aA1dTMdOQ==}
@@ -5997,6 +6030,12 @@ packages:
resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==}
engines: {node: '>=8'}
ai@5.0.26:
resolution: {integrity: sha512-bGNtG+nYQ2U+5mzuLbxIg9WxGQJ2u5jv2gYgP8C+CJ1YI4qqIjvjOgGEZWzvNet8jiOGIlqstsht9aQefKzmBw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4
ajv-errors@3.0.0:
resolution: {integrity: sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==}
peerDependencies:
@@ -6520,6 +6559,9 @@ packages:
caniuse-lite@1.0.30001727:
resolution: {integrity: sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==}
caniuse-lite@1.0.30001731:
resolution: {integrity: sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==}
capital-case@1.0.4:
resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==}
@@ -7443,10 +7485,6 @@ packages:
resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==}
engines: {node: '>=12'}
dotenv@16.5.0:
resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==}
engines: {node: '>=12'}
dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
@@ -7477,8 +7515,8 @@ packages:
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
electron-to-chromium@1.5.191:
resolution: {integrity: sha512-xcwe9ELcuxYLUFqZZxL19Z6HVKcvNkIwhbHUz7L3us6u12yR+7uY89dSl570f/IqNthx8dAw3tojG7i4Ni4tDA==}
electron-to-chromium@1.5.192:
resolution: {integrity: sha512-rP8Ez0w7UNw/9j5eSXCe10o1g/8B1P5SM90PCCMVkIRQn2R0LEHWz4Eh9RnxkniuDe1W0cTSOB3MLlkTGDcuCg==}
embla-carousel-auto-height@8.6.0:
resolution: {integrity: sha512-/HrJQOEM6aol/oF33gd2QlINcXy3e19fJWvHDuHWp2bpyTa+2dm9tVVJak30m2Qy6QyQ6Fc8DkImtv7pxWOJUQ==}
@@ -7515,8 +7553,8 @@ packages:
peerDependencies:
vue: ^3.2.37
embla-carousel-wheel-gestures@8.0.2:
resolution: {integrity: sha512-gtE8xHRwMGsfsMAgco/QoYhvcxNoMLmFF0DaWH7FXJJWk8RlEZyiZHZRZL6TZVCgooo9/hKyYWITLaSZLIvkbQ==}
embla-carousel-wheel-gestures@8.1.0:
resolution: {integrity: sha512-J68jkYrxbWDmXOm2n2YHl+uMEXzkGSKjWmjaEgL9xVvPb3HqVmg6rJSKfI3sqIDVvm7mkeTy87wtG/5263XqHQ==}
engines: {node: '>=10'}
peerDependencies:
embla-carousel: ^8.0.0 || ~8.0.0-rc03
@@ -8089,6 +8127,10 @@ packages:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
eventsource-parser@3.0.5:
resolution: {integrity: sha512-bSRG85ZrMdmWtm7qkF9He9TNRzc/Bm99gEJMaQoHJ9E6Kv9QBbsldh2oMj7iXmYNEAVvNgvv5vPorG6W+XtBhQ==}
engines: {node: '>=20.0.0'}
execa@5.1.1:
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
engines: {node: '>=10'}
@@ -8238,14 +8280,6 @@ packages:
fd-slicer@1.1.0:
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
fdir@6.4.6:
resolution: {integrity: sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -8398,6 +8432,20 @@ packages:
fraction.js@4.3.7:
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
framer-motion@12.23.12:
resolution: {integrity: sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fresh@0.5.2:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'}
@@ -8774,6 +8822,9 @@ packages:
hex-to-rgba@2.0.1:
resolution: {integrity: sha512-5XqPJBpsEUMsseJUi2w2Hl7cHFFi3+OO10M2pzAvKB1zL6fc+koGMhmBqoDOCB4GemiRM/zvDMRIhVw6EkB8dQ==}
hey-listen@1.0.8:
resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==}
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
@@ -9437,6 +9488,9 @@ packages:
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
json-schema@0.4.0:
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
@@ -10038,6 +10092,18 @@ packages:
module-details-from-path@1.0.3:
resolution: {integrity: sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==}
motion-dom@12.23.12:
resolution: {integrity: sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==}
motion-utils@12.23.6:
resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==}
motion-v@1.7.0:
resolution: {integrity: sha512-5oPDF5GBpcRnIZuce7Wap09S8afH4JeBWD3VbMRg4hZKk0olQnTFuHjgQUGMpX3V1WXrZgyveoF02W51XMxx9w==}
peerDependencies:
'@vueuse/core': '>=10.0.0'
vue: '>=3.0.0'
mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
@@ -10730,6 +10796,10 @@ packages:
resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==}
engines: {node: '>=4'}
pify@6.1.0:
resolution: {integrity: sha512-KocF8ve28eFjjuBKKGvzOBGzG8ew2OqOOSxTTZhirkzH7h3BI1vyzqlR0qbfcDBve1Yzo3FVlWUAtCRrbVN8Fw==}
engines: {node: '>=14.16'}
pinia@3.0.3:
resolution: {integrity: sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==}
peerDependencies:
@@ -12108,6 +12178,11 @@ packages:
swap-case@2.0.2:
resolution: {integrity: sha512-kc6S2YS/2yXbtkSMunBtKdah4VFETZ8Oh6ONSmSd9bRxhqTrtARUCBUiWXH3xVPpvR7tz2CSnkuXVE42EcGnMw==}
swrv@1.1.0:
resolution: {integrity: sha512-pjllRDr2s0iTwiE5Isvip51dZGR7GjLH1gCSVyE8bQnbAx6xackXsFdojau+1O5u98yHF5V73HQGOFxKUXO9gQ==}
peerDependencies:
vue: '>=3.2.26 < 4'
symbol-observable@1.2.0:
resolution: {integrity: sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==}
engines: {node: '>=0.10.0'}
@@ -13441,6 +13516,33 @@ snapshots:
'@adobe/css-tools@4.4.3': {}
'@ai-sdk/gateway@1.0.15(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 2.0.0
'@ai-sdk/provider-utils': 3.0.7(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/provider-utils@3.0.7(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 2.0.0
'@standard-schema/spec': 1.0.0
eventsource-parser: 3.0.5
zod: 3.25.76
'@ai-sdk/provider@2.0.0':
dependencies:
json-schema: 0.4.0
'@ai-sdk/vue@2.0.26(vue@3.5.20(typescript@5.9.2))(zod@3.25.76)':
dependencies:
'@ai-sdk/provider-utils': 3.0.7(zod@3.25.76)
ai: 5.0.26(zod@3.25.76)
swrv: 1.1.0(vue@3.5.20(typescript@5.9.2))
optionalDependencies:
vue: 3.5.20(typescript@5.9.2)
transitivePeerDependencies:
- zod
'@alloc/quick-lru@5.2.0': {}
'@ampproject/remapping@2.3.0':
@@ -13455,7 +13557,7 @@ snapshots:
'@antfu/utils@0.7.10': {}
'@antfu/utils@8.1.1': {}
'@antfu/utils@9.2.0': {}
'@apidevtools/json-schema-ref-parser@14.1.1':
dependencies:
@@ -13610,7 +13712,7 @@ snapshots:
dependencies:
'@babel/core': 7.28.0
'@babel/generator': 7.28.0
'@babel/parser': 7.28.0
'@babel/parser': 7.28.3
'@babel/runtime': 7.27.6
'@babel/traverse': 7.28.0
'@babel/types': 7.28.0
@@ -13676,7 +13778,7 @@ snapshots:
'@babel/helper-compilation-targets': 7.27.2
'@babel/helper-module-transforms': 7.27.3(@babel/core@7.27.4)
'@babel/helpers': 7.27.6
'@babel/parser': 7.28.0
'@babel/parser': 7.28.3
'@babel/template': 7.27.2
'@babel/traverse': 7.28.0
'@babel/types': 7.28.0
@@ -15211,7 +15313,7 @@ snapshots:
'@whatwg-node/fetch': 0.10.8
chalk: 4.1.2
debug: 4.4.1(supports-color@5.5.0)
dotenv: 16.5.0
dotenv: 16.6.1
graphql: 16.11.0
graphql-request: 6.1.0(graphql@16.11.0)
http-proxy-agent: 7.0.2
@@ -15365,21 +15467,21 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@iconify/collections@1.0.569':
'@iconify/collections@1.0.588':
dependencies:
'@iconify/types': 2.0.0
'@iconify/types@2.0.0': {}
'@iconify/utils@2.3.0':
'@iconify/utils@3.0.1':
dependencies:
'@antfu/install-pkg': 1.1.0
'@antfu/utils': 8.1.1
'@antfu/utils': 9.2.0
'@iconify/types': 2.0.0
debug: 4.4.1(supports-color@5.5.0)
globals: 15.15.0
kolorist: 1.8.0
local-pkg: 1.1.1
local-pkg: 1.1.2
mlly: 1.7.4
transitivePeerDependencies:
- supports-color
@@ -16241,7 +16343,7 @@ snapshots:
'@nuxt/fonts@0.11.4(@netlify/blobs@9.1.2)(db0@0.3.2)(ioredis@5.6.1)(magicast@0.3.5)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))':
dependencies:
'@nuxt/devtools-kit': 2.6.2(magicast@0.3.5)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))
'@nuxt/devtools-kit': 2.6.3(magicast@0.3.5)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))
'@nuxt/kit': 3.18.1(magicast@0.3.5)
consola: 3.4.2
css-tree: 3.1.0
@@ -16259,7 +16361,7 @@ snapshots:
tinyglobby: 0.2.14
ufo: 1.6.1
unifont: 0.4.1
unplugin: 2.3.5
unplugin: 2.3.8
unstorage: 1.16.1(@netlify/blobs@9.1.2)(db0@0.3.2)(ioredis@5.6.1)
transitivePeerDependencies:
- '@azure/app-configuration'
@@ -16284,16 +16386,16 @@ snapshots:
- uploadthing
- vite
'@nuxt/icon@1.15.0(magicast@0.3.5)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vue@3.5.20(typescript@5.9.2))':
'@nuxt/icon@2.0.0(magicast@0.3.5)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vue@3.5.20(typescript@5.9.2))':
dependencies:
'@iconify/collections': 1.0.569
'@iconify/collections': 1.0.588
'@iconify/types': 2.0.0
'@iconify/utils': 2.3.0
'@iconify/utils': 3.0.1
'@iconify/vue': 5.0.0(vue@3.5.20(typescript@5.9.2))
'@nuxt/devtools-kit': 2.6.2(magicast@0.3.5)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))
'@nuxt/kit': 3.18.1(magicast@0.3.5)
'@nuxt/devtools-kit': 2.6.3(magicast@0.3.5)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))
'@nuxt/kit': 4.0.3(magicast@0.3.5)
consola: 3.4.2
local-pkg: 1.1.1
local-pkg: 1.1.2
mlly: 1.7.4
ohash: 2.0.11
pathe: 2.0.3
@@ -16401,7 +16503,7 @@ snapshots:
mlly: 1.7.4
ohash: 2.0.11
pathe: 2.0.3
pkg-types: 2.2.0
pkg-types: 2.3.0
scule: 1.3.0
semver: 7.7.2
std-env: 3.9.0
@@ -16428,7 +16530,7 @@ snapshots:
'@nuxt/schema@4.0.3':
dependencies:
'@vue/shared': 3.5.18
'@vue/shared': 3.5.20
consola: 3.4.2
defu: 6.1.4
pathe: 2.0.3
@@ -16489,13 +16591,14 @@ snapshots:
- magicast
- typescript
'@nuxt/ui@3.3.2(@babel/parser@7.28.3)(@netlify/blobs@9.1.2)(change-case@5.4.4)(db0@0.3.2)(embla-carousel@8.6.0)(focus-trap@7.6.5)(ioredis@5.6.1)(jwt-decode@4.0.0)(magicast@0.3.5)(typescript@5.9.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vue-router@4.5.1(vue@3.5.20(typescript@5.9.2)))(vue@3.5.20(typescript@5.9.2))(zod@3.25.76)':
'@nuxt/ui@4.0.0-alpha.0(@babel/parser@7.28.3)(@netlify/blobs@9.1.2)(change-case@5.4.4)(db0@0.3.2)(embla-carousel@8.6.0)(focus-trap@7.6.5)(ioredis@5.6.1)(jwt-decode@4.0.0)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vue-router@4.5.1(vue@3.5.20(typescript@5.9.2)))(vue@3.5.20(typescript@5.9.2))(zod@3.25.76)':
dependencies:
'@ai-sdk/vue': 2.0.26(vue@3.5.20(typescript@5.9.2))(zod@3.25.76)
'@iconify/vue': 5.0.0(vue@3.5.20(typescript@5.9.2))
'@internationalized/date': 3.8.2
'@internationalized/number': 3.6.5
'@nuxt/fonts': 0.11.4(@netlify/blobs@9.1.2)(db0@0.3.2)(ioredis@5.6.1)(magicast@0.3.5)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))
'@nuxt/icon': 1.15.0(magicast@0.3.5)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vue@3.5.20(typescript@5.9.2))
'@nuxt/icon': 2.0.0(magicast@0.3.5)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vue@3.5.20(typescript@5.9.2))
'@nuxt/kit': 4.0.3(magicast@0.3.5)
'@nuxt/schema': 4.0.3
'@nuxtjs/color-mode': 3.5.2(magicast@0.3.5)
@@ -16515,12 +16618,13 @@ snapshots:
embla-carousel-class-names: 8.6.0(embla-carousel@8.6.0)
embla-carousel-fade: 8.6.0(embla-carousel@8.6.0)
embla-carousel-vue: 8.6.0(vue@3.5.20(typescript@5.9.2))
embla-carousel-wheel-gestures: 8.0.2(embla-carousel@8.6.0)
embla-carousel-wheel-gestures: 8.1.0(embla-carousel@8.6.0)
fuse.js: 7.1.0
hookable: 5.5.3
knitwork: 1.2.0
magic-string: 0.30.17
mlly: 1.7.4
motion-v: 1.7.0(@vueuse/core@13.8.0(vue@3.5.20(typescript@5.9.2)))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vue@3.5.20(typescript@5.9.2))
ohash: 2.0.11
pathe: 2.0.3
reka-ui: 2.4.1(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2))
@@ -16530,7 +16634,7 @@ snapshots:
tailwindcss: 4.1.12
tinyglobby: 0.2.14
typescript: 5.9.2
unplugin: 2.3.5
unplugin: 2.3.8
unplugin-auto-import: 19.3.0(@nuxt/kit@4.0.3(magicast@0.3.5))(@vueuse/core@13.8.0(vue@3.5.20(typescript@5.9.2)))
unplugin-vue-components: 28.8.0(@babel/parser@7.28.3)(@nuxt/kit@4.0.3(magicast@0.3.5))(vue@3.5.20(typescript@5.9.2))
vaul-vue: 0.4.1(reka-ui@2.4.1(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2)))(vue@3.5.20(typescript@5.9.2))
@@ -16548,6 +16652,7 @@ snapshots:
- '@babel/parser'
- '@capacitor/preferences'
- '@deno/kv'
- '@emotion/is-prop-valid'
- '@netlify/blobs'
- '@planetscale/database'
- '@upstash/redis'
@@ -16569,6 +16674,8 @@ snapshots:
- magicast
- nprogress
- qrcode
- react
- react-dom
- sortablejs
- supports-color
- universal-cookie
@@ -16647,6 +16754,8 @@ snapshots:
'@one-ini/wasm@0.1.1': {}
'@opentelemetry/api@1.9.0': {}
'@originjs/vite-plugin-commonjs@1.0.3':
dependencies:
esbuild: 0.14.54
@@ -17048,7 +17157,7 @@ snapshots:
'@rollup/pluginutils': 5.2.0(rollup@4.46.2)
commondir: 1.0.1
estree-walker: 2.0.2
fdir: 6.4.6(picomatch@4.0.3)
fdir: 6.5.0(picomatch@4.0.3)
is-reference: 1.2.1
magic-string: 0.30.17
picomatch: 4.0.3
@@ -18312,7 +18421,7 @@ snapshots:
dependencies:
'@vue/compiler-sfc': 3.5.18
ast-kit: 2.1.1
local-pkg: 1.1.1
local-pkg: 1.1.2
magic-string-ast: 1.0.0
unplugin-utils: 0.2.4
optionalDependencies:
@@ -18343,7 +18452,7 @@ snapshots:
'@babel/types': 7.28.0
'@vue/babel-helper-vue-transform-on': 1.4.0
'@vue/babel-plugin-resolve-type': 1.4.0(@babel/core@7.28.0)
'@vue/shared': 3.5.18
'@vue/shared': 3.5.20
optionalDependencies:
'@babel/core': 7.28.0
transitivePeerDependencies:
@@ -18362,7 +18471,7 @@ snapshots:
'@vue/compiler-core@3.5.17':
dependencies:
'@babel/parser': 7.28.0
'@babel/parser': 7.28.3
'@vue/shared': 3.5.17
entities: 4.5.0
estree-walker: 2.0.2
@@ -18532,7 +18641,7 @@ snapshots:
'@volar/language-core': 2.4.22
'@vue/compiler-dom': 3.5.18
'@vue/compiler-vue2': 2.7.16
'@vue/shared': 3.5.18
'@vue/shared': 3.5.20
alien-signals: 1.0.13
minimatch: 9.0.5
muggle-string: 0.4.1
@@ -18545,7 +18654,7 @@ snapshots:
'@volar/language-core': 2.4.22
'@vue/compiler-dom': 3.5.18
'@vue/compiler-vue2': 2.7.16
'@vue/shared': 3.5.18
'@vue/shared': 3.5.20
alien-signals: 2.0.5
muggle-string: 0.4.1
path-browserify: 1.0.1
@@ -18783,6 +18892,14 @@ snapshots:
clean-stack: 2.2.0
indent-string: 4.0.0
ai@5.0.26(zod@3.25.76):
dependencies:
'@ai-sdk/gateway': 1.0.15(zod@3.25.76)
'@ai-sdk/provider': 2.0.0
'@ai-sdk/provider-utils': 3.0.7(zod@3.25.76)
'@opentelemetry/api': 1.9.0
zod: 3.25.76
ajv-errors@3.0.0(ajv@8.17.1):
dependencies:
ajv: 8.17.1
@@ -19209,8 +19326,8 @@ snapshots:
browserslist@4.25.1:
dependencies:
caniuse-lite: 1.0.30001727
electron-to-chromium: 1.5.191
caniuse-lite: 1.0.30001731
electron-to-chromium: 1.5.192
node-releases: 2.0.19
update-browserslist-db: 1.1.3(browserslist@4.25.1)
@@ -19269,7 +19386,7 @@ snapshots:
chokidar: 4.0.3
confbox: 0.2.2
defu: 6.1.4
dotenv: 16.5.0
dotenv: 16.6.1
exsolve: 1.0.7
giget: 2.0.0
jiti: 2.5.1
@@ -19293,7 +19410,7 @@ snapshots:
ohash: 2.0.11
pathe: 2.0.3
perfect-debounce: 1.0.0
pkg-types: 2.2.0
pkg-types: 2.3.0
rc9: 2.1.2
optionalDependencies:
magicast: 0.3.5
@@ -19379,6 +19496,8 @@ snapshots:
caniuse-lite@1.0.30001727: {}
caniuse-lite@1.0.30001731: {}
capital-case@1.0.4:
dependencies:
no-case: 3.0.4
@@ -19700,7 +19819,7 @@ snapshots:
constantinople@4.0.1:
dependencies:
'@babel/parser': 7.28.0
'@babel/parser': 7.28.3
'@babel/types': 7.28.0
content-disposition@0.5.4:
@@ -20329,12 +20448,10 @@ snapshots:
dotenv-expand@12.0.1:
dependencies:
dotenv: 16.5.0
dotenv: 16.6.1
dotenv@16.4.7: {}
dotenv@16.5.0: {}
dotenv@16.6.1: {}
dotenv@17.2.1: {}
@@ -20360,7 +20477,7 @@ snapshots:
ee-first@1.1.1: {}
electron-to-chromium@1.5.191: {}
electron-to-chromium@1.5.192: {}
embla-carousel-auto-height@8.6.0(embla-carousel@8.6.0):
dependencies:
@@ -20392,7 +20509,7 @@ snapshots:
embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0)
vue: 3.5.20(typescript@5.9.2)
embla-carousel-wheel-gestures@8.0.2(embla-carousel@8.6.0):
embla-carousel-wheel-gestures@8.1.0(embla-carousel@8.6.0):
dependencies:
embla-carousel: 8.6.0
wheel-gestures: 2.2.48
@@ -21145,6 +21262,8 @@ snapshots:
events@3.3.0: {}
eventsource-parser@3.0.5: {}
execa@5.1.1:
dependencies:
cross-spawn: 7.0.6
@@ -21396,14 +21515,6 @@ snapshots:
dependencies:
pend: 1.2.0
fdir@6.4.6(picomatch@4.0.2):
optionalDependencies:
picomatch: 4.0.2
fdir@6.4.6(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
@@ -21522,7 +21633,7 @@ snapshots:
magic-string: 0.30.17
pathe: 2.0.3
ufo: 1.6.1
unplugin: 2.3.5
unplugin: 2.3.8
transitivePeerDependencies:
- encoding
@@ -21577,6 +21688,15 @@ snapshots:
fraction.js@4.3.7: {}
framer-motion@12.23.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
motion-dom: 12.23.12
motion-utils: 12.23.6
tslib: 2.8.1
optionalDependencies:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
fresh@0.5.2: {}
fresh@2.0.0: {}
@@ -21993,6 +22113,8 @@ snapshots:
hex-to-rgba@2.0.1: {}
hey-listen@1.0.8: {}
highlight.js@11.11.1: {}
hoist-non-react-statics@3.3.2:
@@ -22141,7 +22263,7 @@ snapshots:
exsolve: 1.0.7
mocked-exports: 0.1.1
pathe: 2.0.3
unplugin: 2.3.5
unplugin: 2.3.8
unplugin-utils: 0.2.4
imurmurhash@0.1.4: {}
@@ -22671,6 +22793,8 @@ snapshots:
json-schema-traverse@1.0.0: {}
json-schema@0.4.0: {}
json-stable-stringify-without-jsonify@1.0.1: {}
json-stringify-safe@5.0.1: {}
@@ -23036,7 +23160,7 @@ snapshots:
regexp-tree: 0.1.27
type-level-regexp: 0.1.17
ufo: 1.6.1
unplugin: 2.3.5
unplugin: 2.3.8
magic-string-ast@1.0.0:
dependencies:
@@ -23227,6 +23351,24 @@ snapshots:
module-details-from-path@1.0.3: {}
motion-dom@12.23.12:
dependencies:
motion-utils: 12.23.6
motion-utils@12.23.6: {}
motion-v@1.7.0(@vueuse/core@13.8.0(vue@3.5.20(typescript@5.9.2)))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vue@3.5.20(typescript@5.9.2)):
dependencies:
'@vueuse/core': 13.8.0(vue@3.5.20(typescript@5.9.2))
framer-motion: 12.23.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
hey-listen: 1.0.8
motion-dom: 12.23.12
vue: 3.5.20(typescript@5.9.2)
transitivePeerDependencies:
- '@emotion/is-prop-valid'
- react
- react-dom
mri@1.2.0: {}
mrmime@2.0.1: {}
@@ -23473,7 +23615,7 @@ snapshots:
node-source-walk@7.0.1:
dependencies:
'@babel/parser': 7.28.0
'@babel/parser': 7.28.3
node-window-polyfill@1.0.4:
dependencies:
@@ -24166,6 +24308,8 @@ snapshots:
pify@3.0.0: {}
pify@6.1.0: {}
pinia@3.0.3(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2)):
dependencies:
'@vue/devtools-api': 7.7.2
@@ -25810,6 +25954,10 @@ snapshots:
dependencies:
tslib: 2.8.1
swrv@1.1.0(vue@3.5.20(typescript@5.9.2)):
dependencies:
vue: 3.5.20(typescript@5.9.2)
symbol-observable@1.2.0: {}
symbol-observable@4.0.0: {}
@@ -25930,8 +26078,8 @@ snapshots:
tinyglobby@0.2.14:
dependencies:
fdir: 6.4.6(picomatch@4.0.2)
picomatch: 4.0.2
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
tinypool@1.1.1: {}
@@ -26170,7 +26318,7 @@ snapshots:
acorn: 8.15.0
estree-walker: 3.0.3
magic-string: 0.30.17
unplugin: 2.3.5
unplugin: 2.3.8
undefsafe@2.0.5: {}
@@ -26216,16 +26364,16 @@ snapshots:
acorn: 8.15.0
escape-string-regexp: 5.0.0
estree-walker: 3.0.3
local-pkg: 1.1.1
local-pkg: 1.1.2
magic-string: 0.30.17
mlly: 1.7.4
pathe: 2.0.3
picomatch: 4.0.3
pkg-types: 2.2.0
pkg-types: 2.3.0
scule: 1.3.0
strip-literal: 3.0.0
tinyglobby: 0.2.14
unplugin: 2.3.5
unplugin: 2.3.8
unplugin-utils: 0.2.4
unimport@5.2.0:
@@ -26242,7 +26390,7 @@ snapshots:
scule: 1.3.0
strip-literal: 3.0.0
tinyglobby: 0.2.14
unplugin: 2.3.5
unplugin: 2.3.8
unplugin-utils: 0.2.4
union@0.5.0:
@@ -26261,11 +26409,11 @@ snapshots:
unplugin-auto-import@19.3.0(@nuxt/kit@4.0.3(magicast@0.3.5))(@vueuse/core@13.8.0(vue@3.5.20(typescript@5.9.2))):
dependencies:
local-pkg: 1.1.1
local-pkg: 1.1.2
magic-string: 0.30.17
picomatch: 4.0.3
unimport: 4.2.0
unplugin: 2.3.5
unplugin: 2.3.8
unplugin-utils: 0.2.4
optionalDependencies:
'@nuxt/kit': 4.0.3(magicast@0.3.5)
@@ -26294,11 +26442,11 @@ snapshots:
dependencies:
chokidar: 3.6.0
debug: 4.4.1(supports-color@5.5.0)
local-pkg: 1.1.1
local-pkg: 1.1.2
magic-string: 0.30.17
mlly: 1.7.4
tinyglobby: 0.2.14
unplugin: 2.3.5
unplugin: 2.3.8
unplugin-utils: 0.2.4
vue: 3.5.20(typescript@5.9.2)
optionalDependencies:
@@ -26323,7 +26471,7 @@ snapshots:
picomatch: 4.0.3
scule: 1.3.0
tinyglobby: 0.2.14
unplugin: 2.3.5
unplugin: 2.3.8
unplugin-utils: 0.2.4
yaml: 2.8.0
optionalDependencies:
@@ -27043,7 +27191,7 @@ snapshots:
with@7.0.2:
dependencies:
'@babel/parser': 7.28.0
'@babel/parser': 7.28.3
'@babel/types': 7.28.0
assert-never: 1.4.0
babel-walk: 3.0.0-canary-5

View File

@@ -1,4 +1,4 @@
VITE_ACCOUNT=http://localhost:5555
VITE_ACCOUNT=https://account.unraid.net
VITE_CONNECT=https://connect.myunraid.net
VITE_UNRAID_NET=https://unraid.ddev.site
VITE_OS_RELEASES="https://releases.unraid.net/os"
@@ -14,3 +14,5 @@ VITE_WEBGUI=http://localhost:3001
VITE_CSRF_TOKEN="0000000000000000"
# Flag for mocking a user session during development via an unsecure cookie
VITE_MOCK_USER_SESSION=true
# Get the license key from one of the devs
NUXT_UI_PRO_LICENSE=

View File

@@ -1,4 +0,0 @@
VITE_ACCOUNT=https://account.unraid.net
VITE_CONNECT=https://connect.myunraid.net
VITE_UNRAID_NET=https://unraid.net
VITE_CALLBACK_KEY=Uyv2o8e*FiQe8VeLekTqyX6Z*8XonB

3
web/.gitignore vendored
View File

@@ -1,2 +1,3 @@
.env.*
!.env.staging
!.env.production
!.env.example

View File

@@ -1,7 +1,13 @@
export default {
ui: {
colors: {
primary: 'primary',
},
primary: 'blue',
neutral: 'gray'
}
},
toaster: {
position: 'bottom-right' as const,
expand: true,
duration: 5000
}
};

View File

@@ -1,7 +1,6 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
import { NuxtLayout, NuxtPage, UApp } from '#components';
import { devConfig } from '~/helpers/env';
onMounted(() => {
@@ -28,9 +27,9 @@ onMounted(() => {
</script>
<template>
<UApp>
<div>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</UApp>
</div>
</template>

View File

@@ -8,6 +8,7 @@
@import 'tailwindcss/utilities.css';
@import 'tw-animate-css';
@import '../../@tailwind-shared/index.css';
@import '@nuxt/ui';
/* Scan unraid-ui package from linked directory for class usage */

62
web/auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,62 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const avatarGroupInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useAvatarGroup.js')['avatarGroupInjectionKey']
const defineLocale: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/defineLocale.js')['defineLocale']
const defineShortcuts: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.js')['defineShortcuts']
const extendLocale: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/defineLocale.js')['extendLocale']
const extractShortcuts: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.js')['extractShortcuts']
const fieldGroupInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFieldGroup.js')['fieldGroupInjectionKey']
const formBusInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formBusInjectionKey']
const formFieldInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formFieldInjectionKey']
const formInputsInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formInputsInjectionKey']
const formLoadingInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formLoadingInjectionKey']
const formOptionsInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formOptionsInjectionKey']
const inputIdInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['inputIdInjectionKey']
const kbdKeysMap: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.js')['kbdKeysMap']
const localeContextInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useLocale.js')['localeContextInjectionKey']
const portalTargetInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/usePortal.js')['portalTargetInjectionKey']
const useAppConfig: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/vue/composables/useAppConfig.js')['useAppConfig']
const useAvatarGroup: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useAvatarGroup.js')['useAvatarGroup']
const useComponentIcons: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useComponentIcons.js')['useComponentIcons']
const useContentSearch: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useContentSearch.js')['useContentSearch']
const useFieldGroup: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFieldGroup.js')['useFieldGroup']
const useFileUpload: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload.js')['useFileUpload']
const useFormField: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['useFormField']
const useKbd: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.js')['useKbd']
const useLocale: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useLocale.js')['useLocale']
const useOverlay: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useOverlay.js')['useOverlay']
const usePortal: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/usePortal.js')['usePortal']
const useResizable: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useResizable.js')['useResizable']
const useScrollspy: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useScrollspy.js')['useScrollspy']
const useToast: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useToast.js')['useToast']
}
// for type re-export
declare global {
// @ts-ignore
export type { ShortcutConfig, ShortcutsConfig, ShortcutsOptions } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.d'
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.d')
// @ts-ignore
export type { UseComponentIconsProps } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useComponentIcons.d'
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useComponentIcons.d')
// @ts-ignore
export type { UseFileUploadOptions } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload.d'
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload.d')
// @ts-ignore
export type { KbdKey, KbdKeySpecific } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.d'
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.d')
// @ts-ignore
export type { OverlayOptions, Overlay } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useOverlay.d'
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useOverlay.d')
// @ts-ignore
export type { UseResizableProps, UseResizableReturn } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useResizable.d'
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useResizable.d')
// @ts-ignore
export type { Toast } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useToast.d'
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useToast.d')
}

25
web/components.d.ts vendored Normal file
View File

@@ -0,0 +1,25 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
UBadge: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Badge.vue')['default']
UButton: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Button.vue')['default']
UCard: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Card.vue')['default']
UCheckbox: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Checkbox.vue')['default']
UDrawer: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Drawer.vue')['default']
UDropdownMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/DropdownMenu.vue')['default']
UFormField: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/FormField.vue')['default']
UIcon: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/vue/components/Icon.vue')['default']
UInput: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Input.vue')['default']
UNavigationMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/NavigationMenu.vue')['default']
USelectMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/SelectMenu.vue')['default']
USwitch: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Switch.vue')['default']
UTabs: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Tabs.vue')['default']
}
}

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { ref } from 'vue';
interface Props {
item: {
id: string;
label: string;
icon?: string;
badge?: string | number;
};
}
const props = defineProps<Props>();
const command = ref('');
const output = ref<string[]>([
`root@${props.item.id}:/# echo "Welcome to ${props.item.label}"`,
`Welcome to ${props.item.label}`,
`root@${props.item.id}:/#`,
]);
const executeCommand = () => {
if (command.value.trim()) {
output.value.push(`root@${props.item.id}:/# ${command.value}`);
output.value.push(`${command.value}: command executed`);
output.value.push(`root@${props.item.id}:/#`);
command.value = '';
}
};
</script>
<template>
<div class="space-y-4">
<div class="flex justify-between items-center sm:mx-4">
<h3 class="text-lg font-medium">Terminal</h3>
<div class="flex gap-2">
<UButton size="sm" color="primary" variant="outline" icon="i-lucide-maximize-2">
<span class="hidden sm:inline">Fullscreen</span>
</UButton>
<UButton size="sm" color="primary" variant="outline" icon="i-lucide-refresh-cw">
<span class="hidden sm:inline">Restart</span>
</UButton>
</div>
</div>
<div class="bg-black text-green-400 p-4 rounded-lg font-mono text-sm h-96 overflow-y-auto sm:mx-4">
<div v-for="(line, index) in output" :key="index">
{{ line }}
</div>
<div class="flex items-center">
<span>root@{{ item.id }}:/# </span>
<input
v-model="command"
class="bg-transparent outline-none flex-1 ml-1"
type="text"
@keyup.enter="executeCommand"
>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,126 @@
<script setup lang="ts">
import { ref } from 'vue';
interface Props {
item: {
id: string;
label: string;
icon?: string;
badge?: string | number;
};
}
const props = defineProps<Props>();
const config = ref({
name: props.item.label,
image: 'ghcr.io/imagegenius/immich:latest',
network: 'bridge',
restartPolicy: 'unless-stopped',
cpuLimit: '',
memoryLimit: '',
ports: [{ container: '7878', host: '7878', protocol: 'tcp' }],
volumes: [
{ container: '/config', host: '/mnt/user/appdata/immich' },
{ container: '/media', host: '/mnt/user/media' },
],
environment: [
{ key: 'PUID', value: '99' },
{ key: 'PGID', value: '100' },
],
});
</script>
<template>
<div class="space-y-6">
<div class="flex justify-between items-center sm:mx-4">
<h3 class="text-lg font-medium">Container Configuration</h3>
<div class="flex gap-2">
<UButton color="primary" variant="outline">Cancel</UButton>
<UButton color="primary">Save Changes</UButton>
</div>
</div>
<UCard class="sm:mx-4">
<template #header>
<h4 class="font-medium">Basic Settings</h4>
</template>
<div class="space-y-4">
<UFormField label="Container Name">
<UInput v-model="config.name" />
</UFormField>
<UFormField label="Image">
<UInput v-model="config.image" />
</UFormField>
<UFormField label="Network Mode">
<USelectMenu v-model="config.network" :options="['bridge', 'host', 'none', 'custom']" />
</UFormField>
<UFormField label="Restart Policy">
<USelectMenu
v-model="config.restartPolicy"
:options="['no', 'always', 'unless-stopped', 'on-failure']"
/>
</UFormField>
</div>
</UCard>
<UCard class="sm:mx-4">
<template #header>
<h4 class="font-medium">Resource Limits</h4>
</template>
<div class="grid grid-cols-2 gap-4">
<UFormField label="CPU Limit">
<UInput v-model="config.cpuLimit" placeholder="e.g., 0.5 or 2" />
</UFormField>
<UFormField label="Memory Limit">
<UInput v-model="config.memoryLimit" placeholder="e.g., 512m or 2g" />
</UFormField>
</div>
</UCard>
<UCard class="sm:mx-4">
<template #header>
<h4 class="font-medium">Port Mappings</h4>
</template>
<div class="space-y-2">
<div v-for="(port, index) in config.ports" :key="index" class="flex gap-2 items-center">
<UInput v-model="port.host" placeholder="Host Port" class="flex-1" />
<UIcon name="i-lucide-arrow-right" class="text-gray-400" />
<UInput v-model="port.container" placeholder="Container Port" class="flex-1" />
<USelectMenu v-model="port.protocol" :options="['tcp', 'udp']" class="w-24" />
<UButton icon="i-lucide-trash-2" color="primary" variant="ghost" size="sm" />
</div>
<UButton icon="i-lucide-plus" size="sm" variant="outline">Add Port</UButton>
</div>
</UCard>
<UCard class="sm:mx-4">
<template #header>
<h4 class="font-medium">Volume Mappings</h4>
</template>
<div class="space-y-2">
<div v-for="(volume, index) in config.volumes" :key="index" class="flex gap-2 items-center">
<UInput v-model="volume.host" placeholder="Host Path" class="flex-1" />
<UIcon name="i-lucide-arrow-right" class="text-gray-400" />
<UInput v-model="volume.container" placeholder="Container Path" class="flex-1" />
<UButton icon="i-lucide-trash-2" color="primary" variant="ghost" size="sm" />
</div>
<UButton icon="i-lucide-plus" size="sm" variant="outline">Add Volume</UButton>
</div>
</UCard>
<UCard>
<template #header>
<h4 class="font-medium">Environment Variables</h4>
</template>
<div class="space-y-2">
<div v-for="(env, index) in config.environment" :key="index" class="flex gap-2 items-center">
<UInput v-model="env.key" placeholder="Variable Name" class="flex-1" />
<UInput v-model="env.value" placeholder="Value" class="flex-1" />
<UButton icon="i-lucide-trash-2" color="primary" variant="ghost" size="sm" />
</div>
<UButton icon="i-lucide-plus" size="sm" variant="outline">Add Variable</UButton>
</div>
</UCard>
</div>
</template>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
interface Props {
autostartValue?: boolean;
showAutostart?: boolean;
manageActions?: Array<Array<{ label: string; icon: string; onClick?: () => void }>>;
}
const _props = withDefaults(defineProps<Props>(), {
autostartValue: true,
showAutostart: true,
manageActions: () => [
[
{ label: 'Start', icon: 'i-lucide-play' },
{ label: 'Stop', icon: 'i-lucide-square' },
{ label: 'Pause', icon: 'i-lucide-pause' },
{ label: 'Restart', icon: 'i-lucide-refresh-cw' },
],
[
{ label: 'Update', icon: 'i-lucide-download' },
{ label: 'Force Update', icon: 'i-lucide-download-cloud' },
{ label: 'Remove', icon: 'i-lucide-trash-2' },
],
[
{ label: 'Docker Allocations', icon: 'i-lucide-hard-drive' },
],
[
{ label: 'Project Page', icon: 'i-lucide-external-link' },
{ label: 'Support', icon: 'i-lucide-help-circle' },
{ label: 'More Info', icon: 'i-lucide-info' },
],
],
});
const emit = defineEmits<{
'update:autostart': [value: boolean];
manageAction: [action: string];
}>();
const handleManageAction = (action: { label: string; icon: string }) => {
emit('manageAction', action.label);
};
</script>
<template>
<div class="flex items-center gap-3 sm:gap-6">
<div v-if="showAutostart" class="flex items-center gap-2 sm:gap-3">
<span class="text-xs sm:text-sm font-medium">Autostart</span>
<USwitch :model-value="autostartValue" @update:model-value="$emit('update:autostart', $event)" />
</div>
<UDropdownMenu
:items="manageActions.map(group => group.map(action => ({
...action,
onSelect: () => handleManageAction(action)
})))"
size="md"
>
<UButton variant="subtle" color="primary" size="sm" trailing-icon="i-lucide-chevron-down">
Manage
</UButton>
</UDropdownMenu>
</div>
</template>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
interface Props {
item: {
id: string;
label: string;
icon?: string;
badge?: string | number;
};
}
const props = defineProps<Props>();
const sampleLogs = [
{ timestamp: '2024-01-22 10:15:23', message: `Starting ${props.item.label}...` },
{ timestamp: '2024-01-22 10:15:24', message: 'Container initialized successfully' },
{ timestamp: '2024-01-22 10:15:25', message: 'Listening on configured port' },
{ timestamp: '2024-01-22 10:15:26', message: 'Health check passed' },
{ timestamp: '2024-01-22 10:15:27', message: 'Ready to accept connections' },
];
</script>
<template>
<div class="space-y-2">
<div class="flex justify-between items-center sm:mx-4">
<h3 class="text-lg font-medium">Container Logs</h3>
<UButton size="sm" color="primary" variant="outline" icon="i-lucide-download"> Export </UButton>
</div>
<div class="bg-gray-900 text-gray-100 p-4 rounded-lg font-mono sm:mx-4 text-sm overflow-x-auto">
<div v-for="(log, index) in sampleLogs" :key="index" class="whitespace-nowrap">
<span class="text-gray-500">[{{ log.timestamp }}]</span> {{ log.message }}
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
interface ContainerDetails {
network: string;
lanIpPort: string;
containerIp: string;
uptime: string;
containerPort: string;
creationDate: string;
containerId: string;
maintainer: string;
}
interface Props {
item: {
id: string;
label: string;
icon?: string;
badge?: string | number;
};
details?: ContainerDetails;
}
defineProps<Props>();
</script>
<template>
<div v-if="details" class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:mx-4">
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Network:</p>
<p class="mt-1">{{ details.network }}</p>
</div>
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">LAN IP:Port</p>
<p class="mt-1">{{ details.lanIpPort }}</p>
</div>
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Container IP:</p>
<p class="mt-1">{{ details.containerIp }}</p>
</div>
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Uptime:</p>
<p class="mt-1">{{ details.uptime }}</p>
</div>
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Container Port:</p>
<p class="mt-1">{{ details.containerPort }}</p>
</div>
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Creation Date:</p>
<p class="mt-1">{{ details.creationDate }}</p>
</div>
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Container Id:</p>
<p class="mt-1 font-mono text-sm">{{ details.containerId }}</p>
</div>
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Maintainer:</p>
<p class="mt-1 text-sm">{{ details.maintainer }}</p>
</div>
</div>
</div>
<div v-else class="text-gray-500 dark:text-gray-400 sm:mx-4">
No details available for {{ item.label }}
</div>
</template>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
interface Props {
item: {
id: string;
label: string;
icon?: string;
badge?: string | number;
};
port?: string;
}
const props = defineProps<Props>();
const previewUrl = props.port ? `http://localhost:${props.port}` : null;
</script>
<template>
<div class="space-y-4">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-2 sm:mx-4">
<h3 class="text-lg font-medium">Web Preview</h3>
<div class="flex gap-2">
<UButton
v-if="previewUrl"
size="sm"
color="primary"
variant="outline"
icon="i-lucide-external-link"
:to="previewUrl"
target="_blank"
>
<span class="hidden sm:inline">Open in new tab</span>
</UButton>
<UButton size="sm" color="primary" variant="outline" icon="i-lucide-refresh-cw">
<span class="hidden sm:inline">Refresh</span>
</UButton>
</div>
</div>
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden sm:mx-4">
<div v-if="previewUrl" class="bg-gray-100 dark:bg-gray-800 px-4 py-2 flex items-center gap-2">
<UIcon name="i-lucide-lock" class="w-4 h-4 text-gray-500" />
<span class="text-sm text-gray-600 dark:text-gray-400">{{ previewUrl }}</span>
</div>
<div class="p-8 text-center h-96 flex items-center justify-center">
<div v-if="previewUrl" class="text-gray-500 dark:text-gray-400">
<UIcon name="i-lucide-globe" class="w-16 h-16 mx-auto mb-4" />
<p>Web interface preview for {{ item.label }}</p>
<p class="text-sm mt-2">Container must be running and accessible on port {{ port }}</p>
</div>
<div v-else class="text-gray-500 dark:text-gray-400">
<UIcon name="i-lucide-alert-circle" class="w-16 h-16 mx-auto mb-4" />
<p>No web interface available for {{ item.label }}</p>
<p class="text-sm mt-2">This container does not expose a web interface</p>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,191 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import type { Component } from 'vue';
import CardGrid from './CardGrid.vue';
import CardHeader from './CardHeader.vue';
export interface Item {
id: string;
label: string;
icon?: string;
badge?: string | number;
slot?: string;
status?: {
label: string;
dotColor: string;
}[];
children?: Item[];
isGroup?: boolean;
}
export interface TabItem {
key: string;
label: string;
component?: Component;
props?: Record<string, unknown>;
disabled?: boolean;
}
interface Props {
items?: Item[];
tabs?: TabItem[];
defaultItemId?: string;
defaultTabKey?: string;
navigationLabel?: string;
showFilter?: boolean;
showGrouping?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
items: () => [],
tabs: () => [],
defaultItemId: undefined,
defaultTabKey: undefined,
navigationLabel: 'Docker Overview',
showFilter: true,
showGrouping: true,
});
const selectedItemId = ref(props.defaultItemId || props.items[0]?.id || '');
const selectedItems = ref<string[]>([]);
const expandedGroups = ref<Record<string, boolean>>({});
const filterQuery = ref('');
const groupBy = ref<string>('none');
const autostartStates = ref<Record<string, boolean>>({});
const runningStates = ref<Record<string, boolean>>({});
// Initialize expanded state for groups
const initializeExpandedState = () => {
props.items.forEach((item) => {
if (item.isGroup) {
expandedGroups.value[item.id] = true;
}
});
};
initializeExpandedState();
watch(
() => props.items,
() => {
initializeExpandedState();
},
{ deep: true }
);
const filteredItems = computed(() => {
if (!filterQuery.value) return props.items;
const query = filterQuery.value.toLowerCase();
return props.items.filter((item) => {
const matchesItem = item.label.toLowerCase().includes(query);
const matchesChildren = item.children?.some((child) => child.label.toLowerCase().includes(query));
return matchesItem || matchesChildren;
});
});
const groupedItems = computed(() => {
if (groupBy.value === 'none') {
return filteredItems.value;
}
// For now, return items as-is since grouping logic depends on data structure
return filteredItems.value;
});
// Reusable function to collect all selectable items
const collectSelectableItems = (items: Item[]): string[] => {
const selectableItems: string[] = [];
const collect = (items: Item[]) => {
for (const item of items) {
if (!item.isGroup) {
selectableItems.push(item.id);
}
if (item.children) {
collect(item.children);
}
}
};
collect(items);
return selectableItems;
};
const selectAllItems = () => {
selectedItems.value = [...collectSelectableItems(props.items)];
};
const clearAllSelections = () => {
selectedItems.value = [];
};
const handleAddAction = () => {
console.log('Add action triggered');
};
const handleManageSelectedAction = (action: string) => {
console.log('Manage selected action:', action);
};
const handleItemSelect = (itemId: string) => {
selectedItemId.value = itemId;
};
const handleItemsSelectionUpdate = (items: string[]) => {
selectedItems.value = items;
};
const handleAutostartUpdate = (itemId: string, value: boolean) => {
console.log('Autostart update for item:', itemId, 'value:', value);
autostartStates.value[itemId] = value;
};
const handleToggleRunning = (itemId: string) => {
// TODO: Wire up to actual docker/VM start/stop API
const currentState = runningStates.value[itemId] || false;
runningStates.value[itemId] = !currentState;
console.log('Toggle running for item:', itemId, 'new state:', !currentState);
};
</script>
<template>
<div class="flex flex-col h-full">
<!-- Header -->
<CardHeader
:title="navigationLabel"
:filter-query="filterQuery"
:group-by="groupBy"
:selected-items="selectedItems"
:show-filter="showFilter"
:show-grouping="showGrouping"
@update:filter-query="filterQuery = $event"
@update:group-by="groupBy = $event"
@add="handleAddAction"
@select-all="selectAllItems"
@clear-all="clearAllSelections"
@manage-action="handleManageSelectedAction"
/>
<!-- Card Grid -->
<div class="flex-1 overflow-auto w-full">
<CardGrid
:items="groupedItems"
:selected-items="selectedItems"
:selected-item-id="selectedItemId"
:expanded-groups="expandedGroups"
:autostart-states="autostartStates"
:running-states="runningStates"
@update:selected-items="handleItemsSelectionUpdate"
@update:expanded-groups="expandedGroups = $event"
@item-select="handleItemSelect"
@update:autostart="handleAutostartUpdate"
@toggle-running="handleToggleRunning"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { Item } from './Card.vue';
import CardGroupHeader from './CardGroupHeader.vue';
import CardItem from './CardItem.vue';
interface Props {
items: Item[];
selectedItems: string[];
selectedItemId?: string;
expandedGroups: Record<string, boolean>;
autostartStates: Record<string, boolean>;
runningStates: Record<string, boolean>;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:selectedItems': [items: string[]];
'item-select': [itemId: string];
'update:expandedGroups': [groups: Record<string, boolean>];
'update:autostart': [itemId: string, value: boolean];
'toggle-running': [itemId: string];
}>();
const flattenedItems = computed(() => {
const result: Array<Item & { isGroupChild?: boolean; parentGroup?: string }> = [];
for (const item of props.items) {
if (item.isGroup && item.children) {
// Add group header
result.push(item);
// Add children only if group is expanded
if (props.expandedGroups[item.id]) {
for (const child of item.children) {
result.push({
...child,
isGroupChild: true,
parentGroup: item.id,
});
}
}
} else {
result.push(item);
}
}
return result;
});
const toggleItemSelection = (itemId: string) => {
const newItems = [...props.selectedItems];
const index = newItems.indexOf(itemId);
if (index > -1) {
newItems.splice(index, 1);
} else {
newItems.push(itemId);
}
emit('update:selectedItems', newItems);
};
const isItemSelected = (itemId: string) => {
return props.selectedItems.includes(itemId);
};
const handleItemClick = (itemId: string) => {
emit('item-select', itemId);
};
const toggleGroupExpansion = (groupId: string) => {
const newGroups = { ...props.expandedGroups };
newGroups[groupId] = !newGroups[groupId];
emit('update:expandedGroups', newGroups);
};
const handleAutostartUpdate = (itemId: string, value: boolean) => {
emit('update:autostart', itemId, value);
};
const handleToggleRunning = (itemId: string) => {
emit('toggle-running', itemId);
};
</script>
<template>
<div class="p-6 w-full">
<div class="space-y-4 w-full max-w-full">
<template v-for="item in flattenedItems" :key="item.id">
<!-- Group Header -->
<CardGroupHeader
v-if="item.isGroup"
:label="item.label"
:icon="item.icon"
:badge="item.badge"
:is-expanded="expandedGroups[item.id]"
@toggle="toggleGroupExpansion(item.id)"
/>
<!-- Regular Card Item -->
<CardItem
v-else
:item="item"
:is-selected="isItemSelected(item.id)"
:is-active="selectedItemId === item.id"
:is-group-child="item.isGroupChild"
:autostart-value="autostartStates[item.id] || false"
:is-running="runningStates[item.id] || false"
@toggle-selection="toggleItemSelection"
@click="handleItemClick"
@update:autostart="handleAutostartUpdate(item.id, $event)"
@toggle-running="handleToggleRunning"
/>
</template>
</div>
</div>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
interface Props {
label: string;
icon?: string;
badge?: string | number;
isExpanded?: boolean;
}
defineProps<Props>();
</script>
<template>
<UCard
class="w-full cursor-pointer transition-all duration-200 hover:shadow-md"
@click="$emit('toggle')"
>
<div class="flex items-center gap-4 py-2">
<!-- Icon -->
<div class="flex-shrink-0 pl-2">
<UIcon v-if="icon" :name="icon" class="h-8 w-8" />
<div v-else class="h-8 w-8 rounded flex items-center justify-center">
<UIcon name="i-lucide-folder" class="h-5 w-5 text-gray-500" />
</div>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h2 class="text-lg font-semibold truncate">
{{ label }}
</h2>
<UBadge v-if="badge" size="sm" :label="String(badge)" variant="subtle" />
<!-- Edit icon -->
<UIcon
name="i-lucide-pencil"
class="h-4 w-4 text-gray-400 hover:text-gray-600 transition-colors"
/>
</div>
</div>
<!-- Expansion Arrow on right side -->
<div class="flex-shrink-0 pr-2">
<UIcon
name="i-lucide-chevron-right"
:class="[
'h-5 w-5 text-gray-500 transform transition-transform duration-200',
isExpanded ? 'rotate-90' : 'rotate-0',
]"
/>
</div>
</div>
</UCard>
</template>

View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
title?: string;
filterQuery?: string;
groupBy?: string;
selectedItems?: string[];
showFilter?: boolean;
showGrouping?: boolean;
manageActions?: Array<Array<{ label: string; icon: string; onClick?: () => void }>>;
}
const props = withDefaults(defineProps<Props>(), {
title: 'Docker Overview',
filterQuery: '',
groupBy: 'none',
selectedItems: () => [],
showFilter: true,
showGrouping: true,
manageActions: () => [
[
{ label: 'Sort Alpha Asc', icon: 'i-lucide-arrow-up-a-z' },
{ label: 'Sort Alpha Dec', icon: 'i-lucide-arrow-down-z-a' },
],
[
{ label: 'Start Selected', icon: 'i-lucide-play' },
{ label: 'Stop Selected', icon: 'i-lucide-square' },
{ label: 'Pause Selected', icon: 'i-lucide-pause' },
{ label: 'Restart Selected', icon: 'i-lucide-refresh-cw' },
{ label: 'Autostart Selected', icon: 'i-lucide-timer' },
],
[
{ label: 'Check for Updates', icon: 'i-lucide-refresh-ccw' },
{ label: 'Update Selected', icon: 'i-lucide-download' },
{ label: 'Remove Selected', icon: 'i-lucide-trash-2' },
],
[{ label: 'Add Container', icon: 'i-lucide-plus' }],
],
});
const emit = defineEmits<{
'update:filterQuery': [query: string];
'update:groupBy': [groupBy: string];
add: [];
selectAll: [];
clearAll: [];
manageAction: [action: string];
}>();
const selectedCount = computed(() => props.selectedItems?.length || 0);
const handleManageAction = (action: { label: string; icon: string }) => {
emit('manageAction', action.label);
};
const dropdownItems = computed(() =>
props.manageActions.map((group) =>
group.map((action) => ({
label: action.label,
icon: action.icon,
onSelect: () => handleManageAction(action),
}))
)
);
</script>
<template>
<div class="border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950">
<div class="p-4 space-y-4">
<!-- Title Row -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ title }}</h1>
</div>
<!-- Controls Row -->
<div class="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<!-- Left Side: Filter and Configure View -->
<div class="flex items-center gap-2">
<!-- Filter Input with Configure View -->
<div v-if="showFilter" class="flex items-center gap-2 max-w-sm">
<UInput
:model-value="filterQuery"
placeholder="Filter"
icon="i-lucide-search"
size="md"
class="flex-1"
@update:model-value="$emit('update:filterQuery', $event)"
/>
<!-- Configure View Button -->
<UButton
v-if="showGrouping"
color="primary"
variant="outline"
>
Configure View
</UButton>
</div>
</div>
<!-- Right Side: All action buttons -->
<div class="flex items-center gap-3">
<!-- Select All / Clear All -->
<UButton
variant="link"
color="primary"
size="sm"
@click="selectedCount > 0 ? $emit('clearAll') : $emit('selectAll')"
>
{{ selectedCount > 0 ? 'Clear all' : 'Select all' }}
</UButton>
<!-- Manage Selected Dropdown -->
<UDropdownMenu :items="dropdownItems" size="md">
<UButton
variant="outline"
color="primary"
trailing-icon="i-lucide-chevron-down"
:disabled="selectedCount === 0"
>
Manage Selected ({{ selectedCount }})
</UButton>
</UDropdownMenu>
<!-- Add Folder Button -->
<UButton icon="i-lucide-plus" color="primary" variant="solid" @click="$emit('add')">
Add Folder
</UButton>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,136 @@
<script setup lang="ts">
import type { Item } from './Card.vue';
interface Props {
item: Item;
isSelected: boolean;
isActive?: boolean;
isGroupChild?: boolean;
autostartValue?: boolean;
isRunning?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
autostartValue: false,
isRunning: false,
});
const emit = defineEmits<{
toggleSelection: [itemId: string];
click: [itemId: string];
'update:autostart': [value: boolean];
toggleRunning: [itemId: string];
}>();
const handleCardClick = () => {
emit('click', props.item.id);
};
const handleCheckboxClick = (_value: boolean | 'indeterminate') => {
emit('toggleSelection', props.item.id);
};
const handleToggleRunning = () => {
// TODO: Wire up to actual start/stop functionality for docker containers and VMs
console.log('Toggle running state for:', props.item.id, 'Current state:', props.isRunning);
emit('toggleRunning', props.item.id);
};
</script>
<template>
<div :class="isGroupChild ? 'ml-4' : ''">
<UCard
:class="[
'w-full cursor-pointer transition-all duration-200 hover:shadow-md group',
isActive ? 'ring-2 ring-primary-500' : '',
]"
@click="handleCardClick"
>
<div class="flex items-center gap-4 py-2">
<!-- Selection Checkbox -->
<div class="flex-shrink-0 pl-2 flex items-center">
<UCheckbox :model-value="isSelected" @update:model-value="handleCheckboxClick" @click.stop />
</div>
<!-- Icon -->
<div class="flex-shrink-0 flex items-center">
<UIcon v-if="item.icon" :name="item.icon" class="h-8 w-8" />
<div v-else class="h-8 w-8 rounded flex items-center justify-center">
<UIcon name="i-lucide-box" class="h-5 w-5 text-gray-500" />
</div>
</div>
<!-- Play/Stop Button -->
<div class="flex-shrink-0 flex items-center">
<button
class="p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors flex items-center justify-center"
:aria-label="isRunning ? 'Stop' : 'Start'"
@click.stop="handleToggleRunning"
>
<svg
v-if="!isRunning"
class="w-4 h-4 fill-green-500 hover:fill-green-600"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M8 5v14l11-7z" />
</svg>
<svg
v-else
class="w-4 h-4 fill-red-500 hover:fill-red-600"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="6" y="6" width="12" height="12" />
</svg>
</button>
</div>
<!-- Content -->
<div class="flex-1 min-w-0 flex items-center">
<div class="flex items-center gap-2 w-full">
<h3 class="font-semibold truncate">
{{ item.label }}
</h3>
<UBadge v-if="item.badge" size="xs" :label="String(item.badge)" />
<div v-if="item.status && item.status.length > 0" class="flex flex-wrap gap-2 ml-4">
<UBadge
v-for="(statusItem, index) in item.status"
:key="index"
variant="subtle"
color="neutral"
size="sm"
class="flex items-center"
>
<div :class="['h-2 w-2 rounded-full mr-2', statusItem.dotColor]" />
{{ statusItem.label }}
</UBadge>
<div class="text-sm ml-4">Uptime: 10 hours</div>
</div>
</div>
</div>
<!-- Right side content -->
<div class="flex items-center gap-4 flex-shrink-0 pr-2">
<!-- Action Buttons - only visible on hover -->
<div class="hidden group-hover:flex items-center gap-2">
<UButton color="primary" variant="outline" size="sm" @click.stop> Manage </UButton>
<UButton color="primary" variant="solid" size="sm" @click.stop> Visit </UButton>
</div>
<!-- Autostart Toggle - only visible on hover -->
<div class="hidden group-hover:flex items-center gap-2">
<span class="text-sm font-medium">Autostart</span>
<USwitch
:model-value="autostartValue"
@update:model-value="$emit('update:autostart', $event)"
@click.stop
/>
</div>
</div>
</div>
</UCard>
</div>
</template>

View File

@@ -0,0 +1,185 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import type { Component } from 'vue';
import HeaderContent from '../../Docker/HeaderContent.vue';
import DetailContentHeader from './DetailContentHeader.vue';
import DetailLeftNavigation from './DetailLeftNavigation.vue';
import DetailRightContent from './DetailRightContent.vue';
export interface Item {
id: string;
label: string;
icon?: string;
badge?: string | number;
slot?: string;
status?: {
label: string;
dotColor: string;
}[];
children?: Item[];
isGroup?: boolean;
}
export interface TabItem {
key: string;
label: string;
component?: Component;
props?: Record<string, unknown>;
disabled?: boolean;
}
interface Props {
items?: Item[];
tabs?: TabItem[];
defaultItemId?: string;
defaultTabKey?: string;
navigationLabel?: string;
}
const props = withDefaults(defineProps<Props>(), {
items: () => [],
tabs: () => [],
defaultItemId: undefined,
defaultTabKey: undefined,
navigationLabel: 'Select Item',
});
const selectedItemId = ref(props.defaultItemId || props.items[0]?.id || '');
const selectedTab = ref(props.defaultTabKey || '0');
const selectedItems = ref<string[]>([]);
const expandedGroups = ref<Record<string, boolean>>({});
const autostartEnabled = ref(true);
// Initialize expanded state for groups
const initializeExpandedState = () => {
props.items.forEach((item) => {
if (item.isGroup) {
expandedGroups.value[item.id] = true;
}
});
};
initializeExpandedState();
watch(
() => props.items,
() => {
initializeExpandedState();
},
{ deep: true }
);
const selectedItem = computed(() => {
const topLevel = props.items.find((item) => item.id === selectedItemId.value);
if (topLevel) return topLevel;
for (const item of props.items) {
if (item.children) {
const nested = item.children.find((child) => child.id === selectedItemId.value);
if (nested) return nested;
}
}
return undefined;
});
// Reusable function to collect all selectable items
const collectSelectableItems = (items: Item[]): string[] => {
const selectableItems: string[] = [];
const collect = (items: Item[]) => {
for (const item of items) {
if (!item.isGroup) {
selectableItems.push(item.id);
}
if (item.children) {
collect(item.children);
}
}
};
collect(items);
return selectableItems;
};
const selectAllItems = () => {
selectedItems.value = [...collectSelectableItems(props.items)];
};
const clearAllSelections = () => {
selectedItems.value = [];
};
const handleAddAction = () => {
console.log('Add action triggered');
};
const handleManageSelectedAction = (action: string) => {
console.log('Manage selected action:', action);
};
const handleManageItemAction = (action: string) => {
console.log('Manage item action:', action);
};
</script>
<template>
<div class="flex flex-col p-4 lg:flex-row h-full">
<!-- Navigation -->
<DetailLeftNavigation
:items="items"
:selected-id="selectedItemId"
:selected-items="selectedItems"
:expanded-groups="expandedGroups"
:navigation-label="navigationLabel"
@update:selected-id="selectedItemId = $event"
@update:selected-items="selectedItems = $event"
@update:expanded-groups="expandedGroups = $event"
@add="handleAddAction"
@select-all="selectAllItems"
@clear-all="clearAllSelections"
@manage-action="handleManageSelectedAction"
/>
<!-- Main Content -->
<div class="flex-1 min-w-0">
<DetailRightContent
:selected-item="selectedItem"
:tabs="tabs"
:selected-tab="selectedTab"
@update:selected-tab="selectedTab = $event"
>
<template #header="{ item }">
<DetailContentHeader :icon="item.icon" :title="item.label">
<template #right-content>
<template v-if="item.status && item.status.length > 0">
<UBadge
v-for="(statusItem, index) in item.status"
:key="index"
variant="subtle"
color="neutral"
size="sm"
>
<div :class="['h-2 w-2 rounded-full mr-2', statusItem.dotColor]" />
{{ statusItem.label }}
</UBadge>
</template>
</template>
<template #controls>
<HeaderContent
:autostart-value="autostartEnabled"
@update:autostart="autostartEnabled = $event"
@manage-action="handleManageItemAction"
/>
</template>
</DetailContentHeader>
</template>
</DetailRightContent>
</div>
</div>
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
interface Props {
icon?: string;
title: string;
}
const _props = defineProps<Props>();
</script>
<template>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 sm:gap-6">
<div class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 min-w-0">
<div class="flex items-center gap-3 mb-2 sm:gap-4 lg:mb-0">
<UIcon v-if="icon" :name="icon" class="h-8 w-8 flex-shrink-0" />
<h1 class="text-2xl font-semibold truncate leading-none">
{{ title }}
</h1>
</div>
<div class="flex items-center gap-2 flex-wrap">
<slot name="right-content" />
</div>
</div>
<div class="flex items-center flex-shrink-0">
<slot name="controls" />
</div>
</div>
</template>

View File

@@ -0,0 +1,357 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { Item } from './Detail.vue';
interface Props {
title?: string;
items: Item[];
selectedId: string;
selectedItems: string[];
expandedGroups: Record<string, boolean>;
showHeader?: boolean;
manageActions?: Array<Array<{ label: string; icon: string; onClick?: () => void }>>;
navigationLabel?: string;
}
const props = withDefaults(defineProps<Props>(), {
title: 'Service Name',
showHeader: true,
manageActions: () => [
[
{ label: 'Sort Alpha Asc', icon: 'i-lucide-arrow-up-a-z' },
{ label: 'Sort Alpha Dec', icon: 'i-lucide-arrow-down-z-a' },
],
[
{ label: 'Start Selected', icon: 'i-lucide-play' },
{ label: 'Stop Selected', icon: 'i-lucide-square' },
{ label: 'Pause Selected', icon: 'i-lucide-pause' },
{ label: 'Restart Selected', icon: 'i-lucide-refresh-cw' },
{ label: 'Autostart Selected', icon: 'i-lucide-timer' },
],
[
{ label: 'Check for Updates', icon: 'i-lucide-refresh-ccw' },
{ label: 'Update Selected', icon: 'i-lucide-download' },
{ label: 'Remove Selected', icon: 'i-lucide-trash-2' },
],
[{ label: 'Add Container', icon: 'i-lucide-plus' }],
],
navigationLabel: 'Select Item',
});
const emit = defineEmits<{
'update:selectedId': [id: string];
'update:selectedItems': [items: string[]];
'update:expandedGroups': [groups: Record<string, boolean>];
add: [];
selectAll: [];
clearAll: [];
manageAction: [action: string];
}>();
// Internal drawer state for mobile
const sidebarOpen = ref(false);
const toggleSidebar = () => {
sidebarOpen.value = !sidebarOpen.value;
};
const navigationMenuItems = computed(() =>
props.items.map((item) => ({
label: item.label,
icon: item.icon,
id: item.id,
badge: String(item.badge || ''),
slot: item.slot,
...(item.isGroup ? {} : { onClick: () => selectNavigationItem(item.id) }),
isGroup: item.isGroup,
status: item.status,
...(item.isGroup ? {} : { to: '#' }),
defaultOpen: item.isGroup ? true : undefined,
children: item.children?.map((child) => ({
label: child.label,
icon: child.icon,
id: child.id,
badge: String(child.badge || ''),
slot: child.slot,
onClick: () => selectNavigationItem(child.id),
status: child.status,
to: '#',
})),
}))
);
interface NavigationMenuItem {
id: string;
label: string;
icon?: string;
badge: string;
slot?: string;
onClick?: () => void;
isGroup?: boolean;
status?: {
label: string;
dotColor: string;
}[];
children?: NavigationMenuItem[];
to?: string;
defaultOpen?: boolean;
}
const allItemsWithSlots = computed(() => {
const items: NavigationMenuItem[] = [];
const collectItems = (navItems: NavigationMenuItem[]) => {
for (const item of navItems) {
if (item.slot) {
items.push(item);
}
if (item.children) {
collectItems(item.children);
}
}
};
collectItems(navigationMenuItems.value);
return items;
});
const allItemsSelected = computed(() => {
const allSelectableItems: string[] = [];
const collectSelectableItems = (items: Item[]) => {
for (const item of items) {
if (!item.isGroup) {
allSelectableItems.push(item.id);
}
if (item.children) {
collectSelectableItems(item.children);
}
}
};
collectSelectableItems(props.items);
return (
allSelectableItems.length > 0 && allSelectableItems.every((id) => props.selectedItems.includes(id))
);
});
const selectedItemsCount = computed(() => props.selectedItems.length);
const selectNavigationItem = (id: string) => {
const actualItem =
props.items.find((item) => item.id === id) ||
props.items.flatMap((item) => item.children || []).find((child) => child.id === id);
if (actualItem && !actualItem.isGroup) {
emit('update:selectedId', id);
sidebarOpen.value = false; // Close drawer on mobile when item is selected
}
};
const toggleItemSelection = (itemId: string) => {
const newItems = [...props.selectedItems];
const index = newItems.indexOf(itemId);
if (index > -1) {
newItems.splice(index, 1);
} else {
newItems.push(itemId);
}
emit('update:selectedItems', newItems);
};
const isItemSelected = (itemId: string) => {
return props.selectedItems.includes(itemId);
};
const toggleGroupExpansion = (groupId: string) => {
const newGroups = { ...props.expandedGroups };
newGroups[groupId] = !newGroups[groupId];
emit('update:expandedGroups', newGroups);
};
const handleManageAction = (action: { label: string; icon: string }) => {
emit('manageAction', action.label);
};
const dropdownItems = computed(() =>
props.manageActions.map((group) =>
group.map((action) => ({
label: action.label,
icon: action.icon,
onSelect: () => handleManageAction(action),
}))
)
);
</script>
<template>
<div>
<!-- Desktop navigation -->
<div class="hidden lg:block lg:mr-16">
<div class="h-full overflow-y-auto overflow-x-hidden">
<!-- Navigation Header -->
<div v-if="showHeader" class="mb-6">
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold">{{ title }}</h2>
<UButton
icon="i-lucide-plus"
size="sm"
color="primary"
variant="ghost"
square
@click="$emit('add')"
/>
</div>
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<UButton
variant="link"
color="primary"
size="sm"
:label="allItemsSelected ? 'Clear all' : 'Select all'"
@click="allItemsSelected ? $emit('clearAll') : $emit('selectAll')"
/>
<UDropdownMenu :items="dropdownItems" size="md">
<UButton
variant="subtle"
color="primary"
size="sm"
trailing-icon="i-lucide-chevron-down"
:disabled="selectedItemsCount === 0"
class="w-full sm:w-auto"
>
<span class="sm:hidden">Manage ({{ selectedItemsCount }})</span>
<span class="hidden sm:inline">Manage Selected ({{ selectedItemsCount }})</span>
</UButton>
</UDropdownMenu>
</div>
</div>
<!-- Navigation Menu -->
<UNavigationMenu :items="navigationMenuItems" orientation="vertical">
<template v-for="item in allItemsWithSlots" :key="`slot-${item.id}`" #[item.slot!]>
<div
class="flex items-center gap-3 mb-2 min-w-0"
@click="
item.children && item.children.length > 0 ? toggleGroupExpansion(item.id) : undefined
"
>
<UCheckbox
:model-value="isItemSelected(item.id)"
class="flex-shrink-0"
@update:model-value="toggleItemSelection(item.id)"
@click.stop
/>
<UIcon v-if="item.icon" :name="item.icon" class="h-5 w-5 flex-shrink-0" />
<span class="truncate flex-1 min-w-0">{{ item.label }}</span>
<UBadge v-if="item.badge" size="xs" :label="String(item.badge)" class="flex-shrink-0" />
<UIcon
v-if="item.children?.length"
name="i-lucide-chevron-down"
:class="[
'h-5 w-5 text-gray-400 transition-transform duration-200 flex-shrink-0',
expandedGroups[item.id] ? 'rotate-180' : 'rotate-0',
]"
/>
</div>
</template>
</UNavigationMenu>
</div>
</div>
<!-- Mobile UDrawer -->
<div class="lg:hidden">
<div class="m-4">
<UButton color="primary" size="md" class="w-full justify-center" @click="toggleSidebar">
{{ navigationLabel }}
</UButton>
</div>
<UDrawer v-model:open="sidebarOpen" direction="left" size="md">
<template #content>
<div class="h-full overflow-y-auto overflow-x-hidden p-4">
<!-- Navigation Header -->
<div v-if="showHeader" class="mb-6">
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold">{{ title }}</h2>
<UButton
icon="i-lucide-plus"
size="sm"
color="primary"
variant="ghost"
square
@click="$emit('add')"
/>
</div>
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<UButton
variant="link"
color="primary"
size="sm"
:label="allItemsSelected ? 'Clear all' : 'Select all'"
@click="allItemsSelected ? $emit('clearAll') : $emit('selectAll')"
/>
<UDropdownMenu :items="dropdownItems" size="md">
<UButton
variant="subtle"
color="primary"
size="sm"
trailing-icon="i-lucide-chevron-down"
:disabled="selectedItemsCount === 0"
class="w-full sm:w-auto"
>
<span class="sm:hidden">Manage ({{ selectedItemsCount }})</span>
<span class="hidden sm:inline">Manage Selected ({{ selectedItemsCount }})</span>
</UButton>
</UDropdownMenu>
</div>
</div>
<!-- Navigation Menu -->
<UNavigationMenu :items="navigationMenuItems" orientation="vertical">
<template v-for="item in allItemsWithSlots" :key="`slot-${item.id}`" #[item.slot!]>
<div
class="flex items-center gap-4 mb-2 min-w-0"
@click="
item.children && item.children.length > 0 ? toggleGroupExpansion(item.id) : undefined
"
>
<UCheckbox
:model-value="isItemSelected(item.id)"
class="flex-shrink-0"
@update:model-value="toggleItemSelection(item.id)"
@click.stop
/>
<UIcon v-if="item.icon" :name="item.icon" class="h-5 w-5 flex-shrink-0" />
<span class="truncate flex-1 min-w-0">{{ item.label }}</span>
<UBadge
v-if="item.badge"
size="xs"
:label="String(item.badge)"
class="flex-shrink-0"
/>
<UIcon
v-if="item.children?.length"
name="i-lucide-chevron-down"
:class="[
'h-5 w-5 text-gray-400 transition-transform duration-200 flex-shrink-0',
expandedGroups[item.id] ? 'rotate-180' : 'rotate-0',
]"
/>
</div>
</template>
</UNavigationMenu>
</div>
</template>
</UDrawer>
</div>
</div>
</template>

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { Item, TabItem } from './Detail.vue';
interface Props {
selectedItem?: Item;
tabs: TabItem[];
selectedTab: string;
showHeader?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
selectedItem: undefined,
showHeader: true,
});
const emit = defineEmits<{
'update:selectedTab': [value: string];
}>();
const tabItems = computed(() =>
props.tabs.map((tab) => ({
label: tab.label,
key: tab.key,
disabled: tab.disabled,
}))
);
const getCurrentTabComponent = () => {
const tabIndex = parseInt(props.selectedTab);
// Validate that tabIndex is a valid number and within bounds
if (isNaN(tabIndex) || tabIndex < 0 || tabIndex >= props.tabs.length) {
return null;
}
return props.tabs[tabIndex]?.component || null;
};
const getCurrentTabProps = () => {
const tabIndex = parseInt(props.selectedTab);
// Validate that tabIndex is a valid number and within bounds
if (isNaN(tabIndex) || tabIndex < 0 || tabIndex >= props.tabs.length) {
return {
item: props.selectedItem,
};
}
const currentTab = props.tabs[tabIndex];
return {
item: props.selectedItem,
...currentTab?.props,
};
};
const updateSelectedTab = (value: string | number) => {
emit('update:selectedTab', String(value));
};
</script>
<template>
<div class="flex-1 min-w-0 px-4 lg:px-0">
<div v-if="showHeader && selectedItem" class="mb-6">
<slot name="header" :item="selectedItem" />
</div>
<div class="overflow-x-auto -mx-4 px-4">
<UTabs
:model-value="selectedTab"
variant="link"
:items="tabItems"
class="w-full"
:ui="{
list: 'gap-3 sm:gap-6 md:gap-8 whitespace-nowrap text-sm sm:text-base',
}"
@update:model-value="updateSelectedTab"
/>
</div>
<div class="mt-4 sm:mt-6">
<component
:is="getCurrentTabComponent()"
v-if="getCurrentTabComponent() && selectedItem"
v-bind="getCurrentTabProps()"
/>
<div v-else-if="!selectedItem">
<slot name="empty">No item selected</slot>
</div>
<div v-else>
<slot name="no-content">No content available</slot>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,133 @@
<script setup lang="ts">
import Console from '../../Docker/Console.vue';
import Edit from '../../Docker/Edit.vue';
import Logs from '../../Docker/Logs.vue';
import Overview from '../../Docker/Overview.vue';
import Preview from '../../Docker/Preview.vue';
import Detail from './Detail.vue';
interface ContainerDetails {
network: string;
lanIpPort: string;
containerIp: string;
uptime: string;
containerPort: string;
creationDate: string;
containerId: string;
maintainer: string;
}
const dockerContainers = [
{
id: 'immich',
label: 'immich',
icon: 'i-lucide-play-circle',
slot: 'immich' as const,
status: [
{ label: 'Update available', dotColor: 'bg-orange-500' },
{ label: 'Started', dotColor: 'bg-green-500' },
],
},
{
id: 'organizrv2',
label: 'organizrv2',
icon: 'i-lucide-layers',
slot: 'organizrv2' as const,
status: [{ label: 'Started', dotColor: 'bg-green-500' }],
},
{
id: 'jellyfin',
label: 'Jellyfin',
icon: 'i-lucide-film',
slot: 'jellyfin' as const,
status: [{ label: 'Stopped', dotColor: 'bg-red-500' }],
},
{
id: 'databases',
label: 'Databases',
icon: 'i-lucide-database',
slot: 'databases' as const,
isGroup: true,
children: [
{
id: 'mongodb',
label: 'MongoDB',
icon: 'i-lucide-leafy-green',
badge: 'DB',
slot: 'mongodb' as const,
status: [{ label: 'Started', dotColor: 'bg-green-500' }],
},
{
id: 'postgres17',
label: 'postgres17',
icon: 'i-lucide-pyramid',
badge: 'DB',
slot: 'postgres17' as const,
status: [
{ label: 'Update available', dotColor: 'bg-orange-500' },
{ label: 'Paused', dotColor: 'bg-blue-500' },
],
},
{
id: 'redis',
label: 'Redis',
icon: 'i-lucide-panda',
badge: 'DB',
slot: 'redis' as const,
status: [{ label: 'Started', dotColor: 'bg-green-500' }],
},
],
},
];
const containerDetails: Record<string, ContainerDetails> = {
immich: {
network: 'Bridge',
lanIpPort: '7878',
containerIp: '172.17.0.4',
uptime: '13 hours',
containerPort: '9696:TCP',
creationDate: '2 weeks ago',
containerId: '472b4c2442b9',
maintainer: 'ghcr.io/imagegenius/immich',
},
};
const getTabsWithProps = (containerId: string) => [
{
key: 'overview',
label: 'Overview',
component: Overview,
props: { details: containerDetails[containerId] },
},
{
key: 'logs',
label: 'Logs',
component: Logs,
},
{
key: 'console',
label: 'Console',
component: Console,
},
{
key: 'preview',
label: 'Preview',
component: Preview,
props: { port: containerDetails[containerId]?.lanIpPort || '8080' },
},
{
key: 'edit',
label: 'Edit',
component: Edit,
},
];
const tabs = getTabsWithProps('immich');
</script>
<template>
<div class="h-full">
<Detail :items="dockerContainers" :tabs="tabs" default-item-id="immich" />
</div>
</template>

View File

@@ -5,6 +5,9 @@ import { DefaultApolloClient } from '@vue/apollo-composable';
// Import Tailwind CSS for web components shadow DOM injection
import tailwindStyles from '~/assets/main.css?inline';
// Import UI configurations from .nuxt generated files
import * as uiConfig from '~/.nuxt/ui';
import en_US from '~/locales/en_US.json';
import { createHtmlEntityDecoder } from '~/helpers/i18n-utils';
import { globalPinia } from '~/store/globalPinia';
@@ -23,7 +26,7 @@ export default function (Vue: App) {
if (windowLocaleData) {
try {
parsedMessages = JSON.parse(decodeURIComponent(windowLocaleData));
parsedLocale = Object.keys(parsedMessages)[0];
parsedLocale = Object.keys(parsedMessages)[0] || '';
nonDefaultLocale = parsedLocale !== defaultLocale;
} catch (error) {
console.error('[WebComponentPlugins] error parsing messages', error);
@@ -50,6 +53,10 @@ export default function (Vue: App) {
// Provide Apollo client for all web components
Vue.provide(DefaultApolloClient, client);
// Provide UI config for components
Vue.provide('ui.config', uiConfig);
// Inject Tailwind CSS into the shadow DOM
Vue.mixin({
mounted() {

View File

@@ -0,0 +1,36 @@
import { onMounted, onUnmounted } from 'vue';
type ShortcutHandler = () => void;
type Shortcuts = Record<string, ShortcutHandler>;
export function defineShortcuts(shortcuts: Shortcuts) {
const handleKeyDown = (event: KeyboardEvent) => {
// Build key combination string
const keys: string[] = [];
if (event.metaKey || event.ctrlKey) keys.push('meta');
if (event.altKey) keys.push('alt');
if (event.shiftKey) keys.push('shift');
// Add the actual key
const key = event.key.toLowerCase();
if (key !== 'meta' && key !== 'control' && key !== 'alt' && key !== 'shift') {
keys.push(key);
}
const combination = keys.join('_');
// Check if we have a handler for this combination
if (shortcuts[combination]) {
event.preventDefault();
shortcuts[combination]();
}
};
onMounted(() => {
window.addEventListener('keydown', handleKeyDown);
});
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown);
});
}

View File

@@ -0,0 +1,46 @@
import { getCurrentInstance, inject } from 'vue';
export function useAppConfig() {
// Only try inject if we're in a component context
const instance = getCurrentInstance();
if (instance) {
// Try to get from injection first (only works in setup context)
try {
const injected = inject('appConfig', null);
if (injected) {
return injected;
}
} catch {
// inject failed, continue with fallbacks
}
// Fallback to global
if (instance.appContext.config.globalProperties.$appConfig) {
return instance.appContext.config.globalProperties.$appConfig;
}
}
// Last resort - return from window
if (typeof window !== 'undefined') {
const globalWindow = window as typeof globalThis & { appConfig?: unknown };
if (globalWindow.appConfig) {
return globalWindow.appConfig;
}
}
// Default config
return {
ui: {
colors: {
primary: 'blue',
neutral: 'gray'
}
},
toaster: {
position: 'bottom-right' as const,
expand: true,
duration: 5000
}
};
}

View File

@@ -27,7 +27,7 @@ export default withNuxt(
rules: {
'vue/multi-word-component-names': 'off',
'vue/no-v-html': 'off',
'vue/no-undef-components': [
/* 'vue/no-undef-components': [
'error',
{
ignorePatterns: [
@@ -46,7 +46,7 @@ export default withNuxt(
'^ConnectSettingsCe$',
],
},
],
], */
'eol-last': ['error', 'always'],
// TypeScript rules for unused variables and undefined variables

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { ClientOnly, NuxtLink } from '#components';
import { Badge, Toaster } from '@unraid/ui';
@@ -10,18 +9,71 @@ import DummyServerSwitcher from '~/components/DummyServerSwitcher.vue';
import ModalsCe from '~/components/Modals.ce.vue';
const router = useRouter();
const openDropdown = ref<string | null>(null);
const toggleDropdown = (routePath: string) => {
openDropdown.value = openDropdown.value === routePath ? null : routePath;
};
const routes = computed(() => {
return router
const allRoutes = router
.getRoutes()
.filter((route) => !route.path.includes(':') && route.path !== '/404' && route.name)
.sort((a, b) => a.path.localeCompare(b.path));
// Group routes by parent path
const grouped = new Map<string, any>();
const topLevel: any[] = [];
allRoutes.forEach(route => {
const pathParts = route.path.split('/').filter(Boolean);
if (pathParts.length === 1) {
// Top level route
const existing = grouped.get(pathParts[0]);
if (existing) {
// Already have a parent with this name, skip adding duplicate
return;
}
topLevel.push({
...route,
children: []
});
grouped.set(pathParts[0], topLevel[topLevel.length - 1]);
} else if (pathParts.length >= 2) {
// Nested route - find or create parent
const parentPath = pathParts[0];
let parent = grouped.get(parentPath);
if (!parent) {
// Create a virtual parent route
parent = {
path: `/${parentPath}`,
name: parentPath,
children: []
};
topLevel.push(parent);
grouped.set(parentPath, parent);
}
parent.children.push(route);
}
});
return topLevel;
});
function formatRouteName(name: string | symbol | undefined) {
function formatRouteName(name: string | symbol | undefined, path?: string) {
if (!name) return 'Home';
// Convert symbols to strings if needed
const nameStr = typeof name === 'symbol' ? name.toString() : name;
// For nested routes, show the last part of the path
if (path && path.includes('/') && path.split('/').filter(Boolean).length > 1) {
const lastPart = path.split('/').pop();
return lastPart ? lastPart.charAt(0).toUpperCase() + lastPart.slice(1).replace(/-/g, ' ') : nameStr;
}
// Convert route names like "web-components" to "Web Components"
return nameStr
.replace(/-/g, ' ')
@@ -38,7 +90,45 @@ function formatRouteName(name: string | symbol | undefined) {
<div class="flex flex-wrap items-center justify-between gap-2 p-3 md:p-4">
<nav class="flex flex-wrap items-center gap-2">
<template v-for="route in routes" :key="route.path">
<NuxtLink :to="route.path">
<!-- Routes with children get a dropdown -->
<div v-if="route.children && route.children.length > 0" class="relative">
<Badge
:variant="router.currentRoute.value.path.startsWith(route.path) ? 'orange' : 'gray'"
size="xs"
class="cursor-pointer flex items-center gap-1"
@click="toggleDropdown(route.path)"
>
{{ formatRouteName(route.name) }}
<svg
class="w-3 h-3 transition-transform"
:class="openDropdown === route.path && 'rotate-180'"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</Badge>
<!-- Dropdown menu -->
<div
v-if="openDropdown === route.path"
class="absolute top-full mt-1 left-0 bg-white dark:bg-zinc-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg z-50 min-w-[150px]"
>
<NuxtLink
v-for="child in route.children"
:key="child.path"
:to="child.path"
class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-zinc-700 text-gray-700 dark:text-gray-200"
@click="openDropdown = null"
>
{{ formatRouteName(child.name, child.path) }}
</NuxtLink>
</div>
</div>
<!-- Regular routes without children -->
<NuxtLink v-else :to="route.path">
<Badge
:variant="router.currentRoute.value.path === route.path ? 'orange' : 'gray'"
size="xs"

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import UApp from '@nuxt/ui/components/App.vue';
import UPage from '@nuxt/ui/components/Page.vue';
import { defaultColors } from '~/themes/default';
import { useThemeStore } from '~/store/theme';
const themeStore = useThemeStore();
// Initialize theme on mount
onMounted(() => {
// Set a default theme similar to ColorSwitcherCe
const whiteTheme = defaultColors.white;
if (whiteTheme) {
const defaultTheme = {
banner: false,
bannerGradient: false,
descriptionShow: true,
textColor: whiteTheme['--header-text-primary'],
metaColor: whiteTheme['--header-text-secondary'],
bgColor: whiteTheme['--header-background-color'],
name: 'white',
};
themeStore.setTheme(defaultTheme);
}
});
</script>
<template>
<UApp>
<UPage>
<slot />
</UPage>
</UApp>
</template>
<style>
/* Import theme styles */
@import '~/assets/main.css';
</style>

View File

@@ -149,13 +149,7 @@ export default defineNuxtConfig({
modules: ['@vueuse/nuxt', '@pinia/nuxt', '@nuxt/eslint', '@nuxt/ui'],
ui: {
theme: {
colors: ['primary'],
},
},
// Disable auto-imports
// Disable auto-imports for manual control
imports: {
autoImport: false,
},
@@ -171,7 +165,16 @@ export default defineNuxtConfig({
components: false,
vite: {
plugins: getSharedPlugins(),
plugins: [
...getSharedPlugins(),
// Add Nuxt UI vite plugin for Vue mode
(async () => {
const { default: NuxtUIVite } = await import('@nuxt/ui/vite');
return NuxtUIVite({
colorMode: true
});
})()
],
define: sharedDefine,
build: {
minify: 'terser',

View File

@@ -101,7 +101,7 @@
"@jsonforms/vue": "3.6.0",
"@jsonforms/vue-vanilla": "3.6.0",
"@jsonforms/vue-vuetify": "3.6.0",
"@nuxt/ui": "3.3.2",
"@nuxt/ui": "4.0.0-alpha.0",
"@nuxtjs/color-mode": "3.5.2",
"@pinia/nuxt": "0.11.2",
"@unraid/shared-callbacks": "1.1.1",

176
web/pages/docker.vue Normal file
View File

@@ -0,0 +1,176 @@
<script setup lang="ts">
import { ref } from 'vue';
import { definePageMeta } from '#imports';
import UButton from '@nuxt/ui/components/Button.vue';
import Console from '../components/Docker/Console.vue';
import Edit from '../components/Docker/Edit.vue';
import Logs from '../components/Docker/Logs.vue';
import Overview from '../components/Docker/Overview.vue';
import Preview from '../components/Docker/Preview.vue';
import Card from '../components/LayoutViews/Card/Card.vue';
import Detail from '../components/LayoutViews/Detail/Detail.vue';
definePageMeta({
layout: 'unraid-next',
});
interface ContainerDetails {
network: string;
lanIpPort: string;
containerIp: string;
uptime: string;
containerPort: string;
creationDate: string;
containerId: string;
maintainer: string;
}
const dockerContainers = [
{
id: 'immich',
label: 'immich',
icon: 'i-lucide-play-circle',
slot: 'immich' as const,
status: [
{ label: 'Update available', dotColor: 'bg-orange-500' },
{ label: 'Started', dotColor: 'bg-green-500' },
],
},
{
id: 'organizrv2',
label: 'organizrv2',
icon: 'i-lucide-layers',
slot: 'organizrv2' as const,
status: [{ label: 'Started', dotColor: 'bg-green-500' }],
},
{
id: 'jellyfin',
label: 'Jellyfin',
icon: 'i-lucide-film',
slot: 'jellyfin' as const,
status: [{ label: 'Stopped', dotColor: 'bg-red-500' }],
},
{
id: 'databases',
label: 'Databases',
icon: 'i-lucide-database',
slot: 'databases' as const,
isGroup: true,
children: [
{
id: 'mongodb',
label: 'MongoDB',
icon: 'i-lucide-leafy-green',
badge: 'DB',
slot: 'mongodb' as const,
status: [{ label: 'Started', dotColor: 'bg-green-500' }],
},
{
id: 'postgres17',
label: 'postgres17',
icon: 'i-lucide-pyramid',
badge: 'DB',
slot: 'postgres17' as const,
status: [
{ label: 'Update available', dotColor: 'bg-orange-500' },
{ label: 'Paused', dotColor: 'bg-blue-500' },
],
},
{
id: 'redis',
label: 'Redis',
icon: 'i-lucide-panda',
badge: 'DB',
slot: 'redis' as const,
status: [{ label: 'Started', dotColor: 'bg-green-500' }],
},
],
},
];
const containerDetails: Record<string, ContainerDetails> = {
immich: {
network: 'Bridge',
lanIpPort: '7878',
containerIp: '172.17.0.4',
uptime: '13 hours',
containerPort: '9696:TCP',
creationDate: '2 weeks ago',
containerId: '472b4c2442b9',
maintainer: 'ghcr.io/imagegenius/immich',
},
};
const getTabsWithProps = (containerId: string) => [
{
key: 'overview',
label: 'Overview',
component: Overview,
props: { details: containerDetails[containerId] },
},
{
key: 'logs',
label: 'Logs',
component: Logs,
},
{
key: 'console',
label: 'Console',
component: Console,
},
{
key: 'preview',
label: 'Preview',
component: Preview,
props: { port: containerDetails[containerId]?.lanIpPort || '8080' },
},
{
key: 'edit',
label: 'Edit',
component: Edit,
},
];
const tabs = getTabsWithProps('immich');
// View mode toggle
const viewMode = ref<'detail' | 'card'>('detail');
const toggleView = () => {
viewMode.value = viewMode.value === 'detail' ? 'card' : 'detail';
};
</script>
<template>
<div class="h-full flex flex-col">
<!-- View Toggle Header -->
<div class="border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 p-4">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Docker</h1>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600 dark:text-gray-400">View:</span>
<UButton
:icon="viewMode === 'detail' ? 'i-lucide-list' : 'i-lucide-grid-3x3'"
:color="viewMode === 'detail' ? 'primary' : 'neutral'"
:variant="viewMode === 'detail' ? 'solid' : 'outline'"
size="sm"
@click="toggleView"
>
{{ viewMode === 'detail' ? 'Detail' : 'Card' }}
</UButton>
</div>
</div>
</div>
<!-- View Content -->
<div class="flex-1 min-h-0">
<Detail
v-if="viewMode === 'detail'"
:items="dockerContainers"
:tabs="tabs"
default-item-id="immich"
/>
<Card v-else :items="dockerContainers" navigation-label="Docker Overview" />
</div>
</div>
</template>

36
web/pages/docker/card.vue Normal file
View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { ref } from 'vue';
import { definePageMeta } from '#imports';
import Card from '../../components/LayoutViews/Card/Card.vue';
definePageMeta({
layout: 'unraid-next',
});
const dockerContainers = [
{
id: 'immich',
label: 'immich',
icon: 'i-lucide-play-circle',
slot: 'immich' as const,
status: [
{ label: 'Update available', dotColor: 'bg-orange-500' },
{ label: 'Started', dotColor: 'bg-green-500' },
],
},
{
id: 'organizrv2',
label: 'organizrv2',
icon: 'i-lucide-layers',
slot: 'organizrv2' as const,
status: [{ label: 'Started', dotColor: 'bg-green-500' }],
},
];
</script>
<template>
<div class="h-full flex flex-col p-4">
<h1 class="text-2xl font-bold mb-4">Test Card Component</h1>
<Card :items="dockerContainers" navigation-label="Docker Overview" />
</div>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import { ref } from 'vue';
import { definePageMeta } from '#imports';
import Console from '../../components/Docker/Console.vue';
definePageMeta({
layout: 'unraid-next',
});
</script>
<template>
<div class="h-full flex flex-col p-4">
<h1 class="text-2xl font-bold mb-4">Console Component Test</h1>
<Console />
</div>
</template>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import { ref } from 'vue';
import { definePageMeta } from '#imports';
import Detail from '../../components/LayoutViews/Detail/Detail.vue';
import Console from '../../components/Docker/Console.vue';
import Edit from '../../components/Docker/Edit.vue';
import Logs from '../../components/Docker/Logs.vue';
import Overview from '../../components/Docker/Overview.vue';
import Preview from '../../components/Docker/Preview.vue';
definePageMeta({
layout: 'unraid-next',
});
const dockerContainers = [
{
id: 'immich',
label: 'immich',
icon: 'i-lucide-play-circle',
slot: 'immich' as const,
status: [
{ label: 'Update available', dotColor: 'bg-orange-500' },
{ label: 'Started', dotColor: 'bg-green-500' },
],
},
];
const containerDetails = {
immich: {
network: 'Bridge',
lanIpPort: '7878',
containerIp: '172.17.0.4',
uptime: '13 hours',
containerPort: '9696:TCP',
creationDate: '2 weeks ago',
containerId: '472b4c2442b9',
maintainer: 'ghcr.io/imagegenius/immich',
},
};
const tabs = [
{
key: 'overview',
label: 'Overview',
component: Overview,
props: { details: containerDetails['immich'] },
},
{
key: 'logs',
label: 'Logs',
component: Logs,
},
{
key: 'console',
label: 'Console',
component: Console,
},
{
key: 'preview',
label: 'Preview',
component: Preview,
props: { port: containerDetails['immich']?.lanIpPort || '8080' },
},
{
key: 'edit',
label: 'Edit',
component: Edit,
},
];
</script>
<template>
<div class="h-full flex flex-col p-4">
<h1 class="text-2xl font-bold mb-4">Test Detail Component</h1>
<Detail
:items="dockerContainers"
:tabs="tabs"
default-item-id="immich"
/>
</div>
</template>

16
web/pages/docker/edit.vue Normal file
View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import { ref } from 'vue';
import { definePageMeta } from '#imports';
import Edit from '../../components/Docker/Edit.vue';
definePageMeta({
layout: 'unraid-next',
});
</script>
<template>
<div class="h-full flex flex-col p-4">
<h1 class="text-2xl font-bold mb-4">Test Edit Component</h1>
<Edit />
</div>
</template>

16
web/pages/docker/logs.vue Normal file
View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import { ref } from 'vue';
import { definePageMeta } from '#imports';
import Logs from '../../components/Docker/Logs.vue';
definePageMeta({
layout: 'unraid-next',
});
</script>
<template>
<div class="h-full flex flex-col p-4">
<h1 class="text-2xl font-bold mb-4">Test Logs Component</h1>
<Logs />
</div>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { ref } from 'vue';
import { definePageMeta } from '#imports';
import Overview from '../../components/Docker/Overview.vue';
definePageMeta({
layout: 'unraid-next',
});
const testDetails = {
network: 'Bridge',
lanIpPort: '7878',
containerIp: '172.17.0.4',
uptime: '13 hours',
containerPort: '9696:TCP',
creationDate: '2 weeks ago',
containerId: '472b4c2442b9',
maintainer: 'ghcr.io/imagegenius/immich',
};
</script>
<template>
<div class="h-full flex flex-col p-4">
<h1 class="text-2xl font-bold mb-4">Test Overview Component</h1>
<Overview :details="testDetails" />
</div>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import { ref } from 'vue';
import { definePageMeta } from '#imports';
import Preview from '../../components/Docker/Preview.vue';
definePageMeta({
layout: 'unraid-next',
});
</script>
<template>
<div class="h-full flex flex-col p-4">
<h1 class="text-2xl font-bold mb-4">Test Preview Component</h1>
<Preview port="8080" />
</div>
</template>

View File

@@ -4,7 +4,8 @@ import { storeToRefs } from 'pinia';
import { ExclamationTriangleIcon } from '@heroicons/vue/24/solid';
import { BrandButton, Toaster } from '@unraid/ui';
import { UButton } from '#components';
import { useHead } from '#imports';
import UButton from '@nuxt/ui/components/Button.vue';
import { useDummyServerStore } from '~/_data/serverState';
import AES from 'crypto-js/aes';

View File

@@ -1,21 +1,79 @@
<script lang="ts" setup>
import { storeToRefs } from 'pinia';
import { useDummyServerStore } from '~/_data/serverState';
import { Toaster } from '@unraid/ui';
import { useDummyServerStore } from '~/_data/serverState';
import BrandLogo from '~/components/Brand/Logo.vue';
import HeaderOsVersionCe from '~/components/HeaderOsVersion.ce.vue';
import ConnectSettingsCe from '~/components/ConnectSettings/ConnectSettings.ce.vue';
import HeaderOsVersionCe from '~/components/HeaderOsVersion.ce.vue';
const serverStore = useDummyServerStore();
const { serverState } = storeToRefs(serverStore);
// Define window type extension
declare global {
interface Window {
__unraidUiComponentsRegistered?: boolean;
}
}
// Debug: Log immediately when component is created
console.log('[WebComponents Test] Component setup executing');
// Check if components are loaded after mount
onMounted(async () => {
console.log('[WebComponents Test] onMounted hook called');
// Check if components have already been registered
if (window.__unraidUiComponentsRegistered) {
console.log('[WebComponents Test] Components already registered');
return;
}
try {
// For development, we need to manually import and run the setup
if (import.meta.dev) {
console.log('[WebComponents Test] Loading web components in dev mode...');
// Dynamically import the entry file and execute it
await import('#customElementsEntries/unraid-components.client.js');
console.log('[WebComponents Test] Web components imported successfully');
window.__unraidUiComponentsRegistered = true;
} else {
// In production, create script tag for built bundle
const script = document.createElement('script');
script.type = 'module';
script.src = '/_nuxt/unraid-components.client.js';
await new Promise<void>((resolve, reject) => {
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load production script'));
document.head.appendChild(script);
});
window.__unraidUiComponentsRegistered = true;
}
// Give components time to register
await new Promise((resolve) => setTimeout(resolve, 100));
// Check if components are registered
console.log('[WebComponents Test] Checking for registered components...');
console.log('unraid-auth defined?', customElements.get('unraid-auth') !== undefined);
console.log('unraid-user-profile defined?', customElements.get('unraid-user-profile') !== undefined);
console.log('unraid-detail-test defined?', customElements.get('unraid-detail-test') !== undefined);
} catch (error) {
console.error('[WebComponents Test] Failed to load web components:', error);
window.__unraidUiComponentsRegistered = false;
}
});
</script>
<template>
<client-only>
<div
class="flex flex-col gap-6 p-6 mx-auto text-black bg-white dark:text-white dark:bg-black"
>
<div class="flex flex-col gap-6 p-6 mx-auto text-black bg-white dark:text-white dark:bg-black">
<h2 class="text-xl font-semibold font-mono">Web Components</h2>
<h3 class="text-lg font-semibold font-mono">UserProfileCe</h3>
<header class="bg-header-background-color py-4 flex flex-row justify-between items-center">
@@ -61,6 +119,8 @@ const { serverState } = storeToRefs(serverStore);
<hr class="border-muted" >
<h3 class="text-lg font-semibold font-mono">ApiKeyManagerCe</h3>
<unraid-api-key-manager />
<unraid-detail-test />
</div>
<Toaster rich-colors close-button />
</client-only>

View File

@@ -0,0 +1,29 @@
import { defineNuxtPlugin, useAppConfig } from '#app';
import ui from '@nuxt/ui/vue-plugin';
export default defineNuxtPlugin({
name: 'nuxt-ui-vue-mode',
enforce: 'pre', // Run before other plugins
setup(nuxtApp) {
// Get the app config
const appConfig = useAppConfig();
// Provide the app config globally BEFORE initializing the plugin
nuxtApp.vueApp.config.globalProperties.$appConfig = appConfig;
// Also make it available on window for the composable fallback
if (typeof window !== 'undefined') {
(window as typeof globalThis & { appConfig?: typeof appConfig }).appConfig = appConfig;
}
// Use Nuxt UI in Vue mode - the vue-plugin provides stubs for Nuxt-specific imports
nuxtApp.vueApp.use(ui, {
prefix: 'U',
// Pass the config directly to the plugin
appConfig
});
// Provide after plugin initialization for components
nuxtApp.vueApp.provide('appConfig', appConfig);
}
});