Author SHA1 Message Date
Edd 901244ab48 Merge pull request 'fix frontend bugs' (#19) from na/fix-frontend into master
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DAEProject_Frontend/pulls/19
Reviewed-by: FernandoJVideira <[email protected]>
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
2026-01-26 22:01:32 +00:00
Edd 7902992b77 Merge branch 'master' into na/fix-frontend 2026-01-26 19:14:57 +00:00
Edd 29ebda9b18 fix frontend bugs 2026-01-26 19:12:50 +00:00
FernandoJVideira b5dff50d18 Merge pull request 'LET'S FUCKING GOOOOO' (#18) from develop into master
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DAEProject_Frontend/pulls/18
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: Edd <[email protected]>
2026-01-23 21:47:54 +00:00
FernandoJVideira b60432f1b9 Merge pull request 'THIS-IS-THE-LAST-COMMIT-I-PROMISE-XoXo-PIXA-GORDA' (#17) from THIS-IS-THE-LAST-COMMIT-I-PROMISE-XoXo-PIXA-GORDA into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DAEProject_Frontend/pulls/17
Reviewed-by: FernandoJVideira <[email protected]>
2026-01-23 21:46:37 +00:00
AfonsoCMSousa af6ca88fd4 dont read the name of the branch 2026-01-23 21:45:49 +00:00
AfonsoCMSousa e7b9fdb1bd Merge pull request 'Final front end' (#16) from na/final-frontend into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DAEProject_Frontend/pulls/16
Reviewed-by: FernandoJVideira <[email protected]>
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
2026-01-23 21:23:09 +00:00
Edd 582cb3cb4c user history 2026-01-23 21:21:43 +00:00
Edd 90e0624cfa final frontend part 2 2026-01-23 20:30:00 +00:00
Edd d56b1e5fb9 Merge branch 'develop' into na/final-frontend 2026-01-23 20:07:13 +00:00
Edd 9d99663b0d Merge pull request 'fix: added more file types' (#15) from fix/types into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DAEProject_Frontend/pulls/15
Reviewed-by: Edd <[email protected]>
2026-01-23 20:06:37 +00:00
Edd e38613ca6b final frontend 2026-01-23 20:04:46 +00:00
FernandoJVideira 8ce343a195 fix: added more file types 2026-01-23 20:03:32 +00:00
Edd 3838f3ff7a Merge pull request 'feat: basic toggle post visibility' (#14) from ft/toggle-visibility into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DAEProject_Frontend/pulls/14
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: Edd <[email protected]>
2026-01-23 18:29:57 +00:00
FernandoJVideira 9fff07c674 fix: added back edit post button 2026-01-23 18:02:34 +00:00
FernandoJVideira 6eae88f4e9 feat: basic toggle post visibility 2026-01-23 17:29:04 +00:00
Edd e423497fd2 Merge pull request 'final fe part 1' (#13) from na/tags-posts-and-others into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DAEProject_Frontend/pulls/13
Reviewed-by: FernandoJVideira <[email protected]>
2026-01-23 14:02:16 +00:00
Edd b9ad7879a6 final fe part 1 2026-01-23 03:01:13 +00:00
Edd 3570527bbf Merge pull request 'feat: fixed admin page, settings on user' (#12) from feature/profile-edit into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DAEProject_Frontend/pulls/12
Reviewed-by: FernandoJVideira <[email protected]>
Reviewed-by: Edd <[email protected]>
2026-01-22 22:22:00 +00:00
AfonsoCMSousa 1644d9f80c feat: fixed admin page, settings on user 2026-01-22 21:04:46 +00:00
Edd 6477057798 Merge pull request 'fix/admin-active-status' (#11) from fix/admin-active-status into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DAEProject_Frontend/pulls/11
Reviewed-by: Edd <[email protected]>
2026-01-22 18:56:27 +00:00
AfonsoCMSousa dacec71338 feat: profile publications in page 2026-01-22 18:51:14 +00:00
AfonsoCMSousa 0d93b212c6 feat: profile publications in page 2026-01-22 16:27:28 +00:00
AfonsoCMSousa c2bb9a9b77 feat: admin panel 2026-01-22 15:34:33 +00:00
FernandoJVideira 77c05ee490 Merge pull request 'home-page functionality, and some design tweaks' (#10) from feature/home-page into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DAEProject_Frontend/pulls/10
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: FernandoJVideira <[email protected]>
2026-01-21 02:10:53 +00:00
8 changed files with 1526 additions and 713 deletions
+503
View File
@@ -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>
+67 -43
View File
@@ -1,18 +1,18 @@
<script setup lang="ts"> <script setup>
import { ref, reactive, watch, computed } from 'vue'; import { ref, reactive, watch } from 'vue';
const props = defineProps<{ const props = defineProps({
isOpen: boolean; isOpen: Boolean,
api: string; api: String,
token: string | null; token: String,
}>(); postToEdit: Object
});
const emit = defineEmits(['close', 'refresh']); const emit = defineEmits(['close', 'refresh']);
interface Tag { id: number; name: string; } const notifications = useNotificationStore();
const isSubmitting = ref(false); const isSubmitting = ref(false);
const availableTags = ref<Tag[]>([]); const availableTags = ref([]);
const errors = reactive({ const errors = reactive({
title: '', title: '',
abstract: '' abstract: ''
@@ -23,12 +23,11 @@ const uploadForm = reactive({
abstract: '', abstract: '',
type: 'CONFERENCE_PAPER', type: 'CONFERENCE_PAPER',
newAuthorName: '', newAuthorName: '',
authors: [] as string[], authors: [],
selectedTagIds: [] as number[], selectedTagIds: [],
file: null as File | null file: null
}); });
// --- Validation Logic ---
const validate = () => { const validate = () => {
let isValid = true; let isValid = true;
errors.title = ''; errors.title = '';
@@ -56,7 +55,7 @@ const validate = () => {
async function fetchTags() { async function fetchTags() {
if (!props.token) return; if (!props.token) return;
try { try {
const data = await $fetch<Tag[]>(`${props.api}/tags`, { const data = await $fetch(`${props.api}/tags`, {
headers: { Authorization: `Bearer ${props.token}` } headers: { Authorization: `Bearer ${props.token}` }
}); });
availableTags.value = data || []; availableTags.value = data || [];
@@ -73,26 +72,34 @@ async function submitPublication() {
authors: uploadForm.authors, authors: uploadForm.authors,
type: uploadForm.type, type: uploadForm.type,
abstract: uploadForm.abstract, abstract: uploadForm.abstract,
tags: uploadForm.selectedTagIds, tags: uploadForm.selectedTagIds
// Sending fixed defaults for removed fields to maintain API compatibility if needed };
publication_date: new Date().toISOString().split('T')[0],
is_confidential: false 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(); const formData = new FormData();
if (uploadForm.file) formData.append('file', uploadForm.file); if (uploadForm.file) formData.append('file', uploadForm.file);
formData.append('data', JSON.stringify(metadata)); formData.append('data', JSON.stringify(metadata));
await $fetch(`${props.api}/publications`, { await $fetch(url, {
method: 'POST', method: isEditing ? 'PATCH' : 'POST',
headers: { Authorization: `Bearer ${props.token}` }, headers,
body: formData body: formData
}); });
notifications.success(`Publication ${isEditing ? "updated" : "created"}`);
emit('refresh'); emit('refresh');
closeModal(); closeModal();
} catch (error) { } catch (error) {
alert("Upload failed."); console.error("Submission error:", error);
notifications.error("Operation failed");
} finally { } finally {
isSubmitting.value = false; isSubmitting.value = false;
} }
@@ -105,28 +112,43 @@ function addAuthor() {
} }
} }
function removeAuthor(index: number) { uploadForm.authors.splice(index, 1); } function removeAuthor(index) { uploadForm.authors.splice(index, 1); }
function handleFileChange(event: Event) { function handleFileChange(event) {
const input = event.target as HTMLInputElement; const input = event.target;
if (input.files?.[0]) uploadForm.file = input.files[0]; if (input.files?.[0]) uploadForm.file = input.files[0];
} }
function closeModal() { function closeModal() {
uploadForm.title = '';
uploadForm.abstract = '';
uploadForm.type = 'CONFERENCE_PAPER';
uploadForm.newAuthorName = '';
uploadForm.authors = [];
uploadForm.selectedTagIds = [];
uploadForm.file = null;
emit('close'); emit('close');
} }
watch(() => props.isOpen, (newVal) => { watch(() => props.isOpen, (newVal) => {
if (newVal && availableTags.value.length === 0) fetchTags(); if (newVal && availableTags.value.length === 0) fetchTags();
if (!newVal) { if (!newVal) {
// Reset errors on close
errors.title = ''; errors.title = '';
errors.abstract = ''; errors.abstract = '';
} }
}); });
// Helper for colorful tags watch(() => props.postToEdit, (newPost) => {
const getTagClass = (id: number) => { 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 = [ const colors = [
'bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-900/30 dark:text-pink-300', '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-purple-100 text-purple-700 border-purple-200 dark:bg-purple-900/30 dark:text-purple-300',
@@ -143,7 +165,7 @@ const getTagClass = (id: number) => {
<Transition enter-active-class="transition duration-200 ease-out" enter-from-class="opacity-0" <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" enter-to-class="opacity-100" leave-active-class="transition duration-150 ease-in" leave-from-class="opacity-100"
leave-to-class="opacity-0"> leave-to-class="opacity-0">
<div v-if="isOpen" class="fixed inset-0 z-50 overflow-y-auto"> <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="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="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
@@ -204,16 +226,18 @@ const getTagClass = (id: number) => {
<select v-model="uploadForm.type" <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"> 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="CONFERENCE_PAPER">Conference Paper</option>
<option value="ARTICLE">Article</option> <option value="RESEARCH_POST">Research Post</option>
<option value="THESIS">Thesis</option> <option value="THESIS">Thesis</option>
<option value="ARTICLE">Article</option>
<option value="REPORT">Report</option> <option value="REPORT">Report</option>
<option value="OTHER">Other</option>
</select> </select>
</div> </div>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"> <label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Abstract ({{ uploadForm.abstract.length }}/2000) Abstract ({{ uploadForm.abstract.length }}/255)
</label> </label>
<textarea v-model="uploadForm.abstract" rows="4" <textarea v-model="uploadForm.abstract" rows="4"
:class="[errors.abstract ? 'border-red-500' : 'border-slate-300 dark:border-slate-700']" :class="[errors.abstract ? 'border-red-500' : 'border-slate-300 dark:border-slate-700']"
@@ -224,7 +248,7 @@ const getTagClass = (id: number) => {
<div class="flex flex-col md:flex-row gap-8"> <div class="flex flex-col md:flex-row gap-8">
<div class="flex-1"> <div class="w-full">
<label <label
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Authors</label> class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Authors</label>
<div class="relative flex items-center"> <div class="relative flex items-center">
@@ -241,7 +265,7 @@ const getTagClass = (id: number) => {
</svg> </svg>
</button> </button>
</div> </div>
<div class="flex flex-wrap gap-2 mt-3"> <div :class="['flex flex-wrap gap-2', uploadForm.authors.length != 0 ? 'mt-3' : '']">
<span v-for="(author, index) in uploadForm.authors" :key="index" <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"> 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 }} {{ author }}
@@ -255,7 +279,7 @@ const getTagClass = (id: number) => {
</div> </div>
</div> </div>
<div class="w-full md:w-auto min-w-[200px]"> <div class="flex-1 md:w-auto min-w-[300px]">
<label <label
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Tags</label> class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Tags</label>
<div <div
@@ -264,16 +288,16 @@ const getTagClass = (id: number) => {
class="relative rounded-md font-semibold text-blue-600"> class="relative rounded-md font-semibold text-blue-600">
<span>No tags</span> <span>No tags</span>
</label> </label>
<button v-for="tag in availableTags" :key="tag.id" type="button" @click="() => { <button v-for="tag in availableTags" :key="tag.id" type="button" :class="[
const idx = uploadForm.selectedTagIds.indexOf(tag.id);
if (idx > -1) uploadForm.selectedTagIds.splice(idx, 1);
else uploadForm.selectedTagIds.push(tag.id);
}" :class="[
uploadForm.selectedTagIds.includes(tag.id) uploadForm.selectedTagIds.includes(tag.id)
? getTagClass(tag.id) + ' border-transparent' ? 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' : '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"
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 }} {{ tag.name }}
</button> </button>
</div> </div>
@@ -286,7 +310,7 @@ const getTagClass = (id: number) => {
<button :disabled="isSubmitting" <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" 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"> @click="submitPublication">
{{ isSubmitting ? 'Uploading...' : 'Submit Publication' }} {{ isSubmitting ? 'Uploading...' : 'Submit' }}
</button> </button>
<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" 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"
-5
View File
@@ -165,13 +165,11 @@ import { useAuthStore } from "~/stores/auth-store.js";
const authStore = useAuthStore(); const authStore = useAuthStore();
const router = useRouter(); const router = useRouter();
// 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);
const userMenuRef = ref(null); const userMenuRef = ref(null);
// Toggle Dark Mode
function toggleDark() { function toggleDark() {
isDark.value = !isDark.value; isDark.value = !isDark.value;
if (isDark.value) { if (isDark.value) {
@@ -183,7 +181,6 @@ function toggleDark() {
} }
} }
// Navigation Links
const navLinks = [ const navLinks = [
{ name: 'Profile', path: '/profile' }, { name: 'Profile', path: '/profile' },
]; ];
@@ -208,13 +205,11 @@ function closeDropdowns(event) {
} }
onMounted(() => { onMounted(() => {
// Initialize Theme
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
isDark.value = true; isDark.value = true;
document.documentElement.classList.add('dark'); document.documentElement.classList.add('dark');
} }
// Click listener for closing dropdowns
window.addEventListener('click', closeDropdowns); window.addEventListener('click', closeDropdowns);
}); });
+63 -117
View File
@@ -202,11 +202,9 @@
<span <span
class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-bold border capitalize" class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-bold border capitalize"
:class="{ :class="{
'bg-purple-100 border-purple-200 text-purple-700 dark:bg-purple-900/20 dark:border-purple-800 dark:text-purple-300': user.role === 'ADMIN', 'bg-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-blue-100 border-blue-200 text-blue-700 dark:bg-blue-900/20 dark:border-blue-800 dark:text-blue-300': user.role === 'TEACHER', 'bg-emerald-100 border-emerald-200 text-emerald-700 dark:bg-emerald-900/20 dark:border-emerald-800 dark:text-emerald-300': user.role === 'MODERATOR',
'bg-emerald-100 border-emerald-200 text-emerald-700 dark:bg-emerald-900/20 dark:border-emerald-800 dark:text-emerald-300': user.role === 'RESEARCHER', 'bg-amber-100 border-amber-200 text-amber-700 dark:bg-amber-900/20 dark:border-amber-800 dark:text-amber-300': user.role === 'COLLABORATOR'
'bg-amber-100 border-amber-200 text-amber-700 dark:bg-amber-900/20 dark:border-amber-800 dark:text-amber-300': user.role === 'COLLABORATOR',
'bg-slate-100 border-slate-200 text-slate-700 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300': user.role === 'STUDENT'
}"> }">
{{ user.role.toLowerCase() }} {{ user.role.toLowerCase() }}
</span> </span>
@@ -276,7 +274,7 @@
<div <div
class="w-1.5 h-8 bg-gradient-to-b from-blue-600 to-indigo-600 rounded-full" /> 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 ? <h3 class="text-xl font-bold text-slate-900 dark:text-white">{{ isEditing ?
'Edit User Details' : 'Create New User' }}</h3> 'Edit User Details' : 'Create' }}</h3>
</div> </div>
<button class="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300" <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" @click="closeModal"><svg class="w-5 h-5" fill="none" viewBox="0 0 24 24"
@@ -313,10 +311,8 @@
<div class="relative"> <div class="relative">
<select v-model="formData.role" <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"> class="w-full appearance-none px-4 py-3 rounded-xl border-2 border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-all cursor-pointer">
<option value="STUDENT">Student</option>
<option value="TEACHER">Teacher</option>
<option value="COLLABORATOR">Collaborator</option> <option value="COLLABORATOR">Collaborator</option>
<option value="RESEARCHER">Researcher</option> <option value="MODERATOR">Moderator</option>
<option value="ADMIN">Admin</option> <option value="ADMIN">Admin</option>
</select> </select>
<svg class="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400 pointer-events-none" <svg class="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400 pointer-events-none"
@@ -372,7 +368,7 @@
<button <button
class="px-8 py-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-xl shadow-lg transition-all" class="px-8 py-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-xl shadow-lg transition-all"
@click="handleSubmit">{{ @click="handleSubmit">{{
isEditing ? 'Save Changes' : 'Create Account' }}</button> isEditing ? 'Save Changes' : 'Create' }}</button>
<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" 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> @click="closeModal">Cancel</button>
@@ -445,27 +441,30 @@
class="relative bg-slate-50 dark:bg-slate-800/50 rounded-xl p-5 border border-slate-200 dark:border-slate-800"> 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 gap-4">
<div class="flex flex-col items-center min-w-[90px] text-center"> <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 <span class="text-xs font-bold text-slate-500 uppercase mb-1">
Date(item.timestamp).toLocaleDateString('en-US', { {{ new Date(item.timestamp).toLocaleDateString('en-US', {
month: 'short', month:
day: 'numeric' 'short', day: 'numeric' }) }}
}) }}</span> </span>
<span class="text-xs text-slate-400">{{ new <span class="text-xs text-slate-400">
Date(item.timestamp).toLocaleTimeString([], { {{ new Date(item.timestamp).toLocaleTimeString([], {
hour: '2-digit', hour:
minute: '2-digit' '2-digit', minute: '2-digit' }) }}
}) }}</span> </span>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<span <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">{{ class="px-2.5 py-1 rounded-md text-xs font-bold uppercase tracking-wide bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300">
item.action.replace('_', ' ') }}</span> {{ item.actionType.replace('_', ' ') }}
<span class="text-xs text-slate-400 font-medium">{{ item.target_type </span>
}} #{{ item.target_id }}</span> <span class="text-xs text-slate-400 font-medium">
{{ item.targetType }} #{{ item.targetId }}
</span>
</div> </div>
<p class="text-sm text-slate-700 dark:text-slate-300 leading-relaxed">{{ <p class="text-sm text-slate-700 dark:text-slate-300 leading-relaxed">
item.details }}</p> {{ item.description }}
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -488,7 +487,7 @@
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup>
import { ref, reactive, computed, onMounted } from 'vue'; import { ref, reactive, computed, onMounted } from 'vue';
import { useAuthStore } from "~/stores/auth-store.js"; import { useAuthStore } from "~/stores/auth-store.js";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
@@ -497,95 +496,26 @@ const config = useRuntimeConfig();
const api = config.public.apiBase; const api = config.public.apiBase;
const authStore = useAuthStore(); const authStore = useAuthStore();
const { token } = storeToRefs(authStore); const { token } = storeToRefs(authStore);
const notifications = useNotificationStore();
// State const users = ref([]);
const users = ref<any[]>([
{ id: 1, name: 'Dr. Sarah Mitchell', email: '[email protected]', role: 'ADMIN', is_active: true },
{ id: 2, name: 'Prof. James Anderson', email: '[email protected]', role: 'TEACHER', is_active: true },
{ id: 3, name: 'Dr. Emily Chen', email: '[email protected]', role: 'RESEARCHER', is_active: true },
{ id: 4, name: 'Michael Roberts', email: '[email protected]', role: 'STUDENT', is_active: true },
{ id: 5, name: 'Dr. David Kim', email: '[email protected]', role: 'COLLABORATOR', is_active: true },
{ id: 6, name: 'Prof. Maria Garcia', email: '[email protected]', role: 'TEACHER', is_active: true },
{ id: 7, name: 'Jessica Thompson', email: '[email protected]', role: 'STUDENT', is_active: true },
{ id: 8, name: 'Dr. Robert Wilson', email: '[email protected]', role: 'RESEARCHER', is_active: true },
{ id: 9, name: 'Amanda Foster', email: '[email protected]', role: 'STUDENT', is_active: false },
{ id: 10, name: 'Prof. Thomas Lee', email: '[email protected]', role: 'ADMIN', is_active: true },
{ id: 11, name: 'Dr. Lisa Martinez', email: '[email protected]', role: 'RESEARCHER', is_active: true },
{ id: 12, name: 'Daniel Brown', email: '[email protected]', role: 'STUDENT', is_active: true },
{ id: 13, name: 'Prof. Jennifer Davis', email: '[email protected]', role: 'TEACHER', is_active: true },
{ id: 14, name: 'Christopher Taylor', email: '[email protected]', role: 'STUDENT', is_active: false },
{ id: 15, name: 'Dr. Patricia Johnson', email: '[email protected]', role: 'COLLABORATOR', is_active: true },
{ id: 16, name: 'Matthew White', email: '[email protected]', role: 'STUDENT', is_active: true },
{ id: 17, name: 'Prof. Elizabeth Moore', email: '[email protected]', role: 'TEACHER', is_active: true },
{ id: 18, name: 'Dr. William Harris', email: '[email protected]', role: 'RESEARCHER', is_active: true },
{ id: 19, name: 'Sophie Anderson', email: '[email protected]', role: 'STUDENT', is_active: true },
{ id: 20, name: 'Dr. Richard Clark', email: '[email protected]', role: 'ADMIN', is_active: true }
]);
const loading = ref(false); const loading = ref(false);
const showModal = ref(false); const showModal = ref(false);
const showDetailsModal = ref(false); const showDetailsModal = ref(false);
const isEditing = ref(false); const isEditing = ref(false);
const searchQuery = ref(''); const searchQuery = ref('');
const selectedUser = ref<any>(null); const selectedUser = ref(null);
const userHistory = ref<any[]>([]); const userHistory = ref([]);
// Mock history data generator
const generateMockHistory = (userId: number) => {
const actions = ['UPLOADED_PUBLICATION', 'ADDED_COMMENT', 'CORRECTED_PUBLICATION', 'RATED_PUBLICATION', 'TAGGED_PUBLICATION'];
const targetTypes = ['Publication', 'Comment', 'Review'];
const details = [
'Fez upload da publicação "A New Technique for Data Science"',
'Comentou na publicação "Reference Paper Y"',
'Corrigiu informação da publicação "Machine Learning Advances"',
'Atribuiu 5 estrelas à publicação "Deep Learning Research"',
'Adicionou tag "AI" à publicação "Neural Networks Study"',
'Fez upload da publicação "Quantum Computing Overview"',
'Comentou na publicação "Data Analysis Methods"',
'Corrigiu informação da publicação "Statistical Models in Research"',
'Atribuiu 4 estrelas à publicação "Big Data Trends"',
'Adicionou tag "Data Science" à publicação "Data Visualization Techniques"'
];
const history = [];
const numEvents = Math.floor(Math.random() * 20) + 8;
for (let i = 0; i < numEvents; i++) {
const randomAction = actions[Math.floor(Math.random() * actions.length)];
const randomTargetType = targetTypes[Math.floor(Math.random() * targetTypes.length)];
const randomDetails = details[Math.floor(Math.random() * details.length)];
const daysAgo = Math.floor(Math.random() * 30);
const hoursAgo = Math.floor(Math.random() * 24);
const minutesAgo = Math.floor(Math.random() * 60);
const timestamp = new Date();
timestamp.setDate(timestamp.getDate() - daysAgo);
timestamp.setHours(timestamp.getHours() - hoursAgo);
timestamp.setMinutes(timestamp.getMinutes() - minutesAgo);
history.push({
id: 1000 + i,
action: randomAction,
target_type: randomTargetType,
target_id: 200 + Math.floor(Math.random() * 50),
details: randomDetails,
timestamp: timestamp.toISOString()
});
}
return history.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
};
// Form Data
const formData = reactive({ const formData = reactive({
id: 0, id: 0,
name: '', name: '',
email: '', email: '',
password: '', password: '',
role: 'STUDENT', role: 'COLLABORATOR',
is_active: true is_active: true
}); });
// Computed Stats & Filtering
const filteredUsers = computed(() => { const filteredUsers = computed(() => {
if (!searchQuery.value) return users.value; if (!searchQuery.value) return users.value;
const query = searchQuery.value.toLowerCase(); const query = searchQuery.value.toLowerCase();
@@ -596,13 +526,12 @@ const filteredUsers = computed(() => {
}); });
const activeCount = computed(() => users.value.filter(u => u.is_active).length); const activeCount = computed(() => users.value.filter(u => u.is_active).length);
const adminCount = computed(() => users.value.filter(u => u.role === 'ADMIN').length); const adminCount = computed(() => users.value.filter(u => u.role === 'ADMINISTRATOR').length);
// --- API ACTIONS ---
const fetchUsers = async () => { const fetchUsers = async () => {
loading.value = true; loading.value = true;
try { try {
const data = await $fetch<any[]>(`${api}/admin/users`, { const data = await $fetch(`${api}/admin/users`, {
method: 'GET', method: 'GET',
headers: { Authorization: `Bearer ${token.value}` } headers: { Authorization: `Bearer ${token.value}` }
}); });
@@ -626,10 +555,11 @@ const createUser = async () => {
role: formData.role role: formData.role
} }
}); });
notifications.success("User created successfully");
closeModal(); closeModal();
fetchUsers(); fetchUsers();
} catch (error) { } catch (error) {
alert("Failed to create user."); notifications.error("Failed to create user");
} }
}; };
@@ -643,10 +573,11 @@ const updateUser = async () => {
is_active: formData.is_active is_active: formData.is_active
} }
}); });
notifications.success("User updated successfully");
closeModal(); closeModal();
fetchUsers(); fetchUsers();
} catch (error) { } catch (error) {
alert("Failed to update user."); notifications.error("Failed to update user");
} }
}; };
@@ -656,36 +587,51 @@ const sendPasswordRecovery = async () => {
try { try {
await $fetch(`${api}/auth/recover-password`, { await $fetch(`${api}/auth/recover-password`, {
method: 'POST', method: 'POST',
// headers: { Authorization: ... } // EP02 is usually public, but check your API requirements
body: { body: {
email: formData.email email: formData.email
} }
}); });
alert(`Recovery sent to ${formData.email}`); notifications.success(`Recovery sent to ${formData.email}`);
} catch (error) { } catch (error) {
alert("Failed to send recovery.");
console.error(error); console.error(error);
notifications.error("Failed to send recovery");
} }
}; };
const confirmDelete = async (user: any) => { const confirmDelete = async (user) => {
if (!confirm(`Are you sure you want to delete ${user.name}?`)) return; if (!confirm(`Are you sure you want to delete ${user.name}?`)) return;
try { try {
await $fetch(`${api}/admin/users/${user.id}`, { await $fetch(`${api}/admin/users/${user.id}`, {
method: 'DELETE', method: 'DELETE',
headers: { Authorization: `Bearer ${token.value}` } headers: { Authorization: `Bearer ${token.value}` }
}); });
notifications.success("Deleted user");
fetchUsers(); fetchUsers();
} catch (error) { } catch (error) {
alert("Error deleting user."); notifications.error("Failed to delete user");
} }
}; };
// --- HELPERS --- const openUserDetails = async (user) => {
const openUserDetails = (user: any) => {
selectedUser.value = user; selectedUser.value = user;
userHistory.value = generateMockHistory(user.id);
showDetailsModal.value = true; 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 = () => { const closeDetailsModal = () => {
@@ -700,7 +646,7 @@ const openCreateModal = () => {
showModal.value = true; showModal.value = true;
}; };
const openEditModal = (user: any) => { const openEditModal = (user) => {
isEditing.value = true; isEditing.value = true;
formData.id = user.id; formData.id = user.id;
formData.name = user.name; formData.name = user.name;
@@ -725,11 +671,11 @@ const resetForm = () => {
formData.name = ''; formData.name = '';
formData.email = ''; formData.email = '';
formData.password = ''; formData.password = '';
formData.role = 'STUDENT'; formData.role = 'COLLABORATOR';
formData.is_active = true; formData.is_active = true;
}; };
const getInitials = (name: string) => { const getInitials = (name) => {
if (!name) return '??'; if (!name) return '??';
return name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase(); return name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase();
}; };
+3 -2
View File
@@ -53,6 +53,7 @@
import { useAuthStore } from "~/stores/auth-store.js"; import { useAuthStore } from "~/stores/auth-store.js";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
const notifications = useNotificationStore();
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const api = config.public.apiBase; const api = config.public.apiBase;
const authStore = useAuthStore(); const authStore = useAuthStore();
@@ -91,18 +92,18 @@ 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) {
notifications.error("Please update your password.");
router.push("/profile/update-password"); router.push("/profile/update-password");
} else { } else {
notifications.success("Logged in successfully!");
router.push("/"); router.push("/");
} }
} }
}, },
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 {
+251 -127
View File
@@ -4,17 +4,45 @@
<div class="max-w-6xl mx-auto flex gap-6 py-8 px-4"> <div class="max-w-6xl mx-auto flex gap-6 py-8 px-4">
<aside v-if="!selectedPost" class="hidden lg:block w-fit min-w-[160px] py-24"> <aside v-if="!selectedPost" class="hidden lg:block w-fit min-w-[160px] py-24">
<div class="sticky top-8"> <div class="sticky top-24">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<button v-for="tag in availableTags" :key="tag.id" :class="[ <button v-if="user?.role !== 'COLLABORATOR'"
'px-3 py-2 rounded-lg text-xs font-bold transition-all text-left shadow-sm cursor-pointer', 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"
@click="showCreateInput = !showCreateInput">
<span>+</span> NEW TAG
</button>
<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) subscribedTags.includes(tag.id)
? `${tag.color} border-current ring-1 ring-inset ring-black/5` ? `${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' : 'bg-gray-50 dark:bg-gray-800 dark:border-gray-800 border opacity-60 hover:opacity-100'
]" @click="toggleTagSubscription(tag)"> ]" @click="toggleTagSubscription(tag)">
<span class="truncate">
<span class="opacity-70 mr-1">{{ subscribedTags.includes(tag.id) ? '✓' : '#' }}</span> <span class="opacity-70 mr-1">{{ subscribedTags.includes(tag.id) ? '✓' : '#' }}</span>
{{ tag.name }} {{ tag.name }}
</span>
</button> </button>
<input v-else v-model="editTagName"
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"
autofocus @keyup.enter="handleEditTag(tag.id)" @blur="editingTagId = null">
<button v-if="user?.role !== 'COLLABORATOR' && editingTagId !== tag.id"
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"
@click.stop="startEditing(tag)">
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
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" />
</svg>
</button>
</div>
</div> </div>
</div> </div>
</aside> </aside>
@@ -28,15 +56,16 @@
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 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="flex flex-wrap items-center gap-2 w-full sm:w-auto">
<div class="relative flex-1 sm:flex-none"> <div class="relative flex-1 sm:flex-none">
<input v-model="searchQuery" type="text" placeholder="Search posts..." <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"> 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>
<div class="flex items-center"> <div class="flex items-center">
<select v-model="sortBy" <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"> 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="date">Date</option>
<option value="votes">Rating</option> <option value="rating">Rating</option>
<option value="comments">Comments</option> <option value="comment">Comments</option>
</select> </select>
<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"
@@ -52,6 +81,10 @@
<option value="ALL">All Types</option> <option value="ALL">All Types</option>
<option value="CONFERENCE_PAPER">Conference Paper</option> <option value="CONFERENCE_PAPER">Conference Paper</option>
<option value="RESEARCH_POST">Research Post</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> </select>
</div> </div>
@@ -98,14 +131,20 @@
<TransitionGroup v-else appear enter-active-class="transition duration-500 ease-out" <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"> 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" <div v-for="post in filteredPosts" :key="post.id"
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" :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)"> @click="fetchPostDetail(post.id)">
<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">
@@ -114,16 +153,45 @@
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"> 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('_', ' ') }} {{ post.type.replace('_', ' ') }}
</span> </span>
<span class="text-xs text-gray-500">{{ post.publication_date }}</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> </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 truncate max-w-[400px]">
{{ post.authors.length > 0 ? `By ${post.authors.join(', ')}` : 'No Authors' }}
</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 min-w-0">
<span>💬 {{ post.comment_count }} Comments</span> <span class="shrink-0">💬 {{ 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 <div class="max-w-[300px] truncate">
}}</span> <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> </div>
@@ -149,70 +217,13 @@
<div v-if="loading" <div v-if="loading"
class="animate-spin h-6 w-6 border-2 border-blue-600 border-t-transparent rounded-full" /> class="animate-spin h-6 w-6 border-2 border-blue-600 border-t-transparent rounded-full" />
</div> </div>
</div>
<div v-if="selectedPost" class="animate-fade-in-up">
<button
class="mb-6 flex items-center gap-2 text-sm font-bold text-blue-600 dark:text-blue-400 hover:underline"
@click="selectedPost = null">
BACK TO FEED
</button>
<article class="bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-xl p-8 shadow-lg">
<header class="mb-6">
<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">
<h4 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-2">Abstract</h4>
<p class="text-gray-700 dark:text-gray-300 leading-relaxed text-lg">{{ selectedPost.abstract }}</p>
</div> </div>
<hr class="border-gray-100 dark:border-gray-800 mb-8"> <PostDetail v-if="selectedPost" :post="selectedPost" :api="api" :token="token" @back="selectedPost = null"
@vote="(dir) => votePost(selectedPost, dir)" @comment-added="() => selectedPost.commentCount++" />
<section class="mb-10"> <CreatePostModal :is-open="isModalOpen" :post-to-edit="postToEdit" :api="api" :token="token"
<h3 class="text-xl font-bold mb-4">Comments ({{ selectedPost.comments.length }})</h3> @close="closeModal()" @refresh="handleRefresh" />
<div class="space-y-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"
placeholder="What are your thoughts?" />
<div class="flex justify-end">
<button :disabled="!newCommentContent.trim()"
class="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-bold px-6 py-2 rounded-lg shadow transition-all active:scale-95"
@click="submitComment">
COMMENT
</button>
</div>
</div>
<div class="mt-8 space-y-6">
<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="flex-1">
<div class="flex items-center gap-2 mb-1">
<span class="font-bold text-sm text-blue-600 dark:text-blue-400">{{ comment.author.name }}</span>
<span class="text-[10px] text-gray-500 font-bold uppercase">{{ new
Date(comment.created_at).toLocaleDateString() }}</span>
</div>
<p class="text-gray-700 dark:text-gray-300 text-sm leading-relaxed">
{{ comment.content }}
</p>
</div>
</div>
</div>
</section>
</article>
</div>
<CreatePostModal :is-open="isModalOpen" :api="api" :token="token" @close="closeModal()"
@refresh="handleRefresh" />
</main> </main>
</div> </div>
</div> </div>
@@ -225,13 +236,13 @@ import { storeToRefs } from "pinia";
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import CreatePostModal from "~/components/ui/modal/CreatePostModal.vue"; import CreatePostModal from "~/components/ui/modal/CreatePostModal.vue";
const route = useRoute(); // 2. Access the route object
const config = useRuntimeConfig();
const authStore = useAuthStore(); const authStore = useAuthStore();
const { user, token } = storeToRefs(authStore); const { user, token } = storeToRefs(authStore);
const route = useRoute();
const config = useRuntimeConfig();
const notifications = useNotificationStore();
const api = config.public.apiBase; const api = config.public.apiBase;
// --- State Management ---
const posts = ref([]); const posts = ref([]);
const selectedPost = ref(null); const selectedPost = ref(null);
const loading = ref(false); const loading = ref(false);
@@ -239,18 +250,20 @@ 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);
const filterType = ref('ALL');
const subscribedTags = ref([]); const subscribedTags = ref([]);
const availableTags = ref([]); const availableTags = ref([]);
const showCreateInput = ref(false);
const newTagName = ref('');
const editingTagId = ref(null);
const editTagName = ref('');
const filterType = ref('ALL'); const postToEdit = ref(null);
const newCommentContent = ref('');
// Form States
const checkQueryForModal = () => { const checkQueryForModal = () => {
if (route.query.openCreatePostModal === 'true') { if (route.query.openCreatePostModal === 'true') {
@@ -258,9 +271,92 @@ const checkQueryForModal = () => {
} }
} }
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 closeModal = () => {
const router = useRouter(); const router = useRouter();
isModalOpen.value = false; isModalOpen.value = false;
postToEdit.value = null;
router.replace({ query: { ...route.query, openCreatePostModal: undefined } }); router.replace({ query: { ...route.query, openCreatePostModal: undefined } });
}; };
@@ -291,11 +387,11 @@ const fetchTags = async () => {
subscribedTags.value = myTags.map(t => t.id); subscribedTags.value = myTags.map(t => t.id);
} catch (err) { } catch (err) {
console.error("Error fetching tags:", err); console.error("Error fetching tags:", err);
notifications.error("Failed to subscribe to tag");
} }
}; };
const toggleTagSubscription = async (tag) => { const toggleTagSubscription = async (tag) => {
const index = subscribedTags.value.indexOf(tag.id); const index = subscribedTags.value.indexOf(tag.id);
const isSubscribing = index === -1; const isSubscribing = index === -1;
@@ -318,30 +414,18 @@ const toggleTagSubscription = async (tag) => {
else subscribedTags.value.push(tag.id); else subscribedTags.value.push(tag.id);
console.error("Sync failed:", err); console.error("Sync failed:", err);
notifications.error("Failed to subscribe to tag");
} }
}; };
const handleRefresh = () => { const handleRefresh = () => {
isModalOpen.value = false; isModalOpen.value = false;
posts.value = [];
page.value = 1;
hasMore.value = true;
loadPosts(); loadPosts();
}; window.scrollTo({ top: 0, behavior: 'smooth' })
const submitComment = () => {
if (!newCommentContent.value.trim()) return;
const comment = {
id: Date.now(),
content: newCommentContent.value,
author: { name: user.value?.name || "Guest" },
created_at: new Date().toISOString()
};
selectedPost.value.comments.unshift(comment);
newCommentContent.value = '';
}; };
const loadPosts = async () => { const loadPosts = async () => {
@@ -349,14 +433,15 @@ 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: {
page: page.value, page: page.value,
sort: sortBy.value, sort: sortBy.value,
order: sortDesc.value ? 'desc' : 'asc', order: sortDesc.value ? 'desc' : 'asc',
type: filterType.value !== 'ALL' ? filterType.value : undefined type: filterType.value !== 'ALL' ? filterType.value : undefined,
search: searchQuery.value ? searchQuery.value : ''
} }
}); });
@@ -368,29 +453,60 @@ const loadPosts = async () => {
} }
} catch (err) { } catch (err) {
console.error("Failed to fetch posts:", err); console.error("Failed to fetch posts:", err);
notifications.error("Failed to load publications");
} finally { } finally {
loading.value = false; loading.value = false;
} }
}; };
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.", notifications.error("Failed to fetch details");
submitter: { id: 2, name: "Joana B" }, } finally {
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;
}
notifications.success("Voted with success");
} catch (error) {
console.error("Vote failed", error);
notifications.error("Failed to submit vote.");
}
}; };
const filteredPosts = computed(() => { const filteredPosts = computed(() => {
@@ -404,12 +520,12 @@ const filteredPosts = computed(() => {
return result.sort((a, b) => { return result.sort((a, b) => {
const modifier = sortDesc.value ? 1 : -1; const modifier = sortDesc.value ? 1 : -1;
if (sortBy.value === 'votes') { if (sortBy.value === 'rating') {
return (b.rating - a.rating) * modifier; return (b.rating - a.rating) * modifier;
} else if (sortBy.value === 'comments') { } else if (sortBy.value === 'comment') {
return (b.comment_count - a.comment_count) * modifier; return (b.commentCount - a.commentCount) * modifier;
} else { } else {
return (new Date(b.publication_date).getTime() - new Date(a.publication_date).getTime()) * modifier; return (new Date(b.publicationDate).getTime() - new Date(a.publicationDate).getTime()) * modifier;
} }
}); });
}); });
@@ -421,11 +537,19 @@ watch(posts, () => {
}, { deep: true }); }, { deep: true });
watch([sortBy, sortDesc, filterType], () => { watch([sortBy, sortDesc, filterType], () => {
resetAndLoad();
});
watch(searchQuery, () => {
resetAndLoad();
});
function resetAndLoad() {
posts.value = []; posts.value = [];
page.value = 1; page.value = 1;
hasMore.value = true; hasMore.value = true;
loadPosts(); loadPosts();
}); }
onMounted(async () => { onMounted(async () => {
checkQueryForModal(); checkQueryForModal();
+370 -150
View File
@@ -2,6 +2,27 @@
<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">
@@ -55,14 +76,6 @@
</svg> </svg>
<span>Polytechnic of Leiria</span> <span>Polytechnic of Leiria</span>
</div> </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> </div>
@@ -82,7 +95,7 @@
}}</div> }}</div>
<div <div
class="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-1"> class="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-1">
Papers Publications
</div> </div>
</div> </div>
</div> </div>
@@ -119,28 +132,18 @@
leave-from-class="transform opacity-100 translate-y-0" leave-from-class="transform opacity-100 translate-y-0"
leave-to-class="transform opacity-0 translate-y-2"> leave-to-class="transform opacity-0 translate-y-2">
<div v-if="activeTab === 'overview'" key="overview" class="space-y-6"> <div v-if="activeTab === 'overview'" key="overview" class="space-y-6">
<div
class="bg-white dark:bg-slate-900 p-6 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 transition-colors">
<h2
class="text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3">
Biography</h2>
<p class="text-slate-700 dark:text-slate-300 leading-relaxed text-sm">
{{ userProfile.biography }}
</p>
</div>
<div class="flex items-center justify-between pt-2 px-1"> <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"> <h2 class="text-lg font-bold text-slate-900 dark:text-white flex items-center gap-2">
Recent Publications Recent Publications
<span <span
class="bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-xs font-bold px-2 py-0.5 rounded-full"> 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 }} {{ userProfile.publications.length }} Publications
</span> </span>
</h2> </h2>
</div> </div>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div v-if="userProfile.publications.length === 0" <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"> 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 <div
@@ -159,11 +162,6 @@
<h3 class="text-lg font-bold text-slate-900 dark:text-white mb-1"> <h3 class="text-lg font-bold text-slate-900 dark:text-white mb-1">
No publications yet No publications yet
</h3> </h3>
<p
class="text-slate-500 dark:text-slate-400 text-sm max-w-xs mx-auto mb-6 leading-relaxed">
Your profile is looking a little empty. Share your research with the
community to start building your portfolio.
</p>
<button <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" 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"
@@ -172,30 +170,77 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4v16m8-8H4" /> d="M12 4v16m8-8H4" />
</svg> </svg>
Submit your first paper Submit your first publication
</button> </button>
</div> </div>
</div> </div>
<div v-for="pub in userProfile.publications" :key="pub.id" <div v-for="post in userProfile.publications" :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="['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']"
<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
}}</h3> <div class="flex gap-4">
<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">{{ 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> </div>
@@ -211,114 +256,154 @@
<div <div
class="bg-white dark:bg-slate-900 rounded-xl shadow-sm border border-slate-200 dark:border-slate-800 overflow-hidden"> class="bg-white dark:bg-slate-900 rounded-xl shadow-sm border border-slate-200 dark:border-slate-800 overflow-hidden">
<div v-for="(item, index) in activityLog" :key="item.id" <div v-for="item in activityLog" :key="item.id"
class="p-4 flex gap-4 border-b border-slate-100 dark:border-slate-800 last:border-0 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors"> 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-center min-w-[80px] text-center pt-1"> <div
<span class="text-xs font-bold text-slate-500 uppercase"> 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', { {{ new Date(item.timestamp).toLocaleDateString('en-US', {
month: 'short', month: 'short',
day: 'numeric' day: 'numeric', year: 'numeric' }) }}
}) }}
</span> </span>
<span class="text-[10px] text-slate-400"> <span class="text-[10px] text-slate-400 tabular-nums mt-0.5">
{{ new Date(item.timestamp).toLocaleTimeString([], { {{ new Date(item.timestamp).toLocaleTimeString([], {
hour: '2-digit', hour: '2-digit',
minute: '2-digit' minute: '2-digit' }) }}
}) }}
</span> </span>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<div class="flex items-center gap-2 mb-1"> <div class="flex items-center gap-3 mb-1.5">
<span <span
class="px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide" class="px-2 py-0.5 rounded-full text-[10px] font-bold tracking-tight border"
:class="{ :class="{
'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400': item.action.includes('UPLOAD'), '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-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400': item.action.includes('COMMENT'), '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-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400': item.action.includes('RATED') || item.action.includes('CORRECT'), '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-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400': item.action.includes('TAG') '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.action.replace('_', ' ') }} {{ item.actionType.replace('_', ' ') }}
</span>
<span class="text-[11px] text-slate-400 font-medium">
{{ item.targetType }} #{{ item.targetId }}
</span> </span>
<span class="text-xs text-slate-400 font-medium">{{ item.target_type }} #{{
item.target_id }}</span>
</div> </div>
<p class="text-sm text-slate-700 dark:text-slate-300">
{{ item.details }} <p class="text-sm text-slate-600 dark:text-slate-300 leading-relaxed">
{{ item.description }}
</p> </p>
</div> </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>
</div> </div>
<div v-else-if="activeTab === 'settings'" key="settings" <div v-else-if="activeTab === 'settings'" key="settings"
class="bg-white dark:bg-slate-900 p-6 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 transition-colors"> class="bg-white dark:bg-slate-900 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 transition-colors">
<h2 <div class="px-6 py-5 border-b border-slate-200 dark:border-slate-800">
class="text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3"> <h2 class="text-lg font-semibold text-slate-900 dark:text-white">
Settings</h2> Edit Profile
<p class="text-sm text-slate-600 dark:text-slate-400"> </h2>
Nothing here but chickens! Settings coming soon. <p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
Update your personal information and account details.
</p> </p>
</div> </div>
</Transition>
<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> </div>
</template> </template>
<script setup lang="ts"> <script setup>
import { ref, computed, onMounted } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useAuthStore } from "~/stores/auth-store.js"; import { useAuthStore } from "~/stores/auth-store.js";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import CreatePostModal from '~/components/ui/modal/CreatePostModal.vue';
// --- Config & Store ---
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const api = config.public.apiBase; const api = config.public.apiBase;
const authStore = useAuthStore(); const authStore = useAuthStore();
const { token } = storeToRefs(authStore); const { token } = storeToRefs(authStore);
const router = useRouter(); const router = useRouter();
const notifications = useNotificationStore();
// --- Interfaces --- const isModalOpen = ref(false);
const postToEdit = ref(null);
interface Tag { const selectedPost = ref(null);
id: number;
name: string;
}
// Matches response from EP12 (/api/v1/publications/my-submissions) [cite: 296] const newUsername = ref('');
interface ApiPublication { const newEmail = ref('');
id: number;
title: string;
authors: string[];
publication_date: string;
type: string;
rating: number; // Mapping this to citations for the UI
comment_count: number;
attached_file?: string;
tags: Tag[];
abstract?: string;
}
// Matches your UI requirements
interface UIPublication {
id: number;
title: string;
abstract: string;
publishedDate: string;
publishedYear: string;
citations: number;
field: string;
}
// --- State ---
const loading = ref(true); const loading = ref(true);
const activeTab = ref('overview'); // State to control visible tab const activeTab = ref('overview');
const activityLog = ref<ActivityItem[]>([]); // Store history data const activityLog = ref([]);
const userProfile = ref({ const userProfile = ref({
id: 0, id: 0,
@@ -327,48 +412,82 @@ const userProfile = ref({
role: '', role: '',
is_active: false, is_active: false,
avatarUrl: '', avatarUrl: '',
biography: 'No biography provided.', publications: []
joinDate: new Date().toISOString(),
publications: [] as any[]
}); });
// --- API Actions ---
// Fetch Tags (EP23) - To populate the select box const toggleHide = async (post) => {
async function fetchTags() {
if (!token.value) return;
try { try {
const data = await $fetch<Tag[]>(`${api}/tags`, { const newState = !post.hidden;
method: 'GET',
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}` } headers: { Authorization: `Bearer ${token.value}` }
}); });
availableTags.value = data || []; selectedPost.value = postDetail;
} catch (e) { document.body.style.overflow = 'hidden';
console.error("Failed to fetch tags", e); } catch (err) {
notifications.error("Failed to load publication details");
console.error(err);
} }
} };
// --- 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)
); );
const formattedDate = computed(() => {
return new Date(userProfile.value.joinDate).toLocaleDateString('en-US', {
month: 'long',
year: 'numeric'
});
});
// --- API Actions ---
// 1. Fetch Activity Log (EP06)
async function fetchActivityHistory() { async function fetchActivityHistory() {
if (!token.value) return; if (!token.value) return;
try { try {
// GET /api/v1/me/history const data = await $fetch(`${api}/users/me/history`, {
const data = await $fetch<ActivityItem[]>(`${api}/me/history`, {
method: 'GET', method: 'GET',
headers: { Authorization: `Bearer ${token.value}` } headers: { Authorization: `Bearer ${token.value}` }
}); });
@@ -378,25 +497,20 @@ async function fetchActivityHistory() {
} }
} }
// 2. Fetch Main Profile Data (EP03 & EP12)
async function fetchProfileData() { async function fetchProfileData() {
if (!token.value) return; if (!token.value) return;
loading.value = true; loading.value = true;
try { try {
// Fetch User Details
const userData = await $fetch(`${api}/users/me`, { const userData = await $fetch(`${api}/users/me`, {
method: 'GET', method: 'GET',
headers: { Authorization: `Bearer ${token.value}` } headers: { Authorization: `Bearer ${token.value}` }
}); });
const publicationsData = await $fetch(`${api}/publications/me`, {
method: 'GET',
headers: { Authorization: `Bearer ${token.value}` },
});
// Fetch Publications
// const publicationsData = await $fetch(`${api}/publications/my-submissions`, {
// method: 'GET',
// headers: { Authorization: `Bearer ${token.value}` }
// });
// Map Data
userProfile.value = { userProfile.value = {
...userProfile.value, ...userProfile.value,
id: userData.id, id: userData.id,
@@ -407,16 +521,23 @@ async function fetchProfileData() {
avatarUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(userData.name)}&background=0D8ABC&color=fff` avatarUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(userData.name)}&background=0D8ABC&color=fff`
}; };
userProfile.value.publications = (publicationsData as any[]).map(pub => { if (authStore.user) {
const pubDate = new Date(pub.publication_date); authStore.user.name = userData.name;
authStore.user.email = userData.email;
}
userProfile.value.publications = (publicationsData).map(pub => {
const pubDate = new Date(pub.publicationDate);
return { return {
id: pub.id, ...pub,
title: pub.title,
abstract: pub.abstract || 'No abstract available.',
publishedDate: pubDate.toLocaleDateString(), publishedDate: pubDate.toLocaleDateString(),
publishedYear: pubDate.getFullYear().toString(), publishedYear: pubDate.getFullYear().toString(),
citations: pub.rating || 0, citations: pub.rating || 0,
field: pub.tags && pub.tags.length > 0 ? pub.tags[0].name : 'General' authors: pub.authors || [],
tags: pub.tags || [],
comment_count: pub.comment_count || 0,
rating: pub.rating || 0
}; };
}); });
@@ -427,18 +548,117 @@ async function fetchProfileData() {
} }
} }
// --- Redirects --- 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 = () => { const goToCreatePost = () => {
console.log("Navigating to create post modal...");
router.push({ router.push({
path: '/', path: '/',
query: { openCreatePostModal: 'true' } query: { openCreatePostModal: 'true' }
}); });
}; };
// --- Lifecycle --- 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(() => { onMounted(() => {
fetchProfileData(); fetchProfileData();
fetchActivityHistory(); // Fetch history on mount 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>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB