Commit 429f38d0 authored by shaw's avatar shaw
Browse files

Merge PR #37: Add Gemini OAuth and Messages Compat Support

parents 2d89f366 2714be99
<template> <template>
<!-- Claude/Anthropic logo --> <!-- Claude/Anthropic logo -->
<svg v-if="platform === 'anthropic'" :class="sizeClass" viewBox="0 0 16 16" fill="currentColor"> <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> </svg>
<!-- OpenAI logo --> <!-- OpenAI logo -->
<svg v-else-if="platform === 'openai'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor"> <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> </svg>
<!-- Fallback: generic platform icon --> <!-- Fallback: generic platform icon -->
<svg v-else :class="sizeClass" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
</template> </template>
......
<template> <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 --> <!-- Platform part -->
<span <span :class="['inline-flex items-center gap-1 px-2 py-1', platformClass]">
:class="[
'inline-flex items-center gap-1 px-2 py-1',
platformClass
]"
>
<PlatformIcon :platform="platform" size="xs" /> <PlatformIcon :platform="platform" size="xs" />
<span>{{ platformLabel }}</span> <span>{{ platformLabel }}</span>
</span> </span>
<!-- Type part --> <!-- Type part -->
<span <span :class="['inline-flex items-center gap-1 px-1.5 py-1', typeClass]">
:class="[
'inline-flex items-center gap-1 px-1.5 py-1',
typeClass
]"
>
<!-- OAuth icon --> <!-- OAuth icon -->
<svg v-if="type === 'oauth'" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg
<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" /> 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> </svg>
<!-- Setup Token icon --> <!-- 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"> <svg
<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" /> 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> </svg>
<!-- API Key icon --> <!-- API Key icon -->
<svg v-else class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg
<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" /> 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> </svg>
<span>{{ typeLabel }}</span> <span>{{ typeLabel }}</span>
</span> </span>
...@@ -47,15 +70,21 @@ interface Props { ...@@ -47,15 +70,21 @@ interface Props {
const props = defineProps<Props>() const props = defineProps<Props>()
const platformLabel = computed(() => { 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(() => { const typeLabel = computed(() => {
switch (props.type) { switch (props.type) {
case 'oauth': return 'OAuth' case 'oauth':
case 'setup-token': return 'Token' return 'OAuth'
case 'apikey': return 'Key' case 'setup-token':
default: return props.type return 'Token'
case 'apikey':
return 'Key'
default:
return props.type
} }
}) })
...@@ -63,13 +92,19 @@ const platformClass = computed(() => { ...@@ -63,13 +92,19 @@ const platformClass = computed(() => {
if (props.platform === 'anthropic') { if (props.platform === 'anthropic') {
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' 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(() => { const typeClass = computed(() => {
if (props.platform === 'anthropic') { if (props.platform === 'anthropic') {
return 'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400' 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> </script>
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
</span> </span>
<span class="select-icon"> <span class="select-icon">
<svg <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" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
...@@ -27,15 +27,22 @@ ...@@ -27,15 +27,22 @@
</button> </button>
<Transition name="select-dropdown"> <Transition name="select-dropdown">
<div <div v-if="isOpen" class="select-dropdown">
v-if="isOpen"
class="select-dropdown"
>
<!-- Search and Batch Test Header --> <!-- Search and Batch Test Header -->
<div class="select-header"> <div class="select-header">
<div class="select-search"> <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"> <svg
<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" /> 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> </svg>
<input <input
ref="searchInputRef" ref="searchInputRef"
...@@ -54,12 +61,34 @@ ...@@ -54,12 +61,34 @@
class="batch-test-btn" class="batch-test-btn"
:title="t('admin.proxies.batchTest')" :title="t('admin.proxies.batchTest')"
> >
<svg v-if="batchTesting" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"> <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> <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> 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>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> <svg
<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" /> 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> </svg>
</button> </button>
</div> </div>
...@@ -69,15 +98,12 @@ ...@@ -69,15 +98,12 @@
<!-- No Proxy option --> <!-- No Proxy option -->
<div <div
@click="selectOption(null)" @click="selectOption(null)"
:class="[ :class="['select-option', modelValue === null && 'select-option-selected']"
'select-option',
modelValue === null && 'select-option-selected'
]"
> >
<span class="select-option-label">{{ t('admin.accounts.noProxy') }}</span> <span class="select-option-label">{{ t('admin.accounts.noProxy') }}</span>
<svg <svg
v-if="modelValue === null" v-if="modelValue === null"
class="w-4 h-4 text-primary-500" class="h-4 w-4 text-primary-500"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
...@@ -92,18 +118,15 @@ ...@@ -92,18 +118,15 @@
v-for="proxy in filteredProxies" v-for="proxy in filteredProxies"
:key="proxy.id" :key="proxy.id"
@click="selectOption(proxy.id)" @click="selectOption(proxy.id)"
:class="[ :class="['select-option', modelValue === proxy.id && 'select-option-selected']"
'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"> <div class="flex items-center gap-2">
<span class="truncate font-medium">{{ proxy.name }}</span> <span class="truncate font-medium">{{ proxy.name }}</span>
<!-- Account count badge --> <!-- Account count badge -->
<span <span
v-if="proxy.account_count !== undefined" v-if="proxy.account_count !== undefined"
class="flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-100 dark:bg-dark-600 text-gray-600 dark:text-gray-400" class="inline-flex flex-shrink-0 items-center rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600 dark:bg-dark-600 dark:text-gray-400"
> >
{{ proxy.account_count }} {{ proxy.account_count }}
</span> </span>
...@@ -111,20 +134,24 @@ ...@@ -111,20 +134,24 @@
<template v-if="testResults[proxy.id]"> <template v-if="testResults[proxy.id]">
<span <span
v-if="testResults[proxy.id].success" 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-100 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-700 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].country">{{
<span v-if="testResults[proxy.id].latency_ms">{{ testResults[proxy.id].latency_ms }}ms</span> testResults[proxy.id].country
}}</span>
<span v-if="testResults[proxy.id].latency_ms"
>{{ testResults[proxy.id].latency_ms }}ms</span
>
</span> </span>
<span <span
v-else v-else
class="flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400" class="inline-flex flex-shrink-0 items-center rounded bg-red-100 px-1.5 py-0.5 text-xs text-red-700 dark:bg-red-900/30 dark:text-red-400"
> >
{{ t('admin.proxies.testFailed') }} {{ t('admin.proxies.testFailed') }}
</span> </span>
</template> </template>
</div> </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 }} {{ proxy.protocol }}://{{ proxy.host }}:{{ proxy.port }}
</div> </div>
</div> </div>
...@@ -137,18 +164,45 @@ ...@@ -137,18 +164,45 @@
class="test-btn" class="test-btn"
:title="t('admin.proxies.testConnection')" :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"> <svg
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> v-if="testingProxyIds.has(proxy.id)"
<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> 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>
<svg v-else class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> <svg
<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" /> 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> </svg>
</button> </button>
<svg <svg
v-if="modelValue === proxy.id" 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" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
...@@ -193,7 +247,7 @@ interface Props { ...@@ -193,7 +247,7 @@ interface Props {
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
disabled: false, disabled: false
}) })
const emit = defineEmits<{ const emit = defineEmits<{
...@@ -212,7 +266,7 @@ const batchTesting = ref(false) ...@@ -212,7 +266,7 @@ const batchTesting = ref(false)
const selectedProxy = computed(() => { const selectedProxy = computed(() => {
if (props.modelValue === null) return null 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(() => { const selectedLabel = computed(() => {
...@@ -228,7 +282,7 @@ const filteredProxies = computed(() => { ...@@ -228,7 +282,7 @@ const filteredProxies = computed(() => {
return props.proxies return props.proxies
} }
const query = searchQuery.value.toLowerCase() const query = searchQuery.value.toLowerCase()
return props.proxies.filter(proxy => { return props.proxies.filter((proxy) => {
const name = proxy.name.toLowerCase() const name = proxy.name.toLowerCase()
const host = proxy.host.toLowerCase() const host = proxy.host.toLowerCase()
return name.includes(query) || host.includes(query) return name.includes(query) || host.includes(query)
...@@ -320,27 +374,27 @@ onUnmounted(() => { ...@@ -320,27 +374,27 @@ onUnmounted(() => {
<style scoped> <style scoped>
.select-trigger { .select-trigger {
@apply w-full flex items-center justify-between gap-2; @apply flex w-full items-center justify-between gap-2;
@apply px-4 py-2.5 rounded-xl text-sm; @apply rounded-xl px-4 py-2.5 text-sm;
@apply bg-white dark:bg-dark-800; @apply bg-white dark:bg-dark-800;
@apply border border-gray-200 dark:border-dark-600; @apply border border-gray-200 dark:border-dark-600;
@apply text-gray-900 dark:text-gray-100; @apply text-gray-900 dark:text-gray-100;
@apply transition-all duration-200; @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 hover:border-gray-300 dark:hover:border-dark-500;
@apply cursor-pointer; @apply cursor-pointer;
} }
.select-trigger-open { .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 { .select-trigger-disabled {
@apply bg-gray-100 dark:bg-dark-900 cursor-not-allowed opacity-60; @apply cursor-not-allowed bg-gray-100 opacity-60 dark:bg-dark-900;
} }
.select-value { .select-value {
@apply flex-1 text-left truncate; @apply flex-1 truncate text-left;
} }
.select-icon { .select-icon {
...@@ -348,7 +402,7 @@ onUnmounted(() => { ...@@ -348,7 +402,7 @@ onUnmounted(() => {
} }
.select-dropdown { .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 bg-white dark:bg-dark-800;
@apply rounded-xl; @apply rounded-xl;
@apply border border-gray-200 dark:border-dark-700; @apply border border-gray-200 dark:border-dark-700;
...@@ -362,7 +416,7 @@ onUnmounted(() => { ...@@ -362,7 +416,7 @@ onUnmounted(() => {
} }
.select-search { .select-search {
@apply flex-1 flex items-center gap-2; @apply flex flex-1 items-center gap-2;
} }
.select-search-input { .select-search-input {
...@@ -373,10 +427,10 @@ onUnmounted(() => { ...@@ -373,10 +427,10 @@ onUnmounted(() => {
} }
.batch-test-btn { .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 text-gray-500 hover:text-emerald-600 dark:hover:text-emerald-400;
@apply hover:bg-emerald-50 dark:hover:bg-emerald-900/20; @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 { .select-options {
...@@ -406,10 +460,10 @@ onUnmounted(() => { ...@@ -406,10 +460,10 @@ onUnmounted(() => {
} }
.test-btn { .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 text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400;
@apply hover:bg-emerald-50 dark:hover:bg-emerald-900/20; @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 */ /* Dropdown animation */
......
...@@ -5,18 +5,22 @@ This directory contains reusable Vue 3 components built with Composition API, Ty ...@@ -5,18 +5,22 @@ This directory contains reusable Vue 3 components built with Composition API, Ty
## Components ## Components
### DataTable.vue ### DataTable.vue
A generic data table component with sorting, loading states, and custom cell rendering. A generic data table component with sorting, loading states, and custom cell rendering.
**Props:** **Props:**
- `columns: Column[]` - Array of column definitions with key, label, sortable, and formatter - `columns: Column[]` - Array of column definitions with key, label, sortable, and formatter
- `data: any[]` - Array of data objects to display - `data: any[]` - Array of data objects to display
- `loading?: boolean` - Show loading skeleton - `loading?: boolean` - Show loading skeleton
**Slots:** **Slots:**
- `empty` - Custom empty state content - `empty` - Custom empty state content
- `cell-{key}` - Custom cell renderer for specific column (receives `row` and `value`) - `cell-{key}` - Custom cell renderer for specific column (receives `row` and `value`)
**Usage:** **Usage:**
```vue ```vue
<DataTable <DataTable
:columns="[ :columns="[
...@@ -36,19 +40,23 @@ A generic data table component with sorting, loading states, and custom cell ren ...@@ -36,19 +40,23 @@ A generic data table component with sorting, loading states, and custom cell ren
--- ---
### Pagination.vue ### Pagination.vue
Pagination component with page numbers, navigation, and page size selector. Pagination component with page numbers, navigation, and page size selector.
**Props:** **Props:**
- `total: number` - Total number of items - `total: number` - Total number of items
- `page: number` - Current page (1-indexed) - `page: number` - Current page (1-indexed)
- `pageSize: number` - Items per page - `pageSize: number` - Items per page
- `pageSizeOptions?: number[]` - Available page size options (default: [10, 20, 50, 100]) - `pageSizeOptions?: number[]` - Available page size options (default: [10, 20, 50, 100])
**Events:** **Events:**
- `update:page` - Emitted when page changes - `update:page` - Emitted when page changes
- `update:pageSize` - Emitted when page size changes - `update:pageSize` - Emitted when page size changes
**Usage:** **Usage:**
```vue ```vue
<Pagination <Pagination
:total="totalUsers" :total="totalUsers"
...@@ -62,9 +70,11 @@ Pagination component with page numbers, navigation, and page size selector. ...@@ -62,9 +70,11 @@ Pagination component with page numbers, navigation, and page size selector.
--- ---
### Modal.vue ### Modal.vue
Modal dialog with customizable size and close behavior. Modal dialog with customizable size and close behavior.
**Props:** **Props:**
- `show: boolean` - Control modal visibility - `show: boolean` - Control modal visibility
- `title: string` - Modal title - `title: string` - Modal title
- `size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'` - Modal size (default: 'md') - `size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'` - Modal size (default: 'md')
...@@ -72,13 +82,16 @@ Modal dialog with customizable size and close behavior. ...@@ -72,13 +82,16 @@ Modal dialog with customizable size and close behavior.
- `closeOnClickOutside?: boolean` - Close on backdrop click (default: true) - `closeOnClickOutside?: boolean` - Close on backdrop click (default: true)
**Events:** **Events:**
- `close` - Emitted when modal should close - `close` - Emitted when modal should close
**Slots:** **Slots:**
- `default` - Modal body content - `default` - Modal body content
- `footer` - Modal footer content - `footer` - Modal footer content
**Usage:** **Usage:**
```vue ```vue
<Modal :show="showModal" title="Edit User" size="lg" @close="showModal = false"> <Modal :show="showModal" title="Edit User" size="lg" @close="showModal = false">
<form @submit.prevent="saveUser"> <form @submit.prevent="saveUser">
...@@ -95,9 +108,11 @@ Modal dialog with customizable size and close behavior. ...@@ -95,9 +108,11 @@ Modal dialog with customizable size and close behavior.
--- ---
### ConfirmDialog.vue ### ConfirmDialog.vue
Confirmation dialog built on top of Modal component. Confirmation dialog built on top of Modal component.
**Props:** **Props:**
- `show: boolean` - Control dialog visibility - `show: boolean` - Control dialog visibility
- `title: string` - Dialog title - `title: string` - Dialog title
- `message: string` - Confirmation message - `message: string` - Confirmation message
...@@ -106,10 +121,12 @@ Confirmation dialog built on top of Modal component. ...@@ -106,10 +121,12 @@ Confirmation dialog built on top of Modal component.
- `danger?: boolean` - Use danger/red styling (default: false) - `danger?: boolean` - Use danger/red styling (default: false)
**Events:** **Events:**
- `confirm` - Emitted when user confirms - `confirm` - Emitted when user confirms
- `cancel` - Emitted when user cancels - `cancel` - Emitted when user cancels
**Usage:** **Usage:**
```vue ```vue
<ConfirmDialog <ConfirmDialog
:show="showDeleteConfirm" :show="showDeleteConfirm"
...@@ -126,9 +143,11 @@ Confirmation dialog built on top of Modal component. ...@@ -126,9 +143,11 @@ Confirmation dialog built on top of Modal component.
--- ---
### StatCard.vue ### StatCard.vue
Statistics card component for displaying metrics with optional change indicators. Statistics card component for displaying metrics with optional change indicators.
**Props:** **Props:**
- `title: string` - Card title - `title: string` - Card title
- `value: number | string` - Main value to display - `value: number | string` - Main value to display
- `icon?: Component` - Icon component - `icon?: Component` - Icon component
...@@ -137,22 +156,19 @@ Statistics card component for displaying metrics with optional change indicators ...@@ -137,22 +156,19 @@ Statistics card component for displaying metrics with optional change indicators
- `formatValue?: (value) => string` - Custom value formatter - `formatValue?: (value) => string` - Custom value formatter
**Usage:** **Usage:**
```vue ```vue
<StatCard <StatCard title="Total Users" :value="1234" :icon="UserIcon" :change="12.5" change-type="up" />
title="Total Users"
:value="1234"
:icon="UserIcon"
:change="12.5"
change-type="up"
/>
``` ```
--- ---
### Toast.vue ### Toast.vue
Toast notification component that automatically displays toasts from the app store. Toast notification component that automatically displays toasts from the app store.
**Usage:** **Usage:**
```vue ```vue
<!-- Add once in App.vue or layout --> <!-- Add once in App.vue or layout -->
<Toast /> <Toast />
...@@ -180,13 +196,16 @@ appStore.addToast({ ...@@ -180,13 +196,16 @@ appStore.addToast({
--- ---
### LoadingSpinner.vue ### LoadingSpinner.vue
Simple animated loading spinner. Simple animated loading spinner.
**Props:** **Props:**
- `size?: 'sm' | 'md' | 'lg' | 'xl'` - Spinner size (default: 'md') - `size?: 'sm' | 'md' | 'lg' | 'xl'` - Spinner size (default: 'md')
- `color?: 'primary' | 'secondary' | 'white' | 'gray'` - Spinner color (default: 'primary') - `color?: 'primary' | 'secondary' | 'white' | 'gray'` - Spinner color (default: 'primary')
**Usage:** **Usage:**
```vue ```vue
<LoadingSpinner size="lg" color="primary" /> <LoadingSpinner size="lg" color="primary" />
``` ```
...@@ -194,9 +213,11 @@ Simple animated loading spinner. ...@@ -194,9 +213,11 @@ Simple animated loading spinner.
--- ---
### EmptyState.vue ### EmptyState.vue
Empty state placeholder with icon, message, and optional action button. Empty state placeholder with icon, message, and optional action button.
**Props:** **Props:**
- `icon?: Component` - Icon component - `icon?: Component` - Icon component
- `title: string` - Empty state title - `title: string` - Empty state title
- `description: string` - Empty state description - `description: string` - Empty state description
...@@ -205,10 +226,12 @@ Empty state placeholder with icon, message, and optional action button. ...@@ -205,10 +226,12 @@ Empty state placeholder with icon, message, and optional action button.
- `actionIcon?: boolean` - Show plus icon in button (default: true) - `actionIcon?: boolean` - Show plus icon in button (default: true)
**Slots:** **Slots:**
- `icon` - Custom icon content - `icon` - Custom icon content
- `action` - Custom action button/link - `action` - Custom action button/link
**Usage:** **Usage:**
```vue ```vue
<EmptyState <EmptyState
title="No users found" title="No users found"
...@@ -235,6 +258,7 @@ import DataTable from '@/components/common/DataTable.vue' ...@@ -235,6 +258,7 @@ import DataTable from '@/components/common/DataTable.vue'
## Features ## Features
All components include: All components include:
- **TypeScript support** with proper type definitions - **TypeScript support** with proper type definitions
- **Accessibility** with ARIA attributes and keyboard navigation - **Accessibility** with ARIA attributes and keyboard navigation
- **Responsive design** with mobile-friendly layouts - **Responsive design** with mobile-friendly layouts
......
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
</span> </span>
<span class="select-icon"> <span class="select-icon">
<svg <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" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
...@@ -30,14 +30,21 @@ ...@@ -30,14 +30,21 @@
</button> </button>
<Transition name="select-dropdown"> <Transition name="select-dropdown">
<div <div v-if="isOpen" class="select-dropdown">
v-if="isOpen"
class="select-dropdown"
>
<!-- Search input --> <!-- Search input -->
<div v-if="searchable" class="select-search"> <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"> <svg
<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" /> 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> </svg>
<input <input
ref="searchInputRef" ref="searchInputRef"
...@@ -55,16 +62,13 @@ ...@@ -55,16 +62,13 @@
v-for="option in filteredOptions" v-for="option in filteredOptions"
:key="getOptionValue(option) ?? undefined" :key="getOptionValue(option) ?? undefined"
@click="selectOption(option)" @click="selectOption(option)"
:class="[ :class="['select-option', isSelected(option) && 'select-option-selected']"
'select-option',
isSelected(option) && 'select-option-selected'
]"
> >
<slot name="option" :option="option" :selected="isSelected(option)"> <slot name="option" :option="option" :selected="isSelected(option)">
<span class="select-option-label">{{ getOptionLabel(option) }}</span> <span class="select-option-label">{{ getOptionLabel(option) }}</span>
<svg <svg
v-if="isSelected(option)" v-if="isSelected(option)"
class="w-4 h-4 text-primary-500" class="h-4 w-4 text-primary-500"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
...@@ -126,7 +130,9 @@ const props = withDefaults(defineProps<Props>(), { ...@@ -126,7 +130,9 @@ const props = withDefaults(defineProps<Props>(), {
// Use computed for i18n default values // Use computed for i18n default values
const placeholderText = computed(() => props.placeholder ?? t('common.selectOption')) 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 emptyTextDisplay = computed(() => props.emptyText ?? t('common.noOptionsFound'))
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
...@@ -136,7 +142,9 @@ const searchQuery = ref('') ...@@ -136,7 +142,9 @@ const searchQuery = ref('')
const containerRef = ref<HTMLElement | null>(null) const containerRef = ref<HTMLElement | null>(null)
const searchInputRef = ref<HTMLInputElement | 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) { if (typeof option === 'object' && option !== null) {
return option[props.valueKey] as string | number | null | undefined return option[props.valueKey] as string | number | null | undefined
} }
...@@ -151,7 +159,7 @@ const getOptionLabel = (option: SelectOption | Record<string, unknown>): string ...@@ -151,7 +159,7 @@ const getOptionLabel = (option: SelectOption | Record<string, unknown>): string
} }
const selectedOption = computed(() => { 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(() => { const selectedLabel = computed(() => {
...@@ -166,7 +174,7 @@ const filteredOptions = computed(() => { ...@@ -166,7 +174,7 @@ const filteredOptions = computed(() => {
return props.options return props.options
} }
const query = searchQuery.value.toLowerCase() const query = searchQuery.value.toLowerCase()
return props.options.filter(opt => { return props.options.filter((opt) => {
const label = getOptionLabel(opt).toLowerCase() const label = getOptionLabel(opt).toLowerCase()
return label.includes(query) return label.includes(query)
}) })
...@@ -227,31 +235,31 @@ onUnmounted(() => { ...@@ -227,31 +235,31 @@ onUnmounted(() => {
<style scoped> <style scoped>
.select-trigger { .select-trigger {
@apply w-full flex items-center justify-between gap-2; @apply flex w-full items-center justify-between gap-2;
@apply px-4 py-2.5 rounded-xl text-sm; @apply rounded-xl px-4 py-2.5 text-sm;
@apply bg-white dark:bg-dark-800; @apply bg-white dark:bg-dark-800;
@apply border border-gray-200 dark:border-dark-600; @apply border border-gray-200 dark:border-dark-600;
@apply text-gray-900 dark:text-gray-100; @apply text-gray-900 dark:text-gray-100;
@apply transition-all duration-200; @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 hover:border-gray-300 dark:hover:border-dark-500;
@apply cursor-pointer; @apply cursor-pointer;
} }
.select-trigger-open { .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 { .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 { .select-trigger-disabled {
@apply bg-gray-100 dark:bg-dark-900 cursor-not-allowed opacity-60; @apply cursor-not-allowed bg-gray-100 opacity-60 dark:bg-dark-900;
} }
.select-value { .select-value {
@apply flex-1 text-left truncate; @apply flex-1 truncate text-left;
} }
.select-icon { .select-icon {
...@@ -259,7 +267,7 @@ onUnmounted(() => { ...@@ -259,7 +267,7 @@ onUnmounted(() => {
} }
.select-dropdown { .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 bg-white dark:bg-dark-800;
@apply rounded-xl; @apply rounded-xl;
@apply border border-gray-200 dark:border-dark-700; @apply border border-gray-200 dark:border-dark-700;
......
<template> <template>
<div class="stat-card"> <div class="stat-card">
<div :class="['stat-icon', iconClass]"> <div :class="['stat-icon', iconClass]">
<component <component v-if="icon" :is="icon" class="h-6 w-6" aria-hidden="true" />
v-if="icon"
:is="icon"
class="w-6 h-6"
aria-hidden="true"
/>
</div> </div>
<div class="flex-1 min-w-0"> <div class="min-w-0 flex-1">
<p class="stat-label truncate">{{ title }}</p> <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> <p class="stat-value">{{ formattedValue }}</p>
<span <span v-if="change !== undefined" :class="['stat-trend', trendClass]">
v-if="change !== undefined"
:class="['stat-trend', trendClass]"
>
<svg <svg
v-if="changeType !== 'neutral'" v-if="changeType !== 'neutral'"
:class="['w-3 h-3', changeType === 'down' && 'rotate-180']" :class="['h-3 w-3', changeType === 'down' && 'rotate-180']"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
> >
......
...@@ -3,11 +3,21 @@ ...@@ -3,11 +3,21 @@
<!-- Mini Progress Display --> <!-- Mini Progress Display -->
<button <button
@click="toggleTooltip" @click="toggleTooltip"
class="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-purple-50 dark:bg-purple-900/20 hover:bg-purple-100 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-100 dark:bg-purple-900/20 dark:hover:bg-purple-900/30"
:title="t('subscriptionProgress.viewDetails')" :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"> <svg
<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" /> 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> </svg>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<!-- Combined progress indicator --> <!-- Combined progress indicator -->
...@@ -15,7 +25,7 @@ ...@@ -15,7 +25,7 @@
<div <div
v-for="(sub, index) in displaySubscriptions.slice(0, 3)" v-for="(sub, index) in displaySubscriptions.slice(0, 3)"
:key="index" :key="index"
class="w-2 h-2 rounded-full" class="h-2 w-2 rounded-full"
:class="getProgressDotClass(sub)" :class="getProgressDotClass(sub)"
></div> ></div>
</div> </div>
...@@ -29,13 +39,13 @@ ...@@ -29,13 +39,13 @@
<transition name="dropdown"> <transition name="dropdown">
<div <div
v-if="tooltipOpen" 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"> <h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ t('subscriptionProgress.title') }} {{ t('subscriptionProgress.title') }}
</h3> </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 }) }} {{ t('subscriptionProgress.activeCount', { count: activeSubscriptions.length }) }}
</p> </p>
</div> </div>
...@@ -44,9 +54,9 @@ ...@@ -44,9 +54,9 @@
<div <div
v-for="subscription in displaySubscriptions" v-for="subscription in displaySubscriptions"
:key="subscription.id" :key="subscription.id"
class="p-3 border-b border-gray-50 dark:border-dark-700/50 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"> <span class="text-sm font-medium text-gray-900 dark:text-white">
{{ subscription.group?.name || `Group #${subscription.group_id}` }} {{ subscription.group?.name || `Group #${subscription.group_id}` }}
</span> </span>
...@@ -62,55 +72,100 @@ ...@@ -62,55 +72,100 @@
<!-- Progress bars --> <!-- Progress bars -->
<div class="space-y-1.5"> <div class="space-y-1.5">
<div v-if="subscription.group?.daily_limit_usd" class="flex items-center gap-2"> <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> <span class="w-8 flex-shrink-0 text-[10px] text-gray-500">{{
<div class="flex-1 min-w-0 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5"> t('subscriptionProgress.daily')
}}</span>
<div class="h-1.5 min-w-0 flex-1 rounded-full bg-gray-200 dark:bg-dark-600">
<div <div
class="h-1.5 rounded-full transition-all" class="h-1.5 rounded-full transition-all"
:class="getProgressBarClass(subscription.daily_usage_usd, subscription.group?.daily_limit_usd)" :class="
:style="{ width: getProgressWidth(subscription.daily_usage_usd, subscription.group?.daily_limit_usd) }" getProgressBarClass(
subscription.daily_usage_usd,
subscription.group?.daily_limit_usd
)
"
:style="{
width: getProgressWidth(
subscription.daily_usage_usd,
subscription.group?.daily_limit_usd
)
}"
></div> ></div>
</div> </div>
<span class="text-[10px] text-gray-500 w-24 text-right flex-shrink-0"> <span class="w-24 flex-shrink-0 text-right text-[10px] text-gray-500">
{{ formatUsage(subscription.daily_usage_usd, subscription.group?.daily_limit_usd) }} {{
formatUsage(subscription.daily_usage_usd, subscription.group?.daily_limit_usd)
}}
</span> </span>
</div> </div>
<div v-if="subscription.group?.weekly_limit_usd" class="flex items-center gap-2"> <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> <span class="w-8 flex-shrink-0 text-[10px] text-gray-500">{{
<div class="flex-1 min-w-0 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5"> t('subscriptionProgress.weekly')
}}</span>
<div class="h-1.5 min-w-0 flex-1 rounded-full bg-gray-200 dark:bg-dark-600">
<div <div
class="h-1.5 rounded-full transition-all" class="h-1.5 rounded-full transition-all"
:class="getProgressBarClass(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd)" :class="
:style="{ width: getProgressWidth(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd) }" getProgressBarClass(
subscription.weekly_usage_usd,
subscription.group?.weekly_limit_usd
)
"
:style="{
width: getProgressWidth(
subscription.weekly_usage_usd,
subscription.group?.weekly_limit_usd
)
}"
></div> ></div>
</div> </div>
<span class="text-[10px] text-gray-500 w-24 text-right flex-shrink-0"> <span class="w-24 flex-shrink-0 text-right text-[10px] text-gray-500">
{{ formatUsage(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd) }} {{
formatUsage(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd)
}}
</span> </span>
</div> </div>
<div v-if="subscription.group?.monthly_limit_usd" class="flex items-center gap-2"> <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> <span class="w-8 flex-shrink-0 text-[10px] text-gray-500">{{
<div class="flex-1 min-w-0 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5"> t('subscriptionProgress.monthly')
}}</span>
<div class="h-1.5 min-w-0 flex-1 rounded-full bg-gray-200 dark:bg-dark-600">
<div <div
class="h-1.5 rounded-full transition-all" class="h-1.5 rounded-full transition-all"
:class="getProgressBarClass(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd)" :class="
:style="{ width: getProgressWidth(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd) }" getProgressBarClass(
subscription.monthly_usage_usd,
subscription.group?.monthly_limit_usd
)
"
:style="{
width: getProgressWidth(
subscription.monthly_usage_usd,
subscription.group?.monthly_limit_usd
)
}"
></div> ></div>
</div> </div>
<span class="text-[10px] text-gray-500 w-24 text-right flex-shrink-0"> <span class="w-24 flex-shrink-0 text-right text-[10px] text-gray-500">
{{ formatUsage(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd) }} {{
formatUsage(
subscription.monthly_usage_usd,
subscription.group?.monthly_limit_usd
)
}}
</span> </span>
</div> </div>
</div> </div>
</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 <router-link
to="/subscriptions" to="/subscriptions"
@click="closeTooltip" @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') }} {{ t('subscriptionProgress.viewAll') }}
</router-link> </router-link>
...@@ -121,136 +176,136 @@ ...@@ -121,136 +176,136 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n'
import subscriptionsAPI from '@/api/subscriptions'; import subscriptionsAPI from '@/api/subscriptions'
import type { UserSubscription } from '@/types'; import type { UserSubscription } from '@/types'
const { t } = useI18n(); const { t } = useI18n()
const containerRef = ref<HTMLElement | null>(null); const containerRef = ref<HTMLElement | null>(null)
const tooltipOpen = ref(false); const tooltipOpen = ref(false)
const activeSubscriptions = ref<UserSubscription[]>([]); const activeSubscriptions = ref<UserSubscription[]>([])
const loading = ref(false); const loading = ref(false)
const hasActiveSubscriptions = computed(() => activeSubscriptions.value.length > 0); const hasActiveSubscriptions = computed(() => activeSubscriptions.value.length > 0)
const displaySubscriptions = computed(() => { const displaySubscriptions = computed(() => {
// Sort by most usage (highest percentage first) // Sort by most usage (highest percentage first)
return [...activeSubscriptions.value].sort((a, b) => { return [...activeSubscriptions.value].sort((a, b) => {
const aMax = getMaxUsagePercentage(a); const aMax = getMaxUsagePercentage(a)
const bMax = getMaxUsagePercentage(b); const bMax = getMaxUsagePercentage(b)
return bMax - aMax; return bMax - aMax
}); })
}); })
function getMaxUsagePercentage(sub: UserSubscription): number { function getMaxUsagePercentage(sub: UserSubscription): number {
const percentages: number[] = []; const percentages: number[] = []
if (sub.group?.daily_limit_usd) { 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) { 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) { 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 { function getProgressDotClass(sub: UserSubscription): string {
const maxPercentage = getMaxUsagePercentage(sub); const maxPercentage = getMaxUsagePercentage(sub)
if (maxPercentage >= 90) return 'bg-red-500'; if (maxPercentage >= 90) return 'bg-red-500'
if (maxPercentage >= 70) return 'bg-orange-500'; if (maxPercentage >= 70) return 'bg-orange-500'
return 'bg-green-500'; return 'bg-green-500'
} }
function getProgressBarClass(used: number | undefined, limit: number | null | undefined): string { function getProgressBarClass(used: number | undefined, limit: number | null | undefined): string {
if (!limit || limit === 0) return 'bg-gray-400'; if (!limit || limit === 0) return 'bg-gray-400'
const percentage = ((used || 0) / limit) * 100; const percentage = ((used || 0) / limit) * 100
if (percentage >= 90) return 'bg-red-500'; if (percentage >= 90) return 'bg-red-500'
if (percentage >= 70) return 'bg-orange-500'; if (percentage >= 70) return 'bg-orange-500'
return 'bg-green-500'; return 'bg-green-500'
} }
function getProgressWidth(used: number | undefined, limit: number | null | undefined): string { function getProgressWidth(used: number | undefined, limit: number | null | undefined): string {
if (!limit || limit === 0) return '0%'; if (!limit || limit === 0) return '0%'
const percentage = Math.min(((used || 0) / limit) * 100, 100); const percentage = Math.min(((used || 0) / limit) * 100, 100)
return `${percentage}%`; return `${percentage}%`
} }
function formatUsage(used: number | undefined, limit: number | null | undefined): string { function formatUsage(used: number | undefined, limit: number | null | undefined): string {
const usedValue = (used || 0).toFixed(2); const usedValue = (used || 0).toFixed(2)
const limitValue = limit?.toFixed(2) || ''; const limitValue = limit?.toFixed(2) || ''
return `$${usedValue}/$${limitValue}`; return `$${usedValue}/$${limitValue}`
} }
function formatDaysRemaining(expiresAt: string): string { function formatDaysRemaining(expiresAt: string): string {
const now = new Date(); const now = new Date()
const expires = new Date(expiresAt); const expires = new Date(expiresAt)
const diff = expires.getTime() - now.getTime(); const diff = expires.getTime() - now.getTime()
if (diff < 0) return t('subscriptionProgress.expired'); if (diff < 0) return t('subscriptionProgress.expired')
const days = Math.ceil(diff / (1000 * 60 * 60 * 24)); const days = Math.ceil(diff / (1000 * 60 * 60 * 24))
if (days === 0) return t('subscriptionProgress.expirestoday'); if (days === 0) return t('subscriptionProgress.expirestoday')
if (days === 1) return t('subscriptionProgress.expiresTomorrow'); if (days === 1) return t('subscriptionProgress.expiresTomorrow')
return t('subscriptionProgress.daysRemaining', { days }); return t('subscriptionProgress.daysRemaining', { days })
} }
function getDaysRemainingClass(expiresAt: string): string { function getDaysRemainingClass(expiresAt: string): string {
const now = new Date(); const now = new Date()
const expires = new Date(expiresAt); const expires = new Date(expiresAt)
const diff = expires.getTime() - now.getTime(); const diff = expires.getTime() - now.getTime()
const days = Math.ceil(diff / (1000 * 60 * 60 * 24)); const days = Math.ceil(diff / (1000 * 60 * 60 * 24))
if (days <= 3) return 'text-red-600 dark:text-red-400'; if (days <= 3) return 'text-red-600 dark:text-red-400'
if (days <= 7) return 'text-orange-600 dark:text-orange-400'; if (days <= 7) return 'text-orange-600 dark:text-orange-400'
return 'text-gray-500 dark:text-dark-400'; return 'text-gray-500 dark:text-dark-400'
} }
function toggleTooltip() { function toggleTooltip() {
tooltipOpen.value = !tooltipOpen.value; tooltipOpen.value = !tooltipOpen.value
} }
function closeTooltip() { function closeTooltip() {
tooltipOpen.value = false; tooltipOpen.value = false
} }
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
if (containerRef.value && !containerRef.value.contains(event.target as Node)) { if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
closeTooltip(); closeTooltip()
} }
} }
async function loadSubscriptions() { async function loadSubscriptions() {
try { try {
loading.value = true; loading.value = true
activeSubscriptions.value = await subscriptionsAPI.getActiveSubscriptions(); activeSubscriptions.value = await subscriptionsAPI.getActiveSubscriptions()
} catch (error) { } catch (error) {
console.error('Failed to load subscriptions:', error); console.error('Failed to load subscriptions:', error)
activeSubscriptions.value = []; activeSubscriptions.value = []
} finally { } finally {
loading.value = false; loading.value = false
} }
} }
onMounted(() => { onMounted(() => {
document.addEventListener('click', handleClickOutside); document.addEventListener('click', handleClickOutside)
loadSubscriptions(); loadSubscriptions()
}); })
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside); document.removeEventListener('click', handleClickOutside)
}); })
// Refresh subscriptions periodically (every 5 minutes) // Refresh subscriptions periodically (every 5 minutes)
let refreshInterval: ReturnType<typeof setInterval> | null = null; let refreshInterval: ReturnType<typeof setInterval> | null = null
onMounted(() => { onMounted(() => {
refreshInterval = setInterval(loadSubscriptions, 5 * 60 * 1000); refreshInterval = setInterval(loadSubscriptions, 5 * 60 * 1000)
}); })
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (refreshInterval) { if (refreshInterval) {
clearInterval(refreshInterval); clearInterval(refreshInterval)
} }
}); })
</script> </script>
<style scoped> <style scoped>
......
<template> <template>
<Teleport to="body"> <Teleport to="body">
<div <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-live="polite"
aria-atomic="true" aria-atomic="true"
> >
...@@ -26,26 +26,25 @@ ...@@ -26,26 +26,25 @@
<div class="p-4"> <div class="p-4">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<!-- Icon --> <!-- Icon -->
<div class="flex-shrink-0 mt-0.5"> <div class="mt-0.5 flex-shrink-0">
<component <component
:is="getIcon(toast.type)" :is="getIcon(toast.type)"
:class="['w-5 h-5', getIconColor(toast.type)]" :class="['h-5 w-5', getIconColor(toast.type)]"
aria-hidden="true" aria-hidden="true"
/> />
</div> </div>
<!-- Content --> <!-- Content -->
<div class="flex-1 min-w-0"> <div class="min-w-0 flex-1">
<p <p v-if="toast.title" class="text-sm font-semibold text-gray-900 dark:text-white">
v-if="toast.title"
class="text-sm font-semibold text-gray-900 dark:text-white"
>
{{ toast.title }} {{ toast.title }}
</p> </p>
<p <p
:class="[ :class="[
'text-sm leading-relaxed', '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 }} {{ toast.message }}
...@@ -55,10 +54,10 @@ ...@@ -55,10 +54,10 @@
<!-- Close button --> <!-- Close button -->
<button <button
@click="removeToast(toast.id)" @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-300 hover:bg-gray-100 dark:hover:bg-dark-700" 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-500 dark:hover:bg-dark-700 dark:hover:text-gray-300"
aria-label="Close notification" 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 <path
fill-rule="evenodd" 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" 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 @@ ...@@ -70,10 +69,7 @@
</div> </div>
<!-- Progress bar --> <!-- Progress bar -->
<div <div v-if="toast.duration" class="h-1 bg-gray-100 dark:bg-dark-700">
v-if="toast.duration"
class="h-1 bg-gray-100 dark:bg-dark-700"
>
<div <div
:class="['h-full transition-all', getProgressBarColor(toast.type)]" :class="['h-full transition-all', getProgressBarColor(toast.type)]"
:style="{ width: `${getProgress(toast)}%` }" :style="{ width: `${getProgress(toast)}%` }"
......
...@@ -3,33 +3,27 @@ ...@@ -3,33 +3,27 @@
type="button" type="button"
@click="toggle" @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="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="[ :class="[modelValue ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600']"
modelValue
? 'bg-primary-600'
: 'bg-gray-200 dark:bg-dark-600'
]"
role="switch" role="switch"
:aria-checked="modelValue" :aria-checked="modelValue"
> >
<span <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="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="[ :class="[modelValue ? 'translate-x-5' : 'translate-x-0']"
modelValue ? 'translate-x-5' : 'translate-x-0'
]"
/> />
</button> </button>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps<{ const props = defineProps<{
modelValue: boolean; modelValue: boolean
}>(); }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void; (e: 'update:modelValue', value: boolean): void
}>(); }>()
function toggle() { function toggle() {
emit('update:modelValue', !props.modelValue); emit('update:modelValue', !props.modelValue)
} }
</script> </script>
...@@ -4,20 +4,25 @@ ...@@ -4,20 +4,25 @@
<template v-if="isAdmin"> <template v-if="isAdmin">
<button <button
@click="toggleDropdown" @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="[ :class="[
hasUpdate hasUpdate
? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 hover:bg-amber-200 dark:hover:bg-amber-900/50' ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:hover:bg-amber-900/50'
: 'bg-gray-100 dark:bg-dark-800 text-gray-600 dark:text-dark-400 hover:bg-gray-200 dark:hover:bg-dark-700' : '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'" :title="hasUpdate ? 'New version available' : 'Up to date'"
> >
<span v-if="currentVersion" class="font-medium">v{{ currentVersion }}</span> <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 --> <!-- Update indicator -->
<span v-if="hasUpdate" class="relative flex h-2 w-2"> <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
<span class="relative inline-flex rounded-full h-2 w-2 bg-amber-500"></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> </span>
</button> </button>
...@@ -26,19 +31,34 @@ ...@@ -26,19 +31,34 @@
<div <div
v-if="dropdownOpen" v-if="dropdownOpen"
ref="dropdownRef" 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 --> <!-- Header with refresh button -->
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-dark-700"> <div
<span class="text-sm font-medium text-gray-700 dark:text-dark-300">{{ t('version.currentVersion') }}</span> 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 <button
@click="refreshVersion(true)" @click="refreshVersion(true)"
class="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-dark-200 hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors" class="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dark-700 dark:hover:text-dark-200"
:disabled="loading" :disabled="loading"
:title="t('version.refresh')" :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"> <svg
<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" /> 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> </svg>
</button> </button>
</div> </div>
...@@ -46,42 +66,90 @@ ...@@ -46,42 +66,90 @@
<div class="p-4"> <div class="p-4">
<!-- Loading state --> <!-- Loading state -->
<div v-if="loading" class="flex items-center justify-center py-6"> <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"> <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> <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> 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>
</div> </div>
<!-- Content --> <!-- Content -->
<template v-else> <template v-else>
<!-- Version display - centered and prominent --> <!-- 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"> <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> <span v-else class="text-2xl font-bold text-gray-400 dark:text-dark-500">--</span>
<!-- Show check mark when up to date --> <!-- 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"> <span
<svg class="w-3 h-3 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20"> v-if="!hasUpdate"
<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" /> 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> </svg>
</span> </span>
</div> </div>
<p class="text-xs text-gray-500 dark:text-dark-400 mt-1"> <p class="mt-1 text-xs text-gray-500 dark:text-dark-400">
{{ hasUpdate ? t('version.latestVersion') + ': v' + latestVersion : t('version.upToDate') }} {{
hasUpdate
? t('version.latestVersion') + ': v' + latestVersion
: t('version.upToDate')
}}
</p> </p>
</div> </div>
<!-- Priority 1: Update error (must check before hasUpdate) --> <!-- Priority 1: Update error (must check before hasUpdate) -->
<div v-if="updateError" class="space-y-2"> <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
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-red-100 dark:bg-red-900/50 flex items-center justify-center"> 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"
<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 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> </svg>
</div> </div>
<div class="flex-1 min-w-0"> <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="text-sm font-medium text-red-700 dark:text-red-300">
<p class="text-xs text-red-600/70 dark:text-red-400/70 truncate">{{ updateError }}</p> {{ t('version.updateFailed') }}
</p>
<p class="truncate text-xs text-red-600/70 dark:text-red-400/70">
{{ updateError }}
</p>
</div> </div>
</div> </div>
...@@ -89,7 +157,7 @@ ...@@ -89,7 +157,7 @@
<button <button
@click="handleUpdate" @click="handleUpdate"
:disabled="updating" :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-50 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-600 disabled:cursor-not-allowed disabled:opacity-50"
> >
{{ t('version.retry') }} {{ t('version.retry') }}
</button> </button>
...@@ -97,15 +165,29 @@ ...@@ -97,15 +165,29 @@
<!-- Priority 2: Update success - need restart --> <!-- Priority 2: Update success - need restart -->
<div v-else-if="updateSuccess && needRestart" class="space-y-2"> <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
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-green-100 dark:bg-green-900/50 flex items-center justify-center"> 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"
<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 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" /> <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg> </svg>
</div> </div>
<div class="flex-1 min-w-0"> <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-sm font-medium text-green-700 dark:text-green-300">
<p class="text-xs text-green-600/70 dark:text-green-400/70">{{ t('version.restartRequired') }}</p> {{ t('version.updateComplete') }}
</p>
<p class="text-xs text-green-600/70 dark:text-green-400/70">
{{ t('version.restartRequired') }}
</p>
</div> </div>
</div> </div>
...@@ -113,18 +195,47 @@ ...@@ -113,18 +195,47 @@
<button <button
@click="handleRestart" @click="handleRestart"
:disabled="restarting" :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"> <svg
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> v-if="restarting"
<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> 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>
<svg v-else class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg
<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" /> 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> </svg>
<template v-if="restarting"> <template v-if="restarting">
<span>{{ t('version.restarting') }}</span> <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> </template>
<span v-else>{{ t('version.restartNow') }}</span> <span v-else>{{ t('version.restartNow') }}</span>
</button> </button>
...@@ -137,42 +248,96 @@ ...@@ -137,42 +248,96 @@
:href="releaseInfo.html_url" :href="releaseInfo.html_url"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
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 hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors group" class="group flex items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3 transition-colors hover:bg-amber-100 dark:border-amber-800/50 dark:bg-amber-900/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"> <div
<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"> class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/50"
<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
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> </svg>
</div> </div>
<div class="flex-1 min-w-0"> <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-sm font-medium text-amber-700 dark:text-amber-300">
<p class="text-xs text-amber-600/70 dark:text-amber-400/70">v{{ latestVersion }}</p> {{ t('version.updateAvailable') }}
</p>
<p class="text-xs text-amber-600/70 dark:text-amber-400/70">
v{{ latestVersion }}
</p>
</div> </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" /> <path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg> </svg>
</a> </a>
<!-- Source build hint --> <!-- 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"> <div
<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"> 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"
<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
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> </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>
</div> </div>
<!-- Priority 4: Update available for release build - show update button --> <!-- Priority 4: Update available for release build - show update button -->
<div v-else-if="hasUpdate && isReleaseBuild" class="space-y-2"> <div v-else-if="hasUpdate && isReleaseBuild" class="space-y-2">
<!-- Update info card --> <!-- 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
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center"> 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"
<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> </svg>
</div> </div>
<div class="flex-1 min-w-0"> <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-sm font-medium text-amber-700 dark:text-amber-300">
<p class="text-xs text-amber-600/70 dark:text-amber-400/70">v{{ latestVersion }}</p> {{ t('version.updateAvailable') }}
</p>
<p class="text-xs text-amber-600/70 dark:text-amber-400/70">
v{{ latestVersion }}
</p>
</div> </div>
</div> </div>
...@@ -180,14 +345,36 @@ ...@@ -180,14 +345,36 @@
<button <button
@click="handleUpdate" @click="handleUpdate"
:disabled="updating" :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"> <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> <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> 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>
<svg v-else class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg
<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" /> 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> </svg>
{{ updating ? t('version.updating') : t('version.updateNow') }} {{ updating ? t('version.updating') : t('version.updateNow') }}
</button> </button>
...@@ -198,11 +385,21 @@ ...@@ -198,11 +385,21 @@
:href="releaseInfo.html_url" :href="releaseInfo.html_url"
target="_blank" target="_blank"
rel="noopener noreferrer" 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') }} {{ t('version.viewChangelog') }}
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg
<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" /> 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> </svg>
</a> </a>
</div> </div>
...@@ -213,10 +410,14 @@ ...@@ -213,10 +410,14 @@
:href="releaseInfo.html_url" :href="releaseInfo.html_url"
target="_blank" target="_blank"
rel="noopener noreferrer" 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"> <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" /> <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> </svg>
{{ t('version.viewRelease') }} {{ t('version.viewRelease') }}
</a> </a>
...@@ -227,166 +428,163 @@ ...@@ -227,166 +428,163 @@
</template> </template>
<!-- Non-admin: Simple static version text --> <!-- Non-admin: Simple static version text -->
<span <span v-else-if="version" class="text-xs text-gray-500 dark:text-dark-400">
v-else-if="version"
class="text-xs text-gray-500 dark:text-dark-400"
>
v{{ version }} v{{ version }}
</span> </span>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n'
import { useAuthStore, useAppStore } from '@/stores'; import { useAuthStore, useAppStore } from '@/stores'
import { performUpdate, restartService } from '@/api/admin/system'; import { performUpdate, restartService } from '@/api/admin/system'
const { t } = useI18n(); const { t } = useI18n()
const props = defineProps<{ const props = defineProps<{
version?: string; version?: string
}>(); }>()
const authStore = useAuthStore(); const authStore = useAuthStore()
const appStore = useAppStore(); const appStore = useAppStore()
const isAdmin = computed(() => authStore.isAdmin); const isAdmin = computed(() => authStore.isAdmin)
const dropdownOpen = ref(false); const dropdownOpen = ref(false)
const dropdownRef = ref<HTMLElement | null>(null); const dropdownRef = ref<HTMLElement | null>(null)
// Use store's cached version state // Use store's cached version state
const loading = computed(() => appStore.versionLoading); const loading = computed(() => appStore.versionLoading)
const currentVersion = computed(() => appStore.currentVersion || props.version || ''); const currentVersion = computed(() => appStore.currentVersion || props.version || '')
const latestVersion = computed(() => appStore.latestVersion); const latestVersion = computed(() => appStore.latestVersion)
const hasUpdate = computed(() => appStore.hasUpdate); const hasUpdate = computed(() => appStore.hasUpdate)
const releaseInfo = computed(() => appStore.releaseInfo); const releaseInfo = computed(() => appStore.releaseInfo)
const buildType = computed(() => appStore.buildType); const buildType = computed(() => appStore.buildType)
// Update process states (local to this component) // Update process states (local to this component)
const updating = ref(false); const updating = ref(false)
const restarting = ref(false); const restarting = ref(false)
const needRestart = ref(false); const needRestart = ref(false)
const updateError = ref(''); const updateError = ref('')
const updateSuccess = ref(false); const updateSuccess = ref(false)
const restartCountdown = ref(0); const restartCountdown = ref(0)
// Only show update check for release builds (binary/docker deployment) // Only show update check for release builds (binary/docker deployment)
const isReleaseBuild = computed(() => buildType.value === 'release'); const isReleaseBuild = computed(() => buildType.value === 'release')
function toggleDropdown() { function toggleDropdown() {
dropdownOpen.value = !dropdownOpen.value; dropdownOpen.value = !dropdownOpen.value
} }
function closeDropdown() { function closeDropdown() {
dropdownOpen.value = false; dropdownOpen.value = false
} }
async function refreshVersion(force = true) { async function refreshVersion(force = true) {
if (!isAdmin.value) return; if (!isAdmin.value) return
// Reset update states when refreshing // Reset update states when refreshing
updateError.value = ''; updateError.value = ''
updateSuccess.value = false; updateSuccess.value = false
needRestart.value = false; needRestart.value = false
await appStore.fetchVersion(force); await appStore.fetchVersion(force)
} }
async function handleUpdate() { async function handleUpdate() {
if (updating.value) return; if (updating.value) return
updating.value = true; updating.value = true
updateError.value = ''; updateError.value = ''
updateSuccess.value = false; updateSuccess.value = false
try { try {
const result = await performUpdate(); const result = await performUpdate()
updateSuccess.value = true; updateSuccess.value = true
needRestart.value = result.need_restart; needRestart.value = result.need_restart
// Clear version cache to reflect update completed // Clear version cache to reflect update completed
appStore.clearVersionCache(); appStore.clearVersionCache()
} catch (error: unknown) { } catch (error: unknown) {
const err = error as { response?: { data?: { message?: string } }; message?: string }; const err = error as { response?: { data?: { message?: string } }; message?: string }
updateError.value = err.response?.data?.message || err.message || t('version.updateFailed'); updateError.value = err.response?.data?.message || err.message || t('version.updateFailed')
} finally { } finally {
updating.value = false; updating.value = false
} }
} }
async function handleRestart() { async function handleRestart() {
if (restarting.value) return; if (restarting.value) return
restarting.value = true; restarting.value = true
restartCountdown.value = 8; restartCountdown.value = 8
try { try {
await restartService(); await restartService()
// Service will restart, page will reload automatically or show disconnected // Service will restart, page will reload automatically or show disconnected
} catch (error) { } catch (error) {
// Expected - connection will be lost during restart // Expected - connection will be lost during restart
console.log('Service restarting...'); console.log('Service restarting...')
} }
// Start countdown // Start countdown
const countdownInterval = setInterval(() => { const countdownInterval = setInterval(() => {
restartCountdown.value--; restartCountdown.value--
if (restartCountdown.value <= 0) { if (restartCountdown.value <= 0) {
clearInterval(countdownInterval); clearInterval(countdownInterval)
// Try to check if service is back before reload // Try to check if service is back before reload
checkServiceAndReload(); checkServiceAndReload()
} }
}, 1000); }, 1000)
} }
async function checkServiceAndReload() { async function checkServiceAndReload() {
const maxRetries = 5; const maxRetries = 5
const retryDelay = 1000; const retryDelay = 1000
for (let i = 0; i < maxRetries; i++) { for (let i = 0; i < maxRetries; i++) {
try { try {
const response = await fetch('/api/health', { const response = await fetch('/api/health', {
method: 'GET', method: 'GET',
cache: 'no-cache' cache: 'no-cache'
}); })
if (response.ok) { if (response.ok) {
// Service is back, reload page // Service is back, reload page
window.location.reload(); window.location.reload()
return; return
} }
} catch { } catch {
// Service not ready yet // Service not ready yet
} }
if (i < maxRetries - 1) { if (i < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, retryDelay)); await new Promise((resolve) => setTimeout(resolve, retryDelay))
} }
} }
// After retries, reload anyway // After retries, reload anyway
window.location.reload(); window.location.reload()
} }
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
const target = event.target as Node; const target = event.target as Node
const button = (event.target as Element).closest('button'); const button = (event.target as Element).closest('button')
if (dropdownRef.value && !dropdownRef.value.contains(target) && !button?.contains(target)) { if (dropdownRef.value && !dropdownRef.value.contains(target) && !button?.contains(target)) {
closeDropdown(); closeDropdown()
} }
} }
onMounted(() => { onMounted(() => {
if (isAdmin.value) { if (isAdmin.value) {
// Use cached version if available, otherwise fetch // Use cached version if available, otherwise fetch
appStore.fetchVersion(false); appStore.fetchVersion(false)
} }
document.addEventListener('click', handleClickOutside); document.addEventListener('click', handleClickOutside)
}); })
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside); document.removeEventListener('click', handleClickOutside)
}); })
</script> </script>
<style scoped> <style scoped>
......
<template> <template>
<header class="sticky top-0 z-30 glass border-b border-gray-200/50 dark:border-dark-700/50"> <header class="glass sticky top-0 z-30 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"> <div class="flex h-16 items-center justify-between px-4 md:px-6">
<!-- Left: Mobile Menu Toggle + Page Title --> <!-- Left: Mobile Menu Toggle + Page Title -->
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<button <button
@click="toggleMobileSidebar" @click="toggleMobileSidebar"
class="lg:hidden btn-ghost btn-icon" class="btn-ghost btn-icon lg:hidden"
aria-label="Toggle Menu" aria-label="Toggle Menu"
> >
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> <svg
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /> 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> </svg>
</button> </button>
...@@ -32,9 +42,22 @@ ...@@ -32,9 +42,22 @@
<SubscriptionProgressMini v-if="user" /> <SubscriptionProgressMini v-if="user" />
<!-- Balance Display --> <!-- 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"> <div
<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"> v-if="user"
<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" /> 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> </svg>
<span class="text-sm font-semibold text-primary-700 dark:text-primary-300"> <span class="text-sm font-semibold text-primary-700 dark:text-primary-300">
${{ user.balance?.toFixed(2) || '0.00' }} ${{ user.balance?.toFixed(2) || '0.00' }}
...@@ -45,64 +68,89 @@ ...@@ -45,64 +68,89 @@
<div v-if="user" class="relative" ref="dropdownRef"> <div v-if="user" class="relative" ref="dropdownRef">
<button <button
@click="toggleDropdown" @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" 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 }} {{ userInitials }}
</div> </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"> <div class="text-sm font-medium text-gray-900 dark:text-white">
{{ displayName }} {{ displayName }}
</div> </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 }} {{ user.role }}
</div> </div>
</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"> <svg
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" /> 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> </svg>
</button> </button>
<!-- Dropdown Menu --> <!-- Dropdown Menu -->
<transition name="dropdown"> <transition name="dropdown">
<div <div v-if="dropdownOpen" class="dropdown right-0 mt-2 w-56">
v-if="dropdownOpen"
class="dropdown right-0 mt-2 w-56"
>
<!-- User Info --> <!-- User Info -->
<div class="px-4 py-3 border-b border-gray-100 dark:border-dark-700"> <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-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 class="text-xs text-gray-500 dark:text-dark-400">{{ user.email }}</div>
</div> </div>
<!-- Balance (mobile only) --> <!-- Balance (mobile only) -->
<div class="sm:hidden px-4 py-2 border-b border-gray-100 dark:border-dark-700"> <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-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"> <div class="text-sm font-semibold text-primary-600 dark:text-primary-400">
${{ user.balance?.toFixed(2) || '0.00' }} ${{ user.balance?.toFixed(2) || '0.00' }}
</div> </div>
</div> </div>
<div class="py-1"> <div class="py-1">
<router-link <router-link to="/profile" @click="closeDropdown" class="dropdown-item">
to="/profile" <svg
@click="closeDropdown" class="h-4 w-4"
class="dropdown-item" fill="none"
> viewBox="0 0 24 24"
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> stroke="currentColor"
<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" /> 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> </svg>
{{ t('nav.profile') }} {{ t('nav.profile') }}
</router-link> </router-link>
<router-link <router-link to="/keys" @click="closeDropdown" class="dropdown-item">
to="/keys" <svg
@click="closeDropdown" class="h-4 w-4"
class="dropdown-item" fill="none"
> viewBox="0 0 24 24"
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> stroke="currentColor"
<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" /> 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> </svg>
{{ t('nav.apiKeys') }} {{ t('nav.apiKeys') }}
</router-link> </router-link>
...@@ -114,31 +162,60 @@ ...@@ -114,31 +162,60 @@
@click="closeDropdown" @click="closeDropdown"
class="dropdown-item" class="dropdown-item"
> >
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"> <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" /> <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> </svg>
{{ t('nav.github') }} {{ t('nav.github') }}
</a> </a>
</div> </div>
<!-- Contact Support (only show if configured) --> <!-- 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"> <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"> <svg
<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" /> 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> </svg>
<span>{{ t('common.contactSupport') }}:</span> <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> </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 <button
@click="handleLogout" @click="handleLogout"
class="dropdown-item w-full text-red-600 dark:text-red-400 hover:bg-red-50 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"> <svg
<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" /> 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> </svg>
{{ t('nav.logout') }} {{ t('nav.logout') }}
</button> </button>
...@@ -152,90 +229,90 @@ ...@@ -152,90 +229,90 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n'
import { useAppStore, useAuthStore } from '@/stores'; import { useAppStore, useAuthStore } from '@/stores'
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'; import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
import SubscriptionProgressMini from '@/components/common/SubscriptionProgressMini.vue'; import SubscriptionProgressMini from '@/components/common/SubscriptionProgressMini.vue'
const router = useRouter(); const router = useRouter()
const route = useRoute(); const route = useRoute()
const { t } = useI18n(); const { t } = useI18n()
const appStore = useAppStore(); const appStore = useAppStore()
const authStore = useAuthStore(); const authStore = useAuthStore()
const user = computed(() => authStore.user); const user = computed(() => authStore.user)
const dropdownOpen = ref(false); const dropdownOpen = ref(false)
const dropdownRef = ref<HTMLElement | null>(null); const dropdownRef = ref<HTMLElement | null>(null)
const contactInfo = computed(() => appStore.contactInfo); const contactInfo = computed(() => appStore.contactInfo)
const userInitials = computed(() => { const userInitials = computed(() => {
if (!user.value) return ''; if (!user.value) return ''
// Prefer username, fallback to email // Prefer username, fallback to email
if (user.value.username) { if (user.value.username) {
return user.value.username.substring(0, 2).toUpperCase(); return user.value.username.substring(0, 2).toUpperCase()
} }
if (user.value.email) { if (user.value.email) {
// Get the part before @ and take first 2 chars // Get the part before @ and take first 2 chars
const localPart = user.value.email.split('@')[0]; const localPart = user.value.email.split('@')[0]
return localPart.substring(0, 2).toUpperCase(); return localPart.substring(0, 2).toUpperCase()
} }
return ''; return ''
}); })
const displayName = computed(() => { const displayName = computed(() => {
if (!user.value) return ''; if (!user.value) return ''
return user.value.username || user.value.email?.split('@')[0] || ''; return user.value.username || user.value.email?.split('@')[0] || ''
}); })
const pageTitle = computed(() => { const pageTitle = computed(() => {
const titleKey = route.meta.titleKey as string; const titleKey = route.meta.titleKey as string
if (titleKey) { if (titleKey) {
return t(titleKey); return t(titleKey)
} }
return (route.meta.title as string) || ''; return (route.meta.title as string) || ''
}); })
const pageDescription = computed(() => { const pageDescription = computed(() => {
const descKey = route.meta.descriptionKey as string; const descKey = route.meta.descriptionKey as string
if (descKey) { if (descKey) {
return t(descKey); return t(descKey)
} }
return (route.meta.description as string) || ''; return (route.meta.description as string) || ''
}); })
function toggleMobileSidebar() { function toggleMobileSidebar() {
appStore.toggleMobileSidebar(); appStore.toggleMobileSidebar()
} }
function toggleDropdown() { function toggleDropdown() {
dropdownOpen.value = !dropdownOpen.value; dropdownOpen.value = !dropdownOpen.value
} }
function closeDropdown() { function closeDropdown() {
dropdownOpen.value = false; dropdownOpen.value = false
} }
async function handleLogout() { async function handleLogout() {
closeDropdown(); closeDropdown()
authStore.logout(); authStore.logout()
await router.push('/login'); await router.push('/login')
} }
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) { if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
closeDropdown(); closeDropdown()
} }
} }
onMounted(() => { onMounted(() => {
document.addEventListener('click', handleClickOutside); document.addEventListener('click', handleClickOutside)
}); })
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside); document.removeEventListener('click', handleClickOutside)
}); })
</script> </script>
<style scoped> <style scoped>
......
<template> <template>
<div class="min-h-screen bg-gray-50 dark:bg-dark-950"> <div class="min-h-screen bg-gray-50 dark:bg-dark-950">
<!-- Background Decoration --> <!-- 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 --> <!-- Sidebar -->
<AppSidebar /> <AppSidebar />
...@@ -9,9 +9,7 @@ ...@@ -9,9 +9,7 @@
<!-- Main Content Area --> <!-- Main Content Area -->
<div <div
class="relative min-h-screen transition-all duration-300" class="relative min-h-screen transition-all duration-300"
:class="[ :class="[sidebarCollapsed ? 'lg:ml-[72px]' : 'lg:ml-64']"
sidebarCollapsed ? 'lg:ml-[72px]' : 'lg:ml-64',
]"
> >
<!-- Header --> <!-- Header -->
<AppHeader /> <AppHeader />
...@@ -25,12 +23,11 @@ ...@@ -25,12 +23,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue'
import { useAppStore } from '@/stores'; import { useAppStore } from '@/stores'
import AppSidebar from './AppSidebar.vue'; import AppSidebar from './AppSidebar.vue'
import AppHeader from './AppHeader.vue'; import AppHeader from './AppHeader.vue'
const appStore = useAppStore(); const appStore = useAppStore()
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed); const sidebarCollapsed = computed(() => appStore.sidebarCollapsed)
</script> </script>
...@@ -9,8 +9,8 @@ ...@@ -9,8 +9,8 @@
<!-- Logo/Brand --> <!-- Logo/Brand -->
<div class="sidebar-header"> <div class="sidebar-header">
<!-- Custom Logo or Default Logo --> <!-- Custom Logo or Default Logo -->
<div class="w-9 h-9 rounded-xl overflow-hidden flex items-center justify-center shadow-glow"> <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="w-full h-full object-contain" /> <img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
</div> </div>
<transition name="fade"> <transition name="fade">
<div v-if="!sidebarCollapsed" class="flex flex-col"> <div v-if="!sidebarCollapsed" class="flex flex-col">
...@@ -38,7 +38,7 @@ ...@@ -38,7 +38,7 @@
:title="sidebarCollapsed ? item.label : undefined" :title="sidebarCollapsed ? item.label : undefined"
@click="handleMenuItemClick" @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"> <transition name="fade">
<span v-if="!sidebarCollapsed">{{ item.label }}</span> <span v-if="!sidebarCollapsed">{{ item.label }}</span>
</transition> </transition>
...@@ -50,7 +50,7 @@ ...@@ -50,7 +50,7 @@
<div v-if="!sidebarCollapsed" class="sidebar-section-title"> <div v-if="!sidebarCollapsed" class="sidebar-section-title">
{{ t('nav.myAccount') }} {{ t('nav.myAccount') }}
</div> </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 <router-link
v-for="item in personalNavItems" v-for="item in personalNavItems"
...@@ -61,7 +61,7 @@ ...@@ -61,7 +61,7 @@
:title="sidebarCollapsed ? item.label : undefined" :title="sidebarCollapsed ? item.label : undefined"
@click="handleMenuItemClick" @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"> <transition name="fade">
<span v-if="!sidebarCollapsed">{{ item.label }}</span> <span v-if="!sidebarCollapsed">{{ item.label }}</span>
</transition> </transition>
...@@ -81,7 +81,7 @@ ...@@ -81,7 +81,7 @@
:title="sidebarCollapsed ? item.label : undefined" :title="sidebarCollapsed ? item.label : undefined"
@click="handleMenuItemClick" @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"> <transition name="fade">
<span v-if="!sidebarCollapsed">{{ item.label }}</span> <span v-if="!sidebarCollapsed">{{ item.label }}</span>
</transition> </transition>
...@@ -91,17 +91,19 @@ ...@@ -91,17 +91,19 @@
</nav> </nav>
<!-- Bottom Section --> <!-- 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 --> <!-- Theme Toggle -->
<button <button
@click="toggleTheme" @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" :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" /> <SunIcon v-if="isDark" class="h-5 w-5 flex-shrink-0 text-amber-500" />
<MoonIcon v-else class="w-5 h-5 flex-shrink-0" /> <MoonIcon v-else class="h-5 w-5 flex-shrink-0" />
<transition name="fade"> <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> </transition>
</button> </button>
...@@ -111,8 +113,8 @@ ...@@ -111,8 +113,8 @@
class="sidebar-link w-full" class="sidebar-link w-full"
:title="sidebarCollapsed ? t('nav.expand') : t('nav.collapse')" :title="sidebarCollapsed ? t('nav.expand') : t('nav.collapse')"
> >
<ChevronDoubleLeftIcon v-if="!sidebarCollapsed" class="w-5 h-5 flex-shrink-0" /> <ChevronDoubleLeftIcon v-if="!sidebarCollapsed" class="h-5 w-5 flex-shrink-0" />
<ChevronDoubleRightIcon v-else class="w-5 h-5 flex-shrink-0" /> <ChevronDoubleRightIcon v-else class="h-5 w-5 flex-shrink-0" />
<transition name="fade"> <transition name="fade">
<span v-if="!sidebarCollapsed">{{ t('nav.collapse') }}</span> <span v-if="!sidebarCollapsed">{{ t('nav.collapse') }}</span>
</transition> </transition>
...@@ -124,132 +126,280 @@ ...@@ -124,132 +126,280 @@
<transition name="fade"> <transition name="fade">
<div <div
v-if="mobileOpen" 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" @click="closeMobile"
></div> ></div>
</transition> </transition>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, h, ref } from 'vue'; import { computed, h, ref } from 'vue'
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n'
import { useAppStore, useAuthStore } from '@/stores'; import { useAppStore, useAuthStore } from '@/stores'
import VersionBadge from '@/components/common/VersionBadge.vue'; import VersionBadge from '@/components/common/VersionBadge.vue'
const { t } = useI18n(); const { t } = useI18n()
const route = useRoute(); const route = useRoute()
const appStore = useAppStore(); const appStore = useAppStore()
const authStore = useAuthStore(); const authStore = useAuthStore()
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed); const sidebarCollapsed = computed(() => appStore.sidebarCollapsed)
const mobileOpen = computed(() => appStore.mobileOpen); const mobileOpen = computed(() => appStore.mobileOpen)
const isAdmin = computed(() => authStore.isAdmin); const isAdmin = computed(() => authStore.isAdmin)
const isDark = ref(document.documentElement.classList.contains('dark')); const isDark = ref(document.documentElement.classList.contains('dark'))
// Site settings from appStore (cached, no flicker) // Site settings from appStore (cached, no flicker)
const siteName = computed(() => appStore.siteName); const siteName = computed(() => appStore.siteName)
const siteLogo = computed(() => appStore.siteLogo); const siteLogo = computed(() => appStore.siteLogo)
const siteVersion = computed(() => appStore.siteVersion); const siteVersion = computed(() => appStore.siteVersion)
// SVG Icon Components // SVG Icon Components
const DashboardIcon = { const DashboardIcon = {
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [ render: () =>
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' }) 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 = { const KeyIcon = {
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [ render: () =>
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' }) 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 = { const ChartIcon = {
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [ render: () =>
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' }) 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 = { const GiftIcon = {
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [ render: () =>
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' }) 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 = { const UserIcon = {
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [ render: () =>
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' }) 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 = { const UsersIcon = {
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [ render: () =>
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' }) 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 = { const FolderIcon = {
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [ render: () =>
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' }) 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 = { const CreditCardIcon = {
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [ render: () =>
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' }) 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 = { const GlobeIcon = {
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [ render: () =>
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' }) 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 = { const ServerIcon = {
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [ render: () =>
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' }) 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 = { const TicketIcon = {
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [ render: () =>
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' }) 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 = { const CogIcon = {
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [ render: () =>
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(
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z' }) '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 = { const SunIcon = {
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [ render: () =>
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' }) 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 = { const MoonIcon = {
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [ render: () =>
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' }) 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 = { const ChevronDoubleLeftIcon = {
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [ render: () =>
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' }) 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 = { const ChevronDoubleRightIcon = {
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [ render: () =>
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' }) 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) // User navigation items (for regular users)
const userNavItems = computed(() => [ const userNavItems = computed(() => [
...@@ -258,8 +408,8 @@ const userNavItems = computed(() => [ ...@@ -258,8 +408,8 @@ const userNavItems = computed(() => [
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon }, { path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon },
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon }, { 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) // Personal navigation items (for admin's "My Account" section, without Dashboard)
const personalNavItems = computed(() => [ const personalNavItems = computed(() => [
...@@ -267,8 +417,8 @@ const personalNavItems = computed(() => [ ...@@ -267,8 +417,8 @@ const personalNavItems = computed(() => [
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon }, { path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon },
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon }, { 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 // Admin navigation items
const adminNavItems = computed(() => [ const adminNavItems = computed(() => [
...@@ -280,40 +430,43 @@ const adminNavItems = computed(() => [ ...@@ -280,40 +430,43 @@ const adminNavItems = computed(() => [
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon }, { path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon }, { path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon },
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon }, { 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() { function toggleSidebar() {
appStore.toggleSidebar(); appStore.toggleSidebar()
} }
function toggleTheme() { function toggleTheme() {
isDark.value = !isDark.value; isDark.value = !isDark.value
document.documentElement.classList.toggle('dark', isDark.value); document.documentElement.classList.toggle('dark', isDark.value)
localStorage.setItem('theme', isDark.value ? 'dark' : 'light'); localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
} }
function closeMobile() { function closeMobile() {
appStore.setMobileOpen(false); appStore.setMobileOpen(false)
} }
function handleMenuItemClick() { function handleMenuItemClick() {
if (mobileOpen.value) { if (mobileOpen.value) {
setTimeout(() => { setTimeout(() => {
appStore.setMobileOpen(false); appStore.setMobileOpen(false)
}, 150); }, 150)
} }
} }
function isActive(path: string): boolean { function isActive(path: string): boolean {
return route.path === path || route.path.startsWith(path + '/'); return route.path === path || route.path.startsWith(path + '/')
} }
// Initialize theme // Initialize theme
const savedTheme = localStorage.getItem('theme'); const savedTheme = localStorage.getItem('theme')
if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) { if (
isDark.value = true; savedTheme === 'dark' ||
document.documentElement.classList.add('dark'); (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)
) {
isDark.value = true
document.documentElement.classList.add('dark')
} }
</script> </script>
......
<template> <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 --> <!-- 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 --> <!-- Decorative Elements -->
<div class="absolute inset-0 overflow-hidden pointer-events-none"> <div class="pointer-events-none absolute inset-0 overflow-hidden">
<!-- Gradient Orbs --> <!-- Gradient Orbs -->
<div class="absolute -top-40 -right-40 w-80 h-80 bg-primary-400/20 rounded-full blur-3xl"></div> <div
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-primary-500/15 rounded-full blur-3xl"></div> class="absolute -right-40 -top-40 h-80 w-80 rounded-full bg-primary-400/20 blur-3xl"
<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>
<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 --> <!-- 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> </div>
<!-- Content Container --> <!-- Content Container -->
<div class="relative w-full max-w-md z-10"> <div class="relative z-10 w-full max-w-md">
<!-- Logo/Brand --> <!-- Logo/Brand -->
<div class="text-center mb-8"> <div class="mb-8 text-center">
<!-- Custom Logo or Default Logo --> <!-- 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"> <div
<img :src="siteLogo || '/logo.png'" alt="Logo" class="w-full h-full object-contain" /> 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> </div>
<h1 class="text-3xl font-bold text-gradient mb-2"> <h1 class="text-gradient mb-2 text-3xl font-bold">
{{ siteName }} {{ siteName }}
</h1> </h1>
<p class="text-sm text-gray-500 dark:text-dark-400"> <p class="text-sm text-gray-500 dark:text-dark-400">
...@@ -36,12 +48,12 @@ ...@@ -36,12 +48,12 @@
</div> </div>
<!-- Footer Links --> <!-- Footer Links -->
<div class="text-center mt-6 text-sm"> <div class="mt-6 text-center text-sm">
<slot name="footer" /> <slot name="footer" />
</div> </div>
<!-- Copyright --> <!-- 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">
&copy; {{ currentYear }} {{ siteName }}. All rights reserved. &copy; {{ currentYear }} {{ siteName }}. All rights reserved.
</div> </div>
</div> </div>
...@@ -49,25 +61,25 @@ ...@@ -49,25 +61,25 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue'; import { ref, computed, onMounted } from 'vue'
import { getPublicSettings } from '@/api/auth'; import { getPublicSettings } from '@/api/auth'
const siteName = ref('Sub2API'); const siteName = ref('Sub2API')
const siteLogo = ref(''); const siteLogo = ref('')
const siteSubtitle = ref('Subscription to API Conversion Platform'); const siteSubtitle = ref('Subscription to API Conversion Platform')
const currentYear = computed(() => new Date().getFullYear()); const currentYear = computed(() => new Date().getFullYear())
onMounted(async () => { onMounted(async () => {
try { try {
const settings = await getPublicSettings(); const settings = await getPublicSettings()
siteName.value = settings.site_name || 'Sub2API'; siteName.value = settings.site_name || 'Sub2API'
siteLogo.value = settings.site_logo || ''; siteLogo.value = settings.site_logo || ''
siteSubtitle.value = settings.site_subtitle || 'Subscription to API Conversion Platform'; siteSubtitle.value = settings.site_subtitle || 'Subscription to API Conversion Platform'
} catch (error) { } catch (error) {
console.error('Failed to load public settings:', error); console.error('Failed to load public settings:', error)
} }
}); })
</script> </script>
<style scoped> <style scoped>
......
...@@ -8,31 +8,31 @@ ...@@ -8,31 +8,31 @@
<div class="space-y-6"> <div class="space-y-6">
<h1 class="text-3xl font-bold text-gray-900">Dashboard</h1> <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 --> <!-- 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-sm text-gray-600">API Keys</div>
<div class="text-2xl font-bold text-gray-900">5</div> <div class="text-2xl font-bold text-gray-900">5</div>
</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-sm text-gray-600">Total Usage</div>
<div class="text-2xl font-bold text-gray-900">1,234</div> <div class="text-2xl font-bold text-gray-900">1,234</div>
</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-sm text-gray-600">Balance</div>
<div class="text-2xl font-bold text-indigo-600">${{ balance }}</div> <div class="text-2xl font-bold text-indigo-600">${{ balance }}</div>
</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-sm text-gray-600">Status</div>
<div class="text-2xl font-bold text-green-600">Active</div> <div class="text-2xl font-bold text-green-600">Active</div>
</div> </div>
</div> </div>
<div class="bg-white p-6 rounded-lg shadow"> <div class="rounded-lg bg-white p-6 shadow">
<h2 class="text-xl font-semibold mb-4">Recent Activity</h2> <h2 class="mb-4 text-xl font-semibold">Recent Activity</h2>
<p class="text-gray-600">No recent activity</p> <p class="text-gray-600">No recent activity</p>
</div> </div>
</div> </div>
...@@ -40,12 +40,12 @@ ...@@ -40,12 +40,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue'
import { AppLayout } from '@/components/layout'; import { AppLayout } from '@/components/layout'
import { useAuthStore } from '@/stores'; import { useAuthStore } from '@/stores'
const authStore = useAuthStore(); const authStore = useAuthStore()
const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00'); const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00')
</script> </script>
``` ```
...@@ -56,11 +56,11 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00'); ...@@ -56,11 +56,11 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
```vue ```vue
<template> <template>
<AuthLayout> <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"> <form @submit.prevent="handleSubmit" class="space-y-4">
<div> <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 Username
</label> </label>
<input <input
...@@ -68,13 +68,13 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00'); ...@@ -68,13 +68,13 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
v-model="form.username" v-model="form.username"
type="text" type="text"
required 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" placeholder="Enter your username"
/> />
</div> </div>
<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 Password
</label> </label>
<input <input
...@@ -82,7 +82,7 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00'); ...@@ -82,7 +82,7 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
v-model="form.password" v-model="form.password"
type="password" type="password"
required 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" placeholder="Enter your password"
/> />
</div> </div>
...@@ -90,7 +90,7 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00'); ...@@ -90,7 +90,7 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
<button <button
type="submit" type="submit"
:disabled="loading" :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' }} {{ loading ? 'Logging in...' : 'Login' }}
</button> </button>
...@@ -99,7 +99,7 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00'); ...@@ -99,7 +99,7 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
<template #footer> <template #footer>
<p class="text-gray-600"> <p class="text-gray-600">
Don't have an account? 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 Sign up
</router-link> </router-link>
</p> </p>
...@@ -108,32 +108,32 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00'); ...@@ -108,32 +108,32 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue'
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router'
import { AuthLayout } from '@/components/layout'; import { AuthLayout } from '@/components/layout'
import { useAuthStore, useAppStore } from '@/stores'; import { useAuthStore, useAppStore } from '@/stores'
const router = useRouter(); const router = useRouter()
const authStore = useAuthStore(); const authStore = useAuthStore()
const appStore = useAppStore(); const appStore = useAppStore()
const form = ref({ const form = ref({
username: '', username: '',
password: '', password: ''
}); })
const loading = ref(false); const loading = ref(false)
async function handleSubmit() { async function handleSubmit() {
loading.value = true; loading.value = true
try { try {
await authStore.login(form.value); await authStore.login(form.value)
appStore.showSuccess('Login successful!'); appStore.showSuccess('Login successful!')
await router.push('/dashboard'); await router.push('/dashboard')
} catch (error) { } catch (error) {
appStore.showError('Invalid username or password'); appStore.showError('Invalid username or password')
} finally { } finally {
loading.value = false; loading.value = false
} }
} }
</script> </script>
...@@ -152,42 +152,42 @@ async function handleSubmit() { ...@@ -152,42 +152,42 @@ async function handleSubmit() {
<h1 class="text-3xl font-bold text-gray-900">API Keys</h1> <h1 class="text-3xl font-bold text-gray-900">API Keys</h1>
<button <button
@click="showCreateModal = true" @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 Create New Key
</button> </button>
</div> </div>
<!-- API Keys List --> <!-- 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"> <table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
<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>
Name <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">Key</th>
</th> <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
<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">
Status Status
</th> </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 Created
</th> </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 Actions
</th> </th>
</tr> </tr>
</thead> </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"> <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 font-mono text-sm">{{ key.key }}</td>
<td class="px-6 py-4"> <td class="px-6 py-4">
<span <span
class="px-2 py-1 text-xs rounded-full" 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'" :class="
key.status === 'active'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
"
> >
{{ key.status }} {{ key.status }}
</span> </span>
...@@ -196,9 +196,7 @@ async function handleSubmit() { ...@@ -196,9 +196,7 @@ async function handleSubmit() {
{{ new Date(key.created_at).toLocaleDateString() }} {{ new Date(key.created_at).toLocaleDateString() }}
</td> </td>
<td class="px-6 py-4 text-right"> <td class="px-6 py-4 text-right">
<button class="text-red-600 hover:text-red-800 text-sm"> <button class="text-sm text-red-600 hover:text-red-800">Delete</button>
Delete
</button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
...@@ -209,12 +207,12 @@ async function handleSubmit() { ...@@ -209,12 +207,12 @@ async function handleSubmit() {
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue'
import { AppLayout } from '@/components/layout'; import { AppLayout } from '@/components/layout'
import type { ApiKey } from '@/types'; import type { ApiKey } from '@/types'
const showCreateModal = ref(false); const showCreateModal = ref(false)
const apiKeys = ref<ApiKey[]>([]); const apiKeys = ref<ApiKey[]>([])
// Fetch API keys on mount // Fetch API keys on mount
// fetchApiKeys(); // fetchApiKeys();
...@@ -233,34 +231,40 @@ const apiKeys = ref<ApiKey[]>([]); ...@@ -233,34 +231,40 @@ const apiKeys = ref<ApiKey[]>([]);
<h1 class="text-3xl font-bold text-gray-900">User Management</h1> <h1 class="text-3xl font-bold text-gray-900">User Management</h1>
<button <button
@click="showCreateUser = true" @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 Create User
</button> </button>
</div> </div>
<!-- Users Table --> <!-- Users Table -->
<div class="bg-white rounded-lg shadow"> <div class="rounded-lg bg-white shadow">
<div class="p-6"> <div class="p-6">
<div class="space-y-4"> <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>
<div class="font-medium text-gray-900">{{ user.username }}</div> <div class="font-medium text-gray-900">{{ user.username }}</div>
<div class="text-sm text-gray-500">{{ user.email }}</div> <div class="text-sm text-gray-500">{{ user.email }}</div>
</div> </div>
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<span <span
class="px-2 py-1 text-xs rounded-full" 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'" :class="
user.role === 'admin'
? 'bg-purple-100 text-purple-800'
: 'bg-blue-100 text-blue-800'
"
> >
{{ user.role }} {{ user.role }}
</span> </span>
<span class="text-sm font-medium text-gray-700"> <span class="text-sm font-medium text-gray-700">
${{ user.balance.toFixed(2) }} ${{ user.balance.toFixed(2) }}
</span> </span>
<button class="text-indigo-600 hover:text-indigo-800 text-sm"> <button class="text-sm text-indigo-600 hover:text-indigo-800">Edit</button>
Edit
</button>
</div> </div>
</div> </div>
</div> </div>
...@@ -271,12 +275,12 @@ const apiKeys = ref<ApiKey[]>([]); ...@@ -271,12 +275,12 @@ const apiKeys = ref<ApiKey[]>([]);
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue'
import { AppLayout } from '@/components/layout'; import { AppLayout } from '@/components/layout'
import type { User } from '@/types'; import type { User } from '@/types'
const showCreateUser = ref(false); const showCreateUser = ref(false)
const users = ref<User[]>([]); const users = ref<User[]>([])
// Fetch users on mount // Fetch users on mount
// fetchUsers(); // fetchUsers();
...@@ -294,36 +298,34 @@ const users = ref<User[]>([]); ...@@ -294,36 +298,34 @@ const users = ref<User[]>([]);
<h1 class="text-3xl font-bold text-gray-900">Profile Settings</h1> <h1 class="text-3xl font-bold text-gray-900">Profile Settings</h1>
<!-- User Info Card --> <!-- 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> <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> <div>
<label class="block text-sm font-medium text-gray-700 mb-1"> <label class="mb-1 block text-sm font-medium text-gray-700"> Username </label>
Username <div class="rounded-lg bg-gray-50 px-3 py-2 text-gray-900">
</label>
<div class="px-3 py-2 bg-gray-50 rounded-lg text-gray-900">
{{ user?.username }} {{ user?.username }}
</div> </div>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1"> <label class="mb-1 block text-sm font-medium text-gray-700"> Email </label>
Email <div class="rounded-lg bg-gray-50 px-3 py-2 text-gray-900">
</label>
<div class="px-3 py-2 bg-gray-50 rounded-lg text-gray-900">
{{ user?.email }} {{ user?.email }}
</div> </div>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1"> <label class="mb-1 block text-sm font-medium text-gray-700"> Role </label>
Role <div class="rounded-lg bg-gray-50 px-3 py-2">
</label>
<div class="px-3 py-2 bg-gray-50 rounded-lg">
<span <span
class="px-2 py-1 text-xs rounded-full" 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'" :class="
user?.role === 'admin'
? 'bg-purple-100 text-purple-800'
: 'bg-blue-100 text-blue-800'
"
> >
{{ user?.role }} {{ user?.role }}
</span> </span>
...@@ -331,10 +333,8 @@ const users = ref<User[]>([]); ...@@ -331,10 +333,8 @@ const users = ref<User[]>([]);
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1"> <label class="mb-1 block text-sm font-medium text-gray-700"> Balance </label>
Balance <div class="rounded-lg bg-gray-50 px-3 py-2 font-semibold text-indigo-600">
</label>
<div class="px-3 py-2 bg-gray-50 rounded-lg text-indigo-600 font-semibold">
${{ user?.balance.toFixed(2) }} ${{ user?.balance.toFixed(2) }}
</div> </div>
</div> </div>
...@@ -342,12 +342,12 @@ const users = ref<User[]>([]); ...@@ -342,12 +342,12 @@ const users = ref<User[]>([]);
</div> </div>
<!-- Change Password Card --> <!-- 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> <h2 class="text-xl font-semibold text-gray-900">Change Password</h2>
<form @submit.prevent="handleChangePassword" class="space-y-4"> <form @submit.prevent="handleChangePassword" class="space-y-4">
<div> <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 Current Password
</label> </label>
<input <input
...@@ -355,12 +355,12 @@ const users = ref<User[]>([]); ...@@ -355,12 +355,12 @@ const users = ref<User[]>([]);
v-model="passwordForm.old_password" v-model="passwordForm.old_password"
type="password" type="password"
required 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>
<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 New Password
</label> </label>
<input <input
...@@ -368,13 +368,13 @@ const users = ref<User[]>([]); ...@@ -368,13 +368,13 @@ const users = ref<User[]>([]);
v-model="passwordForm.new_password" v-model="passwordForm.new_password"
type="password" type="password"
required 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>
<button <button
type="submit" 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 Update Password
</button> </button>
...@@ -385,27 +385,27 @@ const users = ref<User[]>([]); ...@@ -385,27 +385,27 @@ const users = ref<User[]>([]);
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'; import { ref, computed } from 'vue'
import { AppLayout } from '@/components/layout'; import { AppLayout } from '@/components/layout'
import { useAuthStore, useAppStore } from '@/stores'; import { useAuthStore, useAppStore } from '@/stores'
const authStore = useAuthStore(); const authStore = useAuthStore()
const appStore = useAppStore(); const appStore = useAppStore()
const user = computed(() => authStore.user); const user = computed(() => authStore.user)
const passwordForm = ref({ const passwordForm = ref({
old_password: '', old_password: '',
new_password: '', new_password: ''
}); })
async function handleChangePassword() { async function handleChangePassword() {
try { try {
// await changePasswordAPI(passwordForm.value); // await changePasswordAPI(passwordForm.value);
appStore.showSuccess('Password updated successfully!'); appStore.showSuccess('Password updated successfully!')
passwordForm.value = { old_password: '', new_password: '' }; passwordForm.value = { old_password: '', new_password: '' }
} catch (error) { } catch (error) {
appStore.showError('Failed to update password'); appStore.showError('Failed to update password')
} }
} }
</script> </script>
......
...@@ -6,20 +6,20 @@ ...@@ -6,20 +6,20 @@
```typescript ```typescript
// In your view files // In your view files
import { AppLayout, AuthLayout } from '@/components/layout'; import { AppLayout, AuthLayout } from '@/components/layout'
``` ```
### 2. Use in Routes ### 2. Use in Routes
```typescript ```typescript
// src/router/index.ts // src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'; import type { RouteRecordRaw } from 'vue-router'
// Views // Views
import DashboardView from '@/views/DashboardView.vue'; import DashboardView from '@/views/DashboardView.vue'
import LoginView from '@/views/auth/LoginView.vue'; import LoginView from '@/views/auth/LoginView.vue'
import RegisterView from '@/views/auth/RegisterView.vue'; import RegisterView from '@/views/auth/RegisterView.vue'
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
// Auth routes (no layout needed - views use AuthLayout internally) // Auth routes (no layout needed - views use AuthLayout internally)
...@@ -27,13 +27,13 @@ const routes: RouteRecordRaw[] = [ ...@@ -27,13 +27,13 @@ const routes: RouteRecordRaw[] = [
path: '/login', path: '/login',
name: 'Login', name: 'Login',
component: LoginView, component: LoginView,
meta: { requiresAuth: false }, meta: { requiresAuth: false }
}, },
{ {
path: '/register', path: '/register',
name: 'Register', name: 'Register',
component: RegisterView, component: RegisterView,
meta: { requiresAuth: false }, meta: { requiresAuth: false }
}, },
// User routes (use AppLayout) // User routes (use AppLayout)
...@@ -41,31 +41,31 @@ const routes: RouteRecordRaw[] = [ ...@@ -41,31 +41,31 @@ const routes: RouteRecordRaw[] = [
path: '/dashboard', path: '/dashboard',
name: 'Dashboard', name: 'Dashboard',
component: DashboardView, component: DashboardView,
meta: { requiresAuth: true, title: 'Dashboard' }, meta: { requiresAuth: true, title: 'Dashboard' }
}, },
{ {
path: '/api-keys', path: '/api-keys',
name: 'ApiKeys', name: 'ApiKeys',
component: () => import('@/views/ApiKeysView.vue'), component: () => import('@/views/ApiKeysView.vue'),
meta: { requiresAuth: true, title: 'API Keys' }, meta: { requiresAuth: true, title: 'API Keys' }
}, },
{ {
path: '/usage', path: '/usage',
name: 'Usage', name: 'Usage',
component: () => import('@/views/UsageView.vue'), component: () => import('@/views/UsageView.vue'),
meta: { requiresAuth: true, title: 'Usage Statistics' }, meta: { requiresAuth: true, title: 'Usage Statistics' }
}, },
{ {
path: '/redeem', path: '/redeem',
name: 'Redeem', name: 'Redeem',
component: () => import('@/views/RedeemView.vue'), component: () => import('@/views/RedeemView.vue'),
meta: { requiresAuth: true, title: 'Redeem Code' }, meta: { requiresAuth: true, title: 'Redeem Code' }
}, },
{ {
path: '/profile', path: '/profile',
name: 'Profile', name: 'Profile',
component: () => import('@/views/ProfileView.vue'), component: () => import('@/views/ProfileView.vue'),
meta: { requiresAuth: true, title: 'Profile Settings' }, meta: { requiresAuth: true, title: 'Profile Settings' }
}, },
// Admin routes (use AppLayout, admin only) // Admin routes (use AppLayout, admin only)
...@@ -73,91 +73,91 @@ const routes: RouteRecordRaw[] = [ ...@@ -73,91 +73,91 @@ const routes: RouteRecordRaw[] = [
path: '/admin/dashboard', path: '/admin/dashboard',
name: 'AdminDashboard', name: 'AdminDashboard',
component: () => import('@/views/admin/DashboardView.vue'), 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', path: '/admin/users',
name: 'AdminUsers', name: 'AdminUsers',
component: () => import('@/views/admin/UsersView.vue'), 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', path: '/admin/groups',
name: 'AdminGroups', name: 'AdminGroups',
component: () => import('@/views/admin/GroupsView.vue'), component: () => import('@/views/admin/GroupsView.vue'),
meta: { requiresAuth: true, requiresAdmin: true, title: 'Groups' }, meta: { requiresAuth: true, requiresAdmin: true, title: 'Groups' }
}, },
{ {
path: '/admin/accounts', path: '/admin/accounts',
name: 'AdminAccounts', name: 'AdminAccounts',
component: () => import('@/views/admin/AccountsView.vue'), component: () => import('@/views/admin/AccountsView.vue'),
meta: { requiresAuth: true, requiresAdmin: true, title: 'Accounts' }, meta: { requiresAuth: true, requiresAdmin: true, title: 'Accounts' }
}, },
{ {
path: '/admin/proxies', path: '/admin/proxies',
name: 'AdminProxies', name: 'AdminProxies',
component: () => import('@/views/admin/ProxiesView.vue'), component: () => import('@/views/admin/ProxiesView.vue'),
meta: { requiresAuth: true, requiresAdmin: true, title: 'Proxies' }, meta: { requiresAuth: true, requiresAdmin: true, title: 'Proxies' }
}, },
{ {
path: '/admin/redeem-codes', path: '/admin/redeem-codes',
name: 'AdminRedeemCodes', name: 'AdminRedeemCodes',
component: () => import('@/views/admin/RedeemCodesView.vue'), component: () => import('@/views/admin/RedeemCodesView.vue'),
meta: { requiresAuth: true, requiresAdmin: true, title: 'Redeem Codes' }, meta: { requiresAuth: true, requiresAdmin: true, title: 'Redeem Codes' }
}, },
// Default redirect // Default redirect
{ {
path: '/', path: '/',
redirect: '/dashboard', redirect: '/dashboard'
}, }
]; ]
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes, routes
}); })
// Navigation guards // Navigation guards
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
const authStore = useAuthStore(); const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) { if (to.meta.requiresAuth && !authStore.isAuthenticated) {
// Redirect to login if not authenticated // Redirect to login if not authenticated
next('/login'); next('/login')
} else if (to.meta.requiresAdmin && !authStore.isAdmin) { } else if (to.meta.requiresAdmin && !authStore.isAdmin) {
// Redirect to dashboard if not admin // Redirect to dashboard if not admin
next('/dashboard'); next('/dashboard')
} else { } else {
next(); next()
} }
}); })
export default router; export default router
``` ```
### 3. Initialize Stores in main.ts ### 3. Initialize Stores in main.ts
```typescript ```typescript
// src/main.ts // src/main.ts
import { createApp } from 'vue'; import { createApp } from 'vue'
import { createPinia } from 'pinia'; import { createPinia } from 'pinia'
import App from './App.vue'; import App from './App.vue'
import router from './router'; import router from './router'
import './style.css'; import './style.css'
const app = createApp(App); const app = createApp(App)
const pinia = createPinia(); const pinia = createPinia()
app.use(pinia); app.use(pinia)
app.use(router); app.use(router)
// Initialize auth state on app startup // Initialize auth state on app startup
import { useAuthStore } from '@/stores'; import { useAuthStore } from '@/stores'
const authStore = useAuthStore(); const authStore = useAuthStore()
authStore.checkAuth(); authStore.checkAuth()
app.mount('#app'); app.mount('#app')
``` ```
### 4. Update App.vue ### 4. Update App.vue
...@@ -193,7 +193,7 @@ app.mount('#app'); ...@@ -193,7 +193,7 @@ app.mount('#app');
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { AppLayout } from '@/components/layout'; import { AppLayout } from '@/components/layout'
// Your component logic here // Your component logic here
</script> </script>
...@@ -205,23 +205,21 @@ import { AppLayout } from '@/components/layout'; ...@@ -205,23 +205,21 @@ import { AppLayout } from '@/components/layout';
<!-- src/views/auth/LoginView.vue --> <!-- src/views/auth/LoginView.vue -->
<template> <template>
<AuthLayout> <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 --> <!-- Your login form here -->
<template #footer> <template #footer>
<p class="text-gray-600"> <p class="text-gray-600">
Don't have an account? Don't have an account?
<router-link to="/register" class="text-indigo-600 hover:underline"> <router-link to="/register" class="text-indigo-600 hover:underline"> Sign up </router-link>
Sign up
</router-link>
</p> </p>
</template> </template>
</AuthLayout> </AuthLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { AuthLayout } from '@/components/layout'; import { AuthLayout } from '@/components/layout'
// Your login logic here // Your login logic here
</script> </script>
...@@ -250,7 +248,7 @@ Replace HTML entity icons with your preferred icon library: ...@@ -250,7 +248,7 @@ Replace HTML entity icons with your preferred icon library:
<span class="text-lg">&#128200;</span> <span class="text-lg">&#128200;</span>
<!-- After (Heroicons example) --> <!-- After (Heroicons example) -->
<ChartBarIcon class="w-5 h-5" /> <ChartBarIcon class="h-5 w-5" />
``` ```
### Sidebar Customization ### Sidebar Customization
...@@ -261,9 +259,9 @@ Modify navigation items in `AppSidebar.vue`: ...@@ -261,9 +259,9 @@ Modify navigation items in `AppSidebar.vue`:
// Add/remove/modify navigation items // Add/remove/modify navigation items
const userNavItems = [ const userNavItems = [
{ path: '/dashboard', label: 'Dashboard', icon: '&#128200;' }, { path: '/dashboard', label: 'Dashboard', icon: '&#128200;' },
{ path: '/new-page', label: 'New Page', icon: '&#128196;' }, // Add new item { path: '/new-page', label: 'New Page', icon: '&#128196;' } // Add new item
// ... // ...
]; ]
``` ```
### Header Customization ### Header Customization
...@@ -287,10 +285,12 @@ Modify user dropdown in `AppHeader.vue`: ...@@ -287,10 +285,12 @@ Modify user dropdown in `AppHeader.vue`:
## Mobile Responsive Behavior ## Mobile Responsive Behavior
### Sidebar ### Sidebar
- **Desktop (md+)**: Always visible, can be collapsed to icon-only view - **Desktop (md+)**: Always visible, can be collapsed to icon-only view
- **Mobile**: Hidden by default, shown via menu toggle in header - **Mobile**: Hidden by default, shown via menu toggle in header
### Header ### Header
- **Desktop**: Shows full user info and balance - **Desktop**: Shows full user info and balance
- **Mobile**: Shows compact view with hamburger menu - **Mobile**: Shows compact view with hamburger menu
...@@ -299,7 +299,7 @@ To improve mobile experience, you can add overlay and transitions: ...@@ -299,7 +299,7 @@ To improve mobile experience, you can add overlay and transitions:
```vue ```vue
<!-- AppSidebar.vue enhancement for mobile --> <!-- AppSidebar.vue enhancement for mobile -->
<aside <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="[ :class="[
sidebarCollapsed ? 'w-16' : 'w-64', sidebarCollapsed ? 'w-16' : 'w-64',
// Hide on mobile when collapsed // Hide on mobile when collapsed
...@@ -314,7 +314,7 @@ To improve mobile experience, you can add overlay and transitions: ...@@ -314,7 +314,7 @@ To improve mobile experience, you can add overlay and transitions:
<div <div
v-if="!sidebarCollapsed" v-if="!sidebarCollapsed"
@click="toggleSidebar" @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> ></div>
``` ```
...@@ -325,9 +325,9 @@ To improve mobile experience, you can add overlay and transitions: ...@@ -325,9 +325,9 @@ To improve mobile experience, you can add overlay and transitions:
### Auth Store Usage ### Auth Store Usage
```typescript ```typescript
import { useAuthStore } from '@/stores'; import { useAuthStore } from '@/stores'
const authStore = useAuthStore(); const authStore = useAuthStore()
// Check if user is authenticated // Check if user is authenticated
if (authStore.isAuthenticated) { if (authStore.isAuthenticated) {
...@@ -340,34 +340,34 @@ if (authStore.isAdmin) { ...@@ -340,34 +340,34 @@ if (authStore.isAdmin) {
} }
// Get current user // Get current user
const user = authStore.user; const user = authStore.user
``` ```
### App Store Usage ### App Store Usage
```typescript ```typescript
import { useAppStore } from '@/stores'; import { useAppStore } from '@/stores'
const appStore = useAppStore(); const appStore = useAppStore()
// Toggle sidebar // Toggle sidebar
appStore.toggleSidebar(); appStore.toggleSidebar()
// Show notifications // Show notifications
appStore.showSuccess('Operation completed!'); appStore.showSuccess('Operation completed!')
appStore.showError('Something went wrong'); appStore.showError('Something went wrong')
appStore.showInfo('Did you know...'); appStore.showInfo('Did you know...')
appStore.showWarning('Be careful!'); appStore.showWarning('Be careful!')
// Loading state // Loading state
appStore.setLoading(true); appStore.setLoading(true)
// ... perform operation // ... perform operation
appStore.setLoading(false); appStore.setLoading(false)
// Or use helper // Or use helper
await appStore.withLoading(async () => { await appStore.withLoading(async () => {
// Your async operation // Your async operation
}); })
``` ```
--- ---
...@@ -388,7 +388,7 @@ To enhance further: ...@@ -388,7 +388,7 @@ To enhance further:
<!-- Add skip to main content link --> <!-- Add skip to main content link -->
<a <a
href="#main-content" 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 Skip to main content
</a> </a>
...@@ -406,27 +406,27 @@ To enhance further: ...@@ -406,27 +406,27 @@ To enhance further:
```typescript ```typescript
// AppHeader.test.ts // AppHeader.test.ts
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'; import { createPinia, setActivePinia } from 'pinia'
import AppHeader from '@/components/layout/AppHeader.vue'; import AppHeader from '@/components/layout/AppHeader.vue'
describe('AppHeader', () => { describe('AppHeader', () => {
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()); setActivePinia(createPinia())
}); })
it('renders user info when authenticated', () => { it('renders user info when authenticated', () => {
const wrapper = mount(AppHeader); const wrapper = mount(AppHeader)
// Add assertions // Add assertions
}); })
it('shows dropdown when clicked', async () => { it('shows dropdown when clicked', async () => {
const wrapper = mount(AppHeader); const wrapper = mount(AppHeader)
await wrapper.find('button').trigger('click'); await wrapper.find('button').trigger('click')
expect(wrapper.find('.dropdown').exists()).toBe(true); expect(wrapper.find('.dropdown').exists()).toBe(true)
}); })
}); })
``` ```
--- ---
...@@ -443,7 +443,7 @@ Layout components are automatically code-split when imported: ...@@ -443,7 +443,7 @@ Layout components are automatically code-split when imported:
```typescript ```typescript
// This creates a separate chunk for layout components // This creates a separate chunk for layout components
import { AppLayout } from '@/components/layout'; import { AppLayout } from '@/components/layout'
``` ```
### Reducing Re-renders ### Reducing Re-renders
...@@ -451,7 +451,7 @@ import { AppLayout } from '@/components/layout'; ...@@ -451,7 +451,7 @@ import { AppLayout } from '@/components/layout';
Layout components use `computed` refs to prevent unnecessary re-renders: Layout components use `computed` refs to prevent unnecessary re-renders:
```typescript ```typescript
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed); const sidebarCollapsed = computed(() => appStore.sidebarCollapsed)
// This only re-renders when sidebarCollapsed changes // This only re-renders when sidebarCollapsed changes
``` ```
...@@ -460,21 +460,25 @@ const sidebarCollapsed = computed(() => appStore.sidebarCollapsed); ...@@ -460,21 +460,25 @@ const sidebarCollapsed = computed(() => appStore.sidebarCollapsed);
## Troubleshooting ## Troubleshooting
### Sidebar not showing ### Sidebar not showing
- Check if `useAppStore` is properly initialized - Check if `useAppStore` is properly initialized
- Verify Tailwind classes are being processed - Verify Tailwind classes are being processed
- Check z-index conflicts with other components - Check z-index conflicts with other components
### Routes not highlighting in sidebar ### Routes not highlighting in sidebar
- Ensure route paths match exactly - Ensure route paths match exactly
- Check `isActive()` function logic - Check `isActive()` function logic
- Verify `useRoute()` is working correctly - Verify `useRoute()` is working correctly
### User info not displaying ### User info not displaying
- Ensure auth store is initialized with `checkAuth()` - Ensure auth store is initialized with `checkAuth()`
- Verify user is logged in - Verify user is logged in
- Check localStorage for auth data - Check localStorage for auth data
### Mobile menu not working ### Mobile menu not working
- Verify `toggleSidebar()` is called correctly - Verify `toggleSidebar()` is called correctly
- Check responsive breakpoints (md:) - Check responsive breakpoints (md:)
- Test on actual mobile device or browser dev tools - Test on actual mobile device or browser dev tools
...@@ -5,9 +5,11 @@ Vue 3 layout components for the Sub2API frontend, built with Composition API, Ty ...@@ -5,9 +5,11 @@ Vue 3 layout components for the Sub2API frontend, built with Composition API, Ty
## Components ## Components
### 1. AppLayout.vue ### 1. AppLayout.vue
Main application layout with sidebar and header. Main application layout with sidebar and header.
**Usage:** **Usage:**
```vue ```vue
<template> <template>
<AppLayout> <AppLayout>
...@@ -18,11 +20,12 @@ Main application layout with sidebar and header. ...@@ -18,11 +20,12 @@ Main application layout with sidebar and header.
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { AppLayout } from '@/components/layout'; import { AppLayout } from '@/components/layout'
</script> </script>
``` ```
**Features:** **Features:**
- Responsive sidebar (collapsible) - Responsive sidebar (collapsible)
- Fixed header at top - Fixed header at top
- Main content area with slot - Main content area with slot
...@@ -31,9 +34,11 @@ import { AppLayout } from '@/components/layout'; ...@@ -31,9 +34,11 @@ import { AppLayout } from '@/components/layout';
--- ---
### 2. AppSidebar.vue ### 2. AppSidebar.vue
Navigation sidebar with user and admin sections. Navigation sidebar with user and admin sections.
**Features:** **Features:**
- Logo/brand at top - Logo/brand at top
- User navigation links: - User navigation links:
- Dashboard - Dashboard
...@@ -58,9 +63,11 @@ Navigation sidebar with user and admin sections. ...@@ -58,9 +63,11 @@ Navigation sidebar with user and admin sections.
--- ---
### 3. AppHeader.vue ### 3. AppHeader.vue
Top header with user info and actions. Top header with user info and actions.
**Features:** **Features:**
- Mobile menu toggle button - Mobile menu toggle button
- Page title (from route meta or slot) - Page title (from route meta or slot)
- User balance display (desktop only) - User balance display (desktop only)
...@@ -72,12 +79,11 @@ Top header with user info and actions. ...@@ -72,12 +79,11 @@ Top header with user info and actions.
- Responsive design - Responsive design
**Usage with custom title:** **Usage with custom title:**
```vue ```vue
<template> <template>
<AppLayout> <AppLayout>
<template #title> <template #title> Custom Page Title </template>
Custom Page Title
</template>
<!-- Your content --> <!-- Your content -->
</AppLayout> </AppLayout>
...@@ -89,14 +95,16 @@ Top header with user info and actions. ...@@ -89,14 +95,16 @@ Top header with user info and actions.
--- ---
### 4. AuthLayout.vue ### 4. AuthLayout.vue
Simple centered layout for authentication pages (login/register). Simple centered layout for authentication pages (login/register).
**Usage:** **Usage:**
```vue ```vue
<template> <template>
<AuthLayout> <AuthLayout>
<!-- Login/Register form content --> <!-- 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 @submit.prevent="handleLogin">
<!-- Form fields --> <!-- Form fields -->
...@@ -106,16 +114,14 @@ Simple centered layout for authentication pages (login/register). ...@@ -106,16 +114,14 @@ Simple centered layout for authentication pages (login/register).
<template #footer> <template #footer>
<p> <p>
Don't have an account? Don't have an account?
<router-link to="/register" class="text-indigo-600 hover:underline"> <router-link to="/register" class="text-indigo-600 hover:underline"> Sign up </router-link>
Sign up
</router-link>
</p> </p>
</template> </template>
</AuthLayout> </AuthLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { AuthLayout } from '@/components/layout'; import { AuthLayout } from '@/components/layout'
function handleLogin() { function handleLogin() {
// Login logic // Login logic
...@@ -124,6 +130,7 @@ function handleLogin() { ...@@ -124,6 +130,7 @@ function handleLogin() {
``` ```
**Features:** **Features:**
- Centered card container - Centered card container
- Gradient background - Gradient background
- Logo/brand at top - Logo/brand at top
...@@ -143,15 +150,15 @@ const routes = [ ...@@ -143,15 +150,15 @@ const routes = [
{ {
path: '/dashboard', path: '/dashboard',
component: DashboardView, component: DashboardView,
meta: { title: 'Dashboard' }, meta: { title: 'Dashboard' }
}, },
{ {
path: '/api-keys', path: '/api-keys',
component: ApiKeysView, 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. ...@@ -173,10 +180,7 @@ All components use TailwindCSS utility classes. Make sure your `tailwind.config.
```js ```js
module.exports = { module.exports = {
content: [ content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}']
'./index.html',
'./src/**/*.{vue,js,ts,jsx,tsx}',
],
// ... // ...
} }
``` ```
...@@ -186,6 +190,7 @@ module.exports = { ...@@ -186,6 +190,7 @@ module.exports = {
## Icons ## Icons
Components use HTML entity icons for simplicity: Components use HTML entity icons for simplicity:
- &#128200; Chart (Dashboard) - &#128200; Chart (Dashboard)
- &#128273; Key (API Keys) - &#128273; Key (API Keys)
- &#128202; Bar Chart (Usage) - &#128202; Bar Chart (Usage)
...@@ -205,6 +210,7 @@ You can replace these with your preferred icon library (e.g., Heroicons, Font Aw ...@@ -205,6 +210,7 @@ You can replace these with your preferred icon library (e.g., Heroicons, Font Aw
## Mobile Responsiveness ## Mobile Responsiveness
All components are fully responsive: All components are fully responsive:
- **AppSidebar**: Fixed positioning on desktop, hidden by default on mobile - **AppSidebar**: Fixed positioning on desktop, hidden by default on mobile
- **AppHeader**: Shows mobile menu toggle on small screens, hides balance display - **AppHeader**: Shows mobile menu toggle on small screens, hides balance display
- **AuthLayout**: Adapts padding and card size for mobile devices - **AuthLayout**: Adapts padding and card size for mobile devices
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
* Export all layout components for easy importing * Export all layout components for easy importing
*/ */
export { default as AppLayout } from './AppLayout.vue'; export { default as AppLayout } from './AppLayout.vue'
export { default as AppSidebar } from './AppSidebar.vue'; export { default as AppSidebar } from './AppSidebar.vue'
export { default as AppHeader } from './AppHeader.vue'; export { default as AppHeader } from './AppHeader.vue'
export { default as AuthLayout } from './AuthLayout.vue'; export { default as AuthLayout } from './AuthLayout.vue'
...@@ -53,9 +53,10 @@ export function useAccountOAuth() { ...@@ -53,9 +53,10 @@ export function useAccountOAuth() {
try { try {
const proxyConfig = proxyId ? { proxy_id: proxyId } : {} const proxyConfig = proxyId ? { proxy_id: proxyId } : {}
const endpoint = addMethod === 'oauth' const endpoint =
? '/admin/accounts/generate-auth-url' addMethod === 'oauth'
: '/admin/accounts/generate-setup-token-url' ? '/admin/accounts/generate-auth-url'
: '/admin/accounts/generate-setup-token-url'
const response = await adminAPI.accounts.generateAuthUrl(endpoint, proxyConfig) const response = await adminAPI.accounts.generateAuthUrl(endpoint, proxyConfig)
authUrl.value = response.auth_url authUrl.value = response.auth_url
...@@ -85,9 +86,10 @@ export function useAccountOAuth() { ...@@ -85,9 +86,10 @@ export function useAccountOAuth() {
try { try {
const proxyConfig = proxyId ? { proxy_id: proxyId } : {} const proxyConfig = proxyId ? { proxy_id: proxyId } : {}
const endpoint = addMethod === 'oauth' const endpoint =
? '/admin/accounts/exchange-code' addMethod === 'oauth'
: '/admin/accounts/exchange-setup-token-code' ? '/admin/accounts/exchange-code'
: '/admin/accounts/exchange-setup-token-code'
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, { const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
session_id: sessionId.value, session_id: sessionId.value,
...@@ -121,9 +123,10 @@ export function useAccountOAuth() { ...@@ -121,9 +123,10 @@ export function useAccountOAuth() {
try { try {
const proxyConfig = proxyId ? { proxy_id: proxyId } : {} const proxyConfig = proxyId ? { proxy_id: proxyId } : {}
const endpoint = addMethod === 'oauth' const endpoint =
? '/admin/accounts/cookie-auth' addMethod === 'oauth'
: '/admin/accounts/setup-token-cookie-auth' ? '/admin/accounts/cookie-auth'
: '/admin/accounts/setup-token-cookie-auth'
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, { const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
session_id: '', session_id: '',
...@@ -142,7 +145,10 @@ export function useAccountOAuth() { ...@@ -142,7 +145,10 @@ export function useAccountOAuth() {
// Parse multiple session keys // Parse multiple session keys
const parseSessionKeys = (input: string): string[] => { 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 // Build extra info from token response
......
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
}
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment