mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-17 18:21:10 +10:00
Merge branch 'AdenMGB-develop' into develop
This commit is contained in:
72
components/DeleteNewsModal.vue
Normal file
72
components/DeleteNewsModal.vue
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<template>
|
||||||
|
<ModalTemplate :modelValue="!!article">
|
||||||
|
<template #default>
|
||||||
|
<div>
|
||||||
|
<DialogTitle
|
||||||
|
as="h3"
|
||||||
|
class="text-lg font-bold font-display text-zinc-100"
|
||||||
|
>
|
||||||
|
Delete Article
|
||||||
|
</DialogTitle>
|
||||||
|
<p class="mt-1 text-sm text-zinc-400">
|
||||||
|
Are you sure you want to delete "{{ article?.title }}"?
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm font-bold text-red-500">
|
||||||
|
This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #buttons>
|
||||||
|
<LoadingButton
|
||||||
|
:loading="deleteLoading"
|
||||||
|
@click="() => deleteArticle()"
|
||||||
|
class="bg-red-600 text-white hover:bg-red-500"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</LoadingButton>
|
||||||
|
<button
|
||||||
|
@click="() => (article = undefined)"
|
||||||
|
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</ModalTemplate>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { DialogTitle } from "@headlessui/vue";
|
||||||
|
|
||||||
|
interface Article {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const article = defineModel<Article | undefined>();
|
||||||
|
const deleteLoading = ref(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const news = useNews();
|
||||||
|
|
||||||
|
async function deleteArticle() {
|
||||||
|
try {
|
||||||
|
if (!article.value) return;
|
||||||
|
|
||||||
|
deleteLoading.value = true;
|
||||||
|
await news.remove(article.value.id);
|
||||||
|
|
||||||
|
article.value = undefined;
|
||||||
|
await router.push('/news');
|
||||||
|
} catch (e: any) {
|
||||||
|
createModal(
|
||||||
|
ModalType.Notification,
|
||||||
|
{
|
||||||
|
title: "Failed to delete article",
|
||||||
|
description: `Drop couldn't delete this article: ${e?.statusMessage}`,
|
||||||
|
},
|
||||||
|
(_, c) => c()
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
deleteLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
346
components/NewsArticleCreate.vue
Normal file
346
components/NewsArticleCreate.vue
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full">
|
||||||
|
<!-- Create article button - only show for admin users -->
|
||||||
|
<button
|
||||||
|
v-if="user?.admin"
|
||||||
|
@click="isCreateExpanded = !isCreateExpanded"
|
||||||
|
class="inline-flex items-center gap-x-2 px-4 py-2 rounded-lg bg-blue-600 text-white font-semibold font-display shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-blue-500/25 hover:shadow-lg active:scale-95"
|
||||||
|
>
|
||||||
|
<PlusIcon
|
||||||
|
class="h-5 w-5 transition-transform duration-200"
|
||||||
|
:class="{ 'rotate-90': isCreateExpanded }"
|
||||||
|
/>
|
||||||
|
<span>New Article</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition duration-200 ease-out"
|
||||||
|
enter-from-class="transform -translate-y-4 opacity-0"
|
||||||
|
enter-to-class="transform translate-y-0 opacity-100"
|
||||||
|
leave-active-class="transition duration-200 ease-in"
|
||||||
|
leave-from-class="transform translate-y-0 opacity-100"
|
||||||
|
leave-to-class="transform -translate-y-4 opacity-0"
|
||||||
|
>
|
||||||
|
<div v-if="isCreateExpanded" class="mt-6 p-6 rounded-lg bg-zinc-900/50 border border-zinc-800 w-full">
|
||||||
|
<h3 class="text-lg font-semibold text-zinc-100 mb-4">Create New Article</h3>
|
||||||
|
<form @submit.prevent="createArticle" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="title" class="block text-sm font-medium text-zinc-400">Title</label>
|
||||||
|
<input
|
||||||
|
id="title"
|
||||||
|
v-model="newArticle.title"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
class="mt-1 block w-full rounded-md bg-zinc-900 border-zinc-700 text-zinc-100 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="excerpt" class="block text-sm font-medium text-zinc-400">Excerpt</label>
|
||||||
|
<input
|
||||||
|
id="excerpt"
|
||||||
|
v-model="newArticle.excerpt"
|
||||||
|
type="text"
|
||||||
|
class="mt-1 block w-full rounded-md bg-zinc-900 border-zinc-700 text-zinc-100 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="content" class="block text-sm font-medium text-zinc-400">Content (Markdown)</label>
|
||||||
|
<div class="mt-1 flex flex-col gap-4">
|
||||||
|
<!-- Markdown shortcuts -->
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="shortcut in markdownShortcuts"
|
||||||
|
:key="shortcut.label"
|
||||||
|
type="button"
|
||||||
|
@click="applyMarkdown(shortcut)"
|
||||||
|
class="px-2 py-1 text-sm rounded bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
{{ shortcut.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4 h-[400px]">
|
||||||
|
<!-- Editor -->
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-sm text-zinc-500 mb-2">Editor</span>
|
||||||
|
<textarea
|
||||||
|
id="content"
|
||||||
|
v-model="newArticle.content"
|
||||||
|
ref="contentEditor"
|
||||||
|
@keydown="handleContentKeydown"
|
||||||
|
class="flex-1 rounded-md bg-zinc-900 border-zinc-700 text-zinc-100 shadow-sm focus:border-primary-500 focus:ring-primary-500 font-mono resize-none"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview -->
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-sm text-zinc-500 mb-2">Preview</span>
|
||||||
|
<div class="flex-1 p-4 rounded-md bg-zinc-900 border border-zinc-700 overflow-y-auto">
|
||||||
|
<div
|
||||||
|
class="prose prose-invert prose-sm h-full overflow-y-auto"
|
||||||
|
v-html="markdownPreview"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-zinc-500">
|
||||||
|
Use the shortcuts above or write Markdown directly. Supports **bold**, *italic*, [links](url), and more.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="image" class="block text-sm font-medium text-zinc-400">Image URL (optional)</label>
|
||||||
|
<input
|
||||||
|
id="image"
|
||||||
|
v-model="newArticle.image"
|
||||||
|
type="url"
|
||||||
|
class="mt-1 block w-full rounded-md bg-zinc-900 border-zinc-700 text-zinc-100 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-zinc-400 mb-2">Tags</label>
|
||||||
|
<div class="flex flex-wrap gap-2 mb-2">
|
||||||
|
<span
|
||||||
|
v-for="tag in newArticle.tags"
|
||||||
|
:key="tag"
|
||||||
|
class="inline-flex items-center gap-x-1 px-2 py-1 rounded-full text-xs font-medium bg-blue-600/80 text-white"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="removeTag(tag)"
|
||||||
|
class="text-white hover:text-white/80"
|
||||||
|
>
|
||||||
|
<XMarkIcon class="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-x-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="newTagInput"
|
||||||
|
@keydown.enter.prevent="addTag"
|
||||||
|
placeholder="Add a tag..."
|
||||||
|
class="mt-1 block w-full rounded-md bg-zinc-900 border-zinc-700 text-zinc-100 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="addTag"
|
||||||
|
class="mt-1 px-3 py-2 rounded-md bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 rounded-md bg-blue-600 text-white font-semibold font-display shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-blue-500/25 hover:shadow-lg active:scale-95 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
>
|
||||||
|
{{ isSubmitting ? 'Creating...' : 'Create Article' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { PlusIcon, XMarkIcon } from "@heroicons/vue/24/solid";
|
||||||
|
import { micromark } from 'micromark';
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'refresh': []
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const user = useUser();
|
||||||
|
const news = useNews();
|
||||||
|
const isCreateExpanded = ref(false);
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
const newTagInput = ref('');
|
||||||
|
|
||||||
|
const newArticle = ref({
|
||||||
|
title: '',
|
||||||
|
excerpt: '',
|
||||||
|
content: '',
|
||||||
|
image: '',
|
||||||
|
tags: [] as string[]
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentEditor = ref<HTMLTextAreaElement>();
|
||||||
|
|
||||||
|
const markdownShortcuts = [
|
||||||
|
{ label: 'Bold', prefix: '**', suffix: '**', placeholder: 'bold text' },
|
||||||
|
{ label: 'Italic', prefix: '_', suffix: '_', placeholder: 'italic text' },
|
||||||
|
{ label: 'Link', prefix: '[', suffix: '](url)', placeholder: 'link text' },
|
||||||
|
{ label: 'Code', prefix: '`', suffix: '`', placeholder: 'code' },
|
||||||
|
{ label: 'List Item', prefix: '- ', suffix: '', placeholder: 'list item' },
|
||||||
|
{ label: 'Heading', prefix: '## ', suffix: '', placeholder: 'heading' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleContentKeydown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const textarea = contentEditor.value;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const text = textarea.value;
|
||||||
|
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
|
||||||
|
const currentLine = text.slice(lineStart, start);
|
||||||
|
|
||||||
|
// Check if the current line starts with a list marker
|
||||||
|
const listMatch = currentLine.match(/^(\s*)([-*+]|\d+\.)\s/);
|
||||||
|
let insertion = '\n';
|
||||||
|
|
||||||
|
if (listMatch) {
|
||||||
|
// If the line is empty except for the list marker, end the list
|
||||||
|
if (currentLine.trim() === listMatch[0].trim()) {
|
||||||
|
const removeLength = currentLine.length;
|
||||||
|
newArticle.value.content =
|
||||||
|
text.slice(0, lineStart) +
|
||||||
|
text.slice(lineStart + removeLength);
|
||||||
|
|
||||||
|
// Move cursor to new position after removing the list marker
|
||||||
|
nextTick(() => {
|
||||||
|
textarea.selectionStart = textarea.selectionEnd = lineStart;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Otherwise, continue the list
|
||||||
|
insertion = '\n' + listMatch[1] + listMatch[2] + ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
newArticle.value.content =
|
||||||
|
text.slice(0, start) +
|
||||||
|
insertion +
|
||||||
|
text.slice(start);
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
textarea.selectionStart = textarea.selectionEnd =
|
||||||
|
start + insertion.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTag = () => {
|
||||||
|
const tag = newTagInput.value.trim();
|
||||||
|
if (tag && !newArticle.value.tags.includes(tag)) {
|
||||||
|
newArticle.value.tags.push(tag);
|
||||||
|
newTagInput.value = ''; // Clear the input
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTag = (tagToRemove: string) => {
|
||||||
|
newArticle.value.tags = newArticle.value.tags.filter(tag => tag !== tagToRemove);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyMarkdown = (shortcut: typeof markdownShortcuts[0]) => {
|
||||||
|
const textarea = contentEditor.value;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const text = textarea.value;
|
||||||
|
|
||||||
|
const selectedText = text.substring(start, end);
|
||||||
|
const replacement = selectedText || shortcut.placeholder;
|
||||||
|
|
||||||
|
const newText =
|
||||||
|
text.substring(0, start) +
|
||||||
|
shortcut.prefix +
|
||||||
|
replacement +
|
||||||
|
shortcut.suffix +
|
||||||
|
text.substring(end);
|
||||||
|
|
||||||
|
newArticle.value.content = newText;
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
textarea.focus();
|
||||||
|
const newStart = start + shortcut.prefix.length;
|
||||||
|
const newEnd = newStart + replacement.length;
|
||||||
|
textarea.setSelectionRange(newStart, newEnd);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createArticle = async () => {
|
||||||
|
if (!user.value?.id) {
|
||||||
|
console.error('User not authenticated');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
await news.create({
|
||||||
|
...newArticle.value,
|
||||||
|
authorId: user.value.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
newArticle.value = {
|
||||||
|
title: '',
|
||||||
|
excerpt: '',
|
||||||
|
content: '',
|
||||||
|
image: '',
|
||||||
|
tags: []
|
||||||
|
};
|
||||||
|
|
||||||
|
emit('refresh');
|
||||||
|
|
||||||
|
isCreateExpanded.value = false;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create article:', error);
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const markdownPreview = computed(() => {
|
||||||
|
return micromark(newArticle.value.content);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
.prose {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose a {
|
||||||
|
color: #60a5fa;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose img {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose code {
|
||||||
|
background: #27272a;
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose pre {
|
||||||
|
background: #18181b;
|
||||||
|
padding: 1em;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
193
components/NewsDirectory.vue
Normal file
193
components/NewsDirectory.vue
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex grow flex-col gap-y-5 overflow-y-auto bg-zinc-900 px-6 py-6 ring-1 ring-white/10">
|
||||||
|
<!-- Search and filters -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label for="search" class="sr-only">Search articles</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
|
<MagnifyingGlassIcon class="h-5 w-5 text-zinc-400" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="search"
|
||||||
|
type="text"
|
||||||
|
v-model="searchQuery"
|
||||||
|
class="block w-full rounded-md border-0 bg-zinc-800 py-2.5 pl-10 pr-3 text-zinc-100 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-500 sm:text-sm sm:leading-6"
|
||||||
|
placeholder="Search articles..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-2">
|
||||||
|
<label for="date" class="block text-sm font-medium text-zinc-400 mb-2">Date</label>
|
||||||
|
<select
|
||||||
|
id="date"
|
||||||
|
v-model="dateFilter"
|
||||||
|
class="mt-1 block w-full rounded-md border-0 bg-zinc-800 py-2 pl-3 pr-10 text-zinc-100 focus:ring-2 focus:ring-inset focus:ring-blue-500 sm:text-sm sm:leading-6"
|
||||||
|
>
|
||||||
|
<option value="all">All time</option>
|
||||||
|
<option value="today">Today</option>
|
||||||
|
<option value="week">This week</option>
|
||||||
|
<option value="month">This month</option>
|
||||||
|
<option value="year">This year</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-zinc-400 mb-2">Tags</label>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="tag in availableTags"
|
||||||
|
:key="tag"
|
||||||
|
@click="toggleTag(tag)"
|
||||||
|
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors duration-200"
|
||||||
|
:class="[
|
||||||
|
selectedTags.includes(tag)
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-zinc-800 text-zinc-300 hover:bg-zinc-700'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="flex-1 space-y-2">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="article in filteredArticles"
|
||||||
|
:key="article.id"
|
||||||
|
:to="`/news/article/${article.id}`"
|
||||||
|
class="group block rounded-lg hover-lift"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="relative flex flex-col gap-y-2 rounded-lg p-3 transition-all duration-200"
|
||||||
|
:class="[
|
||||||
|
route.params.id === article.id
|
||||||
|
? 'bg-zinc-800'
|
||||||
|
: 'hover:bg-zinc-800/50'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="article.image"
|
||||||
|
class="absolute inset-0 rounded-lg transition-all duration-200 overflow-hidden"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="article.image"
|
||||||
|
class="absolute inset-0 w-full h-full object-cover transition-all duration-200"
|
||||||
|
:class="[
|
||||||
|
'blur-sm group-hover:blur-none scale-105 group-hover:scale-100 duration-500'
|
||||||
|
]"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-gradient-to-b from-zinc-900/80 to-zinc-900/90 group-hover:from-zinc-900/40 group-hover:to-zinc-900/60 transition-all duration-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="relative text-sm font-medium text-zinc-100">{{ article.title }}</h3>
|
||||||
|
<p class="relative mt-1 text-xs text-zinc-400 line-clamp-2" v-html="formatExcerpt(article.excerpt)"></p>
|
||||||
|
<div class="relative mt-2 flex items-center gap-x-2 text-xs text-zinc-500">
|
||||||
|
<time :datetime="article.publishedAt">{{ formatDate(article.publishedAt) }}</time>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { MagnifyingGlassIcon } from '@heroicons/vue/24/solid';
|
||||||
|
import { micromark } from 'micromark';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const searchQuery = ref("");
|
||||||
|
const dateFilter = ref("all");
|
||||||
|
const selectedTags = ref<string[]>([]);
|
||||||
|
const { data: articles, refresh: refreshArticles } = await useNews().getAll();
|
||||||
|
|
||||||
|
defineExpose({ refresh: refreshArticles });
|
||||||
|
|
||||||
|
// Get unique tags from all articles
|
||||||
|
const availableTags = computed(() => {
|
||||||
|
if (!articles.value) return [];
|
||||||
|
const tags = new Set<string>();
|
||||||
|
articles.value.forEach(article => {
|
||||||
|
article.tags.forEach(tag => tags.add(tag));
|
||||||
|
});
|
||||||
|
return Array.from(tags);
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleTag = (tag: string) => {
|
||||||
|
const index = selectedTags.value.indexOf(tag);
|
||||||
|
if (index === -1) {
|
||||||
|
selectedTags.value.push(tag);
|
||||||
|
} else {
|
||||||
|
selectedTags.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: string) => {
|
||||||
|
return new Date(date).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatExcerpt = (excerpt: string) => {
|
||||||
|
// Convert markdown to HTML
|
||||||
|
const html = micromark(excerpt);
|
||||||
|
// Strip HTML tags using regex
|
||||||
|
return html.replace(/<[^>]*>/g, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredArticles = computed(() => {
|
||||||
|
if (!articles.value) return [];
|
||||||
|
|
||||||
|
// filter articles based on search, date, and tags
|
||||||
|
return articles.value.filter((article) => {
|
||||||
|
const matchesSearch =
|
||||||
|
article.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||||
|
article.excerpt.toLowerCase().includes(searchQuery.value.toLowerCase());
|
||||||
|
|
||||||
|
const articleDate = new Date(article.publishedAt);
|
||||||
|
const now = new Date();
|
||||||
|
let matchesDate = true;
|
||||||
|
|
||||||
|
switch (dateFilter.value) {
|
||||||
|
case 'today':
|
||||||
|
matchesDate = articleDate.toDateString() === now.toDateString();
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
const weekAgo = new Date(now.setDate(now.getDate() - 7));
|
||||||
|
matchesDate = articleDate >= weekAgo;
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
matchesDate = articleDate.getMonth() === now.getMonth() &&
|
||||||
|
articleDate.getFullYear() === now.getFullYear();
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
matchesDate = articleDate.getFullYear() === now.getFullYear();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchesTags = selectedTags.value.length === 0 ||
|
||||||
|
selectedTags.value.every(tag => article.tags.includes(tag));
|
||||||
|
|
||||||
|
return matchesSearch && matchesDate && matchesTags;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hover-lift {
|
||||||
|
transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-lift:hover {
|
||||||
|
transform: translateY(-2px) scale(1.02);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
50
composables/useNews.ts
Normal file
50
composables/useNews.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
export const useNews = () => {
|
||||||
|
const getAll = async (options?: {
|
||||||
|
limit?: number;
|
||||||
|
skip?: number;
|
||||||
|
orderBy?: 'asc' | 'desc';
|
||||||
|
tags?: string[];
|
||||||
|
search?: string;
|
||||||
|
}) => {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
|
||||||
|
if (options?.limit) query.set('limit', options.limit.toString());
|
||||||
|
if (options?.skip) query.set('skip', options.skip.toString());
|
||||||
|
if (options?.orderBy) query.set('order', options.orderBy);
|
||||||
|
if (options?.tags?.length) query.set('tags', options.tags.join(','));
|
||||||
|
if (options?.search) query.set('search', options.search);
|
||||||
|
|
||||||
|
return await useFetch(`/api/v1/news?${query.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getById = async (id: string) => {
|
||||||
|
return await useFetch(`/api/v1/news/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const create = async (article: {
|
||||||
|
title: string;
|
||||||
|
excerpt: string;
|
||||||
|
content: string;
|
||||||
|
image?: string;
|
||||||
|
tags: string[];
|
||||||
|
authorId: string;
|
||||||
|
}) => {
|
||||||
|
return await $fetch('/api/v1/news', {
|
||||||
|
method: 'POST',
|
||||||
|
body: article
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (id: string) => {
|
||||||
|
return await $fetch(`/api/v1/news/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getAll,
|
||||||
|
getById,
|
||||||
|
create,
|
||||||
|
remove
|
||||||
|
};
|
||||||
|
};
|
||||||
Submodule drop-base updated: 01fd41c65a...637b4e1e9b
156
pages/news.vue
Normal file
156
pages/news.vue
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col lg:flex-row grow">
|
||||||
|
<TransitionRoot as="template" :show="sidebarOpen">
|
||||||
|
<Dialog class="relative z-50 lg:hidden" @close="sidebarOpen = false">
|
||||||
|
<TransitionChild
|
||||||
|
as="template"
|
||||||
|
enter="transition-opacity ease-linear duration-300"
|
||||||
|
enter-from="opacity-0"
|
||||||
|
enter-to="opacity-100"
|
||||||
|
leave="transition-opacity ease-linear duration-300"
|
||||||
|
leave-from="opacity-100"
|
||||||
|
leave-to="opacity-0"
|
||||||
|
>
|
||||||
|
<div class="fixed inset-0 bg-zinc-900/80" />
|
||||||
|
</TransitionChild>
|
||||||
|
|
||||||
|
<div class="fixed inset-0 flex">
|
||||||
|
<TransitionChild
|
||||||
|
as="template"
|
||||||
|
enter="transition ease-in-out duration-300 transform"
|
||||||
|
enter-from="-translate-x-full"
|
||||||
|
enter-to="translate-x-0"
|
||||||
|
leave="transition ease-in-out duration-300 transform"
|
||||||
|
leave-from="translate-x-0"
|
||||||
|
leave-to="-translate-x-full"
|
||||||
|
>
|
||||||
|
<DialogPanel class="relative mr-16 flex w-full max-w-xs flex-1">
|
||||||
|
<TransitionChild
|
||||||
|
as="template"
|
||||||
|
enter="ease-in-out duration-300"
|
||||||
|
enter-from="opacity-0"
|
||||||
|
enter-to="opacity-100"
|
||||||
|
leave="ease-in-out duration-300"
|
||||||
|
leave-from="opacity-100"
|
||||||
|
leave-to="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute top-0 left-full flex w-16 justify-center pt-5"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="-m-2.5 p-2.5"
|
||||||
|
@click="sidebarOpen = false"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Close sidebar</span>
|
||||||
|
<XMarkIcon class="size-6 text-white" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</TransitionChild>
|
||||||
|
<div class="bg-zinc-900">
|
||||||
|
<NewsDirectory ref="newsDirectory" />
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</TransitionChild>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</TransitionRoot>
|
||||||
|
|
||||||
|
<!-- Static sidebar for desktop -->
|
||||||
|
<div
|
||||||
|
class="hidden lg:block lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col lg:border-r-2 lg:border-zinc-800"
|
||||||
|
>
|
||||||
|
<NewsDirectory ref="newsDirectory" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="block flex items-center gap-x-2 bg-zinc-950 px-2 py-1 shadow-xs sm:px-4 lg:hidden border-b border-zinc-700"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="-m-2.5 p-2.5 text-zinc-400 lg:hidden"
|
||||||
|
@click="sidebarOpen = true"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Open sidebar</span>
|
||||||
|
<Bars3Icon class="size-6" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class="flex-1 text-sm/6 font-semibold uppercase font-display text-zinc-400"
|
||||||
|
>
|
||||||
|
News
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-4 py-10 sm:px-6 lg:px-8 lg:py-6 grow">
|
||||||
|
<NuxtPage />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogPanel,
|
||||||
|
TransitionChild,
|
||||||
|
TransitionRoot,
|
||||||
|
} from "@headlessui/vue";
|
||||||
|
import {
|
||||||
|
Bars3Icon,
|
||||||
|
CalendarIcon,
|
||||||
|
ChartPieIcon,
|
||||||
|
DocumentDuplicateIcon,
|
||||||
|
FolderIcon,
|
||||||
|
HomeIcon,
|
||||||
|
UsersIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
} from "@heroicons/vue/24/outline";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const sidebarOpen = ref(false);
|
||||||
|
|
||||||
|
router.afterEach(() => {
|
||||||
|
sidebarOpen.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: "News",
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar for IE, Edge and Firefox */
|
||||||
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-lift {
|
||||||
|
transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-lift:hover {
|
||||||
|
transform: translateY(-2px) scale(1.02);
|
||||||
|
box-shadow: 0 8px 20px -6px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Springy list animations */
|
||||||
|
.list-enter-active {
|
||||||
|
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-move {
|
||||||
|
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
150
pages/news/article/[id]/index.vue
Normal file
150
pages/news/article/[id]/index.vue
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
<template>
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<!-- Banner header with blurred background -->
|
||||||
|
<div class="relative w-full h-[300px] mb-8 rounded-lg overflow-hidden">
|
||||||
|
<div class="absolute inset-0">
|
||||||
|
<template v-if="article.image">
|
||||||
|
<img
|
||||||
|
:src="article.image"
|
||||||
|
alt=""
|
||||||
|
class="w-full h-full object-cover blur-sm scale-110"
|
||||||
|
/>
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-b from-zinc-950/70 via-zinc-950/60 to-zinc-950/90"></div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<!-- Fallback gradient background when no image -->
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-b from-zinc-800 to-zinc-900"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative h-full flex flex-col justify-end p-8">
|
||||||
|
<div class="flex items-center gap-x-3 mb-6">
|
||||||
|
<NuxtLink
|
||||||
|
to="/news"
|
||||||
|
class="px-2 py-1 rounded bg-zinc-900/80 backdrop-blur-sm transition text-sm/6 font-semibold text-zinc-400 hover:text-zinc-100 inline-flex gap-x-2 items-center duration-200 hover:scale-105"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon class="h-4 w-4" aria-hidden="true" />
|
||||||
|
Back to News
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="user?.admin"
|
||||||
|
@click="() => currentlyDeleting = article"
|
||||||
|
class="px-2 py-1 rounded bg-red-900/50 backdrop-blur-sm transition text-sm/6 font-semibold text-red-400 hover:text-red-100 inline-flex gap-x-2 items-center duration-200 hover:scale-105"
|
||||||
|
>
|
||||||
|
<TrashIcon class="h-4 w-4" aria-hidden="true" />
|
||||||
|
Delete Article
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-w-[calc(100%-2rem)]">
|
||||||
|
<h1 class="text-4xl font-bold text-white mb-3">{{ article.title }}</h1>
|
||||||
|
<div class="flex flex-col gap-y-3 sm:flex-row sm:items-center sm:gap-x-4 text-zinc-300">
|
||||||
|
<div class="flex items-center gap-x-4">
|
||||||
|
<time :datetime="article.publishedAt">{{ formatDate(article.publishedAt) }}</time>
|
||||||
|
<span class="text-blue-400">{{ article.author.displayName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
v-for="tag in article.tags"
|
||||||
|
:key="tag"
|
||||||
|
class="inline-flex items-center rounded-full bg-zinc-800/80 backdrop-blur-sm px-3 py-1 text-sm font-semibold text-zinc-100"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-4 text-lg text-zinc-300">{{ article.excerpt }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Article content - markdown -->
|
||||||
|
<div
|
||||||
|
class="max-w-[calc(100%-2rem)] mx-auto prose prose-invert prose-lg"
|
||||||
|
v-html="renderedContent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeleteNewsModal v-model="currentlyDeleting" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ArrowLeftIcon } from "@heroicons/vue/20/solid";
|
||||||
|
import { TrashIcon } from "@heroicons/vue/24/outline";
|
||||||
|
import { micromark } from 'micromark';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const { data: article } = await useNews().getById(route.params.id as string);
|
||||||
|
const currentlyDeleting = ref();
|
||||||
|
const user = useUser();
|
||||||
|
|
||||||
|
if (!article.value) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: 'Article not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render markdown content
|
||||||
|
const renderedContent = computed(() => {
|
||||||
|
return micromark(article.value.content);
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatDate = (date: string) => {
|
||||||
|
return new Date(date).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: article.value.title,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.prose {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose a {
|
||||||
|
color: #60a5fa;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose img {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose code {
|
||||||
|
background: #27272a;
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose pre {
|
||||||
|
background: #18181b;
|
||||||
|
padding: 1em;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(30px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
137
pages/news/index.vue
Normal file
137
pages/news/index.vue
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col max-w-4xl mx-auto">
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex flex-col gap-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold font-display text-zinc-100">
|
||||||
|
Latest News
|
||||||
|
</h2>
|
||||||
|
<p class="mt-2 text-zinc-400">
|
||||||
|
Stay up to date with the latest updates and announcements.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NewsArticleCreate @refresh="refreshAll" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Articles list -->
|
||||||
|
<TransitionGroup
|
||||||
|
name="article-list"
|
||||||
|
tag="div"
|
||||||
|
class="space-y-6"
|
||||||
|
>
|
||||||
|
<NuxtLink
|
||||||
|
v-for="article in articles"
|
||||||
|
:key="article.id"
|
||||||
|
:to="`/news/article/${article.id}`"
|
||||||
|
class="block"
|
||||||
|
>
|
||||||
|
<article
|
||||||
|
class="group relative flex flex-col overflow-hidden rounded-lg bg-zinc-800/50 hover:bg-zinc-800 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<div class="relative h-48 w-full overflow-hidden">
|
||||||
|
<img
|
||||||
|
:src="article.image || '/images/default-news-image.jpg'"
|
||||||
|
alt=""
|
||||||
|
class="h-full w-full object-cover object-center transition-all duration-500 group-hover:scale-110 scale-105"
|
||||||
|
/>
|
||||||
|
<div class="absolute top-4 left-4 flex gap-2">
|
||||||
|
<span
|
||||||
|
v-for="tag in article.tags"
|
||||||
|
:key="tag"
|
||||||
|
class="inline-flex items-center rounded-full bg-zinc-900/75 px-3 py-1 text-sm font-semibold text-zinc-100 backdrop-blur"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-1 flex-col justify-between p-6">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-x-2">
|
||||||
|
<time :datetime="article.publishedAt" class="text-sm text-zinc-400">
|
||||||
|
{{ formatDate(article.publishedAt) }}
|
||||||
|
</time>
|
||||||
|
<span class="text-sm text-blue-400">{{ article.author.displayName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<h3 class="text-xl font-semibold text-zinc-100 group-hover:text-primary-400">
|
||||||
|
{{ article.title }}
|
||||||
|
</h3>
|
||||||
|
<p class="mt-3 text-base text-zinc-400">
|
||||||
|
{{ article.excerpt }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</NuxtLink>
|
||||||
|
</TransitionGroup>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="articles.length === 0"
|
||||||
|
class="text-center py-12"
|
||||||
|
>
|
||||||
|
<DocumentIcon class="mx-auto h-12 w-12 text-zinc-400" />
|
||||||
|
<h3 class="mt-2 text-sm font-semibold text-zinc-100">No articles</h3>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">Check back later for updates.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { DocumentIcon } from "@heroicons/vue/24/outline";
|
||||||
|
|
||||||
|
interface Article {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
excerpt: string;
|
||||||
|
content: string;
|
||||||
|
tags: string[];
|
||||||
|
image: string | null;
|
||||||
|
publishedAt: string;
|
||||||
|
author: {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const newsDirectory = ref();
|
||||||
|
const { data: articles, refresh: refreshArticles } = await useNews().getAll();
|
||||||
|
|
||||||
|
const formatDate = (date: string) => {
|
||||||
|
return new Date(date).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: "News",
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshAll = async () => {
|
||||||
|
await refreshArticles();
|
||||||
|
await newsDirectory.value?.refresh();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Article list transitions */
|
||||||
|
.article-list-enter-active,
|
||||||
|
.article-list-leave-active {
|
||||||
|
transition: all 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-list-enter-from,
|
||||||
|
.article-list-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-list-move {
|
||||||
|
transition: transform 0.5s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
16
prisma/migrations/20250128102738_add_news/migration.sql
Normal file
16
prisma/migrations/20250128102738_add_news/migration.sql
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "news" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"excerpt" TEXT NOT NULL,
|
||||||
|
"tags" TEXT[],
|
||||||
|
"image" TEXT,
|
||||||
|
"publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"authorId" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "news_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "news" ADD CONSTRAINT "news_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
52
prisma/migrations/20250309234300_news_articles/migration.sql
Normal file
52
prisma/migrations/20250309234300_news_articles/migration.sql
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the `news` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "news" DROP CONSTRAINT "news_authorId_fkey";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "news";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Tag" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Article" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"image" TEXT,
|
||||||
|
"publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"authorId" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "Article_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "_ArticleToTag" (
|
||||||
|
"A" TEXT NOT NULL,
|
||||||
|
"B" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "_ArticleToTag_AB_pkey" PRIMARY KEY ("A","B")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "_ArticleToTag_B_index" ON "_ArticleToTag"("B");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Article" ADD CONSTRAINT "Article_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_ArticleToTag" ADD CONSTRAINT "_ArticleToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_ArticleToTag" ADD CONSTRAINT "_ArticleToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[name]` on the table `Tag` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Tag_name_key" ON "Tag"("name");
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[token]` on the table `APIToken` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "APIToken_token_key" ON "APIToken"("token");
|
||||||
@ -29,7 +29,7 @@ enum APITokenMode {
|
|||||||
|
|
||||||
model APIToken {
|
model APIToken {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
token String @default(uuid())
|
token String @default(uuid()) @unique
|
||||||
mode APITokenMode
|
mode APITokenMode
|
||||||
name String
|
name String
|
||||||
|
|
||||||
|
|||||||
21
prisma/schema/news.prisma
Normal file
21
prisma/schema/news.prisma
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
model Tag {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String @unique
|
||||||
|
|
||||||
|
articles Article[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Article {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
title String
|
||||||
|
description String
|
||||||
|
content String @db.Text
|
||||||
|
|
||||||
|
tags Tag[]
|
||||||
|
|
||||||
|
image String? // Object ID
|
||||||
|
publishedAt DateTime @default(now())
|
||||||
|
|
||||||
|
author User? @relation(fields: [authorId], references: [id]) // Optional, if no user, it's a system post
|
||||||
|
authorId String?
|
||||||
|
}
|
||||||
@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
previewFeatures = ["prismaSchemaFolder", "omitApi"]
|
previewFeatures = ["prismaSchemaFolder", "omitApi", "fullTextSearchPostgres"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
|
|||||||
@ -11,6 +11,7 @@ model User {
|
|||||||
clients Client[]
|
clients Client[]
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
collections Collection[]
|
collections Collection[]
|
||||||
|
articles Article[]
|
||||||
|
|
||||||
tokens APIToken[]
|
tokens APIToken[]
|
||||||
}
|
}
|
||||||
|
|||||||
23
server/api/v1/admin/news/[id]/index.delete.ts
Normal file
23
server/api/v1/admin/news/[id]/index.delete.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { defineEventHandler, createError } from "h3";
|
||||||
|
import newsManager from "~/server/internal/news";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const userId = await event.context.session.getUserId(event);
|
||||||
|
if (!userId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
message: "Unauthorized",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = event.context.params?.id;
|
||||||
|
if (!id) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "Missing news ID",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await newsManager.delete(id);
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
27
server/api/v1/admin/news/[id]/index.get.ts
Normal file
27
server/api/v1/admin/news/[id]/index.get.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { defineEventHandler, createError } from "h3";
|
||||||
|
import aclManager from "~/server/internal/acls";
|
||||||
|
import newsManager from "~/server/internal/news";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (h3) => {
|
||||||
|
const allowed = await aclManager.allowSystemACL(h3, ["news:read"]);
|
||||||
|
if (!allowed)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = h3.context.params?.id;
|
||||||
|
if (!id)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "Missing news ID",
|
||||||
|
});
|
||||||
|
|
||||||
|
const news = await newsManager.fetchById(id);
|
||||||
|
if (!news)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: "News article not found",
|
||||||
|
});
|
||||||
|
|
||||||
|
return news;
|
||||||
|
});
|
||||||
36
server/api/v1/admin/news/index.get.ts
Normal file
36
server/api/v1/admin/news/index.get.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { defineEventHandler, getQuery } from "h3";
|
||||||
|
import aclManager from "~/server/internal/acls";
|
||||||
|
import newsManager from "~/server/internal/news";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (h3) => {
|
||||||
|
const allowed = await aclManager.allowSystemACL(h3, ["news:read"]);
|
||||||
|
if (!allowed)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
});
|
||||||
|
|
||||||
|
const query = getQuery(h3);
|
||||||
|
|
||||||
|
const orderBy = query.order as "asc" | "desc";
|
||||||
|
if (orderBy) {
|
||||||
|
if (typeof orderBy !== "string" || !["asc", "desc"].includes(orderBy))
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "Invalid order" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = query.tags as string[] | undefined;
|
||||||
|
if (tags) {
|
||||||
|
if (typeof tags !== "object" || !Array.isArray(tags))
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "Invalid tags" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
take: parseInt(query.limit as string),
|
||||||
|
skip: parseInt(query.skip as string),
|
||||||
|
orderBy: orderBy,
|
||||||
|
tags: tags?.map((e) => e.toString()),
|
||||||
|
search: query.search as string,
|
||||||
|
};
|
||||||
|
|
||||||
|
const news = await newsManager.fetch(options);
|
||||||
|
return news;
|
||||||
|
});
|
||||||
23
server/api/v1/admin/news/index.post.ts
Normal file
23
server/api/v1/admin/news/index.post.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { defineEventHandler, createError, readBody } from "h3";
|
||||||
|
import aclManager from "~/server/internal/acls";
|
||||||
|
import newsManager from "~/server/internal/news";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (h3) => {
|
||||||
|
const allowed = await aclManager.allowSystemACL(h3, ["news:create"]);
|
||||||
|
if (!allowed) throw createError({ statusCode: 403 });
|
||||||
|
|
||||||
|
const body = await readBody(h3);
|
||||||
|
|
||||||
|
const article = await newsManager.create({
|
||||||
|
title: body.title,
|
||||||
|
description: body.description,
|
||||||
|
content: body.content,
|
||||||
|
|
||||||
|
tags: body.tags,
|
||||||
|
|
||||||
|
image: body.image,
|
||||||
|
authorId: body.authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return article;
|
||||||
|
});
|
||||||
@ -93,7 +93,7 @@ export default defineEventHandler(async (h3) => {
|
|||||||
profilePictureId,
|
profilePictureId,
|
||||||
async () => jdenticon.toPng(username, 256),
|
async () => jdenticon.toPng(username, 256),
|
||||||
{},
|
{},
|
||||||
[`anonymous:read`, `${userId}:write`]
|
[`internal:read`, `${userId}:write`]
|
||||||
);
|
);
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
30
server/api/v1/news/[id]/index.get.ts
Normal file
30
server/api/v1/news/[id]/index.get.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { defineEventHandler, createError } from "h3";
|
||||||
|
import aclManager from "~/server/internal/acls";
|
||||||
|
import newsManager from "~/server/internal/news";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (h3) => {
|
||||||
|
const userId = await aclManager.getUserIdACL(h3, ["news:read"]);
|
||||||
|
if (!userId)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: "Requires authentication",
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = h3.context.params?.id;
|
||||||
|
if (!id)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "Missing news ID",
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const news = await newsManager.fetchById(id);
|
||||||
|
if (!news)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: "News article not found",
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
return news;
|
||||||
|
});
|
||||||
37
server/api/v1/news/index.get.ts
Normal file
37
server/api/v1/news/index.get.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { defineEventHandler, getQuery } from "h3";
|
||||||
|
import aclManager from "~/server/internal/acls";
|
||||||
|
import newsManager from "~/server/internal/news";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (h3) => {
|
||||||
|
const userId = await aclManager.getUserIdACL(h3, ["news:read"]);
|
||||||
|
if (!userId)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: "Requires authentication",
|
||||||
|
});
|
||||||
|
|
||||||
|
const query = getQuery(h3);
|
||||||
|
|
||||||
|
const orderBy = query.order as "asc" | "desc";
|
||||||
|
if (orderBy) {
|
||||||
|
if (typeof orderBy !== "string" || !["asc", "desc"].includes(orderBy))
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "Invalid order" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = query.tags as string[] | undefined;
|
||||||
|
if (tags) {
|
||||||
|
if (typeof tags !== "object" || !Array.isArray(tags))
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "Invalid tags" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
take: parseInt(query.limit as string),
|
||||||
|
skip: parseInt(query.skip as string),
|
||||||
|
orderBy: orderBy,
|
||||||
|
tags: tags?.map((e) => e.toString()),
|
||||||
|
search: query.search as string,
|
||||||
|
};
|
||||||
|
|
||||||
|
const news = await newsManager.fetch(options);
|
||||||
|
return news;
|
||||||
|
});
|
||||||
@ -30,6 +30,8 @@ export const userACLDescriptions: ObjectFromList<typeof userACLs> = {
|
|||||||
"Remove a game from any collection (excluding library).",
|
"Remove a game from any collection (excluding library).",
|
||||||
"library:add": "Add a game to your library.",
|
"library:add": "Add a game to your library.",
|
||||||
"library:remove": "Remove a game from your library.",
|
"library:remove": "Remove a game from your library.",
|
||||||
|
|
||||||
|
"news:read": "Read the server's news articles.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = {
|
export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = {
|
||||||
@ -55,4 +57,8 @@ export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = {
|
|||||||
"import:game:new": "Import a game.",
|
"import:game:new": "Import a game.",
|
||||||
|
|
||||||
"user:read": "Fetch any user's information.",
|
"user:read": "Fetch any user's information.",
|
||||||
|
|
||||||
|
"news:read": "Read news articles.",
|
||||||
|
"news:create": "Create a new news article.",
|
||||||
|
"news:delete": "Delete a news article."
|
||||||
};
|
};
|
||||||
|
|||||||
@ -25,6 +25,11 @@ export const userACLs = [
|
|||||||
"collections:remove",
|
"collections:remove",
|
||||||
"library:add",
|
"library:add",
|
||||||
"library:remove",
|
"library:remove",
|
||||||
|
<<<<<<< HEAD
|
||||||
|
=======
|
||||||
|
|
||||||
|
"news:read",
|
||||||
|
>>>>>>> AdenMGB-develop
|
||||||
] as const;
|
] as const;
|
||||||
const userACLPrefix = "user:";
|
const userACLPrefix = "user:";
|
||||||
|
|
||||||
@ -51,6 +56,13 @@ export const systemACLs = [
|
|||||||
"import:game:new",
|
"import:game:new",
|
||||||
|
|
||||||
"user:read",
|
"user:read",
|
||||||
|
<<<<<<< HEAD
|
||||||
|
=======
|
||||||
|
|
||||||
|
"news:read",
|
||||||
|
"news:create",
|
||||||
|
"news:delete",
|
||||||
|
>>>>>>> AdenMGB-develop
|
||||||
] as const;
|
] as const;
|
||||||
const systemACLPrefix = "system:";
|
const systemACLPrefix = "system:";
|
||||||
|
|
||||||
|
|||||||
118
server/internal/news/index.ts
Normal file
118
server/internal/news/index.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { triggerAsyncId } from "async_hooks";
|
||||||
|
import prisma from "../db/database";
|
||||||
|
|
||||||
|
class NewsManager {
|
||||||
|
async create(data: {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
authorId: string;
|
||||||
|
image?: string;
|
||||||
|
}) {
|
||||||
|
return await prisma.article.create({
|
||||||
|
data: {
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
content: data.content,
|
||||||
|
|
||||||
|
tags: {
|
||||||
|
connectOrCreate: data.tags.map((e) => ({
|
||||||
|
where: { name: e },
|
||||||
|
create: { name: e },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
|
||||||
|
image: data.image,
|
||||||
|
author: {
|
||||||
|
connect: {
|
||||||
|
id: data.authorId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetch(
|
||||||
|
options: {
|
||||||
|
take?: number;
|
||||||
|
skip?: number;
|
||||||
|
orderBy?: "asc" | "desc";
|
||||||
|
tags?: string[];
|
||||||
|
search?: string;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
return await prisma.article.findMany({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
tags: {
|
||||||
|
some: { OR: options.tags?.map((e) => ({ name: e })) ?? [] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: {
|
||||||
|
search: options.search
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
search: options.search
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
search: options.search
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
take: options?.take || 10,
|
||||||
|
skip: options?.skip || 0,
|
||||||
|
orderBy: {
|
||||||
|
publishedAt: options?.orderBy || "desc",
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchById(id: string) {
|
||||||
|
return await prisma.article.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
data: {
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
excerpt?: string;
|
||||||
|
image?: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return await prisma.article.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string) {
|
||||||
|
return await prisma.article.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new NewsManager();
|
||||||
Reference in New Issue
Block a user