fix/admin-active-status #11
+296
-45
@@ -41,7 +41,8 @@
|
|||||||
<button
|
<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"
|
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">
|
:title="sortDesc ? 'Descending' : 'Ascending'" @click="sortDesc = !sortDesc">
|
||||||
<span class="inline-block transition-transform duration-200" :class="{ 'rotate-180': !sortDesc }">
|
<span class="inline-block transition-transform duration-200"
|
||||||
|
:class="{ 'rotate-180': !sortDesc }">
|
||||||
▼
|
▼
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -89,7 +90,8 @@
|
|||||||
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"
|
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">
|
@click="isModalOpen = true">
|
||||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 4v16m8-8H4" />
|
||||||
</svg>
|
</svg>
|
||||||
Submit your first post
|
Submit your first post
|
||||||
</button>
|
</button>
|
||||||
@@ -103,9 +105,16 @@
|
|||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<div
|
<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">
|
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" @click.stop="post.rating++">▲</button>
|
|
||||||
|
<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>
|
<span class="text-xs font-bold">{{ post.rating }}</span>
|
||||||
<button class="hover:text-red-500 transition-colors text-lg" @click.stop="post.rating--">▼</button>
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -116,13 +125,16 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="text-xs text-gray-500">{{ post.publication_date }}</span>
|
<span class="text-xs text-gray-500">{{ post.publication_date }}</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="text-lg font-bold text-gray-800 dark:text-white mb-2">{{ post.title }}</h2>
|
<h2 class="text-lg font-bold text-gray-800 dark:text-white mb-2">{{ post.title }}
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">By {{ post.authors.join(', ') }}
|
</h2>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">By {{
|
||||||
|
post.authors.join(', ') }}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex items-center gap-4 text-xs font-bold text-gray-500 uppercase">
|
<div class="flex items-center gap-4 text-xs font-bold text-gray-500 uppercase">
|
||||||
<span>💬 {{ post.comment_count }} Comments</span>
|
<span>💬 {{ post.comment_count }} Comments</span>
|
||||||
<div class="flex gap-2">
|
<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 v-for="tag in post.tags" :key="tag.id"
|
||||||
|
class="text-blue-600 dark:text-blue-400">#{{ tag.name
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,28 +170,184 @@
|
|||||||
← BACK TO FEED
|
← BACK TO FEED
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<article class="bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-xl p-8 shadow-lg">
|
<article
|
||||||
<header class="mb-6">
|
class="bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-2xl shadow-xl overflow-hidden">
|
||||||
<h1 class="text-3xl font-extrabold text-gray-900 dark:text-white mb-4 leading-tight">{{ selectedPost.title
|
|
||||||
}}</h1>
|
|
||||||
<div class="flex items-center gap-3 text-sm">
|
|
||||||
<span class="bg-blue-600 text-white px-3 py-1 rounded-md font-bold text-xs uppercase">{{
|
|
||||||
selectedPost.type
|
|
||||||
}}</span>
|
|
||||||
<span class="text-gray-500 font-medium">By <span class="text-blue-500">{{ selectedPost.submitter.name
|
|
||||||
}}</span></span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="prose dark:prose-invert max-w-none mb-8">
|
<div
|
||||||
<h4 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-2">Abstract</h4>
|
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">
|
||||||
<p class="text-gray-700 dark:text-gray-300 leading-relaxed text-lg">{{ selectedPost.abstract }}</p>
|
<div
|
||||||
|
class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-4">
|
||||||
|
<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">
|
||||||
|
{{ selectedPost.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(selectedPost.publicationDate).toLocaleDateString('en-US', {
|
||||||
|
year:
|
||||||
|
'numeric', month: 'long', day: 'numeric'
|
||||||
|
}) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1
|
||||||
|
class="text-3xl md:text-4xl font-extrabold text-gray-900 dark:text-white mb-6 leading-tight">
|
||||||
|
{{ selectedPost.title }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<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 gap-2">
|
||||||
|
<span v-for="(author, idx) in selectedPost.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">
|
||||||
|
{{ selectedPost.submitter.name.charAt(0) }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-bold text-gray-900 dark:text-white">{{
|
||||||
|
selectedPost.submitter.name }}</div>
|
||||||
|
<div class="text-xs text-gray-500">{{ selectedPost.submitter.role }} • {{
|
||||||
|
selectedPost.submitter.email }}</div>
|
||||||
|
</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">
|
||||||
|
{{ selectedPost.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="selectedPost.tags && selectedPost.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 selectedPost.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 class="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
class="flex items-center bg-slate-100 dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-1.5 shadow-sm">
|
||||||
|
|
||||||
|
<button @click="votePost(selectedPost, 1)"
|
||||||
|
class="p-2 rounded-lg transition-all active:scale-90"
|
||||||
|
:class="selectedPost.my_vote === 'UP' ? 'text-orange-600 bg-orange-100 dark:bg-orange-900/30' : 'text-slate-400 hover:text-orange-500 hover:bg-white dark:hover:bg-slate-700'"
|
||||||
|
title="Upvote">
|
||||||
|
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||||
|
stroke-width="2.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M5 15l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="min-w-[40px] text-center">
|
||||||
|
<span class="text-2xl font-black tracking-tight" :class="{
|
||||||
|
'text-orange-600': selectedPost.my_vote === 'UP',
|
||||||
|
'text-blue-600': selectedPost.my_vote === 'DOWN',
|
||||||
|
'text-slate-900 dark:text-white': !selectedPost.my_vote || selectedPost.my_vote === 'NONE'
|
||||||
|
}">
|
||||||
|
{{ selectedPost.rating }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button @click="votePost(selectedPost, -1)"
|
||||||
|
class="p-2 rounded-lg transition-all active:scale-90"
|
||||||
|
:class="selectedPost.my_vote === 'DOWN' ? 'text-blue-600 bg-blue-100 dark:bg-blue-900/30' : 'text-slate-400 hover:text-blue-500 hover:bg-white dark:hover:bg-slate-700'"
|
||||||
|
title="Downvote">
|
||||||
|
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||||
|
stroke-width="2.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedPost.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(selectedPost.attachedFile) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button @click="downloadFile(selectedPost)"
|
||||||
|
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">
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<hr class="border-gray-100 dark:border-gray-800 mb-8">
|
<hr class="border-gray-100 dark:border-gray-800 mb-8">
|
||||||
|
|
||||||
<section class="mb-10">
|
<section>
|
||||||
<h3 class="text-xl font-bold mb-4">Comments ({{ selectedPost.comments.length }})</h3>
|
<h3 class="text-xl font-bold mb-6 flex items-center gap-2">
|
||||||
|
Comments
|
||||||
|
<span
|
||||||
|
class="bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-xs px-2 py-1 rounded-full">{{
|
||||||
|
selectedPost.comments.length }}</span>
|
||||||
|
</h3>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<textarea v-model="newCommentContent" rows="3"
|
<textarea v-model="newCommentContent" rows="3"
|
||||||
class="w-full px-4 py-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm"
|
class="w-full px-4 py-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm"
|
||||||
@@ -194,12 +362,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 space-y-6">
|
<div class="mt-8 space-y-6">
|
||||||
|
<div v-if="selectedPost.comments.length === 0"
|
||||||
|
class="text-center py-8 text-gray-400 italic">
|
||||||
|
Be the first to comment!
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-for="comment in selectedPost.comments" :key="comment.id" class="flex gap-4">
|
<div v-for="comment in selectedPost.comments" :key="comment.id" class="flex gap-4">
|
||||||
<div class="w-0.5 bg-gray-200 dark:bg-gray-800 rounded-full ml-2" />
|
<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 flex-shrink-0" />
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="flex items-center gap-2 mb-1">
|
<div
|
||||||
<span class="font-bold text-sm text-blue-600 dark:text-blue-400">{{ comment.author.name }}</span>
|
class="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-xl rounded-tl-none border border-gray-100 dark:border-gray-800">
|
||||||
<span class="text-[10px] text-gray-500 font-bold uppercase">{{ new
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="font-bold text-sm text-blue-600 dark:text-blue-400">{{
|
||||||
|
comment.author.name }}</span>
|
||||||
|
<span class="text-[10px] text-gray-400 uppercase">{{ new
|
||||||
Date(comment.created_at).toLocaleDateString() }}</span>
|
Date(comment.created_at).toLocaleDateString() }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-700 dark:text-gray-300 text-sm leading-relaxed">
|
<p class="text-gray-700 dark:text-gray-300 text-sm leading-relaxed">
|
||||||
@@ -208,7 +385,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<CreatePostModal :is-open="isModalOpen" :api="api" :token="token" @close="closeModal()"
|
<CreatePostModal :is-open="isModalOpen" :api="api" :token="token" @close="closeModal()"
|
||||||
@@ -219,7 +398,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { useAuthStore } from "~/stores/auth-store.js";
|
import { useAuthStore } from "~/stores/auth-store.js";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
@@ -239,6 +418,7 @@ const loadMoreTrigger = ref(null);
|
|||||||
const isModalOpen = ref(false);
|
const isModalOpen = ref(false);
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
|
const hasMore = ref(true);
|
||||||
|
|
||||||
const sortBy = ref('date');
|
const sortBy = ref('date');
|
||||||
const sortDesc = ref(true);
|
const sortDesc = ref(true);
|
||||||
@@ -349,7 +529,7 @@ const loadPosts = async () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await $fetch(`${api}/posts`, {
|
const data = await $fetch(`${api}/publications`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { Authorization: `Bearer ${token.value}` },
|
headers: { Authorization: `Bearer ${token.value}` },
|
||||||
params: {
|
params: {
|
||||||
@@ -373,24 +553,95 @@ const loadPosts = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const fetchPostDetail = async (id) => {
|
const fetchPostDetail = async (id) => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
await new Promise(r => setTimeout(r, 400));
|
try {
|
||||||
selectedPost.value = {
|
const postDetail = await $fetch(`${api}/publications/${id}`, {
|
||||||
id,
|
headers: { Authorization: `Bearer ${token.value}` }
|
||||||
title: posts.value.find(p => p.id === id)?.title || "A New Technique for Data Science",
|
});
|
||||||
authors: ["Joana B", "External Researcher"],
|
selectedPost.value = postDetail;
|
||||||
publication_date: "2025-11-01",
|
} catch (err) {
|
||||||
type: "CONFERENCE_PAPER",
|
console.error("Failed to fetch post detail:", err);
|
||||||
abstract: "This breakthrough research explores the boundaries of predictive modeling and machine learning in academic environments. By leveraging sparse data sets, we achieve higher accuracy in student retention predictions.",
|
} finally {
|
||||||
submitter: { id: 2, name: "Joana B" },
|
|
||||||
comments: [
|
|
||||||
{ id: 101, content: "This technique could revolutionize our approach to Project X!", author: { name: "Joana B" }, created_at: "2025-11-10T10:30:00Z" },
|
|
||||||
]
|
|
||||||
};
|
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
alert("Failed to submit vote.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
// Opção B: Se o endpoint for PRIVADO (@Authenticated), tens de fazer fetch com o token:
|
||||||
|
/*
|
||||||
|
const response = await fetch(downloadUrl, {
|
||||||
|
headers: { Authorization: `Bearer ${token.value}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Download failed');
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
*/
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Download error:", e);
|
||||||
|
alert("Could not download file.");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredPosts = computed(() => {
|
const filteredPosts = computed(() => {
|
||||||
|
|||||||
+109
-21
@@ -140,6 +140,41 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-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="searchQuery" type="text" placeholder="Search posts..."
|
||||||
|
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">
|
||||||
|
</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>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="userProfile.publications.length === 0"
|
<div v-if="userProfile.publications.length === 0"
|
||||||
class="relative overflow-hidden rounded-2xl border-2 border-dashed border-slate-300 dark:border-slate-700 bg-slate-50 dark:bg-slate-900/50 p-12 text-center hover:border-blue-400 dark:hover:border-blue-500 transition-all duration-300 group">
|
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">
|
||||||
|
|
||||||
@@ -177,25 +212,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-for="pub in userProfile.publications" :key="pub.id"
|
<div v-for="post in userProfile.publicationsData" :key="post.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">
|
class="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"
|
||||||
<div class="flex justify-between items-start gap-4 mb-2">
|
@click="fetchPostDetail(post.id)">
|
||||||
<h3 class="text-base font-bold text-slate-900 dark:text-slate-100">{{ pub.title
|
<div class="flex gap-4">
|
||||||
}}</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
|
<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">
|
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
|
<span
|
||||||
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-100 dark:border-blue-800/50">
|
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">
|
||||||
{{ pub.field }}
|
{{ post.type.replace('_', ' ') }}
|
||||||
</span>
|
</span>
|
||||||
<span>{{ pub.citations }} Citations</span>
|
<span class="text-xs text-gray-500">{{ post.publication_date
|
||||||
|
}}</span>
|
||||||
|
</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.comment_count }} 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>
|
</div>
|
||||||
@@ -282,6 +342,14 @@ const authStore = useAuthStore();
|
|||||||
const { token } = storeToRefs(authStore);
|
const { token } = storeToRefs(authStore);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// --- Reactive Variables ---
|
||||||
|
const availableTags = ref<Tag[]>([]); // For tag select viewBox
|
||||||
|
const searchQuery = ref('');
|
||||||
|
const sortBy = ref('date'); // 'date' | 'votes' | 'comments'
|
||||||
|
const sortDesc = ref(true);
|
||||||
|
const filterType = ref('ALL'); // 'ALL' | 'CONFERENCE_PAPER' | 'RESEARCH_POST'
|
||||||
|
const page = ref(1);
|
||||||
|
|
||||||
// --- Interfaces ---
|
// --- Interfaces ---
|
||||||
|
|
||||||
interface Tag {
|
interface Tag {
|
||||||
@@ -348,6 +416,20 @@ async function fetchTags() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// --- Computed ---
|
// --- Computed ---
|
||||||
const totalCitations = computed(() =>
|
const totalCitations = computed(() =>
|
||||||
userProfile.value.publications.reduce((sum, pub) => sum + (pub.citations || 0), 0)
|
userProfile.value.publications.reduce((sum, pub) => sum + (pub.citations || 0), 0)
|
||||||
@@ -390,11 +472,17 @@ async function fetchProfileData() {
|
|||||||
headers: { Authorization: `Bearer ${token.value}` }
|
headers: { Authorization: `Bearer ${token.value}` }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch Publications
|
// Fetch User Publications
|
||||||
// const publicationsData = await $fetch(`${api}/publications/my-submissions`, {
|
const publicationsData = await $fetch(`${api}/publications/me`, {
|
||||||
// method: 'GET',
|
method: 'GET',
|
||||||
// headers: { Authorization: `Bearer ${token.value}` }
|
headers: { Authorization: `Bearer ${token.value}` },
|
||||||
// });
|
params: {
|
||||||
|
page: page.value,
|
||||||
|
sort: sortBy.value,
|
||||||
|
order: sortDesc.value ? 'desc' : 'asc',
|
||||||
|
type: filterType.value !== 'ALL' ? filterType.value : undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Map Data
|
// Map Data
|
||||||
userProfile.value = {
|
userProfile.value = {
|
||||||
|
|||||||
Reference in New Issue
Block a user