final frontend
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
<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">
|
||||
<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>
|
||||
</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 toggleHide = async (comment) => {
|
||||
try {
|
||||
console.log(comment)
|
||||
comment.hidden = !comment.hidden;
|
||||
|
||||
await $fetch(`${props.api}/comments/${comment.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${props.token}` },
|
||||
});
|
||||
|
||||
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 {
|
||||
const updatedComment = await $fetch(`${props.api}/comments/${comment.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { Authorization: `Bearer ${props.token}` },
|
||||
body: { content: editText.value }
|
||||
});
|
||||
|
||||
comment.content = updatedComment.content;
|
||||
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 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();
|
||||
});
|
||||
</script>
|
||||
@@ -232,7 +232,7 @@ const getTagClass = (id) => {
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
Abstract ({{ uploadForm.abstract.length }}/2000)
|
||||
Abstract ({{ uploadForm.abstract.length }}/255)
|
||||
</label>
|
||||
<textarea v-model="uploadForm.abstract" rows="4"
|
||||
:class="[errors.abstract ? 'border-red-500' : 'border-slate-300 dark:border-slate-700']"
|
||||
|
||||
@@ -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>
|
||||
@@ -462,7 +458,7 @@
|
||||
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>
|
||||
}} #{{ item.target_id }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300 leading-relaxed">{{
|
||||
item.details }}</p>
|
||||
@@ -558,7 +554,7 @@ const formData = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'STUDENT',
|
||||
role: 'COLLABORATOR',
|
||||
is_active: true
|
||||
});
|
||||
|
||||
@@ -572,7 +568,7 @@ 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);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
loading.value = true;
|
||||
@@ -701,7 +697,7 @@ const resetForm = () => {
|
||||
formData.name = '';
|
||||
formData.email = '';
|
||||
formData.password = '';
|
||||
formData.role = 'STUDENT';
|
||||
formData.role = 'COLLABORATOR';
|
||||
formData.is_active = true;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+7
-253
@@ -135,12 +135,12 @@
|
||||
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="votePost(post, 1)">▲</button>
|
||||
: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="votePost(post, -1)">▼</button>
|
||||
:class="{ 'text-blue-500': post.my_vote === 'DOWN' }" @click.stop="votePost(post, -1)">▼</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
@@ -161,7 +161,7 @@
|
||||
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.role != 'COLLABORATOR'"
|
||||
<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"
|
||||
@@ -171,8 +171,8 @@
|
||||
<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" />
|
||||
<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>
|
||||
@@ -213,216 +213,11 @@
|
||||
<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-2xl shadow-xl overflow-hidden">
|
||||
<PostDetail v-if="selectedPost" :post="selectedPost" :api="api" :token="token" @back="selectedPost = null"
|
||||
@vote="(dir) => votePost(selectedPost, dir)" @comment-added="() => selectedPost.commentCount++" />
|
||||
|
||||
<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">
|
||||
<span
|
||||
class="bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider shadow-sm border border-blue-200 dark:border-blue-800">
|
||||
{{ selectedPost.type.replace('_', ' ') }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{{ new Date(selectedPost.publicationDate).toLocaleDateString('en-US', {
|
||||
year:
|
||||
'numeric', month: 'long', day: 'numeric'
|
||||
}) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl md:text-4xl font-extrabold text-gray-900 dark:text-white mb-6 leading-tight">
|
||||
{{ selectedPost.title }}
|
||||
</h1>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-6 p-4 bg-white/50 dark:bg-gray-800/50 rounded-xl border border-gray-200 dark:border-gray-700 backdrop-blur-sm">
|
||||
|
||||
<div>
|
||||
<h4 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">Authors
|
||||
</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span v-for="(author, idx) in selectedPost.authors" :key="idx"
|
||||
class="inline-flex items-center gap-1.5 text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||
<span
|
||||
class="w-6 h-6 rounded-full bg-indigo-100 text-indigo-600 dark:bg-indigo-900 dark:text-indigo-300 flex items-center justify-center text-xs">
|
||||
{{ author.charAt(0) }}
|
||||
</span>
|
||||
{{ author }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">Submitted
|
||||
By</h4>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-blue-600 text-white flex items-center justify-center font-bold text-xs">
|
||||
{{ selectedPost.submitter.name.charAt(0) }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-bold text-gray-900 dark:text-white">{{
|
||||
selectedPost.submitter.name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ selectedPost.submitter.role }} • {{
|
||||
selectedPost.submitter.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-8">
|
||||
<div class="mb-10">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7" />
|
||||
</svg>
|
||||
Abstract
|
||||
</h3>
|
||||
<p class="text-gray-700 dark:text-gray-300 leading-relaxed text-lg text-justify whitespace-pre-line">
|
||||
{{ selectedPost.abstractText }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col md:flex-row justify-between items-start md:items-center gap-6 mb-8 pb-8 border-b dark:border-gray-800">
|
||||
<div v-if="selectedPost.tags && selectedPost.tags.length > 0">
|
||||
<h4 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">Tags</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span v-for="tag in selectedPost.tags" :key="tag.id"
|
||||
class="px-3 py-1 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-sm font-medium border border-gray-200 dark:border-gray-700">
|
||||
#{{ tag.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-gray-400 italic">No tags assigned</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="flex items-center bg-slate-100 dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-1.5 shadow-sm">
|
||||
|
||||
<button class="p-2 rounded-lg transition-all active:scale-90"
|
||||
:class="selectedPost.my_vote === 'UP' ? 'text-orange-600 bg-orange-100 dark:bg-orange-900/30' : 'text-slate-400 hover:text-orange-500 hover:bg-white dark:hover:bg-slate-700'"
|
||||
title="Upvote" @click="votePost(selectedPost, 1)">
|
||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="min-w-[40px] text-center">
|
||||
<span class="text-2xl font-black tracking-tight" :class="{
|
||||
'text-orange-600': selectedPost.my_vote === 'UP',
|
||||
'text-blue-600': selectedPost.my_vote === 'DOWN',
|
||||
'text-slate-900 dark:text-white': !selectedPost.my_vote || selectedPost.my_vote === 'NONE'
|
||||
}">
|
||||
{{ selectedPost.rating }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button class="p-2 rounded-lg transition-all active:scale-90"
|
||||
:class="selectedPost.my_vote === 'DOWN' ? 'text-blue-600 bg-blue-100 dark:bg-blue-900/30' : 'text-slate-400 hover:text-blue-500 hover:bg-white dark:hover:bg-slate-700'"
|
||||
title="Downvote" @click="votePost(selectedPost, -1)">
|
||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedPost.attachedFile"
|
||||
class="mb-10 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 rounded-xl p-5 hover:border-blue-400 transition-colors group">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 bg-red-100 text-red-600 rounded-lg group-hover:scale-110 transition-transform">
|
||||
<svg class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold text-gray-900 dark:text-white mb-0.5">Attachment
|
||||
Available</div>
|
||||
<div class="text-xs text-gray-500 font-mono truncate max-w-[200px] md:max-w-md">
|
||||
{{ getFileName(selectedPost.attachedFile) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
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(selectedPost)">
|
||||
<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>
|
||||
|
||||
<hr class="border-gray-100 dark:border-gray-800 mb-8">
|
||||
|
||||
<section>
|
||||
<h3 class="text-xl font-bold mb-6 flex items-center gap-2">
|
||||
Comments
|
||||
<span
|
||||
class="bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-xs px-2 py-1 rounded-full">{{
|
||||
selectedPost.comments.length }}</span>
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<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-if="selectedPost.comments.length === 0" class="text-center py-8 text-gray-400 italic">
|
||||
Be the first to comment!
|
||||
</div>
|
||||
|
||||
<div v-for="comment in selectedPost.comments" :key="comment.id" class="flex gap-4">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-600 flex-shrink-0" />
|
||||
<div class="flex-1">
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-xl rounded-tl-none border border-gray-100 dark:border-gray-800">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="font-bold text-sm text-blue-600 dark:text-blue-400">{{
|
||||
comment.author.name }}</span>
|
||||
<span class="text-[10px] text-gray-400 uppercase">{{ new
|
||||
Date(comment.created_at).toLocaleDateString() }}</span>
|
||||
</div>
|
||||
<p class="text-gray-700 dark:text-gray-300 text-sm leading-relaxed">
|
||||
{{ comment.content }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<CreatePostModal :is-open="isModalOpen" :post-to-edit="postToEdit" :api="api" :token="token"
|
||||
@close="closeModal()" @refresh="handleRefresh" />
|
||||
</main>
|
||||
@@ -466,8 +261,6 @@ const editTagName = ref('');
|
||||
|
||||
const postToEdit = ref(null);
|
||||
|
||||
const newCommentContent = ref('');
|
||||
|
||||
const checkQueryForModal = () => {
|
||||
if (route.query.openCreatePostModal === 'true') {
|
||||
isModalOpen.value = true;
|
||||
@@ -629,20 +422,6 @@ const handleRefresh = () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
};
|
||||
|
||||
const submitComment = () => {
|
||||
if (!newCommentContent.value.trim()) return;
|
||||
|
||||
const comment = {
|
||||
id: Date.now(),
|
||||
content: newCommentContent.value,
|
||||
author: { name: user.value?.name || "Guest" },
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
selectedPost.value.comments.unshift(comment);
|
||||
newCommentContent.value = '';
|
||||
};
|
||||
|
||||
const loadPosts = async () => {
|
||||
if (loading.value || !hasMore.value) return;
|
||||
loading.value = true;
|
||||
@@ -720,31 +499,6 @@ const votePost = async (targetPost, direction) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getFileName = (fullPath) => {
|
||||
if (!fullPath) return null;
|
||||
return fullPath.split(/[/\\]/).pop();
|
||||
};
|
||||
|
||||
const downloadFile = async (post) => {
|
||||
if (!post.attachedFile) return;
|
||||
|
||||
const filename = getFileName(post.attachedFile);
|
||||
|
||||
const downloadUrl = `${api}/publications/download/${filename}`;
|
||||
|
||||
try {
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (e) {
|
||||
console.error("Download error:", e);
|
||||
notifications.error("Could not download file.");
|
||||
}
|
||||
};
|
||||
|
||||
const filteredPosts = computed(() => {
|
||||
const result = posts.value.filter(p => {
|
||||
const matchesType = filterType.value === 'ALL' || p.type === filterType.value;
|
||||
|
||||
+53
-293
@@ -12,256 +12,13 @@
|
||||
@click.self="selectedPost = null">
|
||||
|
||||
<div class="min-h-screen px-4 py-8 flex items-start justify-center">
|
||||
<Transition enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="opacity-0 scale-95 translate-y-4"
|
||||
enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-to-class="opacity-0 scale-95 translate-y-4">
|
||||
<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">
|
||||
|
||||
<div v-if="selectedPost"
|
||||
class="w-full max-w-4xl bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border dark:border-gray-800"
|
||||
@click.stop>
|
||||
<PostDetail :post="selectedPost" :api="api" :token="token" @back="selectedPost = null"
|
||||
@comment-added="handleCommentAdded" />
|
||||
|
||||
<div
|
||||
class="sticky top-0 z-10 flex justify-between items-center p-4 bg-white/95 dark:bg-gray-900/95 backdrop-blur-sm border-b dark:border-gray-800 rounded-t-2xl">
|
||||
<button
|
||||
class="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>
|
||||
|
||||
<button
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
@click="selectedPost = null">
|
||||
<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>
|
||||
|
||||
<article class="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">
|
||||
<span
|
||||
class="bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider shadow-sm border border-blue-200 dark:border-blue-800">
|
||||
{{ selectedPost.type.replace('_', ' ') }}
|
||||
</span>
|
||||
<span
|
||||
class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{{ new Date(selectedPost.publicationDate ||
|
||||
selectedPost.publication_date).toLocaleDateString('en-US', {
|
||||
year: 'numeric', month: 'long', day: 'numeric'
|
||||
}) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1
|
||||
class="text-3xl md:text-4xl font-extrabold text-gray-900 dark:text-white mb-6 leading-tight">
|
||||
{{ selectedPost.title }}
|
||||
</h1>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-6 p-4 bg-white/50 dark:bg-gray-800/50 rounded-xl border border-gray-200 dark:border-gray-700 backdrop-blur-sm">
|
||||
<div>
|
||||
<h4 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">
|
||||
Authors</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span v-for="(author, idx) in selectedPost.authors" :key="idx"
|
||||
class="inline-flex items-center gap-1.5 text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||
<span
|
||||
class="w-6 h-6 rounded-full bg-indigo-100 text-indigo-600 dark:bg-indigo-900 dark:text-indigo-300 flex items-center justify-center text-xs">
|
||||
{{ author.charAt(0) }}
|
||||
</span>
|
||||
{{ author }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedPost.submitter">
|
||||
<h4 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">
|
||||
Submitted By</h4>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-blue-600 text-white flex items-center justify-center font-bold text-xs">
|
||||
{{ selectedPost.submitter.name.charAt(0) }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-bold text-gray-900 dark:text-white">{{
|
||||
selectedPost.submitter.name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ selectedPost.submitter.role }}
|
||||
• {{ selectedPost.submitter.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-8 max-h-[60vh] overflow-y-auto">
|
||||
<div class="mb-10">
|
||||
<h3
|
||||
class="text-lg font-bold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-blue-500" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h7" />
|
||||
</svg>
|
||||
Abstract
|
||||
</h3>
|
||||
<p
|
||||
class="text-gray-700 dark:text-gray-300 leading-relaxed text-lg text-justify whitespace-pre-line">
|
||||
{{ selectedPost.abstractText || selectedPost.abstract }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col md:flex-row justify-between items-start md:items-center gap-6 mb-8 pb-8 border-b dark:border-gray-800">
|
||||
<div v-if="selectedPost.tags && selectedPost.tags.length > 0">
|
||||
<h4 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">
|
||||
Tags</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span v-for="tag in selectedPost.tags" :key="tag.id"
|
||||
class="px-3 py-1 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-sm font-medium border border-gray-200 dark:border-gray-700">
|
||||
#{{ tag.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-gray-400 italic">No tags assigned</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="flex items-center bg-slate-100 dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-1.5 shadow-sm">
|
||||
<button class="p-2 rounded-lg transition-all active:scale-90"
|
||||
:class="selectedPost.my_vote === 'UP' ? 'text-orange-600 bg-orange-100 dark:bg-orange-900/30' : 'text-slate-400 hover:text-orange-500 hover:bg-white dark:hover:bg-slate-700'"
|
||||
title="Upvote" @click.stop="votePost(selectedPost, 1)">
|
||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="min-w-[40px] text-center">
|
||||
<span class="text-2xl font-black tracking-tight" :class="{
|
||||
'text-orange-600': selectedPost.my_vote === 'UP',
|
||||
'text-blue-600': selectedPost.my_vote === 'DOWN',
|
||||
'text-slate-900 dark:text-white': !selectedPost.my_vote || selectedPost.my_vote === 'NONE'
|
||||
}">
|
||||
{{ selectedPost.rating }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="p-2 rounded-lg transition-all active:scale-90"
|
||||
:class="selectedPost.my_vote === 'DOWN' ? 'text-blue-600 bg-blue-100 dark:bg-blue-900/30' : 'text-slate-400 hover:text-blue-500 hover:bg-white dark:hover:bg-slate-700'"
|
||||
title="Downvote" @click.stop="votePost(selectedPost, -1)">
|
||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedPost.attachedFile || selectedPost.attached_file"
|
||||
class="mb-10 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 rounded-xl p-5 hover:border-blue-400 transition-colors group">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="p-3 bg-red-100 text-red-600 rounded-lg group-hover:scale-110 transition-transform">
|
||||
<svg class="w-8 h-8" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold text-gray-900 dark:text-white mb-0.5">
|
||||
Attachment Available</div>
|
||||
<div
|
||||
class="text-xs text-gray-500 font-mono truncate max-w-[200px] md:max-w-md">
|
||||
{{ getFileName(selectedPost.attachedFile ||
|
||||
selectedPost.attached_file) }}
|
||||
</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.stop="downloadFile(selectedPost)">
|
||||
<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>
|
||||
|
||||
<hr class="border-gray-100 dark:border-gray-800 mb-8">
|
||||
|
||||
<section>
|
||||
<h3 class="text-xl font-bold mb-6 flex items-center gap-2">
|
||||
Comments
|
||||
<span
|
||||
class="bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-xs px-2 py-1 rounded-full">
|
||||
{{ selectedPost.comments?.length || 0 }}
|
||||
</span>
|
||||
</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-if="!selectedPost.comments || selectedPost.comments.length === 0"
|
||||
class="text-center py-8 text-gray-400 italic">
|
||||
Be the first to comment!
|
||||
</div>
|
||||
|
||||
<div v-for="comment in selectedPost.comments" :key="comment.id"
|
||||
class="flex gap-4">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-600 flex-shrink-0" />
|
||||
<div class="flex-1">
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-xl rounded-tl-none border border-gray-100 dark:border-gray-800">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span
|
||||
class="font-bold text-sm text-blue-600 dark:text-blue-400">{{
|
||||
comment.author.name }}</span>
|
||||
<span class="text-[10px] text-gray-400 uppercase">{{ new
|
||||
Date(comment.created_at).toLocaleDateString() }}</span>
|
||||
</div>
|
||||
<p
|
||||
class="text-gray-700 dark:text-gray-300 text-sm leading-relaxed">
|
||||
{{ comment.content }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -419,15 +176,9 @@
|
||||
</div>
|
||||
|
||||
<div v-for="post in userProfile.publications" :key="post.id"
|
||||
:class="['relative group bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-xl p-5 shadow-sm hover:border-blue-500/50 transition-all cursor-pointer mb-4 overflow-hidden', post.isHidden ? 'opacity-60 bg-gray-50' : 'bg-white']"
|
||||
: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 v-if="post.attachedFile"
|
||||
class="absolute right-1 bottom-5 opacity-50 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<img src="/f.png" alt="attachment"
|
||||
class="w-32 h-32 transform rotate-[-45deg] pointer-events-none select-none">
|
||||
</div>
|
||||
|
||||
<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">
|
||||
@@ -450,7 +201,7 @@
|
||||
year:
|
||||
'numeric', month: 'long', day: 'numeric'
|
||||
}) }}</span>
|
||||
<button v-if="authStore.user.id == post.submitter.id"
|
||||
<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"
|
||||
@@ -461,9 +212,8 @@
|
||||
</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.isHidden ? 'Unhide' : 'Hide'"
|
||||
: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">
|
||||
@@ -472,7 +222,7 @@
|
||||
<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.isHidden" x1="3" y1="3" x2="13" y2="13"
|
||||
<line v-if="post.hidden" x1="3" y1="3" x2="13" y2="13"
|
||||
stroke="currentColor" stroke-width="1.5"
|
||||
stroke-linecap="round" />
|
||||
</svg>
|
||||
@@ -507,42 +257,54 @@
|
||||
<div
|
||||
class="bg-white dark:bg-slate-900 rounded-xl shadow-sm border border-slate-200 dark:border-slate-800 overflow-hidden">
|
||||
<div v-for="item in activityLog" :key="item.id"
|
||||
class="p-4 flex gap-4 border-b border-slate-100 dark:border-slate-800 last:border-0 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
||||
class="group p-4 flex gap-5 border-b border-slate-100 dark:border-slate-800 last:border-0 hover:bg-slate-50/80 dark:hover:bg-slate-800/30 transition-all duration-200">
|
||||
|
||||
<div class="flex flex-col items-center min-w-[80px] text-center pt-1">
|
||||
<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>
|
||||
@@ -618,7 +380,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from "~/stores/auth-store.js";
|
||||
import { storeToRefs } from "pinia";
|
||||
@@ -635,7 +397,6 @@ const isModalOpen = ref(false);
|
||||
const postToEdit = ref(null);
|
||||
|
||||
const selectedPost = ref(null);
|
||||
const newCommentContent = ref('');
|
||||
|
||||
const newUsername = ref('');
|
||||
const newEmail = ref('');
|
||||
@@ -657,15 +418,15 @@ const userProfile = ref({
|
||||
|
||||
const toggleHide = async (post) => {
|
||||
try {
|
||||
const newState = !post.isHidden;
|
||||
const newState = !post.hidden;
|
||||
|
||||
await $fetch(`${api}/publications/${post.id}`, {
|
||||
await $fetch(`${api}/publications/${post.id}/hide`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${token.value}` },
|
||||
body: { isHidden: newState }
|
||||
body: { is_hidden: newState }
|
||||
});
|
||||
|
||||
post.isHidden = newState;
|
||||
post.hidden = newState;
|
||||
notifications.success(`Post ${newState ? 'hidden' : 'restored'}`);
|
||||
|
||||
} catch (err) {
|
||||
@@ -706,20 +467,15 @@ const downloadFile = async (post) => {
|
||||
};
|
||||
|
||||
const fetchPostDetail = async (id) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const postDetail = await $fetch(`${api}/publications/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token.value}` }
|
||||
});
|
||||
|
||||
selectedPost.value = postDetail;
|
||||
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
|
||||
document.body.style.overflow = 'hidden';
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch post detail:", err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
notifications.error("Failed to load publication details");
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -731,7 +487,7 @@ async function fetchActivityHistory() {
|
||||
if (!token.value) return;
|
||||
|
||||
try {
|
||||
const data = await $fetch(`${api}/me/history`, {
|
||||
const data = await $fetch(`${api}/users/me/history`, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${token.value}` }
|
||||
});
|
||||
@@ -870,6 +626,10 @@ const openResetPassword = () => {
|
||||
router.push({ path: '/profile/update-password' });
|
||||
};
|
||||
|
||||
watch(selectedPost, (newVal) => {
|
||||
if (!newVal) document.body.style.overflow = 'auto';
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchProfileData();
|
||||
fetchActivityHistory();
|
||||
|
||||
Reference in New Issue
Block a user