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]>
This commit was merged in pull request #13.
This commit is contained in:
@@ -5,12 +5,14 @@ const props = defineProps<{
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
api: string;
|
api: string;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
|
postToEdit?: any;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'refresh']);
|
const emit = defineEmits(['close', 'refresh']);
|
||||||
|
|
||||||
interface Tag { id: number; name: string; }
|
interface Tag { id: number; name: string; }
|
||||||
|
|
||||||
|
const notifications = useNotificationStore();
|
||||||
const isSubmitting = ref(false);
|
const isSubmitting = ref(false);
|
||||||
const availableTags = ref<Tag[]>([]);
|
const availableTags = ref<Tag[]>([]);
|
||||||
const errors = reactive({
|
const errors = reactive({
|
||||||
@@ -73,26 +75,41 @@ async function submitPublication() {
|
|||||||
authors: uploadForm.authors,
|
authors: uploadForm.authors,
|
||||||
type: uploadForm.type,
|
type: uploadForm.type,
|
||||||
abstract: uploadForm.abstract,
|
abstract: uploadForm.abstract,
|
||||||
tags: uploadForm.selectedTagIds,
|
tags: uploadForm.selectedTagIds
|
||||||
// Sending fixed defaults for removed fields to maintain API compatibility if needed
|
|
||||||
publication_date: new Date().toISOString().split('T')[0],
|
|
||||||
is_confidential: false
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isEditing = !!props.postToEdit;
|
||||||
|
const url = isEditing
|
||||||
|
? `${props.api}/publications/${props.postToEdit.id}`
|
||||||
|
: `${props.api}/publications`;
|
||||||
|
|
||||||
|
let requestBody: any;
|
||||||
|
let headers: Record<string, string> = {
|
||||||
|
Authorization: `Bearer ${props.token}`
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isEditing || uploadForm.file) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
if (uploadForm.file) formData.append('file', uploadForm.file);
|
if (uploadForm.file) formData.append('file', uploadForm.file);
|
||||||
formData.append('data', JSON.stringify(metadata));
|
formData.append('data', JSON.stringify(metadata));
|
||||||
|
requestBody = formData;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
requestBody = metadata;
|
||||||
|
}
|
||||||
|
|
||||||
await $fetch(`${props.api}/publications`, {
|
await $fetch(url, {
|
||||||
method: 'POST',
|
method: isEditing ? 'PATCH' : 'POST',
|
||||||
headers: { Authorization: `Bearer ${props.token}` },
|
headers,
|
||||||
body: formData
|
body: requestBody
|
||||||
});
|
});
|
||||||
|
|
||||||
|
notifications.success(`Publication ${isEditing ? "updated" : "created"}`);
|
||||||
emit('refresh');
|
emit('refresh');
|
||||||
closeModal();
|
closeModal();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert("Upload failed.");
|
console.error("Submission error:", error);
|
||||||
|
notifications.error("Operation failed");
|
||||||
} finally {
|
} finally {
|
||||||
isSubmitting.value = false;
|
isSubmitting.value = false;
|
||||||
}
|
}
|
||||||
@@ -113,19 +130,34 @@ function handleFileChange(event: Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
|
uploadForm.title = '';
|
||||||
|
uploadForm.abstract = '';
|
||||||
|
uploadForm.type = 'CONFERENCE_PAPER';
|
||||||
|
uploadForm.newAuthorName = '';
|
||||||
|
uploadForm.authors = [];
|
||||||
|
uploadForm.selectedTagIds = [];
|
||||||
|
uploadForm.file = null;
|
||||||
emit('close');
|
emit('close');
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.isOpen, (newVal) => {
|
watch(() => props.isOpen, (newVal) => {
|
||||||
if (newVal && availableTags.value.length === 0) fetchTags();
|
if (newVal && availableTags.value.length === 0) fetchTags();
|
||||||
if (!newVal) {
|
if (!newVal) {
|
||||||
// Reset errors on close
|
|
||||||
errors.title = '';
|
errors.title = '';
|
||||||
errors.abstract = '';
|
errors.abstract = '';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper for colorful tags
|
watch(() => props.postToEdit, (newPost) => {
|
||||||
|
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: any) => t.id);
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
const getTagClass = (id: number) => {
|
const getTagClass = (id: number) => {
|
||||||
const colors = [
|
const colors = [
|
||||||
'bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-900/30 dark:text-pink-300',
|
'bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-900/30 dark:text-pink-300',
|
||||||
@@ -204,9 +236,6 @@ const getTagClass = (id: number) => {
|
|||||||
<select v-model="uploadForm.type"
|
<select v-model="uploadForm.type"
|
||||||
class="w-full rounded-lg border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm focus:ring-blue-500 sm:text-sm py-2.5 px-3 border">
|
class="w-full rounded-lg border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm focus:ring-blue-500 sm:text-sm py-2.5 px-3 border">
|
||||||
<option value="CONFERENCE_PAPER">Conference Paper</option>
|
<option value="CONFERENCE_PAPER">Conference Paper</option>
|
||||||
<option value="ARTICLE">Article</option>
|
|
||||||
<option value="THESIS">Thesis</option>
|
|
||||||
<option value="REPORT">Report</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,7 +253,7 @@ const getTagClass = (id: number) => {
|
|||||||
|
|
||||||
<div class="flex flex-col md:flex-row gap-8">
|
<div class="flex flex-col md:flex-row gap-8">
|
||||||
|
|
||||||
<div class="flex-1">
|
<div class="w-full">
|
||||||
<label
|
<label
|
||||||
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Authors</label>
|
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Authors</label>
|
||||||
<div class="relative flex items-center">
|
<div class="relative flex items-center">
|
||||||
@@ -241,7 +270,7 @@ const getTagClass = (id: number) => {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2 mt-3">
|
<div :class="['flex flex-wrap gap-2', uploadForm.authors.length != 0 ? 'mt-3' : '']">
|
||||||
<span v-for="(author, index) in uploadForm.authors" :key="index"
|
<span v-for="(author, index) in uploadForm.authors" :key="index"
|
||||||
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-xs font-medium border border-slate-200 dark:border-slate-700">
|
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-xs font-medium border border-slate-200 dark:border-slate-700">
|
||||||
{{ author }}
|
{{ author }}
|
||||||
@@ -255,7 +284,7 @@ const getTagClass = (id: number) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full md:w-auto min-w-[200px]">
|
<div class="flex-1 md:w-auto min-w-[300px]">
|
||||||
<label
|
<label
|
||||||
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Tags</label>
|
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Tags</label>
|
||||||
<div
|
<div
|
||||||
@@ -286,7 +315,7 @@ const getTagClass = (id: number) => {
|
|||||||
<button :disabled="isSubmitting"
|
<button :disabled="isSubmitting"
|
||||||
class="inline-flex w-full justify-center rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-bold text-white shadow-sm hover:bg-blue-500 disabled:opacity-50 sm:w-auto transition-all"
|
class="inline-flex w-full justify-center rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-bold text-white shadow-sm hover:bg-blue-500 disabled:opacity-50 sm:w-auto transition-all"
|
||||||
@click="submitPublication">
|
@click="submitPublication">
|
||||||
{{ isSubmitting ? 'Uploading...' : 'Submit Publication' }}
|
{{ isSubmitting ? 'Uploading...' : 'Submit' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="inline-flex w-full justify-center rounded-lg bg-white dark:bg-slate-800 px-6 py-2.5 text-sm font-bold text-slate-700 dark:text-slate-300 shadow-sm ring-1 ring-inset ring-slate-300 dark:ring-slate-700 hover:bg-slate-50 sm:w-auto transition-all"
|
class="inline-flex w-full justify-center rounded-lg bg-white dark:bg-slate-800 px-6 py-2.5 text-sm font-bold text-slate-700 dark:text-slate-300 shadow-sm ring-1 ring-inset ring-slate-300 dark:ring-slate-700 hover:bg-slate-50 sm:w-auto transition-all"
|
||||||
|
|||||||
@@ -488,7 +488,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup>
|
||||||
import { ref, reactive, computed, onMounted } from 'vue';
|
import { ref, reactive, computed, onMounted } from 'vue';
|
||||||
import { useAuthStore } from "~/stores/auth-store.js";
|
import { useAuthStore } from "~/stores/auth-store.js";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
@@ -497,19 +497,18 @@ const config = useRuntimeConfig();
|
|||||||
const api = config.public.apiBase;
|
const api = config.public.apiBase;
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const { token } = storeToRefs(authStore);
|
const { token } = storeToRefs(authStore);
|
||||||
|
const notifications = useNotifications();
|
||||||
|
|
||||||
// State
|
const users = ref([]);
|
||||||
const users = ref<any[]>([]);
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const showModal = ref(false);
|
const showModal = ref(false);
|
||||||
const showDetailsModal = ref(false);
|
const showDetailsModal = ref(false);
|
||||||
const isEditing = ref(false);
|
const isEditing = ref(false);
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
const selectedUser = ref<any>(null);
|
const selectedUser = ref(null);
|
||||||
const userHistory = ref<any[]>([]);
|
const userHistory = ref([]);
|
||||||
|
|
||||||
// Mock history data generator
|
const generateMockHistory = () => {
|
||||||
const generateMockHistory = (userId: number) => {
|
|
||||||
const actions = ['UPLOADED_PUBLICATION', 'ADDED_COMMENT', 'CORRECTED_PUBLICATION', 'RATED_PUBLICATION', 'TAGGED_PUBLICATION'];
|
const actions = ['UPLOADED_PUBLICATION', 'ADDED_COMMENT', 'CORRECTED_PUBLICATION', 'RATED_PUBLICATION', 'TAGGED_PUBLICATION'];
|
||||||
const targetTypes = ['Publication', 'Comment', 'Review'];
|
const targetTypes = ['Publication', 'Comment', 'Review'];
|
||||||
const details = [
|
const details = [
|
||||||
@@ -554,7 +553,6 @@ const generateMockHistory = (userId: number) => {
|
|||||||
return history.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
return history.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||||
};
|
};
|
||||||
|
|
||||||
// Form Data
|
|
||||||
const formData = reactive({
|
const formData = reactive({
|
||||||
id: 0,
|
id: 0,
|
||||||
name: '',
|
name: '',
|
||||||
@@ -564,7 +562,6 @@ const formData = reactive({
|
|||||||
is_active: true
|
is_active: true
|
||||||
});
|
});
|
||||||
|
|
||||||
// Computed Stats & Filtering
|
|
||||||
const filteredUsers = computed(() => {
|
const filteredUsers = computed(() => {
|
||||||
if (!searchQuery.value) return users.value;
|
if (!searchQuery.value) return users.value;
|
||||||
const query = searchQuery.value.toLowerCase();
|
const query = searchQuery.value.toLowerCase();
|
||||||
@@ -577,11 +574,10 @@ const filteredUsers = computed(() => {
|
|||||||
const activeCount = computed(() => users.value.filter(u => u.is_active).length);
|
const activeCount = computed(() => users.value.filter(u => u.is_active).length);
|
||||||
const adminCount = computed(() => users.value.filter(u => u.role === 'ADMIN').length);
|
const adminCount = computed(() => users.value.filter(u => u.role === 'ADMIN').length);
|
||||||
|
|
||||||
// --- API ACTIONS ---
|
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const data = await $fetch<any[]>(`${api}/admin/users`, {
|
const data = await $fetch(`${api}/admin/users`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { Authorization: `Bearer ${token.value}` }
|
headers: { Authorization: `Bearer ${token.value}` }
|
||||||
});
|
});
|
||||||
@@ -605,10 +601,11 @@ const createUser = async () => {
|
|||||||
role: formData.role
|
role: formData.role
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
notifications.success("User created successfully");
|
||||||
closeModal();
|
closeModal();
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert("Failed to create user.");
|
notifications.error("Failed to create user");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -622,10 +619,11 @@ const updateUser = async () => {
|
|||||||
is_active: formData.is_active
|
is_active: formData.is_active
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
notifications.success("User updated successfully");
|
||||||
closeModal();
|
closeModal();
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert("Failed to update user.");
|
notifications.error("Failed to update user");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -635,33 +633,32 @@ const sendPasswordRecovery = async () => {
|
|||||||
try {
|
try {
|
||||||
await $fetch(`${api}/auth/recover-password`, {
|
await $fetch(`${api}/auth/recover-password`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
// headers: { Authorization: ... } // EP02 is usually public, but check your API requirements
|
|
||||||
body: {
|
body: {
|
||||||
email: formData.email
|
email: formData.email
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
alert(`Recovery sent to ${formData.email}`);
|
notifications.success(`Recovery sent to ${formData.email}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert("Failed to send recovery.");
|
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
notifications.error("Failed to send recovery");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = async (user: any) => {
|
const confirmDelete = async (user) => {
|
||||||
if (!confirm(`Are you sure you want to delete ${user.name}?`)) return;
|
if (!confirm(`Are you sure you want to delete ${user.name}?`)) return;
|
||||||
try {
|
try {
|
||||||
await $fetch(`${api}/admin/users/${user.id}`, {
|
await $fetch(`${api}/admin/users/${user.id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token.value}` }
|
headers: { Authorization: `Bearer ${token.value}` }
|
||||||
});
|
});
|
||||||
|
notifications.success("Deleted user");
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert("Error deleting user.");
|
notifications.error("Failed to delete user");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- HELPERS ---
|
const openUserDetails = (user) => {
|
||||||
const openUserDetails = (user: any) => {
|
|
||||||
selectedUser.value = user;
|
selectedUser.value = user;
|
||||||
userHistory.value = generateMockHistory(user.id);
|
userHistory.value = generateMockHistory(user.id);
|
||||||
showDetailsModal.value = true;
|
showDetailsModal.value = true;
|
||||||
@@ -679,7 +676,7 @@ const openCreateModal = () => {
|
|||||||
showModal.value = true;
|
showModal.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditModal = (user: any) => {
|
const openEditModal = (user) => {
|
||||||
isEditing.value = true;
|
isEditing.value = true;
|
||||||
formData.id = user.id;
|
formData.id = user.id;
|
||||||
formData.name = user.name;
|
formData.name = user.name;
|
||||||
@@ -708,7 +705,7 @@ const resetForm = () => {
|
|||||||
formData.is_active = true;
|
formData.is_active = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getInitials = (name: string) => {
|
const getInitials = (name) => {
|
||||||
if (!name) return '??';
|
if (!name) return '??';
|
||||||
return name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase();
|
return name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase();
|
||||||
};
|
};
|
||||||
|
|||||||
+217
-102
@@ -4,17 +4,45 @@
|
|||||||
<div class="max-w-6xl mx-auto flex gap-6 py-8 px-4">
|
<div class="max-w-6xl mx-auto flex gap-6 py-8 px-4">
|
||||||
|
|
||||||
<aside v-if="!selectedPost" class="hidden lg:block w-fit min-w-[160px] py-24">
|
<aside v-if="!selectedPost" class="hidden lg:block w-fit min-w-[160px] py-24">
|
||||||
<div class="sticky top-8">
|
<div class="sticky top-24">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<button v-for="tag in availableTags" :key="tag.id" :class="[
|
<button v-if="user?.role !== 'COLLABORATOR'"
|
||||||
'px-3 py-2 rounded-lg text-xs font-bold transition-all text-left shadow-sm cursor-pointer',
|
class="mb-2 px-3 py-2 rounded-lg text-xs font-bold bg-blue-600 hover:bg-blue-700 text-white shadow-md transition-all active:scale-95 flex items-center justify-center gap-1"
|
||||||
|
@click="showCreateInput = !showCreateInput">
|
||||||
|
<span>+</span> NEW TAG
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="showCreateInput" class="mb-2">
|
||||||
|
<input ref="createInputRef" v-model="newTagName" placeholder="Tag name..."
|
||||||
|
class="w-full px-3 py-2 rounded-lg text-xs font-bold bg-white dark:bg-gray-800 border-2 border-blue-500 outline-none"
|
||||||
|
autofocus @keyup.enter="handleCreateTag" @blur="showCreateInput = false">
|
||||||
|
</div>
|
||||||
|
<div v-for="tag in availableTags" :key="tag.id" class="relative group">
|
||||||
|
<button v-if="editingTagId !== tag.id" :class="[
|
||||||
|
'w-full px-3 py-2 rounded-lg text-xs font-bold transition-all text-left shadow-sm cursor-pointer flex justify-between items-center pr-8',
|
||||||
subscribedTags.includes(tag.id)
|
subscribedTags.includes(tag.id)
|
||||||
? `${tag.color} border-current ring-1 ring-inset ring-black/5`
|
? `${tag.color} border-current ring-1 ring-inset ring-black/5`
|
||||||
: 'bg-gray-50 dark:bg-gray-800 dark:border-gray-800 border opacity-60 hover:opacity-100'
|
: 'bg-gray-50 dark:bg-gray-800 dark:border-gray-800 border opacity-60 hover:opacity-100'
|
||||||
]" @click="toggleTagSubscription(tag)">
|
]" @click="toggleTagSubscription(tag)">
|
||||||
|
<span class="truncate">
|
||||||
<span class="opacity-70 mr-1">{{ subscribedTags.includes(tag.id) ? '✓' : '#' }}</span>
|
<span class="opacity-70 mr-1">{{ subscribedTags.includes(tag.id) ? '✓' : '#' }}</span>
|
||||||
{{ tag.name }}
|
{{ tag.name }}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<input v-else v-model="editTagName"
|
||||||
|
class="w-full px-3 py-2 rounded-lg text-xs font-bold bg-white dark:bg-gray-900 border-2 border-blue-500 outline-none"
|
||||||
|
autofocus @keyup.enter="handleEditTag(tag.id)" @blur="editingTagId = null">
|
||||||
|
|
||||||
|
<button v-if="user?.role !== 'COLLABORATOR' && editingTagId !== tag.id"
|
||||||
|
class="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 p-1 hover:text-blue-500 transition-all"
|
||||||
|
@click.stop="startEditing(tag)">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -28,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-col sm:flex-row justify-between items-start sm:items-center mb-6 gap-4">
|
||||||
<div class="flex flex-wrap items-center gap-2 w-full sm:w-auto">
|
<div class="flex flex-wrap items-center gap-2 w-full sm:w-auto">
|
||||||
<div class="relative flex-1 sm:flex-none">
|
<div class="relative flex-1 sm:flex-none">
|
||||||
<input v-model="searchQuery" type="text" placeholder="Search posts..."
|
<input v-model.lazy="searchQuery" type="text" placeholder="Search publications..."
|
||||||
class="w-full sm:w-48 bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-lg pl-3 pr-3 py-2 text-xs font-bold outline-none focus:ring-2 focus:ring-blue-500">
|
class="w-full sm:w-48 bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-lg pl-3 pr-3 py-2 text-xs font-bold outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
@keyup.enter="$el.blur()">
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<select v-model="sortBy"
|
<select v-model="sortBy"
|
||||||
@@ -41,8 +70,7 @@
|
|||||||
<button
|
<button
|
||||||
class="bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-r-lg px-3 py-2 text-xs font-bold hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
class="bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-r-lg px-3 py-2 text-xs font-bold hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
:title="sortDesc ? 'Descending' : 'Ascending'" @click="sortDesc = !sortDesc">
|
:title="sortDesc ? 'Descending' : 'Ascending'" @click="sortDesc = !sortDesc">
|
||||||
<span class="inline-block transition-transform duration-200"
|
<span class="inline-block transition-transform duration-200" :class="{ 'rotate-180': !sortDesc }">
|
||||||
:class="{ 'rotate-180': !sortDesc }">
|
|
||||||
▼
|
▼
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -90,8 +118,7 @@
|
|||||||
class="inline-flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-xl shadow-lg shadow-blue-500/20 hover:shadow-blue-500/30 transform active:scale-95 transition-all duration-200 cursor-pointer"
|
class="inline-flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-xl shadow-lg shadow-blue-500/20 hover:shadow-blue-500/30 transform active:scale-95 transition-all duration-200 cursor-pointer"
|
||||||
@click="isModalOpen = true">
|
@click="isModalOpen = true">
|
||||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
d="M12 4v16m8-8H4" />
|
|
||||||
</svg>
|
</svg>
|
||||||
Submit your first post
|
Submit your first post
|
||||||
</button>
|
</button>
|
||||||
@@ -100,21 +127,26 @@
|
|||||||
<TransitionGroup v-else appear enter-active-class="transition duration-500 ease-out"
|
<TransitionGroup v-else appear enter-active-class="transition duration-500 ease-out"
|
||||||
enter-from-class="translate-y-4 opacity-0" enter-to-class="translate-y-0 opacity-100">
|
enter-from-class="translate-y-4 opacity-0" enter-to-class="translate-y-0 opacity-100">
|
||||||
<div v-for="post in filteredPosts" :key="post.id"
|
<div v-for="post in filteredPosts" :key="post.id"
|
||||||
class="group bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-xl p-5 shadow-sm hover:border-blue-500/50 transition-all cursor-pointer mb-4"
|
:class="['relative group bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-xl p-5 shadow-sm hover:border-blue-500/50 transition-all cursor-pointer mb-4 overflow-hidden', post.isHidden ? 'opacity-60 bg-gray-50' : 'bg-white']"
|
||||||
@click="fetchPostDetail(post.id)">
|
@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 gap-4">
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center gap-1 text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 p-2 rounded-lg h-fit">
|
class="flex flex-col items-center gap-1 text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 p-2 rounded-lg h-fit">
|
||||||
|
|
||||||
<button class="hover:text-blue-500 transition-colors text-lg"
|
<button class="hover:text-blue-500 transition-colors text-lg"
|
||||||
:class="{ 'text-orange-500': post.my_vote === 'UP' }"
|
:class="{ 'text-orange-500': post.my_vote === 'UP' }" @click.stop="votePost(post, 1)">▲</button>
|
||||||
@click.stop="votePost(post, 1)">▲</button>
|
|
||||||
|
|
||||||
<span class="text-xs font-bold">{{ post.rating }}</span>
|
<span class="text-xs font-bold">{{ post.rating }}</span>
|
||||||
|
|
||||||
<button class="hover:text-red-500 transition-colors text-lg"
|
<button class="hover:text-red-500 transition-colors text-lg"
|
||||||
:class="{ 'text-blue-500': post.my_vote === 'DOWN' }"
|
:class="{ 'text-blue-500': post.my_vote === 'DOWN' }" @click.stop="votePost(post, -1)">▼</button>
|
||||||
@click.stop="votePost(post, -1)">▼</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -123,19 +155,45 @@
|
|||||||
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400">
|
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400">
|
||||||
{{ post.type.replace('_', ' ') }}
|
{{ post.type.replace('_', ' ') }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs text-gray-500">{{ post.publication_date }}</span>
|
<span class="text-xs text-gray-500">{{ new Date(post.publicationDate).toLocaleDateString('en-US', {
|
||||||
|
year:
|
||||||
|
'numeric', month: 'long', day: 'numeric'
|
||||||
|
}) }}</span>
|
||||||
|
<button v-if="authStore.user.id == post.submitter.id"
|
||||||
|
class="p-1 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-all transform hover:scale-110 active:scale-95 cursor-pointer"
|
||||||
|
title="Edit" @click.stop="openEditModal(post)">
|
||||||
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button v-if="authStore.user.id == post.submitter.id || authStore.user.role != 'COLLABORATOR'"
|
||||||
|
class="p-1 text-slate-400 hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-lg transition-all transform hover:scale-110 active:scale-95 cursor-pointer"
|
||||||
|
:title="post.isHidden ? '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.isHidden" x1="3" y1="3" x2="13" y2="13" stroke="currentColor"
|
||||||
|
stroke-width="1.5" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="text-lg font-bold text-gray-800 dark:text-white mb-2">{{ post.title }}
|
<h2 class="text-lg font-bold text-gray-800 dark:text-white mb-2">{{ post.title }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">By {{
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 truncate max-w-[400px]">
|
||||||
post.authors.join(', ') }}
|
{{ post.authors.length > 0 ? `By ${post.authors.join(', ')}` : 'No Authors' }}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex items-center gap-4 text-xs font-bold text-gray-500 uppercase">
|
<div class="flex items-center gap-4 text-xs font-bold text-gray-500 uppercase min-w-0">
|
||||||
<span>💬 {{ post.comment_count }} Comments</span>
|
<span class="shrink-0">💬 {{ post.commentCount }} Comments</span>
|
||||||
<div class="flex gap-2">
|
|
||||||
<span v-for="tag in post.tags" :key="tag.id"
|
<div class="max-w-[300px] truncate">
|
||||||
class="text-blue-600 dark:text-blue-400">#{{ tag.name
|
<span v-for="tag in post.tags" :key="tag.id" class="text-blue-600 dark:text-blue-400 mr-2">
|
||||||
}}</span>
|
#{{ tag.name }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,19 +228,16 @@
|
|||||||
← BACK TO FEED
|
← BACK TO FEED
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<article
|
<article class="bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-2xl shadow-xl overflow-hidden">
|
||||||
class="bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-2xl shadow-xl overflow-hidden">
|
|
||||||
|
|
||||||
<div
|
<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">
|
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
|
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-4">
|
||||||
class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-4">
|
|
||||||
<span
|
<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">
|
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('_', ' ') }}
|
{{ selectedPost.type.replace('_', ' ') }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center gap-2">
|
||||||
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">
|
<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"
|
<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" />
|
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" />
|
||||||
@@ -194,8 +249,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1
|
<h1 class="text-3xl md:text-4xl font-extrabold text-gray-900 dark:text-white mb-6 leading-tight">
|
||||||
class="text-3xl md:text-4xl font-extrabold text-gray-900 dark:text-white mb-6 leading-tight">
|
|
||||||
{{ selectedPost.title }}
|
{{ selectedPost.title }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
@@ -238,17 +292,13 @@
|
|||||||
|
|
||||||
<div class="p-8">
|
<div class="p-8">
|
||||||
<div class="mb-10">
|
<div class="mb-10">
|
||||||
<h3
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||||
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">
|
||||||
<svg class="w-5 h-5 text-blue-500" fill="none" viewBox="0 0 24 24"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7" />
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M4 6h16M4 12h16M4 18h7" />
|
|
||||||
</svg>
|
</svg>
|
||||||
Abstract
|
Abstract
|
||||||
</h3>
|
</h3>
|
||||||
<p
|
<p class="text-gray-700 dark:text-gray-300 leading-relaxed text-lg text-justify whitespace-pre-line">
|
||||||
class="text-gray-700 dark:text-gray-300 leading-relaxed text-lg text-justify whitespace-pre-line">
|
|
||||||
{{ selectedPost.abstractText }}
|
{{ selectedPost.abstractText }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -270,14 +320,11 @@
|
|||||||
<div
|
<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">
|
class="flex items-center bg-slate-100 dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-1.5 shadow-sm">
|
||||||
|
|
||||||
<button @click="votePost(selectedPost, 1)"
|
<button class="p-2 rounded-lg transition-all active:scale-90"
|
||||||
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'"
|
: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">
|
title="Upvote" @click="votePost(selectedPost, 1)">
|
||||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||||
stroke-width="2.5">
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" />
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
|
||||||
d="M5 15l7-7 7 7" />
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -291,29 +338,22 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button @click="votePost(selectedPost, -1)"
|
<button class="p-2 rounded-lg transition-all active:scale-90"
|
||||||
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'"
|
: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">
|
title="Downvote" @click="votePost(selectedPost, -1)">
|
||||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||||
stroke-width="2.5">
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
|
||||||
d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="selectedPost.attachedFile"
|
<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">
|
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 justify-between">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div
|
<div class="p-3 bg-red-100 text-red-600 rounded-lg group-hover:scale-110 transition-transform">
|
||||||
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">
|
<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"
|
<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" />
|
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" />
|
||||||
@@ -322,14 +362,14 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="font-bold text-gray-900 dark:text-white mb-0.5">Attachment
|
<div class="font-bold text-gray-900 dark:text-white mb-0.5">Attachment
|
||||||
Available</div>
|
Available</div>
|
||||||
<div
|
<div class="text-xs text-gray-500 font-mono truncate max-w-[200px] md:max-w-md">
|
||||||
class="text-xs text-gray-500 font-mono truncate max-w-[200px] md:max-w-md">
|
|
||||||
{{ getFileName(selectedPost.attachedFile) }}
|
{{ getFileName(selectedPost.attachedFile) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button @click="downloadFile(selectedPost)"
|
<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">
|
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">
|
<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"
|
<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" />
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
@@ -362,8 +402,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 space-y-6">
|
<div class="mt-8 space-y-6">
|
||||||
<div v-if="selectedPost.comments.length === 0"
|
<div v-if="selectedPost.comments.length === 0" class="text-center py-8 text-gray-400 italic">
|
||||||
class="text-center py-8 text-gray-400 italic">
|
|
||||||
Be the first to comment!
|
Be the first to comment!
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -390,27 +429,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<CreatePostModal :is-open="isModalOpen" :api="api" :token="token" @close="closeModal()"
|
<CreatePostModal :is-open="isModalOpen" :post-to-edit="postToEdit" :api="api" :token="token"
|
||||||
@refresh="handleRefresh" />
|
@close="closeModal()" @refresh="handleRefresh" />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup>
|
||||||
import { useAuthStore } from "~/stores/auth-store.js";
|
import { useAuthStore } from "~/stores/auth-store.js";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import CreatePostModal from "~/components/ui/modal/CreatePostModal.vue";
|
import CreatePostModal from "~/components/ui/modal/CreatePostModal.vue";
|
||||||
|
|
||||||
const route = useRoute(); // 2. Access the route object
|
|
||||||
const config = useRuntimeConfig();
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const { user, token } = storeToRefs(authStore);
|
const { user, token } = storeToRefs(authStore);
|
||||||
|
const route = useRoute();
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const notifications = useNotificationStore();
|
||||||
const api = config.public.apiBase;
|
const api = config.public.apiBase;
|
||||||
|
|
||||||
// --- State Management ---
|
|
||||||
const posts = ref([]);
|
const posts = ref([]);
|
||||||
const selectedPost = ref(null);
|
const selectedPost = ref(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -422,25 +461,111 @@ const hasMore = ref(true);
|
|||||||
|
|
||||||
const sortBy = ref('date');
|
const sortBy = ref('date');
|
||||||
const sortDesc = ref(true);
|
const sortDesc = ref(true);
|
||||||
|
const filterType = ref('ALL');
|
||||||
|
|
||||||
const subscribedTags = ref([]);
|
const subscribedTags = ref([]);
|
||||||
const availableTags = ref([]);
|
const availableTags = ref([]);
|
||||||
|
const showCreateInput = ref(false);
|
||||||
|
const newTagName = ref('');
|
||||||
|
const editingTagId = ref(null);
|
||||||
|
const editTagName = ref('');
|
||||||
|
|
||||||
const filterType = ref('ALL');
|
const postToEdit = ref(null);
|
||||||
|
|
||||||
const newCommentContent = ref('');
|
const newCommentContent = ref('');
|
||||||
|
|
||||||
// Form States
|
|
||||||
|
|
||||||
const checkQueryForModal = () => {
|
const checkQueryForModal = () => {
|
||||||
if (route.query.openCreatePostModal === 'true') {
|
if (route.query.openCreatePostModal === 'true') {
|
||||||
isModalOpen.value = true;
|
isModalOpen.value = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleHide = async (post) => {
|
||||||
|
try {
|
||||||
|
const newState = !post.isHidden;
|
||||||
|
|
||||||
|
await $fetch(`${api}/publications/${post.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: `Bearer ${token.value}` },
|
||||||
|
body: { isHidden: newState }
|
||||||
|
});
|
||||||
|
|
||||||
|
post.isHidden = newState;
|
||||||
|
notifications.success(`Post ${newState ? 'hidden' : 'restored'}`);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
notifications.error("Failed to update post visibility");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = (post) => {
|
||||||
|
postToEdit.value = post;
|
||||||
|
isModalOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateTag = async () => {
|
||||||
|
if (!newTagName.value.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch(`${api}/tags`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token.value}` },
|
||||||
|
body: { name: newTagName.value }
|
||||||
|
});
|
||||||
|
newTagName.value = '';
|
||||||
|
showCreateInput.value = false;
|
||||||
|
notifications.success("Tag created successfully");
|
||||||
|
await fetchTags();
|
||||||
|
} catch (err) {
|
||||||
|
notifications.error("Failed to create tag");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEditing = (tag) => {
|
||||||
|
editingTagId.value = tag.id;
|
||||||
|
editTagName.value = tag.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditTag = async (tagId) => {
|
||||||
|
const trimmedName = editTagName.value.trim();
|
||||||
|
if (!trimmedName) {
|
||||||
|
editingTagId.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch(`${api}/tags/${tagId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: `Bearer ${token.value}` },
|
||||||
|
body: { name: trimmedName }
|
||||||
|
});
|
||||||
|
|
||||||
|
const tagIndex = availableTags.value.findIndex(t => t.id === tagId);
|
||||||
|
if (tagIndex !== -1) availableTags.value[tagIndex].name = trimmedName;
|
||||||
|
|
||||||
|
posts.value.forEach(post => {
|
||||||
|
const postTag = post.tags.find(t => t.id === tagId);
|
||||||
|
if (postTag) {
|
||||||
|
postTag.name = trimmedName;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selectedPost.value) {
|
||||||
|
const selectedPostTag = selectedPost.value.tags.find(t => t.id === tagId);
|
||||||
|
if (selectedPostTag) selectedPostTag.name = trimmedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
editingTagId.value = null;
|
||||||
|
notifications.success("Tag edited successfully");
|
||||||
|
} catch (err) {
|
||||||
|
notifications.error("Failed to edit tag");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
isModalOpen.value = false;
|
isModalOpen.value = false;
|
||||||
|
postToEdit.value = null;
|
||||||
router.replace({ query: { ...route.query, openCreatePostModal: undefined } });
|
router.replace({ query: { ...route.query, openCreatePostModal: undefined } });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -475,7 +600,6 @@ const fetchTags = async () => {
|
|||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const toggleTagSubscription = async (tag) => {
|
const toggleTagSubscription = async (tag) => {
|
||||||
const index = subscribedTags.value.indexOf(tag.id);
|
const index = subscribedTags.value.indexOf(tag.id);
|
||||||
const isSubscribing = index === -1;
|
const isSubscribing = index === -1;
|
||||||
@@ -502,13 +626,15 @@ const toggleTagSubscription = async (tag) => {
|
|||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
isModalOpen.value = false;
|
isModalOpen.value = false;
|
||||||
|
posts.value = [];
|
||||||
|
page.value = 1;
|
||||||
|
hasMore.value = true;
|
||||||
loadPosts();
|
loadPosts();
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const submitComment = () => {
|
const submitComment = () => {
|
||||||
if (!newCommentContent.value.trim()) return;
|
if (!newCommentContent.value.trim()) return;
|
||||||
|
|
||||||
@@ -521,7 +647,6 @@ const submitComment = () => {
|
|||||||
|
|
||||||
selectedPost.value.comments.unshift(comment);
|
selectedPost.value.comments.unshift(comment);
|
||||||
newCommentContent.value = '';
|
newCommentContent.value = '';
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadPosts = async () => {
|
const loadPosts = async () => {
|
||||||
@@ -536,7 +661,8 @@ const loadPosts = async () => {
|
|||||||
page: page.value,
|
page: page.value,
|
||||||
sort: sortBy.value,
|
sort: sortBy.value,
|
||||||
order: sortDesc.value ? 'desc' : 'asc',
|
order: sortDesc.value ? 'desc' : 'asc',
|
||||||
type: filterType.value !== 'ALL' ? filterType.value : undefined
|
type: filterType.value !== 'ALL' ? filterType.value : undefined,
|
||||||
|
search: searchQuery.value ? searchQuery.value : ''
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -596,7 +722,7 @@ const votePost = async (targetPost, direction) => {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Vote failed", error);
|
console.error("Vote failed", error);
|
||||||
alert("Failed to submit vote.");
|
notifications.error("Failed to submit vote.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -619,28 +745,9 @@ const downloadFile = async (post) => {
|
|||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
|
|
||||||
// Opção B: Se o endpoint for PRIVADO (@Authenticated), tens de fazer fetch com o token:
|
|
||||||
/*
|
|
||||||
const response = await fetch(downloadUrl, {
|
|
||||||
headers: { Authorization: `Bearer ${token.value}` }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Download failed');
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = filename;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
*/
|
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Download error:", e);
|
console.error("Download error:", e);
|
||||||
alert("Could not download file.");
|
notifications.error("Could not download file.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -658,9 +765,9 @@ const filteredPosts = computed(() => {
|
|||||||
if (sortBy.value === 'votes') {
|
if (sortBy.value === 'votes') {
|
||||||
return (b.rating - a.rating) * modifier;
|
return (b.rating - a.rating) * modifier;
|
||||||
} else if (sortBy.value === 'comments') {
|
} else if (sortBy.value === 'comments') {
|
||||||
return (b.comment_count - a.comment_count) * modifier;
|
return (b.commentCount - a.commentCount) * modifier;
|
||||||
} else {
|
} else {
|
||||||
return (new Date(b.publication_date).getTime() - new Date(a.publication_date).getTime()) * modifier;
|
return (new Date(b.publicationDate).getTime() - new Date(a.publicationDate).getTime()) * modifier;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -672,11 +779,19 @@ watch(posts, () => {
|
|||||||
}, { deep: true });
|
}, { deep: true });
|
||||||
|
|
||||||
watch([sortBy, sortDesc, filterType], () => {
|
watch([sortBy, sortDesc, filterType], () => {
|
||||||
|
resetAndLoad();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(searchQuery, () => {
|
||||||
|
resetAndLoad();
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetAndLoad() {
|
||||||
posts.value = [];
|
posts.value = [];
|
||||||
page.value = 1;
|
page.value = 1;
|
||||||
hasMore.value = true;
|
hasMore.value = true;
|
||||||
loadPosts();
|
loadPosts();
|
||||||
});
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
checkQueryForModal();
|
checkQueryForModal();
|
||||||
|
|||||||
+120
-244
@@ -2,6 +2,9 @@
|
|||||||
<div
|
<div
|
||||||
class="min-h-screen bg-slate-50 dark:bg-slate-950 font-sans text-slate-900 dark:text-slate-200 selection:bg-blue-100 dark:selection:bg-blue-900 pb-12 transition-colors duration-300">
|
class="min-h-screen bg-slate-50 dark:bg-slate-950 font-sans text-slate-900 dark:text-slate-200 selection:bg-blue-100 dark:selection:bg-blue-900 pb-12 transition-colors duration-300">
|
||||||
|
|
||||||
|
<CreatePostModal :is-open="isModalOpen" :post-to-edit="postToEdit" :api="api" :token="token" @close="closeModal"
|
||||||
|
@refresh="fetchProfileData" />
|
||||||
|
|
||||||
<Transition enter-active-class="transition duration-200 ease-out" enter-from-class="opacity-0"
|
<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"
|
enter-to-class="opacity-100" leave-active-class="transition duration-150 ease-in"
|
||||||
leave-from-class="opacity-100" leave-to-class="opacity-0">
|
leave-from-class="opacity-100" leave-to-class="opacity-0">
|
||||||
@@ -20,7 +23,6 @@
|
|||||||
class="w-full max-w-4xl bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border dark:border-gray-800"
|
class="w-full max-w-4xl bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border dark:border-gray-800"
|
||||||
@click.stop>
|
@click.stop>
|
||||||
|
|
||||||
<!-- Close Button Header -->
|
|
||||||
<div
|
<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">
|
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
|
<button
|
||||||
@@ -29,8 +31,9 @@
|
|||||||
← BACK TO FEED
|
← BACK TO FEED
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button @click="selectedPost = null"
|
<button
|
||||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
|
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">
|
<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"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
d="M6 18L18 6M6 6l12 12" />
|
d="M6 18L18 6M6 6l12 12" />
|
||||||
@@ -38,7 +41,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Post Content - Same as main page -->
|
|
||||||
<article class="overflow-hidden">
|
<article class="overflow-hidden">
|
||||||
<div
|
<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">
|
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">
|
||||||
@@ -136,10 +138,9 @@
|
|||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div
|
<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">
|
class="flex items-center bg-slate-100 dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-1.5 shadow-sm">
|
||||||
<button @click.stop="votePost(selectedPost, 1)"
|
<button class="p-2 rounded-lg transition-all active:scale-90"
|
||||||
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'"
|
: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">
|
title="Upvote" @click.stop="votePost(selectedPost, 1)">
|
||||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24"
|
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24"
|
||||||
stroke="currentColor" stroke-width="2.5">
|
stroke="currentColor" stroke-width="2.5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
@@ -155,10 +156,9 @@
|
|||||||
{{ selectedPost.rating }}
|
{{ selectedPost.rating }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button @click.stop="votePost(selectedPost, -1)"
|
<button class="p-2 rounded-lg transition-all active:scale-90"
|
||||||
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'"
|
: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">
|
title="Downvote" @click.stop="votePost(selectedPost, -1)">
|
||||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24"
|
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24"
|
||||||
stroke="currentColor" stroke-width="2.5">
|
stroke="currentColor" stroke-width="2.5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
@@ -192,8 +192,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button @click.stop="downloadFile(selectedPost)"
|
<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">
|
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"
|
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24"
|
||||||
stroke="currentColor">
|
stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
@@ -318,14 +319,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Polytechnic of Leiria</span>
|
<span>Polytechnic of Leiria</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3 text-sm text-slate-600 dark:text-slate-400">
|
|
||||||
<svg class="w-4 h-4 text-slate-400 dark:text-slate-500 shrink-0" fill="none"
|
|
||||||
viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
<span>Joined {{ formattedDate }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -345,7 +338,7 @@
|
|||||||
}}</div>
|
}}</div>
|
||||||
<div
|
<div
|
||||||
class="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-1">
|
class="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-1">
|
||||||
Papers
|
Publications
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -387,46 +380,12 @@
|
|||||||
Recent Publications
|
Recent Publications
|
||||||
<span
|
<span
|
||||||
class="bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-xs font-bold px-2 py-0.5 rounded-full">
|
class="bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-xs font-bold px-2 py-0.5 rounded-full">
|
||||||
{{ userProfile.publications.length }} Papers
|
{{ userProfile.publications.length }} Publications
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div
|
|
||||||
class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 gap-4">
|
|
||||||
<div class="flex flex-wrap items-center gap-2 w-full sm:w-auto">
|
|
||||||
<div class="relative flex-1 sm:flex-none">
|
|
||||||
<input v-model="searchQuery" type="text" placeholder="Search posts..."
|
|
||||||
class="w-full sm:w-48 bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-lg pl-3 pr-3 py-2 text-xs font-bold outline-none focus:ring-2 focus:ring-blue-500">
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<select v-model="sortBy"
|
|
||||||
class="bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-l-lg px-3 py-2 text-xs font-bold uppercase outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer border-r-0">
|
|
||||||
<option value="date">Date</option>
|
|
||||||
<option value="votes">Rating</option>
|
|
||||||
<option value="comments">Comments</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
class="bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-r-lg px-3 py-2 text-xs font-bold hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
|
||||||
:title="sortDesc ? 'Descending' : 'Ascending'"
|
|
||||||
@click="sortDesc = !sortDesc">
|
|
||||||
<span class="inline-block transition-transform duration-200"
|
|
||||||
:class="{ 'rotate-180': !sortDesc }">
|
|
||||||
▼
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<select v-model="filterType"
|
|
||||||
class="bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-lg px-3 py-2 text-xs font-bold uppercase outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer">
|
|
||||||
<option value="ALL">All Types</option>
|
|
||||||
<option value="CONFERENCE_PAPER">Conference Paper</option>
|
|
||||||
<option value="RESEARCH_POST">Research Post</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="userProfile.publications.length === 0 && !loading"
|
<div v-if="userProfile.publications.length === 0 && !loading"
|
||||||
class="relative overflow-hidden rounded-2xl border-2 border-dashed border-slate-300 dark:border-slate-700 bg-slate-50 dark:bg-slate-900/50 p-12 text-center hover:border-blue-400 dark:hover:border-blue-500 transition-all duration-300 group">
|
class="relative overflow-hidden rounded-2xl border-2 border-dashed border-slate-300 dark:border-slate-700 bg-slate-50 dark:bg-slate-900/50 p-12 text-center hover:border-blue-400 dark:hover:border-blue-500 transition-all duration-300 group">
|
||||||
|
|
||||||
@@ -449,21 +408,25 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-xl shadow-lg shadow-blue-500/20 hover:shadow-blue-500/30 transform active:scale-95 transition-all duration-200 cursor-pointer"
|
class="inline-flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-xl shadow-lg shadow-blue-500/20 hover:shadow-blue-500/30 transform active:scale-95 transition-all duration-200 cursor-pointer"
|
||||||
@click="isModalOpen = true">
|
@click="goToCreatePost()">
|
||||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
d="M12 4v16m8-8H4" />
|
d="M12 4v16m8-8H4" />
|
||||||
</svg>
|
</svg>
|
||||||
Submit your first post
|
Submit your first publication
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-for="post in userProfile.publications" :key="post.id"
|
<div v-for="post in userProfile.publications" :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.isHidden ? 'opacity-60 bg-gray-50' : 'bg-white']"
|
||||||
@click="fetchPostDetail(post.id)">
|
@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 gap-4">
|
||||||
<div
|
<div
|
||||||
@@ -482,26 +445,14 @@
|
|||||||
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400">
|
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400">
|
||||||
{{ post.type.replace('_', ' ') }}
|
{{ post.type.replace('_', ' ') }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs text-gray-500">{{ post.publishedDate }}</span>
|
<span class="text-xs text-gray-500">{{ new
|
||||||
</div>
|
Date(post.publicationDate).toLocaleDateString('en-US', {
|
||||||
<h2 class="text-lg font-bold text-gray-800 dark:text-white mb-2">{{
|
year:
|
||||||
post.title }}</h2>
|
'numeric', month: 'long', day: 'numeric'
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">By {{
|
}) }}</span>
|
||||||
post.authors.join(', ') }}</p>
|
<button v-if="authStore.user.id == post.submitter.id"
|
||||||
<div
|
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"
|
||||||
class="flex items-center gap-4 text-xs font-bold text-gray-500 uppercase">
|
title="Edit" @click.stop="openEditModal(post)">
|
||||||
<span>💬 {{ post.comment_count }} Comments</span>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<span v-for="tag in post.tags" :key="tag.id"
|
|
||||||
class="text-blue-600 dark:text-blue-400">#{{ tag.name }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-end gap-2 group-hover/row:opacity-100 transition-opacity duration-200">
|
|
||||||
<button
|
|
||||||
class="p-2.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-all transform hover:scale-110 active:scale-95"
|
|
||||||
title="Edit" @click.stop="openEditModal(user)">
|
|
||||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24"
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24"
|
||||||
stroke="currentColor">
|
stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
@@ -510,45 +461,39 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="p-2.5 text-slate-400 hover:text-amber-600 hover:bg-white-50 dark:hover:bg-white-900/20 rounded-lg transition-all transform hover:scale-110 active:scale-95"
|
v-if="authStore.user.id == post.submitter.id || authStore.user.role != 'COLLABORATOR'"
|
||||||
title="Hide" @click.stop="confirmHide(post)">
|
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'"
|
||||||
|
@click.stop="toggleHide(post)">
|
||||||
<svg width="16px" height="16px" viewBox="0 0 16 16"
|
<svg width="16px" height="16px" viewBox="0 0 16 16"
|
||||||
xmlns="http://www.w3.org/2000/svg" fill="currentColor"
|
xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||||
class="bi bi-eye">
|
|
||||||
<path
|
<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" />
|
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
|
<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" />
|
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"
|
||||||
|
stroke="currentColor" stroke-width="1.5"
|
||||||
|
stroke-linecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<h2 class="text-lg font-bold text-gray-800 dark:text-white mb-2">{{
|
||||||
</div>
|
post.title }}</h2>
|
||||||
</div>
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">By {{
|
||||||
|
post.authors.join(', ') }}</p>
|
||||||
<td class="p-6 pr-8 text-right">
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-end gap-2 opacity-0 group-hover/row:opacity-100 transition-opacity duration-200">
|
class="flex items-center gap-4 text-xs font-bold text-gray-500 uppercase">
|
||||||
<button
|
<span>💬 {{ post.commentCount }} Comments</span>
|
||||||
class="p-2.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-all transform hover:scale-110 active:scale-95"
|
<div class="flex gap-2">
|
||||||
title="Edit" @click.stop="openEditModal(user)">
|
<span v-for="tag in post.tags" :key="tag.id"
|
||||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
class="text-blue-600 dark:text-blue-400">#{{ tag.name }}</span>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
</div>
|
||||||
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" />
|
</div>
|
||||||
</svg>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
<button
|
</div>
|
||||||
class="p-2.5 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-all transform hover:scale-110 active:scale-95"
|
|
||||||
title="Delete" @click.stop="confirmHide(user)">
|
|
||||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="activeTab === 'activity'" key="activity" class="space-y-4">
|
<div v-else-if="activeTab === 'activity'" key="activity" class="space-y-4">
|
||||||
@@ -561,7 +506,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class="bg-white dark:bg-slate-900 rounded-xl shadow-sm border border-slate-200 dark:border-slate-800 overflow-hidden">
|
class="bg-white dark:bg-slate-900 rounded-xl shadow-sm border border-slate-200 dark:border-slate-800 overflow-hidden">
|
||||||
<div v-for="(item, index) in activityLog" :key="item.id"
|
<div v-for="item in activityLog" :key="item.id"
|
||||||
class="p-4 flex gap-4 border-b border-slate-100 dark:border-slate-800 last:border-0 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
class="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 class="flex flex-col items-center min-w-[80px] text-center pt-1">
|
<div class="flex flex-col items-center min-w-[80px] text-center pt-1">
|
||||||
@@ -604,7 +549,6 @@
|
|||||||
|
|
||||||
<div v-else-if="activeTab === 'settings'" key="settings"
|
<div v-else-if="activeTab === 'settings'" key="settings"
|
||||||
class="bg-white dark:bg-slate-900 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 transition-colors">
|
class="bg-white dark:bg-slate-900 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 transition-colors">
|
||||||
<!-- Header -->
|
|
||||||
<div class="px-6 py-5 border-b border-slate-200 dark:border-slate-800">
|
<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">
|
<h2 class="text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
Edit Profile
|
Edit Profile
|
||||||
@@ -620,13 +564,13 @@
|
|||||||
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||||
Name
|
Name
|
||||||
</label>
|
</label>
|
||||||
<input id="name" type="text" v-model="newUsername"
|
<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
|
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
|
border border-slate-300 dark:border-slate-700
|
||||||
rounded-xl text-slate-900 dark:text-white
|
rounded-xl text-slate-900 dark:text-white
|
||||||
placeholder-slate-400
|
placeholder-slate-400
|
||||||
focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||||
outline-none transition-all" />
|
outline-none transition-all">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -634,33 +578,34 @@
|
|||||||
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||||
Email address
|
Email address
|
||||||
</label>
|
</label>
|
||||||
<input id="email" type="email" v-model="newEmail" placeholder="[email protected]"
|
<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
|
class="w-full px-4 py-2.5 bg-white dark:bg-slate-800
|
||||||
border border-slate-300 dark:border-slate-700
|
border border-slate-300 dark:border-slate-700
|
||||||
rounded-xl text-slate-900 dark:text-white
|
rounded-xl text-slate-900 dark:text-white
|
||||||
placeholder-slate-400
|
placeholder-slate-400
|
||||||
focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||||
outline-none transition-all" />
|
outline-none transition-all">
|
||||||
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
<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.
|
Dont worry, Your email is going to be sold to russian hackers.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a @click="openResetPassword" class="text-sm text-red dark:text-red-600 hover:underline">
|
<a class="text-sm text-red dark:text-red-600 hover:underline"
|
||||||
|
@click="openResetPassword">
|
||||||
Reset Password
|
Reset Password
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer / Actions -->
|
|
||||||
<div class="px-6 py-4 bg-slate-50 dark:bg-slate-800/50
|
<div class="px-6 py-4 bg-slate-50 dark:bg-slate-800/50
|
||||||
border-t border-slate-200 dark:border-slate-800
|
border-t border-slate-200 dark:border-slate-800
|
||||||
flex justify-end">
|
flex justify-end">
|
||||||
<button @click="updateProfile" class="inline-flex items-center gap-2
|
|
||||||
|
<button class="inline-flex items-center gap-2
|
||||||
bg-blue-600 hover:bg-blue-700
|
bg-blue-600 hover:bg-blue-700
|
||||||
text-white font-semibold
|
text-white font-semibold
|
||||||
px-6 py-2.5 rounded-xl
|
px-6 py-2.5 rounded-xl
|
||||||
shadow-sm transition-all
|
shadow-sm transition-all
|
||||||
active:scale-95">
|
active:scale-95" @click="updateProfile">
|
||||||
Save changes
|
Save changes
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -672,69 +617,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useAuthStore } from "~/stores/auth-store.js";
|
import { useAuthStore } from "~/stores/auth-store.js";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
|
import CreatePostModal from '~/components/ui/modal/CreatePostModal.vue';
|
||||||
|
|
||||||
// --- Config & Store ---
|
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
const api = config.public.apiBase;
|
const api = config.public.apiBase;
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const { token } = storeToRefs(authStore);
|
const { token } = storeToRefs(authStore);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const notifications = useNotificationStore();
|
||||||
|
|
||||||
// --- Reactive Variables ---
|
const isModalOpen = ref(false);
|
||||||
const availableTags = ref<Tag[]>([]); // For tag select viewBox
|
const postToEdit = ref(null);
|
||||||
const searchQuery = ref('');
|
|
||||||
const sortBy = ref('date'); // 'date' | 'votes' | 'comments'
|
|
||||||
const sortDesc = ref(true);
|
|
||||||
const filterType = ref('ALL'); // 'ALL' | 'CONFERENCE_PAPER' | 'RESEARCH_POST'
|
|
||||||
const page = ref(1);
|
|
||||||
|
|
||||||
// --- Interfaces ---
|
|
||||||
const selectedPost = ref(null);
|
const selectedPost = ref(null);
|
||||||
const newCommentContent = ref('');
|
const newCommentContent = ref('');
|
||||||
|
|
||||||
const newUsername = ref('');
|
const newUsername = ref('');
|
||||||
const newEmail = ref('');
|
const newEmail = ref('');
|
||||||
|
|
||||||
interface Tag {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Matches your UI requirements
|
|
||||||
interface UIPublication {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
abstract: string;
|
|
||||||
publishedDate: string;
|
|
||||||
publishedYear: string;
|
|
||||||
citations: number;
|
|
||||||
field: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// --- State ---
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const activeTab = ref('overview'); // State to control visible tab
|
const activeTab = ref('overview');
|
||||||
const activityLog = ref<ActivityItem[]>([]); // Store history data
|
const activityLog = ref([]);
|
||||||
|
|
||||||
const userProfile = ref({
|
const userProfile = ref({
|
||||||
id: 0,
|
id: 0,
|
||||||
@@ -743,22 +651,49 @@ const userProfile = ref({
|
|||||||
role: '',
|
role: '',
|
||||||
is_active: false,
|
is_active: false,
|
||||||
avatarUrl: '',
|
avatarUrl: '',
|
||||||
publications: [] as any[]
|
publications: []
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- API Actions ---
|
|
||||||
const getFileName = (fullPath: string) => {
|
const toggleHide = async (post) => {
|
||||||
|
try {
|
||||||
|
const newState = !post.isHidden;
|
||||||
|
|
||||||
|
await $fetch(`${api}/publications/${post.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: `Bearer ${token.value}` },
|
||||||
|
body: { isHidden: newState }
|
||||||
|
});
|
||||||
|
|
||||||
|
post.isHidden = 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;
|
if (!fullPath) return null;
|
||||||
return fullPath.split(/[/\\]/).pop();
|
return fullPath.split(/[/\\]/).pop();
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadFile = async (post: any) => {
|
const downloadFile = async (post) => {
|
||||||
if (!post.attachedFile) return;
|
if (!post.attachedFile) return;
|
||||||
const filename = getFileName(post.attachedFile);
|
const filename = getFileName(post.attachedFile);
|
||||||
const downloadUrl = `${api}/publications/download/${filename}`; // Verifica se este endpoint existe no teu backend
|
const downloadUrl = `${api}/publications/download/${filename}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Método simples para download (se o endpoint for público ou lidar com cookies)
|
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = downloadUrl;
|
link.href = downloadUrl;
|
||||||
link.setAttribute('download', filename || 'download');
|
link.setAttribute('download', filename || 'download');
|
||||||
@@ -770,21 +705,7 @@ const downloadFile = async (post: any) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch Tags (EP23) - To populate the select box
|
const fetchPostDetail = async (id) => {
|
||||||
async function fetchTags() {
|
|
||||||
if (!token.value) return;
|
|
||||||
try {
|
|
||||||
const data = await $fetch<Tag[]>(`${api}/tags`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: { Authorization: `Bearer ${token.value}` }
|
|
||||||
});
|
|
||||||
availableTags.value = data || [];
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to fetch tags", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchPostDetail = async (id: number) => {
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const postDetail = await $fetch(`${api}/publications/${id}`, {
|
const postDetail = await $fetch(`${api}/publications/${id}`, {
|
||||||
@@ -802,27 +723,15 @@ const fetchPostDetail = async (id: number) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Computed ---
|
|
||||||
const totalCitations = computed(() =>
|
const totalCitations = computed(() =>
|
||||||
userProfile.value.publications.reduce((sum, pub) => sum + (pub.citations || 0), 0)
|
userProfile.value.publications.reduce((sum, pub) => sum + (pub.citations || 0), 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
const formattedDate = computed(() => {
|
|
||||||
return new Date(userProfile.value.joinDate).toLocaleDateString('en-US', {
|
|
||||||
month: 'long',
|
|
||||||
year: 'numeric'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- API Actions ---
|
|
||||||
|
|
||||||
// 1. Fetch Activity Log (EP06)
|
|
||||||
async function fetchActivityHistory() {
|
async function fetchActivityHistory() {
|
||||||
if (!token.value) return;
|
if (!token.value) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// GET /api/v1/me/history
|
const data = await $fetch(`${api}/me/history`, {
|
||||||
const data = await $fetch<ActivityItem[]>(`${api}/me/history`, {
|
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { Authorization: `Bearer ${token.value}` }
|
headers: { Authorization: `Bearer ${token.value}` }
|
||||||
});
|
});
|
||||||
@@ -832,7 +741,6 @@ async function fetchActivityHistory() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fetch Main Profile Data (EP03 & EP12)
|
|
||||||
async function fetchProfileData() {
|
async function fetchProfileData() {
|
||||||
if (!token.value) return;
|
if (!token.value) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -845,12 +753,6 @@ async function fetchProfileData() {
|
|||||||
const publicationsData = await $fetch(`${api}/publications/me`, {
|
const publicationsData = await $fetch(`${api}/publications/me`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { Authorization: `Bearer ${token.value}` },
|
headers: { Authorization: `Bearer ${token.value}` },
|
||||||
params: {
|
|
||||||
page: page.value,
|
|
||||||
sort: sortBy.value,
|
|
||||||
order: sortDesc.value ? 'desc' : 'asc',
|
|
||||||
type: filterType.value !== 'ALL' ? filterType.value : undefined
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
userProfile.value = {
|
userProfile.value = {
|
||||||
@@ -863,16 +765,14 @@ async function fetchProfileData() {
|
|||||||
avatarUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(userData.name)}&background=0D8ABC&color=fff`
|
avatarUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(userData.name)}&background=0D8ABC&color=fff`
|
||||||
};
|
};
|
||||||
|
|
||||||
userProfile.value.publications = (publicationsData as any[]).map(pub => {
|
userProfile.value.publications = (publicationsData).map(pub => {
|
||||||
const pubDate = new Date(pub.publication_date);
|
const pubDate = new Date(pub.publicationDate);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...pub, // Manter todos os campos originais da API
|
...pub,
|
||||||
|
|
||||||
publishedDate: pubDate.toLocaleDateString(),
|
publishedDate: pubDate.toLocaleDateString(),
|
||||||
publishedYear: pubDate.getFullYear().toString(),
|
publishedYear: pubDate.getFullYear().toString(),
|
||||||
citations: pub.rating || 0,
|
citations: pub.rating || 0,
|
||||||
|
|
||||||
authors: pub.authors || [],
|
authors: pub.authors || [],
|
||||||
tags: pub.tags || [],
|
tags: pub.tags || [],
|
||||||
comment_count: pub.comment_count || 0,
|
comment_count: pub.comment_count || 0,
|
||||||
@@ -914,33 +814,18 @@ const votePost = async (targetPost, direction) => {
|
|||||||
selectedPost.value.my_vote = response.my_vote;
|
selectedPost.value.my_vote = response.my_vote;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notifications.success("Voted successfully");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Vote failed", error);
|
console.error("Vote failed", error);
|
||||||
alert("Failed to submit vote.");
|
notifications.error("Failed to voted");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmHide = async (post) => {
|
const goToCreatePost = () => {
|
||||||
if (!post) return;
|
router.push({
|
||||||
|
path: '/',
|
||||||
const confirmed = confirm(`Are you sure you want to hide the post titled "${post.title}"?`);
|
query: { openCreatePostModal: 'true' }
|
||||||
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await $fetch(`${api}/publications/${post.id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { Authorization: `Bearer ${token.value}` }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
userProfile.value.publications = userProfile.value.publications.filter(p => p.id !== post.id);
|
|
||||||
|
|
||||||
alert("Post deleted successfully.");
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Delete failed", error);
|
|
||||||
alert("Failed to delete the post.");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateProfile = async () => {
|
const updateProfile = async () => {
|
||||||
@@ -953,7 +838,7 @@ const updateProfile = async () => {
|
|||||||
const email = newEmail.value.trim();
|
const email = newEmail.value.trim();
|
||||||
|
|
||||||
if (name.length === 0 || email.length === 0) {
|
if (name.length === 0 || email.length === 0) {
|
||||||
alert("Name and email cannot be empty.");
|
notifications.error("Name and email cannot be empty");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -965,35 +850,26 @@ const updateProfile = async () => {
|
|||||||
email: email
|
email: email
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
alert("Profile updated successfully. Please log in again.");
|
notifications.success("Profile updated successfully");
|
||||||
authStore.logout();
|
authStore.logout();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Profile update failed", error);
|
console.error("Profile update failed", error);
|
||||||
alert("Failed to update profile.");
|
notifications.error("Failed to update profile");
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchProfileData();
|
fetchProfileData();
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Redirects ---
|
|
||||||
const goToCreatePost = () => {
|
|
||||||
console.log("Navigating to create post modal...");
|
|
||||||
router.push({
|
|
||||||
path: '/',
|
|
||||||
query: { openCreatePostModal: 'true' }
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const openResetPassword = () => {
|
const openResetPassword = () => {
|
||||||
router.push({ path: '/profile/update-password' });
|
router.push({ path: '/profile/update-password' });
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Lifecycle ---
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchProfileData();
|
fetchProfileData();
|
||||||
fetchActivityHistory(); // Fetch history on mount
|
fetchActivityHistory();
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Reference in New Issue
Block a user