Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
429f38d0
Commit
429f38d0
authored
Dec 26, 2025
by
shaw
Browse files
Merge PR #37: Add Gemini OAuth and Messages Compat Support
parents
2d89f366
2714be99
Changes
165
Hide whitespace changes
Inline
Side-by-side
frontend/src/components/common/PlatformIcon.vue
View file @
429f38d0
<
template
>
<!-- Claude/Anthropic logo -->
<svg
v-if=
"platform === 'anthropic'"
:class=
"sizeClass"
viewBox=
"0 0 16 16"
fill=
"currentColor"
>
<path
d=
"m3.127 10.604 3.135-1.76.053-.153-.053-.085H6.11l-.525-.032-1.791-.048-1.554-.065-1.505-.08-.38-.081L0 7.832l.036-.234.32-.214.455.04 1.009.069 1.513.105 1.097.064 1.626.17h.259l.036-.105-.089-.065-.068-.064-1.566-1.062-1.695-1.121-.887-.646-.48-.327-.243-.306-.104-.67.435-.48.585.04.15.04.593.456 1.267.981 1.654 1.218.242.202.097-.068.012-.049-.109-.181-.9-1.626-.96-1.655-.428-.686-.113-.411a2 2 0 0 1-.068-.484l.496-.674L4.446 0l.662.089.279.242.411.94.666 1.48 1.033 2.014.302.597.162.553.06.17h.105v-.097l.085-1.134.157-1.392.154-1.792.052-.504.25-.605.497-.327.387.186.319.456-.045.294-.19 1.23-.37 1.93-.243 1.29h.142l.161-.16.654-.868 1.097-1.372.484-.545.565-.601.363-.287h.686l.505.751-.226.775-.707.895-.585.759-.839 1.13-.524.904.048.072.125-.012 1.897-.403 1.024-.186 1.223-.21.553.258.06.263-.218.536-1.307.323-1.533.307-2.284.54-.028.02.032.04 1.029.098.44.024h1.077l2.005.15.525.346.315.424-.053.323-.807.411-3.631-.863-.872-.218h-.12v.073l.726.71 1.331 1.202 1.667 1.55.084.383-.214.302-.226-.032-1.464-1.101-.565-.497-1.28-1.077h-.084v.113l.295.432 1.557 2.34.08.718-.112.234-.404.141-.444-.08-.911-1.28-.94-1.44-.759-1.291-.093.053-.448 4.821-.21.246-.484.186-.403-.307-.214-.496.214-.98.258-1.28.21-1.016.19-1.263.112-.42-.008-.028-.092.012-.953 1.307-1.448 1.957-1.146 1.227-.274.109-.477-.247.045-.44.266-.39 1.586-2.018.956-1.25.617-.723-.004-.105h-.036l-4.212 2.736-.75.096-.324-.302.04-.496.154-.162 1.267-.871z"
/>
<path
d=
"m3.127 10.604 3.135-1.76.053-.153-.053-.085H6.11l-.525-.032-1.791-.048-1.554-.065-1.505-.08-.38-.081L0 7.832l.036-.234.32-.214.455.04 1.009.069 1.513.105 1.097.064 1.626.17h.259l.036-.105-.089-.065-.068-.064-1.566-1.062-1.695-1.121-.887-.646-.48-.327-.243-.306-.104-.67.435-.48.585.04.15.04.593.456 1.267.981 1.654 1.218.242.202.097-.068.012-.049-.109-.181-.9-1.626-.96-1.655-.428-.686-.113-.411a2 2 0 0 1-.068-.484l.496-.674L4.446 0l.662.089.279.242.411.94.666 1.48 1.033 2.014.302.597.162.553.06.17h.105v-.097l.085-1.134.157-1.392.154-1.792.052-.504.25-.605.497-.327.387.186.319.456-.045.294-.19 1.23-.37 1.93-.243 1.29h.142l.161-.16.654-.868 1.097-1.372.484-.545.565-.601.363-.287h.686l.505.751-.226.775-.707.895-.585.759-.839 1.13-.524.904.048.072.125-.012 1.897-.403 1.024-.186 1.223-.21.553.258.06.263-.218.536-1.307.323-1.533.307-2.284.54-.028.02.032.04 1.029.098.44.024h1.077l2.005.15.525.346.315.424-.053.323-.807.411-3.631-.863-.872-.218h-.12v.073l.726.71 1.331 1.202 1.667 1.55.084.383-.214.302-.226-.032-1.464-1.101-.565-.497-1.28-1.077h-.084v.113l.295.432 1.557 2.34.08.718-.112.234-.404.141-.444-.08-.911-1.28-.94-1.44-.759-1.291-.093.053-.448 4.821-.21.246-.484.186-.403-.307-.214-.496.214-.98.258-1.28.21-1.016.19-1.263.112-.42-.008-.028-.092.012-.953 1.307-1.448 1.957-1.146 1.227-.274.109-.477-.247.045-.44.266-.39 1.586-2.018.956-1.25.617-.723-.004-.105h-.036l-4.212 2.736-.75.096-.324-.302.04-.496.154-.162 1.267-.871z"
/>
</svg>
<!-- OpenAI logo -->
<svg
v-else-if=
"platform === 'openai'"
:class=
"sizeClass"
viewBox=
"0 0 24 24"
fill=
"currentColor"
>
<path
d=
"M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z"
/>
<path
d=
"M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z"
/>
</svg>
<!-- Gemini logo (simple star) -->
<svg
v-else-if=
"platform === 'gemini'"
:class=
"sizeClass"
viewBox=
"0 0 24 24"
fill=
"currentColor"
>
<path
d=
"M12 2l1.89 7.2L21 12l-7.11 2.8L12 22l-1.89-7.2L3 12l7.11-2.8L12 2z"
/>
</svg>
<!-- Fallback: generic platform icon -->
<svg
v-else
:class=
"sizeClass"
fill=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
d=
"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"
/>
<path
d=
"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"
/>
</svg>
</
template
>
...
...
frontend/src/components/common/PlatformTypeBadge.vue
View file @
429f38d0
<
template
>
<div
class=
"inline-flex items-center
rounded-md
overflow-hidden text-xs font-medium"
>
<div
class=
"inline-flex items-center overflow-hidden
rounded-md
text-xs font-medium"
>
<!-- Platform part -->
<span
:class=
"[
'inline-flex items-center gap-1 px-2 py-1',
platformClass
]"
>
<span
:class=
"['inline-flex items-center gap-1 px-2 py-1', platformClass]"
>
<PlatformIcon
:platform=
"platform"
size=
"xs"
/>
<span>
{{
platformLabel
}}
</span>
</span>
<!-- Type part -->
<span
:class=
"[
'inline-flex items-center gap-1 px-1.5 py-1',
typeClass
]"
>
<span
:class=
"['inline-flex items-center gap-1 px-1.5 py-1', typeClass]"
>
<!-- OAuth icon -->
<svg
v-if=
"type === 'oauth'"
class=
"w-3 h-3"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
<svg
v-if=
"type === 'oauth'"
class=
"h-3 w-3"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg>
<!-- Setup Token icon -->
<svg
v-else-if=
"type === 'setup-token'"
class=
"w-3 h-3"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
<svg
v-else-if=
"type === 'setup-token'"
class=
"h-3 w-3"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg>
<!-- API Key icon -->
<svg
v-else
class=
"w-3 h-3"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
<svg
v-else
class=
"h-3 w-3"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
<span>
{{
typeLabel
}}
</span>
</span>
...
...
@@ -47,15 +70,21 @@ interface Props {
const
props
=
defineProps
<
Props
>
()
const
platformLabel
=
computed
(()
=>
{
return
props
.
platform
===
'
anthropic
'
?
'
Anthropic
'
:
'
OpenAI
'
if
(
props
.
platform
===
'
anthropic
'
)
return
'
Anthropic
'
if
(
props
.
platform
===
'
openai
'
)
return
'
OpenAI
'
return
'
Gemini
'
})
const
typeLabel
=
computed
(()
=>
{
switch
(
props
.
type
)
{
case
'
oauth
'
:
return
'
OAuth
'
case
'
setup-token
'
:
return
'
Token
'
case
'
apikey
'
:
return
'
Key
'
default
:
return
props
.
type
case
'
oauth
'
:
return
'
OAuth
'
case
'
setup-token
'
:
return
'
Token
'
case
'
apikey
'
:
return
'
Key
'
default
:
return
props
.
type
}
})
...
...
@@ -63,13 +92,19 @@ const platformClass = computed(() => {
if
(
props
.
platform
===
'
anthropic
'
)
{
return
'
bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400
'
}
return
'
bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400
'
if
(
props
.
platform
===
'
openai
'
)
{
return
'
bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400
'
}
return
'
bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400
'
})
const
typeClass
=
computed
(()
=>
{
if
(
props
.
platform
===
'
anthropic
'
)
{
return
'
bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400
'
}
return
'
bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400
'
if
(
props
.
platform
===
'
openai
'
)
{
return
'
bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400
'
}
return
'
bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400
'
})
</
script
>
frontend/src/components/common/ProxySelector.vue
View file @
429f38d0
...
...
@@ -15,7 +15,7 @@
</span>
<span
class=
"select-icon"
>
<svg
:class=
"['
w
-5
h
-5 transition-transform duration-200', isOpen && 'rotate-180']"
:class=
"['
h
-5
w
-5 transition-transform duration-200', isOpen && 'rotate-180']"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
...
...
@@ -27,15 +27,22 @@
</button>
<Transition
name=
"select-dropdown"
>
<div
v-if=
"isOpen"
class=
"select-dropdown"
>
<div
v-if=
"isOpen"
class=
"select-dropdown"
>
<!-- Search and Batch Test Header -->
<div
class=
"select-header"
>
<div
class=
"select-search"
>
<svg
class=
"w-4 h-4 text-gray-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
<svg
class=
"h-4 w-4 text-gray-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
<input
ref=
"searchInputRef"
...
...
@@ -54,12 +61,34 @@
class=
"batch-test-btn"
:title=
"t('admin.proxies.batchTest')"
>
<svg
v-if=
"batchTesting"
class=
"w-4 h-4 animate-spin"
fill=
"none"
viewBox=
"0 0 24 24"
>
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle>
<path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
<svg
v-if=
"batchTesting"
class=
"h-4 w-4 animate-spin"
fill=
"none"
viewBox=
"0 0 24 24"
>
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle>
<path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<svg
v-else
class=
"w-4 h-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z"
/>
<svg
v-else
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z"
/>
</svg>
</button>
</div>
...
...
@@ -69,15 +98,12 @@
<!-- No Proxy option -->
<div
@
click=
"selectOption(null)"
:class=
"[
'select-option',
modelValue === null && 'select-option-selected'
]"
:class=
"['select-option', modelValue === null && 'select-option-selected']"
>
<span
class=
"select-option-label"
>
{{
t
(
'
admin.accounts.noProxy
'
)
}}
</span>
<svg
v-if=
"modelValue === null"
class=
"
w
-4
h
-4 text-primary-500"
class=
"
h
-4
w
-4 text-primary-500"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
...
...
@@ -92,18 +118,15 @@
v-for=
"proxy in filteredProxies"
:key=
"proxy.id"
@
click=
"selectOption(proxy.id)"
:class=
"[
'select-option',
modelValue === proxy.id && 'select-option-selected'
]"
:class=
"['select-option', modelValue === proxy.id && 'select-option-selected']"
>
<div
class=
"
flex-1
min-w-0"
>
<div
class=
"min-w-0
flex-1
"
>
<div
class=
"flex items-center gap-2"
>
<span
class=
"truncate font-medium"
>
{{
proxy
.
name
}}
</span>
<!-- Account count badge -->
<span
v-if=
"proxy.account_count !== undefined"
class=
"flex-shrink-0 i
nline-flex items-center
px-1.5 py-0.5
rounded
text-xs
bg
-gray-
1
00 dark:bg-dark-600
text-gray-600
dark:text-gray-400"
class=
"
inline-flex
flex-shrink-0 i
tems-center rounded bg-gray-100
px-1.5 py-0.5 text-xs
text
-gray-
6
00 dark:bg-dark-600 dark:text-gray-400"
>
{{
proxy
.
account_count
}}
</span>
...
...
@@ -111,20 +134,24 @@
<template
v-if=
"testResults[proxy.id]"
>
<span
v-if=
"testResults[proxy.id].success"
class=
"flex-shrink-0
inline-flex
items-center gap-1 px-1.5 py-0.5
rounded
text-xs
bg
-emerald-
1
00 dark:bg-emerald-900/30
text-emerald-700
dark:text-emerald-400"
class=
"
inline-flex
flex-shrink-0 items-center gap-1
rounded bg-emerald-100
px-1.5 py-0.5 text-xs
text
-emerald-
7
00 dark:bg-emerald-900/30 dark:text-emerald-400"
>
<span
v-if=
"testResults[proxy.id].country"
>
{{
testResults
[
proxy
.
id
].
country
}}
</span>
<span
v-if=
"testResults[proxy.id].latency_ms"
>
{{
testResults
[
proxy
.
id
].
latency_ms
}}
ms
</span>
<span
v-if=
"testResults[proxy.id].country"
>
{{
testResults
[
proxy
.
id
].
country
}}
</span>
<span
v-if=
"testResults[proxy.id].latency_ms"
>
{{
testResults
[
proxy
.
id
].
latency_ms
}}
ms
</span
>
</span>
<span
v-else
class=
"flex-shrink-0 i
nline-flex items-center
px-1.5 py-0.5
rounded
text-xs
bg
-red-
1
00 dark:bg-red-900/30
text-red-700
dark:text-red-400"
class=
"
inline-flex
flex-shrink-0 i
tems-center rounded bg-red-100
px-1.5 py-0.5 text-xs
text
-red-
7
00 dark:bg-red-900/30 dark:text-red-400"
>
{{
t
(
'
admin.proxies.testFailed
'
)
}}
</span>
</
template
>
</div>
<div
class=
"text-xs text-gray-500 dark:text-gray-400
truncate
"
>
<div
class=
"
truncate
text-xs text-gray-500 dark:text-gray-400"
>
{{ proxy.protocol }}://{{ proxy.host }}:{{ proxy.port }}
</div>
</div>
...
...
@@ -137,18 +164,45 @@
class=
"test-btn"
:title=
"t('admin.proxies.testConnection')"
>
<svg
v-if=
"testingProxyIds.has(proxy.id)"
class=
"w-3.5 h-3.5 animate-spin"
fill=
"none"
viewBox=
"0 0 24 24"
>
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle>
<path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
<svg
v-if=
"testingProxyIds.has(proxy.id)"
class=
"h-3.5 w-3.5 animate-spin"
fill=
"none"
viewBox=
"0 0 24 24"
>
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle>
<path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<svg
v-else
class=
"w-3.5 h-3.5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z"
/>
<svg
v-else
class=
"h-3.5 w-3.5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z"
/>
</svg>
</button>
<svg
v-if=
"modelValue === proxy.id"
class=
"
w-4 h-4
text-primary-500
flex-shrink-0
"
class=
"
h-4 w-4 flex-shrink-0
text-primary-500"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
...
...
@@ -193,7 +247,7 @@ interface Props {
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
disabled
:
false
,
disabled
:
false
})
const
emit
=
defineEmits
<
{
...
...
@@ -212,7 +266,7 @@ const batchTesting = ref(false)
const
selectedProxy
=
computed
(()
=>
{
if
(
props
.
modelValue
===
null
)
return
null
return
props
.
proxies
.
find
(
p
=>
p
.
id
===
props
.
modelValue
)
||
null
return
props
.
proxies
.
find
(
(
p
)
=>
p
.
id
===
props
.
modelValue
)
||
null
})
const
selectedLabel
=
computed
(()
=>
{
...
...
@@ -228,7 +282,7 @@ const filteredProxies = computed(() => {
return
props
.
proxies
}
const
query
=
searchQuery
.
value
.
toLowerCase
()
return
props
.
proxies
.
filter
(
proxy
=>
{
return
props
.
proxies
.
filter
(
(
proxy
)
=>
{
const
name
=
proxy
.
name
.
toLowerCase
()
const
host
=
proxy
.
host
.
toLowerCase
()
return
name
.
includes
(
query
)
||
host
.
includes
(
query
)
...
...
@@ -320,27 +374,27 @@ onUnmounted(() => {
<
style
scoped
>
.select-trigger
{
@apply
w-full
flex
items-center
justify-between
gap-2;
@apply
px-4
py-2.5
rounded-xl
text-sm;
@apply
flex
w-full
items-center
justify-between
gap-2;
@apply
rounded-xl
px-4
py-2.5
text-sm;
@apply
bg-white
dark
:
bg-dark-800
;
@apply
border
border-gray-200
dark
:
border-dark-600
;
@apply
text-gray-900
dark
:
text-gray-100
;
@apply
transition-all
duration-200;
@apply
focus
:
outline-none
focus
:
ring-2
focus
:
ring-primary-500
/
30
focus
:
border-primary-500
;
@apply
focus
:
border-primary-500
focus
:
outline-none
focus
:
ring-2
focus
:
ring-primary-500
/
30
;
@apply
hover
:
border-gray-300
dark
:
hover
:
border-dark-500
;
@apply
cursor-pointer;
}
.select-trigger-open
{
@apply
ring-2
ring-primary-500/30
border-primary-500
;
@apply
border-primary-500
ring-2
ring-primary-500/30;
}
.select-trigger-disabled
{
@apply
bg-gray-100
dark
:
bg-dark-900
cursor-not-allowed
opacity-6
0
;
@apply
cursor-not-allowed
bg-gray-100
opacity-60
dark
:
bg-dark-90
0
;
}
.select-value
{
@apply
flex-1
t
ext-left
truncate
;
@apply
flex-1
t
runcate
text-left
;
}
.select-icon
{
...
...
@@ -348,7 +402,7 @@ onUnmounted(() => {
}
.select-dropdown
{
@apply
absolute
z-[100]
w-full
mt-2
;
@apply
absolute
z-[100]
mt-2
w-full;
@apply
bg-white
dark
:
bg-dark-800
;
@apply
rounded-xl;
@apply
border
border-gray-200
dark
:
border-dark-700
;
...
...
@@ -362,7 +416,7 @@ onUnmounted(() => {
}
.select-search
{
@apply
flex
-1
flex
items-center
gap-2;
@apply
flex
flex
-1
items-center
gap-2;
}
.select-search-input
{
...
...
@@ -373,10 +427,10 @@ onUnmounted(() => {
}
.batch-test-btn
{
@apply
flex-shrink-0
p-1.5
rounded-lg;
@apply
flex-shrink-0
rounded-lg
p-1.5
;
@apply
text-gray-500
hover
:
text-emerald-600
dark
:
hover
:
text-emerald-400
;
@apply
hover
:
bg-emerald-50
dark
:
hover
:
bg-emerald-900
/
20
;
@apply
transition-colors
disabled
:
opacity-50
disabled
:
cursor-not-allowed
;
@apply
transition-colors
disabled
:
cursor-not-allowed
disabled
:
opacity-50
;
}
.select-options
{
...
...
@@ -406,10 +460,10 @@ onUnmounted(() => {
}
.test-btn
{
@apply
flex-shrink-0
p-1
rounded;
@apply
flex-shrink-0
rounded
p-1
;
@apply
text-gray-400
hover
:
text-emerald-600
dark
:
hover
:
text-emerald-400
;
@apply
hover
:
bg-emerald-50
dark
:
hover
:
bg-emerald-900
/
20
;
@apply
transition-colors
disabled
:
opacity-50
disabled
:
cursor-not-allowed
;
@apply
transition-colors
disabled
:
cursor-not-allowed
disabled
:
opacity-50
;
}
/* Dropdown animation */
...
...
frontend/src/components/common/README.md
View file @
429f38d0
...
...
@@ -5,18 +5,22 @@ This directory contains reusable Vue 3 components built with Composition API, Ty
## Components
### DataTable.vue
A generic data table component with sorting, loading states, and custom cell rendering.
**Props:**
-
`columns: Column[]`
- Array of column definitions with key, label, sortable, and formatter
-
`data: any[]`
- Array of data objects to display
-
`loading?: boolean`
- Show loading skeleton
**Slots:**
-
`empty`
- Custom empty state content
-
`cell-{key}`
- Custom cell renderer for specific column (receives
`row`
and
`value`
)
**Usage:**
```
vue
<DataTable
:columns=
"[
...
...
@@ -36,19 +40,23 @@ A generic data table component with sorting, loading states, and custom cell ren
---
### Pagination.vue
Pagination component with page numbers, navigation, and page size selector.
**Props:**
-
`total: number`
- Total number of items
-
`page: number`
- Current page (1-indexed)
-
`pageSize: number`
- Items per page
-
`pageSizeOptions?: number[]`
- Available page size options (default: [10, 20, 50, 100])
**Events:**
-
`update:page`
- Emitted when page changes
-
`update:pageSize`
- Emitted when page size changes
**Usage:**
```
vue
<Pagination
:total=
"totalUsers"
...
...
@@ -62,9 +70,11 @@ Pagination component with page numbers, navigation, and page size selector.
---
### Modal.vue
Modal dialog with customizable size and close behavior.
**Props:**
-
`show: boolean`
- Control modal visibility
-
`title: string`
- Modal title
-
`size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'`
- Modal size (default: 'md')
...
...
@@ -72,13 +82,16 @@ Modal dialog with customizable size and close behavior.
-
`closeOnClickOutside?: boolean`
- Close on backdrop click (default: true)
**Events:**
-
`close`
- Emitted when modal should close
**Slots:**
-
`default`
- Modal body content
-
`footer`
- Modal footer content
**Usage:**
```
vue
<Modal
:show=
"showModal"
title=
"Edit User"
size=
"lg"
@
close=
"showModal = false"
>
<form
@
submit.prevent=
"saveUser"
>
...
...
@@ -95,9 +108,11 @@ Modal dialog with customizable size and close behavior.
---
### ConfirmDialog.vue
Confirmation dialog built on top of Modal component.
**Props:**
-
`show: boolean`
- Control dialog visibility
-
`title: string`
- Dialog title
-
`message: string`
- Confirmation message
...
...
@@ -106,10 +121,12 @@ Confirmation dialog built on top of Modal component.
-
`danger?: boolean`
- Use danger/red styling (default: false)
**Events:**
-
`confirm`
- Emitted when user confirms
-
`cancel`
- Emitted when user cancels
**Usage:**
```
vue
<ConfirmDialog
:show=
"showDeleteConfirm"
...
...
@@ -126,9 +143,11 @@ Confirmation dialog built on top of Modal component.
---
### StatCard.vue
Statistics card component for displaying metrics with optional change indicators.
**Props:**
-
`title: string`
- Card title
-
`value: number | string`
- Main value to display
-
`icon?: Component`
- Icon component
...
...
@@ -137,22 +156,19 @@ Statistics card component for displaying metrics with optional change indicators
-
`formatValue?: (value) => string`
- Custom value formatter
**Usage:**
```
vue
<StatCard
title=
"Total Users"
:value=
"1234"
:icon=
"UserIcon"
:change=
"12.5"
change-type=
"up"
/>
<StatCard
title=
"Total Users"
:value=
"1234"
:icon=
"UserIcon"
:change=
"12.5"
change-type=
"up"
/>
```
---
### Toast.vue
Toast notification component that automatically displays toasts from the app store.
**Usage:**
```
vue
<!-- Add once in App.vue or layout -->
<Toast
/>
...
...
@@ -180,13 +196,16 @@ appStore.addToast({
---
### LoadingSpinner.vue
Simple animated loading spinner.
**Props:**
-
`size?: 'sm' | 'md' | 'lg' | 'xl'`
- Spinner size (default: 'md')
-
`color?: 'primary' | 'secondary' | 'white' | 'gray'`
- Spinner color (default: 'primary')
**Usage:**
```
vue
<LoadingSpinner
size=
"lg"
color=
"primary"
/>
```
...
...
@@ -194,9 +213,11 @@ Simple animated loading spinner.
---
### EmptyState.vue
Empty state placeholder with icon, message, and optional action button.
**Props:**
-
`icon?: Component`
- Icon component
-
`title: string`
- Empty state title
-
`description: string`
- Empty state description
...
...
@@ -205,10 +226,12 @@ Empty state placeholder with icon, message, and optional action button.
-
`actionIcon?: boolean`
- Show plus icon in button (default: true)
**Slots:**
-
`icon`
- Custom icon content
-
`action`
- Custom action button/link
**Usage:**
```
vue
<EmptyState
title=
"No users found"
...
...
@@ -235,6 +258,7 @@ import DataTable from '@/components/common/DataTable.vue'
## Features
All components include:
-
**TypeScript support**
with proper type definitions
-
**Accessibility**
with ARIA attributes and keyboard navigation
-
**Responsive design**
with mobile-friendly layouts
...
...
frontend/src/components/common/Select.vue
View file @
429f38d0
...
...
@@ -18,7 +18,7 @@
</span>
<span
class=
"select-icon"
>
<svg
:class=
"['
w
-5
h
-5 transition-transform duration-200', isOpen && 'rotate-180']"
:class=
"['
h
-5
w
-5 transition-transform duration-200', isOpen && 'rotate-180']"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
...
...
@@ -30,14 +30,21 @@
</button>
<Transition
name=
"select-dropdown"
>
<div
v-if=
"isOpen"
class=
"select-dropdown"
>
<div
v-if=
"isOpen"
class=
"select-dropdown"
>
<!-- Search input -->
<div
v-if=
"searchable"
class=
"select-search"
>
<svg
class=
"w-4 h-4 text-gray-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
<svg
class=
"h-4 w-4 text-gray-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
<input
ref=
"searchInputRef"
...
...
@@ -55,16 +62,13 @@
v-for=
"option in filteredOptions"
:key=
"getOptionValue(option) ?? undefined"
@
click=
"selectOption(option)"
:class=
"[
'select-option',
isSelected(option) && 'select-option-selected'
]"
:class=
"['select-option', isSelected(option) && 'select-option-selected']"
>
<slot
name=
"option"
:option=
"option"
:selected=
"isSelected(option)"
>
<span
class=
"select-option-label"
>
{{
getOptionLabel
(
option
)
}}
</span>
<svg
v-if=
"isSelected(option)"
class=
"
w
-4
h
-4 text-primary-500"
class=
"
h
-4
w
-4 text-primary-500"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
...
...
@@ -126,7 +130,9 @@ const props = withDefaults(defineProps<Props>(), {
// Use computed for i18n default values
const
placeholderText
=
computed
(()
=>
props
.
placeholder
??
t
(
'
common.selectOption
'
))
const
searchPlaceholderText
=
computed
(()
=>
props
.
searchPlaceholder
??
t
(
'
common.searchPlaceholder
'
))
const
searchPlaceholderText
=
computed
(
()
=>
props
.
searchPlaceholder
??
t
(
'
common.searchPlaceholder
'
)
)
const
emptyTextDisplay
=
computed
(()
=>
props
.
emptyText
??
t
(
'
common.noOptionsFound
'
))
const
emit
=
defineEmits
<
Emits
>
()
...
...
@@ -136,7 +142,9 @@ const searchQuery = ref('')
const
containerRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
searchInputRef
=
ref
<
HTMLInputElement
|
null
>
(
null
)
const
getOptionValue
=
(
option
:
SelectOption
|
Record
<
string
,
unknown
>
):
string
|
number
|
null
|
undefined
=>
{
const
getOptionValue
=
(
option
:
SelectOption
|
Record
<
string
,
unknown
>
):
string
|
number
|
null
|
undefined
=>
{
if
(
typeof
option
===
'
object
'
&&
option
!==
null
)
{
return
option
[
props
.
valueKey
]
as
string
|
number
|
null
|
undefined
}
...
...
@@ -151,7 +159,7 @@ const getOptionLabel = (option: SelectOption | Record<string, unknown>): string
}
const
selectedOption
=
computed
(()
=>
{
return
props
.
options
.
find
(
opt
=>
getOptionValue
(
opt
)
===
props
.
modelValue
)
||
null
return
props
.
options
.
find
(
(
opt
)
=>
getOptionValue
(
opt
)
===
props
.
modelValue
)
||
null
})
const
selectedLabel
=
computed
(()
=>
{
...
...
@@ -166,7 +174,7 @@ const filteredOptions = computed(() => {
return
props
.
options
}
const
query
=
searchQuery
.
value
.
toLowerCase
()
return
props
.
options
.
filter
(
opt
=>
{
return
props
.
options
.
filter
(
(
opt
)
=>
{
const
label
=
getOptionLabel
(
opt
).
toLowerCase
()
return
label
.
includes
(
query
)
})
...
...
@@ -227,31 +235,31 @@ onUnmounted(() => {
<
style
scoped
>
.select-trigger
{
@apply
w-full
flex
items-center
justify-between
gap-2;
@apply
px-4
py-2.5
rounded-xl
text-sm;
@apply
flex
w-full
items-center
justify-between
gap-2;
@apply
rounded-xl
px-4
py-2.5
text-sm;
@apply
bg-white
dark
:
bg-dark-800
;
@apply
border
border-gray-200
dark
:
border-dark-600
;
@apply
text-gray-900
dark
:
text-gray-100
;
@apply
transition-all
duration-200;
@apply
focus
:
outline-none
focus
:
ring-2
focus
:
ring-primary-500
/
30
focus
:
border-primary-500
;
@apply
focus
:
border-primary-500
focus
:
outline-none
focus
:
ring-2
focus
:
ring-primary-500
/
30
;
@apply
hover
:
border-gray-300
dark
:
hover
:
border-dark-500
;
@apply
cursor-pointer;
}
.select-trigger-open
{
@apply
ring-2
ring-primary-500/30
border-primary-500
;
@apply
border-primary-500
ring-2
ring-primary-500/30;
}
.select-trigger-error
{
@apply
border-red-500
focus
:
ring
-red-500
/
30
focus
:
border
-red-500
;
@apply
border-red-500
focus
:
border
-red-500
focus
:
ring
-red-500
/
30
;
}
.select-trigger-disabled
{
@apply
bg-gray-100
dark
:
bg-dark-900
cursor-not-allowed
opacity-6
0
;
@apply
cursor-not-allowed
bg-gray-100
opacity-60
dark
:
bg-dark-90
0
;
}
.select-value
{
@apply
flex-1
t
ext-left
truncate
;
@apply
flex-1
t
runcate
text-left
;
}
.select-icon
{
...
...
@@ -259,7 +267,7 @@ onUnmounted(() => {
}
.select-dropdown
{
@apply
absolute
z-[100]
w-full
mt-2
;
@apply
absolute
z-[100]
mt-2
w-full;
@apply
bg-white
dark
:
bg-dark-800
;
@apply
rounded-xl;
@apply
border
border-gray-200
dark
:
border-dark-700
;
...
...
frontend/src/components/common/StatCard.vue
View file @
429f38d0
<
template
>
<div
class=
"stat-card"
>
<div
:class=
"['stat-icon', iconClass]"
>
<component
v-if=
"icon"
:is=
"icon"
class=
"w-6 h-6"
aria-hidden=
"true"
/>
<component
v-if=
"icon"
:is=
"icon"
class=
"h-6 w-6"
aria-hidden=
"true"
/>
</div>
<div
class=
"
flex-1
min-w-0"
>
<div
class=
"min-w-0
flex-1
"
>
<p
class=
"stat-label truncate"
>
{{
title
}}
</p>
<div
class=
"flex items-baseline gap-2
mt-1
"
>
<div
class=
"
mt-1
flex items-baseline gap-2"
>
<p
class=
"stat-value"
>
{{
formattedValue
}}
</p>
<span
v-if=
"change !== undefined"
:class=
"['stat-trend', trendClass]"
>
<span
v-if=
"change !== undefined"
:class=
"['stat-trend', trendClass]"
>
<svg
v-if=
"changeType !== 'neutral'"
:class=
"['
w
-3
h
-3', changeType === 'down' && 'rotate-180']"
:class=
"['
h
-3
w
-3', changeType === 'down' && 'rotate-180']"
fill=
"currentColor"
viewBox=
"0 0 20 20"
>
...
...
frontend/src/components/common/SubscriptionProgressMini.vue
View file @
429f38d0
...
...
@@ -3,11 +3,21 @@
<!-- Mini Progress Display -->
<button
@
click=
"toggleTooltip"
class=
"flex items-center gap-2
px-3 py-1.5
rounded-xl bg-purple-50
dark
:bg-purple-
9
00
/20 hover
:bg-purple-
10
0 dark:hover:bg-purple-900/30
transition-colors cursor-pointer
"
class=
"flex
cursor-pointer
items-center gap-2 rounded-xl bg-purple-50
px-3 py-1.5 transition-colors hover
:bg-purple-
1
00
dark
:bg-purple-
900/2
0 dark:hover:bg-purple-900/30"
:title=
"t('subscriptionProgress.viewDetails')"
>
<svg
class=
"w-4 h-4 text-purple-600 dark:text-purple-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"
/>
<svg
class=
"h-4 w-4 text-purple-600 dark:text-purple-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"
/>
</svg>
<div
class=
"flex items-center gap-1.5"
>
<!-- Combined progress indicator -->
...
...
@@ -15,7 +25,7 @@
<div
v-for=
"(sub, index) in displaySubscriptions.slice(0, 3)"
:key=
"index"
class=
"
w
-2
h
-2 rounded-full"
class=
"
h
-2
w
-2 rounded-full"
:class=
"getProgressDotClass(sub)"
></div>
</div>
...
...
@@ -29,13 +39,13 @@
<transition
name=
"dropdown"
>
<div
v-if=
"tooltipOpen"
class=
"absolute right-0 mt-2 w-[340px]
bg-white dark:bg-dark-800
rounded-xl
shadow-xl
border border-gray-200 dark:border-dark-700
z-50 overflow-hidden
"
class=
"absolute right-0
z-50
mt-2 w-[340px]
overflow-hidden
rounded-xl border border-gray-200
bg-white shadow-xl
dark:border-dark-700
dark:bg-dark-800
"
>
<div
class=
"
p-3
border-b border-gray-100 dark:border-dark-700"
>
<div
class=
"border-b border-gray-100
p-3
dark:border-dark-700"
>
<h3
class=
"text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
subscriptionProgress.title
'
)
}}
</h3>
<p
class=
"text-xs text-gray-500 dark:text-dark-400
mt-0.5
"
>
<p
class=
"
mt-0.5
text-xs text-gray-500 dark:text-dark-400"
>
{{
t
(
'
subscriptionProgress.activeCount
'
,
{
count
:
activeSubscriptions
.
length
}
)
}}
<
/p
>
<
/div
>
...
...
@@ -44,9 +54,9 @@
<
div
v
-
for
=
"
subscription in displaySubscriptions
"
:
key
=
"
subscription.id
"
class
=
"
p-3
border-b border-gray-50 dark:border-dark-700/5
0 last:border-b-
0
"
class
=
"
border-b border-gray-50
p-3 last:border-b-0
dark:border-dark-700/50
"
>
<
div
class
=
"
flex items-center justify-between
mb-2
"
>
<
div
class
=
"
mb-2
flex items-center justify-between
"
>
<
span
class
=
"
text-sm font-medium text-gray-900 dark:text-white
"
>
{{
subscription
.
group
?.
name
||
`Group #${subscription.group_id
}
`
}}
<
/span
>
...
...
@@ -62,55 +72,100 @@
<!--
Progress
bars
-->
<
div
class
=
"
space-y-1.5
"
>
<
div
v
-
if
=
"
subscription.group?.daily_limit_usd
"
class
=
"
flex items-center gap-2
"
>
<
span
class
=
"
text-[10px] text-gray-500 w-8 flex-shrink-0
"
>
{{
t
(
'
subscriptionProgress.daily
'
)
}}
<
/span
>
<
div
class
=
"
flex-1 min-w-0 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5
"
>
<
span
class
=
"
w-8 flex-shrink-0 text-[10px] text-gray-500
"
>
{{
t
(
'
subscriptionProgress.daily
'
)
}}
<
/span
>
<
div
class
=
"
h-1.5 min-w-0 flex-1 rounded-full bg-gray-200 dark:bg-dark-600
"
>
<
div
class
=
"
h-1.5 rounded-full transition-all
"
:
class
=
"
getProgressBarClass(subscription.daily_usage_usd, subscription.group?.daily_limit_usd)
"
:
style
=
"
{ width: getProgressWidth(subscription.daily_usage_usd, subscription.group?.daily_limit_usd)
}
"
:
class
=
"
getProgressBarClass(
subscription.daily_usage_usd,
subscription.group?.daily_limit_usd
)
"
:
style
=
"
{
width: getProgressWidth(
subscription.daily_usage_usd,
subscription.group?.daily_limit_usd
)
}
"
><
/div
>
<
/div
>
<
span
class
=
"
text-[10px] text-gray-500 w-24 text-right flex-shrink-0
"
>
{{
formatUsage
(
subscription
.
daily_usage_usd
,
subscription
.
group
?.
daily_limit_usd
)
}}
<
span
class
=
"
w-24 flex-shrink-0 text-right text-[10px] text-gray-500
"
>
{{
formatUsage
(
subscription
.
daily_usage_usd
,
subscription
.
group
?.
daily_limit_usd
)
}}
<
/span
>
<
/div
>
<
div
v
-
if
=
"
subscription.group?.weekly_limit_usd
"
class
=
"
flex items-center gap-2
"
>
<
span
class
=
"
text-[10px] text-gray-500 w-8 flex-shrink-0
"
>
{{
t
(
'
subscriptionProgress.weekly
'
)
}}
<
/span
>
<
div
class
=
"
flex-1 min-w-0 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5
"
>
<
span
class
=
"
w-8 flex-shrink-0 text-[10px] text-gray-500
"
>
{{
t
(
'
subscriptionProgress.weekly
'
)
}}
<
/span
>
<
div
class
=
"
h-1.5 min-w-0 flex-1 rounded-full bg-gray-200 dark:bg-dark-600
"
>
<
div
class
=
"
h-1.5 rounded-full transition-all
"
:
class
=
"
getProgressBarClass(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd)
"
:
style
=
"
{ width: getProgressWidth(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd)
}
"
:
class
=
"
getProgressBarClass(
subscription.weekly_usage_usd,
subscription.group?.weekly_limit_usd
)
"
:
style
=
"
{
width: getProgressWidth(
subscription.weekly_usage_usd,
subscription.group?.weekly_limit_usd
)
}
"
><
/div
>
<
/div
>
<
span
class
=
"
text-[10px] text-gray-500 w-24 text-right flex-shrink-0
"
>
{{
formatUsage
(
subscription
.
weekly_usage_usd
,
subscription
.
group
?.
weekly_limit_usd
)
}}
<
span
class
=
"
w-24 flex-shrink-0 text-right text-[10px] text-gray-500
"
>
{{
formatUsage
(
subscription
.
weekly_usage_usd
,
subscription
.
group
?.
weekly_limit_usd
)
}}
<
/span
>
<
/div
>
<
div
v
-
if
=
"
subscription.group?.monthly_limit_usd
"
class
=
"
flex items-center gap-2
"
>
<
span
class
=
"
text-[10px] text-gray-500 w-8 flex-shrink-0
"
>
{{
t
(
'
subscriptionProgress.monthly
'
)
}}
<
/span
>
<
div
class
=
"
flex-1 min-w-0 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5
"
>
<
span
class
=
"
w-8 flex-shrink-0 text-[10px] text-gray-500
"
>
{{
t
(
'
subscriptionProgress.monthly
'
)
}}
<
/span
>
<
div
class
=
"
h-1.5 min-w-0 flex-1 rounded-full bg-gray-200 dark:bg-dark-600
"
>
<
div
class
=
"
h-1.5 rounded-full transition-all
"
:
class
=
"
getProgressBarClass(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd)
"
:
style
=
"
{ width: getProgressWidth(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd)
}
"
:
class
=
"
getProgressBarClass(
subscription.monthly_usage_usd,
subscription.group?.monthly_limit_usd
)
"
:
style
=
"
{
width: getProgressWidth(
subscription.monthly_usage_usd,
subscription.group?.monthly_limit_usd
)
}
"
><
/div
>
<
/div
>
<
span
class
=
"
text-[10px] text-gray-500 w-24 text-right flex-shrink-0
"
>
{{
formatUsage
(
subscription
.
monthly_usage_usd
,
subscription
.
group
?.
monthly_limit_usd
)
}}
<
span
class
=
"
w-24 flex-shrink-0 text-right text-[10px] text-gray-500
"
>
{{
formatUsage
(
subscription
.
monthly_usage_usd
,
subscription
.
group
?.
monthly_limit_usd
)
}}
<
/span
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
div
class
=
"
p-2
border-t border-gray-100 dark:border-dark-700
"
>
<
div
class
=
"
border-t border-gray-100
p-2
dark:border-dark-700
"
>
<
router
-
link
to
=
"
/subscriptions
"
@
click
=
"
closeTooltip
"
class
=
"
block w-full text-center text-xs text-primary-600 dark:text-primary-400
hover:underline py-1
"
class
=
"
block w-full
py-1
text-center text-xs text-primary-600
hover:underline
dark:text-primary-400
"
>
{{
t
(
'
subscriptionProgress.viewAll
'
)
}}
<
/router-link
>
...
...
@@ -121,136 +176,136 @@
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
computed
,
onMounted
,
onBeforeUnmount
}
from
'
vue
'
;
import
{
useI18n
}
from
'
vue-i18n
'
;
import
subscriptionsAPI
from
'
@/api/subscriptions
'
;
import
type
{
UserSubscription
}
from
'
@/types
'
;
import
{
ref
,
computed
,
onMounted
,
onBeforeUnmount
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
subscriptionsAPI
from
'
@/api/subscriptions
'
import
type
{
UserSubscription
}
from
'
@/types
'
const
{
t
}
=
useI18n
()
;
const
{
t
}
=
useI18n
()
const
containerRef
=
ref
<
HTMLElement
|
null
>
(
null
)
;
const
tooltipOpen
=
ref
(
false
)
;
const
activeSubscriptions
=
ref
<
UserSubscription
[]
>
([])
;
const
loading
=
ref
(
false
)
;
const
containerRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
tooltipOpen
=
ref
(
false
)
const
activeSubscriptions
=
ref
<
UserSubscription
[]
>
([])
const
loading
=
ref
(
false
)
const
hasActiveSubscriptions
=
computed
(()
=>
activeSubscriptions
.
value
.
length
>
0
)
;
const
hasActiveSubscriptions
=
computed
(()
=>
activeSubscriptions
.
value
.
length
>
0
)
const
displaySubscriptions
=
computed
(()
=>
{
// Sort by most usage (highest percentage first)
return
[...
activeSubscriptions
.
value
].
sort
((
a
,
b
)
=>
{
const
aMax
=
getMaxUsagePercentage
(
a
)
;
const
bMax
=
getMaxUsagePercentage
(
b
)
;
return
bMax
-
aMax
;
}
)
;
}
)
;
const
aMax
=
getMaxUsagePercentage
(
a
)
const
bMax
=
getMaxUsagePercentage
(
b
)
return
bMax
-
aMax
}
)
}
)
function
getMaxUsagePercentage
(
sub
:
UserSubscription
):
number
{
const
percentages
:
number
[]
=
[]
;
const
percentages
:
number
[]
=
[]
if
(
sub
.
group
?.
daily_limit_usd
)
{
percentages
.
push
((
sub
.
daily_usage_usd
||
0
)
/
sub
.
group
.
daily_limit_usd
*
100
)
;
percentages
.
push
((
(
sub
.
daily_usage_usd
||
0
)
/
sub
.
group
.
daily_limit_usd
)
*
100
)
}
if
(
sub
.
group
?.
weekly_limit_usd
)
{
percentages
.
push
((
sub
.
weekly_usage_usd
||
0
)
/
sub
.
group
.
weekly_limit_usd
*
100
)
;
percentages
.
push
((
(
sub
.
weekly_usage_usd
||
0
)
/
sub
.
group
.
weekly_limit_usd
)
*
100
)
}
if
(
sub
.
group
?.
monthly_limit_usd
)
{
percentages
.
push
((
sub
.
monthly_usage_usd
||
0
)
/
sub
.
group
.
monthly_limit_usd
*
100
)
;
percentages
.
push
((
(
sub
.
monthly_usage_usd
||
0
)
/
sub
.
group
.
monthly_limit_usd
)
*
100
)
}
return
percentages
.
length
>
0
?
Math
.
max
(...
percentages
)
:
0
;
return
percentages
.
length
>
0
?
Math
.
max
(...
percentages
)
:
0
}
function
getProgressDotClass
(
sub
:
UserSubscription
):
string
{
const
maxPercentage
=
getMaxUsagePercentage
(
sub
)
;
if
(
maxPercentage
>=
90
)
return
'
bg-red-500
'
;
if
(
maxPercentage
>=
70
)
return
'
bg-orange-500
'
;
return
'
bg-green-500
'
;
const
maxPercentage
=
getMaxUsagePercentage
(
sub
)
if
(
maxPercentage
>=
90
)
return
'
bg-red-500
'
if
(
maxPercentage
>=
70
)
return
'
bg-orange-500
'
return
'
bg-green-500
'
}
function
getProgressBarClass
(
used
:
number
|
undefined
,
limit
:
number
|
null
|
undefined
):
string
{
if
(
!
limit
||
limit
===
0
)
return
'
bg-gray-400
'
;
const
percentage
=
((
used
||
0
)
/
limit
)
*
100
;
if
(
percentage
>=
90
)
return
'
bg-red-500
'
;
if
(
percentage
>=
70
)
return
'
bg-orange-500
'
;
return
'
bg-green-500
'
;
if
(
!
limit
||
limit
===
0
)
return
'
bg-gray-400
'
const
percentage
=
((
used
||
0
)
/
limit
)
*
100
if
(
percentage
>=
90
)
return
'
bg-red-500
'
if
(
percentage
>=
70
)
return
'
bg-orange-500
'
return
'
bg-green-500
'
}
function
getProgressWidth
(
used
:
number
|
undefined
,
limit
:
number
|
null
|
undefined
):
string
{
if
(
!
limit
||
limit
===
0
)
return
'
0%
'
;
const
percentage
=
Math
.
min
(((
used
||
0
)
/
limit
)
*
100
,
100
)
;
return
`${percentage
}
%`
;
if
(
!
limit
||
limit
===
0
)
return
'
0%
'
const
percentage
=
Math
.
min
(((
used
||
0
)
/
limit
)
*
100
,
100
)
return
`${percentage
}
%`
}
function
formatUsage
(
used
:
number
|
undefined
,
limit
:
number
|
null
|
undefined
):
string
{
const
usedValue
=
(
used
||
0
).
toFixed
(
2
)
;
const
limitValue
=
limit
?.
toFixed
(
2
)
||
'
∞
'
;
return
`$${usedValue
}
/$${limitValue
}
`
;
const
usedValue
=
(
used
||
0
).
toFixed
(
2
)
const
limitValue
=
limit
?.
toFixed
(
2
)
||
'
∞
'
return
`$${usedValue
}
/$${limitValue
}
`
}
function
formatDaysRemaining
(
expiresAt
:
string
):
string
{
const
now
=
new
Date
()
;
const
expires
=
new
Date
(
expiresAt
)
;
const
diff
=
expires
.
getTime
()
-
now
.
getTime
()
;
if
(
diff
<
0
)
return
t
(
'
subscriptionProgress.expired
'
)
;
const
days
=
Math
.
ceil
(
diff
/
(
1000
*
60
*
60
*
24
))
;
if
(
days
===
0
)
return
t
(
'
subscriptionProgress.expirestoday
'
)
;
if
(
days
===
1
)
return
t
(
'
subscriptionProgress.expiresTomorrow
'
)
;
return
t
(
'
subscriptionProgress.daysRemaining
'
,
{
days
}
)
;
const
now
=
new
Date
()
const
expires
=
new
Date
(
expiresAt
)
const
diff
=
expires
.
getTime
()
-
now
.
getTime
()
if
(
diff
<
0
)
return
t
(
'
subscriptionProgress.expired
'
)
const
days
=
Math
.
ceil
(
diff
/
(
1000
*
60
*
60
*
24
))
if
(
days
===
0
)
return
t
(
'
subscriptionProgress.expirestoday
'
)
if
(
days
===
1
)
return
t
(
'
subscriptionProgress.expiresTomorrow
'
)
return
t
(
'
subscriptionProgress.daysRemaining
'
,
{
days
}
)
}
function
getDaysRemainingClass
(
expiresAt
:
string
):
string
{
const
now
=
new
Date
()
;
const
expires
=
new
Date
(
expiresAt
)
;
const
diff
=
expires
.
getTime
()
-
now
.
getTime
()
;
const
days
=
Math
.
ceil
(
diff
/
(
1000
*
60
*
60
*
24
))
;
if
(
days
<=
3
)
return
'
text-red-600 dark:text-red-400
'
;
if
(
days
<=
7
)
return
'
text-orange-600 dark:text-orange-400
'
;
return
'
text-gray-500 dark:text-dark-400
'
;
const
now
=
new
Date
()
const
expires
=
new
Date
(
expiresAt
)
const
diff
=
expires
.
getTime
()
-
now
.
getTime
()
const
days
=
Math
.
ceil
(
diff
/
(
1000
*
60
*
60
*
24
))
if
(
days
<=
3
)
return
'
text-red-600 dark:text-red-400
'
if
(
days
<=
7
)
return
'
text-orange-600 dark:text-orange-400
'
return
'
text-gray-500 dark:text-dark-400
'
}
function
toggleTooltip
()
{
tooltipOpen
.
value
=
!
tooltipOpen
.
value
;
tooltipOpen
.
value
=
!
tooltipOpen
.
value
}
function
closeTooltip
()
{
tooltipOpen
.
value
=
false
;
tooltipOpen
.
value
=
false
}
function
handleClickOutside
(
event
:
MouseEvent
)
{
if
(
containerRef
.
value
&&
!
containerRef
.
value
.
contains
(
event
.
target
as
Node
))
{
closeTooltip
()
;
closeTooltip
()
}
}
async
function
loadSubscriptions
()
{
try
{
loading
.
value
=
true
;
activeSubscriptions
.
value
=
await
subscriptionsAPI
.
getActiveSubscriptions
()
;
loading
.
value
=
true
activeSubscriptions
.
value
=
await
subscriptionsAPI
.
getActiveSubscriptions
()
}
catch
(
error
)
{
console
.
error
(
'
Failed to load subscriptions:
'
,
error
)
;
activeSubscriptions
.
value
=
[]
;
console
.
error
(
'
Failed to load subscriptions:
'
,
error
)
activeSubscriptions
.
value
=
[]
}
finally
{
loading
.
value
=
false
;
loading
.
value
=
false
}
}
onMounted
(()
=>
{
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
;
loadSubscriptions
()
;
}
)
;
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
loadSubscriptions
()
}
)
onBeforeUnmount
(()
=>
{
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
;
}
)
;
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
}
)
// Refresh subscriptions periodically (every 5 minutes)
let
refreshInterval
:
ReturnType
<
typeof
setInterval
>
|
null
=
null
;
let
refreshInterval
:
ReturnType
<
typeof
setInterval
>
|
null
=
null
onMounted
(()
=>
{
refreshInterval
=
setInterval
(
loadSubscriptions
,
5
*
60
*
1000
)
;
}
)
;
refreshInterval
=
setInterval
(
loadSubscriptions
,
5
*
60
*
1000
)
}
)
onBeforeUnmount
(()
=>
{
if
(
refreshInterval
)
{
clearInterval
(
refreshInterval
)
;
clearInterval
(
refreshInterval
)
}
}
)
;
}
)
<
/script
>
<
style
scoped
>
...
...
frontend/src/components/common/Toast.vue
View file @
429f38d0
<
template
>
<Teleport
to=
"body"
>
<div
class=
"
fixed top-4
right-4 z-[9999] space-y-3
pointer-events-none
"
class=
"
pointer-events-none fixed
right-4
top-4
z-[9999] space-y-3"
aria-live=
"polite"
aria-atomic=
"true"
>
...
...
@@ -26,26 +26,25 @@
<div
class=
"p-4"
>
<div
class=
"flex items-start gap-3"
>
<!-- Icon -->
<div
class=
"flex-shrink-0
mt-0.5
"
>
<div
class=
"
mt-0.5
flex-shrink-0"
>
<component
:is=
"getIcon(toast.type)"
:class=
"['
w
-5
h
-5', getIconColor(toast.type)]"
:class=
"['
h
-5
w
-5', getIconColor(toast.type)]"
aria-hidden=
"true"
/>
</div>
<!-- Content -->
<div
class=
"flex-1 min-w-0"
>
<p
v-if=
"toast.title"
class=
"text-sm font-semibold text-gray-900 dark:text-white"
>
<div
class=
"min-w-0 flex-1"
>
<p
v-if=
"toast.title"
class=
"text-sm font-semibold text-gray-900 dark:text-white"
>
{{
toast
.
title
}}
</p>
<p
:class=
"[
'text-sm leading-relaxed',
toast.title ? 'mt-1 text-gray-600 dark:text-gray-300' : 'text-gray-900 dark:text-white'
toast.title
? 'mt-1 text-gray-600 dark:text-gray-300'
: 'text-gray-900 dark:text-white'
]"
>
{{
toast
.
message
}}
...
...
@@ -55,10 +54,10 @@
<!-- Close button -->
<button
@
click=
"removeToast(toast.id)"
class=
"flex-shrink-0
p-1 -m
-1 text-gray-400
dark:text-gray-500
transition-colors
rounded
hover:text-gray-600 dark:
hover:
text-gray-
3
00 hover:bg-
gray-1
00 dark:hover:
bg-dark-7
00"
class=
"
-m-1
flex-shrink-0
rounded p
-1 text-gray-400 transition-colors
hover:bg-gray-100
hover:text-gray-600 dark:text-gray-
5
00
dark:
hover:bg-
dark-7
00 dark:hover:
text-gray-3
00"
aria-label=
"Close notification"
>
<svg
class=
"
w
-4
h
-4"
fill=
"currentColor"
viewBox=
"0 0 20 20"
>
<svg
class=
"
h
-4
w
-4"
fill=
"currentColor"
viewBox=
"0 0 20 20"
>
<path
fill-rule=
"evenodd"
d=
"M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
...
...
@@ -70,10 +69,7 @@
</div>
<!-- Progress bar -->
<div
v-if=
"toast.duration"
class=
"h-1 bg-gray-100 dark:bg-dark-700"
>
<div
v-if=
"toast.duration"
class=
"h-1 bg-gray-100 dark:bg-dark-700"
>
<div
:class=
"['h-full transition-all', getProgressBarColor(toast.type)]"
:style=
"
{ width: `${getProgress(toast)}%` }"
...
...
frontend/src/components/common/Toggle.vue
View file @
429f38d0
...
...
@@ -3,33 +3,27 @@
type=
"button"
@
click=
"toggle"
class=
"relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-dark-800"
:class=
"[
modelValue
? 'bg-primary-600'
: 'bg-gray-200 dark:bg-dark-600'
]"
:class=
"[modelValue ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600']"
role=
"switch"
:aria-checked=
"modelValue"
>
<span
class=
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class=
"[
modelValue ? 'translate-x-5' : 'translate-x-0'
]"
:class=
"[modelValue ? 'translate-x-5' : 'translate-x-0']"
/>
</button>
</
template
>
<
script
setup
lang=
"ts"
>
const
props
=
defineProps
<
{
modelValue
:
boolean
;
}
>
()
;
modelValue
:
boolean
}
>
()
const
emit
=
defineEmits
<
{
(
e
:
'
update:modelValue
'
,
value
:
boolean
):
void
;
}
>
()
;
(
e
:
'
update:modelValue
'
,
value
:
boolean
):
void
}
>
()
function
toggle
()
{
emit
(
'
update:modelValue
'
,
!
props
.
modelValue
)
;
emit
(
'
update:modelValue
'
,
!
props
.
modelValue
)
}
</
script
>
frontend/src/components/common/VersionBadge.vue
View file @
429f38d0
...
...
@@ -4,20 +4,25 @@
<template
v-if=
"isAdmin"
>
<button
@
click=
"toggleDropdown"
class=
"flex items-center gap-1.5 px-2 py-1 text-xs
rounded-lg
transition-colors"
class=
"flex items-center gap-1.5
rounded-lg
px-2 py-1 text-xs transition-colors"
:class=
"[
hasUpdate
? 'bg-amber-100
dark:bg
-amber-
9
00
/30 text
-amber-
7
00 dark:
text
-amber-
4
00
hover:bg
-amber-
2
00 dark:hover:bg-amber-900/50'
: 'bg-gray-100 dark:bg-dark-8
00 text-gray-6
00 dark:text-dark-400
hover:bg-gray-200
dark:hover:bg-dark-700'
? 'bg-amber-100
text
-amber-
7
00
hover:bg
-amber-
2
00 dark:
bg
-amber-
9
00
/30 dark:text
-amber-
4
00 dark:hover:bg-amber-900/50'
: 'bg-gray-100
text-gray-600 hover:bg-gray-200
dark:bg-dark-800 dark:text-dark-400 dark:hover:bg-dark-700'
]"
:title=
"hasUpdate ? 'New version available' : 'Up to date'"
>
<span
v-if=
"currentVersion"
class=
"font-medium"
>
v
{{
currentVersion
}}
</span>
<span
v-else
class=
"font-medium w-12 h-3 bg-gray-200 dark:bg-dark-600 rounded animate-pulse"
></span>
<span
v-else
class=
"h-3 w-12 animate-pulse rounded bg-gray-200 font-medium dark:bg-dark-600"
></span>
<!-- Update indicator -->
<span
v-if=
"hasUpdate"
class=
"relative flex h-2 w-2"
>
<span
class=
"animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"
></span>
<span
class=
"relative inline-flex rounded-full h-2 w-2 bg-amber-500"
></span>
<span
class=
"absolute inline-flex h-full w-full animate-ping rounded-full bg-amber-400 opacity-75"
></span>
<span
class=
"relative inline-flex h-2 w-2 rounded-full bg-amber-500"
></span>
</span>
</button>
...
...
@@ -26,19 +31,34 @@
<div
v-if=
"dropdownOpen"
ref=
"dropdownRef"
class=
"absolute left-0 mt-2 w-64
bg-white dark:bg-dark-800
rounded-xl
shadow-lg
border border-gray-200 dark:border-dark-700
z-50 overflow-hidden
"
class=
"absolute left-0
z-50
mt-2 w-64
overflow-hidden
rounded-xl border border-gray-200
bg-white shadow-lg
dark:border-dark-700
dark:bg-dark-800
"
>
<!-- Header with refresh button -->
<div
class=
"flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-dark-700"
>
<span
class=
"text-sm font-medium text-gray-700 dark:text-dark-300"
>
{{
t
(
'
version.currentVersion
'
)
}}
</span>
<div
class=
"flex items-center justify-between border-b border-gray-100 px-4 py-3 dark:border-dark-700"
>
<span
class=
"text-sm font-medium text-gray-700 dark:text-dark-300"
>
{{
t
(
'
version.currentVersion
'
)
}}
</span>
<button
@
click=
"refreshVersion(true)"
class=
"
p-1.5
rounded-lg text-gray-400 hover:
text
-gray-
6
00
dark:
hover:text-
dark-200
hover:bg-
gray-1
00 dark:hover:
bg
-dark-
7
00
transition-colors
"
class=
"rounded-lg
p-1.5
text-gray-400
transition-colors
hover:
bg
-gray-
1
00 hover:text-
gray-600 dark:
hover:bg-
dark-7
00 dark:hover:
text
-dark-
2
00"
:disabled=
"loading"
:title=
"t('version.refresh')"
>
<svg
class=
"w-4 h-4"
:class=
"
{ 'animate-spin': loading }" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
<svg
class=
"h-4 w-4"
:class=
"
{ 'animate-spin': loading }"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
</div>
...
...
@@ -46,42 +66,90 @@
<div
class=
"p-4"
>
<!-- Loading state -->
<div
v-if=
"loading"
class=
"flex items-center justify-center py-6"
>
<svg
class=
"animate-spin h-6 w-6 text-primary-500"
fill=
"none"
viewBox=
"0 0 24 24"
>
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle>
<path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
<svg
class=
"h-6 w-6 animate-spin text-primary-500"
fill=
"none"
viewBox=
"0 0 24 24"
>
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle>
<path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
<!-- Content -->
<template
v-else
>
<!-- Version display - centered and prominent -->
<div
class=
"text-center
mb-4
"
>
<div
class=
"
mb-4
text-center"
>
<div
class=
"inline-flex items-center gap-2"
>
<span
v-if=
"currentVersion"
class=
"text-2xl font-bold text-gray-900 dark:text-white"
>
v
{{
currentVersion
}}
</span>
<span
v-if=
"currentVersion"
class=
"text-2xl font-bold text-gray-900 dark:text-white"
>
v
{{
currentVersion
}}
</span
>
<span
v-else
class=
"text-2xl font-bold text-gray-400 dark:text-dark-500"
>
--
</span>
<!-- Show check mark when up to date -->
<span
v-if=
"!hasUpdate"
class=
"flex items-center justify-center w-5 h-5 rounded-full bg-green-100 dark:bg-green-900/30"
>
<svg
class=
"w-3 h-3 text-green-600 dark:text-green-400"
fill=
"currentColor"
viewBox=
"0 0 20 20"
>
<path
fill-rule=
"evenodd"
d=
"M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule=
"evenodd"
/>
<span
v-if=
"!hasUpdate"
class=
"flex h-5 w-5 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30"
>
<svg
class=
"h-3 w-3 text-green-600 dark:text-green-400"
fill=
"currentColor"
viewBox=
"0 0 20 20"
>
<path
fill-rule=
"evenodd"
d=
"M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule=
"evenodd"
/>
</svg>
</span>
</div>
<p
class=
"text-xs text-gray-500 dark:text-dark-400 mt-1"
>
{{
hasUpdate
?
t
(
'
version.latestVersion
'
)
+
'
: v
'
+
latestVersion
:
t
(
'
version.upToDate
'
)
}}
<p
class=
"mt-1 text-xs text-gray-500 dark:text-dark-400"
>
{{
hasUpdate
?
t
(
'
version.latestVersion
'
)
+
'
: v
'
+
latestVersion
:
t
(
'
version.upToDate
'
)
}}
</p>
</div>
<!-- Priority 1: Update error (must check before hasUpdate) -->
<div
v-if=
"updateError"
class=
"space-y-2"
>
<div
class=
"flex items-center gap-3 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800/50"
>
<div
class=
"flex-shrink-0 w-8 h-8 rounded-full bg-red-100 dark:bg-red-900/50 flex items-center justify-center"
>
<svg
class=
"w-4 h-4 text-red-600 dark:text-red-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M6 18L18 6M6 6l12 12"
/>
<div
class=
"flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800/50 dark:bg-red-900/20"
>
<div
class=
"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/50"
>
<svg
class=
"h-4 w-4 text-red-600 dark:text-red-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
<div
class=
"flex-1 min-w-0"
>
<p
class=
"text-sm font-medium text-red-700 dark:text-red-300"
>
{{
t
(
'
version.updateFailed
'
)
}}
</p>
<p
class=
"text-xs text-red-600/70 dark:text-red-400/70 truncate"
>
{{
updateError
}}
</p>
<div
class=
"min-w-0 flex-1"
>
<p
class=
"text-sm font-medium text-red-700 dark:text-red-300"
>
{{
t
(
'
version.updateFailed
'
)
}}
</p>
<p
class=
"truncate text-xs text-red-600/70 dark:text-red-400/70"
>
{{
updateError
}}
</p>
</div>
</div>
...
...
@@ -89,7 +157,7 @@
<button
@
click=
"handleUpdate"
:disabled=
"updating"
class=
"w-full
flex
items-center justify-center gap-2
px-4 py-2
rounded-lg text-sm font-medium text-white
bg-red-500 hover:bg-red-600 disabled:opacity-5
0 disabled:cursor-not-allowed
transition-colors
"
class=
"
flex
w-full items-center justify-center gap-2 rounded-lg
bg-red-500 px-4 py-2
text-sm font-medium text-white
transition-colors hover:bg-red-60
0 disabled:cursor-not-allowed
disabled:opacity-50
"
>
{{
t
(
'
version.retry
'
)
}}
</button>
...
...
@@ -97,15 +165,29 @@
<!-- Priority 2: Update success - need restart -->
<div
v-else-if=
"updateSuccess && needRestart"
class=
"space-y-2"
>
<div
class=
"flex items-center gap-3 p-3 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800/50"
>
<div
class=
"flex-shrink-0 w-8 h-8 rounded-full bg-green-100 dark:bg-green-900/50 flex items-center justify-center"
>
<svg
class=
"w-4 h-4 text-green-600 dark:text-green-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<div
class=
"flex items-center gap-3 rounded-lg border border-green-200 bg-green-50 p-3 dark:border-green-800/50 dark:bg-green-900/20"
>
<div
class=
"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/50"
>
<svg
class=
"h-4 w-4 text-green-600 dark:text-green-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M5 13l4 4L19 7"
/>
</svg>
</div>
<div
class=
"flex-1 min-w-0"
>
<p
class=
"text-sm font-medium text-green-700 dark:text-green-300"
>
{{
t
(
'
version.updateComplete
'
)
}}
</p>
<p
class=
"text-xs text-green-600/70 dark:text-green-400/70"
>
{{
t
(
'
version.restartRequired
'
)
}}
</p>
<div
class=
"min-w-0 flex-1"
>
<p
class=
"text-sm font-medium text-green-700 dark:text-green-300"
>
{{
t
(
'
version.updateComplete
'
)
}}
</p>
<p
class=
"text-xs text-green-600/70 dark:text-green-400/70"
>
{{
t
(
'
version.restartRequired
'
)
}}
</p>
</div>
</div>
...
...
@@ -113,18 +195,47 @@
<button
@
click=
"handleRestart"
:disabled=
"restarting"
class=
"w-full
flex
items-center justify-center gap-2
px-4 py-2
rounded-lg text-sm font-medium text-white
bg-green-500
hover:bg-green-600
disabled:opacity-50
disabled:cursor-not-allowed
transition-colors
"
class=
"
flex
w-full items-center justify-center gap-2 rounded-lg
bg-green-500 px-4 py-2
text-sm font-medium text-white
transition-colors
hover:bg-green-600 disabled:cursor-not-allowed
disabled:opacity-50
"
>
<svg
v-if=
"restarting"
class=
"animate-spin h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
>
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle>
<path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
<svg
v-if=
"restarting"
class=
"h-4 w-4 animate-spin"
fill=
"none"
viewBox=
"0 0 24 24"
>
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle>
<path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<svg
v-else
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
<svg
v-else
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<template
v-if=
"restarting"
>
<span>
{{
t
(
'
version.restarting
'
)
}}
</span>
<span
v-if=
"restartCountdown > 0"
class=
"tabular-nums"
>
(
{{
restartCountdown
}}
s)
</span>
<span
v-if=
"restartCountdown > 0"
class=
"tabular-nums"
>
(
{{
restartCountdown
}}
s)
</span
>
</
template
>
<span
v-else
>
{{ t('version.restartNow') }}
</span>
</button>
...
...
@@ -137,42 +248,96 @@
:href=
"releaseInfo.html_url"
target=
"_blank"
rel=
"noopener noreferrer"
class=
"flex items-center gap-3
p-3
rounded-lg b
g-amber-50 dark:bg-amber-900/20 border border
-amber-
2
00 dark:border-amber-800/50
hover
:bg-amber-
1
00 dark:hover:bg-amber-900/30
transition-colors group
"
class=
"
group
flex items-center gap-3 rounded-lg b
order border-amber-200 bg-amber-50 p-3 transition-colors hover:bg
-amber-
1
00 dark:border-amber-800/50
dark
:bg-amber-
9
00
/20
dark:hover:bg-amber-900/30"
>
<div
class=
"flex-shrink-0 w-8 h-8 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center"
>
<svg
class=
"w-4 h-4 text-amber-600 dark:text-amber-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
<div
class=
"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/50"
>
<svg
class=
"h-4 w-4 text-amber-600 dark:text-amber-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</div>
<div
class=
"flex-1 min-w-0"
>
<p
class=
"text-sm font-medium text-amber-700 dark:text-amber-300"
>
{{ t('version.updateAvailable') }}
</p>
<p
class=
"text-xs text-amber-600/70 dark:text-amber-400/70"
>
v{{ latestVersion }}
</p>
<div
class=
"min-w-0 flex-1"
>
<p
class=
"text-sm font-medium text-amber-700 dark:text-amber-300"
>
{{ t('version.updateAvailable') }}
</p>
<p
class=
"text-xs text-amber-600/70 dark:text-amber-400/70"
>
v{{ latestVersion }}
</p>
</div>
<svg
class=
"w-4 h-4 text-amber-500 dark:text-amber-400 group-hover:translate-x-0.5 transition-transform"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<svg
class=
"h-4 w-4 text-amber-500 transition-transform group-hover:translate-x-0.5 dark:text-amber-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9 5l7 7-7 7"
/>
</svg>
</a>
<!-- Source build hint -->
<div
class=
"flex items-center gap-2 p-2 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50"
>
<svg
class=
"w-3.5 h-3.5 text-blue-500 dark:text-blue-400 flex-shrink-0"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
<div
class=
"flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 p-2 dark:border-blue-800/50 dark:bg-blue-900/20"
>
<svg
class=
"h-3.5 w-3.5 flex-shrink-0 text-blue-500 dark:text-blue-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<p
class=
"text-xs text-blue-600 dark:text-blue-400"
>
{{ t('version.sourceModeHint') }}
</p>
<p
class=
"text-xs text-blue-600 dark:text-blue-400"
>
{{ t('version.sourceModeHint') }}
</p>
</div>
</div>
<!-- Priority 4: Update available for release build - show update button -->
<div
v-else-if=
"hasUpdate && isReleaseBuild"
class=
"space-y-2"
>
<!-- Update info card -->
<div
class=
"flex items-center gap-3 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50"
>
<div
class=
"flex-shrink-0 w-8 h-8 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center"
>
<svg
class=
"w-4 h-4 text-amber-600 dark:text-amber-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
<div
class=
"flex items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-800/50 dark:bg-amber-900/20"
>
<div
class=
"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/50"
>
<svg
class=
"h-4 w-4 text-amber-600 dark:text-amber-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</div>
<div
class=
"flex-1 min-w-0"
>
<p
class=
"text-sm font-medium text-amber-700 dark:text-amber-300"
>
{{ t('version.updateAvailable') }}
</p>
<p
class=
"text-xs text-amber-600/70 dark:text-amber-400/70"
>
v{{ latestVersion }}
</p>
<div
class=
"min-w-0 flex-1"
>
<p
class=
"text-sm font-medium text-amber-700 dark:text-amber-300"
>
{{ t('version.updateAvailable') }}
</p>
<p
class=
"text-xs text-amber-600/70 dark:text-amber-400/70"
>
v{{ latestVersion }}
</p>
</div>
</div>
...
...
@@ -180,14 +345,36 @@
<button
@
click=
"handleUpdate"
:disabled=
"updating"
class=
"w-full
flex
items-center justify-center gap-2
px-4 py-2
rounded-lg text-sm font-medium text-white
bg-primary-500
hover:bg-primary-600 disabled:
opacity-50 disabled:cursor-not-allowed transition-colors
"
class=
"
flex
w-full items-center justify-center gap-2 rounded-lg
bg-primary-500 px-4 py-2
text-sm font-medium text-white
transition-colors
hover:bg-primary-600 disabled:
cursor-not-allowed disabled:opacity-50
"
>
<svg
v-if=
"updating"
class=
"animate-spin h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
>
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle>
<path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
<svg
v-if=
"updating"
class=
"h-4 w-4 animate-spin"
fill=
"none"
viewBox=
"0 0 24 24"
>
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle>
<path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<svg
v-else
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
<svg
v-else
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
{{ updating ? t('version.updating') : t('version.updateNow') }}
</button>
...
...
@@ -198,11 +385,21 @@
:href=
"releaseInfo.html_url"
target=
"_blank"
rel=
"noopener noreferrer"
class=
"flex items-center justify-center gap-1 text-xs text-gray-500
dark:text-dark-400
hover:text-gray-700 dark:hover:text-dark-200
transition-colors
"
class=
"flex items-center justify-center gap-1 text-xs text-gray-500
transition-colors
hover:text-gray-700
dark:text-dark-400
dark:hover:text-dark-200"
>
{{ t('version.viewChangelog') }}
<svg
class=
"w-3 h-3"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
<svg
class=
"h-3 w-3"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</div>
...
...
@@ -213,10 +410,14 @@
:href=
"releaseInfo.html_url"
target=
"_blank"
rel=
"noopener noreferrer"
class=
"flex items-center justify-center gap-2 py-2 text-sm text-gray-500
dark:text-dark-400
hover:text-gray-700 dark:hover:text-dark-200
transition-colors
"
class=
"flex items-center justify-center gap-2 py-2 text-sm text-gray-500
transition-colors
hover:text-gray-700
dark:text-dark-400
dark:hover:text-dark-200"
>
<svg
class=
"w-4 h-4"
fill=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
fill-rule=
"evenodd"
clip-rule=
"evenodd"
d=
"M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.464-1.11-1.464-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z"
/>
<svg
class=
"h-4 w-4"
fill=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
fill-rule=
"evenodd"
clip-rule=
"evenodd"
d=
"M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.464-1.11-1.464-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z"
/>
</svg>
{{ t('version.viewRelease') }}
</a>
...
...
@@ -227,166 +428,163 @@
</template>
<!-- Non-admin: Simple static version text -->
<span
v-else-if=
"version"
class=
"text-xs text-gray-500 dark:text-dark-400"
>
<span
v-else-if=
"version"
class=
"text-xs text-gray-500 dark:text-dark-400"
>
v{{ version }}
</span>
</div>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
onMounted
,
onBeforeUnmount
}
from
'
vue
'
;
import
{
useI18n
}
from
'
vue-i18n
'
;
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
;
import
{
performUpdate
,
restartService
}
from
'
@/api/admin/system
'
;
import
{
ref
,
computed
,
onMounted
,
onBeforeUnmount
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
import
{
performUpdate
,
restartService
}
from
'
@/api/admin/system
'
const
{
t
}
=
useI18n
()
;
const
{
t
}
=
useI18n
()
const
props
=
defineProps
<
{
version
?:
string
;
}
>
()
;
version
?:
string
}
>
()
const
authStore
=
useAuthStore
()
;
const
appStore
=
useAppStore
()
;
const
authStore
=
useAuthStore
()
const
appStore
=
useAppStore
()
const
isAdmin
=
computed
(()
=>
authStore
.
isAdmin
)
;
const
isAdmin
=
computed
(()
=>
authStore
.
isAdmin
)
const
dropdownOpen
=
ref
(
false
)
;
const
dropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
;
const
dropdownOpen
=
ref
(
false
)
const
dropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
// Use store's cached version state
const
loading
=
computed
(()
=>
appStore
.
versionLoading
)
;
const
currentVersion
=
computed
(()
=>
appStore
.
currentVersion
||
props
.
version
||
''
)
;
const
latestVersion
=
computed
(()
=>
appStore
.
latestVersion
)
;
const
hasUpdate
=
computed
(()
=>
appStore
.
hasUpdate
)
;
const
releaseInfo
=
computed
(()
=>
appStore
.
releaseInfo
)
;
const
buildType
=
computed
(()
=>
appStore
.
buildType
)
;
const
loading
=
computed
(()
=>
appStore
.
versionLoading
)
const
currentVersion
=
computed
(()
=>
appStore
.
currentVersion
||
props
.
version
||
''
)
const
latestVersion
=
computed
(()
=>
appStore
.
latestVersion
)
const
hasUpdate
=
computed
(()
=>
appStore
.
hasUpdate
)
const
releaseInfo
=
computed
(()
=>
appStore
.
releaseInfo
)
const
buildType
=
computed
(()
=>
appStore
.
buildType
)
// Update process states (local to this component)
const
updating
=
ref
(
false
)
;
const
restarting
=
ref
(
false
)
;
const
needRestart
=
ref
(
false
)
;
const
updateError
=
ref
(
''
)
;
const
updateSuccess
=
ref
(
false
)
;
const
restartCountdown
=
ref
(
0
)
;
const
updating
=
ref
(
false
)
const
restarting
=
ref
(
false
)
const
needRestart
=
ref
(
false
)
const
updateError
=
ref
(
''
)
const
updateSuccess
=
ref
(
false
)
const
restartCountdown
=
ref
(
0
)
// Only show update check for release builds (binary/docker deployment)
const
isReleaseBuild
=
computed
(()
=>
buildType
.
value
===
'
release
'
)
;
const
isReleaseBuild
=
computed
(()
=>
buildType
.
value
===
'
release
'
)
function
toggleDropdown
()
{
dropdownOpen
.
value
=
!
dropdownOpen
.
value
;
dropdownOpen
.
value
=
!
dropdownOpen
.
value
}
function
closeDropdown
()
{
dropdownOpen
.
value
=
false
;
dropdownOpen
.
value
=
false
}
async
function
refreshVersion
(
force
=
true
)
{
if
(
!
isAdmin
.
value
)
return
;
if
(
!
isAdmin
.
value
)
return
// Reset update states when refreshing
updateError
.
value
=
''
;
updateSuccess
.
value
=
false
;
needRestart
.
value
=
false
;
updateError
.
value
=
''
updateSuccess
.
value
=
false
needRestart
.
value
=
false
await
appStore
.
fetchVersion
(
force
)
;
await
appStore
.
fetchVersion
(
force
)
}
async
function
handleUpdate
()
{
if
(
updating
.
value
)
return
;
if
(
updating
.
value
)
return
updating
.
value
=
true
;
updateError
.
value
=
''
;
updateSuccess
.
value
=
false
;
updating
.
value
=
true
updateError
.
value
=
''
updateSuccess
.
value
=
false
try
{
const
result
=
await
performUpdate
()
;
updateSuccess
.
value
=
true
;
needRestart
.
value
=
result
.
need_restart
;
const
result
=
await
performUpdate
()
updateSuccess
.
value
=
true
needRestart
.
value
=
result
.
need_restart
// Clear version cache to reflect update completed
appStore
.
clearVersionCache
()
;
appStore
.
clearVersionCache
()
}
catch
(
error
:
unknown
)
{
const
err
=
error
as
{
response
?:
{
data
?:
{
message
?:
string
}
};
message
?:
string
}
;
updateError
.
value
=
err
.
response
?.
data
?.
message
||
err
.
message
||
t
(
'
version.updateFailed
'
)
;
const
err
=
error
as
{
response
?:
{
data
?:
{
message
?:
string
}
};
message
?:
string
}
updateError
.
value
=
err
.
response
?.
data
?.
message
||
err
.
message
||
t
(
'
version.updateFailed
'
)
}
finally
{
updating
.
value
=
false
;
updating
.
value
=
false
}
}
async
function
handleRestart
()
{
if
(
restarting
.
value
)
return
;
if
(
restarting
.
value
)
return
restarting
.
value
=
true
;
restartCountdown
.
value
=
8
;
restarting
.
value
=
true
restartCountdown
.
value
=
8
try
{
await
restartService
()
;
await
restartService
()
// Service will restart, page will reload automatically or show disconnected
}
catch
(
error
)
{
// Expected - connection will be lost during restart
console
.
log
(
'
Service restarting...
'
)
;
console
.
log
(
'
Service restarting...
'
)
}
// Start countdown
const
countdownInterval
=
setInterval
(()
=>
{
restartCountdown
.
value
--
;
restartCountdown
.
value
--
if
(
restartCountdown
.
value
<=
0
)
{
clearInterval
(
countdownInterval
)
;
clearInterval
(
countdownInterval
)
// Try to check if service is back before reload
checkServiceAndReload
()
;
checkServiceAndReload
()
}
},
1000
)
;
},
1000
)
}
async
function
checkServiceAndReload
()
{
const
maxRetries
=
5
;
const
retryDelay
=
1000
;
const
maxRetries
=
5
const
retryDelay
=
1000
for
(
let
i
=
0
;
i
<
maxRetries
;
i
++
)
{
try
{
const
response
=
await
fetch
(
'
/api/health
'
,
{
method
:
'
GET
'
,
cache
:
'
no-cache
'
})
;
})
if
(
response
.
ok
)
{
// Service is back, reload page
window
.
location
.
reload
()
;
return
;
window
.
location
.
reload
()
return
}
}
catch
{
// Service not ready yet
}
if
(
i
<
maxRetries
-
1
)
{
await
new
Promise
(
resolve
=>
setTimeout
(
resolve
,
retryDelay
))
;
await
new
Promise
(
(
resolve
)
=>
setTimeout
(
resolve
,
retryDelay
))
}
}
// After retries, reload anyway
window
.
location
.
reload
()
;
window
.
location
.
reload
()
}
function
handleClickOutside
(
event
:
MouseEvent
)
{
const
target
=
event
.
target
as
Node
;
const
button
=
(
event
.
target
as
Element
).
closest
(
'
button
'
)
;
const
target
=
event
.
target
as
Node
const
button
=
(
event
.
target
as
Element
).
closest
(
'
button
'
)
if
(
dropdownRef
.
value
&&
!
dropdownRef
.
value
.
contains
(
target
)
&&
!
button
?.
contains
(
target
))
{
closeDropdown
()
;
closeDropdown
()
}
}
onMounted
(()
=>
{
if
(
isAdmin
.
value
)
{
// Use cached version if available, otherwise fetch
appStore
.
fetchVersion
(
false
)
;
appStore
.
fetchVersion
(
false
)
}
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
;
})
;
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
})
onBeforeUnmount
(()
=>
{
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
;
})
;
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
})
</
script
>
<
style
scoped
>
...
...
frontend/src/components/layout/AppHeader.vue
View file @
429f38d0
<
template
>
<header
class=
"sticky top-0 z-30
glass
border-b border-gray-200/50 dark:border-dark-700/50"
>
<div
class=
"flex items-center justify-between
h-16
px-4 md:px-6"
>
<header
class=
"
glass
sticky top-0 z-30 border-b border-gray-200/50 dark:border-dark-700/50"
>
<div
class=
"flex
h-16
items-center justify-between px-4 md:px-6"
>
<!-- Left: Mobile Menu Toggle + Page Title -->
<div
class=
"flex items-center gap-4"
>
<button
@
click=
"toggleMobileSidebar"
class=
"
lg:hidden
btn-ghost btn-icon"
class=
"btn-ghost btn-icon
lg:hidden
"
aria-label=
"Toggle Menu"
>
<svg
class=
"w-5 h-5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
<svg
class=
"h-5 w-5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
</button>
...
...
@@ -32,9 +42,22 @@
<SubscriptionProgressMini
v-if=
"user"
/>
<!-- Balance Display -->
<div
v-if=
"user"
class=
"hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-xl bg-primary-50 dark:bg-primary-900/20"
>
<svg
class=
"w-4 h-4 text-primary-600 dark:text-primary-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z"
/>
<div
v-if=
"user"
class=
"hidden items-center gap-2 rounded-xl bg-primary-50 px-3 py-1.5 dark:bg-primary-900/20 sm:flex"
>
<svg
class=
"h-4 w-4 text-primary-600 dark:text-primary-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z"
/>
</svg>
<span
class=
"text-sm font-semibold text-primary-700 dark:text-primary-300"
>
$
{{
user
.
balance
?.
toFixed
(
2
)
||
'
0.00
'
}}
...
...
@@ -45,64 +68,89 @@
<div
v-if=
"user"
class=
"relative"
ref=
"dropdownRef"
>
<button
@
click=
"toggleDropdown"
class=
"flex items-center gap-2
p-1.5
rounded-xl hover:bg-gray-100 dark:hover:bg-dark-800
transition-colors
"
class=
"flex items-center gap-2 rounded-xl
p-1.5 transition-colors
hover:bg-gray-100 dark:hover:bg-dark-800"
aria-label=
"User Menu"
>
<div
class=
"w-8 h-8 rounded-xl bg-gradient-to-br from-primary-500 to-primary-600 text-white flex items-center justify-center text-sm font-medium shadow-sm"
>
<div
class=
"flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-br from-primary-500 to-primary-600 text-sm font-medium text-white shadow-sm"
>
{{
userInitials
}}
</div>
<div
class=
"hidden
md:block
text-left"
>
<div
class=
"hidden text-left
md:block
"
>
<div
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
displayName
}}
</div>
<div
class=
"text-xs text-gray-500 dark:text-dark-400
capitalize
"
>
<div
class=
"text-xs
capitalize
text-gray-500 dark:text-dark-400"
>
{{
user
.
role
}}
</div>
</div>
<svg
class=
"w-4 h-4 text-gray-400 hidden md:block"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
<svg
class=
"hidden h-4 w-4 text-gray-400 md:block"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
</button>
<!-- Dropdown Menu -->
<transition
name=
"dropdown"
>
<div
v-if=
"dropdownOpen"
class=
"dropdown right-0 mt-2 w-56"
>
<div
v-if=
"dropdownOpen"
class=
"dropdown right-0 mt-2 w-56"
>
<!-- User Info -->
<div
class=
"px-4 py-3 border-b border-gray-100 dark:border-dark-700"
>
<div
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
displayName
}}
</div>
<div
class=
"border-b border-gray-100 px-4 py-3 dark:border-dark-700"
>
<div
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
displayName
}}
</div>
<div
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
user
.
email
}}
</div>
</div>
<!-- Balance (mobile only) -->
<div
class=
"sm:hidden px-4 py-2 border-b border-gray-100 dark:border-dark-700"
>
<div
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
t
(
'
common.balance
'
)
}}
</div>
<div
class=
"border-b border-gray-100 px-4 py-2 dark:border-dark-700 sm:hidden"
>
<div
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
t
(
'
common.balance
'
)
}}
</div>
<div
class=
"text-sm font-semibold text-primary-600 dark:text-primary-400"
>
$
{{
user
.
balance
?.
toFixed
(
2
)
||
'
0.00
'
}}
</div>
</div>
<div
class=
"py-1"
>
<router-link
to=
"/profile"
@
click=
"closeDropdown"
class=
"dropdown-item"
>
<svg
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
/>
<router-link
to=
"/profile"
@
click=
"closeDropdown"
class=
"dropdown-item"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
/>
</svg>
{{
t
(
'
nav.profile
'
)
}}
</router-link>
<router-link
to=
"/keys"
@
click=
"closeDropdown"
class=
"dropdown-item"
>
<svg
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
<router-link
to=
"/keys"
@
click=
"closeDropdown"
class=
"dropdown-item"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
{{
t
(
'
nav.apiKeys
'
)
}}
</router-link>
...
...
@@ -114,31 +162,60 @@
@
click=
"closeDropdown"
class=
"dropdown-item"
>
<svg
class=
"w-4 h-4"
fill=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
fill-rule=
"evenodd"
clip-rule=
"evenodd"
d=
"M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.464-1.11-1.464-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z"
/>
<svg
class=
"h-4 w-4"
fill=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
fill-rule=
"evenodd"
clip-rule=
"evenodd"
d=
"M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.464-1.11-1.464-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z"
/>
</svg>
{{
t
(
'
nav.github
'
)
}}
</a>
</div>
<!-- Contact Support (only show if configured) -->
<div
v-if=
"contactInfo"
class=
"border-t border-gray-100 dark:border-dark-700 px-4 py-2.5"
>
<div
v-if=
"contactInfo"
class=
"border-t border-gray-100 px-4 py-2.5 dark:border-dark-700"
>
<div
class=
"flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400"
>
<svg
class=
"w-3.5 h-3.5 flex-shrink-0"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155"
/>
<svg
class=
"h-3.5 w-3.5 flex-shrink-0"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155"
/>
</svg>
<span>
{{
t
(
'
common.contactSupport
'
)
}}
:
</span>
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
{{
contactInfo
}}
</span>
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
{{
contactInfo
}}
</span>
</div>
</div>
<div
class=
"border-t border-gray-100 dark:border-dark-700
py-1
"
>
<div
class=
"border-t border-gray-100
py-1
dark:border-dark-700"
>
<button
@
click=
"handleLogout"
class=
"dropdown-item w-full text-red-600 dark:text-red-40
0 hover:bg-red-5
0 dark:hover:bg-red-900/20"
class=
"dropdown-item w-full text-red-600
hover:bg-red-50
dark:text-red-400 dark:hover:bg-red-900/20"
>
<svg
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75"
/>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75"
/>
</svg>
{{
t
(
'
nav.logout
'
)
}}
</button>
...
...
@@ -152,90 +229,90 @@
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
onMounted
,
onBeforeUnmount
}
from
'
vue
'
;
import
{
useRouter
,
useRoute
}
from
'
vue-router
'
;
import
{
useI18n
}
from
'
vue-i18n
'
;
import
{
useAppStore
,
useAuthStore
}
from
'
@/stores
'
;
import
LocaleSwitcher
from
'
@/components/common/LocaleSwitcher.vue
'
;
import
SubscriptionProgressMini
from
'
@/components/common/SubscriptionProgressMini.vue
'
;
const
router
=
useRouter
()
;
const
route
=
useRoute
()
;
const
{
t
}
=
useI18n
()
;
const
appStore
=
useAppStore
()
;
const
authStore
=
useAuthStore
()
;
const
user
=
computed
(()
=>
authStore
.
user
)
;
const
dropdownOpen
=
ref
(
false
)
;
const
dropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
;
const
contactInfo
=
computed
(()
=>
appStore
.
contactInfo
)
;
import
{
ref
,
computed
,
onMounted
,
onBeforeUnmount
}
from
'
vue
'
import
{
useRouter
,
useRoute
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
,
useAuthStore
}
from
'
@/stores
'
import
LocaleSwitcher
from
'
@/components/common/LocaleSwitcher.vue
'
import
SubscriptionProgressMini
from
'
@/components/common/SubscriptionProgressMini.vue
'
const
router
=
useRouter
()
const
route
=
useRoute
()
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
authStore
=
useAuthStore
()
const
user
=
computed
(()
=>
authStore
.
user
)
const
dropdownOpen
=
ref
(
false
)
const
dropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
contactInfo
=
computed
(()
=>
appStore
.
contactInfo
)
const
userInitials
=
computed
(()
=>
{
if
(
!
user
.
value
)
return
''
;
if
(
!
user
.
value
)
return
''
// Prefer username, fallback to email
if
(
user
.
value
.
username
)
{
return
user
.
value
.
username
.
substring
(
0
,
2
).
toUpperCase
()
;
return
user
.
value
.
username
.
substring
(
0
,
2
).
toUpperCase
()
}
if
(
user
.
value
.
email
)
{
// Get the part before @ and take first 2 chars
const
localPart
=
user
.
value
.
email
.
split
(
'
@
'
)[
0
]
;
return
localPart
.
substring
(
0
,
2
).
toUpperCase
()
;
const
localPart
=
user
.
value
.
email
.
split
(
'
@
'
)[
0
]
return
localPart
.
substring
(
0
,
2
).
toUpperCase
()
}
return
''
;
})
;
return
''
})
const
displayName
=
computed
(()
=>
{
if
(
!
user
.
value
)
return
''
;
return
user
.
value
.
username
||
user
.
value
.
email
?.
split
(
'
@
'
)[
0
]
||
''
;
})
;
if
(
!
user
.
value
)
return
''
return
user
.
value
.
username
||
user
.
value
.
email
?.
split
(
'
@
'
)[
0
]
||
''
})
const
pageTitle
=
computed
(()
=>
{
const
titleKey
=
route
.
meta
.
titleKey
as
string
;
const
titleKey
=
route
.
meta
.
titleKey
as
string
if
(
titleKey
)
{
return
t
(
titleKey
)
;
return
t
(
titleKey
)
}
return
(
route
.
meta
.
title
as
string
)
||
''
;
})
;
return
(
route
.
meta
.
title
as
string
)
||
''
})
const
pageDescription
=
computed
(()
=>
{
const
descKey
=
route
.
meta
.
descriptionKey
as
string
;
const
descKey
=
route
.
meta
.
descriptionKey
as
string
if
(
descKey
)
{
return
t
(
descKey
)
;
return
t
(
descKey
)
}
return
(
route
.
meta
.
description
as
string
)
||
''
;
})
;
return
(
route
.
meta
.
description
as
string
)
||
''
})
function
toggleMobileSidebar
()
{
appStore
.
toggleMobileSidebar
()
;
appStore
.
toggleMobileSidebar
()
}
function
toggleDropdown
()
{
dropdownOpen
.
value
=
!
dropdownOpen
.
value
;
dropdownOpen
.
value
=
!
dropdownOpen
.
value
}
function
closeDropdown
()
{
dropdownOpen
.
value
=
false
;
dropdownOpen
.
value
=
false
}
async
function
handleLogout
()
{
closeDropdown
()
;
authStore
.
logout
()
;
await
router
.
push
(
'
/login
'
)
;
closeDropdown
()
authStore
.
logout
()
await
router
.
push
(
'
/login
'
)
}
function
handleClickOutside
(
event
:
MouseEvent
)
{
if
(
dropdownRef
.
value
&&
!
dropdownRef
.
value
.
contains
(
event
.
target
as
Node
))
{
closeDropdown
()
;
closeDropdown
()
}
}
onMounted
(()
=>
{
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
;
})
;
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
})
onBeforeUnmount
(()
=>
{
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
;
})
;
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
})
</
script
>
<
style
scoped
>
...
...
frontend/src/components/layout/AppLayout.vue
View file @
429f38d0
<
template
>
<div
class=
"min-h-screen bg-gray-50 dark:bg-dark-950"
>
<!-- Background Decoration -->
<div
class=
"fixed inset-0 bg-mesh-gradient
pointer-events-none
"
></div>
<div
class=
"
pointer-events-none
fixed inset-0 bg-mesh-gradient"
></div>
<!-- Sidebar -->
<AppSidebar
/>
...
...
@@ -9,9 +9,7 @@
<!-- Main Content Area -->
<div
class=
"relative min-h-screen transition-all duration-300"
:class=
"[
sidebarCollapsed ? 'lg:ml-[72px]' : 'lg:ml-64',
]"
:class=
"[sidebarCollapsed ? 'lg:ml-[72px]' : 'lg:ml-64']"
>
<!-- Header -->
<AppHeader
/>
...
...
@@ -25,12 +23,11 @@
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
;
import
{
useAppStore
}
from
'
@/stores
'
;
import
AppSidebar
from
'
./AppSidebar.vue
'
;
import
AppHeader
from
'
./AppHeader.vue
'
;
import
{
computed
}
from
'
vue
'
import
{
useAppStore
}
from
'
@/stores
'
import
AppSidebar
from
'
./AppSidebar.vue
'
import
AppHeader
from
'
./AppHeader.vue
'
const
appStore
=
useAppStore
()
;
const
sidebarCollapsed
=
computed
(()
=>
appStore
.
sidebarCollapsed
)
;
const
appStore
=
useAppStore
()
const
sidebarCollapsed
=
computed
(()
=>
appStore
.
sidebarCollapsed
)
</
script
>
frontend/src/components/layout/AppSidebar.vue
View file @
429f38d0
...
...
@@ -9,8 +9,8 @@
<!-- Logo/Brand -->
<div
class=
"sidebar-header"
>
<!-- Custom Logo or Default Logo -->
<div
class=
"
w-9
h-9
rounded-xl overflow-hidden flex
items-center justify-center shadow-glow"
>
<img
:src=
"siteLogo || '/logo.png'"
alt=
"Logo"
class=
"
w
-full
h
-full object-contain"
/>
<div
class=
"
flex
h-9
w-9
items-center justify-center
overflow-hidden rounded-xl
shadow-glow"
>
<img
:src=
"siteLogo || '/logo.png'"
alt=
"Logo"
class=
"
h
-full
w
-full object-contain"
/>
</div>
<transition
name=
"fade"
>
<div
v-if=
"!sidebarCollapsed"
class=
"flex flex-col"
>
...
...
@@ -38,7 +38,7 @@
:title="sidebarCollapsed ? item.label : undefined"
@click="handleMenuItemClick"
>
<component
:is=
"item.icon"
class=
"
w
-5
h
-5 flex-shrink-0"
/>
<component
:is=
"item.icon"
class=
"
h
-5
w
-5 flex-shrink-0"
/>
<transition
name=
"fade"
>
<span
v-if=
"!sidebarCollapsed"
>
{{
item
.
label
}}
</span>
</transition>
...
...
@@ -50,7 +50,7 @@
<div
v-if=
"!sidebarCollapsed"
class=
"sidebar-section-title"
>
{{
t
(
'
nav.myAccount
'
)
}}
</div>
<div
v-else
class=
"h-px bg-gray-200 dark:bg-dark-700
mx-3 my-3
"
></div>
<div
v-else
class=
"
mx-3 my-3
h-px bg-gray-200 dark:bg-dark-700"
></div>
<router-link
v-for=
"item in personalNavItems"
...
...
@@ -61,7 +61,7 @@
:title="sidebarCollapsed ? item.label : undefined"
@click="handleMenuItemClick"
>
<component
:is=
"item.icon"
class=
"
w
-5
h
-5 flex-shrink-0"
/>
<component
:is=
"item.icon"
class=
"
h
-5
w
-5 flex-shrink-0"
/>
<transition
name=
"fade"
>
<span
v-if=
"!sidebarCollapsed"
>
{{
item
.
label
}}
</span>
</transition>
...
...
@@ -81,7 +81,7 @@
:title="sidebarCollapsed ? item.label : undefined"
@click="handleMenuItemClick"
>
<component
:is=
"item.icon"
class=
"
w
-5
h
-5 flex-shrink-0"
/>
<component
:is=
"item.icon"
class=
"
h
-5
w
-5 flex-shrink-0"
/>
<transition
name=
"fade"
>
<span
v-if=
"!sidebarCollapsed"
>
{{
item
.
label
}}
</span>
</transition>
...
...
@@ -91,17 +91,19 @@
</nav>
<!-- Bottom Section -->
<div
class=
"mt-auto border-t border-gray-100 dark:border-dark-800
p-3
"
>
<div
class=
"mt-auto border-t border-gray-100
p-3
dark:border-dark-800"
>
<!-- Theme Toggle -->
<button
@
click=
"toggleTheme"
class=
"sidebar-link w-full
mb-2
"
class=
"sidebar-link
mb-2
w-full"
:title=
"sidebarCollapsed ? (isDark ? t('nav.lightMode') : t('nav.darkMode')) : undefined"
>
<SunIcon
v-if=
"isDark"
class=
"
w
-5
h
-5 flex-shrink-0 text-amber-500"
/>
<MoonIcon
v-else
class=
"
w
-5
h
-5 flex-shrink-0"
/>
<SunIcon
v-if=
"isDark"
class=
"
h
-5
w
-5 flex-shrink-0 text-amber-500"
/>
<MoonIcon
v-else
class=
"
h
-5
w
-5 flex-shrink-0"
/>
<transition
name=
"fade"
>
<span
v-if=
"!sidebarCollapsed"
>
{{ isDark ? t('nav.lightMode') : t('nav.darkMode') }}
</span>
<span
v-if=
"!sidebarCollapsed"
>
{{
isDark ? t('nav.lightMode') : t('nav.darkMode')
}}
</span>
</transition>
</button>
...
...
@@ -111,8 +113,8 @@
class=
"sidebar-link w-full"
:title=
"sidebarCollapsed ? t('nav.expand') : t('nav.collapse')"
>
<ChevronDoubleLeftIcon
v-if=
"!sidebarCollapsed"
class=
"
w
-5
h
-5 flex-shrink-0"
/>
<ChevronDoubleRightIcon
v-else
class=
"
w
-5
h
-5 flex-shrink-0"
/>
<ChevronDoubleLeftIcon
v-if=
"!sidebarCollapsed"
class=
"
h
-5
w
-5 flex-shrink-0"
/>
<ChevronDoubleRightIcon
v-else
class=
"
h
-5
w
-5 flex-shrink-0"
/>
<transition
name=
"fade"
>
<span
v-if=
"!sidebarCollapsed"
>
{{ t('nav.collapse') }}
</span>
</transition>
...
...
@@ -124,132 +126,280 @@
<transition
name=
"fade"
>
<div
v-if=
"mobileOpen"
class=
"fixed inset-0 bg-black/50
z-30
lg:hidden"
class=
"fixed inset-0
z-30
bg-black/50 lg:hidden"
@
click=
"closeMobile"
></div>
</transition>
</template>
<
script
setup
lang=
"ts"
>
import
{
computed
,
h
,
ref
}
from
'
vue
'
;
import
{
useRoute
}
from
'
vue-router
'
;
import
{
useI18n
}
from
'
vue-i18n
'
;
import
{
useAppStore
,
useAuthStore
}
from
'
@/stores
'
;
import
VersionBadge
from
'
@/components/common/VersionBadge.vue
'
;
import
{
computed
,
h
,
ref
}
from
'
vue
'
import
{
useRoute
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
,
useAuthStore
}
from
'
@/stores
'
import
VersionBadge
from
'
@/components/common/VersionBadge.vue
'
const
{
t
}
=
useI18n
()
;
const
{
t
}
=
useI18n
()
const
route
=
useRoute
()
;
const
appStore
=
useAppStore
()
;
const
authStore
=
useAuthStore
()
;
const
route
=
useRoute
()
const
appStore
=
useAppStore
()
const
authStore
=
useAuthStore
()
const
sidebarCollapsed
=
computed
(()
=>
appStore
.
sidebarCollapsed
)
;
const
mobileOpen
=
computed
(()
=>
appStore
.
mobileOpen
)
;
const
isAdmin
=
computed
(()
=>
authStore
.
isAdmin
)
;
const
isDark
=
ref
(
document
.
documentElement
.
classList
.
contains
(
'
dark
'
))
;
const
sidebarCollapsed
=
computed
(()
=>
appStore
.
sidebarCollapsed
)
const
mobileOpen
=
computed
(()
=>
appStore
.
mobileOpen
)
const
isAdmin
=
computed
(()
=>
authStore
.
isAdmin
)
const
isDark
=
ref
(
document
.
documentElement
.
classList
.
contains
(
'
dark
'
))
// Site settings from appStore (cached, no flicker)
const
siteName
=
computed
(()
=>
appStore
.
siteName
)
;
const
siteLogo
=
computed
(()
=>
appStore
.
siteLogo
)
;
const
siteVersion
=
computed
(()
=>
appStore
.
siteVersion
)
;
const
siteName
=
computed
(()
=>
appStore
.
siteName
)
const
siteLogo
=
computed
(()
=>
appStore
.
siteLogo
)
const
siteVersion
=
computed
(()
=>
appStore
.
siteVersion
)
// SVG Icon Components
const
DashboardIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z
'
})
])
};
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z
'
})
]
)
}
const
KeyIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z
'
})
])
};
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z
'
})
]
)
}
const
ChartIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z
'
})
])
};
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z
'
})
]
)
}
const
GiftIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z
'
})
])
};
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z
'
})
]
)
}
const
UserIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z
'
})
])
};
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z
'
})
]
)
}
const
UsersIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z
'
})
])
};
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z
'
})
]
)
}
const
FolderIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z
'
})
])
};
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z
'
})
]
)
}
const
CreditCardIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z
'
})
])
};
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z
'
})
]
)
}
const
GlobeIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418
'
})
])
};
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418
'
})
]
)
}
const
ServerIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z
'
})
])
};
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z
'
})
]
)
}
const
TicketIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z
'
})
])
};
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z
'
})
]
)
}
const
CogIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z
'
}),
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M15 12a3 3 0 11-6 0 3 3 0 016 0z
'
})
])
};
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z
'
}),
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M15 12a3 3 0 11-6 0 3 3 0 016 0z
'
})
]
)
}
const
SunIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z
'
})
])
};
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z
'
})
]
)
}
const
MoonIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z
'
})
])
};
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z
'
})
]
)
}
const
ChevronDoubleLeftIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
m18.75 4.5-7.5 7.5 7.5 7.5m-6-15L5.25 12l7.5 7.5
'
})
])
};
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
m18.75 4.5-7.5 7.5 7.5 7.5m-6-15L5.25 12l7.5 7.5
'
})
]
)
}
const
ChevronDoubleRightIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
m5.25 4.5 7.5 7.5-7.5 7.5m6-15 7.5 7.5-7.5 7.5
'
})
])
};
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
m5.25 4.5 7.5 7.5-7.5 7.5m6-15 7.5 7.5-7.5 7.5
'
})
]
)
}
// User navigation items (for regular users)
const
userNavItems
=
computed
(()
=>
[
...
...
@@ -258,8 +408,8 @@ const userNavItems = computed(() => [
{
path
:
'
/usage
'
,
label
:
t
(
'
nav.usage
'
),
icon
:
ChartIcon
},
{
path
:
'
/subscriptions
'
,
label
:
t
(
'
nav.mySubscriptions
'
),
icon
:
CreditCardIcon
},
{
path
:
'
/redeem
'
,
label
:
t
(
'
nav.redeem
'
),
icon
:
GiftIcon
},
{
path
:
'
/profile
'
,
label
:
t
(
'
nav.profile
'
),
icon
:
UserIcon
}
,
])
;
{
path
:
'
/profile
'
,
label
:
t
(
'
nav.profile
'
),
icon
:
UserIcon
}
])
// Personal navigation items (for admin's "My Account" section, without Dashboard)
const
personalNavItems
=
computed
(()
=>
[
...
...
@@ -267,8 +417,8 @@ const personalNavItems = computed(() => [
{
path
:
'
/usage
'
,
label
:
t
(
'
nav.usage
'
),
icon
:
ChartIcon
},
{
path
:
'
/subscriptions
'
,
label
:
t
(
'
nav.mySubscriptions
'
),
icon
:
CreditCardIcon
},
{
path
:
'
/redeem
'
,
label
:
t
(
'
nav.redeem
'
),
icon
:
GiftIcon
},
{
path
:
'
/profile
'
,
label
:
t
(
'
nav.profile
'
),
icon
:
UserIcon
}
,
])
;
{
path
:
'
/profile
'
,
label
:
t
(
'
nav.profile
'
),
icon
:
UserIcon
}
])
// Admin navigation items
const
adminNavItems
=
computed
(()
=>
[
...
...
@@ -280,40 +430,43 @@ const adminNavItems = computed(() => [
{
path
:
'
/admin/proxies
'
,
label
:
t
(
'
nav.proxies
'
),
icon
:
ServerIcon
},
{
path
:
'
/admin/redeem
'
,
label
:
t
(
'
nav.redeemCodes
'
),
icon
:
TicketIcon
},
{
path
:
'
/admin/usage
'
,
label
:
t
(
'
nav.usage
'
),
icon
:
ChartIcon
},
{
path
:
'
/admin/settings
'
,
label
:
t
(
'
nav.settings
'
),
icon
:
CogIcon
}
,
])
;
{
path
:
'
/admin/settings
'
,
label
:
t
(
'
nav.settings
'
),
icon
:
CogIcon
}
])
function
toggleSidebar
()
{
appStore
.
toggleSidebar
()
;
appStore
.
toggleSidebar
()
}
function
toggleTheme
()
{
isDark
.
value
=
!
isDark
.
value
;
document
.
documentElement
.
classList
.
toggle
(
'
dark
'
,
isDark
.
value
)
;
localStorage
.
setItem
(
'
theme
'
,
isDark
.
value
?
'
dark
'
:
'
light
'
)
;
isDark
.
value
=
!
isDark
.
value
document
.
documentElement
.
classList
.
toggle
(
'
dark
'
,
isDark
.
value
)
localStorage
.
setItem
(
'
theme
'
,
isDark
.
value
?
'
dark
'
:
'
light
'
)
}
function
closeMobile
()
{
appStore
.
setMobileOpen
(
false
)
;
appStore
.
setMobileOpen
(
false
)
}
function
handleMenuItemClick
()
{
if
(
mobileOpen
.
value
)
{
setTimeout
(()
=>
{
appStore
.
setMobileOpen
(
false
)
;
},
150
)
;
appStore
.
setMobileOpen
(
false
)
},
150
)
}
}
function
isActive
(
path
:
string
):
boolean
{
return
route
.
path
===
path
||
route
.
path
.
startsWith
(
path
+
'
/
'
)
;
return
route
.
path
===
path
||
route
.
path
.
startsWith
(
path
+
'
/
'
)
}
// Initialize theme
const
savedTheme
=
localStorage
.
getItem
(
'
theme
'
);
if
(
savedTheme
===
'
dark
'
||
(
!
savedTheme
&&
window
.
matchMedia
(
'
(prefers-color-scheme: dark)
'
).
matches
))
{
isDark
.
value
=
true
;
document
.
documentElement
.
classList
.
add
(
'
dark
'
);
const
savedTheme
=
localStorage
.
getItem
(
'
theme
'
)
if
(
savedTheme
===
'
dark
'
||
(
!
savedTheme
&&
window
.
matchMedia
(
'
(prefers-color-scheme: dark)
'
).
matches
)
)
{
isDark
.
value
=
true
document
.
documentElement
.
classList
.
add
(
'
dark
'
)
}
</
script
>
...
...
frontend/src/components/layout/AuthLayout.vue
View file @
429f38d0
<
template
>
<div
class=
"min-h-screen
flex
items-center justify-center
p-4 relative
overflow-hidden"
>
<div
class=
"
relative flex
min-h-screen items-center justify-center overflow-hidden
p-4
"
>
<!-- Background -->
<div
class=
"absolute inset-0 bg-gradient-to-br from-gray-50 via-primary-50/30 to-gray-100 dark:from-dark-950 dark:via-dark-900 dark:to-dark-950"
></div>
<div
class=
"absolute inset-0 bg-gradient-to-br from-gray-50 via-primary-50/30 to-gray-100 dark:from-dark-950 dark:via-dark-900 dark:to-dark-950"
></div>
<!-- Decorative Elements -->
<div
class=
"absolute inset-0 overflow-hidden
pointer-events-none
"
>
<div
class=
"
pointer-events-none
absolute inset-0 overflow-hidden"
>
<!-- Gradient Orbs -->
<div
class=
"absolute -top-40 -right-40 w-80 h-80 bg-primary-400/20 rounded-full blur-3xl"
></div>
<div
class=
"absolute -bottom-40 -left-40 w-80 h-80 bg-primary-500/15 rounded-full blur-3xl"
></div>
<div
class=
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-primary-300/10 rounded-full blur-3xl"
></div>
<div
class=
"absolute -right-40 -top-40 h-80 w-80 rounded-full bg-primary-400/20 blur-3xl"
></div>
<div
class=
"absolute -bottom-40 -left-40 h-80 w-80 rounded-full bg-primary-500/15 blur-3xl"
></div>
<div
class=
"absolute left-1/2 top-1/2 h-96 w-96 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-300/10 blur-3xl"
></div>
<!-- Grid Pattern -->
<div
class=
"absolute inset-0 bg-[linear-gradient(rgba(20,184,166,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(20,184,166,0.03)_1px,transparent_1px)] bg-[size:64px_64px]"
></div>
<div
class=
"absolute inset-0 bg-[linear-gradient(rgba(20,184,166,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(20,184,166,0.03)_1px,transparent_1px)] bg-[size:64px_64px]"
></div>
</div>
<!-- Content Container -->
<div
class=
"relative w-full max-w-md
z-10
"
>
<div
class=
"relative
z-10
w-full max-w-md"
>
<!-- Logo/Brand -->
<div
class=
"text-center
mb-8
"
>
<div
class=
"
mb-8
text-center"
>
<!-- Custom Logo or Default Logo -->
<div
class=
"inline-flex items-center justify-center w-16 h-16 rounded-2xl overflow-hidden shadow-lg shadow-primary-500/30 mb-4"
>
<img
:src=
"siteLogo || '/logo.png'"
alt=
"Logo"
class=
"w-full h-full object-contain"
/>
<div
class=
"mb-4 inline-flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl shadow-lg shadow-primary-500/30"
>
<img
:src=
"siteLogo || '/logo.png'"
alt=
"Logo"
class=
"h-full w-full object-contain"
/>
</div>
<h1
class=
"text-
3xl font-bold text-gradient mb-2
"
>
<h1
class=
"text-
gradient mb-2 text-3xl font-bold
"
>
{{
siteName
}}
</h1>
<p
class=
"text-sm text-gray-500 dark:text-dark-400"
>
...
...
@@ -36,12 +48,12 @@
</div>
<!-- Footer Links -->
<div
class=
"text-center
mt-6
text-sm"
>
<div
class=
"
mt-6
text-center text-sm"
>
<slot
name=
"footer"
/>
</div>
<!-- Copyright -->
<div
class=
"text-center
mt-8
text-xs text-gray-400 dark:text-dark-500"
>
<div
class=
"
mt-8
text-center text-xs text-gray-400 dark:text-dark-500"
>
©
{{
currentYear
}}
{{
siteName
}}
. All rights reserved.
</div>
</div>
...
...
@@ -49,25 +61,25 @@
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
onMounted
}
from
'
vue
'
;
import
{
getPublicSettings
}
from
'
@/api/auth
'
;
import
{
ref
,
computed
,
onMounted
}
from
'
vue
'
import
{
getPublicSettings
}
from
'
@/api/auth
'
const
siteName
=
ref
(
'
Sub2API
'
)
;
const
siteLogo
=
ref
(
''
)
;
const
siteSubtitle
=
ref
(
'
Subscription to API Conversion Platform
'
)
;
const
siteName
=
ref
(
'
Sub2API
'
)
const
siteLogo
=
ref
(
''
)
const
siteSubtitle
=
ref
(
'
Subscription to API Conversion Platform
'
)
const
currentYear
=
computed
(()
=>
new
Date
().
getFullYear
())
;
const
currentYear
=
computed
(()
=>
new
Date
().
getFullYear
())
onMounted
(
async
()
=>
{
try
{
const
settings
=
await
getPublicSettings
()
;
siteName
.
value
=
settings
.
site_name
||
'
Sub2API
'
;
siteLogo
.
value
=
settings
.
site_logo
||
''
;
siteSubtitle
.
value
=
settings
.
site_subtitle
||
'
Subscription to API Conversion Platform
'
;
const
settings
=
await
getPublicSettings
()
siteName
.
value
=
settings
.
site_name
||
'
Sub2API
'
siteLogo
.
value
=
settings
.
site_logo
||
''
siteSubtitle
.
value
=
settings
.
site_subtitle
||
'
Subscription to API Conversion Platform
'
}
catch
(
error
)
{
console
.
error
(
'
Failed to load public settings:
'
,
error
)
;
console
.
error
(
'
Failed to load public settings:
'
,
error
)
}
})
;
})
</
script
>
<
style
scoped
>
...
...
frontend/src/components/layout/EXAMPLES.md
View file @
429f38d0
...
...
@@ -8,31 +8,31 @@
<div
class=
"space-y-6"
>
<h1
class=
"text-3xl font-bold text-gray-900"
>
Dashboard
</h1>
<div
class=
"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4
gap-4
"
>
<div
class=
"grid grid-cols-1
gap-4
md:grid-cols-2 lg:grid-cols-4"
>
<!-- Stats Cards -->
<div
class=
"bg-white p-6
rounded-lg
shadow"
>
<div
class=
"
rounded-lg
bg-white p-6 shadow"
>
<div
class=
"text-sm text-gray-600"
>
API Keys
</div>
<div
class=
"text-2xl font-bold text-gray-900"
>
5
</div>
</div>
<div
class=
"bg-white p-6
rounded-lg
shadow"
>
<div
class=
"
rounded-lg
bg-white p-6 shadow"
>
<div
class=
"text-sm text-gray-600"
>
Total Usage
</div>
<div
class=
"text-2xl font-bold text-gray-900"
>
1,234
</div>
</div>
<div
class=
"bg-white p-6
rounded-lg
shadow"
>
<div
class=
"
rounded-lg
bg-white p-6 shadow"
>
<div
class=
"text-sm text-gray-600"
>
Balance
</div>
<div
class=
"text-2xl font-bold text-indigo-600"
>
$
{{
balance
}}
</div>
</div>
<div
class=
"bg-white p-6
rounded-lg
shadow"
>
<div
class=
"
rounded-lg
bg-white p-6 shadow"
>
<div
class=
"text-sm text-gray-600"
>
Status
</div>
<div
class=
"text-2xl font-bold text-green-600"
>
Active
</div>
</div>
</div>
<div
class=
"bg-white p-6
rounded-lg
shadow"
>
<h2
class=
"text-xl font-semibold
mb-4
"
>
Recent Activity
</h2>
<div
class=
"
rounded-lg
bg-white p-6 shadow"
>
<h2
class=
"
mb-4
text-xl font-semibold"
>
Recent Activity
</h2>
<p
class=
"text-gray-600"
>
No recent activity
</p>
</div>
</div>
...
...
@@ -40,12 +40,12 @@
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
;
import
{
AppLayout
}
from
'
@/components/layout
'
;
import
{
useAuthStore
}
from
'
@/stores
'
;
import
{
computed
}
from
'
vue
'
import
{
AppLayout
}
from
'
@/components/layout
'
import
{
useAuthStore
}
from
'
@/stores
'
const
authStore
=
useAuthStore
()
;
const
balance
=
computed
(()
=>
authStore
.
user
?.
balance
.
toFixed
(
2
)
||
'
0.00
'
)
;
const
authStore
=
useAuthStore
()
const
balance
=
computed
(()
=>
authStore
.
user
?.
balance
.
toFixed
(
2
)
||
'
0.00
'
)
</
script
>
```
...
...
@@ -56,11 +56,11 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
```
vue
<
template
>
<AuthLayout>
<h2
class=
"text-2xl font-bold text-gray-900
mb-6
"
>
Welcome Back
</h2>
<h2
class=
"
mb-6
text-2xl font-bold text-gray-900"
>
Welcome Back
</h2>
<form
@
submit.prevent=
"handleSubmit"
class=
"space-y-4"
>
<div>
<label
for=
"username"
class=
"block text-sm font-medium text-gray-700
mb-1
"
>
<label
for=
"username"
class=
"
mb-1
block text-sm font-medium text-gray-700"
>
Username
</label>
<input
...
...
@@ -68,13 +68,13 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
v-model=
"form.username"
type=
"text"
required
class=
"w-full
px-3 py-2
border border-gray-300
rounded-lg
focus:ring-2 focus:ring-indigo-500
focus:border-transparent
"
class=
"w-full
rounded-lg
border border-gray-300
px-3 py-2 focus:border-transparent
focus:ring-2 focus:ring-indigo-500"
placeholder=
"Enter your username"
/>
</div>
<div>
<label
for=
"password"
class=
"block text-sm font-medium text-gray-700
mb-1
"
>
<label
for=
"password"
class=
"
mb-1
block text-sm font-medium text-gray-700"
>
Password
</label>
<input
...
...
@@ -82,7 +82,7 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
v-model=
"form.password"
type=
"password"
required
class=
"w-full
px-3 py-2
border border-gray-300
rounded-lg
focus:ring-2 focus:ring-indigo-500
focus:border-transparent
"
class=
"w-full
rounded-lg
border border-gray-300
px-3 py-2 focus:border-transparent
focus:ring-2 focus:ring-indigo-500"
placeholder=
"Enter your password"
/>
</div>
...
...
@@ -90,7 +90,7 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
<button
type=
"submit"
:disabled=
"loading"
class=
"w-full bg-indigo-600
text-white py-2 px-4 rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors
"
class=
"w-full
rounded-lg
bg-indigo-600
px-4 py-2 text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50
"
>
{{
loading
?
'
Logging in...
'
:
'
Login
'
}}
</button>
...
...
@@ -99,7 +99,7 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
<template
#footer
>
<p
class=
"text-gray-600"
>
Don't have an account?
<router-link
to=
"/register"
class=
"text-indigo-600 hover:underline
font-medium
"
>
<router-link
to=
"/register"
class=
"
font-medium
text-indigo-600 hover:underline"
>
Sign up
</router-link>
</p>
...
...
@@ -108,32 +108,32 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
}
from
'
vue
'
;
import
{
useRouter
}
from
'
vue-router
'
;
import
{
AuthLayout
}
from
'
@/components/layout
'
;
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
;
import
{
ref
}
from
'
vue
'
import
{
useRouter
}
from
'
vue-router
'
import
{
AuthLayout
}
from
'
@/components/layout
'
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
const
router
=
useRouter
()
;
const
authStore
=
useAuthStore
()
;
const
appStore
=
useAppStore
()
;
const
router
=
useRouter
()
const
authStore
=
useAuthStore
()
const
appStore
=
useAppStore
()
const
form
=
ref
({
username
:
''
,
password
:
''
,
})
;
password
:
''
})
const
loading
=
ref
(
false
)
;
const
loading
=
ref
(
false
)
async
function
handleSubmit
()
{
loading
.
value
=
true
;
loading
.
value
=
true
try
{
await
authStore
.
login
(
form
.
value
)
;
appStore
.
showSuccess
(
'
Login successful!
'
)
;
await
router
.
push
(
'
/dashboard
'
)
;
await
authStore
.
login
(
form
.
value
)
appStore
.
showSuccess
(
'
Login successful!
'
)
await
router
.
push
(
'
/dashboard
'
)
}
catch
(
error
)
{
appStore
.
showError
(
'
Invalid username or password
'
)
;
appStore
.
showError
(
'
Invalid username or password
'
)
}
finally
{
loading
.
value
=
false
;
loading
.
value
=
false
}
}
</
script
>
...
...
@@ -152,42 +152,42 @@ async function handleSubmit() {
<h1
class=
"text-3xl font-bold text-gray-900"
>
API Keys
</h1>
<button
@
click=
"showCreateModal = true"
class=
"bg-indigo-600 text-white
px-4 py-2 rounded-lg
hover:bg-indigo-700
transition-colors
"
class=
"
rounded-lg
bg-indigo-600
px-4 py-2
text-white
transition-colors
hover:bg-indigo-700"
>
Create New Key
</button>
</div>
<!-- API Keys List -->
<div
class=
"
bg-white rounded-lg shadow overflow-hidden
"
>
<div
class=
"
overflow-hidden rounded-lg bg-white shadow
"
>
<table
class=
"min-w-full divide-y divide-gray-200"
>
<thead
class=
"bg-gray-50"
>
<tr>
<th
class=
"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"
>
Name
</th>
<th
class=
"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"
>
Key
</th>
<th
class=
"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"
>
<th
class=
"px-6 py-3 text-left text-xs font-medium uppercase text-gray-500"
>
Name
</th>
<th
class=
"px-6 py-3 text-left text-xs font-medium uppercase text-gray-500"
>
Key
</th>
<th
class=
"px-6 py-3 text-left text-xs font-medium uppercase text-gray-500"
>
Status
</th>
<th
class=
"px-6 py-3 text-left text-xs font-medium text-gray-500
uppercase
"
>
<th
class=
"px-6 py-3 text-left text-xs font-medium
uppercase
text-gray-500"
>
Created
</th>
<th
class=
"px-6 py-3 text-right text-xs font-medium text-gray-500
uppercase
"
>
<th
class=
"px-6 py-3 text-right text-xs font-medium
uppercase
text-gray-500"
>
Actions
</th>
</tr>
</thead>
<tbody
class=
"
bg-white
divide-y divide-gray-200"
>
<tbody
class=
"divide-y divide-gray-200
bg-white
"
>
<tr
v-for=
"key in apiKeys"
:key=
"key.id"
>
<td
class=
"
px-6 py-4
whitespace-nowrap"
>
{{
key
.
name
}}
</td>
<td
class=
"whitespace-nowrap
px-6 py-4
"
>
{{
key
.
name
}}
</td>
<td
class=
"px-6 py-4 font-mono text-sm"
>
{{
key
.
key
}}
</td>
<td
class=
"px-6 py-4"
>
<span
class=
"px-2 py-1 text-xs rounded-full"
:class=
"key.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'"
class=
"rounded-full px-2 py-1 text-xs"
:class=
"
key.status === 'active'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
"
>
{{
key
.
status
}}
</span>
...
...
@@ -196,9 +196,7 @@ async function handleSubmit() {
{{
new
Date
(
key
.
created_at
).
toLocaleDateString
()
}}
</td>
<td
class=
"px-6 py-4 text-right"
>
<button
class=
"text-red-600 hover:text-red-800 text-sm"
>
Delete
</button>
<button
class=
"text-sm text-red-600 hover:text-red-800"
>
Delete
</button>
</td>
</tr>
</tbody>
...
...
@@ -209,12 +207,12 @@ async function handleSubmit() {
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
}
from
'
vue
'
;
import
{
AppLayout
}
from
'
@/components/layout
'
;
import
type
{
ApiKey
}
from
'
@/types
'
;
import
{
ref
}
from
'
vue
'
import
{
AppLayout
}
from
'
@/components/layout
'
import
type
{
ApiKey
}
from
'
@/types
'
const
showCreateModal
=
ref
(
false
)
;
const
apiKeys
=
ref
<
ApiKey
[]
>
([])
;
const
showCreateModal
=
ref
(
false
)
const
apiKeys
=
ref
<
ApiKey
[]
>
([])
// Fetch API keys on mount
// fetchApiKeys();
...
...
@@ -233,34 +231,40 @@ const apiKeys = ref<ApiKey[]>([]);
<h1
class=
"text-3xl font-bold text-gray-900"
>
User Management
</h1>
<button
@
click=
"showCreateUser = true"
class=
"bg-indigo-600 text-white
px-4 py-2 rounded-lg
hover:bg-indigo-700
transition-colors
"
class=
"
rounded-lg
bg-indigo-600
px-4 py-2
text-white
transition-colors
hover:bg-indigo-700"
>
Create User
</button>
</div>
<!-- Users Table -->
<div
class=
"
bg-white
rounded-lg shadow"
>
<div
class=
"rounded-lg
bg-white
shadow"
>
<div
class=
"p-6"
>
<div
class=
"space-y-4"
>
<div
v-for=
"user in users"
:key=
"user.id"
class=
"flex items-center justify-between border-b pb-4"
>
<div
v-for=
"user in users"
:key=
"user.id"
class=
"flex items-center justify-between border-b pb-4"
>
<div>
<div
class=
"font-medium text-gray-900"
>
{{
user
.
username
}}
</div>
<div
class=
"text-sm text-gray-500"
>
{{
user
.
email
}}
</div>
</div>
<div
class=
"flex items-center space-x-4"
>
<span
class=
"px-2 py-1 text-xs rounded-full"
:class=
"user.role === 'admin' ? 'bg-purple-100 text-purple-800' : 'bg-blue-100 text-blue-800'"
class=
"rounded-full px-2 py-1 text-xs"
:class=
"
user.role === 'admin'
? 'bg-purple-100 text-purple-800'
: 'bg-blue-100 text-blue-800'
"
>
{{
user
.
role
}}
</span>
<span
class=
"text-sm font-medium text-gray-700"
>
$
{{
user
.
balance
.
toFixed
(
2
)
}}
</span>
<button
class=
"text-indigo-600 hover:text-indigo-800 text-sm"
>
Edit
</button>
<button
class=
"text-sm text-indigo-600 hover:text-indigo-800"
>
Edit
</button>
</div>
</div>
</div>
...
...
@@ -271,12 +275,12 @@ const apiKeys = ref<ApiKey[]>([]);
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
}
from
'
vue
'
;
import
{
AppLayout
}
from
'
@/components/layout
'
;
import
type
{
User
}
from
'
@/types
'
;
import
{
ref
}
from
'
vue
'
import
{
AppLayout
}
from
'
@/components/layout
'
import
type
{
User
}
from
'
@/types
'
const
showCreateUser
=
ref
(
false
)
;
const
users
=
ref
<
User
[]
>
([])
;
const
showCreateUser
=
ref
(
false
)
const
users
=
ref
<
User
[]
>
([])
// Fetch users on mount
// fetchUsers();
...
...
@@ -294,36 +298,34 @@ const users = ref<User[]>([]);
<h1
class=
"text-3xl font-bold text-gray-900"
>
Profile Settings
</h1>
<!-- User Info Card -->
<div
class=
"
bg-white
rounded-lg
shadow p-6 space-y-4
"
>
<div
class=
"
space-y-4
rounded-lg
bg-white p-6 shadow
"
>
<h2
class=
"text-xl font-semibold text-gray-900"
>
Account Information
</h2>
<div
class=
"grid grid-cols-1 md:grid-cols-2
gap-4
"
>
<div
class=
"grid grid-cols-1
gap-4
md:grid-cols-2"
>
<div>
<label
class=
"block text-sm font-medium text-gray-700 mb-1"
>
Username
</label>
<div
class=
"px-3 py-2 bg-gray-50 rounded-lg text-gray-900"
>
<label
class=
"mb-1 block text-sm font-medium text-gray-700"
>
Username
</label>
<div
class=
"rounded-lg bg-gray-50 px-3 py-2 text-gray-900"
>
{{
user
?.
username
}}
</div>
</div>
<div>
<label
class=
"block text-sm font-medium text-gray-700 mb-1"
>
Email
</label>
<div
class=
"px-3 py-2 bg-gray-50 rounded-lg text-gray-900"
>
<label
class=
"mb-1 block text-sm font-medium text-gray-700"
>
Email
</label>
<div
class=
"rounded-lg bg-gray-50 px-3 py-2 text-gray-900"
>
{{
user
?.
email
}}
</div>
</div>
<div>
<label
class=
"block text-sm font-medium text-gray-700 mb-1"
>
Role
</label>
<div
class=
"px-3 py-2 bg-gray-50 rounded-lg"
>
<label
class=
"mb-1 block text-sm font-medium text-gray-700"
>
Role
</label>
<div
class=
"rounded-lg bg-gray-50 px-3 py-2"
>
<span
class=
"px-2 py-1 text-xs rounded-full"
:class=
"user?.role === 'admin' ? 'bg-purple-100 text-purple-800' : 'bg-blue-100 text-blue-800'"
class=
"rounded-full px-2 py-1 text-xs"
:class=
"
user?.role === 'admin'
? 'bg-purple-100 text-purple-800'
: 'bg-blue-100 text-blue-800'
"
>
{{
user
?.
role
}}
</span>
...
...
@@ -331,10 +333,8 @@ const users = ref<User[]>([]);
</div>
<div>
<label
class=
"block text-sm font-medium text-gray-700 mb-1"
>
Balance
</label>
<div
class=
"px-3 py-2 bg-gray-50 rounded-lg text-indigo-600 font-semibold"
>
<label
class=
"mb-1 block text-sm font-medium text-gray-700"
>
Balance
</label>
<div
class=
"rounded-lg bg-gray-50 px-3 py-2 font-semibold text-indigo-600"
>
$
{{
user
?.
balance
.
toFixed
(
2
)
}}
</div>
</div>
...
...
@@ -342,12 +342,12 @@ const users = ref<User[]>([]);
</div>
<!-- Change Password Card -->
<div
class=
"
bg-white
rounded-lg
shadow p-6 space-y-4
"
>
<div
class=
"
space-y-4
rounded-lg
bg-white p-6 shadow
"
>
<h2
class=
"text-xl font-semibold text-gray-900"
>
Change Password
</h2>
<form
@
submit.prevent=
"handleChangePassword"
class=
"space-y-4"
>
<div>
<label
for=
"old-password"
class=
"block text-sm font-medium text-gray-700
mb-1
"
>
<label
for=
"old-password"
class=
"
mb-1
block text-sm font-medium text-gray-700"
>
Current Password
</label>
<input
...
...
@@ -355,12 +355,12 @@ const users = ref<User[]>([]);
v-model=
"passwordForm.old_password"
type=
"password"
required
class=
"w-full
px-3 py-2
border border-gray-300
rounded-lg
focus:ring-2 focus:ring-indigo-500"
class=
"w-full
rounded-lg
border border-gray-300
px-3 py-2
focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label
for=
"new-password"
class=
"block text-sm font-medium text-gray-700
mb-1
"
>
<label
for=
"new-password"
class=
"
mb-1
block text-sm font-medium text-gray-700"
>
New Password
</label>
<input
...
...
@@ -368,13 +368,13 @@ const users = ref<User[]>([]);
v-model=
"passwordForm.new_password"
type=
"password"
required
class=
"w-full
px-3 py-2
border border-gray-300
rounded-lg
focus:ring-2 focus:ring-indigo-500"
class=
"w-full
rounded-lg
border border-gray-300
px-3 py-2
focus:ring-2 focus:ring-indigo-500"
/>
</div>
<button
type=
"submit"
class=
"bg-indigo-600 text-white
px-4 py-2 rounded-lg
hover:bg-indigo-700
transition-colors
"
class=
"
rounded-lg
bg-indigo-600
px-4 py-2
text-white
transition-colors
hover:bg-indigo-700"
>
Update Password
</button>
...
...
@@ -385,27 +385,27 @@ const users = ref<User[]>([]);
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
}
from
'
vue
'
;
import
{
AppLayout
}
from
'
@/components/layout
'
;
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
;
import
{
ref
,
computed
}
from
'
vue
'
import
{
AppLayout
}
from
'
@/components/layout
'
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
const
authStore
=
useAuthStore
()
;
const
appStore
=
useAppStore
()
;
const
authStore
=
useAuthStore
()
const
appStore
=
useAppStore
()
const
user
=
computed
(()
=>
authStore
.
user
)
;
const
user
=
computed
(()
=>
authStore
.
user
)
const
passwordForm
=
ref
({
old_password
:
''
,
new_password
:
''
,
})
;
new_password
:
''
})
async
function
handleChangePassword
()
{
try
{
// await changePasswordAPI(passwordForm.value);
appStore
.
showSuccess
(
'
Password updated successfully!
'
)
;
passwordForm
.
value
=
{
old_password
:
''
,
new_password
:
''
}
;
appStore
.
showSuccess
(
'
Password updated successfully!
'
)
passwordForm
.
value
=
{
old_password
:
''
,
new_password
:
''
}
}
catch
(
error
)
{
appStore
.
showError
(
'
Failed to update password
'
)
;
appStore
.
showError
(
'
Failed to update password
'
)
}
}
</
script
>
...
...
frontend/src/components/layout/INTEGRATION.md
View file @
429f38d0
...
...
@@ -6,20 +6,20 @@
```
typescript
// In your view files
import
{
AppLayout
,
AuthLayout
}
from
'
@/components/layout
'
;
import
{
AppLayout
,
AuthLayout
}
from
'
@/components/layout
'
```
### 2. Use in Routes
```
typescript
// src/router/index.ts
import
{
createRouter
,
createWebHistory
}
from
'
vue-router
'
;
import
type
{
RouteRecordRaw
}
from
'
vue-router
'
;
import
{
createRouter
,
createWebHistory
}
from
'
vue-router
'
import
type
{
RouteRecordRaw
}
from
'
vue-router
'
// Views
import
DashboardView
from
'
@/views/DashboardView.vue
'
;
import
LoginView
from
'
@/views/auth/LoginView.vue
'
;
import
RegisterView
from
'
@/views/auth/RegisterView.vue
'
;
import
DashboardView
from
'
@/views/DashboardView.vue
'
import
LoginView
from
'
@/views/auth/LoginView.vue
'
import
RegisterView
from
'
@/views/auth/RegisterView.vue
'
const
routes
:
RouteRecordRaw
[]
=
[
// Auth routes (no layout needed - views use AuthLayout internally)
...
...
@@ -27,13 +27,13 @@ const routes: RouteRecordRaw[] = [
path
:
'
/login
'
,
name
:
'
Login
'
,
component
:
LoginView
,
meta
:
{
requiresAuth
:
false
}
,
meta
:
{
requiresAuth
:
false
}
},
{
path
:
'
/register
'
,
name
:
'
Register
'
,
component
:
RegisterView
,
meta
:
{
requiresAuth
:
false
}
,
meta
:
{
requiresAuth
:
false
}
},
// User routes (use AppLayout)
...
...
@@ -41,31 +41,31 @@ const routes: RouteRecordRaw[] = [
path
:
'
/dashboard
'
,
name
:
'
Dashboard
'
,
component
:
DashboardView
,
meta
:
{
requiresAuth
:
true
,
title
:
'
Dashboard
'
}
,
meta
:
{
requiresAuth
:
true
,
title
:
'
Dashboard
'
}
},
{
path
:
'
/api-keys
'
,
name
:
'
ApiKeys
'
,
component
:
()
=>
import
(
'
@/views/ApiKeysView.vue
'
),
meta
:
{
requiresAuth
:
true
,
title
:
'
API Keys
'
}
,
meta
:
{
requiresAuth
:
true
,
title
:
'
API Keys
'
}
},
{
path
:
'
/usage
'
,
name
:
'
Usage
'
,
component
:
()
=>
import
(
'
@/views/UsageView.vue
'
),
meta
:
{
requiresAuth
:
true
,
title
:
'
Usage Statistics
'
}
,
meta
:
{
requiresAuth
:
true
,
title
:
'
Usage Statistics
'
}
},
{
path
:
'
/redeem
'
,
name
:
'
Redeem
'
,
component
:
()
=>
import
(
'
@/views/RedeemView.vue
'
),
meta
:
{
requiresAuth
:
true
,
title
:
'
Redeem Code
'
}
,
meta
:
{
requiresAuth
:
true
,
title
:
'
Redeem Code
'
}
},
{
path
:
'
/profile
'
,
name
:
'
Profile
'
,
component
:
()
=>
import
(
'
@/views/ProfileView.vue
'
),
meta
:
{
requiresAuth
:
true
,
title
:
'
Profile Settings
'
}
,
meta
:
{
requiresAuth
:
true
,
title
:
'
Profile Settings
'
}
},
// Admin routes (use AppLayout, admin only)
...
...
@@ -73,91 +73,91 @@ const routes: RouteRecordRaw[] = [
path
:
'
/admin/dashboard
'
,
name
:
'
AdminDashboard
'
,
component
:
()
=>
import
(
'
@/views/admin/DashboardView.vue
'
),
meta
:
{
requiresAuth
:
true
,
requiresAdmin
:
true
,
title
:
'
Admin Dashboard
'
}
,
meta
:
{
requiresAuth
:
true
,
requiresAdmin
:
true
,
title
:
'
Admin Dashboard
'
}
},
{
path
:
'
/admin/users
'
,
name
:
'
AdminUsers
'
,
component
:
()
=>
import
(
'
@/views/admin/UsersView.vue
'
),
meta
:
{
requiresAuth
:
true
,
requiresAdmin
:
true
,
title
:
'
User Management
'
}
,
meta
:
{
requiresAuth
:
true
,
requiresAdmin
:
true
,
title
:
'
User Management
'
}
},
{
path
:
'
/admin/groups
'
,
name
:
'
AdminGroups
'
,
component
:
()
=>
import
(
'
@/views/admin/GroupsView.vue
'
),
meta
:
{
requiresAuth
:
true
,
requiresAdmin
:
true
,
title
:
'
Groups
'
}
,
meta
:
{
requiresAuth
:
true
,
requiresAdmin
:
true
,
title
:
'
Groups
'
}
},
{
path
:
'
/admin/accounts
'
,
name
:
'
AdminAccounts
'
,
component
:
()
=>
import
(
'
@/views/admin/AccountsView.vue
'
),
meta
:
{
requiresAuth
:
true
,
requiresAdmin
:
true
,
title
:
'
Accounts
'
}
,
meta
:
{
requiresAuth
:
true
,
requiresAdmin
:
true
,
title
:
'
Accounts
'
}
},
{
path
:
'
/admin/proxies
'
,
name
:
'
AdminProxies
'
,
component
:
()
=>
import
(
'
@/views/admin/ProxiesView.vue
'
),
meta
:
{
requiresAuth
:
true
,
requiresAdmin
:
true
,
title
:
'
Proxies
'
}
,
meta
:
{
requiresAuth
:
true
,
requiresAdmin
:
true
,
title
:
'
Proxies
'
}
},
{
path
:
'
/admin/redeem-codes
'
,
name
:
'
AdminRedeemCodes
'
,
component
:
()
=>
import
(
'
@/views/admin/RedeemCodesView.vue
'
),
meta
:
{
requiresAuth
:
true
,
requiresAdmin
:
true
,
title
:
'
Redeem Codes
'
}
,
meta
:
{
requiresAuth
:
true
,
requiresAdmin
:
true
,
title
:
'
Redeem Codes
'
}
},
// Default redirect
{
path
:
'
/
'
,
redirect
:
'
/dashboard
'
,
}
,
]
;
redirect
:
'
/dashboard
'
}
]
const
router
=
createRouter
({
history
:
createWebHistory
(),
routes
,
})
;
routes
})
// Navigation guards
router
.
beforeEach
((
to
,
from
,
next
)
=>
{
const
authStore
=
useAuthStore
()
;
const
authStore
=
useAuthStore
()
if
(
to
.
meta
.
requiresAuth
&&
!
authStore
.
isAuthenticated
)
{
// Redirect to login if not authenticated
next
(
'
/login
'
)
;
next
(
'
/login
'
)
}
else
if
(
to
.
meta
.
requiresAdmin
&&
!
authStore
.
isAdmin
)
{
// Redirect to dashboard if not admin
next
(
'
/dashboard
'
)
;
next
(
'
/dashboard
'
)
}
else
{
next
()
;
next
()
}
})
;
})
export
default
router
;
export
default
router
```
### 3. Initialize Stores in main.ts
```
typescript
// src/main.ts
import
{
createApp
}
from
'
vue
'
;
import
{
createPinia
}
from
'
pinia
'
;
import
App
from
'
./App.vue
'
;
import
router
from
'
./router
'
;
import
'
./style.css
'
;
import
{
createApp
}
from
'
vue
'
import
{
createPinia
}
from
'
pinia
'
import
App
from
'
./App.vue
'
import
router
from
'
./router
'
import
'
./style.css
'
const
app
=
createApp
(
App
)
;
const
pinia
=
createPinia
()
;
const
app
=
createApp
(
App
)
const
pinia
=
createPinia
()
app
.
use
(
pinia
)
;
app
.
use
(
router
)
;
app
.
use
(
pinia
)
app
.
use
(
router
)
// Initialize auth state on app startup
import
{
useAuthStore
}
from
'
@/stores
'
;
const
authStore
=
useAuthStore
()
;
authStore
.
checkAuth
()
;
import
{
useAuthStore
}
from
'
@/stores
'
const
authStore
=
useAuthStore
()
authStore
.
checkAuth
()
app
.
mount
(
'
#app
'
)
;
app
.
mount
(
'
#app
'
)
```
### 4. Update App.vue
...
...
@@ -193,7 +193,7 @@ app.mount('#app');
</
template
>
<
script
setup
lang=
"ts"
>
import
{
AppLayout
}
from
'
@/components/layout
'
;
import
{
AppLayout
}
from
'
@/components/layout
'
// Your component logic here
</
script
>
...
...
@@ -205,23 +205,21 @@ import { AppLayout } from '@/components/layout';
<!-- src/views/auth/LoginView.vue -->
<
template
>
<AuthLayout>
<h2
class=
"text-2xl font-bold text-gray-900
mb-6
"
>
Login
</h2>
<h2
class=
"
mb-6
text-2xl font-bold text-gray-900"
>
Login
</h2>
<!-- Your login form here -->
<template
#footer
>
<p
class=
"text-gray-600"
>
Don't have an account?
<router-link
to=
"/register"
class=
"text-indigo-600 hover:underline"
>
Sign up
</router-link>
<router-link
to=
"/register"
class=
"text-indigo-600 hover:underline"
>
Sign up
</router-link>
</p>
</
template
>
</AuthLayout>
</template>
<
script
setup
lang=
"ts"
>
import
{
AuthLayout
}
from
'
@/components/layout
'
;
import
{
AuthLayout
}
from
'
@/components/layout
'
// Your login logic here
</
script
>
...
...
@@ -250,7 +248,7 @@ Replace HTML entity icons with your preferred icon library:
<span
class=
"text-lg"
>
📈
</span>
<!-- After (Heroicons example) -->
<ChartBarIcon
class=
"
w
-5
h
-5"
/>
<ChartBarIcon
class=
"
h
-5
w
-5"
/>
```
### Sidebar Customization
...
...
@@ -261,9 +259,9 @@ Modify navigation items in `AppSidebar.vue`:
// Add/remove/modify navigation items
const
userNavItems
=
[
{
path
:
'
/dashboard
'
,
label
:
'
Dashboard
'
,
icon
:
'
📈
'
},
{
path
:
'
/new-page
'
,
label
:
'
New Page
'
,
icon
:
'
📄
'
}
,
// Add new item
{
path
:
'
/new-page
'
,
label
:
'
New Page
'
,
icon
:
'
📄
'
}
// Add new item
// ...
]
;
]
```
### Header Customization
...
...
@@ -287,10 +285,12 @@ Modify user dropdown in `AppHeader.vue`:
## Mobile Responsive Behavior
### Sidebar
-
**Desktop (md+)**
: Always visible, can be collapsed to icon-only view
-
**Mobile**
: Hidden by default, shown via menu toggle in header
### Header
-
**Desktop**
: Shows full user info and balance
-
**Mobile**
: Shows compact view with hamburger menu
...
...
@@ -299,7 +299,7 @@ To improve mobile experience, you can add overlay and transitions:
```
vue
<!-- AppSidebar.vue enhancement for mobile -->
<aside
class=
"fixed left-0 top-0 h-screen transition-transform duration-300
z-40
"
class=
"fixed left-0 top-0
z-40
h-screen transition-transform duration-300"
:class=
"[
sidebarCollapsed ? 'w-16' : 'w-64',
// Hide on mobile when collapsed
...
...
@@ -314,7 +314,7 @@ To improve mobile experience, you can add overlay and transitions:
<div
v-if=
"!sidebarCollapsed"
@
click=
"toggleSidebar"
class=
"fixed inset-0 bg-black bg-opacity-50
z-30
md:hidden"
class=
"fixed inset-0
z-30
bg-black bg-opacity-50 md:hidden"
></div>
```
...
...
@@ -325,9 +325,9 @@ To improve mobile experience, you can add overlay and transitions:
### Auth Store Usage
```
typescript
import
{
useAuthStore
}
from
'
@/stores
'
;
import
{
useAuthStore
}
from
'
@/stores
'
const
authStore
=
useAuthStore
()
;
const
authStore
=
useAuthStore
()
// Check if user is authenticated
if
(
authStore
.
isAuthenticated
)
{
...
...
@@ -340,34 +340,34 @@ if (authStore.isAdmin) {
}
// Get current user
const
user
=
authStore
.
user
;
const
user
=
authStore
.
user
```
### App Store Usage
```
typescript
import
{
useAppStore
}
from
'
@/stores
'
;
import
{
useAppStore
}
from
'
@/stores
'
const
appStore
=
useAppStore
()
;
const
appStore
=
useAppStore
()
// Toggle sidebar
appStore
.
toggleSidebar
()
;
appStore
.
toggleSidebar
()
// Show notifications
appStore
.
showSuccess
(
'
Operation completed!
'
)
;
appStore
.
showError
(
'
Something went wrong
'
)
;
appStore
.
showInfo
(
'
Did you know...
'
)
;
appStore
.
showWarning
(
'
Be careful!
'
)
;
appStore
.
showSuccess
(
'
Operation completed!
'
)
appStore
.
showError
(
'
Something went wrong
'
)
appStore
.
showInfo
(
'
Did you know...
'
)
appStore
.
showWarning
(
'
Be careful!
'
)
// Loading state
appStore
.
setLoading
(
true
)
;
appStore
.
setLoading
(
true
)
// ... perform operation
appStore
.
setLoading
(
false
)
;
appStore
.
setLoading
(
false
)
// Or use helper
await
appStore
.
withLoading
(
async
()
=>
{
// Your async operation
})
;
})
```
---
...
...
@@ -388,7 +388,7 @@ To enhance further:
<!-- Add skip to main content link -->
<a
href=
"#main-content"
class=
"sr-only focus:not-sr-only focus:absolute focus:
top
-4 focus:
left-4 bg-white px-4 py-2 rounded
"
class=
"sr-only
rounded bg-white px-4 py-2
focus:not-sr-only focus:absolute focus:
left
-4 focus:
top-4
"
>
Skip to main content
</a>
...
...
@@ -406,27 +406,27 @@ To enhance further:
```
typescript
// AppHeader.test.ts
import
{
describe
,
it
,
expect
,
beforeEach
}
from
'
vitest
'
;
import
{
mount
}
from
'
@vue/test-utils
'
;
import
{
createPinia
,
setActivePinia
}
from
'
pinia
'
;
import
AppHeader
from
'
@/components/layout/AppHeader.vue
'
;
import
{
describe
,
it
,
expect
,
beforeEach
}
from
'
vitest
'
import
{
mount
}
from
'
@vue/test-utils
'
import
{
createPinia
,
setActivePinia
}
from
'
pinia
'
import
AppHeader
from
'
@/components/layout/AppHeader.vue
'
describe
(
'
AppHeader
'
,
()
=>
{
beforeEach
(()
=>
{
setActivePinia
(
createPinia
())
;
})
;
setActivePinia
(
createPinia
())
})
it
(
'
renders user info when authenticated
'
,
()
=>
{
const
wrapper
=
mount
(
AppHeader
)
;
const
wrapper
=
mount
(
AppHeader
)
// Add assertions
})
;
})
it
(
'
shows dropdown when clicked
'
,
async
()
=>
{
const
wrapper
=
mount
(
AppHeader
)
;
await
wrapper
.
find
(
'
button
'
).
trigger
(
'
click
'
)
;
expect
(
wrapper
.
find
(
'
.dropdown
'
).
exists
()).
toBe
(
true
)
;
})
;
})
;
const
wrapper
=
mount
(
AppHeader
)
await
wrapper
.
find
(
'
button
'
).
trigger
(
'
click
'
)
expect
(
wrapper
.
find
(
'
.dropdown
'
).
exists
()).
toBe
(
true
)
})
})
```
---
...
...
@@ -443,7 +443,7 @@ Layout components are automatically code-split when imported:
```
typescript
// This creates a separate chunk for layout components
import
{
AppLayout
}
from
'
@/components/layout
'
;
import
{
AppLayout
}
from
'
@/components/layout
'
```
### Reducing Re-renders
...
...
@@ -451,7 +451,7 @@ import { AppLayout } from '@/components/layout';
Layout components use
`computed`
refs to prevent unnecessary re-renders:
```
typescript
const
sidebarCollapsed
=
computed
(()
=>
appStore
.
sidebarCollapsed
)
;
const
sidebarCollapsed
=
computed
(()
=>
appStore
.
sidebarCollapsed
)
// This only re-renders when sidebarCollapsed changes
```
...
...
@@ -460,21 +460,25 @@ const sidebarCollapsed = computed(() => appStore.sidebarCollapsed);
## Troubleshooting
### Sidebar not showing
-
Check if
`useAppStore`
is properly initialized
-
Verify Tailwind classes are being processed
-
Check z-index conflicts with other components
### Routes not highlighting in sidebar
-
Ensure route paths match exactly
-
Check
`isActive()`
function logic
-
Verify
`useRoute()`
is working correctly
### User info not displaying
-
Ensure auth store is initialized with
`checkAuth()`
-
Verify user is logged in
-
Check localStorage for auth data
### Mobile menu not working
-
Verify
`toggleSidebar()`
is called correctly
-
Check responsive breakpoints (md:)
-
Test on actual mobile device or browser dev tools
frontend/src/components/layout/README.md
View file @
429f38d0
...
...
@@ -5,9 +5,11 @@ Vue 3 layout components for the Sub2API frontend, built with Composition API, Ty
## Components
### 1. AppLayout.vue
Main application layout with sidebar and header.
**Usage:**
```
vue
<
template
>
<AppLayout>
...
...
@@ -18,11 +20,12 @@ Main application layout with sidebar and header.
</
template
>
<
script
setup
lang=
"ts"
>
import
{
AppLayout
}
from
'
@/components/layout
'
;
import
{
AppLayout
}
from
'
@/components/layout
'
</
script
>
```
**Features:**
-
Responsive sidebar (collapsible)
-
Fixed header at top
-
Main content area with slot
...
...
@@ -31,9 +34,11 @@ import { AppLayout } from '@/components/layout';
---
### 2. AppSidebar.vue
Navigation sidebar with user and admin sections.
**Features:**
-
Logo/brand at top
-
User navigation links:
-
Dashboard
...
...
@@ -58,9 +63,11 @@ Navigation sidebar with user and admin sections.
---
### 3. AppHeader.vue
Top header with user info and actions.
**Features:**
-
Mobile menu toggle button
-
Page title (from route meta or slot)
-
User balance display (desktop only)
...
...
@@ -72,12 +79,11 @@ Top header with user info and actions.
-
Responsive design
**Usage with custom title:**
```
vue
<
template
>
<AppLayout>
<template
#title
>
Custom Page Title
</
template
>
<template
#title
>
Custom Page Title
</
template
>
<!-- Your content -->
</AppLayout>
...
...
@@ -89,14 +95,16 @@ Top header with user info and actions.
---
### 4. AuthLayout.vue
Simple centered layout for authentication pages (login/register).
**Usage:**
```
vue
<
template
>
<AuthLayout>
<!-- Login/Register form content -->
<h2
class=
"text-2xl font-bold
mb-6
"
>
Login
</h2>
<h2
class=
"
mb-6
text-2xl font-bold"
>
Login
</h2>
<form
@
submit.prevent=
"handleLogin"
>
<!-- Form fields -->
...
...
@@ -106,16 +114,14 @@ Simple centered layout for authentication pages (login/register).
<template
#footer
>
<p>
Don't have an account?
<router-link
to=
"/register"
class=
"text-indigo-600 hover:underline"
>
Sign up
</router-link>
<router-link
to=
"/register"
class=
"text-indigo-600 hover:underline"
>
Sign up
</router-link>
</p>
</
template
>
</AuthLayout>
</template>
<
script
setup
lang=
"ts"
>
import
{
AuthLayout
}
from
'
@/components/layout
'
;
import
{
AuthLayout
}
from
'
@/components/layout
'
function
handleLogin
()
{
// Login logic
...
...
@@ -124,6 +130,7 @@ function handleLogin() {
```
**Features:**
-
Centered card container
-
Gradient background
-
Logo/brand at top
...
...
@@ -143,15 +150,15 @@ const routes = [
{
path
:
'
/dashboard
'
,
component
:
DashboardView
,
meta
:
{
title
:
'
Dashboard
'
}
,
meta
:
{
title
:
'
Dashboard
'
}
},
{
path
:
'
/api-keys
'
,
component
:
ApiKeysView
,
meta
:
{
title
:
'
API Keys
'
}
,
}
,
meta
:
{
title
:
'
API Keys
'
}
}
// ...
]
;
]
```
---
...
...
@@ -173,10 +180,7 @@ All components use TailwindCSS utility classes. Make sure your `tailwind.config.
```
js
module
.
exports
=
{
content
:
[
'
./index.html
'
,
'
./src/**/*.{vue,js,ts,jsx,tsx}
'
,
],
content
:
[
'
./index.html
'
,
'
./src/**/*.{vue,js,ts,jsx,tsx}
'
]
// ...
}
```
...
...
@@ -186,6 +190,7 @@ module.exports = {
## Icons
Components use HTML entity icons for simplicity:
-
📈
Chart (Dashboard)
-
🔑
Key (API Keys)
-
📊
Bar Chart (Usage)
...
...
@@ -205,6 +210,7 @@ You can replace these with your preferred icon library (e.g., Heroicons, Font Aw
## Mobile Responsiveness
All components are fully responsive:
-
**AppSidebar**
: Fixed positioning on desktop, hidden by default on mobile
-
**AppHeader**
: Shows mobile menu toggle on small screens, hides balance display
-
**AuthLayout**
: Adapts padding and card size for mobile devices
...
...
frontend/src/components/layout/index.ts
View file @
429f38d0
...
...
@@ -3,7 +3,7 @@
* Export all layout components for easy importing
*/
export
{
default
as
AppLayout
}
from
'
./AppLayout.vue
'
;
export
{
default
as
AppSidebar
}
from
'
./AppSidebar.vue
'
;
export
{
default
as
AppHeader
}
from
'
./AppHeader.vue
'
;
export
{
default
as
AuthLayout
}
from
'
./AuthLayout.vue
'
;
export
{
default
as
AppLayout
}
from
'
./AppLayout.vue
'
export
{
default
as
AppSidebar
}
from
'
./AppSidebar.vue
'
export
{
default
as
AppHeader
}
from
'
./AppHeader.vue
'
export
{
default
as
AuthLayout
}
from
'
./AuthLayout.vue
'
frontend/src/composables/useAccountOAuth.ts
View file @
429f38d0
...
...
@@ -53,9 +53,10 @@ export function useAccountOAuth() {
try
{
const
proxyConfig
=
proxyId
?
{
proxy_id
:
proxyId
}
:
{}
const
endpoint
=
addMethod
===
'
oauth
'
?
'
/admin/accounts/generate-auth-url
'
:
'
/admin/accounts/generate-setup-token-url
'
const
endpoint
=
addMethod
===
'
oauth
'
?
'
/admin/accounts/generate-auth-url
'
:
'
/admin/accounts/generate-setup-token-url
'
const
response
=
await
adminAPI
.
accounts
.
generateAuthUrl
(
endpoint
,
proxyConfig
)
authUrl
.
value
=
response
.
auth_url
...
...
@@ -85,9 +86,10 @@ export function useAccountOAuth() {
try
{
const
proxyConfig
=
proxyId
?
{
proxy_id
:
proxyId
}
:
{}
const
endpoint
=
addMethod
===
'
oauth
'
?
'
/admin/accounts/exchange-code
'
:
'
/admin/accounts/exchange-setup-token-code
'
const
endpoint
=
addMethod
===
'
oauth
'
?
'
/admin/accounts/exchange-code
'
:
'
/admin/accounts/exchange-setup-token-code
'
const
tokenInfo
=
await
adminAPI
.
accounts
.
exchangeCode
(
endpoint
,
{
session_id
:
sessionId
.
value
,
...
...
@@ -121,9 +123,10 @@ export function useAccountOAuth() {
try
{
const
proxyConfig
=
proxyId
?
{
proxy_id
:
proxyId
}
:
{}
const
endpoint
=
addMethod
===
'
oauth
'
?
'
/admin/accounts/cookie-auth
'
:
'
/admin/accounts/setup-token-cookie-auth
'
const
endpoint
=
addMethod
===
'
oauth
'
?
'
/admin/accounts/cookie-auth
'
:
'
/admin/accounts/setup-token-cookie-auth
'
const
tokenInfo
=
await
adminAPI
.
accounts
.
exchangeCode
(
endpoint
,
{
session_id
:
''
,
...
...
@@ -142,7 +145,10 @@ export function useAccountOAuth() {
// Parse multiple session keys
const
parseSessionKeys
=
(
input
:
string
):
string
[]
=>
{
return
input
.
split
(
'
\n
'
).
map
(
k
=>
k
.
trim
()).
filter
(
k
=>
k
)
return
input
.
split
(
'
\n
'
)
.
map
((
k
)
=>
k
.
trim
())
.
filter
((
k
)
=>
k
)
}
// Build extra info from token response
...
...
frontend/src/composables/useGeminiOAuth.ts
0 → 100644
View file @
429f38d0
import
{
ref
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
GeminiOAuthCapabilities
}
from
'
@/api/admin/gemini
'
export
interface
GeminiTokenInfo
{
access_token
?:
string
refresh_token
?:
string
token_type
?:
string
scope
?:
string
expires_at
?:
number
|
string
project_id
?:
string
oauth_type
?:
string
[
key
:
string
]:
unknown
}
export
function
useGeminiOAuth
()
{
const
appStore
=
useAppStore
()
const
{
t
}
=
useI18n
()
const
authUrl
=
ref
(
''
)
const
sessionId
=
ref
(
''
)
const
state
=
ref
(
''
)
const
loading
=
ref
(
false
)
const
error
=
ref
(
''
)
const
resetState
=
()
=>
{
authUrl
.
value
=
''
sessionId
.
value
=
''
state
.
value
=
''
loading
.
value
=
false
error
.
value
=
''
}
const
generateAuthUrl
=
async
(
proxyId
:
number
|
null
|
undefined
,
projectId
?:
string
|
null
,
oauthType
?:
string
):
Promise
<
boolean
>
=>
{
loading
.
value
=
true
authUrl
.
value
=
''
sessionId
.
value
=
''
state
.
value
=
''
error
.
value
=
''
try
{
const
payload
:
Record
<
string
,
unknown
>
=
{}
if
(
proxyId
)
payload
.
proxy_id
=
proxyId
const
trimmedProjectID
=
projectId
?.
trim
()
if
(
trimmedProjectID
)
payload
.
project_id
=
trimmedProjectID
if
(
oauthType
)
payload
.
oauth_type
=
oauthType
const
response
=
await
adminAPI
.
gemini
.
generateAuthUrl
(
payload
as
any
)
authUrl
.
value
=
response
.
auth_url
sessionId
.
value
=
response
.
session_id
state
.
value
=
response
.
state
return
true
}
catch
(
err
:
any
)
{
error
.
value
=
err
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.gemini.failedToGenerateUrl
'
)
appStore
.
showError
(
error
.
value
)
return
false
}
finally
{
loading
.
value
=
false
}
}
const
exchangeAuthCode
=
async
(
params
:
{
code
:
string
sessionId
:
string
state
:
string
proxyId
?:
number
|
null
oauthType
?:
string
}):
Promise
<
GeminiTokenInfo
|
null
>
=>
{
const
code
=
params
.
code
?.
trim
()
if
(
!
code
||
!
params
.
sessionId
||
!
params
.
state
)
{
error
.
value
=
t
(
'
admin.accounts.oauth.gemini.missingExchangeParams
'
)
return
null
}
loading
.
value
=
true
error
.
value
=
''
try
{
const
payload
:
Record
<
string
,
unknown
>
=
{
session_id
:
params
.
sessionId
,
state
:
params
.
state
,
code
}
if
(
params
.
proxyId
)
payload
.
proxy_id
=
params
.
proxyId
if
(
params
.
oauthType
)
payload
.
oauth_type
=
params
.
oauthType
const
tokenInfo
=
await
adminAPI
.
gemini
.
exchangeCode
(
payload
as
any
)
return
tokenInfo
as
GeminiTokenInfo
}
catch
(
err
:
any
)
{
error
.
value
=
err
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.gemini.failedToExchangeCode
'
)
appStore
.
showError
(
error
.
value
)
return
null
}
finally
{
loading
.
value
=
false
}
}
const
buildCredentials
=
(
tokenInfo
:
GeminiTokenInfo
):
Record
<
string
,
unknown
>
=>
{
let
expiresAt
:
string
|
undefined
if
(
typeof
tokenInfo
.
expires_at
===
'
number
'
&&
Number
.
isFinite
(
tokenInfo
.
expires_at
))
{
expiresAt
=
Math
.
floor
(
tokenInfo
.
expires_at
).
toString
()
}
else
if
(
typeof
tokenInfo
.
expires_at
===
'
string
'
&&
tokenInfo
.
expires_at
.
trim
())
{
expiresAt
=
tokenInfo
.
expires_at
.
trim
()
}
return
{
access_token
:
tokenInfo
.
access_token
,
refresh_token
:
tokenInfo
.
refresh_token
,
token_type
:
tokenInfo
.
token_type
,
expires_at
:
expiresAt
,
scope
:
tokenInfo
.
scope
,
project_id
:
tokenInfo
.
project_id
,
oauth_type
:
tokenInfo
.
oauth_type
}
}
const
getCapabilities
=
async
():
Promise
<
GeminiOAuthCapabilities
|
null
>
=>
{
try
{
return
await
adminAPI
.
gemini
.
getCapabilities
()
}
catch
(
err
:
any
)
{
// Capabilities are optional for older servers; don't block the UI.
return
null
}
}
return
{
authUrl
,
sessionId
,
state
,
loading
,
error
,
resetState
,
generateAuthUrl
,
exchangeAuthCode
,
buildCredentials
,
getCapabilities
}
}
Prev
1
2
3
4
5
6
7
8
9
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment