feat: basic toggle post visibility #14

Merged
Edd merged 2 commits from ft/toggle-visibility into develop 2026-01-23 18:29:57 +00:00
4 changed files with 343 additions and 362 deletions
Showing only changes of commit 6eae88f4e9 - Show all commits
+319 -329
View File
@@ -1,330 +1,320 @@
<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 isSubmitting = ref(false);
const notifications = useNotificationStore(); const availableTags = ref([]);
const isSubmitting = ref(false); const errors = reactive({
const availableTags = ref<Tag[]>([]); title: '',
const errors = reactive({ abstract: ''
title: '', });
abstract: ''
}); const uploadForm = reactive({
title: '',
const uploadForm = reactive({ abstract: '',
title: '', type: 'CONFERENCE_PAPER',
abstract: '', newAuthorName: '',
type: 'CONFERENCE_PAPER', authors: [],
newAuthorName: '', selectedTagIds: [],
authors: [] as string[], file: null
selectedTagIds: [] as number[], });
file: null as File | null
}); const validate = () => {
let isValid = true;
// --- Validation Logic --- errors.title = '';
const validate = () => { errors.abstract = '';
let isValid = true;
errors.title = ''; if (!uploadForm.title.trim()) {
errors.abstract = ''; errors.title = 'Title is required';
isValid = false;
if (!uploadForm.title.trim()) { } else if (uploadForm.title.length > 50) {
errors.title = 'Title is required'; errors.title = 'Title cannot exceed 50 characters';
isValid = false; isValid = false;
} else if (uploadForm.title.length > 50) { }
errors.title = 'Title cannot exceed 50 characters';
isValid = false; if (!uploadForm.abstract.trim()) {
} errors.abstract = 'Abstract is required';
isValid = false;
if (!uploadForm.abstract.trim()) { } else if (uploadForm.abstract.length > 2000) {
errors.abstract = 'Abstract is required'; errors.abstract = 'Abstract cannot exceed 2000 characters';
isValid = false; isValid = false;
} else if (uploadForm.abstract.length > 2000) { }
errors.abstract = 'Abstract cannot exceed 2000 characters';
isValid = false; return isValid;
} };
return isValid; async function fetchTags() {
}; if (!props.token) return;
try {
async function fetchTags() { const data = await $fetch(`${props.api}/tags`, {
if (!props.token) return; headers: { Authorization: `Bearer ${props.token}` }
try { });
const data = await $fetch<Tag[]>(`${props.api}/tags`, { availableTags.value = data || [];
headers: { Authorization: `Bearer ${props.token}` } } catch (e) { console.error("Tags fetch failed", e); }
}); }
availableTags.value = data || [];
} catch (e) { console.error("Tags fetch failed", e); } async function submitPublication() {
} if (!validate()) return;
async function submitPublication() { isSubmitting.value = true;
if (!validate()) return; try {
const metadata = {
isSubmitting.value = true; title: uploadForm.title,
try { authors: uploadForm.authors,
const metadata = { type: uploadForm.type,
title: uploadForm.title, abstract: uploadForm.abstract,
authors: uploadForm.authors, tags: uploadForm.selectedTagIds
type: uploadForm.type, };
abstract: uploadForm.abstract,
tags: uploadForm.selectedTagIds const isEditing = !!props.postToEdit;
}; const url = isEditing
? `${props.api}/publications/${props.postToEdit.id}`
const isEditing = !!props.postToEdit; : `${props.api}/publications`;
const url = isEditing
? `${props.api}/publications/${props.postToEdit.id}` const headers = {
: `${props.api}/publications`; Authorization: `Bearer ${props.token}`
};
let requestBody: any;
let headers: Record<string, string> = { const formData = new FormData();
Authorization: `Bearer ${props.token}` if (uploadForm.file) formData.append('file', uploadForm.file);
}; formData.append('data', JSON.stringify(metadata));
if (!isEditing || uploadForm.file) { await $fetch(url, {
const formData = new FormData(); method: isEditing ? 'PATCH' : 'POST',
if (uploadForm.file) formData.append('file', uploadForm.file); headers,
formData.append('data', JSON.stringify(metadata)); body: formData
requestBody = formData; });
}
else { notifications.success(`Publication ${isEditing ? "updated" : "created"}`);
requestBody = metadata; emit('refresh');
} closeModal();
} catch (error) {
await $fetch(url, { console.error("Submission error:", error);
method: isEditing ? 'PATCH' : 'POST', notifications.error("Operation failed");
headers, } finally {
body: requestBody isSubmitting.value = false;
}); }
}
notifications.success(`Publication ${isEditing ? "updated" : "created"}`);
emit('refresh'); function addAuthor() {
closeModal(); if (uploadForm.newAuthorName.trim()) {
} catch (error) { uploadForm.authors.push(uploadForm.newAuthorName.trim());
console.error("Submission error:", error); uploadForm.newAuthorName = '';
notifications.error("Operation failed"); }
} finally { }
isSubmitting.value = false;
} function removeAuthor(index) { uploadForm.authors.splice(index, 1); }
}
function handleFileChange(event) {
function addAuthor() { const input = event.target;
if (uploadForm.newAuthorName.trim()) { if (input.files?.[0]) uploadForm.file = input.files[0];
uploadForm.authors.push(uploadForm.newAuthorName.trim()); }
uploadForm.newAuthorName = '';
} function closeModal() {
} uploadForm.title = '';
uploadForm.abstract = '';
function removeAuthor(index: number) { uploadForm.authors.splice(index, 1); } uploadForm.type = 'CONFERENCE_PAPER';
uploadForm.newAuthorName = '';
function handleFileChange(event: Event) { uploadForm.authors = [];
const input = event.target as HTMLInputElement; uploadForm.selectedTagIds = [];
if (input.files?.[0]) uploadForm.file = input.files[0]; uploadForm.file = null;
} emit('close');
}
function closeModal() {
uploadForm.title = ''; watch(() => props.isOpen, (newVal) => {
uploadForm.abstract = ''; if (newVal && availableTags.value.length === 0) fetchTags();
uploadForm.type = 'CONFERENCE_PAPER'; if (!newVal) {
uploadForm.newAuthorName = ''; errors.title = '';
uploadForm.authors = []; errors.abstract = '';
uploadForm.selectedTagIds = []; }
uploadForm.file = null; });
emit('close');
} watch(() => props.postToEdit, (newPost) => {
if (newPost) {
watch(() => props.isOpen, (newVal) => { uploadForm.title = newPost.title;
if (newVal && availableTags.value.length === 0) fetchTags(); uploadForm.abstract = newPost.abstractText || newPost.abstract;
if (!newVal) { uploadForm.type = newPost.type;
errors.title = ''; uploadForm.authors = [...newPost?.authors];
errors.abstract = ''; uploadForm.selectedTagIds = newPost?.tags.map((t) => t.id);
} }
}); }, { immediate: true });
watch(() => props.postToEdit, (newPost) => { const getTagClass = (id) => {
if (newPost) { const colors = [
uploadForm.title = newPost.title; 'bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-900/30 dark:text-pink-300',
uploadForm.abstract = newPost.abstractText || newPost.abstract; 'bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-900/30 dark:text-purple-300',
uploadForm.type = newPost.type; 'bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-300',
uploadForm.authors = [...newPost?.authors]; 'bg-cyan-100 text-cyan-700 border-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-300',
uploadForm.selectedTagIds = newPost?.tags.map((t: any) => t.id); 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-300',
} 'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-300',
}, { immediate: true }); ];
return colors[id % colors.length];
const getTagClass = (id: number) => { };
const colors = [ </script>
'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', <template>
'bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-300', <Transition enter-active-class="transition duration-200 ease-out" enter-from-class="opacity-0"
'bg-cyan-100 text-cyan-700 border-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-300', enter-to-class="opacity-100" leave-active-class="transition duration-150 ease-in" leave-from-class="opacity-100"
'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-300', leave-to-class="opacity-0">
'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-300', <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" />
return colors[id % colors.length];
}; <div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
</script> <div
class="relative transform overflow-hidden rounded-2xl bg-white dark:bg-slate-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl border border-slate-200 dark:border-slate-800">
<template>
<Transition enter-active-class="transition duration-200 ease-out" enter-from-class="opacity-0" <div
enter-to-class="opacity-100" leave-active-class="transition duration-150 ease-in" leave-from-class="opacity-100" class="bg-slate-50 dark:bg-slate-800/50 px-6 py-4 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
leave-to-class="opacity-0"> <h3 class="text-lg font-bold text-slate-900 dark:text-white">Submit New Publication</h3>
<div v-if="isOpen" class="fixed inset-0 z-50 overflow-y-auto"> <button class="text-slate-400 hover:text-slate-500 dark:hover:text-slate-300"
<div class="fixed inset-0 bg-slate-900/75 backdrop-blur-sm" @click="closeModal" /> @click="closeModal">
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
<div d="M6 18L18 6M6 6l12 12" />
class="relative transform overflow-hidden rounded-2xl bg-white dark:bg-slate-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl border border-slate-200 dark:border-slate-800"> </svg>
</button>
<div </div>
class="bg-slate-50 dark:bg-slate-800/50 px-6 py-4 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
<h3 class="text-lg font-bold text-slate-900 dark:text-white">Submit New Publication</h3> <div class="px-6 py-6 max-h-[80vh] overflow-y-auto space-y-6">
<button class="text-slate-400 hover:text-slate-500 dark:hover:text-slate-300" <div
@click="closeModal"> class="flex justify-center rounded-lg border-2 border-dashed border-slate-300 dark:border-slate-700 px-6 py-8 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <div class="text-center">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <svg class="mx-auto h-12 w-12 text-slate-400" fill="none" viewBox="0 0 24 24"
d="M6 18L18 6M6 6l12 12" /> stroke="currentColor">
</svg> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
</button> d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</div> </svg>
<div
<div class="px-6 py-6 max-h-[80vh] overflow-y-auto space-y-6"> class="mt-4 flex text-sm leading-6 text-slate-600 dark:text-slate-400 justify-center">
<div <label for="file-upload"
class="flex justify-center rounded-lg border-2 border-dashed border-slate-300 dark:border-slate-700 px-6 py-8 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors"> class="relative cursor-pointer rounded-md font-semibold text-blue-600 hover:text-blue-500">
<div class="text-center"> <span>Upload a file</span>
<svg class="mx-auto h-12 w-12 text-slate-400" fill="none" viewBox="0 0 24 24" <input id="file-upload" type="file" class="sr-only" accept=".pdf,.zip"
stroke="currentColor"> @change="handleFileChange">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" </label>
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /> <p class="pl-1">or drag and drop</p>
</svg> </div>
<div <p v-if="uploadForm.file"
class="mt-4 flex text-sm leading-6 text-slate-600 dark:text-slate-400 justify-center"> class="mt-2 text-sm font-bold text-green-600 dark:text-green-400 text-center">
<label for="file-upload" Selected: {{ uploadForm.file.name }}
class="relative cursor-pointer rounded-md font-semibold text-blue-600 hover:text-blue-500"> </p>
<span>Upload a file</span> </div>
<input id="file-upload" type="file" class="sr-only" accept=".pdf,.zip" </div>
@change="handleFileChange">
</label> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<p class="pl-1">or drag and drop</p> <div class="md:col-span-2">
</div> <label
<p v-if="uploadForm.file" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Title</label>
class="mt-2 text-sm font-bold text-green-600 dark:text-green-400 text-center"> <input v-model="uploadForm.title" type="text"
Selected: {{ uploadForm.file.name }} :class="[errors.title ? 'border-red-500' : 'border-slate-300 dark:border-slate-700']"
</p> class="w-full rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm focus:ring-blue-500 sm:text-sm py-2.5 px-3 border"
</div> placeholder="e.g. A New Technique for Data Science">
</div> <p v-if="errors.title" class="mt-1 text-xs text-red-500">{{ errors.title }}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div>
<div class="md:col-span-2"> <label
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Type</label>
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Title</label> <select v-model="uploadForm.type"
<input v-model="uploadForm.title" type="text" class="w-full rounded-lg border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm focus:ring-blue-500 sm:text-sm py-2.5 px-3 border">
:class="[errors.title ? 'border-red-500' : 'border-slate-300 dark:border-slate-700']" <option value="CONFERENCE_PAPER">Conference Paper</option>
class="w-full rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm focus:ring-blue-500 sm:text-sm py-2.5 px-3 border" </select>
placeholder="e.g. A New Technique for Data Science"> </div>
<p v-if="errors.title" class="mt-1 text-xs text-red-500">{{ errors.title }}</p> </div>
</div>
<div> <div>
<label <label 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">Type</label> Abstract ({{ uploadForm.abstract.length }}/2000)
<select v-model="uploadForm.type" </label>
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"> <textarea v-model="uploadForm.abstract" rows="4"
<option value="CONFERENCE_PAPER">Conference Paper</option> :class="[errors.abstract ? 'border-red-500' : 'border-slate-300 dark:border-slate-700']"
</select> class="w-full rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm focus:ring-blue-500 sm:text-sm py-2 px-3 border"
</div> placeholder="Enter a brief summary..." />
</div> <p v-if="errors.abstract" class="mt-1 text-xs text-red-500">{{ errors.abstract }}</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"> <div class="flex flex-col md:flex-row gap-8">
Abstract ({{ uploadForm.abstract.length }}/2000)
</label> <div class="w-full">
<textarea v-model="uploadForm.abstract" rows="4" <label
:class="[errors.abstract ? 'border-red-500' : 'border-slate-300 dark:border-slate-700']" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Authors</label>
class="w-full rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm focus:ring-blue-500 sm:text-sm py-2 px-3 border" <div class="relative flex items-center">
placeholder="Enter a brief summary..." /> <input v-model="uploadForm.newAuthorName" type="text"
<p v-if="errors.abstract" class="mt-1 text-xs text-red-500">{{ errors.abstract }}</p> class="w-full rounded-lg border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm text-sm py-2 pl-3 pr-10 border"
</div> placeholder="Name" @keyup.enter="addAuthor">
<button type="button"
<div class="flex flex-col md:flex-row gap-8"> class="absolute right-2 p-1 text-blue-600 hover:text-blue-700 transition-colors"
@click="addAuthor">
<div class="w-full"> <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"
<label stroke-width="3">
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Authors</label> <path stroke-linecap="round" stroke-linejoin="round"
<div class="relative flex items-center"> d="M12 4.5v15m7.5-7.5h-15" />
<input v-model="uploadForm.newAuthorName" type="text" </svg>
class="w-full rounded-lg border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm text-sm py-2 pl-3 pr-10 border" </button>
placeholder="Name" @keyup.enter="addAuthor"> </div>
<button type="button" <div :class="['flex flex-wrap gap-2', uploadForm.authors.length != 0 ? 'mt-3' : '']">
class="absolute right-2 p-1 text-blue-600 hover:text-blue-700 transition-colors" <span v-for="(author, index) in uploadForm.authors" :key="index"
@click="addAuthor"> 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">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" {{ author }}
stroke-width="3"> <button @click="removeAuthor(index)">
<path stroke-linecap="round" stroke-linejoin="round" <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
d="M12 4.5v15m7.5-7.5h-15" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
</svg> d="M6 18L18 6M6 6l12 12" />
</button> </svg>
</div> </button>
<div :class="['flex flex-wrap gap-2', uploadForm.authors.length != 0 ? 'mt-3' : '']"> </span>
<span v-for="(author, index) in uploadForm.authors" :key="index" </div>
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"> </div>
{{ author }}
<button @click="removeAuthor(index)"> <div class="flex-1 md:w-auto min-w-[300px]">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <label
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Tags</label>
d="M6 18L18 6M6 6l12 12" /> <div
</svg> class="overflow-y-auto border border-slate-200 dark:border-slate-700 rounded-lg p-3 bg-slate-50/50 dark:bg-slate-800/30 flex flex-wrap gap-2 content-start">
</button> <label v-if="availableTags.length == 0"
</span> class="relative rounded-md font-semibold text-blue-600">
</div> <span>No tags</span>
</div> </label>
<button v-for="tag in availableTags" :key="tag.id" type="button" :class="[
<div class="flex-1 md:w-auto min-w-[300px]"> uploadForm.selectedTagIds.includes(tag.id)
<label ? getTagClass(tag.id) + ' border-transparent'
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Tags</label> : 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400'
<div ]" class="px-3 py-1.5 rounded-full border text-xs font-bold uppercase tracking-tight transition-all shadow-sm whitespace-nowrap"
class="overflow-y-auto border border-slate-200 dark:border-slate-700 rounded-lg p-3 bg-slate-50/50 dark:bg-slate-800/30 flex flex-wrap gap-2 content-start"> @click="() => {
<label v-if="availableTags.length == 0" const idx = uploadForm.selectedTagIds.indexOf(tag.id);
class="relative rounded-md font-semibold text-blue-600"> if (idx > -1) uploadForm.selectedTagIds.splice(idx, 1);
<span>No tags</span> else uploadForm.selectedTagIds.push(tag.id);
</label> }">
<button v-for="tag in availableTags" :key="tag.id" type="button" @click="() => { {{ tag.name }}
const idx = uploadForm.selectedTagIds.indexOf(tag.id); </button>
if (idx > -1) uploadForm.selectedTagIds.splice(idx, 1); </div>
else uploadForm.selectedTagIds.push(tag.id); </div>
}" :class="[ </div>
uploadForm.selectedTagIds.includes(tag.id) </div>
? 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' <div
]" class="bg-slate-50 dark:bg-slate-800/50 px-6 py-4 flex flex-row-reverse gap-3 border-t border-slate-100 dark:border-slate-800">
class="px-3 py-1.5 rounded-full border text-xs font-bold uppercase tracking-tight transition-all shadow-sm whitespace-nowrap"> <button :disabled="isSubmitting"
{{ tag.name }} 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"
</button> @click="submitPublication">
</div> {{ isSubmitting ? 'Uploading...' : 'Submit' }}
</div> </button>
</div> <button
</div> class="inline-flex w-full justify-center rounded-lg bg-white dark:bg-slate-800 px-6 py-2.5 text-sm font-bold text-slate-700 dark:text-slate-300 shadow-sm ring-1 ring-inset ring-slate-300 dark:ring-slate-700 hover:bg-slate-50 sm:w-auto transition-all"
@click="closeModal">
<div Cancel
class="bg-slate-50 dark:bg-slate-800/50 px-6 py-4 flex flex-row-reverse gap-3 border-t border-slate-100 dark:border-slate-800"> </button>
<button :disabled="isSubmitting" </div>
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" </div>
@click="submitPublication"> </div>
{{ isSubmitting ? 'Uploading...' : 'Submit' }} </div>
</button> </Transition>
<button
class="inline-flex w-full justify-center rounded-lg bg-white dark:bg-slate-800 px-6 py-2.5 text-sm font-bold text-slate-700 dark:text-slate-300 shadow-sm ring-1 ring-inset ring-slate-300 dark:ring-slate-700 hover:bg-slate-50 sm:w-auto transition-all"
@click="closeModal">
Cancel
</button>
</div>
</div>
</div>
</div>
</Transition>
</template> </template>
+1 -1
View File
@@ -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 -24
View File
@@ -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">
@@ -159,17 +153,9 @@
year: year:
'numeric', month: 'long', day: 'numeric' 'numeric', month: 'long', day: 'numeric'
}) }}</span> }) }}</span>
<button v-if="authStore.user.id == post.submitter.id" <button v-if="authStore.user.role != 'COLLABORATOR'"
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" 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 +163,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 +468,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) {
+13 -8
View File
@@ -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);