Author SHA1 Message Date
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
7 changed files with 1513 additions and 704 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>
+324 -300
View File
@@ -1,301 +1,325 @@
<script setup lang="ts">
import { ref, reactive, watch, computed } from 'vue';
const props = defineProps<{
isOpen: boolean;
api: string;
token: string | null;
}>();
const emit = defineEmits(['close', 'refresh']);
interface Tag { id: number; name: string; }
const isSubmitting = ref(false);
const availableTags = ref<Tag[]>([]);
const errors = reactive({
title: '',
abstract: ''
});
const uploadForm = reactive({
title: '',
abstract: '',
type: 'CONFERENCE_PAPER',
newAuthorName: '',
authors: [] as string[],
selectedTagIds: [] as number[],
file: null as File | null
});
// --- Validation Logic ---
const validate = () => {
let isValid = true;
errors.title = '';
errors.abstract = '';
if (!uploadForm.title.trim()) {
errors.title = 'Title is required';
isValid = false;
} else if (uploadForm.title.length > 50) {
errors.title = 'Title cannot exceed 50 characters';
isValid = false;
}
if (!uploadForm.abstract.trim()) {
errors.abstract = 'Abstract is required';
isValid = false;
} else if (uploadForm.abstract.length > 2000) {
errors.abstract = 'Abstract cannot exceed 2000 characters';
isValid = false;
}
return isValid;
};
async function fetchTags() {
if (!props.token) return;
try {
const data = await $fetch<Tag[]>(`${props.api}/tags`, {
headers: { Authorization: `Bearer ${props.token}` }
});
availableTags.value = data || [];
} catch (e) { console.error("Tags fetch failed", e); }
}
async function submitPublication() {
if (!validate()) return;
isSubmitting.value = true;
try {
const metadata = {
title: uploadForm.title,
authors: uploadForm.authors,
type: uploadForm.type,
abstract: uploadForm.abstract,
tags: uploadForm.selectedTagIds,
// Sending fixed defaults for removed fields to maintain API compatibility if needed
publication_date: new Date().toISOString().split('T')[0],
is_confidential: false
};
const formData = new FormData();
if (uploadForm.file) formData.append('file', uploadForm.file);
formData.append('data', JSON.stringify(metadata));
await $fetch(`${props.api}/publications`, {
method: 'POST',
headers: { Authorization: `Bearer ${props.token}` },
body: formData
});
emit('refresh');
closeModal();
} catch (error) {
alert("Upload failed.");
} finally {
isSubmitting.value = false;
}
}
function addAuthor() {
if (uploadForm.newAuthorName.trim()) {
uploadForm.authors.push(uploadForm.newAuthorName.trim());
uploadForm.newAuthorName = '';
}
}
function removeAuthor(index: number) { uploadForm.authors.splice(index, 1); }
function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files?.[0]) uploadForm.file = input.files[0];
}
function closeModal() {
emit('close');
}
watch(() => props.isOpen, (newVal) => {
if (newVal && availableTags.value.length === 0) fetchTags();
if (!newVal) {
// Reset errors on close
errors.title = '';
errors.abstract = '';
}
});
// Helper for colorful tags
const getTagClass = (id: number) => {
const colors = [
'bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-900/30 dark:text-pink-300',
'bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-900/30 dark:text-purple-300',
'bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-300',
'bg-cyan-100 text-cyan-700 border-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-300',
'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-300',
'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-300',
];
return colors[id % colors.length];
};
</script>
<template>
<Transition enter-active-class="transition duration-200 ease-out" enter-from-class="opacity-0"
enter-to-class="opacity-100" leave-active-class="transition duration-150 ease-in" leave-from-class="opacity-100"
leave-to-class="opacity-0">
<div v-if="isOpen" class="fixed inset-0 z-50 overflow-y-auto">
<div class="fixed inset-0 bg-slate-900/75 backdrop-blur-sm" @click="closeModal" />
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div
class="relative transform overflow-hidden rounded-2xl bg-white dark:bg-slate-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl border border-slate-200 dark:border-slate-800">
<div
class="bg-slate-50 dark:bg-slate-800/50 px-6 py-4 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
<h3 class="text-lg font-bold text-slate-900 dark:text-white">Submit New Publication</h3>
<button class="text-slate-400 hover:text-slate-500 dark:hover:text-slate-300"
@click="closeModal">
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="px-6 py-6 max-h-[80vh] overflow-y-auto space-y-6">
<div
class="flex justify-center rounded-lg border-2 border-dashed border-slate-300 dark:border-slate-700 px-6 py-8 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
<div class="text-center">
<svg class="mx-auto h-12 w-12 text-slate-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<div
class="mt-4 flex text-sm leading-6 text-slate-600 dark:text-slate-400 justify-center">
<label for="file-upload"
class="relative cursor-pointer rounded-md font-semibold text-blue-600 hover:text-blue-500">
<span>Upload a file</span>
<input id="file-upload" type="file" class="sr-only" accept=".pdf,.zip"
@change="handleFileChange">
</label>
<p class="pl-1">or drag and drop</p>
</div>
<p v-if="uploadForm.file"
class="mt-2 text-sm font-bold text-green-600 dark:text-green-400 text-center">
Selected: {{ uploadForm.file.name }}
</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="md:col-span-2">
<label
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Title</label>
<input v-model="uploadForm.title" type="text"
:class="[errors.title ? 'border-red-500' : 'border-slate-300 dark:border-slate-700']"
class="w-full rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm focus:ring-blue-500 sm:text-sm py-2.5 px-3 border"
placeholder="e.g. A New Technique for Data Science">
<p v-if="errors.title" class="mt-1 text-xs text-red-500">{{ errors.title }}</p>
</div>
<div>
<label
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Type</label>
<select v-model="uploadForm.type"
class="w-full rounded-lg border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm focus:ring-blue-500 sm:text-sm py-2.5 px-3 border">
<option value="CONFERENCE_PAPER">Conference Paper</option>
<option value="ARTICLE">Article</option>
<option value="THESIS">Thesis</option>
<option value="REPORT">Report</option>
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Abstract ({{ uploadForm.abstract.length }}/2000)
</label>
<textarea v-model="uploadForm.abstract" rows="4"
:class="[errors.abstract ? 'border-red-500' : 'border-slate-300 dark:border-slate-700']"
class="w-full rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm focus:ring-blue-500 sm:text-sm py-2 px-3 border"
placeholder="Enter a brief summary..." />
<p v-if="errors.abstract" class="mt-1 text-xs text-red-500">{{ errors.abstract }}</p>
</div>
<div class="flex flex-col md:flex-row gap-8">
<div class="flex-1">
<label
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Authors</label>
<div class="relative flex items-center">
<input v-model="uploadForm.newAuthorName" type="text"
class="w-full rounded-lg border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm text-sm py-2 pl-3 pr-10 border"
placeholder="Name" @keyup.enter="addAuthor">
<button type="button"
class="absolute right-2 p-1 text-blue-600 hover:text-blue-700 transition-colors"
@click="addAuthor">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"
stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 4.5v15m7.5-7.5h-15" />
</svg>
</button>
</div>
<div class="flex flex-wrap gap-2 mt-3">
<span v-for="(author, index) in uploadForm.authors" :key="index"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-xs font-medium border border-slate-200 dark:border-slate-700">
{{ author }}
<button @click="removeAuthor(index)">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
</div>
</div>
<div class="w-full md:w-auto min-w-[200px]">
<label
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Tags</label>
<div
class="overflow-y-auto border border-slate-200 dark:border-slate-700 rounded-lg p-3 bg-slate-50/50 dark:bg-slate-800/30 flex flex-wrap gap-2 content-start">
<label v-if="availableTags.length == 0"
class="relative rounded-md font-semibold text-blue-600">
<span>No tags</span>
</label>
<button v-for="tag in availableTags" :key="tag.id" type="button" @click="() => {
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)
? getTagClass(tag.id) + ' border-transparent'
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400'
]"
class="px-3 py-1.5 rounded-full border text-xs font-bold uppercase tracking-tight transition-all shadow-sm whitespace-nowrap">
{{ tag.name }}
</button>
</div>
</div>
</div>
</div>
<div
class="bg-slate-50 dark:bg-slate-800/50 px-6 py-4 flex flex-row-reverse gap-3 border-t border-slate-100 dark:border-slate-800">
<button :disabled="isSubmitting"
class="inline-flex w-full justify-center rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-bold text-white shadow-sm hover:bg-blue-500 disabled:opacity-50 sm:w-auto transition-all"
@click="submitPublication">
{{ isSubmitting ? 'Uploading...' : 'Submit Publication' }}
</button>
<button
class="inline-flex w-full justify-center rounded-lg bg-white dark:bg-slate-800 px-6 py-2.5 text-sm font-bold text-slate-700 dark:text-slate-300 shadow-sm ring-1 ring-inset ring-slate-300 dark:ring-slate-700 hover:bg-slate-50 sm:w-auto transition-all"
@click="closeModal">
Cancel
</button>
</div>
</div>
</div>
</div>
</Transition>
<script setup>
import { ref, reactive, watch } from 'vue';
const props = defineProps({
isOpen: Boolean,
api: String,
token: String,
postToEdit: Object
});
const emit = defineEmits(['close', 'refresh']);
const notifications = useNotificationStore();
const isSubmitting = ref(false);
const availableTags = ref([]);
const errors = reactive({
title: '',
abstract: ''
});
const uploadForm = reactive({
title: '',
abstract: '',
type: 'CONFERENCE_PAPER',
newAuthorName: '',
authors: [],
selectedTagIds: [],
file: null
});
const validate = () => {
let isValid = true;
errors.title = '';
errors.abstract = '';
if (!uploadForm.title.trim()) {
errors.title = 'Title is required';
isValid = false;
} else if (uploadForm.title.length > 50) {
errors.title = 'Title cannot exceed 50 characters';
isValid = false;
}
if (!uploadForm.abstract.trim()) {
errors.abstract = 'Abstract is required';
isValid = false;
} else if (uploadForm.abstract.length > 2000) {
errors.abstract = 'Abstract cannot exceed 2000 characters';
isValid = false;
}
return isValid;
};
async function fetchTags() {
if (!props.token) return;
try {
const data = await $fetch(`${props.api}/tags`, {
headers: { Authorization: `Bearer ${props.token}` }
});
availableTags.value = data || [];
} catch (e) { console.error("Tags fetch failed", e); }
}
async function submitPublication() {
if (!validate()) return;
isSubmitting.value = true;
try {
const metadata = {
title: uploadForm.title,
authors: uploadForm.authors,
type: uploadForm.type,
abstract: uploadForm.abstract,
tags: uploadForm.selectedTagIds
};
const isEditing = !!props.postToEdit;
const url = isEditing
? `${props.api}/publications/${props.postToEdit.id}`
: `${props.api}/publications`;
const headers = {
Authorization: `Bearer ${props.token}`
};
const formData = new FormData();
if (uploadForm.file) formData.append('file', uploadForm.file);
formData.append('data', JSON.stringify(metadata));
await $fetch(url, {
method: isEditing ? 'PATCH' : 'POST',
headers,
body: formData
});
notifications.success(`Publication ${isEditing ? "updated" : "created"}`);
emit('refresh');
closeModal();
} catch (error) {
console.error("Submission error:", error);
notifications.error("Operation failed");
} finally {
isSubmitting.value = false;
}
}
function addAuthor() {
if (uploadForm.newAuthorName.trim()) {
uploadForm.authors.push(uploadForm.newAuthorName.trim());
uploadForm.newAuthorName = '';
}
}
function removeAuthor(index) { uploadForm.authors.splice(index, 1); }
function handleFileChange(event) {
const input = event.target;
if (input.files?.[0]) uploadForm.file = input.files[0];
}
function closeModal() {
uploadForm.title = '';
uploadForm.abstract = '';
uploadForm.type = 'CONFERENCE_PAPER';
uploadForm.newAuthorName = '';
uploadForm.authors = [];
uploadForm.selectedTagIds = [];
uploadForm.file = null;
emit('close');
}
watch(() => props.isOpen, (newVal) => {
if (newVal && availableTags.value.length === 0) fetchTags();
if (!newVal) {
errors.title = '';
errors.abstract = '';
}
});
watch(() => props.postToEdit, (newPost) => {
if (newPost) {
uploadForm.title = newPost.title;
uploadForm.abstract = newPost.abstractText || newPost.abstract;
uploadForm.type = newPost.type;
uploadForm.authors = [...newPost?.authors];
uploadForm.selectedTagIds = newPost?.tags.map((t) => t.id);
}
}, { immediate: true });
const getTagClass = (id) => {
const colors = [
'bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-900/30 dark:text-pink-300',
'bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-900/30 dark:text-purple-300',
'bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-300',
'bg-cyan-100 text-cyan-700 border-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-300',
'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-300',
'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-300',
];
return colors[id % colors.length];
};
</script>
<template>
<Transition enter-active-class="transition duration-200 ease-out" enter-from-class="opacity-0"
enter-to-class="opacity-100" leave-active-class="transition duration-150 ease-in" leave-from-class="opacity-100"
leave-to-class="opacity-0">
<div v-if="props.isOpen" class="fixed inset-0 z-50 overflow-y-auto">
<div class="fixed inset-0 bg-slate-900/75 backdrop-blur-sm" @click="closeModal" />
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div
class="relative transform overflow-hidden rounded-2xl bg-white dark:bg-slate-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl border border-slate-200 dark:border-slate-800">
<div
class="bg-slate-50 dark:bg-slate-800/50 px-6 py-4 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
<h3 class="text-lg font-bold text-slate-900 dark:text-white">Submit New Publication</h3>
<button class="text-slate-400 hover:text-slate-500 dark:hover:text-slate-300"
@click="closeModal">
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="px-6 py-6 max-h-[80vh] overflow-y-auto space-y-6">
<div
class="flex justify-center rounded-lg border-2 border-dashed border-slate-300 dark:border-slate-700 px-6 py-8 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
<div class="text-center">
<svg class="mx-auto h-12 w-12 text-slate-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<div
class="mt-4 flex text-sm leading-6 text-slate-600 dark:text-slate-400 justify-center">
<label for="file-upload"
class="relative cursor-pointer rounded-md font-semibold text-blue-600 hover:text-blue-500">
<span>Upload a file</span>
<input id="file-upload" type="file" class="sr-only" accept=".pdf,.zip"
@change="handleFileChange">
</label>
<p class="pl-1">or drag and drop</p>
</div>
<p v-if="uploadForm.file"
class="mt-2 text-sm font-bold text-green-600 dark:text-green-400 text-center">
Selected: {{ uploadForm.file.name }}
</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="md:col-span-2">
<label
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Title</label>
<input v-model="uploadForm.title" type="text"
:class="[errors.title ? 'border-red-500' : 'border-slate-300 dark:border-slate-700']"
class="w-full rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm focus:ring-blue-500 sm:text-sm py-2.5 px-3 border"
placeholder="e.g. A New Technique for Data Science">
<p v-if="errors.title" class="mt-1 text-xs text-red-500">{{ errors.title }}</p>
</div>
<div>
<label
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Type</label>
<select v-model="uploadForm.type"
class="w-full rounded-lg border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm focus:ring-blue-500 sm:text-sm py-2.5 px-3 border">
<option value="CONFERENCE_PAPER">Conference Paper</option>
<option value="RESEARCH_POST">Research Post</option>
<option value="THESIS">Thesis</option>
<option value="ARTICLE">Article</option>
<option value="REPORT">Report</option>
<option value="OTHER">Other</option>
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Abstract ({{ uploadForm.abstract.length }}/255)
</label>
<textarea v-model="uploadForm.abstract" rows="4"
:class="[errors.abstract ? 'border-red-500' : 'border-slate-300 dark:border-slate-700']"
class="w-full rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm focus:ring-blue-500 sm:text-sm py-2 px-3 border"
placeholder="Enter a brief summary..." />
<p v-if="errors.abstract" class="mt-1 text-xs text-red-500">{{ errors.abstract }}</p>
</div>
<div class="flex flex-col md:flex-row gap-8">
<div class="w-full">
<label
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Authors</label>
<div class="relative flex items-center">
<input v-model="uploadForm.newAuthorName" type="text"
class="w-full rounded-lg border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm text-sm py-2 pl-3 pr-10 border"
placeholder="Name" @keyup.enter="addAuthor">
<button type="button"
class="absolute right-2 p-1 text-blue-600 hover:text-blue-700 transition-colors"
@click="addAuthor">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"
stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 4.5v15m7.5-7.5h-15" />
</svg>
</button>
</div>
<div :class="['flex flex-wrap gap-2', uploadForm.authors.length != 0 ? 'mt-3' : '']">
<span v-for="(author, index) in uploadForm.authors" :key="index"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-xs font-medium border border-slate-200 dark:border-slate-700">
{{ author }}
<button @click="removeAuthor(index)">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
</div>
</div>
<div class="flex-1 md:w-auto min-w-[300px]">
<label
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Tags</label>
<div
class="overflow-y-auto border border-slate-200 dark:border-slate-700 rounded-lg p-3 bg-slate-50/50 dark:bg-slate-800/30 flex flex-wrap gap-2 content-start">
<label v-if="availableTags.length == 0"
class="relative rounded-md font-semibold text-blue-600">
<span>No tags</span>
</label>
<button v-for="tag in availableTags" :key="tag.id" type="button" :class="[
uploadForm.selectedTagIds.includes(tag.id)
? getTagClass(tag.id) + ' border-transparent'
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400'
]" class="px-3 py-1.5 rounded-full border text-xs font-bold uppercase tracking-tight transition-all shadow-sm whitespace-nowrap"
@click="() => {
const idx = uploadForm.selectedTagIds.indexOf(tag.id);
if (idx > -1) uploadForm.selectedTagIds.splice(idx, 1);
else uploadForm.selectedTagIds.push(tag.id);
}">
{{ tag.name }}
</button>
</div>
</div>
</div>
</div>
<div
class="bg-slate-50 dark:bg-slate-800/50 px-6 py-4 flex flex-row-reverse gap-3 border-t border-slate-100 dark:border-slate-800">
<button :disabled="isSubmitting"
class="inline-flex w-full justify-center rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-bold text-white shadow-sm hover:bg-blue-500 disabled:opacity-50 sm:w-auto transition-all"
@click="submitPublication">
{{ isSubmitting ? 'Uploading...' : 'Submit' }}
</button>
<button
class="inline-flex w-full justify-center rounded-lg bg-white dark:bg-slate-800 px-6 py-2.5 text-sm font-bold text-slate-700 dark:text-slate-300 shadow-sm ring-1 ring-inset ring-slate-300 dark:ring-slate-700 hover:bg-slate-50 sm:w-auto transition-all"
@click="closeModal">
Cancel
</button>
</div>
</div>
</div>
</div>
</Transition>
</template>
+63 -117
View File
@@ -202,11 +202,9 @@
<span
class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-bold border capitalize"
:class="{
'bg-purple-100 border-purple-200 text-purple-700 dark:bg-purple-900/20 dark:border-purple-800 dark:text-purple-300': user.role === 'ADMIN',
'bg-blue-100 border-blue-200 text-blue-700 dark:bg-blue-900/20 dark:border-blue-800 dark:text-blue-300': user.role === 'TEACHER',
'bg-emerald-100 border-emerald-200 text-emerald-700 dark:bg-emerald-900/20 dark:border-emerald-800 dark:text-emerald-300': user.role === 'RESEARCHER',
'bg-amber-100 border-amber-200 text-amber-700 dark:bg-amber-900/20 dark:border-amber-800 dark:text-amber-300': user.role === 'COLLABORATOR',
'bg-slate-100 border-slate-200 text-slate-700 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300': user.role === 'STUDENT'
'bg-purple-100 border-purple-200 text-purple-700 dark:bg-purple-900/20 dark:border-purple-800 dark:text-purple-300': user.role === 'ADMINISTRATOR',
'bg-emerald-100 border-emerald-200 text-emerald-700 dark:bg-emerald-900/20 dark:border-emerald-800 dark:text-emerald-300': user.role === 'MODERATOR',
'bg-amber-100 border-amber-200 text-amber-700 dark:bg-amber-900/20 dark:border-amber-800 dark:text-amber-300': user.role === 'COLLABORATOR'
}">
{{ user.role.toLowerCase() }}
</span>
@@ -276,7 +274,7 @@
<div
class="w-1.5 h-8 bg-gradient-to-b from-blue-600 to-indigo-600 rounded-full" />
<h3 class="text-xl font-bold text-slate-900 dark:text-white">{{ isEditing ?
'Edit User Details' : 'Create New User' }}</h3>
'Edit User Details' : 'Create' }}</h3>
</div>
<button class="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
@click="closeModal"><svg class="w-5 h-5" fill="none" viewBox="0 0 24 24"
@@ -313,10 +311,8 @@
<div class="relative">
<select v-model="formData.role"
class="w-full appearance-none px-4 py-3 rounded-xl border-2 border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-all cursor-pointer">
<option value="STUDENT">Student</option>
<option value="TEACHER">Teacher</option>
<option value="COLLABORATOR">Collaborator</option>
<option value="RESEARCHER">Researcher</option>
<option value="MODERATOR">Moderator</option>
<option value="ADMIN">Admin</option>
</select>
<svg class="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400 pointer-events-none"
@@ -372,7 +368,7 @@
<button
class="px-8 py-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-xl shadow-lg transition-all"
@click="handleSubmit">{{
isEditing ? 'Save Changes' : 'Create Account' }}</button>
isEditing ? 'Save Changes' : 'Create' }}</button>
<button
class="px-8 py-3 bg-white dark:bg-slate-800 border-2 border-slate-200 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 font-semibold rounded-xl transition-all"
@click="closeModal">Cancel</button>
@@ -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">
<div class="flex gap-4">
<div class="flex flex-col items-center min-w-[90px] text-center">
<span class="text-xs font-bold text-slate-500 uppercase mb-1">{{ new
Date(item.timestamp).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
}) }}</span>
<span class="text-xs text-slate-400">{{ new
Date(item.timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
}) }}</span>
<span class="text-xs font-bold text-slate-500 uppercase mb-1">
{{ new Date(item.timestamp).toLocaleDateString('en-US', {
month:
'short', day: 'numeric' }) }}
</span>
<span class="text-xs text-slate-400">
{{ new Date(item.timestamp).toLocaleTimeString([], {
hour:
'2-digit', minute: '2-digit' }) }}
</span>
</div>
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<span
class="px-2.5 py-1 rounded-md text-xs font-bold uppercase tracking-wide bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300">{{
item.action.replace('_', ' ') }}</span>
<span class="text-xs text-slate-400 font-medium">{{ item.target_type
}} #{{ item.target_id }}</span>
class="px-2.5 py-1 rounded-md text-xs font-bold uppercase tracking-wide bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300">
{{ item.actionType.replace('_', ' ') }}
</span>
<span class="text-xs text-slate-400 font-medium">
{{ item.targetType }} #{{ item.targetId }}
</span>
</div>
<p class="text-sm text-slate-700 dark:text-slate-300 leading-relaxed">{{
item.details }}</p>
<p class="text-sm text-slate-700 dark:text-slate-300 leading-relaxed">
{{ item.description }}
</p>
</div>
</div>
</div>
@@ -488,7 +487,7 @@
</div>
</template>
<script setup lang="ts">
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { useAuthStore } from "~/stores/auth-store.js";
import { storeToRefs } from "pinia";
@@ -497,95 +496,26 @@ const config = useRuntimeConfig();
const api = config.public.apiBase;
const authStore = useAuthStore();
const { token } = storeToRefs(authStore);
const notifications = useNotificationStore();
// State
const users = ref<any[]>([
{ id: 1, name: 'Dr. Sarah Mitchell', email: '[email protected]', role: 'ADMIN', is_active: true },
{ id: 2, name: 'Prof. James Anderson', email: '[email protected]', role: 'TEACHER', is_active: true },
{ id: 3, name: 'Dr. Emily Chen', email: '[email protected]', role: 'RESEARCHER', is_active: true },
{ id: 4, name: 'Michael Roberts', email: '[email protected]', role: 'STUDENT', is_active: true },
{ id: 5, name: 'Dr. David Kim', email: '[email protected]', role: 'COLLABORATOR', is_active: true },
{ id: 6, name: 'Prof. Maria Garcia', email: '[email protected]', role: 'TEACHER', is_active: true },
{ id: 7, name: 'Jessica Thompson', email: '[email protected]', role: 'STUDENT', is_active: true },
{ id: 8, name: 'Dr. Robert Wilson', email: '[email protected]', role: 'RESEARCHER', is_active: true },
{ id: 9, name: 'Amanda Foster', email: '[email protected]', role: 'STUDENT', is_active: false },
{ id: 10, name: 'Prof. Thomas Lee', email: '[email protected]', role: 'ADMIN', is_active: true },
{ id: 11, name: 'Dr. Lisa Martinez', email: '[email protected]', role: 'RESEARCHER', is_active: true },
{ id: 12, name: 'Daniel Brown', email: '[email protected]', role: 'STUDENT', is_active: true },
{ id: 13, name: 'Prof. Jennifer Davis', email: '[email protected]', role: 'TEACHER', is_active: true },
{ id: 14, name: 'Christopher Taylor', email: '[email protected]', role: 'STUDENT', is_active: false },
{ id: 15, name: 'Dr. Patricia Johnson', email: '[email protected]', role: 'COLLABORATOR', is_active: true },
{ id: 16, name: 'Matthew White', email: '[email protected]', role: 'STUDENT', is_active: true },
{ id: 17, name: 'Prof. Elizabeth Moore', email: '[email protected]', role: 'TEACHER', is_active: true },
{ id: 18, name: 'Dr. William Harris', email: '[email protected]', role: 'RESEARCHER', is_active: true },
{ id: 19, name: 'Sophie Anderson', email: '[email protected]', role: 'STUDENT', is_active: true },
{ id: 20, name: 'Dr. Richard Clark', email: '[email protected]', role: 'ADMIN', is_active: true }
]);
const users = ref([]);
const loading = ref(false);
const showModal = ref(false);
const showDetailsModal = ref(false);
const isEditing = ref(false);
const searchQuery = ref('');
const selectedUser = ref<any>(null);
const userHistory = ref<any[]>([]);
const selectedUser = ref(null);
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({
id: 0,
name: '',
email: '',
password: '',
role: 'STUDENT',
role: 'COLLABORATOR',
is_active: true
});
// Computed Stats & Filtering
const filteredUsers = computed(() => {
if (!searchQuery.value) return users.value;
const query = searchQuery.value.toLowerCase();
@@ -596,13 +526,12 @@ const filteredUsers = computed(() => {
});
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 () => {
loading.value = true;
try {
const data = await $fetch<any[]>(`${api}/admin/users`, {
const data = await $fetch(`${api}/admin/users`, {
method: 'GET',
headers: { Authorization: `Bearer ${token.value}` }
});
@@ -626,10 +555,11 @@ const createUser = async () => {
role: formData.role
}
});
notifications.success("User created successfully");
closeModal();
fetchUsers();
} 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
}
});
notifications.success("User updated successfully");
closeModal();
fetchUsers();
} catch (error) {
alert("Failed to update user.");
notifications.error("Failed to update user");
}
};
@@ -656,36 +587,51 @@ const sendPasswordRecovery = async () => {
try {
await $fetch(`${api}/auth/recover-password`, {
method: 'POST',
// headers: { Authorization: ... } // EP02 is usually public, but check your API requirements
body: {
email: formData.email
}
});
alert(`Recovery sent to ${formData.email}`);
notifications.success(`Recovery sent to ${formData.email}`);
} catch (error) {
alert("Failed to send recovery.");
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;
try {
await $fetch(`${api}/admin/users/${user.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token.value}` }
});
notifications.success("Deleted user");
fetchUsers();
} catch (error) {
alert("Error deleting user.");
notifications.error("Failed to delete user");
}
};
// --- HELPERS ---
const openUserDetails = (user: any) => {
const openUserDetails = async (user) => {
selectedUser.value = user;
userHistory.value = generateMockHistory(user.id);
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 = () => {
@@ -700,7 +646,7 @@ const openCreateModal = () => {
showModal.value = true;
};
const openEditModal = (user: any) => {
const openEditModal = (user) => {
isEditing.value = true;
formData.id = user.id;
formData.name = user.name;
@@ -725,11 +671,11 @@ const resetForm = () => {
formData.name = '';
formData.email = '';
formData.password = '';
formData.role = 'STUDENT';
formData.role = 'COLLABORATOR';
formData.is_active = true;
};
const getInitials = (name: string) => {
const getInitials = (name) => {
if (!name) return '??';
return name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase();
};
-2
View File
@@ -91,7 +91,6 @@ async function handleLogin() {
body: loginFormData,
onResponse({ response }) {
if (response.status === 200) {
console.log(response)
token.value = response._data.token;
user.value = response._data.user;
if (response._data.user.mustChangePassword === true) {
@@ -102,7 +101,6 @@ async function handleLogin() {
}
},
onResponseError({ response }) {
console.log(response.status)
if (response.status === 401) {
globalError.value = "Invalid email or password.";
} else {
+249 -131
View File
@@ -4,17 +4,45 @@
<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">
<div class="sticky top-8">
<div class="sticky top-24">
<div class="flex flex-col gap-2">
<button v-for="tag in availableTags" :key="tag.id" :class="[
'px-3 py-2 rounded-lg text-xs font-bold transition-all text-left shadow-sm cursor-pointer',
subscribedTags.includes(tag.id)
? `${tag.color} border-current ring-1 ring-inset ring-black/5`
: 'bg-gray-50 dark:bg-gray-800 dark:border-gray-800 border opacity-60 hover:opacity-100'
]" @click="toggleTagSubscription(tag)">
<span class="opacity-70 mr-1">{{ subscribedTags.includes(tag.id) ? '✓' : '#' }}</span>
{{ tag.name }}
<button v-if="user?.role !== 'COLLABORATOR'"
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)
? `${tag.color} border-current ring-1 ring-inset ring-black/5`
: 'bg-gray-50 dark:bg-gray-800 dark:border-gray-800 border opacity-60 hover:opacity-100'
]" @click="toggleTagSubscription(tag)">
<span class="truncate">
<span class="opacity-70 mr-1">{{ subscribedTags.includes(tag.id) ? '✓' : '#' }}</span>
{{ tag.name }}
</span>
</button>
<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>
</aside>
@@ -28,8 +56,9 @@
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 gap-4">
<div class="flex flex-wrap items-center gap-2 w-full sm:w-auto">
<div class="relative flex-1 sm:flex-none">
<input v-model="searchQuery" type="text" placeholder="Search posts..."
class="w-full sm:w-48 bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-lg pl-3 pr-3 py-2 text-xs font-bold outline-none focus:ring-2 focus:ring-blue-500">
<input v-model.lazy="searchQuery" type="text" placeholder="Search publications..."
class="w-full sm:w-48 bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-lg pl-3 pr-3 py-2 text-xs font-bold outline-none focus:ring-2 focus:ring-blue-500"
@keyup.enter="$el.blur()">
</div>
<div class="flex items-center">
<select v-model="sortBy"
@@ -52,6 +81,10 @@
<option value="ALL">All Types</option>
<option value="CONFERENCE_PAPER">Conference Paper</option>
<option value="RESEARCH_POST">Research Post</option>
<option value="THESIS">Thesis</option>
<option value="ARTICLE">Article</option>
<option value="REPORT">Report</option>
<option value="OTHER">Other</option>
</select>
</div>
@@ -98,14 +131,20 @@
<TransitionGroup v-else appear enter-active-class="transition duration-500 ease-out"
enter-from-class="translate-y-4 opacity-0" enter-to-class="translate-y-0 opacity-100">
<div v-for="post in filteredPosts" :key="post.id"
class="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)">
<div class="flex gap-4">
<div
class="flex flex-col items-center gap-1 text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 p-2 rounded-lg h-fit">
<button class="hover:text-blue-500 transition-colors text-lg" @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>
<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 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">
{{ post.type.replace('_', ' ') }}
</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>
<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(', ') }}
<h2 class="text-lg font-bold text-gray-800 dark:text-white mb-2">{{ post.title }}
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 truncate max-w-[400px]">
{{ post.authors.length > 0 ? `By ${post.authors.join(', ')}` : 'No Authors' }}
</p>
<div class="flex items-center gap-4 text-xs font-bold text-gray-500 uppercase">
<span>💬 {{ post.comment_count }} Comments</span>
<div class="flex gap-2">
<span v-for="tag in post.tags" :key="tag.id" class="text-blue-600 dark:text-blue-400">#{{ tag.name
}}</span>
<div class="flex items-center gap-4 text-xs font-bold text-gray-500 uppercase min-w-0">
<span class="shrink-0">💬 {{ post.commentCount }} Comments</span>
<div class="max-w-[300px] truncate">
<span v-for="tag in post.tags" :key="tag.id" class="text-blue-600 dark:text-blue-400 mr-2">
#{{ tag.name }}
</span>
</div>
</div>
</div>
@@ -149,70 +217,13 @@
<div v-if="loading"
class="animate-spin h-6 w-6 border-2 border-blue-600 border-t-transparent rounded-full" />
</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>
<PostDetail v-if="selectedPost" :post="selectedPost" :api="api" :token="token" @back="selectedPost = null"
@vote="(dir) => votePost(selectedPost, dir)" @comment-added="() => selectedPost.commentCount++" />
<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>
<hr class="border-gray-100 dark:border-gray-800 mb-8">
<section class="mb-10">
<h3 class="text-xl font-bold mb-4">Comments ({{ selectedPost.comments.length }})</h3>
<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" />
<CreatePostModal :is-open="isModalOpen" :post-to-edit="postToEdit" :api="api" :token="token"
@close="closeModal()" @refresh="handleRefresh" />
</main>
</div>
</div>
@@ -225,13 +236,13 @@ import { storeToRefs } from "pinia";
import { useRoute } from 'vue-router';
import CreatePostModal from "~/components/ui/modal/CreatePostModal.vue";
const route = useRoute(); // 2. Access the route object
const config = useRuntimeConfig();
const authStore = useAuthStore();
const { user, token } = storeToRefs(authStore);
const route = useRoute();
const config = useRuntimeConfig();
const notifications = useNotificationStore();
const api = config.public.apiBase;
// --- State Management ---
const posts = ref([]);
const selectedPost = ref(null);
const loading = ref(false);
@@ -239,18 +250,20 @@ const loadMoreTrigger = ref(null);
const isModalOpen = ref(false);
const searchQuery = ref('');
const page = ref(1);
const hasMore = ref(true);
const sortBy = ref('date');
const sortDesc = ref(true);
const filterType = ref('ALL');
const subscribedTags = ref([]);
const availableTags = ref([]);
const showCreateInput = ref(false);
const newTagName = ref('');
const editingTagId = ref(null);
const editTagName = ref('');
const filterType = ref('ALL');
const newCommentContent = ref('');
// Form States
const postToEdit = ref(null);
const checkQueryForModal = () => {
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 router = useRouter();
isModalOpen.value = false;
postToEdit.value = null;
router.replace({ query: { ...route.query, openCreatePostModal: undefined } });
};
@@ -295,7 +391,6 @@ const fetchTags = async () => {
};
const toggleTagSubscription = async (tag) => {
const index = subscribedTags.value.indexOf(tag.id);
const isSubscribing = index === -1;
@@ -322,26 +417,13 @@ const toggleTagSubscription = async (tag) => {
};
const handleRefresh = () => {
isModalOpen.value = false;
posts.value = [];
page.value = 1;
hasMore.value = true;
loadPosts();
};
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 = '';
window.scrollTo({ top: 0, behavior: 'smooth' })
};
const loadPosts = async () => {
@@ -349,14 +431,15 @@ const loadPosts = async () => {
loading.value = true;
try {
const data = await $fetch(`${api}/posts`, {
const data = await $fetch(`${api}/publications`, {
method: 'GET',
headers: { Authorization: `Bearer ${token.value}` },
params: {
page: page.value,
sort: sortBy.value,
order: sortDesc.value ? 'desc' : 'asc',
type: filterType.value !== 'ALL' ? filterType.value : undefined
type: filterType.value !== 'ALL' ? filterType.value : undefined,
search: searchQuery.value ? searchQuery.value : ''
}
});
@@ -373,24 +456,51 @@ const loadPosts = async () => {
}
};
const fetchPostDetail = async (id) => {
loading.value = true;
await new Promise(r => setTimeout(r, 400));
selectedPost.value = {
id,
title: posts.value.find(p => p.id === id)?.title || "A New Technique for Data Science",
authors: ["Joana B", "External Researcher"],
publication_date: "2025-11-01",
type: "CONFERENCE_PAPER",
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.",
submitter: { id: 2, name: "Joana B" },
comments: [
{ id: 101, content: "This technique could revolutionize our approach to Project X!", author: { name: "Joana B" }, created_at: "2025-11-10T10:30:00Z" },
]
};
loading.value = false;
window.scrollTo({ top: 0, behavior: 'smooth' });
try {
const postDetail = await $fetch(`${api}/publications/${id}`, {
headers: { Authorization: `Bearer ${token.value}` }
});
selectedPost.value = postDetail;
} catch (err) {
console.error("Failed to fetch post detail:", err);
} finally {
loading.value = false;
}
};
const votePost = async (targetPost, direction) => {
if (!targetPost) return;
let voteTypeToSend = 'NONE';
const currentVote = targetPost.my_vote || 'NONE';
if (direction === 1) {
voteTypeToSend = currentVote === 'UP' ? 'NONE' : 'UP';
} else if (direction === -1) {
voteTypeToSend = currentVote === 'DOWN' ? 'NONE' : 'DOWN';
}
try {
const response = await $fetch(`${api}/publications/${targetPost.id}/vote`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token.value}` },
body: { vote_type: voteTypeToSend }
});
targetPost.rating = response.score;
targetPost.my_vote = response.my_vote;
if (selectedPost.value && selectedPost.value.id === targetPost.id) {
selectedPost.value.rating = response.score;
selectedPost.value.my_vote = response.my_vote;
}
} catch (error) {
console.error("Vote failed", error);
notifications.error("Failed to submit vote.");
}
};
const filteredPosts = computed(() => {
@@ -407,9 +517,9 @@ const filteredPosts = computed(() => {
if (sortBy.value === 'votes') {
return (b.rating - a.rating) * modifier;
} else if (sortBy.value === 'comments') {
return (b.comment_count - a.comment_count) * modifier;
return (b.commentCount - a.commentCount) * modifier;
} 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 +531,19 @@ watch(posts, () => {
}, { deep: true });
watch([sortBy, sortDesc, filterType], () => {
resetAndLoad();
});
watch(searchQuery, () => {
resetAndLoad();
});
function resetAndLoad() {
posts.value = [];
page.value = 1;
hasMore.value = true;
loadPosts();
});
}
onMounted(async () => {
checkQueryForModal();
@@ -474,4 +592,4 @@ onMounted(async () => {
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
</style>
+374 -154
View File
@@ -2,6 +2,27 @@
<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">
<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="grid grid-cols-1 md:grid-cols-12 gap-8 lg:gap-12">
@@ -55,14 +76,6 @@
</svg>
<span>Polytechnic of Leiria</span>
</div>
<div class="flex items-center gap-3 text-sm text-slate-600 dark:text-slate-400">
<svg class="w-4 h-4 text-slate-400 dark:text-slate-500 shrink-0" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>Joined {{ formattedDate }}</span>
</div>
</div>
</div>
@@ -82,7 +95,7 @@
}}</div>
<div
class="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-1">
Papers
Publications
</div>
</div>
</div>
@@ -119,28 +132,18 @@
leave-from-class="transform opacity-100 translate-y-0"
leave-to-class="transform opacity-0 translate-y-2">
<div v-if="activeTab === 'overview'" key="overview" class="space-y-6">
<div
class="bg-white dark:bg-slate-900 p-6 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 transition-colors">
<h2
class="text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3">
Biography</h2>
<p class="text-slate-700 dark:text-slate-300 leading-relaxed text-sm">
{{ userProfile.biography }}
</p>
</div>
<div class="flex items-center justify-between pt-2 px-1">
<h2 class="text-lg font-bold text-slate-900 dark:text-white flex items-center gap-2">
Recent Publications
<span
class="bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-xs font-bold px-2 py-0.5 rounded-full">
{{ userProfile.publications.length }}
{{ userProfile.publications.length }} Publications
</span>
</h2>
</div>
<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">
<div
@@ -159,11 +162,6 @@
<h3 class="text-lg font-bold text-slate-900 dark:text-white mb-1">
No publications yet
</h3>
<p
class="text-slate-500 dark:text-slate-400 text-sm max-w-xs mx-auto mb-6 leading-relaxed">
Your profile is looking a little empty. Share your research with the
community to start building your portfolio.
</p>
<button
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"
d="M12 4v16m8-8H4" />
</svg>
Submit your first paper
Submit your first publication
</button>
</div>
</div>
<div v-for="pub in userProfile.publications" :key="pub.id"
class="bg-white dark:bg-slate-900 p-6 rounded-xl shadow-sm border border-slate-200 dark:border-slate-800 hover:shadow-md hover:border-blue-300 dark:hover:border-blue-700 transition-all duration-200 group relative">
<div class="flex justify-between items-start gap-4 mb-2">
<h3 class="text-base font-bold text-slate-900 dark:text-slate-100">{{ pub.title
}}</h3>
<span
class="shrink-0 px-2.5 py-1 rounded-md bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 text-xs font-bold border border-slate-200 dark:border-slate-700">
{{ pub.publishedYear }}
</span>
</div>
<p class="text-slate-600 dark:text-slate-400 text-sm leading-relaxed line-clamp-2">
{{ pub.abstract }}</p>
<div
class="mt-4 pt-4 border-t border-slate-50 dark:border-slate-800 flex flex-wrap items-center gap-4 text-xs font-medium">
<span
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-100 dark:border-blue-800/50">
{{ pub.field }}
</span>
<span>{{ pub.citations }} Citations</span>
<div v-for="post in userProfile.publications" :key="post.id"
:class="['relative group bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-xl p-5 shadow-sm hover:border-blue-500/50 transition-all cursor-pointer mb-4 overflow-hidden', post.hidden ? 'opacity-60 bg-gray-50' : 'bg-white']"
@click="fetchPostDetail(post.id)">
<div class="flex gap-4">
<div
class="flex flex-col items-center gap-1 text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 p-2 rounded-lg h-fit">
<button class="hover:text-blue-500 transition-colors text-lg"
:class="{ 'text-orange-500': post.my_vote === 'UP' }"
@click.stop="votePost(post, 1)"></button>
<span class="text-xs font-bold">{{ post.rating }}</span>
<button class="hover:text-red-500 transition-colors text-lg"
:class="{ 'text-blue-500': post.my_vote === 'DOWN' }"
@click.stop="votePost(post, -1)"></button>
</div>
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<span
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400">
{{ post.type.replace('_', ' ') }}
</span>
<span class="text-xs text-gray-500">{{ new
Date(post.publicationDate).toLocaleDateString('en-US', {
year:
'numeric', month: 'long', day: 'numeric'
}) }}</span>
<button
class="p-1 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-all transform hover:scale-110 active:scale-95 cursor-pointer"
title="Edit" @click.stop="openEditModal(post)">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
class="p-1 text-slate-400 hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-lg transition-all transform hover:scale-110 active:scale-95 cursor-pointer"
:title="post.hiddenidden ? 'Unhide' : 'Hide'"
@click.stop="toggleHide(post)">
<svg width="16px" height="16px" viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<path
d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z" />
<path
d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z" />
<line v-if="post.hidden" x1="3" y1="3" x2="13" y2="13"
stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" />
</svg>
</button>
</div>
<h2 class="text-lg font-bold text-gray-800 dark:text-white mb-2">{{
post.title }}</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">By {{
post.authors.join(', ') }}</p>
<div
class="flex items-center gap-4 text-xs font-bold text-gray-500 uppercase">
<span>💬 {{ post.commentCount }} Comments</span>
<div class="flex gap-2">
<span v-for="tag in post.tags" :key="tag.id"
class="text-blue-600 dark:text-blue-400">#{{ tag.name }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -211,114 +256,154 @@
<div
class="bg-white dark:bg-slate-900 rounded-xl shadow-sm border border-slate-200 dark:border-slate-800 overflow-hidden">
<div v-for="(item, index) in activityLog" :key="item.id"
class="p-4 flex gap-4 border-b border-slate-100 dark:border-slate-800 last:border-0 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
<div v-for="item in activityLog" :key="item.id"
class="group p-4 flex gap-5 border-b border-slate-100 dark:border-slate-800 last:border-0 hover:bg-slate-50/80 dark:hover:bg-slate-800/30 transition-all duration-200">
<div class="flex flex-col items-center min-w-[80px] text-center pt-1">
<span class="text-xs font-bold text-slate-500 uppercase">
<div
class="flex flex-col items-end min-w-[90px] pt-1 border-r border-slate-100 dark:border-slate-800 pr-4">
<span
class="text-[11px] font-semibold text-slate-600 dark:text-slate-400 tabular-nums">
{{ new Date(item.timestamp).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
}) }}
day: 'numeric', year: 'numeric' }) }}
</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([], {
hour: '2-digit',
minute: '2-digit'
}) }}
minute: '2-digit' }) }}
</span>
</div>
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<div class="flex items-center gap-3 mb-1.5">
<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="{
'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400': item.action.includes('UPLOAD'),
'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400': item.action.includes('COMMENT'),
'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400': item.action.includes('RATED') || item.action.includes('CORRECT'),
'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400': item.action.includes('TAG')
'bg-emerald-50 text-emerald-700 border-emerald-100 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20': item.actionType.includes('UPLOAD'),
'bg-blue-50 text-blue-700 border-blue-100 dark:bg-blue-500/10 dark:text-blue-400 dark:border-blue-500/20': item.actionType.includes('COMMENT'),
'bg-indigo-50 text-indigo-700 border-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-400 dark:border-indigo-500/20': item.actionType.includes('EDIT'),
'bg-amber-50 text-amber-700 border-amber-100 dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/20': item.actionType.includes('RATED') || item.actionType.includes('CORRECT'),
'bg-purple-50 text-purple-700 border-purple-100 dark:bg-purple-500/10 dark:text-purple-400 dark:border-purple-500/20': item.actionType.includes('TAG')
}">
{{ item.action.replace('_', ' ') }}
{{ item.actionType.replace('_', ' ') }}
</span>
<span class="text-[11px] text-slate-400 font-medium">
{{ item.targetType }} #{{ item.targetId }}
</span>
<span class="text-xs text-slate-400 font-medium">{{ item.target_type }} #{{
item.target_id }}</span>
</div>
<p class="text-sm text-slate-700 dark:text-slate-300">
{{ item.details }}
<p class="text-sm text-slate-600 dark:text-slate-300 leading-relaxed">
{{ item.description }}
</p>
</div>
<div class="flex items-center opacity-0 group-hover:opacity-100 transition-opacity">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-slate-300"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="9 5l7 7-7 7" />
</svg>
</div>
</div>
</div>
</div>
<div v-else-if="activeTab === 'settings'" key="settings"
class="bg-white dark:bg-slate-900 p-6 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 transition-colors">
<h2
class="text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3">
Settings</h2>
<p class="text-sm text-slate-600 dark:text-slate-400">
Nothing here but chickens! Settings coming soon.
</p>
class="bg-white dark:bg-slate-900 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 transition-colors">
<div class="px-6 py-5 border-b border-slate-200 dark:border-slate-800">
<h2 class="text-lg font-semibold text-slate-900 dark:text-white">
Edit Profile
</h2>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
Update your personal information and account details.
</p>
</div>
<div class="p-6 space-y-6">
<div>
<label for="name"
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Name
</label>
<input id="name" v-model="newUsername" type="text"
placeholder="Your first name and last name" class="w-full px-4 py-2.5 bg-white dark:bg-slate-800
border border-slate-300 dark:border-slate-700
rounded-xl text-slate-900 dark:text-white
placeholder-slate-400
focus:ring-2 focus:ring-blue-500 focus:border-blue-500
outline-none transition-all">
</div>
<div>
<label for="email"
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Email address
</label>
<input id="email" v-model="newEmail" type="email" placeholder="[email protected]"
class="w-full px-4 py-2.5 bg-white dark:bg-slate-800
border border-slate-300 dark:border-slate-700
rounded-xl text-slate-900 dark:text-white
placeholder-slate-400
focus:ring-2 focus:ring-blue-500 focus:border-blue-500
outline-none transition-all">
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
Dont worry, Your email is going to be sold to russian hackers.
</p>
</div>
<a class="text-sm text-red dark:text-red-600 hover:underline"
@click="openResetPassword">
Reset Password
</a>
</div>
<div class="px-6 py-4 bg-slate-50 dark:bg-slate-800/50
border-t border-slate-200 dark:border-slate-800
flex justify-end">
<button class="inline-flex items-center gap-2
bg-blue-600 hover:bg-blue-700
text-white font-semibold
px-6 py-2.5 rounded-xl
shadow-sm transition-all
active:scale-95" @click="updateProfile">
Save changes
</button>
</div>
</div>
</Transition>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from "~/stores/auth-store.js";
import { storeToRefs } from "pinia";
import CreatePostModal from '~/components/ui/modal/CreatePostModal.vue';
// --- Config & Store ---
const config = useRuntimeConfig();
const api = config.public.apiBase;
const authStore = useAuthStore();
const { token } = storeToRefs(authStore);
const router = useRouter();
const notifications = useNotificationStore();
// --- Interfaces ---
const isModalOpen = ref(false);
const postToEdit = ref(null);
interface Tag {
id: number;
name: string;
}
const selectedPost = ref(null);
// Matches response from EP12 (/api/v1/publications/my-submissions) [cite: 296]
interface ApiPublication {
id: number;
title: string;
authors: string[];
publication_date: string;
type: string;
rating: number; // Mapping this to citations for the UI
comment_count: number;
attached_file?: string;
tags: Tag[];
abstract?: string;
}
const newUsername = ref('');
const newEmail = ref('');
// 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 activeTab = ref('overview'); // State to control visible tab
const activityLog = ref<ActivityItem[]>([]); // Store history data
const activeTab = ref('overview');
const activityLog = ref([]);
const userProfile = ref({
id: 0,
@@ -327,48 +412,82 @@ const userProfile = ref({
role: '',
is_active: false,
avatarUrl: '',
biography: 'No biography provided.',
joinDate: new Date().toISOString(),
publications: [] as any[]
publications: []
});
// --- API Actions ---
// Fetch Tags (EP23) - To populate the select box
async function fetchTags() {
if (!token.value) return;
const toggleHide = async (post) => {
try {
const data = await $fetch<Tag[]>(`${api}/tags`, {
method: 'GET',
const newState = !post.hidden;
await $fetch(`${api}/publications/${post.id}/hide`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token.value}` },
body: { is_hidden: newState }
});
post.hidden = newState;
notifications.success(`Post ${newState ? 'hidden' : 'restored'}`);
} catch (err) {
notifications.error("Failed to update post visibility");
}
};
const openEditModal = (post) => {
postToEdit.value = post;
isModalOpen.value = true;
};
const closeModal = () => {
isModalOpen.value = false;
postToEdit.value = null;
};
const getFileName = (fullPath) => {
if (!fullPath) return null;
return fullPath.split(/[/\\]/).pop();
};
const downloadFile = async (post) => {
if (!post.attachedFile) return;
const filename = getFileName(post.attachedFile);
const downloadUrl = `${api}/publications/download/${filename}`;
try {
const link = document.createElement('a');
link.href = downloadUrl;
link.setAttribute('download', filename || 'download');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (e) {
console.error("Download error:", e);
}
};
const fetchPostDetail = async (id) => {
try {
const postDetail = await $fetch(`${api}/publications/${id}`, {
headers: { Authorization: `Bearer ${token.value}` }
});
availableTags.value = data || [];
} catch (e) {
console.error("Failed to fetch tags", e);
selectedPost.value = postDetail;
document.body.style.overflow = 'hidden';
} catch (err) {
notifications.error("Failed to load publication details");
console.error(err);
}
}
};
// --- Computed ---
const totalCitations = computed(() =>
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() {
if (!token.value) return;
try {
// GET /api/v1/me/history
const data = await $fetch<ActivityItem[]>(`${api}/me/history`, {
const data = await $fetch(`${api}/users/me/history`, {
method: 'GET',
headers: { Authorization: `Bearer ${token.value}` }
});
@@ -378,25 +497,20 @@ async function fetchActivityHistory() {
}
}
// 2. Fetch Main Profile Data (EP03 & EP12)
async function fetchProfileData() {
if (!token.value) return;
loading.value = true;
try {
// Fetch User Details
const userData = await $fetch(`${api}/users/me`, {
method: 'GET',
headers: { Authorization: `Bearer ${token.value}` }
});
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,
id: userData.id,
@@ -407,16 +521,23 @@ async function fetchProfileData() {
avatarUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(userData.name)}&background=0D8ABC&color=fff`
};
userProfile.value.publications = (publicationsData as any[]).map(pub => {
const pubDate = new Date(pub.publication_date);
if (authStore.user) {
authStore.user.name = userData.name;
authStore.user.email = userData.email;
}
userProfile.value.publications = (publicationsData).map(pub => {
const pubDate = new Date(pub.publicationDate);
return {
id: pub.id,
title: pub.title,
abstract: pub.abstract || 'No abstract available.',
...pub,
publishedDate: pubDate.toLocaleDateString(),
publishedYear: pubDate.getFullYear().toString(),
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 = () => {
console.log("Navigating to create post modal...");
router.push({
path: '/',
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(() => {
fetchProfileData();
fetchActivityHistory(); // Fetch history on mount
fetchActivityHistory();
});
</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