Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af6ca88fd4 | ||
|
|
e7b9fdb1bd | ||
|
|
582cb3cb4c | ||
|
|
90e0624cfa | ||
|
|
d56b1e5fb9 | ||
|
|
9d99663b0d | ||
|
|
e38613ca6b | ||
|
|
8ce343a195
|
||
|
|
3838f3ff7a | ||
|
|
9fff07c674
|
||
|
|
6eae88f4e9
|
||
|
|
e423497fd2 | ||
|
|
b9ad7879a6 | ||
|
|
3570527bbf | ||
|
|
1644d9f80c | ||
|
|
6477057798 | ||
|
|
dacec71338 | ||
|
|
0d93b212c6 | ||
|
|
c2bb9a9b77 | ||
|
|
77c05ee490 | ||
|
|
0fade43944 | ||
|
|
56844c9188 | ||
|
|
fef30e9069 | ||
|
|
c2d4b9da27 | ||
|
|
691bf60b9f | ||
|
|
55287b026b |
@@ -5,7 +5,6 @@
|
|||||||
--text-tertiary: #6e7681;
|
--text-tertiary: #6e7681;
|
||||||
--text-link: #2f81f7;
|
--text-link: #2f81f7;
|
||||||
--text-link-hover: #539bf5;
|
--text-link-hover: #539bf5;
|
||||||
--text-slate: #57606a;
|
|
||||||
|
|
||||||
--border-primary: #30363d;
|
--border-primary: #30363d;
|
||||||
--border-hover: #8d96a0;
|
--border-hover: #8d96a0;
|
||||||
@@ -60,17 +59,14 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--text-link);
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover {
|
a:hover {
|
||||||
color: var(--text-link-hover);
|
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,8 +77,7 @@ code {
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
SEMANTIC COMPONENTS (SAFE)
|
/* SEMANTIC COMPONENTS (SAFE) */
|
||||||
============================================== */
|
|
||||||
|
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
|
|||||||
@@ -0,0 +1,503 @@
|
|||||||
|
<template>
|
||||||
|
<div class="animate-fade-in-up">
|
||||||
|
<button
|
||||||
|
class="my-6 mx-6 flex items-center gap-2 text-sm font-bold text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
@click="$emit('back')">
|
||||||
|
← BACK TO FEED
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<article class="bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-2xl shadow-xl overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="relative bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-800 dark:to-slate-900 p-8 border-b dark:border-gray-800">
|
||||||
|
|
||||||
|
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-4">
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-2 text-xs font-bold text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||||
|
@click="showHistory = true">
|
||||||
|
<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 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
View history
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
class="bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider shadow-sm border border-blue-200 dark:border-blue-800">
|
||||||
|
{{ post.type.replace('_', ' ') }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm font-medium text-gray-500 dark:text-gray-400 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="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>
|
||||||
|
{{ new Date(post.publicationDate).toLocaleDateString('en-US', {
|
||||||
|
year:
|
||||||
|
'numeric', month: 'long', day: 'numeric'
|
||||||
|
}) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-6 mb-6">
|
||||||
|
<div>
|
||||||
|
<span class="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-1">Rating</span>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 px-4 py-1.5 bg-white/80 dark:bg-gray-800/80 backdrop-blur shadow-sm rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
|
<span class="text-xl font-black text-blue-600 dark:text-blue-400">{{ post.rating }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
class="content-end text-3xl md:text-4xl font-extrabold text-gray-900 dark:text-white leading-tight">
|
||||||
|
{{
|
||||||
|
post.title }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-1 md:grid-cols-2 gap-6 p-4 bg-white/50 dark:bg-gray-800/50 rounded-xl border border-gray-200 dark:border-gray-700 backdrop-blur-sm">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">Authors
|
||||||
|
</h4>
|
||||||
|
<div class="flex flex-wrap flex-col gap-2">
|
||||||
|
<span v-for="(author, idx) in post.authors" :key="idx"
|
||||||
|
class="inline-flex items-center gap-1.5 text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
<span
|
||||||
|
class="w-6 h-6 rounded-full bg-indigo-100 text-indigo-600 dark:bg-indigo-900 dark:text-indigo-300 flex items-center justify-center text-xs">
|
||||||
|
{{ author.charAt(0) }}
|
||||||
|
</span>
|
||||||
|
{{ author }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">Submitted
|
||||||
|
By</h4>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="w-8 h-8 rounded-full bg-blue-600 text-white flex items-center justify-center font-bold text-xs">
|
||||||
|
{{ post.submitter.name.charAt(0) }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-bold text-gray-900 dark:text-white">{{
|
||||||
|
post.submitter.name }}</div>
|
||||||
|
<div class="text-xs text-gray-500">{{ post.submitter.role }} • {{
|
||||||
|
post.submitter.email }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-8">
|
||||||
|
<div class="mb-10">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M4 6h16M4 12h16M4 18h7" />
|
||||||
|
</svg>
|
||||||
|
Abstract
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
class="text-gray-700 dark:text-gray-300 leading-relaxed text-lg text-justify whitespace-pre-line break-normal">
|
||||||
|
{{ post.abstractText }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col md:flex-row justify-between items-start md:items-center gap-6 mb-8 pb-8 border-b dark:border-gray-800">
|
||||||
|
<div v-if="post.tags && post.tags.length > 0">
|
||||||
|
<h4 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">Tags</h4>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span v-for="tag in post.tags" :key="tag.id"
|
||||||
|
class="px-3 py-1 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-sm font-medium border border-gray-200 dark:border-gray-700">
|
||||||
|
#{{ tag.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm text-gray-400 italic">No tags assigned</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="post.attachedFile"
|
||||||
|
class="mb-10 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 rounded-xl p-5 hover:border-blue-400 transition-colors group">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
class="p-3 bg-red-100 text-red-600 rounded-lg group-hover:scale-110 transition-transform">
|
||||||
|
<svg class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-bold text-gray-900 dark:text-white mb-0.5">Attachment
|
||||||
|
Available</div>
|
||||||
|
<div class="text-xs text-gray-500 font-mono truncate max-w-[200px] md:max-w-md">
|
||||||
|
{{ getFileName(post.attachedFile) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-lg shadow-lg shadow-blue-500/30 transform hover:-translate-y-0.5 transition-all"
|
||||||
|
@click="downloadFile(post)">
|
||||||
|
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
|
</svg>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="border-t dark:border-gray-800 pt-8">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h3 class="text-xl font-bold flex items-center gap-2">
|
||||||
|
Comments <span class="text-xs bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded-full">{{
|
||||||
|
totalComments }}</span>
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
class="w-10 h-10 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg transition-all active:scale-95 flex items-center justify-center group"
|
||||||
|
title="New Post" @click="showCommentInput = !showCommentInput">
|
||||||
|
<svg viewBox="0 0 24 24" class="w-5 h-5 fill-none stroke-current stroke-[2.5]"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition enter-active-class="transition duration-200 ease-out"
|
||||||
|
enter-from-class="transform scale-95 opacity-0"
|
||||||
|
enter-to-class="transform scale-100 opacity-100"
|
||||||
|
leave-active-class="transition duration-150 ease-in"
|
||||||
|
leave-from-class="transform scale-100 opacity-100"
|
||||||
|
leave-to-class="transform scale-95 opacity-0">
|
||||||
|
<div v-if="showCommentInput" class="mb-8 space-y-3">
|
||||||
|
<textarea v-model="commentText" rows="3"
|
||||||
|
class="w-full px-4 py-3 bg-gray-50 dark:bg-gray-800 border dark:border-gray-700 rounded-lg outline-none text-sm focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="What are your thoughts?" />
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button :disabled="!commentText.trim() || submitting"
|
||||||
|
class="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-bold px-6 py-2 rounded-lg transition-all"
|
||||||
|
@click="handleCommentSubmit">
|
||||||
|
{{ submitting ? 'POSTING...' : 'COMMENT' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div v-for="comment in comments" :key="comment.id" class="flex gap-4">
|
||||||
|
<div
|
||||||
|
class="w-8 h-8 rounded-full bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-600 shrink-0 flex items-center justify-center text-[10px] font-bold">
|
||||||
|
{{ comment.author.name.charAt(0) }}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div
|
||||||
|
class="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-xl rounded-tl-none border dark:border-gray-800">
|
||||||
|
<div class="flex justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<span class="mr-3 font-bold text-sm text-blue-600 dark:text-blue-400">{{
|
||||||
|
comment.author.name }}</span>
|
||||||
|
<button v-if="authStore.user.id == comment.author.id"
|
||||||
|
class="p-1 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 cursor-pointer"
|
||||||
|
title="Edit" @click.stop="startEdit(comment)">
|
||||||
|
<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
|
||||||
|
v-if="authStore.user.id == comment.author.id || authStore.user.role != 'COLLABORATOR'"
|
||||||
|
class="p-1 text-slate-400 hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-lg transition-all transform hover:scale-110 active:scale-95 cursor-pointer"
|
||||||
|
:title="post.hidden ? 'Unhide' : 'Hide'"
|
||||||
|
@click.stop="toggleHide(comment)">
|
||||||
|
<svg width="16px" height="16px" viewBox="0 0 16 16"
|
||||||
|
xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z" />
|
||||||
|
<path
|
||||||
|
d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z" />
|
||||||
|
|
||||||
|
<line v-if="comment.hidden" x1="3" y1="3" x2="13" y2="13"
|
||||||
|
stroke="currentColor" stroke-width="1.5"
|
||||||
|
stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="text-[10px] text-gray-400 uppercase">{{ new
|
||||||
|
Date(comment.createdAt).toLocaleDateString('en-US', {
|
||||||
|
year:
|
||||||
|
'numeric', month: 'long', day: 'numeric'
|
||||||
|
}) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<div v-if="editingCommentId === comment.id" class="space-y-2">
|
||||||
|
<textarea v-model="editText"
|
||||||
|
class="w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none"
|
||||||
|
rows="2" />
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button class="text-xs font-bold text-gray-500 hover:text-gray-700"
|
||||||
|
@click="cancelEdit">CANCEL</button>
|
||||||
|
<button class="text-xs font-bold text-blue-600 hover:text-blue-700"
|
||||||
|
:disabled="submitting" @click="handleEditSubmit(comment)">
|
||||||
|
{{ submitting ? 'SAVING...' : 'SAVE' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else
|
||||||
|
:class="['text-sm leading-relaxed whitespace-pre-wrap break-normal', comment.hidden ? 'text-gray-400 italic opacity-50' : 'text-gray-700 dark:text-gray-300']">
|
||||||
|
{{ comment.hidden ? 'This comment has been hidden.' : comment.content }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-20 flex flex-col items-center justify-center">
|
||||||
|
<p v-if="comments.length === 0 && !loadingComments"
|
||||||
|
class="text-sm text-gray-500 italic py-4">No comments yet. Be the first!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<Transition enter-active-class="transition duration-200 ease-out" enter-from-class="opacity-0 scale-95"
|
||||||
|
enter-to-class="opacity-100 scale-100" leave-active-class="transition duration-150 ease-in"
|
||||||
|
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95">
|
||||||
|
<div v-if="showHistory"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
|
<div
|
||||||
|
class="bg-white dark:bg-gray-900 w-full max-w-2xl rounded-2xl shadow-xl border dark:border-gray-800 overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex justify-between items-center px-6 py-4 border-b dark:border-gray-800">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white">
|
||||||
|
Publication history
|
||||||
|
</h3>
|
||||||
|
<button class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
@click="showHistory = false">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="p-6 space-y-4 max-h-[60vh] overflow-y-auto">
|
||||||
|
<div v-for="entry in publicationHistory" :key="entry.id"
|
||||||
|
class="p-4 rounded-xl border border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/50">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<!-- Action tag -->
|
||||||
|
<span class="px-2 py-0.5 rounded-md text-[10px] font-bold uppercase tracking-wide"
|
||||||
|
:class="actionTagClass(entry.actionType)">
|
||||||
|
{{ formatAction(entry.actionType) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 flex items-start gap-4">
|
||||||
|
<!-- Text -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{{ entry.description }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-400">
|
||||||
|
{{ new Date(entry.timestamp).toLocaleString() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
post: Object,
|
||||||
|
api: String,
|
||||||
|
token: String
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['back', 'comment-added']);
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const notifications = useNotificationStore();
|
||||||
|
const comments = ref([]);
|
||||||
|
const commentText = ref('');
|
||||||
|
const submitting = ref(false);
|
||||||
|
const loadingComments = ref(false);
|
||||||
|
const showCommentInput = ref(false);
|
||||||
|
const totalComments = ref(0);
|
||||||
|
const editingCommentId = ref(null);
|
||||||
|
const editText = ref('');
|
||||||
|
const showHistory = ref(false)
|
||||||
|
const publicationHistory = ref([])
|
||||||
|
|
||||||
|
const actionTagClass = (type) => {
|
||||||
|
const map = {
|
||||||
|
// Publication lifecycle
|
||||||
|
CREATE_PUBLICATION: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||||
|
DELETE_PUBLICATION: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
|
||||||
|
EDIT_PUBLICATION: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
|
TAG_PUBLICATION: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||||
|
HIDE_PUBLICATION: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300',
|
||||||
|
|
||||||
|
// Interaction
|
||||||
|
COMMENT_PUBLICATION: 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300',
|
||||||
|
EDIT_COMMENT: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300',
|
||||||
|
HIDE_COMMENT: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
|
||||||
|
RATE_PUBLICATION: 'bg-pink-100 text-pink-700 dark:bg-pink-900/40 dark:text-pink-300',
|
||||||
|
|
||||||
|
// User / admin actions
|
||||||
|
CREATE_USER: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||||
|
DELETE_USER: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300',
|
||||||
|
CHANGE_ROLE: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
|
||||||
|
CHANGE_EMAIL: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300',
|
||||||
|
CHANGE_NAME: 'bg-lime-100 text-lime-700 dark:bg-lime-900/40 dark:text-lime-300',
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
UPLOAD_PUBLICATION: 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[type] ?? 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatAction = (type) => {
|
||||||
|
return type
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleHide = async (comment) => {
|
||||||
|
try {
|
||||||
|
comment.hidden = !comment.hidden;
|
||||||
|
|
||||||
|
await $fetch(`${props.api}/comments/${comment.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: `Bearer ${props.token}` },
|
||||||
|
body: { is_hidden: comment.hidden }
|
||||||
|
});
|
||||||
|
|
||||||
|
notifications.success(comment.hidden ? "Comment hidden" : "Comment visible");
|
||||||
|
} catch (err) {
|
||||||
|
comment.hidden = !comment.hidden;
|
||||||
|
notifications.error("Failed to update comment visibility");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = (comment) => {
|
||||||
|
editingCommentId.value = comment.id;
|
||||||
|
editText.value = comment.content;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
editingCommentId.value = null;
|
||||||
|
editText.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSubmit = async (comment) => {
|
||||||
|
if (!editText.value.trim() || submitting.value) return;
|
||||||
|
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
await $fetch(`${props.api}/comments/${comment.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { Authorization: `Bearer ${props.token}` },
|
||||||
|
body: { content: editText.value }
|
||||||
|
});
|
||||||
|
|
||||||
|
comment.content = editText.value;
|
||||||
|
editingCommentId.value = null;
|
||||||
|
notifications.success("Comment updated!");
|
||||||
|
} catch (err) {
|
||||||
|
notifications.error("Failed to edit comment");
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchComments = async () => {
|
||||||
|
if (loadingComments.value) return;
|
||||||
|
loadingComments.value = true;
|
||||||
|
try {
|
||||||
|
const data = await $fetch(`${props.api}/publications/${props.post.id}/comments`, {
|
||||||
|
headers: { Authorization: `Bearer ${props.token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
comments.value = data.items || data;
|
||||||
|
totalComments.value = data.total || comments.value.length;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Comments fetch error", err);
|
||||||
|
} finally {
|
||||||
|
loadingComments.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPublicationHistory = async () => {
|
||||||
|
try {
|
||||||
|
const history = await $fetch(`${props.api}/publications/${props.post.id}/history`, {
|
||||||
|
headers: { Authorization: `Bearer ${props.token}` }
|
||||||
|
});
|
||||||
|
return history;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Publication history fetch error", err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCommentSubmit = async () => {
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
const newComment = await $fetch(`${props.api}/publications/${props.post.id}/comments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${props.token}` },
|
||||||
|
body: { content: commentText.value }
|
||||||
|
});
|
||||||
|
|
||||||
|
comments.value.unshift(newComment);
|
||||||
|
totalComments.value++;
|
||||||
|
commentText.value = '';
|
||||||
|
showCommentInput.value = false;
|
||||||
|
emit('comment-added');
|
||||||
|
notifications.success("Comment posted!");
|
||||||
|
} catch (err) {
|
||||||
|
notifications.error("Failed to post comment");
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileName = (fullPath) => {
|
||||||
|
if (!fullPath) return null;
|
||||||
|
return fullPath.split(/[/\\]/).pop();
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadFile = async () => {
|
||||||
|
if (!props.post.attachedFile) return;
|
||||||
|
|
||||||
|
const filename = getFileName(props.post.attachedFile);
|
||||||
|
|
||||||
|
const downloadUrl = `${props.api}/publications/download/${filename}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = downloadUrl;
|
||||||
|
link.setAttribute('download', filename);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
} catch (e) {
|
||||||
|
notifications.error("Could not download file.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchComments();
|
||||||
|
fetchPublicationHistory().then(history => {
|
||||||
|
publicationHistory.value = history;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, reactive, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
isOpen: Boolean,
|
||||||
|
api: String,
|
||||||
|
token: String,
|
||||||
|
postToEdit: Object
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'refresh']);
|
||||||
|
|
||||||
|
const notifications = useNotificationStore();
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
const availableTags = ref([]);
|
||||||
|
const errors = reactive({
|
||||||
|
title: '',
|
||||||
|
abstract: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadForm = reactive({
|
||||||
|
title: '',
|
||||||
|
abstract: '',
|
||||||
|
type: 'CONFERENCE_PAPER',
|
||||||
|
newAuthorName: '',
|
||||||
|
authors: [],
|
||||||
|
selectedTagIds: [],
|
||||||
|
file: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
let isValid = true;
|
||||||
|
errors.title = '';
|
||||||
|
errors.abstract = '';
|
||||||
|
|
||||||
|
if (!uploadForm.title.trim()) {
|
||||||
|
errors.title = 'Title is required';
|
||||||
|
isValid = false;
|
||||||
|
} else if (uploadForm.title.length > 50) {
|
||||||
|
errors.title = 'Title cannot exceed 50 characters';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!uploadForm.abstract.trim()) {
|
||||||
|
errors.abstract = 'Abstract is required';
|
||||||
|
isValid = false;
|
||||||
|
} else if (uploadForm.abstract.length > 2000) {
|
||||||
|
errors.abstract = 'Abstract cannot exceed 2000 characters';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchTags() {
|
||||||
|
if (!props.token) return;
|
||||||
|
try {
|
||||||
|
const data = await $fetch(`${props.api}/tags`, {
|
||||||
|
headers: { Authorization: `Bearer ${props.token}` }
|
||||||
|
});
|
||||||
|
availableTags.value = data || [];
|
||||||
|
} catch (e) { console.error("Tags fetch failed", e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPublication() {
|
||||||
|
if (!validate()) return;
|
||||||
|
|
||||||
|
isSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
const metadata = {
|
||||||
|
title: uploadForm.title,
|
||||||
|
authors: uploadForm.authors,
|
||||||
|
type: uploadForm.type,
|
||||||
|
abstract: uploadForm.abstract,
|
||||||
|
tags: uploadForm.selectedTagIds
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEditing = !!props.postToEdit;
|
||||||
|
const url = isEditing
|
||||||
|
? `${props.api}/publications/${props.postToEdit.id}`
|
||||||
|
: `${props.api}/publications`;
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Bearer ${props.token}`
|
||||||
|
};
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
if (uploadForm.file) formData.append('file', uploadForm.file);
|
||||||
|
formData.append('data', JSON.stringify(metadata));
|
||||||
|
|
||||||
|
await $fetch(url, {
|
||||||
|
method: isEditing ? 'PATCH' : 'POST',
|
||||||
|
headers,
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
notifications.success(`Publication ${isEditing ? "updated" : "created"}`);
|
||||||
|
emit('refresh');
|
||||||
|
closeModal();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Submission error:", error);
|
||||||
|
notifications.error("Operation failed");
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAuthor() {
|
||||||
|
if (uploadForm.newAuthorName.trim()) {
|
||||||
|
uploadForm.authors.push(uploadForm.newAuthorName.trim());
|
||||||
|
uploadForm.newAuthorName = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAuthor(index) { uploadForm.authors.splice(index, 1); }
|
||||||
|
|
||||||
|
function handleFileChange(event) {
|
||||||
|
const input = event.target;
|
||||||
|
if (input.files?.[0]) uploadForm.file = input.files[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
uploadForm.title = '';
|
||||||
|
uploadForm.abstract = '';
|
||||||
|
uploadForm.type = 'CONFERENCE_PAPER';
|
||||||
|
uploadForm.newAuthorName = '';
|
||||||
|
uploadForm.authors = [];
|
||||||
|
uploadForm.selectedTagIds = [];
|
||||||
|
uploadForm.file = null;
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.isOpen, (newVal) => {
|
||||||
|
if (newVal && availableTags.value.length === 0) fetchTags();
|
||||||
|
if (!newVal) {
|
||||||
|
errors.title = '';
|
||||||
|
errors.abstract = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.postToEdit, (newPost) => {
|
||||||
|
if (newPost) {
|
||||||
|
uploadForm.title = newPost.title;
|
||||||
|
uploadForm.abstract = newPost.abstractText || newPost.abstract;
|
||||||
|
uploadForm.type = newPost.type;
|
||||||
|
uploadForm.authors = [...newPost?.authors];
|
||||||
|
uploadForm.selectedTagIds = newPost?.tags.map((t) => t.id);
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const getTagClass = (id) => {
|
||||||
|
const colors = [
|
||||||
|
'bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-900/30 dark:text-pink-300',
|
||||||
|
'bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-900/30 dark:text-purple-300',
|
||||||
|
'bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-300',
|
||||||
|
'bg-cyan-100 text-cyan-700 border-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-300',
|
||||||
|
'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-300',
|
||||||
|
'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-300',
|
||||||
|
];
|
||||||
|
return colors[id % colors.length];
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Transition enter-active-class="transition duration-200 ease-out" enter-from-class="opacity-0"
|
||||||
|
enter-to-class="opacity-100" leave-active-class="transition duration-150 ease-in" leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0">
|
||||||
|
<div v-if="props.isOpen" class="fixed inset-0 z-50 overflow-y-auto">
|
||||||
|
<div class="fixed inset-0 bg-slate-900/75 backdrop-blur-sm" @click="closeModal" />
|
||||||
|
|
||||||
|
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||||
|
<div
|
||||||
|
class="relative transform overflow-hidden rounded-2xl bg-white dark:bg-slate-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl border border-slate-200 dark:border-slate-800">
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="bg-slate-50 dark:bg-slate-800/50 px-6 py-4 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
|
||||||
|
<h3 class="text-lg font-bold text-slate-900 dark:text-white">Submit New Publication</h3>
|
||||||
|
<button class="text-slate-400 hover:text-slate-500 dark:hover:text-slate-300"
|
||||||
|
@click="closeModal">
|
||||||
|
<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="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-6 py-6 max-h-[80vh] overflow-y-auto space-y-6">
|
||||||
|
<div
|
||||||
|
class="flex justify-center rounded-lg border-2 border-dashed border-slate-300 dark:border-slate-700 px-6 py-8 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
||||||
|
<div class="text-center">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-slate-400" fill="none" viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||||
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||||
|
</svg>
|
||||||
|
<div
|
||||||
|
class="mt-4 flex text-sm leading-6 text-slate-600 dark:text-slate-400 justify-center">
|
||||||
|
<label for="file-upload"
|
||||||
|
class="relative cursor-pointer rounded-md font-semibold text-blue-600 hover:text-blue-500">
|
||||||
|
<span>Upload a file</span>
|
||||||
|
<input id="file-upload" type="file" class="sr-only" accept=".pdf,.zip"
|
||||||
|
@change="handleFileChange">
|
||||||
|
</label>
|
||||||
|
<p class="pl-1">or drag and drop</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="uploadForm.file"
|
||||||
|
class="mt-2 text-sm font-bold text-green-600 dark:text-green-400 text-center">
|
||||||
|
Selected: {{ uploadForm.file.name }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label
|
||||||
|
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Title</label>
|
||||||
|
<input v-model="uploadForm.title" type="text"
|
||||||
|
:class="[errors.title ? 'border-red-500' : 'border-slate-300 dark:border-slate-700']"
|
||||||
|
class="w-full rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm focus:ring-blue-500 sm:text-sm py-2.5 px-3 border"
|
||||||
|
placeholder="e.g. A New Technique for Data Science">
|
||||||
|
<p v-if="errors.title" class="mt-1 text-xs text-red-500">{{ errors.title }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Type</label>
|
||||||
|
<select v-model="uploadForm.type"
|
||||||
|
class="w-full rounded-lg border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm focus:ring-blue-500 sm:text-sm py-2.5 px-3 border">
|
||||||
|
<option value="CONFERENCE_PAPER">Conference Paper</option>
|
||||||
|
<option value="RESEARCH_POST">Research Post</option>
|
||||||
|
<option value="THESIS">Thesis</option>
|
||||||
|
<option value="ARTICLE">Article</option>
|
||||||
|
<option value="REPORT">Report</option>
|
||||||
|
<option value="OTHER">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||||
|
Abstract ({{ uploadForm.abstract.length }}/255)
|
||||||
|
</label>
|
||||||
|
<textarea v-model="uploadForm.abstract" rows="4"
|
||||||
|
:class="[errors.abstract ? 'border-red-500' : 'border-slate-300 dark:border-slate-700']"
|
||||||
|
class="w-full rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm focus:ring-blue-500 sm:text-sm py-2 px-3 border"
|
||||||
|
placeholder="Enter a brief summary..." />
|
||||||
|
<p v-if="errors.abstract" class="mt-1 text-xs text-red-500">{{ errors.abstract }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col md:flex-row gap-8">
|
||||||
|
|
||||||
|
<div class="w-full">
|
||||||
|
<label
|
||||||
|
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Authors</label>
|
||||||
|
<div class="relative flex items-center">
|
||||||
|
<input v-model="uploadForm.newAuthorName" type="text"
|
||||||
|
class="w-full rounded-lg border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm text-sm py-2 pl-3 pr-10 border"
|
||||||
|
placeholder="Name" @keyup.enter="addAuthor">
|
||||||
|
<button type="button"
|
||||||
|
class="absolute right-2 p-1 text-blue-600 hover:text-blue-700 transition-colors"
|
||||||
|
@click="addAuthor">
|
||||||
|
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||||
|
stroke-width="3">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div :class="['flex flex-wrap gap-2', uploadForm.authors.length != 0 ? 'mt-3' : '']">
|
||||||
|
<span v-for="(author, index) in uploadForm.authors" :key="index"
|
||||||
|
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-xs font-medium border border-slate-200 dark:border-slate-700">
|
||||||
|
{{ author }}
|
||||||
|
<button @click="removeAuthor(index)">
|
||||||
|
<svg class="w-3 h-3" 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>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 md:w-auto min-w-[300px]">
|
||||||
|
<label
|
||||||
|
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Tags</label>
|
||||||
|
<div
|
||||||
|
class="overflow-y-auto border border-slate-200 dark:border-slate-700 rounded-lg p-3 bg-slate-50/50 dark:bg-slate-800/30 flex flex-wrap gap-2 content-start">
|
||||||
|
<label v-if="availableTags.length == 0"
|
||||||
|
class="relative rounded-md font-semibold text-blue-600">
|
||||||
|
<span>No tags</span>
|
||||||
|
</label>
|
||||||
|
<button v-for="tag in availableTags" :key="tag.id" type="button" :class="[
|
||||||
|
uploadForm.selectedTagIds.includes(tag.id)
|
||||||
|
? getTagClass(tag.id) + ' border-transparent'
|
||||||
|
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400'
|
||||||
|
]" class="px-3 py-1.5 rounded-full border text-xs font-bold uppercase tracking-tight transition-all shadow-sm whitespace-nowrap"
|
||||||
|
@click="() => {
|
||||||
|
const idx = uploadForm.selectedTagIds.indexOf(tag.id);
|
||||||
|
if (idx > -1) uploadForm.selectedTagIds.splice(idx, 1);
|
||||||
|
else uploadForm.selectedTagIds.push(tag.id);
|
||||||
|
}">
|
||||||
|
{{ tag.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="bg-slate-50 dark:bg-slate-800/50 px-6 py-4 flex flex-row-reverse gap-3 border-t border-slate-100 dark:border-slate-800">
|
||||||
|
<button :disabled="isSubmitting"
|
||||||
|
class="inline-flex w-full justify-center rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-bold text-white shadow-sm hover:bg-blue-500 disabled:opacity-50 sm:w-auto transition-all"
|
||||||
|
@click="submitPublication">
|
||||||
|
{{ isSubmitting ? 'Uploading...' : 'Submit' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="inline-flex w-full justify-center rounded-lg bg-white dark:bg-slate-800 px-6 py-2.5 text-sm font-bold text-slate-700 dark:text-slate-300 shadow-sm ring-1 ring-inset ring-slate-300 dark:ring-slate-700 hover:bg-slate-50 sm:w-auto transition-all"
|
||||||
|
@click="closeModal">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
+163
-152
@@ -1,10 +1,171 @@
|
|||||||
|
<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
|
||||||
|
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="flex items-center gap-3">
|
||||||
|
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
@click="toggleDark">
|
||||||
|
<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" ref="userMenuRef" class="relative">
|
||||||
|
<button
|
||||||
|
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 }"
|
||||||
|
@click.stop="isUserMenuOpen = !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" />
|
||||||
|
<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>
|
||||||
|
</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
|
||||||
|
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"
|
||||||
|
@click="navigateTo('/profile')">
|
||||||
|
<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
|
||||||
|
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"
|
||||||
|
@click="navigateTo('/admin/users')">
|
||||||
|
<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
|
||||||
|
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"
|
||||||
|
@click="handleLogout">
|
||||||
|
<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
|
||||||
|
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"
|
||||||
|
@click="isMobileMenuOpen = !isMobileMenuOpen">
|
||||||
|
<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>
|
<script setup>
|
||||||
import { useAuthStore } from "~/stores/auth-store.js";
|
import { useAuthStore } from "~/stores/auth-store.js";
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// State (No more isScrolled)
|
// State
|
||||||
const isDark = ref(false);
|
const isDark = ref(false);
|
||||||
const isUserMenuOpen = ref(false);
|
const isUserMenuOpen = ref(false);
|
||||||
const isMobileMenuOpen = ref(false);
|
const isMobileMenuOpen = ref(false);
|
||||||
@@ -24,10 +185,7 @@ function toggleDark() {
|
|||||||
|
|
||||||
// Navigation Links
|
// Navigation Links
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ name: 'Dashboard', path: '/' },
|
{ name: 'Profile', path: '/profile' },
|
||||||
{ name: 'Courses', path: '/courses' },
|
|
||||||
{ name: 'Students', path: '/students' },
|
|
||||||
{ name: 'Schedule', path: '/schedule' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
@@ -64,150 +222,3 @@ onUnmounted(() => {
|
|||||||
window.removeEventListener('click', closeDropdowns);
|
window.removeEventListener('click', closeDropdowns);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
|
||||||
<nav
|
|
||||||
class="sticky top-0 z-50 w-full border-b border-slate-200 dark:border-slate-800 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md 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-2 group flex-shrink-0">
|
|
||||||
<div
|
|
||||||
class="w-9 h-9 rounded-lg bg-blue-600 flex items-center justify-center text-white font-bold text-xl shadow-blue-500/20 shadow-lg group-hover:scale-110 transition-transform">
|
|
||||||
A
|
|
||||||
</div>
|
|
||||||
<span class="text-xl font-bold text-slate-900 dark:text-white tracking-tight hidden md:block">
|
|
||||||
Academic<span class="text-blue-600">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-3 py-2 rounded-md text-sm font-medium 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-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 font-semibold">
|
|
||||||
{{ 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 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
||||||
<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-2 pr-1 py-1.5 rounded-full border border-transparent hover:bg-slate-100 dark:hover:bg-slate-800 transition-all focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
:class="{ 'bg-slate-100 dark:bg-slate-800': isUserMenuOpen }">
|
|
||||||
<span
|
|
||||||
class="text-sm font-semibold text-slate-700 dark:text-slate-200 hidden sm:block ml-2">
|
|
||||||
{{ authStore.user.name.split(' ')[0] }}
|
|
||||||
</span>
|
|
||||||
<img :src="authStore.user.avatarUrl || `https://ui-avatars.com/api/?name=${authStore.user.name}&background=0D8ABC&color=fff`"
|
|
||||||
alt="Avatar"
|
|
||||||
class="w-9 h-9 rounded-full border border-slate-200 dark:border-slate-700 object-cover">
|
|
||||||
<svg class="w-4 h-4 text-slate-400" :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-100 dark:border-slate-800 py-2 focus:outline-none z-50">
|
|
||||||
<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-medium">Signed in as
|
|
||||||
</p>
|
|
||||||
<p class="text-sm font-bold text-slate-900 dark:text-white truncate">{{
|
|
||||||
authStore.user.email }}</p>
|
|
||||||
</div>
|
|
||||||
<button @click="navigateTo('/profile')"
|
|
||||||
class="w-full text-left px-4 py-2.5 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800 flex items-center gap-3 transition-colors">
|
|
||||||
<span>👤</span> Your Profile
|
|
||||||
</button>
|
|
||||||
<button @click="navigateTo('/settings')"
|
|
||||||
class="w-full text-left px-4 py-2.5 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800 flex items-center gap-3 transition-colors">
|
|
||||||
<span>⚙️</span> Settings
|
|
||||||
</button>
|
|
||||||
<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 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-3 transition-colors">
|
|
||||||
<span>🚪</span> 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-blue-600 hover:bg-blue-700 rounded-lg shadow-sm shadow-blue-500/30 transition-all">
|
|
||||||
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 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-3 py-3 rounded-lg text-base font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800 hover:text-blue-600"
|
|
||||||
active-class="bg-blue-50 dark:bg-blue-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-blue-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">
|
|
||||||
<slot />
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
|
|||||||
@@ -0,0 +1,686 @@
|
|||||||
|
<template>
|
||||||
|
<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
|
||||||
|
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"
|
||||||
|
@click="openCreateModal">
|
||||||
|
<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" />
|
||||||
|
<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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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 === 'ADMINISTRATOR',
|
||||||
|
'bg-emerald-100 border-emerald-200 text-emerald-700 dark:bg-emerald-900/20 dark:border-emerald-800 dark:text-emerald-300': user.role === 'MODERATOR',
|
||||||
|
'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'
|
||||||
|
}">
|
||||||
|
{{ 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'" />
|
||||||
|
{{ 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
|
||||||
|
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" @click.stop="openEditModal(user)">
|
||||||
|
<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
|
||||||
|
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" @click.stop="confirmDelete(user)">
|
||||||
|
<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 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
|
||||||
|
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" />
|
||||||
|
<h3 class="text-xl font-bold text-slate-900 dark:text-white">{{ isEditing ?
|
||||||
|
'Edit User Details' : 'Create' }}</h3>
|
||||||
|
</div>
|
||||||
|
<button class="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
|
||||||
|
@click="closeModal"><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="COLLABORATOR">Collaborator</option>
|
||||||
|
<option value="MODERATOR">Moderator</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>
|
||||||
|
<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"
|
||||||
|
class="text-sm text-red-500 hover:text-red-600 font-semibold flex items-center gap-2"
|
||||||
|
@click="sendPasswordRecovery">
|
||||||
|
<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
|
||||||
|
class="px-8 py-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-xl shadow-lg transition-all"
|
||||||
|
@click="handleSubmit">{{
|
||||||
|
isEditing ? 'Save Changes' : 'Create' }}</button>
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
@click="closeModal">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 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 class="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
|
||||||
|
@click="closeDetailsModal"><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.actionType.replace('_', ' ') }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-slate-400 font-medium">
|
||||||
|
{{ item.targetType }} #{{ item.targetId }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-slate-700 dark:text-slate-300 leading-relaxed">
|
||||||
|
{{ item.description }}
|
||||||
|
</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
|
||||||
|
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"
|
||||||
|
@click="closeDetailsModal">Close</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
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);
|
||||||
|
const notifications = useNotificationStore();
|
||||||
|
|
||||||
|
const users = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const showModal = ref(false);
|
||||||
|
const showDetailsModal = ref(false);
|
||||||
|
const isEditing = ref(false);
|
||||||
|
const searchQuery = ref('');
|
||||||
|
const selectedUser = ref(null);
|
||||||
|
const userHistory = ref([]);
|
||||||
|
|
||||||
|
const formData = reactive({
|
||||||
|
id: 0,
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
role: 'COLLABORATOR',
|
||||||
|
is_active: true
|
||||||
|
});
|
||||||
|
|
||||||
|
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 === 'ADMINISTRATOR').length);
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const data = await $fetch(`${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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
notifications.success("User created successfully");
|
||||||
|
closeModal();
|
||||||
|
fetchUsers();
|
||||||
|
} catch (error) {
|
||||||
|
notifications.error("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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
notifications.success("User updated successfully");
|
||||||
|
closeModal();
|
||||||
|
fetchUsers();
|
||||||
|
} catch (error) {
|
||||||
|
notifications.error("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',
|
||||||
|
body: {
|
||||||
|
email: formData.email
|
||||||
|
}
|
||||||
|
});
|
||||||
|
notifications.success(`Recovery sent to ${formData.email}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
notifications.error("Failed to send recovery");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async (user) => {
|
||||||
|
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}` }
|
||||||
|
});
|
||||||
|
notifications.success("Deleted user");
|
||||||
|
fetchUsers();
|
||||||
|
} catch (error) {
|
||||||
|
notifications.error("Failed to delete user");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openUserDetails = async (user) => {
|
||||||
|
selectedUser.value = user;
|
||||||
|
showDetailsModal.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await $fetch(`${api}/users/${user.id}/history`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Authorization: `Bearer ${token.value}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
|
userHistory.value = Object.values(data).sort((a, b) => b.timestamp - a.timestamp);
|
||||||
|
} else {
|
||||||
|
userHistory.value = [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching user history", error);
|
||||||
|
notifications.error("Failed to load activity history");
|
||||||
|
userHistory.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDetailsModal = () => {
|
||||||
|
showDetailsModal.value = false;
|
||||||
|
selectedUser.value = null;
|
||||||
|
userHistory.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
isEditing.value = false;
|
||||||
|
resetForm();
|
||||||
|
showModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = (user) => {
|
||||||
|
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 = 'COLLABORATOR';
|
||||||
|
formData.is_active = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInitials = (name) => {
|
||||||
|
if (!name) return '??';
|
||||||
|
return name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchUsers();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -49,6 +49,7 @@ async function handleResetRequest() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<title>Reset Password - Reddit for Academic Papers</title>
|
||||||
<div
|
<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">
|
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"
|
<Transition appear enter-active-class="transition duration-700 ease-out"
|
||||||
@@ -56,7 +57,7 @@ async function handleResetRequest() {
|
|||||||
|
|
||||||
<div
|
<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">
|
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>
|
<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">
|
<form class="space-y-6" @submit.prevent="handleResetRequest">
|
||||||
<div>
|
<div>
|
||||||
@@ -68,7 +69,7 @@ async function handleResetRequest() {
|
|||||||
'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',
|
'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'
|
error ? 'border-red-500 ring-1 ring-red-500' : 'border-gray-200 dark:border-gray-700'
|
||||||
]" placeholder="[email protected]">
|
]" placeholder="[email protected]">
|
||||||
<p v-if="error" class="mt-1.5 text-xs text-red-500! ml-1 font-medium">{{ error }}</p>
|
<p v-if="error" class="mt-1.5 text-xs text-red-500 ml-1 font-medium">{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col items-center gap-4 pt-2">
|
<div class="flex flex-col items-center gap-4 pt-2">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<div
|
<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">
|
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>
|
<h1 class="text-3xl font-bold text-gray-800 dark:text-white mb-8 text-center capitalize">Login</h1>
|
||||||
|
|
||||||
<div v-if="globalError"
|
<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">
|
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">
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
'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',
|
'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'
|
errors.email ? 'border-red-500 ring-1 ring-red-500' : 'border-gray-200 dark:border-gray-700'
|
||||||
]" placeholder="[email protected]">
|
]" placeholder="[email protected]">
|
||||||
<p v-if="errors.email" class="mt-1 text-xs text-red-500! ml-1">{{ errors.email }}</p>
|
<p v-if="errors.email" class="mt-1 text-xs text-red-500 ml-1">{{ errors.email }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
'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',
|
'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'
|
errors.password ? 'border-red-500 ring-1 ring-red-500' : 'border-gray-200 dark:border-gray-700'
|
||||||
]" placeholder="••••••••" @keyup.enter="handleLogin">
|
]" placeholder="••••••••" @keyup.enter="handleLogin">
|
||||||
<p v-if="errors.password" class="mt-1 text-xs text-red-500! ml-1">{{ errors.password }}</p>
|
<p v-if="errors.password" class="mt-1 text-xs text-red-500 ml-1">{{ errors.password }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col items-center gap-4 pt-2">
|
<div class="flex flex-col items-center gap-4 pt-2">
|
||||||
@@ -91,7 +91,6 @@ async function handleLogin() {
|
|||||||
body: loginFormData,
|
body: loginFormData,
|
||||||
onResponse({ response }) {
|
onResponse({ response }) {
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
console.log(response)
|
|
||||||
token.value = response._data.token;
|
token.value = response._data.token;
|
||||||
user.value = response._data.user;
|
user.value = response._data.user;
|
||||||
if (response._data.user.mustChangePassword === true) {
|
if (response._data.user.mustChangePassword === true) {
|
||||||
@@ -102,7 +101,6 @@ async function handleLogin() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onResponseError({ response }) {
|
onResponseError({ response }) {
|
||||||
console.log(response.status)
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
globalError.value = "Invalid email or password.";
|
globalError.value = "Invalid email or password.";
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+585
-37
@@ -1,47 +1,595 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-center justify-center p-6 transition-colors duration-300">
|
class="min-h-screen bg-gray-100 dark:bg-gray-950 text-gray-900 dark:text-gray-100 transition-colors duration-300">
|
||||||
<div class="w-full max-w-2xl animate-[fadeInUp_0.6s_ease-out]">
|
<div class="max-w-6xl mx-auto flex gap-6 py-8 px-4">
|
||||||
|
|
||||||
<header class="text-center mb-12">
|
<aside v-if="!selectedPost" class="hidden lg:block w-fit min-w-[160px] py-24">
|
||||||
<h1 class="text-4xl md:text-5xl font-extrabold text-gray-900 dark:text-white! mb-4 tracking-tight">
|
<div class="sticky top-24">
|
||||||
Academic Management System
|
<div class="flex flex-col gap-2">
|
||||||
</h1>
|
<button v-if="user?.role !== 'COLLABORATOR'"
|
||||||
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-md mx-auto">
|
class="mb-2 px-3 py-2 rounded-lg text-xs font-bold bg-blue-600 hover:bg-blue-700 text-white shadow-md transition-all active:scale-95 flex items-center justify-center gap-1"
|
||||||
Streamline your educational workflow, manage student records, and oversee courses in one place.
|
@click="showCreateInput = !showCreateInput">
|
||||||
</p>
|
<span>+</span> NEW TAG
|
||||||
</header>
|
</button>
|
||||||
|
|
||||||
<nav class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div v-if="showCreateInput" class="mb-2">
|
||||||
|
<input ref="createInputRef" v-model="newTagName" placeholder="Tag name..."
|
||||||
|
class="w-full px-3 py-2 rounded-lg text-xs font-bold bg-white dark:bg-gray-800 border-2 border-blue-500 outline-none"
|
||||||
|
autofocus @keyup.enter="handleCreateTag" @blur="showCreateInput = false">
|
||||||
|
</div>
|
||||||
|
<div v-for="tag in availableTags" :key="tag.id" class="relative group">
|
||||||
|
<button v-if="editingTagId !== tag.id" :class="[
|
||||||
|
'w-full px-3 py-2 rounded-lg text-xs font-bold transition-all text-left shadow-sm cursor-pointer flex justify-between items-center pr-8',
|
||||||
|
subscribedTags.includes(tag.id)
|
||||||
|
? `${tag.color} border-current ring-1 ring-inset ring-black/5`
|
||||||
|
: 'bg-gray-50 dark:bg-gray-800 dark:border-gray-800 border opacity-60 hover:opacity-100'
|
||||||
|
]" @click="toggleTagSubscription(tag)">
|
||||||
|
<span class="truncate">
|
||||||
|
<span class="opacity-70 mr-1">{{ subscribedTags.includes(tag.id) ? '✓' : '#' }}</span>
|
||||||
|
{{ tag.name }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<nuxt-link to="/auth/login"
|
<input v-else v-model="editTagName"
|
||||||
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">
|
class="w-full px-3 py-2 rounded-lg text-xs font-bold bg-white dark:bg-gray-900 border-2 border-blue-500 outline-none"
|
||||||
<div class="text-3xl mb-4 group-hover:scale-110 transition-transform">🔐</div>
|
autofocus @keyup.enter="handleEditTag(tag.id)" @blur="editingTagId = null">
|
||||||
<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"
|
<button v-if="user?.role !== 'COLLABORATOR' && editingTagId !== tag.id"
|
||||||
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">
|
class="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 p-1 hover:text-blue-500 transition-all"
|
||||||
<div class="text-3xl mb-4 group-hover:scale-110 transition-transform">🛠️</div>
|
@click.stop="startEditing(tag)">
|
||||||
<h2 class="text-xl font-bold text-gray-800 dark:text-gray-100! mb-2">System Test</h2>
|
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Verify API connections and authentication state
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
persistence.</p>
|
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||||
</nuxt-link>
|
</svg>
|
||||||
|
</button>
|
||||||
</nav>
|
</div>
|
||||||
|
</div>
|
||||||
<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>
|
</div>
|
||||||
</footer>
|
</aside>
|
||||||
|
|
||||||
|
<main :class="[
|
||||||
|
'py-8 flex-1 transition-all duration-500 ease-in-out',
|
||||||
|
selectedPost ? 'max-w-3xl mx-auto' : 'max-w-2xl'
|
||||||
|
]">
|
||||||
|
|
||||||
|
<div v-show="!selectedPost" class="space-y-4">
|
||||||
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 gap-4">
|
||||||
|
<div class="flex flex-wrap items-center gap-2 w-full sm:w-auto">
|
||||||
|
<div class="relative flex-1 sm:flex-none">
|
||||||
|
<input v-model.lazy="searchQuery" type="text" placeholder="Search publications..."
|
||||||
|
class="w-full sm:w-48 bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-lg pl-3 pr-3 py-2 text-xs font-bold outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
@keyup.enter="$el.blur()">
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<select v-model="sortBy"
|
||||||
|
class="bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-l-lg px-3 py-2 text-xs font-bold uppercase outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer border-r-0">
|
||||||
|
<option value="date">Date</option>
|
||||||
|
<option value="votes">Rating</option>
|
||||||
|
<option value="comments">Comments</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
class="bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-r-lg px-3 py-2 text-xs font-bold hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
:title="sortDesc ? 'Descending' : 'Ascending'" @click="sortDesc = !sortDesc">
|
||||||
|
<span class="inline-block transition-transform duration-200" :class="{ 'rotate-180': !sortDesc }">
|
||||||
|
▼
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select v-model="filterType"
|
||||||
|
class="bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-lg px-3 py-2 text-xs font-bold uppercase outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer">
|
||||||
|
<option value="ALL">All Types</option>
|
||||||
|
<option value="CONFERENCE_PAPER">Conference Paper</option>
|
||||||
|
<option value="RESEARCH_POST">Research Post</option>
|
||||||
|
<option value="THESIS">Thesis</option>
|
||||||
|
<option value="ARTICLE">Article</option>
|
||||||
|
<option value="REPORT">Report</option>
|
||||||
|
<option value="OTHER">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="w-10 h-10 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg transition-all active:scale-95 flex items-center justify-center group"
|
||||||
|
title="New Post" @click="isModalOpen = true">
|
||||||
|
<svg viewBox="0 0 24 24" class="w-5 h-5 fill-none stroke-current stroke-[2.5]"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="filteredPosts.length === 0 && !loading"
|
||||||
|
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 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>
|
||||||
|
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
@click="isModalOpen = true">
|
||||||
|
<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 post
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TransitionGroup v-else appear enter-active-class="transition duration-500 ease-out"
|
||||||
|
enter-from-class="translate-y-4 opacity-0" enter-to-class="translate-y-0 opacity-100">
|
||||||
|
<div v-for="post in filteredPosts" :key="post.id"
|
||||||
|
:class="['relative group bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-xl p-5 shadow-sm hover:border-blue-500/50 transition-all cursor-pointer mb-4 overflow-hidden', post.hidden ? 'opacity-60 bg-gray-50' : 'bg-white']"
|
||||||
|
@click="fetchPostDetail(post.id)">
|
||||||
|
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center gap-1 text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 p-2 rounded-lg h-fit">
|
||||||
|
|
||||||
|
<button class="hover:text-blue-500 transition-colors text-lg"
|
||||||
|
:class="{ 'text-orange-500': post.my_vote === 'UP' }" @click.stop="votePost(post, 1)">▲</button>
|
||||||
|
|
||||||
|
<span class="text-xs font-bold">{{ post.rating }}</span>
|
||||||
|
|
||||||
|
<button class="hover:text-red-500 transition-colors text-lg"
|
||||||
|
:class="{ 'text-blue-500': post.my_vote === 'DOWN' }" @click.stop="votePost(post, -1)">▼</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span
|
||||||
|
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400">
|
||||||
|
{{ post.type.replace('_', ' ') }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500">{{ new Date(post.publicationDate).toLocaleDateString('en-US', {
|
||||||
|
year:
|
||||||
|
'numeric', month: 'long', day: 'numeric'
|
||||||
|
}) }}</span>
|
||||||
|
<button v-if="authStore.user.id == post.submitter.id"
|
||||||
|
class="p-1 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 cursor-pointer"
|
||||||
|
title="Edit" @click.stop="openEditModal(post)">
|
||||||
|
<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 v-if="authStore.user.id == post.submitter.id || authStore.user.role != 'COLLABORATOR'"
|
||||||
|
class="p-1 text-slate-400 hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-lg transition-all transform hover:scale-110 active:scale-95 cursor-pointer"
|
||||||
|
:title="post.hidden ? 'Unhide' : 'Hide'" @click.stop="toggleHide(post)">
|
||||||
|
<svg width="16px" height="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z" />
|
||||||
|
<path
|
||||||
|
d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z" />
|
||||||
|
|
||||||
|
<line v-if="post.hidden" x1="3" y1="3" x2="13" y2="13" stroke="currentColor" stroke-width="1.5"
|
||||||
|
stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-lg font-bold text-gray-800 dark:text-white mb-2">{{ post.title }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 truncate max-w-[400px]">
|
||||||
|
{{ post.authors.length > 0 ? `By ${post.authors.join(', ')}` : 'No Authors' }}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-4 text-xs font-bold text-gray-500 uppercase min-w-0">
|
||||||
|
<span class="shrink-0">💬 {{ post.commentCount }} Comments</span>
|
||||||
|
|
||||||
|
<div class="max-w-[300px] truncate">
|
||||||
|
<span v-for="tag in post.tags" :key="tag.id" class="text-blue-600 dark:text-blue-400 mr-2">
|
||||||
|
#{{ tag.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
|
||||||
|
<div v-if="loading && filteredPosts.length === 0" class="space-y-4">
|
||||||
|
<div v-for="i in 6" :key="i"
|
||||||
|
class="bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-xl p-5 animate-pulse">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="w-10 h-16 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||||
|
<div class="flex-1 space-y-3">
|
||||||
|
<div class="h-3 w-24 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||||
|
<div class="h-5 w-3/4 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||||
|
<div class="h-3 w-full bg-gray-100 dark:bg-gray-800 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="loadMoreTrigger" class="h-20 w-full flex flex-col items-center justify-center gap-2">
|
||||||
|
<div v-if="loading"
|
||||||
|
class="animate-spin h-6 w-6 border-2 border-blue-600 border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PostDetail v-if="selectedPost" :post="selectedPost" :api="api" :token="token" @back="selectedPost = null"
|
||||||
|
@vote="(dir) => votePost(selectedPost, dir)" @comment-added="() => selectedPost.commentCount++" />
|
||||||
|
|
||||||
|
<CreatePostModal :is-open="isModalOpen" :post-to-edit="postToEdit" :api="api" :token="token"
|
||||||
|
@close="closeModal()" @refresh="handleRefresh" />
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useAuthStore } from "~/stores/auth-store.js";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import CreatePostModal from "~/components/ui/modal/CreatePostModal.vue";
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const { user, token } = storeToRefs(authStore);
|
||||||
|
const route = useRoute();
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const notifications = useNotificationStore();
|
||||||
|
const api = config.public.apiBase;
|
||||||
|
|
||||||
|
const posts = ref([]);
|
||||||
|
const selectedPost = ref(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const loadMoreTrigger = ref(null);
|
||||||
|
const isModalOpen = ref(false);
|
||||||
|
const searchQuery = ref('');
|
||||||
|
const page = ref(1);
|
||||||
|
const hasMore = ref(true);
|
||||||
|
|
||||||
|
const sortBy = ref('date');
|
||||||
|
const sortDesc = ref(true);
|
||||||
|
const filterType = ref('ALL');
|
||||||
|
|
||||||
|
const subscribedTags = ref([]);
|
||||||
|
const availableTags = ref([]);
|
||||||
|
const showCreateInput = ref(false);
|
||||||
|
const newTagName = ref('');
|
||||||
|
const editingTagId = ref(null);
|
||||||
|
const editTagName = ref('');
|
||||||
|
|
||||||
|
const postToEdit = ref(null);
|
||||||
|
|
||||||
|
const checkQueryForModal = () => {
|
||||||
|
if (route.query.openCreatePostModal === 'true') {
|
||||||
|
isModalOpen.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleHide = async (post) => {
|
||||||
|
try {
|
||||||
|
const newState = !post.hidden;
|
||||||
|
|
||||||
|
await $fetch(`${api}/publications/${post.id}/hide`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: `Bearer ${token.value}` },
|
||||||
|
body: { is_hidden: newState }
|
||||||
|
});
|
||||||
|
|
||||||
|
post.hidden = newState;
|
||||||
|
notifications.success(`Post ${newState ? 'hidden' : 'restored'}`);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
notifications.error("Failed to update post visibility");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = (post) => {
|
||||||
|
postToEdit.value = post;
|
||||||
|
isModalOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateTag = async () => {
|
||||||
|
if (!newTagName.value.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch(`${api}/tags`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token.value}` },
|
||||||
|
body: { name: newTagName.value }
|
||||||
|
});
|
||||||
|
newTagName.value = '';
|
||||||
|
showCreateInput.value = false;
|
||||||
|
notifications.success("Tag created successfully");
|
||||||
|
await fetchTags();
|
||||||
|
} catch (err) {
|
||||||
|
notifications.error("Failed to create tag");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEditing = (tag) => {
|
||||||
|
editingTagId.value = tag.id;
|
||||||
|
editTagName.value = tag.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditTag = async (tagId) => {
|
||||||
|
const trimmedName = editTagName.value.trim();
|
||||||
|
if (!trimmedName) {
|
||||||
|
editingTagId.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch(`${api}/tags/${tagId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: `Bearer ${token.value}` },
|
||||||
|
body: { name: trimmedName }
|
||||||
|
});
|
||||||
|
|
||||||
|
const tagIndex = availableTags.value.findIndex(t => t.id === tagId);
|
||||||
|
if (tagIndex !== -1) availableTags.value[tagIndex].name = trimmedName;
|
||||||
|
|
||||||
|
posts.value.forEach(post => {
|
||||||
|
const postTag = post.tags.find(t => t.id === tagId);
|
||||||
|
if (postTag) {
|
||||||
|
postTag.name = trimmedName;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selectedPost.value) {
|
||||||
|
const selectedPostTag = selectedPost.value.tags.find(t => t.id === tagId);
|
||||||
|
if (selectedPostTag) selectedPostTag.name = trimmedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
editingTagId.value = null;
|
||||||
|
notifications.success("Tag edited successfully");
|
||||||
|
} catch (err) {
|
||||||
|
notifications.error("Failed to edit tag");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
isModalOpen.value = false;
|
||||||
|
postToEdit.value = null;
|
||||||
|
router.replace({ query: { ...route.query, openCreatePostModal: undefined } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchTags = async () => {
|
||||||
|
try {
|
||||||
|
const allTags = await $fetch(`${api}/tags`, {
|
||||||
|
headers: { Authorization: `Bearer ${token.value}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
const colors = [
|
||||||
|
"bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400",
|
||||||
|
"bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400",
|
||||||
|
"bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400",
|
||||||
|
"bg-pink-100 dark:bg-pink-900/30 text-pink-700 dark:text-pink-400",
|
||||||
|
"bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400"
|
||||||
|
];
|
||||||
|
|
||||||
|
availableTags.value = allTags.map((tag, index) => ({
|
||||||
|
...tag,
|
||||||
|
color: colors[index % colors.length]
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
const myTags = await $fetch(`${api}/tags/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token.value}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
subscribedTags.value = myTags.map(t => t.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching tags:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTagSubscription = async (tag) => {
|
||||||
|
const index = subscribedTags.value.indexOf(tag.id);
|
||||||
|
const isSubscribing = index === -1;
|
||||||
|
|
||||||
|
if (isSubscribing) subscribedTags.value.push(tag.id);
|
||||||
|
else subscribedTags.value.splice(index, 1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const endpoint = isSubscribing
|
||||||
|
? `${api}/tags/${tag.id}/subscribe`
|
||||||
|
: `${api}/tags/${tag.id}/unsubscribe`;
|
||||||
|
|
||||||
|
await $fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token.value}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
if (isSubscribing) subscribedTags.value.pop();
|
||||||
|
else subscribedTags.value.push(tag.id);
|
||||||
|
|
||||||
|
console.error("Sync failed:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
isModalOpen.value = false;
|
||||||
|
posts.value = [];
|
||||||
|
page.value = 1;
|
||||||
|
hasMore.value = true;
|
||||||
|
loadPosts();
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPosts = async () => {
|
||||||
|
if (loading.value || !hasMore.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await $fetch(`${api}/publications`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Authorization: `Bearer ${token.value}` },
|
||||||
|
params: {
|
||||||
|
page: page.value,
|
||||||
|
sort: sortBy.value,
|
||||||
|
order: sortDesc.value ? 'desc' : 'asc',
|
||||||
|
type: filterType.value !== 'ALL' ? filterType.value : undefined,
|
||||||
|
search: searchQuery.value ? searchQuery.value : ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
posts.value = [...posts.value, ...data];
|
||||||
|
page.value++;
|
||||||
|
} else {
|
||||||
|
hasMore.value = false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch posts:", err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPostDetail = async (id) => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const postDetail = await $fetch(`${api}/publications/${id}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token.value}` }
|
||||||
|
});
|
||||||
|
selectedPost.value = postDetail;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch post detail:", err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const votePost = async (targetPost, direction) => {
|
||||||
|
if (!targetPost) return;
|
||||||
|
|
||||||
|
let voteTypeToSend = 'NONE';
|
||||||
|
const currentVote = targetPost.my_vote || 'NONE';
|
||||||
|
|
||||||
|
if (direction === 1) {
|
||||||
|
voteTypeToSend = currentVote === 'UP' ? 'NONE' : 'UP';
|
||||||
|
} else if (direction === -1) {
|
||||||
|
voteTypeToSend = currentVote === 'DOWN' ? 'NONE' : 'DOWN';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch(`${api}/publications/${targetPost.id}/vote`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { Authorization: `Bearer ${token.value}` },
|
||||||
|
body: { vote_type: voteTypeToSend }
|
||||||
|
});
|
||||||
|
|
||||||
|
targetPost.rating = response.score;
|
||||||
|
targetPost.my_vote = response.my_vote;
|
||||||
|
|
||||||
|
if (selectedPost.value && selectedPost.value.id === targetPost.id) {
|
||||||
|
selectedPost.value.rating = response.score;
|
||||||
|
selectedPost.value.my_vote = response.my_vote;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Vote failed", error);
|
||||||
|
notifications.error("Failed to submit vote.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredPosts = computed(() => {
|
||||||
|
const result = posts.value.filter(p => {
|
||||||
|
const matchesType = filterType.value === 'ALL' || p.type === filterType.value;
|
||||||
|
const matchesTags = subscribedTags.value.length === 0 ||
|
||||||
|
p.tags.some(t => subscribedTags.value.includes(t.id));
|
||||||
|
return matchesType && matchesTags;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.sort((a, b) => {
|
||||||
|
const modifier = sortDesc.value ? 1 : -1;
|
||||||
|
|
||||||
|
if (sortBy.value === 'votes') {
|
||||||
|
return (b.rating - a.rating) * modifier;
|
||||||
|
} else if (sortBy.value === 'comments') {
|
||||||
|
return (b.commentCount - a.commentCount) * modifier;
|
||||||
|
} else {
|
||||||
|
return (new Date(b.publicationDate).getTime() - new Date(a.publicationDate).getTime()) * modifier;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(posts, () => {
|
||||||
|
if (filteredPosts.value.length < 3 && !loading.value && !selectedPost.value) {
|
||||||
|
loadPosts();
|
||||||
|
}
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
watch([sortBy, sortDesc, filterType], () => {
|
||||||
|
resetAndLoad();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(searchQuery, () => {
|
||||||
|
resetAndLoad();
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetAndLoad() {
|
||||||
|
posts.value = [];
|
||||||
|
page.value = 1;
|
||||||
|
hasMore.value = true;
|
||||||
|
loadPosts();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
checkQueryForModal();
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
loadPosts(),
|
||||||
|
fetchTags()
|
||||||
|
]);
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
if (entries[0].isIntersecting && !loading.value && !selectedPost.value) {
|
||||||
|
loadPosts();
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
rootMargin: '300px',
|
||||||
|
threshold: 0.1
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loadMoreTrigger.value) {
|
||||||
|
observer.observe(loadMoreTrigger.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in-up {
|
||||||
|
animation: fadeInUp 0.5s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
+623
-216
@@ -1,7 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<title> Profile - {{ user.name }}</title>
|
|
||||||
<div
|
<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">
|
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">
|
||||||
|
|
||||||
|
<CreatePostModal :is-open="isModalOpen" :post-to-edit="postToEdit" :api="api" :token="token" @close="closeModal"
|
||||||
|
@refresh="fetchProfileData" />
|
||||||
|
|
||||||
|
<Transition enter-active-class="transition duration-200 ease-out" enter-from-class="opacity-0"
|
||||||
|
enter-to-class="opacity-100" leave-active-class="transition duration-150 ease-in"
|
||||||
|
leave-from-class="opacity-100" leave-to-class="opacity-0">
|
||||||
|
<div v-if="selectedPost" class="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 overflow-y-auto"
|
||||||
|
@click.self="selectedPost = null">
|
||||||
|
|
||||||
|
<div class="min-h-screen px-4 py-8 flex items-start justify-center">
|
||||||
|
<div
|
||||||
|
class="self-center w-full max-w-3xl bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border dark:border-gray-800 relative">
|
||||||
|
|
||||||
|
<PostDetail :post="selectedPost" :api="api" :token="token" @back="selectedPost = null"
|
||||||
|
@comment-added="handleCommentAdded" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<div class="container mx-auto max-w-7xl px-4 py-8 md:py-12">
|
<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="grid grid-cols-1 md:grid-cols-12 gap-8 lg:gap-12">
|
||||||
|
|
||||||
@@ -13,245 +34,631 @@
|
|||||||
<div class="relative inline-block mb-4">
|
<div class="relative inline-block mb-4">
|
||||||
<div
|
<div
|
||||||
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">
|
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="user.avatarUrl" :alt="user.name"
|
<img :src="userProfile.avatarUrl" :alt="userProfile.name"
|
||||||
class="h-full w-full object-cover transition-transform hover:scale-105">
|
class="h-full w-full object-cover transition-transform hover:scale-105">
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute bottom-2 right-2 h-5 w-5 rounded-full border-[3px] border-white shadow-sm"
|
<div class="absolute bottom-2 right-2 h-5 w-5 rounded-full border-[3px] border-white dark:border-slate-900 shadow-sm"
|
||||||
:class="user.isActive ? 'bg-emerald-500' : 'bg-gray-400'" title="Online Status" />
|
:class="userProfile.is_active ? 'bg-emerald-500' : 'bg-gray-400'"
|
||||||
|
title="Online Status" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 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"
|
||||||
|
@click=" activeTab = 'settings'">
|
||||||
|
Edit Profile
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="text-2xl font-bold text-slate-900 dark:text-white tracking-tight">{{ user.name }}
|
<div class="mt-8 pt-6 border-t border-slate-100 dark:border-slate-800 space-y-4">
|
||||||
</h1>
|
<div class="flex items-center gap-3 text-sm text-slate-600 dark:text-slate-400">
|
||||||
<p
|
<svg class="w-4 h-4 text-slate-400 dark:text-slate-500 shrink-0" fill="none"
|
||||||
class="text-sm font-semibold text-slate-500 dark:text-slate-400 mb-6 uppercase tracking-wide">
|
viewBox="0 0 24 24" stroke="currentColor">
|
||||||
{{ user.role }}
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
</p>
|
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" />
|
||||||
|
|
||||||
<div class="w-full space-y-3">
|
|
||||||
<button
|
|
||||||
class="w-full py-2.5 px-4 bg-slate-900 dark:bg-blue-900 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 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 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 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>
|
|
||||||
</div>
|
|
||||||
<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 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>
|
|
||||||
<span>Joined {{ formattedDate }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div
|
|
||||||
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 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">{{ user.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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="md:col-span-8 lg:col-span-9 space-y-6">
|
|
||||||
|
|
||||||
<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
|
|
||||||
class="flex-1 min-w-[100px] py-2.5 px-4 rounded-lg text-lg font-semibold bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm ring-1 ring-black/5 dark:ring-white/5 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 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-50 dark:hover:bg-slate-800 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 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors cursor-pointer">
|
|
||||||
Settings
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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-s font-bold text-slate-900 dark:text-slate-100 uppercase tracking-wider mb-3">
|
|
||||||
Biography</h2>
|
|
||||||
<p class="text-slate-700 dark:text-slate-300 leading-relaxed text-sm">
|
|
||||||
{{ user.biography }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between pt-2 px-1">
|
|
||||||
<h2 class="text-lg font-bold text-slate-900 dark:text-slate-300 flex items-center gap-2">
|
|
||||||
Recent Publications
|
|
||||||
<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">{{
|
|
||||||
user.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 dark:border-slate-800 rounded-xl bg-slate-50/50 dark:bg-slate-900/50">
|
|
||||||
<p class="text-slate-500 dark:text-slate-400 font-medium">No publications found yet.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-for="pub in user.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 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors leading-snug">
|
|
||||||
<a href="#" class="before:absolute before:inset-0">{{ pub.title }}</a>
|
|
||||||
</h3>
|
|
||||||
<span
|
|
||||||
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 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 dark:border-slate-800 flex flex-wrap items-center gap-4 text-xs font-medium z-10 relative">
|
|
||||||
<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">
|
|
||||||
{{ pub.field }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span class="inline-flex items-center gap-1.5 text-slate-500 dark:text-slate-400">
|
|
||||||
<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>
|
</svg>
|
||||||
{{ pub.citations }} Citations
|
<span class="truncate">{{ userProfile.email }}</span>
|
||||||
</span>
|
</div>
|
||||||
<span class="text-slate-300 dark:text-slate-700">•</span>
|
<div class="flex items-center gap-3 text-sm text-slate-600 dark:text-slate-400">
|
||||||
<span class="text-slate-500 dark:text-slate-400">{{ pub.publishedDate }}</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div
|
||||||
|
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 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">
|
||||||
|
Publications
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-8 lg:col-span-9 space-y-6">
|
||||||
|
|
||||||
|
<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
|
||||||
|
: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"
|
||||||
|
@click="activeTab = 'overview'">
|
||||||
|
Overview
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
: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"
|
||||||
|
@click="activeTab = 'activity'">
|
||||||
|
Activity Log
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
: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"
|
||||||
|
@click="activeTab = 'settings'">
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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="flex items-center justify-between pt-2 px-1">
|
||||||
|
<h2 class="text-lg font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
||||||
|
Recent Publications
|
||||||
|
<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 }} Publications
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div v-if="userProfile.publications.length === 0 && !loading"
|
||||||
|
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 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>
|
||||||
|
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
@click="goToCreatePost()">
|
||||||
|
<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 publication
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="post in userProfile.publications" :key="post.id"
|
||||||
|
:class="['relative group bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-xl p-5 shadow-sm hover:border-blue-500/50 transition-all cursor-pointer mb-4 overflow-hidden', post.hidden ? 'opacity-60 bg-gray-50' : 'bg-white']"
|
||||||
|
@click="fetchPostDetail(post.id)">
|
||||||
|
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center gap-1 text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 p-2 rounded-lg h-fit">
|
||||||
|
<button class="hover:text-blue-500 transition-colors text-lg"
|
||||||
|
:class="{ 'text-orange-500': post.my_vote === 'UP' }"
|
||||||
|
@click.stop="votePost(post, 1)">▲</button>
|
||||||
|
<span class="text-xs font-bold">{{ post.rating }}</span>
|
||||||
|
<button class="hover:text-red-500 transition-colors text-lg"
|
||||||
|
:class="{ 'text-blue-500': post.my_vote === 'DOWN' }"
|
||||||
|
@click.stop="votePost(post, -1)">▼</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span
|
||||||
|
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400">
|
||||||
|
{{ post.type.replace('_', ' ') }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500">{{ new
|
||||||
|
Date(post.publicationDate).toLocaleDateString('en-US', {
|
||||||
|
year:
|
||||||
|
'numeric', month: 'long', day: 'numeric'
|
||||||
|
}) }}</span>
|
||||||
|
<button
|
||||||
|
class="p-1 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 cursor-pointer"
|
||||||
|
title="Edit" @click.stop="openEditModal(post)">
|
||||||
|
<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
|
||||||
|
class="p-1 text-slate-400 hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-lg transition-all transform hover:scale-110 active:scale-95 cursor-pointer"
|
||||||
|
:title="post.hiddenidden ? 'Unhide' : 'Hide'"
|
||||||
|
@click.stop="toggleHide(post)">
|
||||||
|
<svg width="16px" height="16px" viewBox="0 0 16 16"
|
||||||
|
xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z" />
|
||||||
|
<path
|
||||||
|
d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z" />
|
||||||
|
|
||||||
|
<line v-if="post.hidden" x1="3" y1="3" x2="13" y2="13"
|
||||||
|
stroke="currentColor" stroke-width="1.5"
|
||||||
|
stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-lg font-bold text-gray-800 dark:text-white mb-2">{{
|
||||||
|
post.title }}</h2>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">By {{
|
||||||
|
post.authors.join(', ') }}</p>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-4 text-xs font-bold text-gray-500 uppercase">
|
||||||
|
<span>💬 {{ post.commentCount }} Comments</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<span v-for="tag in post.tags" :key="tag.id"
|
||||||
|
class="text-blue-600 dark:text-blue-400">#{{ tag.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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 in activityLog" :key="item.id"
|
||||||
|
class="group p-4 flex gap-5 border-b border-slate-100 dark:border-slate-800 last:border-0 hover:bg-slate-50/80 dark:hover:bg-slate-800/30 transition-all duration-200">
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-end min-w-[90px] pt-1 border-r border-slate-100 dark:border-slate-800 pr-4">
|
||||||
|
<span
|
||||||
|
class="text-[11px] font-semibold text-slate-600 dark:text-slate-400 tabular-nums">
|
||||||
|
{{ new Date(item.timestamp).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric', year: 'numeric' }) }}
|
||||||
|
</span>
|
||||||
|
<span class="text-[10px] text-slate-400 tabular-nums mt-0.5">
|
||||||
|
{{ new Date(item.timestamp).toLocaleTimeString([], {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit' }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-3 mb-1.5">
|
||||||
|
<span
|
||||||
|
class="px-2 py-0.5 rounded-full text-[10px] font-bold tracking-tight border"
|
||||||
|
:class="{
|
||||||
|
'bg-emerald-50 text-emerald-700 border-emerald-100 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20': item.actionType.includes('UPLOAD'),
|
||||||
|
'bg-blue-50 text-blue-700 border-blue-100 dark:bg-blue-500/10 dark:text-blue-400 dark:border-blue-500/20': item.actionType.includes('COMMENT'),
|
||||||
|
'bg-indigo-50 text-indigo-700 border-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-400 dark:border-indigo-500/20': item.actionType.includes('EDIT'),
|
||||||
|
'bg-amber-50 text-amber-700 border-amber-100 dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/20': item.actionType.includes('RATED') || item.actionType.includes('CORRECT'),
|
||||||
|
'bg-purple-50 text-purple-700 border-purple-100 dark:bg-purple-500/10 dark:text-purple-400 dark:border-purple-500/20': item.actionType.includes('TAG')
|
||||||
|
}">
|
||||||
|
{{ item.actionType.replace('_', ' ') }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="text-[11px] text-slate-400 font-medium">
|
||||||
|
{{ item.targetType }} #{{ item.targetId }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm text-slate-600 dark:text-slate-300 leading-relaxed">
|
||||||
|
{{ item.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-slate-300"
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="activeTab === 'settings'" key="settings"
|
||||||
|
class="bg-white dark:bg-slate-900 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 transition-colors">
|
||||||
|
<div class="px-6 py-5 border-b border-slate-200 dark:border-slate-800">
|
||||||
|
<h2 class="text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
|
Edit Profile
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||||
|
Update your personal information and account details.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 space-y-6">
|
||||||
|
<div>
|
||||||
|
<label for="name"
|
||||||
|
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input id="name" v-model="newUsername" type="text"
|
||||||
|
placeholder="Your first name and last name" class="w-full px-4 py-2.5 bg-white dark:bg-slate-800
|
||||||
|
border border-slate-300 dark:border-slate-700
|
||||||
|
rounded-xl text-slate-900 dark:text-white
|
||||||
|
placeholder-slate-400
|
||||||
|
focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||||
|
outline-none transition-all">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="email"
|
||||||
|
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||||
|
Email address
|
||||||
|
</label>
|
||||||
|
<input id="email" v-model="newEmail" type="email" placeholder="[email protected]"
|
||||||
|
class="w-full px-4 py-2.5 bg-white dark:bg-slate-800
|
||||||
|
border border-slate-300 dark:border-slate-700
|
||||||
|
rounded-xl text-slate-900 dark:text-white
|
||||||
|
placeholder-slate-400
|
||||||
|
focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||||
|
outline-none transition-all">
|
||||||
|
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Dont worry, Your email is going to be sold to russian hackers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a class="text-sm text-red dark:text-red-600 hover:underline"
|
||||||
|
@click="openResetPassword">
|
||||||
|
Reset Password
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-6 py-4 bg-slate-50 dark:bg-slate-800/50
|
||||||
|
border-t border-slate-200 dark:border-slate-800
|
||||||
|
flex justify-end">
|
||||||
|
|
||||||
|
<button class="inline-flex items-center gap-2
|
||||||
|
bg-blue-600 hover:bg-blue-700
|
||||||
|
text-white font-semibold
|
||||||
|
px-6 py-2.5 rounded-xl
|
||||||
|
shadow-sm transition-all
|
||||||
|
active:scale-95" @click="updateProfile">
|
||||||
|
Save changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup>
|
||||||
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";
|
||||||
|
import CreatePostModal from '~/components/ui/modal/CreatePostModal.vue';
|
||||||
|
|
||||||
enum Role {
|
const config = useRuntimeConfig();
|
||||||
STUDENT = "STUDENT",
|
const api = config.public.apiBase;
|
||||||
TEACHER = "TEACHER",
|
const authStore = useAuthStore();
|
||||||
ADMIN = "ADMIN",
|
const { token } = storeToRefs(authStore);
|
||||||
RESEARCHER = "RESEARCHER"
|
const router = useRouter();
|
||||||
|
const notifications = useNotificationStore();
|
||||||
|
|
||||||
|
const isModalOpen = ref(false);
|
||||||
|
const postToEdit = ref(null);
|
||||||
|
|
||||||
|
const selectedPost = ref(null);
|
||||||
|
|
||||||
|
const newUsername = ref('');
|
||||||
|
const newEmail = ref('');
|
||||||
|
|
||||||
|
const loading = ref(true);
|
||||||
|
const activeTab = ref('overview');
|
||||||
|
const activityLog = ref([]);
|
||||||
|
|
||||||
|
const userProfile = ref({
|
||||||
|
id: 0,
|
||||||
|
name: 'Loading...',
|
||||||
|
email: '',
|
||||||
|
role: '',
|
||||||
|
is_active: false,
|
||||||
|
avatarUrl: '',
|
||||||
|
publications: []
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const toggleHide = async (post) => {
|
||||||
|
try {
|
||||||
|
const newState = !post.hidden;
|
||||||
|
|
||||||
|
await $fetch(`${api}/publications/${post.id}/hide`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: `Bearer ${token.value}` },
|
||||||
|
body: { is_hidden: newState }
|
||||||
|
});
|
||||||
|
|
||||||
|
post.hidden = newState;
|
||||||
|
notifications.success(`Post ${newState ? 'hidden' : 'restored'}`);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
notifications.error("Failed to update post visibility");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = (post) => {
|
||||||
|
postToEdit.value = post;
|
||||||
|
isModalOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
isModalOpen.value = false;
|
||||||
|
postToEdit.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileName = (fullPath) => {
|
||||||
|
if (!fullPath) return null;
|
||||||
|
return fullPath.split(/[/\\]/).pop();
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadFile = async (post) => {
|
||||||
|
if (!post.attachedFile) return;
|
||||||
|
const filename = getFileName(post.attachedFile);
|
||||||
|
const downloadUrl = `${api}/publications/download/${filename}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = downloadUrl;
|
||||||
|
link.setAttribute('download', filename || 'download');
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Download error:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPostDetail = async (id) => {
|
||||||
|
try {
|
||||||
|
const postDetail = await $fetch(`${api}/publications/${id}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token.value}` }
|
||||||
|
});
|
||||||
|
selectedPost.value = postDetail;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} catch (err) {
|
||||||
|
notifications.error("Failed to load publication details");
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalCitations = computed(() =>
|
||||||
|
userProfile.value.publications.reduce((sum, pub) => sum + (pub.citations || 0), 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
async function fetchActivityHistory() {
|
||||||
|
if (!token.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await $fetch(`${api}/users/me/history`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Authorization: `Bearer ${token.value}` }
|
||||||
|
});
|
||||||
|
activityLog.value = data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching history:", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Publication {
|
async function fetchProfileData() {
|
||||||
id: number;
|
if (!token.value) return;
|
||||||
title: string;
|
loading.value = true;
|
||||||
abstract: string;
|
|
||||||
publishedDate: string;
|
|
||||||
publishedYear: string;
|
|
||||||
citations: number;
|
|
||||||
field: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface User {
|
try {
|
||||||
id: number;
|
const userData = await $fetch(`${api}/users/me`, {
|
||||||
name: string;
|
method: 'GET',
|
||||||
email: string;
|
headers: { Authorization: `Bearer ${token.value}` }
|
||||||
role: Role;
|
});
|
||||||
isActive: boolean;
|
const publicationsData = await $fetch(`${api}/publications/me`, {
|
||||||
avatarUrl: string;
|
method: 'GET',
|
||||||
joinDate: string;
|
headers: { Authorization: `Bearer ${token.value}` },
|
||||||
publications: Publication[];
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const user = ref<User>({
|
userProfile.value = {
|
||||||
id: 1,
|
...userProfile.value,
|
||||||
name: 'Dr. Maria Santos',
|
id: userData.id,
|
||||||
email: '[email protected]',
|
name: userData.name,
|
||||||
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.',
|
email: userData.email,
|
||||||
role: Role.RESEARCHER,
|
role: userData.role,
|
||||||
isActive: true,
|
is_active: userData.is_active,
|
||||||
avatarUrl: 'https://api.dicebear.com/7.x/notionists/svg?seed=Maria',
|
avatarUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(userData.name)}&background=0D8ABC&color=fff`
|
||||||
joinDate: '2023-09-15',
|
};
|
||||||
publications: [
|
|
||||||
{
|
if (authStore.user) {
|
||||||
id: 1,
|
authStore.user.name = userData.name;
|
||||||
title: 'Machine Learning Approaches to Predictive Maintenance in Industrial IoT',
|
authStore.user.email = userData.email;
|
||||||
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'
|
|
||||||
}
|
}
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalCitations = computed(() => user.value.publications.reduce((sum, pub) => sum + pub.citations, 0));
|
userProfile.value.publications = (publicationsData).map(pub => {
|
||||||
|
const pubDate = new Date(pub.publicationDate);
|
||||||
|
|
||||||
const formattedDate = computed(() => {
|
return {
|
||||||
return new Date(user.value.joinDate).toLocaleDateString('en-US', {
|
...pub,
|
||||||
month: 'long',
|
publishedDate: pubDate.toLocaleDateString(),
|
||||||
year: 'numeric'
|
publishedYear: pubDate.getFullYear().toString(),
|
||||||
|
citations: pub.rating || 0,
|
||||||
|
authors: pub.authors || [],
|
||||||
|
tags: pub.tags || [],
|
||||||
|
comment_count: pub.comment_count || 0,
|
||||||
|
rating: pub.rating || 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching profile:", error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const votePost = async (targetPost, direction) => {
|
||||||
|
if (!targetPost) return;
|
||||||
|
|
||||||
|
let voteTypeToSend = 'NONE';
|
||||||
|
const currentVote = targetPost.my_vote || 'NONE';
|
||||||
|
|
||||||
|
if (direction === 1) {
|
||||||
|
voteTypeToSend = currentVote === 'UP' ? 'NONE' : 'UP';
|
||||||
|
} else if (direction === -1) {
|
||||||
|
voteTypeToSend = currentVote === 'DOWN' ? 'NONE' : 'DOWN';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch(`${api}/publications/${targetPost.id}/vote`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { Authorization: `Bearer ${token.value}` },
|
||||||
|
body: { vote_type: voteTypeToSend }
|
||||||
|
});
|
||||||
|
|
||||||
|
targetPost.rating = response.score;
|
||||||
|
targetPost.my_vote = response.my_vote;
|
||||||
|
|
||||||
|
if (selectedPost.value && selectedPost.value.id === targetPost.id) {
|
||||||
|
selectedPost.value.rating = response.score;
|
||||||
|
selectedPost.value.my_vote = response.my_vote;
|
||||||
|
}
|
||||||
|
|
||||||
|
notifications.success("Voted successfully");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Vote failed", error);
|
||||||
|
notifications.error("Failed to voted");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToCreatePost = () => {
|
||||||
|
router.push({
|
||||||
|
path: '/',
|
||||||
|
query: { openCreatePostModal: 'true' }
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateProfile = async () => {
|
||||||
|
if (!token.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const name = newUsername.value.trim();
|
||||||
|
const email = newEmail.value.trim();
|
||||||
|
|
||||||
|
|
||||||
|
await $fetch(`${api}/users/me`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: `Bearer ${token.value}` },
|
||||||
|
body: {
|
||||||
|
name: name || userProfile.value.name,
|
||||||
|
email: email || userProfile.value.email
|
||||||
|
}
|
||||||
|
});
|
||||||
|
notifications.success("Profile updated successfully");
|
||||||
|
|
||||||
|
if (authStore.user) {
|
||||||
|
authStore.user.name = name || userProfile.value.name;
|
||||||
|
authStore.user.email = email || userProfile.value.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Profile update failed", error);
|
||||||
|
notifications.error("Failed to update profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchProfileData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openResetPassword = () => {
|
||||||
|
router.push({ path: '/profile/update-password' });
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(selectedPost, (newVal) => {
|
||||||
|
if (!newVal) document.body.style.overflow = 'auto';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchProfileData();
|
||||||
|
fetchActivityHistory();
|
||||||
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in-up {
|
||||||
|
animation: fadeInUp 0.5s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ async function handleUpdatePassword() {
|
|||||||
|
|
||||||
<div
|
<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">
|
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>
|
<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
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-8 text-center">Please update your password to
|
||||||
secure your account.</p>
|
secure your account.</p>
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -3,7 +3,13 @@ import tailwindcss from "@tailwindcss/vite";
|
|||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
compatibilityDate: "2026-01-01",
|
compatibilityDate: "2026-01-01",
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: true },
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
link: [
|
||||||
|
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
css: [
|
css: [
|
||||||
"~/assets/css/main.css",
|
"~/assets/css/main.css",
|
||||||
"~/assets/css/tailwind.css"
|
"~/assets/css/tailwind.css"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 401 KiB |
Reference in New Issue
Block a user