feat: basic toggle post visibility #14
@@ -1,20 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup>
|
||||||
import { ref, reactive, watch, computed } from 'vue';
|
import { ref, reactive, watch } from 'vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps({
|
||||||
isOpen: boolean;
|
isOpen: Boolean,
|
||||||
api: string;
|
api: String,
|
||||||
token: string | null;
|
token: String,
|
||||||
postToEdit?: any;
|
postToEdit: Object
|
||||||
}>();
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'refresh']);
|
const emit = defineEmits(['close', 'refresh']);
|
||||||
|
|
||||||
interface Tag { id: number; name: string; }
|
|
||||||
|
|
||||||
const notifications = useNotificationStore();
|
const notifications = useNotificationStore();
|
||||||
const isSubmitting = ref(false);
|
const isSubmitting = ref(false);
|
||||||
const availableTags = ref<Tag[]>([]);
|
const availableTags = ref([]);
|
||||||
const errors = reactive({
|
const errors = reactive({
|
||||||
title: '',
|
title: '',
|
||||||
abstract: ''
|
abstract: ''
|
||||||
@@ -25,12 +23,11 @@ const uploadForm = reactive({
|
|||||||
abstract: '',
|
abstract: '',
|
||||||
type: 'CONFERENCE_PAPER',
|
type: 'CONFERENCE_PAPER',
|
||||||
newAuthorName: '',
|
newAuthorName: '',
|
||||||
authors: [] as string[],
|
authors: [],
|
||||||
selectedTagIds: [] as number[],
|
selectedTagIds: [],
|
||||||
file: null as File | null
|
file: null
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Validation Logic ---
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
let isValid = true;
|
let isValid = true;
|
||||||
errors.title = '';
|
errors.title = '';
|
||||||
@@ -58,7 +55,7 @@ const validate = () => {
|
|||||||
async function fetchTags() {
|
async function fetchTags() {
|
||||||
if (!props.token) return;
|
if (!props.token) return;
|
||||||
try {
|
try {
|
||||||
const data = await $fetch<Tag[]>(`${props.api}/tags`, {
|
const data = await $fetch(`${props.api}/tags`, {
|
||||||
headers: { Authorization: `Bearer ${props.token}` }
|
headers: { Authorization: `Bearer ${props.token}` }
|
||||||
});
|
});
|
||||||
availableTags.value = data || [];
|
availableTags.value = data || [];
|
||||||
@@ -83,25 +80,18 @@ async function submitPublication() {
|
|||||||
? `${props.api}/publications/${props.postToEdit.id}`
|
? `${props.api}/publications/${props.postToEdit.id}`
|
||||||
: `${props.api}/publications`;
|
: `${props.api}/publications`;
|
||||||
|
|
||||||
let requestBody: any;
|
const headers = {
|
||||||
let headers: Record<string, string> = {
|
|
||||||
Authorization: `Bearer ${props.token}`
|
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(url, {
|
await $fetch(url, {
|
||||||
method: isEditing ? 'PATCH' : 'POST',
|
method: isEditing ? 'PATCH' : 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: requestBody
|
body: formData
|
||||||
});
|
});
|
||||||
|
|
||||||
notifications.success(`Publication ${isEditing ? "updated" : "created"}`);
|
notifications.success(`Publication ${isEditing ? "updated" : "created"}`);
|
||||||
@@ -122,10 +112,10 @@ function addAuthor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeAuthor(index: number) { uploadForm.authors.splice(index, 1); }
|
function removeAuthor(index) { uploadForm.authors.splice(index, 1); }
|
||||||
|
|
||||||
function handleFileChange(event: Event) {
|
function handleFileChange(event) {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target;
|
||||||
if (input.files?.[0]) uploadForm.file = input.files[0];
|
if (input.files?.[0]) uploadForm.file = input.files[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,11 +144,11 @@ watch(() => props.postToEdit, (newPost) => {
|
|||||||
uploadForm.abstract = newPost.abstractText || newPost.abstract;
|
uploadForm.abstract = newPost.abstractText || newPost.abstract;
|
||||||
uploadForm.type = newPost.type;
|
uploadForm.type = newPost.type;
|
||||||
uploadForm.authors = [...newPost?.authors];
|
uploadForm.authors = [...newPost?.authors];
|
||||||
uploadForm.selectedTagIds = newPost?.tags.map((t: any) => t.id);
|
uploadForm.selectedTagIds = newPost?.tags.map((t) => t.id);
|
||||||
}
|
}
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
const getTagClass = (id: number) => {
|
const getTagClass = (id) => {
|
||||||
const colors = [
|
const colors = [
|
||||||
'bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-900/30 dark:text-pink-300',
|
'bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-900/30 dark:text-pink-300',
|
||||||
'bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-900/30 dark:text-purple-300',
|
'bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-900/30 dark:text-purple-300',
|
||||||
@@ -175,7 +165,7 @@ const getTagClass = (id: number) => {
|
|||||||
<Transition enter-active-class="transition duration-200 ease-out" enter-from-class="opacity-0"
|
<Transition enter-active-class="transition duration-200 ease-out" enter-from-class="opacity-0"
|
||||||
enter-to-class="opacity-100" leave-active-class="transition duration-150 ease-in" leave-from-class="opacity-100"
|
enter-to-class="opacity-100" leave-active-class="transition duration-150 ease-in" leave-from-class="opacity-100"
|
||||||
leave-to-class="opacity-0">
|
leave-to-class="opacity-0">
|
||||||
<div v-if="isOpen" class="fixed inset-0 z-50 overflow-y-auto">
|
<div v-if="props.isOpen" class="fixed inset-0 z-50 overflow-y-auto">
|
||||||
<div class="fixed inset-0 bg-slate-900/75 backdrop-blur-sm" @click="closeModal" />
|
<div class="fixed inset-0 bg-slate-900/75 backdrop-blur-sm" @click="closeModal" />
|
||||||
|
|
||||||
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||||
@@ -293,16 +283,16 @@ const getTagClass = (id: number) => {
|
|||||||
class="relative rounded-md font-semibold text-blue-600">
|
class="relative rounded-md font-semibold text-blue-600">
|
||||||
<span>No tags</span>
|
<span>No tags</span>
|
||||||
</label>
|
</label>
|
||||||
<button v-for="tag in availableTags" :key="tag.id" type="button" @click="() => {
|
<button v-for="tag in availableTags" :key="tag.id" type="button" :class="[
|
||||||
const idx = uploadForm.selectedTagIds.indexOf(tag.id);
|
|
||||||
if (idx > -1) uploadForm.selectedTagIds.splice(idx, 1);
|
|
||||||
else uploadForm.selectedTagIds.push(tag.id);
|
|
||||||
}" :class="[
|
|
||||||
uploadForm.selectedTagIds.includes(tag.id)
|
uploadForm.selectedTagIds.includes(tag.id)
|
||||||
? getTagClass(tag.id) + ' border-transparent'
|
? getTagClass(tag.id) + ' border-transparent'
|
||||||
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400'
|
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400'
|
||||||
]"
|
]" class="px-3 py-1.5 rounded-full border text-xs font-bold uppercase tracking-tight transition-all shadow-sm whitespace-nowrap"
|
||||||
class="px-3 py-1.5 rounded-full border text-xs font-bold uppercase tracking-tight transition-all shadow-sm whitespace-nowrap">
|
@click="() => {
|
||||||
|
const idx = uploadForm.selectedTagIds.indexOf(tag.id);
|
||||||
|
if (idx > -1) uploadForm.selectedTagIds.splice(idx, 1);
|
||||||
|
else uploadForm.selectedTagIds.push(tag.id);
|
||||||
|
}">
|
||||||
{{ tag.name }}
|
{{ tag.name }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -497,7 +497,7 @@ 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();
|
const notifications = useNotificationStore();
|
||||||
|
|
||||||
const users = ref([]);
|
const users = ref([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|||||||
+10
-16
@@ -127,26 +127,20 @@
|
|||||||
<TransitionGroup v-else appear enter-active-class="transition duration-500 ease-out"
|
<TransitionGroup v-else appear enter-active-class="transition duration-500 ease-out"
|
||||||
enter-from-class="translate-y-4 opacity-0" enter-to-class="translate-y-0 opacity-100">
|
enter-from-class="translate-y-4 opacity-0" enter-to-class="translate-y-0 opacity-100">
|
||||||
<div v-for="post in filteredPosts" :key="post.id"
|
<div v-for="post in filteredPosts" :key="post.id"
|
||||||
:class="['relative group bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-xl p-5 shadow-sm hover:border-blue-500/50 transition-all cursor-pointer mb-4 overflow-hidden', post.isHidden ? 'opacity-60 bg-gray-50' : 'bg-white']"
|
:class="['relative group bg-white dark:bg-gray-900 border dark:border-gray-800 rounded-xl p-5 shadow-sm hover:border-blue-500/50 transition-all cursor-pointer mb-4 overflow-hidden', post.hidden ? 'opacity-60 bg-gray-50' : 'bg-white']"
|
||||||
@click="fetchPostDetail(post.id)">
|
@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' }" @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' }" @click.stop="votePost(post, -1)">▼</button>
|
@click.stop="votePost(post, -1)">▼</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -167,9 +161,9 @@
|
|||||||
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" />
|
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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button v-if="authStore.user.id == post.submitter.id || authStore.user.role != 'COLLABORATOR'"
|
<button v-if="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"
|
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)">
|
:title="post.hidden ? 'Unhide' : 'Hide'" @click.stop="toggleHide(post)">
|
||||||
<svg width="16px" height="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"
|
<svg width="16px" height="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"
|
||||||
fill="currentColor">
|
fill="currentColor">
|
||||||
<path
|
<path
|
||||||
@@ -177,7 +171,7 @@
|
|||||||
<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"
|
<line v-if="post.hidden" x1="3" y1="3" x2="13" y2="13" stroke="currentColor"
|
||||||
stroke-width="1.5" stroke-linecap="round" />
|
stroke-width="1.5" stroke-linecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -482,15 +476,15 @@ const checkQueryForModal = () => {
|
|||||||
|
|
||||||
const toggleHide = async (post) => {
|
const toggleHide = async (post) => {
|
||||||
try {
|
try {
|
||||||
const newState = !post.isHidden;
|
const newState = !post.hidden;
|
||||||
|
|
||||||
await $fetch(`${api}/publications/${post.id}`, {
|
await $fetch(`${api}/publications/${post.id}/hide`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { Authorization: `Bearer ${token.value}` },
|
headers: { Authorization: `Bearer ${token.value}` },
|
||||||
body: { isHidden: newState }
|
body: { is_hidden: newState }
|
||||||
});
|
});
|
||||||
|
|
||||||
post.isHidden = newState;
|
post.hidden = newState;
|
||||||
notifications.success(`Post ${newState ? 'hidden' : 'restored'}`);
|
notifications.success(`Post ${newState ? 'hidden' : 'restored'}`);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -618,7 +618,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, computed, onMounted, watch } 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";
|
||||||
@@ -765,6 +765,11 @@ 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`
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (authStore.user) {
|
||||||
|
authStore.user.name = userData.name;
|
||||||
|
authStore.user.email = userData.email;
|
||||||
|
}
|
||||||
|
|
||||||
userProfile.value.publications = (publicationsData).map(pub => {
|
userProfile.value.publications = (publicationsData).map(pub => {
|
||||||
const pubDate = new Date(pub.publicationDate);
|
const pubDate = new Date(pub.publicationDate);
|
||||||
|
|
||||||
@@ -837,21 +842,21 @@ const updateProfile = async () => {
|
|||||||
const name = newUsername.value.trim();
|
const name = newUsername.value.trim();
|
||||||
const email = newEmail.value.trim();
|
const email = newEmail.value.trim();
|
||||||
|
|
||||||
if (name.length === 0 || email.length === 0) {
|
|
||||||
notifications.error("Name and email cannot be empty");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await $fetch(`${api}/users/me`, {
|
await $fetch(`${api}/users/me`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { Authorization: `Bearer ${token.value}` },
|
headers: { Authorization: `Bearer ${token.value}` },
|
||||||
body: {
|
body: {
|
||||||
name: name,
|
name: name || userProfile.value.name,
|
||||||
email: email
|
email: email || userProfile.value.email
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
notifications.success("Profile updated successfully");
|
notifications.success("Profile updated successfully");
|
||||||
authStore.logout();
|
|
||||||
|
if (authStore.user) {
|
||||||
|
authStore.user.name = name || userProfile.value.name;
|
||||||
|
authStore.user.email = email || userProfile.value.email;
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Profile update failed", error);
|
console.error("Profile update failed", error);
|
||||||
|
|||||||
Reference in New Issue
Block a user