Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fef30e9069 | ||
|
|
c2d4b9da27 | ||
|
|
691bf60b9f | ||
|
|
55287b026b | ||
|
|
f031dddc4c | ||
|
|
351b54d941 | ||
|
|
9c0dca8f13 | ||
|
|
7f26585649 | ||
|
|
0fe0b444c2 | ||
|
|
5132e3eb2f | ||
|
|
4c33975d1b | ||
|
|
8da82f2735 | ||
|
|
0dcd1cfcc4 | ||
|
|
6968631b86 | ||
|
|
85acc051b1 | ||
|
|
d8d7e3e38a | ||
|
|
88b51a6faa | ||
|
|
fd0fc20b7d | ||
|
|
d8c262ff5f |
+7
-2
@@ -1,5 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import Toast from './components/ui/toast/Toast.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<NuxtLayout>
|
||||
<Toast />
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
--text-tertiary: #6e7681;
|
||||
--text-link: #2f81f7;
|
||||
--text-link-hover: #539bf5;
|
||||
--text-slate: #57606a;
|
||||
|
||||
--border-primary: #30363d;
|
||||
--border-hover: #8d96a0;
|
||||
@@ -56,7 +57,6 @@ body {
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup>
|
||||
const store = useNotificationStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed bottom-6 right-6 z-[9999] flex flex-col gap-3 w-80">
|
||||
<TransitionGroup name="toast">
|
||||
<div v-for="toast in store.notifications" :key="toast.id" :class="[
|
||||
'p-4 rounded-lg shadow-xl border text-sm font-medium transition-all duration-300',
|
||||
toast.type === 'error'
|
||||
? 'bg-red-50 border-red-200 text-red-800'
|
||||
: 'bg-green-50 border-green-200 text-green-800'
|
||||
]">
|
||||
<div class="flex justify-between items-center">
|
||||
<span>{{ toast.message }}</span>
|
||||
<button class="ml-4 opacity-50 hover:opacity-100" @click="store.remove(toast.id)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toast-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(50px);
|
||||
}
|
||||
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<nav
|
||||
class="sticky top-0 z-50 w-full border-b border-slate-200/80 dark:border-slate-800/80 bg-white/80 dark:bg-slate-900 backdrop-blur-xl transition-colors duration-300">
|
||||
<div class="w-full px-6 md:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
|
||||
<nuxt-link to="/" class="flex items-center gap-3 group flex-shrink-0">
|
||||
<div class="relative">
|
||||
<div class="absolute -inset-1 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-lg blur opacity-25 group-hover:opacity-40 transition duration-300"></div>
|
||||
<div
|
||||
class="relative w-10 h-10 rounded-lg bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center text-white font-bold text-lg shadow-lg shadow-blue-500/30">
|
||||
A
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xl font-bold text-slate-900 dark:text-white tracking-tight hidden md:block">
|
||||
Academic<span class="bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">Sys</span>
|
||||
</span>
|
||||
</nuxt-link>
|
||||
|
||||
<div class="flex items-center gap-4 md:gap-8">
|
||||
|
||||
<div class="hidden md:flex items-center gap-1">
|
||||
<nuxt-link v-for="link in navLinks" :key="link.path" :to="link.path"
|
||||
class="px-4 py-2 rounded-lg text-sm font-semibold text-slate-600 dark:text-slate-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-slate-50 dark:hover:bg-slate-800 transition-all duration-200"
|
||||
active-class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 text-blue-600 dark:text-blue-400 shadow-sm">
|
||||
{{ link.name }}
|
||||
</nuxt-link>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:block h-6 w-px bg-slate-200 dark:bg-slate-700 mx-2"></div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
<button @click="toggleDark"
|
||||
class="p-2.5 rounded-full text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800 hover:text-slate-700 dark:hover:text-slate-300 transition-all focus:outline-none focus:ring-2 focus:ring-blue-500/50">
|
||||
<svg v-if="!isDark" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div v-if="authStore?.token" class="relative" ref="userMenuRef">
|
||||
<button @click.stop="isUserMenuOpen = !isUserMenuOpen"
|
||||
class="flex items-center gap-3 pl-3 pr-2 py-1.5 rounded-full border border-slate-200 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800 hover:border-slate-300 dark:hover:border-slate-600 transition-all focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
||||
:class="{ 'bg-slate-50 dark:bg-slate-800 border-slate-300 dark:border-slate-600': isUserMenuOpen }">
|
||||
<span
|
||||
class="text-sm font-semibold text-slate-700 dark:text-slate-200 hidden sm:block">
|
||||
{{ authStore.user.name.split(' ')[0] }}
|
||||
</span>
|
||||
<div class="relative">
|
||||
<div class="absolute -inset-0.5 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-full blur opacity-30"></div>
|
||||
<img :src="authStore.user.avatarUrl || `https://ui-avatars.com/api/?name=${authStore.user.name}&background=0D8ABC&color=fff`"
|
||||
alt="Avatar"
|
||||
class="relative w-9 h-9 rounded-full border-2 border-white dark:border-slate-800 object-cover shadow-md">
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-slate-400 transition-transform duration-200" :class="{ 'rotate-180': isUserMenuOpen }"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<transition enter-active-class="transition ease-out duration-100"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95">
|
||||
<div v-if="isUserMenuOpen"
|
||||
class="absolute right-0 mt-2 w-64 bg-white dark:bg-slate-900 rounded-xl shadow-xl border border-slate-200 dark:border-slate-800 py-2 focus:outline-none z-50 ring-1 ring-black/5 dark:ring-white/5">
|
||||
<div class="px-4 py-3 border-b border-slate-100 dark:border-slate-800 mb-1">
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 font-semibold uppercase tracking-wide">Signed in as
|
||||
</p>
|
||||
<p class="text-sm font-bold text-slate-900 dark:text-white truncate mt-1">{{
|
||||
authStore.user.email }}</p>
|
||||
</div>
|
||||
<button @click="navigateTo('/profile')"
|
||||
class="w-full text-left px-4 py-2.5 text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800 flex items-center gap-3 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
Your Profile
|
||||
</button>
|
||||
<div v-if="authStore.user.role == 'ADMINISTRATOR'">
|
||||
<button @click="navigateTo('/admin/users')"
|
||||
class="w-full text-left px-4 py-2.5 text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800 flex items-center gap-3 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-10c1.657 0 3 .895 3 2s-1.343 2-3 2-3 .895-3 2 1.343 2 3 2m0-10v10m0 0l3-3m-3 3l-3-3" />
|
||||
</svg>
|
||||
Admin Dashboard
|
||||
</button>
|
||||
</div>
|
||||
<div class="border-t border-slate-100 dark:border-slate-800 mt-1 pt-1">
|
||||
<button @click="handleLogout"
|
||||
class="w-full text-left px-4 py-2.5 text-sm font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-3 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<nuxt-link to="/auth/login"
|
||||
class="hidden sm:inline-flex items-center justify-center px-6 py-2.5 text-sm font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 rounded-lg shadow-lg shadow-blue-500/30 hover:shadow-blue-500/40 transition-all duration-200 transform hover:-translate-y-0.5 active:translate-y-0">
|
||||
Sign In
|
||||
</nuxt-link>
|
||||
</template>
|
||||
|
||||
<button @click="isMobileMenuOpen = !isMobileMenuOpen"
|
||||
class="md:hidden p-2 rounded-lg text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
|
||||
<svg v-if="!isMobileMenuOpen" class="w-7 h-7" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
<svg v-else class="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="transform -translate-y-4 opacity-0"
|
||||
enter-to-class="transform translate-y-0 opacity-100"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="transform translate-y-0 opacity-100"
|
||||
leave-to-class="transform -translate-y-4 opacity-0">
|
||||
<div v-if="isMobileMenuOpen"
|
||||
class="md:hidden absolute left-0 right-0 top-16 border-t border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-xl">
|
||||
<div class="px-4 pt-2 pb-6 space-y-2">
|
||||
<nuxt-link v-for="link in navLinks" :key="link.path" :to="link.path"
|
||||
class="block px-4 py-3 rounded-lg text-base font-semibold text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
active-class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/30 dark:to-indigo-900/30 text-blue-600 dark:text-blue-400"
|
||||
@click="isMobileMenuOpen = false">
|
||||
{{ link.name }}
|
||||
</nuxt-link>
|
||||
<template v-if="!authStore?.token">
|
||||
<nuxt-link to="/auth/login"
|
||||
class="block w-full text-center mt-6 px-5 py-3 text-base font-bold text-white bg-gradient-to-r from-blue-600 to-indigo-600 rounded-lg shadow-lg shadow-blue-500/30">
|
||||
Sign In
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="w-full min-h-screen bg-slate-50 dark:bg-slate-950">
|
||||
<slot />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useAuthStore } from "~/stores/auth-store.js";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
// State
|
||||
const isDark = ref(false);
|
||||
const isUserMenuOpen = ref(false);
|
||||
const isMobileMenuOpen = ref(false);
|
||||
const userMenuRef = ref(null);
|
||||
|
||||
// Toggle Dark Mode
|
||||
function toggleDark() {
|
||||
isDark.value = !isDark.value;
|
||||
if (isDark.value) {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.theme = 'dark';
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.theme = 'light';
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation Links
|
||||
const navLinks = [
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Profile', path: '/profile' },
|
||||
];
|
||||
|
||||
function handleLogout() {
|
||||
isUserMenuOpen.value = false;
|
||||
isMobileMenuOpen.value = false;
|
||||
authStore.logout();
|
||||
router.push("/auth/login");
|
||||
}
|
||||
|
||||
function navigateTo(path) {
|
||||
isUserMenuOpen.value = false;
|
||||
isMobileMenuOpen.value = false;
|
||||
router.push(path);
|
||||
}
|
||||
|
||||
function closeDropdowns(event) {
|
||||
if (userMenuRef.value && !userMenuRef.value.contains(event.target)) {
|
||||
isUserMenuOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Initialize Theme
|
||||
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
isDark.value = true;
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
|
||||
// Click listener for closing dropdowns
|
||||
window.addEventListener('click', closeDropdowns);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('click', closeDropdowns);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export default defineNuxtRouteMiddleware((to, from) => {
|
||||
const authStore = useAuthStore();
|
||||
const isLoggedIn = !!(authStore.user || authStore.token);
|
||||
const mustChangePassword = authStore.user?.mustChangePassword === true;
|
||||
|
||||
const isAuthPage = to.path.startsWith('/auth');
|
||||
const isUpdatePasswordPage = to.path === '/profile/update-password';
|
||||
|
||||
if (!isLoggedIn && !isAuthPage) {
|
||||
return navigateTo('/auth/login');
|
||||
}
|
||||
|
||||
if (isLoggedIn && mustChangePassword && !isUpdatePasswordPage) {
|
||||
return navigateTo('/profile/update-password');
|
||||
}
|
||||
|
||||
if (isLoggedIn && isAuthPage) {
|
||||
return navigateTo('/');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,747 @@
|
||||
<template>
|
||||
<title>Team Management - Reddit for Academic Papers</title>
|
||||
<div
|
||||
class="min-h-screen bg-slate-50 dark:bg-slate-950 p-4 md:p-8 font-sans text-slate-900 dark:text-slate-200 transition-colors duration-300">
|
||||
|
||||
<div class="max-w-7xl mx-auto space-y-8">
|
||||
|
||||
<!-- Header Section -->
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-6">
|
||||
<div>
|
||||
<h1 class="text-3xl md:text-4xl font-extrabold tracking-tight text-slate-900 dark:text-white mb-2">
|
||||
Team Management
|
||||
</h1>
|
||||
<p class="text-slate-600 dark:text-slate-400">
|
||||
Manage user accounts, roles, and system access permissions
|
||||
</p>
|
||||
</div>
|
||||
<button @click="openCreateModal"
|
||||
class="group relative inline-flex items-center gap-2.5 px-6 py-3 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 text-white font-semibold rounded-xl shadow-lg shadow-blue-500/30 hover:shadow-blue-500/50 transform hover:-translate-y-0.5 active:translate-y-0 transition-all duration-200">
|
||||
<div
|
||||
class="absolute -inset-0.5 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-xl blur opacity-30 group-hover:opacity-50 transition duration-200">
|
||||
</div>
|
||||
<svg class="relative w-5 h-5 transition-transform group-hover:rotate-90 duration-300" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span class="relative">Add Team Member</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="relative group">
|
||||
<div
|
||||
class="absolute -inset-0.5 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-2xl blur opacity-0 group-hover:opacity-20 transition duration-300">
|
||||
</div>
|
||||
<div
|
||||
class="relative bg-white dark:bg-slate-900 p-6 rounded-2xl border border-slate-200 dark:border-slate-800 shadow-sm hover:shadow-md transition-all duration-300">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative">
|
||||
<div class="absolute -inset-1 bg-blue-500/20 rounded-xl blur"></div>
|
||||
<div
|
||||
class="relative p-3 rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 text-white shadow-lg shadow-blue-500/30">
|
||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="text-3xl font-bold bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
|
||||
{{ users.length }}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
Total Users</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative group">
|
||||
<div
|
||||
class="absolute -inset-0.5 bg-gradient-to-r from-emerald-600 to-teal-600 rounded-2xl blur opacity-0 group-hover:opacity-20 transition duration-300">
|
||||
</div>
|
||||
<div
|
||||
class="relative bg-white dark:bg-slate-900 p-6 rounded-2xl border border-slate-200 dark:border-slate-800 shadow-sm hover:shadow-md transition-all duration-300">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative">
|
||||
<div class="absolute -inset-1 bg-emerald-500/20 rounded-xl blur"></div>
|
||||
<div
|
||||
class="relative p-3 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 text-white shadow-lg shadow-emerald-500/30">
|
||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">
|
||||
{{ activeCount }}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
Active Accounts</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative group">
|
||||
<div
|
||||
class="absolute -inset-0.5 bg-gradient-to-r from-purple-600 to-pink-600 rounded-2xl blur opacity-0 group-hover:opacity-20 transition duration-300">
|
||||
</div>
|
||||
<div
|
||||
class="relative bg-white dark:bg-slate-900 p-6 rounded-2xl border border-slate-200 dark:border-slate-800 shadow-sm hover:shadow-md transition-all duration-300">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative">
|
||||
<div class="absolute -inset-1 bg-purple-500/20 rounded-xl blur"></div>
|
||||
<div
|
||||
class="relative p-3 rounded-xl bg-gradient-to-br from-purple-500 to-purple-600 text-white shadow-lg shadow-purple-500/30">
|
||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="text-3xl font-bold bg-gradient-to-r from-purple-600 to-pink-600 bg-clip-text text-transparent">
|
||||
{{ adminCount }}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
Administrators</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="relative group">
|
||||
<div
|
||||
class="absolute -inset-0.5 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-2xl blur opacity-0 group-hover:opacity-10 transition duration-300">
|
||||
</div>
|
||||
<div
|
||||
class="relative bg-white dark:bg-slate-900 p-1.5 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 hover:border-blue-300 dark:hover:border-blue-700/50 transition-all">
|
||||
<div class="flex items-center gap-3 px-4">
|
||||
<svg class="w-5 h-5 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input v-model="searchQuery" type="text" placeholder="Search by name or email..."
|
||||
class="flex-1 py-3 bg-transparent border-none text-slate-900 dark:text-white placeholder-slate-400 focus:outline-none text-base">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="relative group">
|
||||
<div
|
||||
class="absolute -inset-0.5 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-2xl blur opacity-0 group-hover:opacity-5 transition duration-500">
|
||||
</div>
|
||||
<div
|
||||
class="relative bg-white dark:bg-slate-900 rounded-2xl shadow-lg border border-slate-200 dark:border-slate-800 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr
|
||||
class="bg-gradient-to-r from-slate-50 to-slate-100/50 dark:from-slate-800/50 dark:to-slate-800/30 border-b border-slate-200 dark:border-slate-800">
|
||||
<th
|
||||
class="p-6 pl-8 text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
User</th>
|
||||
<th
|
||||
class="p-6 text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
Role</th>
|
||||
<th
|
||||
class="p-6 text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
Status</th>
|
||||
<th
|
||||
class="p-6 pr-8 text-right text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
<tr v-if="loading" class="animate-pulse">
|
||||
<td colspan="4" class="p-12 text-center text-slate-400">Loading users...</td>
|
||||
</tr>
|
||||
<tr v-else-if="filteredUsers.length === 0">
|
||||
<td colspan="4" class="p-12 text-center">
|
||||
<div class="flex flex-col items-center justify-center text-slate-500">
|
||||
<svg class="w-12 h-12 text-slate-300 mb-3" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
<p>No users found matching your search.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="user in filteredUsers" :key="user.id"
|
||||
class="group/row hover:bg-slate-50/50 dark:hover:bg-slate-800/30 transition-colors duration-150 cursor-pointer"
|
||||
@click="openUserDetails(user)">
|
||||
<td class="p-6 pl-8">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative">
|
||||
<div
|
||||
class="absolute -inset-1 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-full blur opacity-0 group-hover/row:opacity-30 transition duration-300">
|
||||
</div>
|
||||
<div
|
||||
class="relative w-11 h-11 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 text-white flex items-center justify-center font-bold text-sm shadow-md shadow-blue-500/20">
|
||||
{{ getInitials(user.name) }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="font-semibold text-slate-900 dark:text-white group-hover/row:text-blue-600 dark:group-hover/row:text-blue-400 transition-colors">
|
||||
{{ user.name }}
|
||||
</div>
|
||||
<div class="text-sm text-slate-500 dark:text-slate-400">{{ user.email }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-6">
|
||||
<span
|
||||
class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-bold border capitalize"
|
||||
:class="{
|
||||
'bg-purple-100 border-purple-200 text-purple-700 dark:bg-purple-900/20 dark:border-purple-800 dark:text-purple-300': user.role === 'ADMIN',
|
||||
'bg-blue-100 border-blue-200 text-blue-700 dark:bg-blue-900/20 dark:border-blue-800 dark:text-blue-300': user.role === 'TEACHER',
|
||||
'bg-emerald-100 border-emerald-200 text-emerald-700 dark:bg-emerald-900/20 dark:border-emerald-800 dark:text-emerald-300': user.role === 'RESEARCHER',
|
||||
'bg-amber-100 border-amber-200 text-amber-700 dark:bg-amber-900/20 dark:border-amber-800 dark:text-amber-300': user.role === 'COLLABORATOR',
|
||||
'bg-slate-100 border-slate-200 text-slate-700 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300': user.role === 'STUDENT'
|
||||
}">
|
||||
{{ user.role.toLowerCase() }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-6">
|
||||
<span
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-semibold shadow-sm border"
|
||||
:class="user.is_active ? 'bg-emerald-100 border-emerald-200 text-emerald-700 dark:bg-emerald-900/30 dark:border-emerald-800 dark:text-emerald-400' : 'bg-red-100 border-red-200 text-red-700 dark:bg-red-900/30 dark:border-red-800 dark:text-red-400'">
|
||||
<span class="w-1.5 h-1.5 rounded-full"
|
||||
:class="user.is_active ? 'bg-emerald-500' : 'bg-red-500'"></span>
|
||||
{{ user.is_active ? 'Active' : 'Suspended' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-6 pr-8 text-right">
|
||||
<div
|
||||
class="flex items-center justify-end gap-2 opacity-0 group-hover/row:opacity-100 transition-opacity duration-200">
|
||||
<button @click.stop="openEditModal(user)"
|
||||
class="p-2.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-all transform hover:scale-110 active:scale-95"
|
||||
title="Edit">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button @click.stop="confirmDelete(user)"
|
||||
class="p-2.5 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-all transform hover:scale-110 active:scale-95"
|
||||
title="Delete">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<Transition enter-active-class="duration-300 ease-out" enter-from-class="opacity-0" enter-to-class="opacity-100"
|
||||
leave-active-class="duration-200 ease-in" leave-from-class="opacity-100" leave-to-class="opacity-0">
|
||||
<div v-if="showModal" class="fixed inset-0 z-[100] overflow-y-auto" role="dialog" aria-modal="true">
|
||||
<div class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity" @click="closeModal">
|
||||
</div>
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<Transition enter-active-class="duration-300 ease-out"
|
||||
enter-from-class="opacity-0 scale-95 translate-y-4"
|
||||
enter-to-class="opacity-100 scale-100 translate-y-0" leave-active-class="duration-200 ease-in"
|
||||
leave-from-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-to-class="opacity-0 scale-95 translate-y-4">
|
||||
<div class="relative w-full max-w-2xl">
|
||||
<div
|
||||
class="absolute -inset-1 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-3xl blur opacity-20">
|
||||
</div>
|
||||
<div
|
||||
class="relative overflow-hidden rounded-2xl bg-white dark:bg-slate-900 shadow-2xl border border-slate-200 dark:border-slate-800">
|
||||
|
||||
<div
|
||||
class="relative bg-gradient-to-r from-slate-50 to-slate-100/50 dark:from-slate-800/50 dark:to-slate-800/30 px-8 py-6 border-b border-slate-200 dark:border-slate-800 flex justify-between items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-1.5 h-8 bg-gradient-to-b from-blue-600 to-indigo-600 rounded-full">
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-slate-900 dark:text-white">{{ isEditing ?
|
||||
'Edit User Details' : 'Create New User' }}</h3>
|
||||
</div>
|
||||
<button @click="closeModal"
|
||||
class="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"><svg
|
||||
class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12" />
|
||||
</svg></button>
|
||||
</div>
|
||||
|
||||
<div class="p-8 space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label
|
||||
class="block text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider mb-2">Full
|
||||
Name</label>
|
||||
<input v-model="formData.name" :disabled="isEditing" type="text"
|
||||
class="w-full px-4 py-3 rounded-xl border-2 border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white disabled:opacity-60 disabled:cursor-not-allowed transition-all"
|
||||
placeholder="John Doe">
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider mb-2">Email
|
||||
Address</label>
|
||||
<input v-model="formData.email" :disabled="isEditing" type="email"
|
||||
class="w-full px-4 py-3 rounded-xl border-2 border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white disabled:opacity-60 disabled:cursor-not-allowed transition-all"
|
||||
placeholder="[email protected]">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label
|
||||
class="block text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider mb-2">Role</label>
|
||||
<div class="relative">
|
||||
<select v-model="formData.role"
|
||||
class="w-full appearance-none px-4 py-3 rounded-xl border-2 border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-all cursor-pointer">
|
||||
<option value="STUDENT">Student</option>
|
||||
<option value="TEACHER">Teacher</option>
|
||||
<option value="COLLABORATOR">Collaborator</option>
|
||||
<option value="RESEARCHER">Researcher</option>
|
||||
<option value="ADMIN">Admin</option>
|
||||
</select>
|
||||
<svg class="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400 pointer-events-none"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isEditing">
|
||||
<label
|
||||
class="block text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider mb-2">Password</label>
|
||||
<input v-model="formData.password" type="password"
|
||||
class="w-full px-4 py-3 rounded-xl border-2 border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-all"
|
||||
placeholder="••••••••">
|
||||
</div>
|
||||
|
||||
<div v-if="isEditing">
|
||||
<label
|
||||
class="block text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider mb-2">Account
|
||||
Status</label>
|
||||
<label
|
||||
class="flex items-center gap-3 p-4 rounded-xl border-2 border-slate-200 dark:border-slate-700 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-all h-[52px]">
|
||||
<div class="relative inline-flex items-center">
|
||||
<input v-model="formData.is_active" type="checkbox"
|
||||
class="sr-only peer">
|
||||
<div
|
||||
class="w-11 h-6 bg-slate-200 rounded-full peer peer-checked:bg-blue-600 dark:bg-slate-700 peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all">
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm font-semibold transition-colors"
|
||||
:class="formData.is_active ? 'text-slate-900 dark:text-white' : 'text-slate-500'">{{
|
||||
formData.is_active ? 'Active' : 'Suspended' }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditing" class="pt-2 border-t border-slate-100 dark:border-slate-800">
|
||||
<button type="button" @click="sendPasswordRecovery"
|
||||
class="text-sm text-red-500 hover:text-red-600 font-semibold flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
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>
|
||||
Send Password Recovery
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-gradient-to-r from-slate-50 to-slate-100/50 dark:from-slate-800/50 dark:to-slate-800/30 px-8 py-6 border-t border-slate-200 dark:border-slate-800 flex flex-row-reverse gap-3">
|
||||
<button @click="handleSubmit"
|
||||
class="px-8 py-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-xl shadow-lg transition-all">{{
|
||||
isEditing ? 'Save Changes' : 'Create Account' }}</button>
|
||||
<button @click="closeModal"
|
||||
class="px-8 py-3 bg-white dark:bg-slate-800 border-2 border-slate-200 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 font-semibold rounded-xl transition-all">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- User Details Modal -->
|
||||
<Transition enter-active-class="duration-300 ease-out" enter-from-class="opacity-0" enter-to-class="opacity-100"
|
||||
leave-active-class="duration-200 ease-in" leave-from-class="opacity-100" leave-to-class="opacity-0">
|
||||
<div v-if="showDetailsModal" class="fixed inset-0 z-[100] overflow-y-auto" role="dialog" aria-modal="true">
|
||||
<div class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity"
|
||||
@click="closeDetailsModal"></div>
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<Transition enter-active-class="duration-300 ease-out"
|
||||
enter-from-class="opacity-0 scale-95 translate-y-4"
|
||||
enter-to-class="opacity-100 scale-100 translate-y-0" leave-active-class="duration-200 ease-in"
|
||||
leave-from-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-to-class="opacity-0 scale-95 translate-y-4">
|
||||
|
||||
<div v-if="selectedUser"
|
||||
class="relative w-full max-w-4xl max-h-[85vh] flex flex-col bg-white dark:bg-slate-900 rounded-2xl shadow-2xl border border-slate-200 dark:border-slate-800 overflow-hidden">
|
||||
|
||||
<div
|
||||
class="flex-none relative bg-gradient-to-r from-slate-50 to-slate-100/50 dark:from-slate-800/50 dark:to-slate-800/30 px-8 py-6 border-b border-slate-200 dark:border-slate-800">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="w-16 h-16 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 text-white flex items-center justify-center font-bold text-xl">
|
||||
{{ getInitials(selectedUser.name) }}</div>
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold text-slate-900 dark:text-white">{{
|
||||
selectedUser.name }}</h3>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">{{
|
||||
selectedUser.email }}</p>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-bold border capitalize bg-slate-100 dark:bg-slate-800 dark:text-slate-300 border-slate-200 dark:border-slate-700">{{
|
||||
selectedUser.role.toLowerCase() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="closeDetailsModal"
|
||||
class="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"><svg
|
||||
class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12" />
|
||||
</svg></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h4
|
||||
class="text-lg font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
Activity History</h4>
|
||||
<span class="text-sm text-slate-500 font-medium">{{ userHistory.length }}
|
||||
events</span>
|
||||
</div>
|
||||
<div v-if="userHistory.length === 0"
|
||||
class="text-center py-12 border border-dashed border-slate-200 dark:border-slate-800 rounded-xl">
|
||||
<p class="text-slate-500">No activity recorded.</p>
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="(item, index) in userHistory" :key="item.id"
|
||||
class="relative bg-slate-50 dark:bg-slate-800/50 rounded-xl p-5 border border-slate-200 dark:border-slate-800">
|
||||
<div class="flex gap-4">
|
||||
<div class="flex flex-col items-center min-w-[90px] text-center">
|
||||
<span class="text-xs font-bold text-slate-500 uppercase mb-1">{{ new
|
||||
Date(item.timestamp).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}) }}</span>
|
||||
<span class="text-xs text-slate-400">{{ new
|
||||
Date(item.timestamp).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}) }}</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
class="px-2.5 py-1 rounded-md text-xs font-bold uppercase tracking-wide bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300">{{
|
||||
item.action.replace('_', ' ') }}</span>
|
||||
<span class="text-xs text-slate-400 font-medium">{{ item.target_type
|
||||
}} #{{ item.target_id }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300 leading-relaxed">{{
|
||||
item.details }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-none bg-gradient-to-r from-slate-50 to-slate-100/50 dark:from-slate-800/50 dark:to-slate-800/30 px-8 py-4 border-t border-slate-200 dark:border-slate-800">
|
||||
<button @click="closeDetailsModal"
|
||||
class="w-full px-6 py-3 bg-white dark:bg-slate-800 border-2 border-slate-200 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 font-semibold rounded-xl transition-all">Close</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import { useAuthStore } from "~/stores/auth-store.js";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
const api = config.public.apiBase;
|
||||
const authStore = useAuthStore();
|
||||
const { token } = storeToRefs(authStore);
|
||||
|
||||
// State
|
||||
const users = ref<any[]>([
|
||||
{ id: 1, name: 'Dr. Sarah Mitchell', email: '[email protected]', role: 'ADMIN', is_active: true },
|
||||
{ id: 2, name: 'Prof. James Anderson', email: '[email protected]', role: 'TEACHER', is_active: true },
|
||||
{ id: 3, name: 'Dr. Emily Chen', email: '[email protected]', role: 'RESEARCHER', is_active: true },
|
||||
{ id: 4, name: 'Michael Roberts', email: '[email protected]', role: 'STUDENT', is_active: true },
|
||||
{ id: 5, name: 'Dr. David Kim', email: '[email protected]', role: 'COLLABORATOR', is_active: true },
|
||||
{ id: 6, name: 'Prof. Maria Garcia', email: '[email protected]', role: 'TEACHER', is_active: true },
|
||||
{ id: 7, name: 'Jessica Thompson', email: '[email protected]', role: 'STUDENT', is_active: true },
|
||||
{ id: 8, name: 'Dr. Robert Wilson', email: '[email protected]', role: 'RESEARCHER', is_active: true },
|
||||
{ id: 9, name: 'Amanda Foster', email: '[email protected]', role: 'STUDENT', is_active: false },
|
||||
{ id: 10, name: 'Prof. Thomas Lee', email: '[email protected]', role: 'ADMIN', is_active: true },
|
||||
{ id: 11, name: 'Dr. Lisa Martinez', email: '[email protected]', role: 'RESEARCHER', is_active: true },
|
||||
{ id: 12, name: 'Daniel Brown', email: '[email protected]', role: 'STUDENT', is_active: true },
|
||||
{ id: 13, name: 'Prof. Jennifer Davis', email: '[email protected]', role: 'TEACHER', is_active: true },
|
||||
{ id: 14, name: 'Christopher Taylor', email: '[email protected]', role: 'STUDENT', is_active: false },
|
||||
{ id: 15, name: 'Dr. Patricia Johnson', email: '[email protected]', role: 'COLLABORATOR', is_active: true },
|
||||
{ id: 16, name: 'Matthew White', email: '[email protected]', role: 'STUDENT', is_active: true },
|
||||
{ id: 17, name: 'Prof. Elizabeth Moore', email: '[email protected]', role: 'TEACHER', is_active: true },
|
||||
{ id: 18, name: 'Dr. William Harris', email: '[email protected]', role: 'RESEARCHER', is_active: true },
|
||||
{ id: 19, name: 'Sophie Anderson', email: '[email protected]', role: 'STUDENT', is_active: true },
|
||||
{ id: 20, name: 'Dr. Richard Clark', email: '[email protected]', role: 'ADMIN', is_active: true }
|
||||
]);
|
||||
const loading = ref(false);
|
||||
const showModal = ref(false);
|
||||
const showDetailsModal = ref(false);
|
||||
const isEditing = ref(false);
|
||||
const searchQuery = ref('');
|
||||
const selectedUser = ref<any>(null);
|
||||
const userHistory = ref<any[]>([]);
|
||||
|
||||
// Mock history data generator
|
||||
const generateMockHistory = (userId: number) => {
|
||||
const actions = ['UPLOADED_PUBLICATION', 'ADDED_COMMENT', 'CORRECTED_PUBLICATION', 'RATED_PUBLICATION', 'TAGGED_PUBLICATION'];
|
||||
const targetTypes = ['Publication', 'Comment', 'Review'];
|
||||
const details = [
|
||||
'Fez upload da publicação "A New Technique for Data Science"',
|
||||
'Comentou na publicação "Reference Paper Y"',
|
||||
'Corrigiu informação da publicação "Machine Learning Advances"',
|
||||
'Atribuiu 5 estrelas à publicação "Deep Learning Research"',
|
||||
'Adicionou tag "AI" à publicação "Neural Networks Study"',
|
||||
'Fez upload da publicação "Quantum Computing Overview"',
|
||||
'Comentou na publicação "Data Analysis Methods"',
|
||||
'Corrigiu informação da publicação "Statistical Models in Research"',
|
||||
'Atribuiu 4 estrelas à publicação "Big Data Trends"',
|
||||
'Adicionou tag "Data Science" à publicação "Data Visualization Techniques"'
|
||||
];
|
||||
|
||||
const history = [];
|
||||
const numEvents = Math.floor(Math.random() * 20) + 8;
|
||||
|
||||
for (let i = 0; i < numEvents; i++) {
|
||||
const randomAction = actions[Math.floor(Math.random() * actions.length)];
|
||||
const randomTargetType = targetTypes[Math.floor(Math.random() * targetTypes.length)];
|
||||
const randomDetails = details[Math.floor(Math.random() * details.length)];
|
||||
const daysAgo = Math.floor(Math.random() * 30);
|
||||
const hoursAgo = Math.floor(Math.random() * 24);
|
||||
const minutesAgo = Math.floor(Math.random() * 60);
|
||||
|
||||
const timestamp = new Date();
|
||||
timestamp.setDate(timestamp.getDate() - daysAgo);
|
||||
timestamp.setHours(timestamp.getHours() - hoursAgo);
|
||||
timestamp.setMinutes(timestamp.getMinutes() - minutesAgo);
|
||||
|
||||
history.push({
|
||||
id: 1000 + i,
|
||||
action: randomAction,
|
||||
target_type: randomTargetType,
|
||||
target_id: 200 + Math.floor(Math.random() * 50),
|
||||
details: randomDetails,
|
||||
timestamp: timestamp.toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
return history.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
};
|
||||
|
||||
// Form Data
|
||||
const formData = reactive({
|
||||
id: 0,
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'STUDENT',
|
||||
is_active: true
|
||||
});
|
||||
|
||||
// Computed Stats & Filtering
|
||||
const filteredUsers = computed(() => {
|
||||
if (!searchQuery.value) return users.value;
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return users.value.filter(u =>
|
||||
u.name.toLowerCase().includes(query) ||
|
||||
u.email.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const activeCount = computed(() => users.value.filter(u => u.is_active).length);
|
||||
const adminCount = computed(() => users.value.filter(u => u.role === 'ADMIN').length);
|
||||
|
||||
// --- API ACTIONS ---
|
||||
const fetchUsers = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await $fetch<any[]>(`${api}/admin/users`, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${token.value}` }
|
||||
});
|
||||
users.value = data || [];
|
||||
} catch (error) {
|
||||
console.error("Error fetching users", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const createUser = async () => {
|
||||
try {
|
||||
await $fetch(`${api}/admin/users`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token.value}` },
|
||||
body: {
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
role: formData.role
|
||||
}
|
||||
});
|
||||
closeModal();
|
||||
fetchUsers();
|
||||
} catch (error) {
|
||||
alert("Failed to create user.");
|
||||
}
|
||||
};
|
||||
|
||||
const updateUser = async () => {
|
||||
try {
|
||||
await $fetch(`${api}/admin/users/${formData.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${token.value}` },
|
||||
body: {
|
||||
role: formData.role,
|
||||
is_active: formData.is_active
|
||||
}
|
||||
});
|
||||
closeModal();
|
||||
fetchUsers();
|
||||
} catch (error) {
|
||||
alert("Failed to update user.");
|
||||
}
|
||||
};
|
||||
|
||||
const sendPasswordRecovery = async () => {
|
||||
if (!confirm(`Are you sure you want to send a password recovery to ${formData.email}?`)) return;
|
||||
|
||||
try {
|
||||
await $fetch(`${api}/auth/recover-password`, {
|
||||
method: 'POST',
|
||||
// headers: { Authorization: ... } // EP02 is usually public, but check your API requirements
|
||||
body: {
|
||||
email: formData.email
|
||||
}
|
||||
});
|
||||
alert(`Recovery sent to ${formData.email}`);
|
||||
} catch (error) {
|
||||
alert("Failed to send recovery.");
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async (user: any) => {
|
||||
if (!confirm(`Are you sure you want to delete ${user.name}?`)) return;
|
||||
try {
|
||||
await $fetch(`${api}/admin/users/${user.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token.value}` }
|
||||
});
|
||||
fetchUsers();
|
||||
} catch (error) {
|
||||
alert("Error deleting user.");
|
||||
}
|
||||
};
|
||||
|
||||
// --- HELPERS ---
|
||||
const openUserDetails = (user: any) => {
|
||||
selectedUser.value = user;
|
||||
userHistory.value = generateMockHistory(user.id);
|
||||
showDetailsModal.value = true;
|
||||
};
|
||||
|
||||
const closeDetailsModal = () => {
|
||||
showDetailsModal.value = false;
|
||||
selectedUser.value = null;
|
||||
userHistory.value = [];
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
isEditing.value = false;
|
||||
resetForm();
|
||||
showModal.value = true;
|
||||
};
|
||||
|
||||
const openEditModal = (user: any) => {
|
||||
isEditing.value = true;
|
||||
formData.id = user.id;
|
||||
formData.name = user.name;
|
||||
formData.email = user.email;
|
||||
formData.role = user.role;
|
||||
formData.is_active = user.is_active;
|
||||
showModal.value = true;
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
showModal.value = false;
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (isEditing.value) updateUser();
|
||||
else createUser();
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
formData.id = 0;
|
||||
formData.name = '';
|
||||
formData.email = '';
|
||||
formData.password = '';
|
||||
formData.role = 'STUDENT';
|
||||
formData.is_active = true;
|
||||
};
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
if (!name) return '??';
|
||||
return name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchUsers();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup>
|
||||
const email = ref("");
|
||||
const error = ref(null);
|
||||
const message = ref("");
|
||||
const loading = ref(false);
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
const api = config.public.apiBase;
|
||||
const router = useRouter();
|
||||
const notifications = useNotificationStore();
|
||||
|
||||
async function handleResetRequest() {
|
||||
error.value = null;
|
||||
message.value = "";
|
||||
|
||||
if (!email.value) {
|
||||
error.value = "Email is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^\S+@\S+\.\S+$/.test(email.value)) {
|
||||
error.value = "Please enter a valid email address.";
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
await $fetch(`${api}/auth/recover-password`, {
|
||||
method: "POST",
|
||||
body: { email: email.value },
|
||||
onResponse({ response }) {
|
||||
if (response.status === 200) {
|
||||
notifications.success("Password reset email sent.");
|
||||
router.push("/auth/login")
|
||||
}
|
||||
},
|
||||
onResponseError() {
|
||||
error.value = "Email not found in our records.";
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (!error.value) error.value = "Network error: Connection to server failed.";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<title>Reset Password - Reddit for Academic Papers</title>
|
||||
<div
|
||||
class="min-h-screen bg-gray-100 dark:bg-gray-950 flex flex-col items-center justify-center p-6 transition-colors duration-300">
|
||||
<Transition appear enter-active-class="transition duration-700 ease-out"
|
||||
enter-from-class="translate-y-8 opacity-0" enter-to-class="translate-y-0 opacity-100">
|
||||
|
||||
<div
|
||||
class="w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-lg p-8 border dark:border-gray-800 transition-colors">
|
||||
<h1 class="text-3xl font-bold text-gray-800 dark:text-gray-100! mb-8 text-center">Reset Password</h1>
|
||||
|
||||
<form class="space-y-6" @submit.prevent="handleResetRequest">
|
||||
<div>
|
||||
<label for="email"
|
||||
class="block text-sm font-semibold text-gray-600 dark:text-gray-400 mb-1.5 ml-1">
|
||||
Email Address
|
||||
</label>
|
||||
<input id="email" v-model="email" type="email" :class="[
|
||||
'w-full px-4 py-2.5 bg-gray-50 dark:bg-gray-800 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all text-gray-900 dark:text-white',
|
||||
error ? 'border-red-500 ring-1 ring-red-500' : 'border-gray-200 dark:border-gray-700'
|
||||
]" placeholder="[email protected]">
|
||||
<p v-if="error" class="mt-1.5 text-xs text-red-500! ml-1 font-medium">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-4 pt-2">
|
||||
<button type="submit" :disabled="loading"
|
||||
class="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-bold py-3 rounded-lg shadow-md hover:shadow-lg transform active:scale-95 transition-all duration-200">
|
||||
{{ loading ? 'SENDING...' : 'SEND RESET LINK' }}
|
||||
</button>
|
||||
|
||||
<nuxt-link to="/auth/login"
|
||||
class="text-sm text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400 transition-colors">
|
||||
Back to Login
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<title> Login - Reddit for Academic Papers</title>
|
||||
<div
|
||||
class="min-h-screen bg-gray-100 dark:bg-gray-950 flex flex-col items-center justify-center p-6 transition-colors duration-300">
|
||||
<Transition appear enter-active-class="transition duration-700 ease-out" enter-from-class="translate-y-8 opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100">
|
||||
|
||||
<div
|
||||
class="w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-lg p-8 border dark:border-gray-800 transition-colors">
|
||||
<h1 class="text-3xl font-bold text-gray-800 dark:text-white! mb-8 text-center capitalize">Login</h1>
|
||||
|
||||
<div v-if="globalError"
|
||||
class="mb-6 p-3 bg-red-100 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-400 text-sm text-center">
|
||||
{{ globalError }}
|
||||
</div>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-600 dark:text-gray-400 mb-1.5 ml-1">Email</label>
|
||||
<input v-model="loginFormData.email" type="email" :class="[
|
||||
'w-full px-4 py-2.5 bg-gray-50 dark:bg-gray-800 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all text-gray-900 dark:text-white',
|
||||
errors.email ? 'border-red-500 ring-1 ring-red-500' : 'border-gray-200 dark:border-gray-700'
|
||||
]" placeholder="[email protected]">
|
||||
<p v-if="errors.email" class="mt-1 text-xs text-red-500! ml-1">{{ errors.email }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-600 dark:text-gray-400 mb-1.5 ml-1">Password</label>
|
||||
<input v-model="loginFormData.password" type="password" :class="[
|
||||
'w-full px-4 py-2.5 bg-gray-50 dark:bg-gray-800 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all text-gray-900 dark:text-white',
|
||||
errors.password ? 'border-red-500 ring-1 ring-red-500' : 'border-gray-200 dark:border-gray-700'
|
||||
]" placeholder="••••••••" @keyup.enter="handleLogin">
|
||||
<p v-if="errors.password" class="mt-1 text-xs text-red-500! ml-1">{{ errors.password }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-4 pt-2">
|
||||
<button :disabled="loading"
|
||||
class="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-bold py-3 rounded-lg shadow-md hover:shadow-lg transform active:scale-95 transition-all duration-200"
|
||||
@click="handleLogin">
|
||||
{{ loading ? 'AUTHENTICATING...' : 'LOGIN' }}
|
||||
</button>
|
||||
|
||||
<nuxt-link to="/auth/forgot-password" class="text-xs text-blue-600 hover:underline dark:text-blue-400">
|
||||
Forgot Password?
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useAuthStore } from "~/stores/auth-store.js";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
const api = config.public.apiBase;
|
||||
const authStore = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { token, user } = storeToRefs(authStore);
|
||||
|
||||
const loginFormData = reactive({
|
||||
email: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
const errors = reactive({ email: null, password: null });
|
||||
const globalError = ref(null);
|
||||
const loading = ref(false);
|
||||
|
||||
function validateForm() {
|
||||
errors.email = !loginFormData.email ? "Email is required" : null;
|
||||
errors.password = !loginFormData.password ? "Password is required" : null;
|
||||
|
||||
if (loginFormData.email && !/^\S+@\S+\.\S+$/.test(loginFormData.email)) {
|
||||
errors.email = "Please enter a valid email address";
|
||||
}
|
||||
|
||||
return !errors.email && !errors.password;
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
globalError.value = null;
|
||||
if (!validateForm()) return;
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
await $fetch(`${api}/auth/login`, {
|
||||
method: "POST",
|
||||
body: loginFormData,
|
||||
onResponse({ response }) {
|
||||
if (response.status === 200) {
|
||||
console.log(response)
|
||||
token.value = response._data.token;
|
||||
user.value = response._data.user;
|
||||
if (response._data.user.mustChangePassword === true) {
|
||||
router.push("/profile/update-password");
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
}
|
||||
},
|
||||
onResponseError({ response }) {
|
||||
console.log(response.status)
|
||||
if (response.status === 401) {
|
||||
globalError.value = "Invalid email or password.";
|
||||
} else {
|
||||
globalError.value = "An unexpected error occurred. Please try again.";
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (!globalError.value) globalError.value = "Network error: Connection to server failed.";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.delay-200 {
|
||||
animation-delay: 0.2s;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
+45
-2
@@ -1,5 +1,48 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1> This will be the home page </h1>
|
||||
<title> Reddit for Academic Papers - Home</title>
|
||||
<div
|
||||
class="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-center justify-center p-6 transition-colors duration-300">
|
||||
<div class="w-full max-w-2xl animate-[fadeInUp_0.6s_ease-out]">
|
||||
|
||||
<header class="text-center mb-12">
|
||||
<h1 class="text-4xl md:text-5xl font-extrabold text-gray-900 dark:text-white! mb-4 tracking-tight">
|
||||
Academic Management System
|
||||
</h1>
|
||||
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-md mx-auto">
|
||||
Streamline your educational workflow, manage student records, and oversee courses in one place.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<nav class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
<nuxt-link to="/auth/login"
|
||||
class="no-underline text-current block group p-8 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-2xl shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300">
|
||||
<div class="text-3xl mb-4 group-hover:scale-110 transition-transform">🔐</div>
|
||||
<h2 class="text-xl font-bold text-gray-800 dark:text-gray-100! mb-2">Access Portal</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Sign in to manage your personalized dashboard and academic
|
||||
data.</p>
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link to="/auth-test"
|
||||
class="no-underline text-current block group p-8 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-2xl shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300">
|
||||
<div class="text-3xl mb-4 group-hover:scale-110 transition-transform">🛠️</div>
|
||||
<h2 class="text-xl font-bold text-gray-800 dark:text-gray-100! mb-2">System Test</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Verify API connections and authentication state
|
||||
persistence.</p>
|
||||
</nuxt-link>
|
||||
|
||||
</nav>
|
||||
|
||||
<footer class="mt-16 text-center">
|
||||
<div
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-gray-200 dark:bg-gray-800 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500" />
|
||||
</span>
|
||||
System Operational
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+329
-128
@@ -1,61 +1,65 @@
|
||||
<template>
|
||||
<title> Profile - {{user.name}}</title>
|
||||
<div class="min-h-screen bg-slate-50 font-sans text-slate-900 selection:bg-blue-100 pb-12">
|
||||
<title>{{ userProfile.name }} - Reddit for Academic Papers</title>
|
||||
<div
|
||||
class="min-h-screen bg-slate-50 dark:bg-slate-950 font-sans text-slate-900 dark:text-slate-200 selection:bg-blue-100 dark:selection:bg-blue-900 pb-12 transition-colors duration-300">
|
||||
|
||||
<div class="container mx-auto max-w-7xl px-4 py-8 md:py-12">
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-8 lg:gap-12">
|
||||
|
||||
<div class="md:col-span-4 lg:col-span-3 flex flex-col gap-6">
|
||||
|
||||
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-200">
|
||||
<div
|
||||
class="bg-white dark:bg-slate-900 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-800 transition-colors duration-300">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="relative inline-block mb-4">
|
||||
<div
|
||||
class="h-32 w-32 rounded-full border-4 border-white bg-slate-100 overflow-hidden shadow-md ring-1 ring-slate-200">
|
||||
<img :src="user.avatarUrl" :alt="user.name"
|
||||
class="h-32 w-32 rounded-full border-4 border-white dark:border-slate-800 bg-slate-100 dark:bg-slate-800 overflow-hidden shadow-md ring-1 ring-slate-200 dark:ring-slate-700">
|
||||
<img :src="userProfile.avatarUrl" :alt="userProfile.name"
|
||||
class="h-full w-full object-cover transition-transform hover:scale-105" />
|
||||
</div>
|
||||
<div class="absolute bottom-2 right-2 h-5 w-5 rounded-full border-[3px] border-white shadow-sm"
|
||||
:class="user.isActive ? 'bg-emerald-500' : 'bg-gray-400'" title="Online Status">
|
||||
<div class="absolute bottom-2 right-2 h-5 w-5 rounded-full border-[3px] border-white dark:border-slate-900 shadow-sm"
|
||||
:class="userProfile.is_active ? 'bg-emerald-500' : 'bg-gray-400'"
|
||||
title="Online Status">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-bold text-slate-900 tracking-tight">{{ user.name }}</h1>
|
||||
<p class="text-sm font-semibold text-slate-500 mb-6 uppercase tracking-wide">{{ user.role }}
|
||||
<h1 class="text-2xl font-bold text-slate-900 dark:text-white tracking-tight">{{
|
||||
userProfile.name }}
|
||||
</h1>
|
||||
<p
|
||||
class="text-sm font-semibold text-slate-500 dark:text-slate-400 mb-6 uppercase tracking-wide">
|
||||
{{ userProfile.role }}
|
||||
</p>
|
||||
|
||||
<div class="w-full space-y-3">
|
||||
<button
|
||||
class="w-full py-2.5 px-4 bg-slate-900 hover:bg-slate-800 text-white font-medium rounded-lg text-sm transition-all shadow-sm active:scale-95 cursor-pointer">
|
||||
@click=" activeTab = 'settings' "
|
||||
class="w-full py-2.5 px-4 bg-slate-900 dark:bg-blue-600 hover:bg-slate-800 dark:hover:bg-blue-500 text-white font-medium rounded-lg text-sm transition-all shadow-sm active:scale-95 cursor-pointer">
|
||||
Edit Profile
|
||||
</button>
|
||||
<button
|
||||
class="w-full py-2.5 px-4 bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 font-medium rounded-lg text-sm transition-all active:scale-95 cursor-pointer">
|
||||
View Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pt-6 border-t border-slate-100 space-y-4">
|
||||
<div class="flex items-center gap-3 text-sm text-slate-600">
|
||||
<svg class="w-4 h-4 text-slate-400 shrink-0" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<div class="mt-8 pt-6 border-t border-slate-100 dark:border-slate-800 space-y-4">
|
||||
<div class="flex items-center gap-3 text-sm text-slate-600 dark:text-slate-400">
|
||||
<svg class="w-4 h-4 text-slate-400 dark:text-slate-500 shrink-0" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span class="truncate">{{ user.email }}</span>
|
||||
<span class="truncate">{{ userProfile.email }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-sm text-slate-600">
|
||||
<svg class="w-4 h-4 text-slate-400 shrink-0" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<div class="flex items-center gap-3 text-sm text-slate-600 dark:text-slate-400">
|
||||
<svg class="w-4 h-4 text-slate-400 dark:text-slate-500 shrink-0" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
<span>Polytechnic of Leiria</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-sm text-slate-600">
|
||||
<svg class="w-4 h-4 text-slate-400 shrink-0" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<div class="flex items-center gap-3 text-sm text-slate-600 dark:text-slate-400">
|
||||
<svg class="w-4 h-4 text-slate-400 dark:text-slate-500 shrink-0" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
@@ -66,15 +70,21 @@
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
class="bg-white p-4 rounded-xl shadow-sm border border-slate-200 text-center hover:border-slate-300 transition-colors">
|
||||
<div class="text-2xl font-bold text-slate-900">{{ totalCitations }}</div>
|
||||
<div class="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">Citations
|
||||
class="bg-white dark:bg-slate-900 p-4 rounded-xl shadow-sm border border-slate-200 dark:border-slate-800 text-center hover:border-slate-300 dark:hover:border-slate-700 transition-colors">
|
||||
<div class="text-2xl font-bold text-slate-900 dark:text-white">{{ totalCitations }}</div>
|
||||
<div
|
||||
class="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-1">
|
||||
Citations
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-white p-4 rounded-xl shadow-sm border border-slate-200 text-center hover:border-slate-300 transition-colors">
|
||||
<div class="text-2xl font-bold text-slate-900">{{ user.publications.length }}</div>
|
||||
<div class="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">Papers
|
||||
class="bg-white dark:bg-slate-900 p-4 rounded-xl shadow-sm border border-slate-200 dark:border-slate-800 text-center hover:border-slate-300 dark:hover:border-slate-700 transition-colors">
|
||||
<div class="text-2xl font-bold text-slate-900 dark:text-white">{{
|
||||
userProfile.publications.length
|
||||
}}</div>
|
||||
<div
|
||||
class="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-1">
|
||||
Papers
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,78 +92,176 @@
|
||||
|
||||
<div class="md:col-span-8 lg:col-span-9 space-y-6">
|
||||
|
||||
<div class="bg-white p-1.5 rounded-xl border border-slate-200 shadow-sm flex gap-1 overflow-x-auto">
|
||||
<button
|
||||
class="flex-1 min-w-[100px] py-2.5 px-4 rounded-lg text-lg font-semibold bg-slate-100 text-slate-900 shadow-sm ring-1 ring-black/5 cursor-pointer">
|
||||
<div
|
||||
class="bg-white dark:bg-slate-900 p-1.5 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm flex gap-1 overflow-x-auto transition-colors">
|
||||
<button @click="activeTab = 'overview'"
|
||||
:class="activeTab === 'overview' ? 'bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm ring-1 ring-black/5 dark:ring-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-50 dark:hover:bg-slate-800'"
|
||||
class="flex-1 min-w-[100px] py-2.5 px-4 rounded-lg text-lg font-semibold cursor-pointer transition-colors">
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 min-w-[100px] py-2.5 px-4 rounded-lg text-lg font-medium text-slate-500 hover:text-slate-900 hover:bg-slate-50 transition-colors cursor-pointer">
|
||||
<button @click="activeTab = 'activity'"
|
||||
:class="activeTab === 'activity' ? 'bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm ring-1 ring-black/5 dark:ring-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-50 dark:hover:bg-slate-800'"
|
||||
class="flex-1 min-w-[100px] py-2.5 px-4 rounded-lg text-lg font-medium transition-colors cursor-pointer">
|
||||
Activity Log
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 min-w-[100px] py-2.5 px-4 rounded-lg text-lg font-medium text-slate-500 hover:text-slate-900 hover:bg-slate-50 transition-colors cursor-pointer">
|
||||
<button @click="activeTab = 'settings'"
|
||||
:class="activeTab === 'settings' ? 'bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm ring-1 ring-black/5 dark:ring-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-50 dark:hover:bg-slate-800'"
|
||||
class="flex-1 min-w-[100px] py-2.5 px-4 rounded-lg text-lg font-medium transition-colors cursor-pointer">
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-2xl shadow-sm border border-slate-200">
|
||||
<h2 class="text-xs font-bold text-slate-400 uppercase tracking-wider mb-3">Biography</h2>
|
||||
<p class="text-slate-700 leading-relaxed text-sm">
|
||||
{{ user.biography }}
|
||||
<Transition mode="out-in" enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="transform opacity-0 translate-y-2"
|
||||
enter-to-class="transform opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="transform opacity-100 translate-y-0"
|
||||
leave-to-class="transform opacity-0 translate-y-2">
|
||||
<div v-if="activeTab === 'overview'" key="overview" class="space-y-6">
|
||||
<div
|
||||
class="bg-white dark:bg-slate-900 p-6 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 transition-colors">
|
||||
<h2
|
||||
class="text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3">
|
||||
Biography</h2>
|
||||
<p class="text-slate-700 dark:text-slate-300 leading-relaxed text-sm">
|
||||
{{ userProfile.biography }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-2 px-1">
|
||||
<h2 class="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||
<h2 class="text-lg font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
Recent Publications
|
||||
<span class="bg-slate-200 text-slate-700 text-xs font-bold px-2 py-0.5 rounded-full">{{
|
||||
user.publications.length }}</span>
|
||||
<span
|
||||
class="bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-xs font-bold px-2 py-0.5 rounded-full">
|
||||
{{ userProfile.publications.length }}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-if="user.publications.length === 0"
|
||||
class="text-center py-16 border-2 border-dashed border-slate-200 rounded-xl bg-slate-50/50">
|
||||
<p class="text-slate-500 font-medium">No publications found yet.</p>
|
||||
<div v-if="userProfile.publications.length === 0"
|
||||
class="relative overflow-hidden rounded-2xl border-2 border-dashed border-slate-300 dark:border-slate-700 bg-slate-50 dark:bg-slate-900/50 p-12 text-center hover:border-blue-400 dark:hover:border-blue-500 transition-all duration-300 group">
|
||||
|
||||
<div
|
||||
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-48 h-48 bg-blue-500/10 dark:bg-blue-400/10 rounded-full blur-3xl opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none">
|
||||
</div>
|
||||
|
||||
<div v-for="pub in user.publications" :key="pub.id"
|
||||
class="bg-white p-6 rounded-xl shadow-sm border border-slate-200 hover:shadow-md hover:border-blue-300 transition-all duration-200 group relative">
|
||||
<div class="flex justify-between items-start gap-4 mb-2">
|
||||
<h3
|
||||
class="text-base font-bold text-slate-900 group-hover:text-blue-600 transition-colors leading-snug">
|
||||
<a href="#" class="before:absolute before:inset-0">{{ pub.title }}</a>
|
||||
<div class="relative z-10 flex flex-col items-center">
|
||||
<div
|
||||
class="mb-4 p-4 bg-white dark:bg-slate-800 rounded-full shadow-sm ring-1 ring-slate-100 dark:ring-slate-700 group-hover:scale-110 group-hover:ring-blue-200 dark:group-hover:ring-blue-800 transition-all duration-300">
|
||||
<svg class="w-8 h-8 text-blue-500 dark:text-blue-400" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-bold text-slate-900 dark:text-white mb-1">
|
||||
No publications yet
|
||||
</h3>
|
||||
<p
|
||||
class="text-slate-500 dark:text-slate-400 text-sm max-w-xs mx-auto mb-6 leading-relaxed">
|
||||
Your profile is looking a little empty. Share your research with the
|
||||
community to start building your portfolio.
|
||||
</p>
|
||||
|
||||
<button
|
||||
@click="goToCreatePost()"
|
||||
class="inline-flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-xl shadow-lg shadow-blue-500/20 hover:shadow-blue-500/30 transform active:scale-95 transition-all duration-200 cursor-pointer">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Submit your first paper
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="pub in userProfile.publications" :key="pub.id"
|
||||
class="bg-white dark:bg-slate-900 p-6 rounded-xl shadow-sm border border-slate-200 dark:border-slate-800 hover:shadow-md hover:border-blue-300 dark:hover:border-blue-700 transition-all duration-200 group relative">
|
||||
<div class="flex justify-between items-start gap-4 mb-2">
|
||||
<h3 class="text-base font-bold text-slate-900 dark:text-slate-100">{{ pub.title
|
||||
}}</h3>
|
||||
<span
|
||||
class="shrink-0 px-2.5 py-1 rounded-md bg-slate-100 text-slate-600 text-xs font-bold border border-slate-200">
|
||||
class="shrink-0 px-2.5 py-1 rounded-md bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 text-xs font-bold border border-slate-200 dark:border-slate-700">
|
||||
{{ pub.publishedYear }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-slate-600 text-sm leading-relaxed line-clamp-2">
|
||||
{{ pub.abstract }}
|
||||
</p>
|
||||
|
||||
<p class="text-slate-600 dark:text-slate-400 text-sm leading-relaxed line-clamp-2">
|
||||
{{ pub.abstract }}</p>
|
||||
<div
|
||||
class="mt-4 pt-4 border-t border-slate-50 flex flex-wrap items-center gap-4 text-xs font-medium z-10 relative">
|
||||
class="mt-4 pt-4 border-t border-slate-50 dark:border-slate-800 flex flex-wrap items-center gap-4 text-xs font-medium">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-blue-50 text-blue-700 border border-blue-100">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-blue-500"></span>
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-100 dark:border-blue-800/50">
|
||||
{{ pub.field }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1.5 text-slate-500">
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
{{ pub.citations }} Citations
|
||||
<span>{{ pub.citations }} Citations</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeTab === 'activity'" key="activity" class="space-y-4">
|
||||
<h2 class="text-lg font-bold text-slate-900 dark:text-white px-1">User History</h2>
|
||||
|
||||
<div v-if="activityLog.length === 0"
|
||||
class="text-center py-12 border border-dashed border-slate-200 dark:border-slate-800 rounded-xl">
|
||||
<p class="text-slate-500">No activity recorded.</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-white dark:bg-slate-900 rounded-xl shadow-sm border border-slate-200 dark:border-slate-800 overflow-hidden">
|
||||
<div v-for="(item, index) in activityLog" :key="item.id"
|
||||
class="p-4 flex gap-4 border-b border-slate-100 dark:border-slate-800 last:border-0 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
||||
|
||||
<div class="flex flex-col items-center min-w-[80px] text-center pt-1">
|
||||
<span class="text-xs font-bold text-slate-500 uppercase">
|
||||
{{ new Date(item.timestamp).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}) }}
|
||||
</span>
|
||||
<span class="text-slate-300">•</span>
|
||||
<span class="text-slate-500">{{ pub.publishedDate }}</span>
|
||||
<span class="text-[10px] text-slate-400">
|
||||
{{ new Date(item.timestamp).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
class="px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide"
|
||||
:class="{
|
||||
'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400': item.action.includes('UPLOAD'),
|
||||
'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400': item.action.includes('COMMENT'),
|
||||
'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400': item.action.includes('RATED') || item.action.includes('CORRECT'),
|
||||
'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400': item.action.includes('TAG')
|
||||
}">
|
||||
{{ item.action.replace('_', ' ') }}
|
||||
</span>
|
||||
<span class="text-xs text-slate-400 font-medium">{{ item.target_type }} #{{
|
||||
item.target_id }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300">
|
||||
{{ item.details }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeTab === 'settings'" key="settings"
|
||||
class="bg-white dark:bg-slate-900 p-6 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 transition-colors">
|
||||
<h2
|
||||
class="text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3">
|
||||
Settings</h2>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||
Nothing here but chickens! Settings coming soon.
|
||||
</p>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,16 +270,41 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from "~/stores/auth-store.js";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
enum Role {
|
||||
STUDENT = "STUDENT",
|
||||
TEACHER = "TEACHER",
|
||||
ADMIN = "ADMIN",
|
||||
RESEARCHER = "RESEARCHER"
|
||||
// --- Config & Store ---
|
||||
const config = useRuntimeConfig();
|
||||
const api = config.public.apiBase;
|
||||
const authStore = useAuthStore();
|
||||
const { token } = storeToRefs(authStore);
|
||||
const router = useRouter();
|
||||
|
||||
// --- Interfaces ---
|
||||
|
||||
interface Tag {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Publication {
|
||||
// Matches response from EP12 (/api/v1/publications/my-submissions) [cite: 296]
|
||||
interface ApiPublication {
|
||||
id: number;
|
||||
title: string;
|
||||
authors: string[];
|
||||
publication_date: string;
|
||||
type: string;
|
||||
rating: number; // Mapping this to citations for the UI
|
||||
comment_count: number;
|
||||
attached_file?: string;
|
||||
tags: Tag[];
|
||||
abstract?: string;
|
||||
}
|
||||
|
||||
// Matches your UI requirements
|
||||
interface UIPublication {
|
||||
id: number;
|
||||
title: string;
|
||||
abstract: string;
|
||||
@@ -181,63 +314,131 @@ interface Publication {
|
||||
field: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
isActive: boolean;
|
||||
avatarUrl: string;
|
||||
joinDate: string;
|
||||
publications: Publication[];
|
||||
}
|
||||
|
||||
const user = ref<User>({
|
||||
id: 1,
|
||||
name: 'Dr. Maria Santos',
|
||||
email: '[email protected]',
|
||||
biography: 'Focused on distributed systems and IoT security. Currently leading the research group on \"Smart City Infrastructures\" at IPLeiria. My work primarily involves developing scalable architectures for real-time data processing and ensuring data integrity in edge computing environments.',
|
||||
role: Role.RESEARCHER,
|
||||
isActive: true,
|
||||
avatarUrl: 'https://api.dicebear.com/7.x/notionists/svg?seed=Maria',
|
||||
joinDate: '2023-09-15',
|
||||
publications: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Machine Learning Approaches to Predictive Maintenance in Industrial IoT',
|
||||
abstract: 'This paper explores novel machine learning techniques for predictive maintenance in industrial Internet of Things environments, demonstrating a 35% improvement in fault detection accuracy.',
|
||||
publishedDate: 'Nov 20, 2024',
|
||||
publishedYear: '2024',
|
||||
citations: 12,
|
||||
field: 'Computer Science'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Distributed Systems Architecture for Real-Time Data Processing',
|
||||
abstract: 'We present a scalable distributed architecture designed for real-time processing of large-scale sensor data streams. The proposed system leverages Kafka for ingestion.',
|
||||
publishedDate: 'Aug 05, 2024',
|
||||
publishedYear: '2024',
|
||||
citations: 8,
|
||||
field: 'Software Engineering'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Security Considerations in Edge Computing Environments',
|
||||
abstract: 'An in-depth analysis of security vulnerabilities in edge computing deployments. This paper proposes a layered security framework that addresses both hardware and software.',
|
||||
publishedDate: 'Mar 12, 2024',
|
||||
publishedYear: '2024',
|
||||
citations: 23,
|
||||
field: 'Cybersecurity'
|
||||
}
|
||||
]
|
||||
// --- State ---
|
||||
const loading = ref(true);
|
||||
const activeTab = ref('overview'); // State to control visible tab
|
||||
const activityLog = ref<ActivityItem[]>([]); // Store history data
|
||||
|
||||
const userProfile = ref({
|
||||
id: 0,
|
||||
name: 'Loading...',
|
||||
email: '',
|
||||
role: '',
|
||||
is_active: false,
|
||||
avatarUrl: '',
|
||||
biography: 'No biography provided.',
|
||||
joinDate: new Date().toISOString(),
|
||||
publications: [] as any[]
|
||||
});
|
||||
|
||||
const totalCitations = computed(() => user.value.publications.reduce((sum, pub) => sum + pub.citations, 0));
|
||||
// --- API Actions ---
|
||||
|
||||
// Fetch Tags (EP23) - To populate the select box
|
||||
async function fetchTags() {
|
||||
if (!token.value) return;
|
||||
try {
|
||||
const data = await $fetch<Tag[]>(`${api}/tags`, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${token.value}` }
|
||||
});
|
||||
availableTags.value = data || [];
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch tags", e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Computed ---
|
||||
const totalCitations = computed(() =>
|
||||
userProfile.value.publications.reduce((sum, pub) => sum + (pub.citations || 0), 0)
|
||||
);
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
return new Date(user.value.joinDate).toLocaleDateString('en-US', {
|
||||
return new Date(userProfile.value.joinDate).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
});
|
||||
|
||||
// --- API Actions ---
|
||||
|
||||
// 1. Fetch Activity Log (EP06)
|
||||
async function fetchActivityHistory() {
|
||||
if (!token.value) return;
|
||||
|
||||
try {
|
||||
// GET /api/v1/me/history
|
||||
const data = await $fetch<ActivityItem[]>(`${api}/me/history`, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${token.value}` }
|
||||
});
|
||||
activityLog.value = data;
|
||||
} catch (error) {
|
||||
console.error("Error fetching history:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch Main Profile Data (EP03 & EP12)
|
||||
async function fetchProfileData() {
|
||||
if (!token.value) return;
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
// Fetch User Details
|
||||
const userData = await $fetch(`${api}/users/me`, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${token.value}` }
|
||||
});
|
||||
|
||||
// Fetch Publications
|
||||
// const publicationsData = await $fetch(`${api}/publications/my-submissions`, {
|
||||
// method: 'GET',
|
||||
// headers: { Authorization: `Bearer ${token.value}` }
|
||||
// });
|
||||
|
||||
// Map Data
|
||||
userProfile.value = {
|
||||
...userProfile.value,
|
||||
id: userData.id,
|
||||
name: userData.name,
|
||||
email: userData.email,
|
||||
role: userData.role,
|
||||
is_active: userData.is_active,
|
||||
avatarUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(userData.name)}&background=0D8ABC&color=fff`
|
||||
};
|
||||
|
||||
userProfile.value.publications = (publicationsData as any[]).map(pub => {
|
||||
const pubDate = new Date(pub.publication_date);
|
||||
return {
|
||||
id: pub.id,
|
||||
title: pub.title,
|
||||
abstract: pub.abstract || 'No abstract available.',
|
||||
publishedDate: pubDate.toLocaleDateString(),
|
||||
publishedYear: pubDate.getFullYear().toString(),
|
||||
citations: pub.rating || 0,
|
||||
field: pub.tags && pub.tags.length > 0 ? pub.tags[0].name : 'General'
|
||||
};
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error fetching profile:", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Redirects ---
|
||||
const goToCreatePost = () => {
|
||||
console.log("Navigating to create post modal...");
|
||||
router.push({
|
||||
path: '/',
|
||||
query: { openCreatePostModal: 'true' }
|
||||
});
|
||||
};
|
||||
|
||||
// --- Lifecycle ---
|
||||
onMounted(() => {
|
||||
fetchProfileData();
|
||||
fetchActivityHistory(); // Fetch history on mount
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup>
|
||||
const config = useRuntimeConfig();
|
||||
const api = config.public.apiBase;
|
||||
const authStore = useAuthStore();
|
||||
const notifications = useNotificationStore();
|
||||
const router = useRouter();
|
||||
|
||||
const passwordData = reactive({
|
||||
oldPassword: "",
|
||||
newPassword: "",
|
||||
confirmNewPassword: ""
|
||||
});
|
||||
|
||||
const error = ref(null);
|
||||
const loading = ref(false);
|
||||
|
||||
async function handleUpdatePassword() {
|
||||
error.value = null;
|
||||
|
||||
if (passwordData.newPassword !== passwordData.confirmNewPassword) {
|
||||
error.value = "Passwords do not match.";
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
await $fetch(`${api}/users/me/password`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${authStore.token}`
|
||||
},
|
||||
body: {
|
||||
oldPassword: passwordData.oldPassword,
|
||||
newPassword: passwordData.newPassword,
|
||||
confirmNewPassword: passwordData.confirmNewPassword
|
||||
},
|
||||
onResponse({ response }) {
|
||||
if (response.status === 200) {
|
||||
notifications.success("Password updated successfully.");
|
||||
authStore.user.mustChangePassword = false;
|
||||
router.push("/");
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
error.value = "Failed to update password. Please check your current password";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-h-screen bg-gray-100 dark:bg-gray-950 flex flex-col items-center justify-center p-6 transition-colors duration-300">
|
||||
<Transition appear enter-active-class="transition duration-700 ease-out"
|
||||
enter-from-class="translate-y-8 opacity-0" enter-to-class="translate-y-0 opacity-100">
|
||||
|
||||
<div
|
||||
class="w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-lg p-8 border dark:border-gray-800 transition-colors">
|
||||
<h1 class="text-2xl font-bold text-gray-800 dark:text-white! mb-2 text-center">Password Update</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-8 text-center">Please update your password to
|
||||
secure your account.</p>
|
||||
|
||||
<form class="space-y-5" @submit.prevent="handleUpdatePassword">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-600 dark:text-gray-400 mb-1.5 ml-1">Current
|
||||
Password</label>
|
||||
<input v-model="passwordData.oldPassword" type="password" required
|
||||
class="w-full px-4 py-2.5 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all text-gray-900 dark:text-white"
|
||||
placeholder="••••••••">
|
||||
</div>
|
||||
|
||||
<hr class="border-gray-100 dark:border-gray-800 my-2">
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-600 dark:text-gray-400 mb-1.5 ml-1">New
|
||||
Password</label>
|
||||
<input v-model="passwordData.newPassword" type="password" required
|
||||
class="w-full px-4 py-2.5 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all text-gray-900 dark:text-white"
|
||||
placeholder="••••••••">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-600 dark:text-gray-400 mb-1.5 ml-1">Confirm
|
||||
New Password</label>
|
||||
<input v-model="passwordData.confirmNewPassword" type="password" required
|
||||
class="w-full px-4 py-2.5 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all text-gray-900 dark:text-white"
|
||||
placeholder="••••••••">
|
||||
</div>
|
||||
|
||||
<div v-if="error"
|
||||
class="p-3 bg-red-100 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-400 text-xs text-center">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-4 pt-4">
|
||||
<button type="submit" :disabled="loading"
|
||||
class="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-bold py-3 rounded-lg shadow-md hover:shadow-lg transform active:scale-95 transition-all duration-200 capitalize">
|
||||
{{ loading ? 'Updating...' : 'Update Password' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const useAuthStore = defineStore("authStore", () => {
|
||||
const token = useCookie("auth_token", { maxAge: 60 * 15 });
|
||||
const user = useCookie("auth_user", { maxAge: 60 * 15 });
|
||||
|
||||
if (process.client) {
|
||||
token.value = localStorage.getItem("auth_token");
|
||||
|
||||
const savedUser = localStorage.getItem("auth_user");
|
||||
if (savedUser) {
|
||||
try {
|
||||
user.value = JSON.parse(savedUser);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse user from storage", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch([token, user], ([newToken, newUser]) => {
|
||||
if (!process.client) return;
|
||||
|
||||
if (newToken) {
|
||||
localStorage.setItem("auth_token", newToken);
|
||||
} else {
|
||||
localStorage.removeItem("auth_token");
|
||||
}
|
||||
|
||||
if (newUser) {
|
||||
localStorage.setItem("auth_user", JSON.stringify(newUser));
|
||||
} else {
|
||||
localStorage.removeItem("auth_user");
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
function logout() {
|
||||
token.value = null;
|
||||
user.value = null;
|
||||
}
|
||||
|
||||
return { token, user, logout };
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useNotificationStore = defineStore('notifications', {
|
||||
state: () => ({
|
||||
notifications: []
|
||||
}),
|
||||
actions: {
|
||||
add(message, type = 'success', duration = 3000) {
|
||||
const id = Date.now()
|
||||
this.notifications.push({ id, message, type })
|
||||
|
||||
setTimeout(() => {
|
||||
this.remove(id)
|
||||
}, duration)
|
||||
},
|
||||
remove(id) {
|
||||
this.notifications = this.notifications.filter(n => n.id !== id)
|
||||
},
|
||||
error(msg) { this.add(msg, 'error') },
|
||||
success(msg) { this.add(msg, 'success') }
|
||||
}
|
||||
})
|
||||
+8
-5
@@ -1,8 +1,7 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: "2025-07-15",
|
||||
compatibilityDate: "2026-01-01",
|
||||
devtools: { enabled: true },
|
||||
|
||||
css: [
|
||||
@@ -13,18 +12,22 @@ export default defineNuxtConfig({
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiBase: "http://localhost:8080/academics/api/v1",
|
||||
},
|
||||
},
|
||||
|
||||
modules: [
|
||||
"@nuxt/fonts",
|
||||
"@nuxt/icon",
|
||||
"@nuxt/image",
|
||||
"@nuxt/eslint",
|
||||
"shadcn-nuxt",
|
||||
"@pinia/nuxt",
|
||||
],
|
||||
|
||||
shadcn: {
|
||||
/**
|
||||
* Prefix for all the imported component
|
||||
*/
|
||||
prefix: "",
|
||||
/**
|
||||
* Directory that the component lives in.
|
||||
|
||||
Generated
+68
-21
@@ -11,6 +11,7 @@
|
||||
"@nuxt/fonts": "^0.11.4",
|
||||
"@nuxt/icon": "^2.1.0",
|
||||
"@nuxt/image": "^1.11.0",
|
||||
"@pinia/nuxt": "0.11.3",
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@vueuse/core": "^14.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -18,6 +19,7 @@
|
||||
"eslint": "^9.39.0",
|
||||
"lucide-vue-next": "^0.552.0",
|
||||
"nuxt": "^4.2.0",
|
||||
"pinia": "^3.0.4",
|
||||
"reka-ui": "^2.6.0",
|
||||
"shadcn-nuxt": "^2.3.2",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
@@ -3439,6 +3441,21 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinia/nuxt": {
|
||||
"version": "0.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@pinia/nuxt/-/nuxt-0.11.3.tgz",
|
||||
"integrity": "sha512-7WVNHpWx4qAEzOlnyrRC88kYrwnlR/PrThWT0XI1dSNyUAXu/KBv9oR37uCgYkZroqP5jn8DfzbkNF3BtKvE9w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nuxt/kit": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pinia": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
@@ -5106,12 +5123,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/devtools-kit": {
|
||||
"version": "7.7.7",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.7.tgz",
|
||||
"integrity": "sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA==",
|
||||
"version": "7.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
|
||||
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-shared": "^7.7.7",
|
||||
"@vue/devtools-shared": "^7.7.9",
|
||||
"birpc": "^2.3.0",
|
||||
"hookable": "^5.5.3",
|
||||
"mitt": "^3.0.1",
|
||||
@@ -5127,9 +5144,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vue/devtools-shared": {
|
||||
"version": "7.7.7",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.7.tgz",
|
||||
"integrity": "sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw==",
|
||||
"version": "7.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
|
||||
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"rfdc": "^1.4.1"
|
||||
@@ -7006,9 +7023,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/devalue": {
|
||||
"version": "5.4.2",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.4.2.tgz",
|
||||
"integrity": "sha512-MwPZTKEPK2k8Qgfmqrd48ZKVvzSQjgW0lXLxiIBA8dQjtf/6mw6pggHNLcyDKyf+fI6eXxlQwPsfaCMTU5U+Bw==",
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz",
|
||||
"integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dfa": {
|
||||
@@ -8409,9 +8426,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/h3": {
|
||||
"version": "1.15.4",
|
||||
"resolved": "https://registry.npmjs.org/h3/-/h3-1.15.4.tgz",
|
||||
"integrity": "sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==",
|
||||
"version": "1.15.5",
|
||||
"resolved": "https://registry.npmjs.org/h3/-/h3-1.15.5.tgz",
|
||||
"integrity": "sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie-es": "^1.2.2",
|
||||
@@ -8419,9 +8436,9 @@
|
||||
"defu": "^6.1.4",
|
||||
"destr": "^2.0.5",
|
||||
"iron-webcrypto": "^1.2.1",
|
||||
"node-mock-http": "^1.0.2",
|
||||
"node-mock-http": "^1.0.4",
|
||||
"radix3": "^1.1.2",
|
||||
"ufo": "^1.6.1",
|
||||
"ufo": "^1.6.3",
|
||||
"uncrypto": "^0.1.3"
|
||||
}
|
||||
},
|
||||
@@ -10181,9 +10198,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-mock-http": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.3.tgz",
|
||||
"integrity": "sha512-jN8dK25fsfnMrVsEhluUTPkBFY+6ybu7jSB1n+ri/vOGjJxU8J9CZhpSGkHXSkFjtUhbmoncG/YG9ta5Ludqog==",
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.4.tgz",
|
||||
"integrity": "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
@@ -10814,6 +10831,36 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pinia": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^7.7.7"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.5.0",
|
||||
"vue": "^3.5.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pinia/node_modules/@vue/devtools-api": {
|
||||
"version": "7.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
|
||||
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-kit": "^7.7.9"
|
||||
}
|
||||
},
|
||||
"node_modules/pkg-types": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
||||
@@ -13273,9 +13320,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ufo": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz",
|
||||
"integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==",
|
||||
"version": "1.6.3",
|
||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
|
||||
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ultrahtml": {
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
"clsx": "^2.1.1",
|
||||
"eslint": "^9.39.0",
|
||||
"lucide-vue-next": "^0.552.0",
|
||||
"@pinia/nuxt": "0.11.3",
|
||||
"nuxt": "^4.2.0",
|
||||
"pinia": "^3.0.4",
|
||||
"reka-ui": "^2.6.0",
|
||||
"shadcn-nuxt": "^2.3.2",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
|
||||
Reference in New Issue
Block a user