477 lines
19 KiB
Vue
477 lines
19 KiB
Vue
<template>
|
|
<div
|
|
class="min-h-screen bg-gray-100 dark:bg-gray-950 text-gray-900 dark:text-gray-100 transition-colors duration-300">
|
|
<div class="max-w-6xl mx-auto flex gap-6 py-8 px-4">
|
|
|
|
<aside v-if="!selectedPost" class="hidden lg:block w-fit min-w-[160px] py-24">
|
|
<div class="sticky top-8">
|
|
<div class="flex flex-col gap-2">
|
|
<button v-for="tag in availableTags" :key="tag.id" :class="[
|
|
'px-3 py-2 rounded-lg text-xs font-bold transition-all text-left shadow-sm cursor-pointer',
|
|
subscribedTags.includes(tag.id)
|
|
? `${tag.color} border-current ring-1 ring-inset ring-black/5`
|
|
: 'bg-gray-50 dark:bg-gray-800 dark:border-gray-800 border opacity-60 hover:opacity-100'
|
|
]" @click="toggleTagSubscription(tag)">
|
|
<span class="opacity-70 mr-1">{{ subscribedTags.includes(tag.id) ? '✓' : '#' }}</span>
|
|
{{ tag.name }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<main :class="[
|
|
'py-8 flex-1 transition-all duration-500 ease-in-out',
|
|
selectedPost ? 'max-w-3xl mx-auto' : 'max-w-2xl'
|
|
]">
|
|
|
|
<div v-show="!selectedPost" class="space-y-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>
|
|
|
|
<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="isModalOpen = true">
|
|
<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>
|
|
<div v-if="filteredPosts.length === 0 && !loading"
|
|
class="relative overflow-hidden rounded-2xl border-2 border-dashed border-slate-300 dark:border-slate-700 bg-slate-50 dark:bg-slate-900/50 p-12 text-center hover:border-blue-400 dark:hover:border-blue-500 transition-all duration-300 group">
|
|
|
|
<div
|
|
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-48 h-48 bg-blue-500/10 dark:bg-blue-400/10 rounded-full blur-3xl opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
|
|
|
|
<div class="relative z-10 flex flex-col items-center">
|
|
<div
|
|
class="mb-4 p-4 bg-white dark:bg-slate-800 rounded-full shadow-sm ring-1 ring-slate-100 dark:ring-slate-700 group-hover:scale-110 group-hover:ring-blue-200 dark:group-hover:ring-blue-800 transition-all duration-300">
|
|
<svg class="w-8 h-8 text-blue-500 dark:text-blue-400" fill="none" viewBox="0 0 24 24"
|
|
stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
</svg>
|
|
</div>
|
|
|
|
<h3 class="text-lg font-bold text-slate-900 dark:text-white mb-1">
|
|
No publications yet
|
|
</h3>
|
|
|
|
<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"
|
|
@click="isModalOpen = true">
|
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
Submit your first post
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<TransitionGroup v-else appear enter-active-class="transition duration-500 ease-out"
|
|
enter-from-class="translate-y-4 opacity-0" enter-to-class="translate-y-0 opacity-100">
|
|
<div v-for="post in filteredPosts" :key="post.id"
|
|
class="group bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-xl p-5 shadow-sm hover:border-blue-500/50 transition-all cursor-pointer mb-4"
|
|
@click="fetchPostDetail(post.id)">
|
|
<div class="flex gap-4">
|
|
<div
|
|
class="flex flex-col items-center gap-1 text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 p-2 rounded-lg h-fit">
|
|
<button class="hover:text-blue-500 transition-colors text-lg" @click.stop="post.rating++">▲</button>
|
|
<span class="text-xs font-bold">{{ post.rating }}</span>
|
|
<button class="hover:text-red-500 transition-colors text-lg" @click.stop="post.rating--">▼</button>
|
|
</div>
|
|
|
|
<div class="flex-1">
|
|
<div class="flex items-center gap-2 mb-1">
|
|
<span
|
|
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400">
|
|
{{ post.type.replace('_', ' ') }}
|
|
</span>
|
|
<span class="text-xs text-gray-500">{{ post.publication_date }}</span>
|
|
</div>
|
|
<h2 class="text-lg font-bold text-gray-800 dark:text-white mb-2">{{ post.title }}</h2>
|
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">By {{ post.authors.join(', ') }}
|
|
</p>
|
|
<div class="flex items-center gap-4 text-xs font-bold text-gray-500 uppercase">
|
|
<span>💬 {{ post.comment_count }} Comments</span>
|
|
<div class="flex gap-2">
|
|
<span v-for="tag in post.tags" :key="tag.id" class="text-blue-600 dark:text-blue-400">#{{ tag.name
|
|
}}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TransitionGroup>
|
|
|
|
<div v-if="loading && filteredPosts.length === 0" class="space-y-4">
|
|
<div v-for="i in 6" :key="i"
|
|
class="bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-xl p-5 animate-pulse">
|
|
<div class="flex gap-4">
|
|
<div class="w-10 h-16 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
|
<div class="flex-1 space-y-3">
|
|
<div class="h-3 w-24 bg-gray-100 dark:bg-gray-800 rounded" />
|
|
<div class="h-5 w-3/4 bg-gray-100 dark:bg-gray-800 rounded" />
|
|
<div class="h-3 w-full bg-gray-100 dark:bg-gray-800 rounded" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div ref="loadMoreTrigger" class="h-20 w-full flex flex-col items-center justify-center gap-2">
|
|
<div v-if="loading"
|
|
class="animate-spin h-6 w-6 border-2 border-blue-600 border-t-transparent rounded-full" />
|
|
</div>
|
|
|
|
</div>
|
|
<div v-if="selectedPost" class="animate-fade-in-up">
|
|
<button
|
|
class="mb-6 flex items-center gap-2 text-sm font-bold text-blue-600 dark:text-blue-400 hover:underline"
|
|
@click="selectedPost = null">
|
|
← BACK TO FEED
|
|
</button>
|
|
|
|
<article class="bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-xl p-8 shadow-lg">
|
|
<header class="mb-6">
|
|
<h1 class="text-3xl font-extrabold text-gray-900 dark:text-white mb-4 leading-tight">{{ selectedPost.title
|
|
}}</h1>
|
|
<div class="flex items-center gap-3 text-sm">
|
|
<span class="bg-blue-600 text-white px-3 py-1 rounded-md font-bold text-xs uppercase">{{
|
|
selectedPost.type
|
|
}}</span>
|
|
<span class="text-gray-500 font-medium">By <span class="text-blue-500">{{ selectedPost.submitter.name
|
|
}}</span></span>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="prose dark:prose-invert max-w-none mb-8">
|
|
<h4 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-2">Abstract</h4>
|
|
<p class="text-gray-700 dark:text-gray-300 leading-relaxed text-lg">{{ selectedPost.abstract }}</p>
|
|
</div>
|
|
|
|
<hr class="border-gray-100 dark:border-gray-800 mb-8">
|
|
|
|
<section class="mb-10">
|
|
<h3 class="text-xl font-bold mb-4">Comments ({{ selectedPost.comments.length }})</h3>
|
|
<div class="space-y-3">
|
|
<textarea v-model="newCommentContent" rows="3"
|
|
class="w-full px-4 py-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm"
|
|
placeholder="What are your thoughts?" />
|
|
<div class="flex justify-end">
|
|
<button :disabled="!newCommentContent.trim()"
|
|
class="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-bold px-6 py-2 rounded-lg shadow transition-all active:scale-95"
|
|
@click="submitComment">
|
|
COMMENT
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-8 space-y-6">
|
|
<div v-for="comment in selectedPost.comments" :key="comment.id" class="flex gap-4">
|
|
<div class="w-0.5 bg-gray-200 dark:bg-gray-800 rounded-full ml-2" />
|
|
<div class="flex-1">
|
|
<div class="flex items-center gap-2 mb-1">
|
|
<span class="font-bold text-sm text-blue-600 dark:text-blue-400">{{ comment.author.name }}</span>
|
|
<span class="text-[10px] text-gray-500 font-bold uppercase">{{ new
|
|
Date(comment.created_at).toLocaleDateString() }}</span>
|
|
</div>
|
|
<p class="text-gray-700 dark:text-gray-300 text-sm leading-relaxed">
|
|
{{ comment.content }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</article>
|
|
</div>
|
|
<CreatePostModal :is-open="isModalOpen" :api="api" :token="token" @close="closeModal()"
|
|
@refresh="handleRefresh" />
|
|
</main>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
import { useAuthStore } from "~/stores/auth-store.js";
|
|
import { storeToRefs } from "pinia";
|
|
import { useRoute } from 'vue-router';
|
|
import CreatePostModal from "~/components/ui/modal/CreatePostModal.vue";
|
|
|
|
const route = useRoute(); // 2. Access the route object
|
|
const config = useRuntimeConfig();
|
|
const authStore = useAuthStore();
|
|
const { user, token } = storeToRefs(authStore);
|
|
const api = config.public.apiBase;
|
|
|
|
// --- State Management ---
|
|
const posts = ref([]);
|
|
const selectedPost = ref(null);
|
|
const loading = ref(false);
|
|
const loadMoreTrigger = ref(null);
|
|
const isModalOpen = ref(false);
|
|
const searchQuery = ref('');
|
|
const page = ref(1);
|
|
|
|
const sortBy = ref('date');
|
|
const sortDesc = ref(true);
|
|
|
|
const subscribedTags = ref([]);
|
|
const availableTags = ref([]);
|
|
|
|
const filterType = ref('ALL');
|
|
|
|
const newCommentContent = ref('');
|
|
|
|
// Form States
|
|
|
|
const checkQueryForModal = () => {
|
|
if (route.query.openCreatePostModal === 'true') {
|
|
isModalOpen.value = true;
|
|
}
|
|
}
|
|
|
|
const closeModal = () => {
|
|
const router = useRouter();
|
|
isModalOpen.value = false;
|
|
router.replace({ query: { ...route.query, openCreatePostModal: undefined } });
|
|
};
|
|
|
|
const fetchTags = async () => {
|
|
try {
|
|
const allTags = await $fetch(`${api}/tags`, {
|
|
headers: { Authorization: `Bearer ${token.value}` }
|
|
});
|
|
|
|
const colors = [
|
|
"bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400",
|
|
"bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400",
|
|
"bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400",
|
|
"bg-pink-100 dark:bg-pink-900/30 text-pink-700 dark:text-pink-400",
|
|
"bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400"
|
|
];
|
|
|
|
availableTags.value = allTags.map((tag, index) => ({
|
|
...tag,
|
|
color: colors[index % colors.length]
|
|
}));
|
|
|
|
|
|
const myTags = await $fetch(`${api}/tags/me`, {
|
|
headers: { Authorization: `Bearer ${token.value}` }
|
|
});
|
|
|
|
subscribedTags.value = myTags.map(t => t.id);
|
|
} catch (err) {
|
|
console.error("Error fetching tags:", err);
|
|
}
|
|
|
|
};
|
|
|
|
|
|
const toggleTagSubscription = async (tag) => {
|
|
const index = subscribedTags.value.indexOf(tag.id);
|
|
const isSubscribing = index === -1;
|
|
|
|
if (isSubscribing) subscribedTags.value.push(tag.id);
|
|
else subscribedTags.value.splice(index, 1);
|
|
|
|
try {
|
|
const endpoint = isSubscribing
|
|
? `${api}/tags/${tag.id}/subscribe`
|
|
: `${api}/tags/${tag.id}/unsubscribe`;
|
|
|
|
await $fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${token.value}` }
|
|
});
|
|
|
|
} catch (err) {
|
|
if (isSubscribing) subscribedTags.value.pop();
|
|
else subscribedTags.value.push(tag.id);
|
|
|
|
console.error("Sync failed:", err);
|
|
}
|
|
|
|
};
|
|
|
|
|
|
const handleRefresh = () => {
|
|
isModalOpen.value = false;
|
|
loadPosts();
|
|
};
|
|
|
|
|
|
const submitComment = () => {
|
|
if (!newCommentContent.value.trim()) return;
|
|
|
|
const comment = {
|
|
id: Date.now(),
|
|
content: newCommentContent.value,
|
|
author: { name: user.value?.name || "Guest" },
|
|
created_at: new Date().toISOString()
|
|
};
|
|
|
|
selectedPost.value.comments.unshift(comment);
|
|
newCommentContent.value = '';
|
|
|
|
};
|
|
|
|
const loadPosts = async () => {
|
|
if (loading.value || !hasMore.value) return;
|
|
loading.value = true;
|
|
|
|
try {
|
|
const data = await $fetch(`${api}/posts`, {
|
|
method: 'GET',
|
|
headers: { Authorization: `Bearer ${token.value}` },
|
|
params: {
|
|
page: page.value,
|
|
sort: sortBy.value,
|
|
order: sortDesc.value ? 'desc' : 'asc',
|
|
type: filterType.value !== 'ALL' ? filterType.value : undefined
|
|
}
|
|
});
|
|
|
|
if (data && data.length > 0) {
|
|
posts.value = [...posts.value, ...data];
|
|
page.value++;
|
|
} else {
|
|
hasMore.value = false;
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to fetch posts:", err);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
|
|
const fetchPostDetail = async (id) => {
|
|
loading.value = true;
|
|
await new Promise(r => setTimeout(r, 400));
|
|
selectedPost.value = {
|
|
id,
|
|
title: posts.value.find(p => p.id === id)?.title || "A New Technique for Data Science",
|
|
authors: ["Joana B", "External Researcher"],
|
|
publication_date: "2025-11-01",
|
|
type: "CONFERENCE_PAPER",
|
|
abstract: "This breakthrough research explores the boundaries of predictive modeling and machine learning in academic environments. By leveraging sparse data sets, we achieve higher accuracy in student retention predictions.",
|
|
submitter: { id: 2, name: "Joana B" },
|
|
comments: [
|
|
{ id: 101, content: "This technique could revolutionize our approach to Project X!", author: { name: "Joana B" }, created_at: "2025-11-10T10:30:00Z" },
|
|
]
|
|
};
|
|
loading.value = false;
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
};
|
|
|
|
const filteredPosts = computed(() => {
|
|
const result = posts.value.filter(p => {
|
|
const matchesType = filterType.value === 'ALL' || p.type === filterType.value;
|
|
const matchesTags = subscribedTags.value.length === 0 ||
|
|
p.tags.some(t => subscribedTags.value.includes(t.id));
|
|
return matchesType && matchesTags;
|
|
});
|
|
|
|
return result.sort((a, b) => {
|
|
const modifier = sortDesc.value ? 1 : -1;
|
|
|
|
if (sortBy.value === 'votes') {
|
|
return (b.rating - a.rating) * modifier;
|
|
} else if (sortBy.value === 'comments') {
|
|
return (b.comment_count - a.comment_count) * modifier;
|
|
} else {
|
|
return (new Date(b.publication_date).getTime() - new Date(a.publication_date).getTime()) * modifier;
|
|
}
|
|
});
|
|
});
|
|
|
|
watch(posts, () => {
|
|
if (filteredPosts.value.length < 3 && !loading.value && !selectedPost.value) {
|
|
loadPosts();
|
|
}
|
|
}, { deep: true });
|
|
|
|
watch([sortBy, sortDesc, filterType], () => {
|
|
posts.value = [];
|
|
page.value = 1;
|
|
hasMore.value = true;
|
|
loadPosts();
|
|
});
|
|
|
|
onMounted(async () => {
|
|
checkQueryForModal();
|
|
|
|
await Promise.all([
|
|
loadPosts(),
|
|
fetchTags()
|
|
]);
|
|
|
|
const observer = new IntersectionObserver((entries) => {
|
|
if (entries[0].isIntersecting && !loading.value && !selectedPost.value) {
|
|
loadPosts();
|
|
}
|
|
}, {
|
|
rootMargin: '300px',
|
|
threshold: 0.1
|
|
});
|
|
|
|
if (loadMoreTrigger.value) {
|
|
observer.observe(loadMoreTrigger.value);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
@keyframes fadeInUp {
|
|
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(20px);
|
|
}
|
|
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
.animate-fade-in-up {
|
|
animation: fadeInUp 0.5s ease-out forwards;
|
|
}
|
|
|
|
.line-clamp-2 {
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
</style> |