style(frontend): 优化 Components 代码风格和结构
- 统一移除语句末尾分号,规范代码格式 - 优化组件类型定义和 props 声明 - 改进组件文档和示例代码 - 提升代码可读性和一致性
This commit is contained in:
@@ -1,15 +1,25 @@
|
||||
<template>
|
||||
<header class="sticky top-0 z-30 glass border-b border-gray-200/50 dark:border-dark-700/50">
|
||||
<div class="flex items-center justify-between h-16 px-4 md:px-6">
|
||||
<header class="glass sticky top-0 z-30 border-b border-gray-200/50 dark:border-dark-700/50">
|
||||
<div class="flex h-16 items-center justify-between px-4 md:px-6">
|
||||
<!-- Left: Mobile Menu Toggle + Page Title -->
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
@click="toggleMobileSidebar"
|
||||
class="lg:hidden btn-ghost btn-icon"
|
||||
class="btn-ghost btn-icon lg:hidden"
|
||||
aria-label="Toggle Menu"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -32,9 +42,22 @@
|
||||
<SubscriptionProgressMini v-if="user" />
|
||||
|
||||
<!-- Balance Display -->
|
||||
<div v-if="user" class="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-xl bg-primary-50 dark:bg-primary-900/20">
|
||||
<svg class="w-4 h-4 text-primary-600 dark:text-primary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
|
||||
<div
|
||||
v-if="user"
|
||||
class="hidden items-center gap-2 rounded-xl bg-primary-50 px-3 py-1.5 dark:bg-primary-900/20 sm:flex"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-primary-600 dark:text-primary-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm font-semibold text-primary-700 dark:text-primary-300">
|
||||
${{ user.balance?.toFixed(2) || '0.00' }}
|
||||
@@ -45,64 +68,89 @@
|
||||
<div v-if="user" class="relative" ref="dropdownRef">
|
||||
<button
|
||||
@click="toggleDropdown"
|
||||
class="flex items-center gap-2 p-1.5 rounded-xl hover:bg-gray-100 dark:hover:bg-dark-800 transition-colors"
|
||||
class="flex items-center gap-2 rounded-xl p-1.5 transition-colors hover:bg-gray-100 dark:hover:bg-dark-800"
|
||||
aria-label="User Menu"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-xl bg-gradient-to-br from-primary-500 to-primary-600 text-white flex items-center justify-center text-sm font-medium shadow-sm">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-br from-primary-500 to-primary-600 text-sm font-medium text-white shadow-sm"
|
||||
>
|
||||
{{ userInitials }}
|
||||
</div>
|
||||
<div class="hidden md:block text-left">
|
||||
<div class="hidden text-left md:block">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ displayName }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-dark-400 capitalize">
|
||||
<div class="text-xs capitalize text-gray-500 dark:text-dark-400">
|
||||
{{ user.role }}
|
||||
</div>
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-gray-400 hidden md:block" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
<svg
|
||||
class="hidden h-4 w-4 text-gray-400 md:block"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown Menu -->
|
||||
<transition name="dropdown">
|
||||
<div
|
||||
v-if="dropdownOpen"
|
||||
class="dropdown right-0 mt-2 w-56"
|
||||
>
|
||||
<div v-if="dropdownOpen" class="dropdown right-0 mt-2 w-56">
|
||||
<!-- User Info -->
|
||||
<div class="px-4 py-3 border-b border-gray-100 dark:border-dark-700">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">{{ displayName }}</div>
|
||||
<div class="border-b border-gray-100 px-4 py-3 dark:border-dark-700">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ displayName }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-dark-400">{{ user.email }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Balance (mobile only) -->
|
||||
<div class="sm:hidden px-4 py-2 border-b border-gray-100 dark:border-dark-700">
|
||||
<div class="text-xs text-gray-500 dark:text-dark-400">{{ t('common.balance') }}</div>
|
||||
<div class="border-b border-gray-100 px-4 py-2 dark:border-dark-700 sm:hidden">
|
||||
<div class="text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ t('common.balance') }}
|
||||
</div>
|
||||
<div class="text-sm font-semibold text-primary-600 dark:text-primary-400">
|
||||
${{ user.balance?.toFixed(2) || '0.00' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-1">
|
||||
<router-link
|
||||
to="/profile"
|
||||
@click="closeDropdown"
|
||||
class="dropdown-item"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
<router-link to="/profile" @click="closeDropdown" class="dropdown-item">
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('nav.profile') }}
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/keys"
|
||||
@click="closeDropdown"
|
||||
class="dropdown-item"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
||||
<router-link to="/keys" @click="closeDropdown" class="dropdown-item">
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('nav.apiKeys') }}
|
||||
</router-link>
|
||||
@@ -114,31 +162,60 @@
|
||||
@click="closeDropdown"
|
||||
class="dropdown-item"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.464-1.11-1.464-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z" />
|
||||
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.464-1.11-1.464-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('nav.github') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Contact Support (only show if configured) -->
|
||||
<div v-if="contactInfo" class="border-t border-gray-100 dark:border-dark-700 px-4 py-2.5">
|
||||
<div
|
||||
v-if="contactInfo"
|
||||
class="border-t border-gray-100 px-4 py-2.5 dark:border-dark-700"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
|
||||
<svg
|
||||
class="h-3.5 w-3.5 flex-shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ t('common.contactSupport') }}:</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ contactInfo }}</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{
|
||||
contactInfo
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-100 dark:border-dark-700 py-1">
|
||||
<div class="border-t border-gray-100 py-1 dark:border-dark-700">
|
||||
<button
|
||||
@click="handleLogout"
|
||||
class="dropdown-item w-full text-red-600 dark:text-red-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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('nav.logout') }}
|
||||
</button>
|
||||
@@ -152,90 +229,90 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAppStore, useAuthStore } from '@/stores';
|
||||
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue';
|
||||
import SubscriptionProgressMini from '@/components/common/SubscriptionProgressMini.vue';
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore, useAuthStore } from '@/stores'
|
||||
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
|
||||
import SubscriptionProgressMini from '@/components/common/SubscriptionProgressMini.vue'
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const appStore = useAppStore();
|
||||
const authStore = useAuthStore();
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const user = computed(() => authStore.user);
|
||||
const dropdownOpen = ref(false);
|
||||
const dropdownRef = ref<HTMLElement | null>(null);
|
||||
const contactInfo = computed(() => appStore.contactInfo);
|
||||
const user = computed(() => authStore.user)
|
||||
const dropdownOpen = ref(false)
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
const contactInfo = computed(() => appStore.contactInfo)
|
||||
|
||||
const userInitials = computed(() => {
|
||||
if (!user.value) return '';
|
||||
if (!user.value) return ''
|
||||
// Prefer username, fallback to email
|
||||
if (user.value.username) {
|
||||
return user.value.username.substring(0, 2).toUpperCase();
|
||||
return user.value.username.substring(0, 2).toUpperCase()
|
||||
}
|
||||
if (user.value.email) {
|
||||
// Get the part before @ and take first 2 chars
|
||||
const localPart = user.value.email.split('@')[0];
|
||||
return localPart.substring(0, 2).toUpperCase();
|
||||
const localPart = user.value.email.split('@')[0]
|
||||
return localPart.substring(0, 2).toUpperCase()
|
||||
}
|
||||
return '';
|
||||
});
|
||||
return ''
|
||||
})
|
||||
|
||||
const displayName = computed(() => {
|
||||
if (!user.value) return '';
|
||||
return user.value.username || user.value.email?.split('@')[0] || '';
|
||||
});
|
||||
if (!user.value) return ''
|
||||
return user.value.username || user.value.email?.split('@')[0] || ''
|
||||
})
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
const titleKey = route.meta.titleKey as string;
|
||||
const titleKey = route.meta.titleKey as string
|
||||
if (titleKey) {
|
||||
return t(titleKey);
|
||||
return t(titleKey)
|
||||
}
|
||||
return (route.meta.title as string) || '';
|
||||
});
|
||||
return (route.meta.title as string) || ''
|
||||
})
|
||||
|
||||
const pageDescription = computed(() => {
|
||||
const descKey = route.meta.descriptionKey as string;
|
||||
const descKey = route.meta.descriptionKey as string
|
||||
if (descKey) {
|
||||
return t(descKey);
|
||||
return t(descKey)
|
||||
}
|
||||
return (route.meta.description as string) || '';
|
||||
});
|
||||
return (route.meta.description as string) || ''
|
||||
})
|
||||
|
||||
function toggleMobileSidebar() {
|
||||
appStore.toggleMobileSidebar();
|
||||
appStore.toggleMobileSidebar()
|
||||
}
|
||||
|
||||
function toggleDropdown() {
|
||||
dropdownOpen.value = !dropdownOpen.value;
|
||||
dropdownOpen.value = !dropdownOpen.value
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
dropdownOpen.value = false;
|
||||
dropdownOpen.value = false
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
closeDropdown();
|
||||
authStore.logout();
|
||||
await router.push('/login');
|
||||
closeDropdown()
|
||||
authStore.logout()
|
||||
await router.push('/login')
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
|
||||
closeDropdown();
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
});
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-dark-950">
|
||||
<!-- Background Decoration -->
|
||||
<div class="fixed inset-0 bg-mesh-gradient pointer-events-none"></div>
|
||||
<div class="pointer-events-none fixed inset-0 bg-mesh-gradient"></div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<AppSidebar />
|
||||
@@ -9,9 +9,7 @@
|
||||
<!-- Main Content Area -->
|
||||
<div
|
||||
class="relative min-h-screen transition-all duration-300"
|
||||
:class="[
|
||||
sidebarCollapsed ? 'lg:ml-[72px]' : 'lg:ml-64',
|
||||
]"
|
||||
:class="[sidebarCollapsed ? 'lg:ml-[72px]' : 'lg:ml-64']"
|
||||
>
|
||||
<!-- Header -->
|
||||
<AppHeader />
|
||||
@@ -25,12 +23,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useAppStore } from '@/stores';
|
||||
import AppSidebar from './AppSidebar.vue';
|
||||
import AppHeader from './AppHeader.vue';
|
||||
import { computed } from 'vue'
|
||||
import { useAppStore } from '@/stores'
|
||||
import AppSidebar from './AppSidebar.vue'
|
||||
import AppHeader from './AppHeader.vue'
|
||||
|
||||
const appStore = useAppStore();
|
||||
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed);
|
||||
const appStore = useAppStore()
|
||||
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
<!-- Logo/Brand -->
|
||||
<div class="sidebar-header">
|
||||
<!-- Custom Logo or Default Logo -->
|
||||
<div class="w-9 h-9 rounded-xl overflow-hidden flex items-center justify-center shadow-glow">
|
||||
<img :src="siteLogo || '/logo.png'" alt="Logo" class="w-full h-full object-contain" />
|
||||
<div class="flex h-9 w-9 items-center justify-center overflow-hidden rounded-xl shadow-glow">
|
||||
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<div v-if="!sidebarCollapsed" class="flex flex-col">
|
||||
@@ -38,7 +38,7 @@
|
||||
:title="sidebarCollapsed ? item.label : undefined"
|
||||
@click="handleMenuItemClick"
|
||||
>
|
||||
<component :is="item.icon" class="w-5 h-5 flex-shrink-0" />
|
||||
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<transition name="fade">
|
||||
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
|
||||
</transition>
|
||||
@@ -50,7 +50,7 @@
|
||||
<div v-if="!sidebarCollapsed" class="sidebar-section-title">
|
||||
{{ t('nav.myAccount') }}
|
||||
</div>
|
||||
<div v-else class="h-px bg-gray-200 dark:bg-dark-700 mx-3 my-3"></div>
|
||||
<div v-else class="mx-3 my-3 h-px bg-gray-200 dark:bg-dark-700"></div>
|
||||
|
||||
<router-link
|
||||
v-for="item in personalNavItems"
|
||||
@@ -61,7 +61,7 @@
|
||||
:title="sidebarCollapsed ? item.label : undefined"
|
||||
@click="handleMenuItemClick"
|
||||
>
|
||||
<component :is="item.icon" class="w-5 h-5 flex-shrink-0" />
|
||||
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<transition name="fade">
|
||||
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
|
||||
</transition>
|
||||
@@ -81,7 +81,7 @@
|
||||
:title="sidebarCollapsed ? item.label : undefined"
|
||||
@click="handleMenuItemClick"
|
||||
>
|
||||
<component :is="item.icon" class="w-5 h-5 flex-shrink-0" />
|
||||
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<transition name="fade">
|
||||
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
|
||||
</transition>
|
||||
@@ -91,17 +91,19 @@
|
||||
</nav>
|
||||
|
||||
<!-- Bottom Section -->
|
||||
<div class="mt-auto border-t border-gray-100 dark:border-dark-800 p-3">
|
||||
<div class="mt-auto border-t border-gray-100 p-3 dark:border-dark-800">
|
||||
<!-- Theme Toggle -->
|
||||
<button
|
||||
@click="toggleTheme"
|
||||
class="sidebar-link w-full mb-2"
|
||||
class="sidebar-link mb-2 w-full"
|
||||
:title="sidebarCollapsed ? (isDark ? t('nav.lightMode') : t('nav.darkMode')) : undefined"
|
||||
>
|
||||
<SunIcon v-if="isDark" class="w-5 h-5 flex-shrink-0 text-amber-500" />
|
||||
<MoonIcon v-else class="w-5 h-5 flex-shrink-0" />
|
||||
<SunIcon v-if="isDark" class="h-5 w-5 flex-shrink-0 text-amber-500" />
|
||||
<MoonIcon v-else class="h-5 w-5 flex-shrink-0" />
|
||||
<transition name="fade">
|
||||
<span v-if="!sidebarCollapsed">{{ isDark ? t('nav.lightMode') : t('nav.darkMode') }}</span>
|
||||
<span v-if="!sidebarCollapsed">{{
|
||||
isDark ? t('nav.lightMode') : t('nav.darkMode')
|
||||
}}</span>
|
||||
</transition>
|
||||
</button>
|
||||
|
||||
@@ -111,8 +113,8 @@
|
||||
class="sidebar-link w-full"
|
||||
:title="sidebarCollapsed ? t('nav.expand') : t('nav.collapse')"
|
||||
>
|
||||
<ChevronDoubleLeftIcon v-if="!sidebarCollapsed" class="w-5 h-5 flex-shrink-0" />
|
||||
<ChevronDoubleRightIcon v-else class="w-5 h-5 flex-shrink-0" />
|
||||
<ChevronDoubleLeftIcon v-if="!sidebarCollapsed" class="h-5 w-5 flex-shrink-0" />
|
||||
<ChevronDoubleRightIcon v-else class="h-5 w-5 flex-shrink-0" />
|
||||
<transition name="fade">
|
||||
<span v-if="!sidebarCollapsed">{{ t('nav.collapse') }}</span>
|
||||
</transition>
|
||||
@@ -124,132 +126,280 @@
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="mobileOpen"
|
||||
class="fixed inset-0 bg-black/50 z-30 lg:hidden"
|
||||
class="fixed inset-0 z-30 bg-black/50 lg:hidden"
|
||||
@click="closeMobile"
|
||||
></div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAppStore, useAuthStore } from '@/stores';
|
||||
import VersionBadge from '@/components/common/VersionBadge.vue';
|
||||
import { computed, h, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore, useAuthStore } from '@/stores'
|
||||
import VersionBadge from '@/components/common/VersionBadge.vue'
|
||||
|
||||
const { t } = useI18n();
|
||||
const { t } = useI18n()
|
||||
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
const authStore = useAuthStore();
|
||||
const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed);
|
||||
const mobileOpen = computed(() => appStore.mobileOpen);
|
||||
const isAdmin = computed(() => authStore.isAdmin);
|
||||
const isDark = ref(document.documentElement.classList.contains('dark'));
|
||||
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed)
|
||||
const mobileOpen = computed(() => appStore.mobileOpen)
|
||||
const isAdmin = computed(() => authStore.isAdmin)
|
||||
const isDark = ref(document.documentElement.classList.contains('dark'))
|
||||
|
||||
// Site settings from appStore (cached, no flicker)
|
||||
const siteName = computed(() => appStore.siteName);
|
||||
const siteLogo = computed(() => appStore.siteLogo);
|
||||
const siteVersion = computed(() => appStore.siteVersion);
|
||||
const siteName = computed(() => appStore.siteName)
|
||||
const siteLogo = computed(() => appStore.siteLogo)
|
||||
const siteVersion = computed(() => appStore.siteVersion)
|
||||
|
||||
// SVG Icon Components
|
||||
const DashboardIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const KeyIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const ChartIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const GiftIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const UserIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const UsersIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const FolderIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const CreditCardIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const GlobeIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const ServerIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const TicketIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const CogIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z' }),
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z'
|
||||
}),
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const SunIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const MoonIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const ChevronDoubleLeftIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'm18.75 4.5-7.5 7.5 7.5 7.5m-6-15L5.25 12l7.5 7.5' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'm18.75 4.5-7.5 7.5 7.5 7.5m-6-15L5.25 12l7.5 7.5'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const ChevronDoubleRightIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'm5.25 4.5 7.5 7.5-7.5 7.5m6-15 7.5 7.5-7.5 7.5' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'm5.25 4.5 7.5 7.5-7.5 7.5m6-15 7.5 7.5-7.5 7.5'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// User navigation items (for regular users)
|
||||
const userNavItems = computed(() => [
|
||||
@@ -258,8 +408,8 @@ const userNavItems = computed(() => [
|
||||
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon },
|
||||
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon },
|
||||
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon },
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
|
||||
]);
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon }
|
||||
])
|
||||
|
||||
// Personal navigation items (for admin's "My Account" section, without Dashboard)
|
||||
const personalNavItems = computed(() => [
|
||||
@@ -267,8 +417,8 @@ const personalNavItems = computed(() => [
|
||||
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon },
|
||||
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon },
|
||||
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon },
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
|
||||
]);
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon }
|
||||
])
|
||||
|
||||
// Admin navigation items
|
||||
const adminNavItems = computed(() => [
|
||||
@@ -280,40 +430,43 @@ const adminNavItems = computed(() => [
|
||||
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
|
||||
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon },
|
||||
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon },
|
||||
{ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon },
|
||||
]);
|
||||
{ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon }
|
||||
])
|
||||
|
||||
function toggleSidebar() {
|
||||
appStore.toggleSidebar();
|
||||
appStore.toggleSidebar()
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
isDark.value = !isDark.value;
|
||||
document.documentElement.classList.toggle('dark', isDark.value);
|
||||
localStorage.setItem('theme', isDark.value ? 'dark' : 'light');
|
||||
isDark.value = !isDark.value
|
||||
document.documentElement.classList.toggle('dark', isDark.value)
|
||||
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
function closeMobile() {
|
||||
appStore.setMobileOpen(false);
|
||||
appStore.setMobileOpen(false)
|
||||
}
|
||||
|
||||
function handleMenuItemClick() {
|
||||
if (mobileOpen.value) {
|
||||
setTimeout(() => {
|
||||
appStore.setMobileOpen(false);
|
||||
}, 150);
|
||||
appStore.setMobileOpen(false)
|
||||
}, 150)
|
||||
}
|
||||
}
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
return route.path === path || route.path.startsWith(path + '/');
|
||||
return route.path === path || route.path.startsWith(path + '/')
|
||||
}
|
||||
|
||||
// Initialize theme
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
isDark.value = true;
|
||||
document.documentElement.classList.add('dark');
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
if (
|
||||
savedTheme === 'dark' ||
|
||||
(!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
) {
|
||||
isDark.value = true
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,28 +1,40 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center p-4 relative overflow-hidden">
|
||||
<div class="relative flex min-h-screen items-center justify-center overflow-hidden p-4">
|
||||
<!-- Background -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-gray-50 via-primary-50/30 to-gray-100 dark:from-dark-950 dark:via-dark-900 dark:to-dark-950"></div>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-gray-50 via-primary-50/30 to-gray-100 dark:from-dark-950 dark:via-dark-900 dark:to-dark-950"
|
||||
></div>
|
||||
|
||||
<!-- Decorative Elements -->
|
||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<!-- Gradient Orbs -->
|
||||
<div class="absolute -top-40 -right-40 w-80 h-80 bg-primary-400/20 rounded-full blur-3xl"></div>
|
||||
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-primary-500/15 rounded-full blur-3xl"></div>
|
||||
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-primary-300/10 rounded-full blur-3xl"></div>
|
||||
<div
|
||||
class="absolute -right-40 -top-40 h-80 w-80 rounded-full bg-primary-400/20 blur-3xl"
|
||||
></div>
|
||||
<div
|
||||
class="absolute -bottom-40 -left-40 h-80 w-80 rounded-full bg-primary-500/15 blur-3xl"
|
||||
></div>
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 h-96 w-96 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-300/10 blur-3xl"
|
||||
></div>
|
||||
|
||||
<!-- Grid Pattern -->
|
||||
<div class="absolute inset-0 bg-[linear-gradient(rgba(20,184,166,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(20,184,166,0.03)_1px,transparent_1px)] bg-[size:64px_64px]"></div>
|
||||
<div
|
||||
class="absolute inset-0 bg-[linear-gradient(rgba(20,184,166,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(20,184,166,0.03)_1px,transparent_1px)] bg-[size:64px_64px]"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Content Container -->
|
||||
<div class="relative w-full max-w-md z-10">
|
||||
<div class="relative z-10 w-full max-w-md">
|
||||
<!-- Logo/Brand -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="mb-8 text-center">
|
||||
<!-- Custom Logo or Default Logo -->
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl overflow-hidden shadow-lg shadow-primary-500/30 mb-4">
|
||||
<img :src="siteLogo || '/logo.png'" alt="Logo" class="w-full h-full object-contain" />
|
||||
<div
|
||||
class="mb-4 inline-flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl shadow-lg shadow-primary-500/30"
|
||||
>
|
||||
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-gradient mb-2">
|
||||
<h1 class="text-gradient mb-2 text-3xl font-bold">
|
||||
{{ siteName }}
|
||||
</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-dark-400">
|
||||
@@ -36,12 +48,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Footer Links -->
|
||||
<div class="text-center mt-6 text-sm">
|
||||
<div class="mt-6 text-center text-sm">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
|
||||
<!-- Copyright -->
|
||||
<div class="text-center mt-8 text-xs text-gray-400 dark:text-dark-500">
|
||||
<div class="mt-8 text-center text-xs text-gray-400 dark:text-dark-500">
|
||||
© {{ currentYear }} {{ siteName }}. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,25 +61,25 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { getPublicSettings } from '@/api/auth';
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { getPublicSettings } from '@/api/auth'
|
||||
|
||||
const siteName = ref('Sub2API');
|
||||
const siteLogo = ref('');
|
||||
const siteSubtitle = ref('Subscription to API Conversion Platform');
|
||||
const siteName = ref('Sub2API')
|
||||
const siteLogo = ref('')
|
||||
const siteSubtitle = ref('Subscription to API Conversion Platform')
|
||||
|
||||
const currentYear = computed(() => new Date().getFullYear());
|
||||
const currentYear = computed(() => new Date().getFullYear())
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const settings = await getPublicSettings();
|
||||
siteName.value = settings.site_name || 'Sub2API';
|
||||
siteLogo.value = settings.site_logo || '';
|
||||
siteSubtitle.value = settings.site_subtitle || 'Subscription to API Conversion Platform';
|
||||
const settings = await getPublicSettings()
|
||||
siteName.value = settings.site_name || 'Sub2API'
|
||||
siteLogo.value = settings.site_logo || ''
|
||||
siteSubtitle.value = settings.site_subtitle || 'Subscription to API Conversion Platform'
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error);
|
||||
console.error('Failed to load public settings:', error)
|
||||
}
|
||||
});
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -8,31 +8,31 @@
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- Stats Cards -->
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<div class="rounded-lg bg-white p-6 shadow">
|
||||
<div class="text-sm text-gray-600">API Keys</div>
|
||||
<div class="text-2xl font-bold text-gray-900">5</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<div class="rounded-lg bg-white p-6 shadow">
|
||||
<div class="text-sm text-gray-600">Total Usage</div>
|
||||
<div class="text-2xl font-bold text-gray-900">1,234</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<div class="rounded-lg bg-white p-6 shadow">
|
||||
<div class="text-sm text-gray-600">Balance</div>
|
||||
<div class="text-2xl font-bold text-indigo-600">${{ balance }}</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<div class="rounded-lg bg-white p-6 shadow">
|
||||
<div class="text-sm text-gray-600">Status</div>
|
||||
<div class="text-2xl font-bold text-green-600">Active</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<h2 class="text-xl font-semibold mb-4">Recent Activity</h2>
|
||||
<div class="rounded-lg bg-white p-6 shadow">
|
||||
<h2 class="mb-4 text-xl font-semibold">Recent Activity</h2>
|
||||
<p class="text-gray-600">No recent activity</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,12 +40,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { AppLayout } from '@/components/layout';
|
||||
import { useAuthStore } from '@/stores';
|
||||
import { computed } from 'vue'
|
||||
import { AppLayout } from '@/components/layout'
|
||||
import { useAuthStore } from '@/stores'
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
|
||||
const authStore = useAuthStore()
|
||||
const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00')
|
||||
</script>
|
||||
```
|
||||
|
||||
@@ -56,11 +56,11 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
|
||||
```vue
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6">Welcome Back</h2>
|
||||
<h2 class="mb-6 text-2xl font-bold text-gray-900">Welcome Back</h2>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label for="username" class="mb-1 block text-sm font-medium text-gray-700">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
@@ -68,13 +68,13 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
|
||||
v-model="form.username"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
class="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-transparent focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="Enter your username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label for="password" class="mb-1 block text-sm font-medium text-gray-700">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
@@ -82,7 +82,7 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
class="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-transparent focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
</div>
|
||||
@@ -90,7 +90,7 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full bg-indigo-600 text-white py-2 px-4 rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
class="w-full rounded-lg bg-indigo-600 px-4 py-2 text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{{ loading ? 'Logging in...' : 'Login' }}
|
||||
</button>
|
||||
@@ -99,7 +99,7 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
|
||||
<template #footer>
|
||||
<p class="text-gray-600">
|
||||
Don't have an account?
|
||||
<router-link to="/register" class="text-indigo-600 hover:underline font-medium">
|
||||
<router-link to="/register" class="font-medium text-indigo-600 hover:underline">
|
||||
Sign up
|
||||
</router-link>
|
||||
</p>
|
||||
@@ -108,32 +108,32 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { AuthLayout } from '@/components/layout';
|
||||
import { useAuthStore, useAppStore } from '@/stores';
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const appStore = useAppStore();
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const form = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
password: ''
|
||||
})
|
||||
|
||||
const loading = ref(false);
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleSubmit() {
|
||||
loading.value = true;
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.login(form.value);
|
||||
appStore.showSuccess('Login successful!');
|
||||
await router.push('/dashboard');
|
||||
await authStore.login(form.value)
|
||||
appStore.showSuccess('Login successful!')
|
||||
await router.push('/dashboard')
|
||||
} catch (error) {
|
||||
appStore.showError('Invalid username or password');
|
||||
appStore.showError('Invalid username or password')
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -152,42 +152,42 @@ async function handleSubmit() {
|
||||
<h1 class="text-3xl font-bold text-gray-900">API Keys</h1>
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
class="rounded-lg bg-indigo-600 px-4 py-2 text-white transition-colors hover:bg-indigo-700"
|
||||
>
|
||||
Create New Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- API Keys List -->
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="overflow-hidden rounded-lg bg-white shadow">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Name
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Key
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">Name</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">Key</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
Status
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
Created
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
<th class="px-6 py-3 text-right text-xs font-medium uppercase text-gray-500">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
<tr v-for="key in apiKeys" :key="key.id">
|
||||
<td class="px-6 py-4 whitespace-nowrap">{{ key.name }}</td>
|
||||
<td class="whitespace-nowrap px-6 py-4">{{ key.name }}</td>
|
||||
<td class="px-6 py-4 font-mono text-sm">{{ key.key }}</td>
|
||||
<td class="px-6 py-4">
|
||||
<span
|
||||
class="px-2 py-1 text-xs rounded-full"
|
||||
:class="key.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'"
|
||||
class="rounded-full px-2 py-1 text-xs"
|
||||
:class="
|
||||
key.status === 'active'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
"
|
||||
>
|
||||
{{ key.status }}
|
||||
</span>
|
||||
@@ -196,9 +196,7 @@ async function handleSubmit() {
|
||||
{{ new Date(key.created_at).toLocaleDateString() }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<button class="text-red-600 hover:text-red-800 text-sm">
|
||||
Delete
|
||||
</button>
|
||||
<button class="text-sm text-red-600 hover:text-red-800">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -209,12 +207,12 @@ async function handleSubmit() {
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { AppLayout } from '@/components/layout';
|
||||
import type { ApiKey } from '@/types';
|
||||
import { ref } from 'vue'
|
||||
import { AppLayout } from '@/components/layout'
|
||||
import type { ApiKey } from '@/types'
|
||||
|
||||
const showCreateModal = ref(false);
|
||||
const apiKeys = ref<ApiKey[]>([]);
|
||||
const showCreateModal = ref(false)
|
||||
const apiKeys = ref<ApiKey[]>([])
|
||||
|
||||
// Fetch API keys on mount
|
||||
// fetchApiKeys();
|
||||
@@ -233,34 +231,40 @@ const apiKeys = ref<ApiKey[]>([]);
|
||||
<h1 class="text-3xl font-bold text-gray-900">User Management</h1>
|
||||
<button
|
||||
@click="showCreateUser = true"
|
||||
class="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
class="rounded-lg bg-indigo-600 px-4 py-2 text-white transition-colors hover:bg-indigo-700"
|
||||
>
|
||||
Create User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="rounded-lg bg-white shadow">
|
||||
<div class="p-6">
|
||||
<div class="space-y-4">
|
||||
<div v-for="user in users" :key="user.id" class="flex items-center justify-between border-b pb-4">
|
||||
<div
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
class="flex items-center justify-between border-b pb-4"
|
||||
>
|
||||
<div>
|
||||
<div class="font-medium text-gray-900">{{ user.username }}</div>
|
||||
<div class="text-sm text-gray-500">{{ user.email }}</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<span
|
||||
class="px-2 py-1 text-xs rounded-full"
|
||||
:class="user.role === 'admin' ? 'bg-purple-100 text-purple-800' : 'bg-blue-100 text-blue-800'"
|
||||
class="rounded-full px-2 py-1 text-xs"
|
||||
:class="
|
||||
user.role === 'admin'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
"
|
||||
>
|
||||
{{ user.role }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-700">
|
||||
${{ user.balance.toFixed(2) }}
|
||||
</span>
|
||||
<button class="text-indigo-600 hover:text-indigo-800 text-sm">
|
||||
Edit
|
||||
</button>
|
||||
<button class="text-sm text-indigo-600 hover:text-indigo-800">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -271,12 +275,12 @@ const apiKeys = ref<ApiKey[]>([]);
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { AppLayout } from '@/components/layout';
|
||||
import type { User } from '@/types';
|
||||
import { ref } from 'vue'
|
||||
import { AppLayout } from '@/components/layout'
|
||||
import type { User } from '@/types'
|
||||
|
||||
const showCreateUser = ref(false);
|
||||
const users = ref<User[]>([]);
|
||||
const showCreateUser = ref(false)
|
||||
const users = ref<User[]>([])
|
||||
|
||||
// Fetch users on mount
|
||||
// fetchUsers();
|
||||
@@ -294,36 +298,34 @@ const users = ref<User[]>([]);
|
||||
<h1 class="text-3xl font-bold text-gray-900">Profile Settings</h1>
|
||||
|
||||
<!-- User Info Card -->
|
||||
<div class="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<div class="space-y-4 rounded-lg bg-white p-6 shadow">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Account Information</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<div class="px-3 py-2 bg-gray-50 rounded-lg text-gray-900">
|
||||
<label class="mb-1 block text-sm font-medium text-gray-700"> Username </label>
|
||||
<div class="rounded-lg bg-gray-50 px-3 py-2 text-gray-900">
|
||||
{{ user?.username }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<div class="px-3 py-2 bg-gray-50 rounded-lg text-gray-900">
|
||||
<label class="mb-1 block text-sm font-medium text-gray-700"> Email </label>
|
||||
<div class="rounded-lg bg-gray-50 px-3 py-2 text-gray-900">
|
||||
{{ user?.email }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Role
|
||||
</label>
|
||||
<div class="px-3 py-2 bg-gray-50 rounded-lg">
|
||||
<label class="mb-1 block text-sm font-medium text-gray-700"> Role </label>
|
||||
<div class="rounded-lg bg-gray-50 px-3 py-2">
|
||||
<span
|
||||
class="px-2 py-1 text-xs rounded-full"
|
||||
:class="user?.role === 'admin' ? 'bg-purple-100 text-purple-800' : 'bg-blue-100 text-blue-800'"
|
||||
class="rounded-full px-2 py-1 text-xs"
|
||||
:class="
|
||||
user?.role === 'admin'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
"
|
||||
>
|
||||
{{ user?.role }}
|
||||
</span>
|
||||
@@ -331,10 +333,8 @@ const users = ref<User[]>([]);
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Balance
|
||||
</label>
|
||||
<div class="px-3 py-2 bg-gray-50 rounded-lg text-indigo-600 font-semibold">
|
||||
<label class="mb-1 block text-sm font-medium text-gray-700"> Balance </label>
|
||||
<div class="rounded-lg bg-gray-50 px-3 py-2 font-semibold text-indigo-600">
|
||||
${{ user?.balance.toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -342,12 +342,12 @@ const users = ref<User[]>([]);
|
||||
</div>
|
||||
|
||||
<!-- Change Password Card -->
|
||||
<div class="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<div class="space-y-4 rounded-lg bg-white p-6 shadow">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Change Password</h2>
|
||||
|
||||
<form @submit.prevent="handleChangePassword" class="space-y-4">
|
||||
<div>
|
||||
<label for="old-password" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label for="old-password" class="mb-1 block text-sm font-medium text-gray-700">
|
||||
Current Password
|
||||
</label>
|
||||
<input
|
||||
@@ -355,12 +355,12 @@ const users = ref<User[]>([]);
|
||||
v-model="passwordForm.old_password"
|
||||
type="password"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full rounded-lg border border-gray-300 px-3 py-2 focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="new-password" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label for="new-password" class="mb-1 block text-sm font-medium text-gray-700">
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
@@ -368,13 +368,13 @@ const users = ref<User[]>([]);
|
||||
v-model="passwordForm.new_password"
|
||||
type="password"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full rounded-lg border border-gray-300 px-3 py-2 focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
class="rounded-lg bg-indigo-600 px-4 py-2 text-white transition-colors hover:bg-indigo-700"
|
||||
>
|
||||
Update Password
|
||||
</button>
|
||||
@@ -385,27 +385,27 @@ const users = ref<User[]>([]);
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { AppLayout } from '@/components/layout';
|
||||
import { useAuthStore, useAppStore } from '@/stores';
|
||||
import { ref, computed } from 'vue'
|
||||
import { AppLayout } from '@/components/layout'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const appStore = useAppStore();
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const user = computed(() => authStore.user);
|
||||
const user = computed(() => authStore.user)
|
||||
|
||||
const passwordForm = ref({
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
});
|
||||
new_password: ''
|
||||
})
|
||||
|
||||
async function handleChangePassword() {
|
||||
try {
|
||||
// await changePasswordAPI(passwordForm.value);
|
||||
appStore.showSuccess('Password updated successfully!');
|
||||
passwordForm.value = { old_password: '', new_password: '' };
|
||||
appStore.showSuccess('Password updated successfully!')
|
||||
passwordForm.value = { old_password: '', new_password: '' }
|
||||
} catch (error) {
|
||||
appStore.showError('Failed to update password');
|
||||
appStore.showError('Failed to update password')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -6,20 +6,20 @@
|
||||
|
||||
```typescript
|
||||
// In your view files
|
||||
import { AppLayout, AuthLayout } from '@/components/layout';
|
||||
import { AppLayout, AuthLayout } from '@/components/layout'
|
||||
```
|
||||
|
||||
### 2. Use in Routes
|
||||
|
||||
```typescript
|
||||
// src/router/index.ts
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
// Views
|
||||
import DashboardView from '@/views/DashboardView.vue';
|
||||
import LoginView from '@/views/auth/LoginView.vue';
|
||||
import RegisterView from '@/views/auth/RegisterView.vue';
|
||||
import DashboardView from '@/views/DashboardView.vue'
|
||||
import LoginView from '@/views/auth/LoginView.vue'
|
||||
import RegisterView from '@/views/auth/RegisterView.vue'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
// Auth routes (no layout needed - views use AuthLayout internally)
|
||||
@@ -27,13 +27,13 @@ const routes: RouteRecordRaw[] = [
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: LoginView,
|
||||
meta: { requiresAuth: false },
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: RegisterView,
|
||||
meta: { requiresAuth: false },
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
|
||||
// User routes (use AppLayout)
|
||||
@@ -41,31 +41,31 @@ const routes: RouteRecordRaw[] = [
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: DashboardView,
|
||||
meta: { requiresAuth: true, title: 'Dashboard' },
|
||||
meta: { requiresAuth: true, title: 'Dashboard' }
|
||||
},
|
||||
{
|
||||
path: '/api-keys',
|
||||
name: 'ApiKeys',
|
||||
component: () => import('@/views/ApiKeysView.vue'),
|
||||
meta: { requiresAuth: true, title: 'API Keys' },
|
||||
meta: { requiresAuth: true, title: 'API Keys' }
|
||||
},
|
||||
{
|
||||
path: '/usage',
|
||||
name: 'Usage',
|
||||
component: () => import('@/views/UsageView.vue'),
|
||||
meta: { requiresAuth: true, title: 'Usage Statistics' },
|
||||
meta: { requiresAuth: true, title: 'Usage Statistics' }
|
||||
},
|
||||
{
|
||||
path: '/redeem',
|
||||
name: 'Redeem',
|
||||
component: () => import('@/views/RedeemView.vue'),
|
||||
meta: { requiresAuth: true, title: 'Redeem Code' },
|
||||
meta: { requiresAuth: true, title: 'Redeem Code' }
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'Profile',
|
||||
component: () => import('@/views/ProfileView.vue'),
|
||||
meta: { requiresAuth: true, title: 'Profile Settings' },
|
||||
meta: { requiresAuth: true, title: 'Profile Settings' }
|
||||
},
|
||||
|
||||
// Admin routes (use AppLayout, admin only)
|
||||
@@ -73,91 +73,91 @@ const routes: RouteRecordRaw[] = [
|
||||
path: '/admin/dashboard',
|
||||
name: 'AdminDashboard',
|
||||
component: () => import('@/views/admin/DashboardView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Admin Dashboard' },
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Admin Dashboard' }
|
||||
},
|
||||
{
|
||||
path: '/admin/users',
|
||||
name: 'AdminUsers',
|
||||
component: () => import('@/views/admin/UsersView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'User Management' },
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'User Management' }
|
||||
},
|
||||
{
|
||||
path: '/admin/groups',
|
||||
name: 'AdminGroups',
|
||||
component: () => import('@/views/admin/GroupsView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Groups' },
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Groups' }
|
||||
},
|
||||
{
|
||||
path: '/admin/accounts',
|
||||
name: 'AdminAccounts',
|
||||
component: () => import('@/views/admin/AccountsView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Accounts' },
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Accounts' }
|
||||
},
|
||||
{
|
||||
path: '/admin/proxies',
|
||||
name: 'AdminProxies',
|
||||
component: () => import('@/views/admin/ProxiesView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Proxies' },
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Proxies' }
|
||||
},
|
||||
{
|
||||
path: '/admin/redeem-codes',
|
||||
name: 'AdminRedeemCodes',
|
||||
component: () => import('@/views/admin/RedeemCodesView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Redeem Codes' },
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Redeem Codes' }
|
||||
},
|
||||
|
||||
// Default redirect
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/dashboard',
|
||||
},
|
||||
];
|
||||
redirect: '/dashboard'
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
||||
routes
|
||||
})
|
||||
|
||||
// Navigation guards
|
||||
router.beforeEach((to, from, next) => {
|
||||
const authStore = useAuthStore();
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
// Redirect to login if not authenticated
|
||||
next('/login');
|
||||
next('/login')
|
||||
} else if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
||||
// Redirect to dashboard if not admin
|
||||
next('/dashboard');
|
||||
next('/dashboard')
|
||||
} else {
|
||||
next();
|
||||
next()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
export default router;
|
||||
export default router
|
||||
```
|
||||
|
||||
### 3. Initialize Stores in main.ts
|
||||
|
||||
```typescript
|
||||
// src/main.ts
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import './style.css';
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
// Initialize auth state on app startup
|
||||
import { useAuthStore } from '@/stores';
|
||||
const authStore = useAuthStore();
|
||||
authStore.checkAuth();
|
||||
import { useAuthStore } from '@/stores'
|
||||
const authStore = useAuthStore()
|
||||
authStore.checkAuth()
|
||||
|
||||
app.mount('#app');
|
||||
app.mount('#app')
|
||||
```
|
||||
|
||||
### 4. Update App.vue
|
||||
@@ -193,7 +193,7 @@ app.mount('#app');
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AppLayout } from '@/components/layout';
|
||||
import { AppLayout } from '@/components/layout'
|
||||
|
||||
// Your component logic here
|
||||
</script>
|
||||
@@ -205,23 +205,21 @@ import { AppLayout } from '@/components/layout';
|
||||
<!-- src/views/auth/LoginView.vue -->
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6">Login</h2>
|
||||
<h2 class="mb-6 text-2xl font-bold text-gray-900">Login</h2>
|
||||
|
||||
<!-- Your login form here -->
|
||||
|
||||
<template #footer>
|
||||
<p class="text-gray-600">
|
||||
Don't have an account?
|
||||
<router-link to="/register" class="text-indigo-600 hover:underline">
|
||||
Sign up
|
||||
</router-link>
|
||||
<router-link to="/register" class="text-indigo-600 hover:underline"> Sign up </router-link>
|
||||
</p>
|
||||
</template>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AuthLayout } from '@/components/layout';
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
|
||||
// Your login logic here
|
||||
</script>
|
||||
@@ -250,7 +248,7 @@ Replace HTML entity icons with your preferred icon library:
|
||||
<span class="text-lg">📈</span>
|
||||
|
||||
<!-- After (Heroicons example) -->
|
||||
<ChartBarIcon class="w-5 h-5" />
|
||||
<ChartBarIcon class="h-5 w-5" />
|
||||
```
|
||||
|
||||
### Sidebar Customization
|
||||
@@ -261,9 +259,9 @@ Modify navigation items in `AppSidebar.vue`:
|
||||
// Add/remove/modify navigation items
|
||||
const userNavItems = [
|
||||
{ path: '/dashboard', label: 'Dashboard', icon: '📈' },
|
||||
{ path: '/new-page', label: 'New Page', icon: '📄' }, // Add new item
|
||||
{ path: '/new-page', label: 'New Page', icon: '📄' } // Add new item
|
||||
// ...
|
||||
];
|
||||
]
|
||||
```
|
||||
|
||||
### Header Customization
|
||||
@@ -287,10 +285,12 @@ Modify user dropdown in `AppHeader.vue`:
|
||||
## Mobile Responsive Behavior
|
||||
|
||||
### Sidebar
|
||||
|
||||
- **Desktop (md+)**: Always visible, can be collapsed to icon-only view
|
||||
- **Mobile**: Hidden by default, shown via menu toggle in header
|
||||
|
||||
### Header
|
||||
|
||||
- **Desktop**: Shows full user info and balance
|
||||
- **Mobile**: Shows compact view with hamburger menu
|
||||
|
||||
@@ -299,7 +299,7 @@ To improve mobile experience, you can add overlay and transitions:
|
||||
```vue
|
||||
<!-- AppSidebar.vue enhancement for mobile -->
|
||||
<aside
|
||||
class="fixed left-0 top-0 h-screen transition-transform duration-300 z-40"
|
||||
class="fixed left-0 top-0 z-40 h-screen transition-transform duration-300"
|
||||
:class="[
|
||||
sidebarCollapsed ? 'w-16' : 'w-64',
|
||||
// Hide on mobile when collapsed
|
||||
@@ -314,7 +314,7 @@ To improve mobile experience, you can add overlay and transitions:
|
||||
<div
|
||||
v-if="!sidebarCollapsed"
|
||||
@click="toggleSidebar"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-30 md:hidden"
|
||||
class="fixed inset-0 z-30 bg-black bg-opacity-50 md:hidden"
|
||||
></div>
|
||||
```
|
||||
|
||||
@@ -325,9 +325,9 @@ To improve mobile experience, you can add overlay and transitions:
|
||||
### Auth Store Usage
|
||||
|
||||
```typescript
|
||||
import { useAuthStore } from '@/stores';
|
||||
import { useAuthStore } from '@/stores'
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Check if user is authenticated
|
||||
if (authStore.isAuthenticated) {
|
||||
@@ -340,34 +340,34 @@ if (authStore.isAdmin) {
|
||||
}
|
||||
|
||||
// Get current user
|
||||
const user = authStore.user;
|
||||
const user = authStore.user
|
||||
```
|
||||
|
||||
### App Store Usage
|
||||
|
||||
```typescript
|
||||
import { useAppStore } from '@/stores';
|
||||
import { useAppStore } from '@/stores'
|
||||
|
||||
const appStore = useAppStore();
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Toggle sidebar
|
||||
appStore.toggleSidebar();
|
||||
appStore.toggleSidebar()
|
||||
|
||||
// Show notifications
|
||||
appStore.showSuccess('Operation completed!');
|
||||
appStore.showError('Something went wrong');
|
||||
appStore.showInfo('Did you know...');
|
||||
appStore.showWarning('Be careful!');
|
||||
appStore.showSuccess('Operation completed!')
|
||||
appStore.showError('Something went wrong')
|
||||
appStore.showInfo('Did you know...')
|
||||
appStore.showWarning('Be careful!')
|
||||
|
||||
// Loading state
|
||||
appStore.setLoading(true);
|
||||
appStore.setLoading(true)
|
||||
// ... perform operation
|
||||
appStore.setLoading(false);
|
||||
appStore.setLoading(false)
|
||||
|
||||
// Or use helper
|
||||
await appStore.withLoading(async () => {
|
||||
// Your async operation
|
||||
});
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
@@ -388,7 +388,7 @@ To enhance further:
|
||||
<!-- Add skip to main content link -->
|
||||
<a
|
||||
href="#main-content"
|
||||
class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 bg-white px-4 py-2 rounded"
|
||||
class="sr-only rounded bg-white px-4 py-2 focus:not-sr-only focus:absolute focus:left-4 focus:top-4"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
@@ -406,27 +406,27 @@ To enhance further:
|
||||
|
||||
```typescript
|
||||
// AppHeader.test.ts
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import AppHeader from '@/components/layout/AppHeader.vue';
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||
|
||||
describe('AppHeader', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('renders user info when authenticated', () => {
|
||||
const wrapper = mount(AppHeader);
|
||||
const wrapper = mount(AppHeader)
|
||||
// Add assertions
|
||||
});
|
||||
})
|
||||
|
||||
it('shows dropdown when clicked', async () => {
|
||||
const wrapper = mount(AppHeader);
|
||||
await wrapper.find('button').trigger('click');
|
||||
expect(wrapper.find('.dropdown').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
const wrapper = mount(AppHeader)
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(wrapper.find('.dropdown').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
@@ -443,7 +443,7 @@ Layout components are automatically code-split when imported:
|
||||
|
||||
```typescript
|
||||
// This creates a separate chunk for layout components
|
||||
import { AppLayout } from '@/components/layout';
|
||||
import { AppLayout } from '@/components/layout'
|
||||
```
|
||||
|
||||
### Reducing Re-renders
|
||||
@@ -451,7 +451,7 @@ import { AppLayout } from '@/components/layout';
|
||||
Layout components use `computed` refs to prevent unnecessary re-renders:
|
||||
|
||||
```typescript
|
||||
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed);
|
||||
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed)
|
||||
// This only re-renders when sidebarCollapsed changes
|
||||
```
|
||||
|
||||
@@ -460,21 +460,25 @@ const sidebarCollapsed = computed(() => appStore.sidebarCollapsed);
|
||||
## Troubleshooting
|
||||
|
||||
### Sidebar not showing
|
||||
|
||||
- Check if `useAppStore` is properly initialized
|
||||
- Verify Tailwind classes are being processed
|
||||
- Check z-index conflicts with other components
|
||||
|
||||
### Routes not highlighting in sidebar
|
||||
|
||||
- Ensure route paths match exactly
|
||||
- Check `isActive()` function logic
|
||||
- Verify `useRoute()` is working correctly
|
||||
|
||||
### User info not displaying
|
||||
|
||||
- Ensure auth store is initialized with `checkAuth()`
|
||||
- Verify user is logged in
|
||||
- Check localStorage for auth data
|
||||
|
||||
### Mobile menu not working
|
||||
|
||||
- Verify `toggleSidebar()` is called correctly
|
||||
- Check responsive breakpoints (md:)
|
||||
- Test on actual mobile device or browser dev tools
|
||||
|
||||
@@ -5,9 +5,11 @@ Vue 3 layout components for the Sub2API frontend, built with Composition API, Ty
|
||||
## Components
|
||||
|
||||
### 1. AppLayout.vue
|
||||
|
||||
Main application layout with sidebar and header.
|
||||
|
||||
**Usage:**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<AppLayout>
|
||||
@@ -18,11 +20,12 @@ Main application layout with sidebar and header.
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AppLayout } from '@/components/layout';
|
||||
import { AppLayout } from '@/components/layout'
|
||||
</script>
|
||||
```
|
||||
|
||||
**Features:**
|
||||
|
||||
- Responsive sidebar (collapsible)
|
||||
- Fixed header at top
|
||||
- Main content area with slot
|
||||
@@ -31,9 +34,11 @@ import { AppLayout } from '@/components/layout';
|
||||
---
|
||||
|
||||
### 2. AppSidebar.vue
|
||||
|
||||
Navigation sidebar with user and admin sections.
|
||||
|
||||
**Features:**
|
||||
|
||||
- Logo/brand at top
|
||||
- User navigation links:
|
||||
- Dashboard
|
||||
@@ -58,9 +63,11 @@ Navigation sidebar with user and admin sections.
|
||||
---
|
||||
|
||||
### 3. AppHeader.vue
|
||||
|
||||
Top header with user info and actions.
|
||||
|
||||
**Features:**
|
||||
|
||||
- Mobile menu toggle button
|
||||
- Page title (from route meta or slot)
|
||||
- User balance display (desktop only)
|
||||
@@ -72,12 +79,11 @@ Top header with user info and actions.
|
||||
- Responsive design
|
||||
|
||||
**Usage with custom title:**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #title>
|
||||
Custom Page Title
|
||||
</template>
|
||||
<template #title> Custom Page Title </template>
|
||||
|
||||
<!-- Your content -->
|
||||
</AppLayout>
|
||||
@@ -89,14 +95,16 @@ Top header with user info and actions.
|
||||
---
|
||||
|
||||
### 4. AuthLayout.vue
|
||||
|
||||
Simple centered layout for authentication pages (login/register).
|
||||
|
||||
**Usage:**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<!-- Login/Register form content -->
|
||||
<h2 class="text-2xl font-bold mb-6">Login</h2>
|
||||
<h2 class="mb-6 text-2xl font-bold">Login</h2>
|
||||
|
||||
<form @submit.prevent="handleLogin">
|
||||
<!-- Form fields -->
|
||||
@@ -106,16 +114,14 @@ Simple centered layout for authentication pages (login/register).
|
||||
<template #footer>
|
||||
<p>
|
||||
Don't have an account?
|
||||
<router-link to="/register" class="text-indigo-600 hover:underline">
|
||||
Sign up
|
||||
</router-link>
|
||||
<router-link to="/register" class="text-indigo-600 hover:underline"> Sign up </router-link>
|
||||
</p>
|
||||
</template>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AuthLayout } from '@/components/layout';
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
|
||||
function handleLogin() {
|
||||
// Login logic
|
||||
@@ -124,6 +130,7 @@ function handleLogin() {
|
||||
```
|
||||
|
||||
**Features:**
|
||||
|
||||
- Centered card container
|
||||
- Gradient background
|
||||
- Logo/brand at top
|
||||
@@ -143,15 +150,15 @@ const routes = [
|
||||
{
|
||||
path: '/dashboard',
|
||||
component: DashboardView,
|
||||
meta: { title: 'Dashboard' },
|
||||
meta: { title: 'Dashboard' }
|
||||
},
|
||||
{
|
||||
path: '/api-keys',
|
||||
component: ApiKeysView,
|
||||
meta: { title: 'API Keys' },
|
||||
},
|
||||
meta: { title: 'API Keys' }
|
||||
}
|
||||
// ...
|
||||
];
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
@@ -173,10 +180,7 @@ All components use TailwindCSS utility classes. Make sure your `tailwind.config.
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{vue,js,ts,jsx,tsx}',
|
||||
],
|
||||
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}']
|
||||
// ...
|
||||
}
|
||||
```
|
||||
@@ -186,6 +190,7 @@ module.exports = {
|
||||
## Icons
|
||||
|
||||
Components use HTML entity icons for simplicity:
|
||||
|
||||
- 📈 Chart (Dashboard)
|
||||
- 🔑 Key (API Keys)
|
||||
- 📊 Bar Chart (Usage)
|
||||
@@ -205,6 +210,7 @@ You can replace these with your preferred icon library (e.g., Heroicons, Font Aw
|
||||
## Mobile Responsiveness
|
||||
|
||||
All components are fully responsive:
|
||||
|
||||
- **AppSidebar**: Fixed positioning on desktop, hidden by default on mobile
|
||||
- **AppHeader**: Shows mobile menu toggle on small screens, hides balance display
|
||||
- **AuthLayout**: Adapts padding and card size for mobile devices
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Export all layout components for easy importing
|
||||
*/
|
||||
|
||||
export { default as AppLayout } from './AppLayout.vue';
|
||||
export { default as AppSidebar } from './AppSidebar.vue';
|
||||
export { default as AppHeader } from './AppHeader.vue';
|
||||
export { default as AuthLayout } from './AuthLayout.vue';
|
||||
export { default as AppLayout } from './AppLayout.vue'
|
||||
export { default as AppSidebar } from './AppSidebar.vue'
|
||||
export { default as AppHeader } from './AppHeader.vue'
|
||||
export { default as AuthLayout } from './AuthLayout.vue'
|
||||
|
||||
Reference in New Issue
Block a user