feat: file uploads on news articles

This commit is contained in:
DecDuck
2025-03-11 17:51:46 +11:00
parent 137241fdbb
commit 0f0874c10a
7 changed files with 257 additions and 159 deletions

View File

@ -3,21 +3,21 @@
<!-- Create article button - only show for admin users --> <!-- Create article button - only show for admin users -->
<button <button
v-if="user?.admin" v-if="user?.admin"
@click="isCreateExpanded = !isCreateExpanded" @click="modalOpen = !modalOpen"
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" 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 <PlusIcon
class="h-5 w-5 transition-transform duration-200" class="h-5 w-5 transition-transform duration-200"
:class="{ 'rotate-90': isCreateExpanded }" :class="{ 'rotate-90': modalOpen }"
/> />
<span>New Article</span> <span>New Article</span>
</button> </button>
<ModalTemplate size-class="sm:max-w-[80vw]" v-model="isCreateExpanded"> <ModalTemplate size-class="sm:max-w-[80vw]" v-model="modalOpen">
<h3 class="text-lg font-semibold text-zinc-100 mb-4"> <h3 class="text-lg font-semibold text-zinc-100 mb-4">
Create New Article Create New Article
</h3> </h3>
<form @submit.prevent="createArticle" class="space-y-4"> <form @submit.prevent="() => createArticle()" class="space-y-4">
<div> <div>
<label for="title" class="block text-sm font-medium text-zinc-400" <label for="title" class="block text-sm font-medium text-zinc-400"
>Title</label >Title</label
@ -34,7 +34,7 @@
<div> <div>
<label for="excerpt" class="block text-sm font-medium text-zinc-400" <label for="excerpt" class="block text-sm font-medium text-zinc-400"
>Exercept</label >Short description</label
> >
<input <input
id="excerpt" id="excerpt"
@ -63,7 +63,9 @@
</button> </button>
</div> </div>
<div class="grid grid-cols-2 gap-4 h-[400px]"> <div
class="grid grid-rows-2 sm:grid-cols-2 sm:grid-rows-1 gap-4 h-[400px]"
>
<!-- Editor --> <!-- Editor -->
<div class="flex flex-col"> <div class="flex flex-col">
<span class="text-sm text-zinc-500 mb-2">Editor</span> <span class="text-sm text-zinc-500 mb-2">Editor</span>
@ -98,14 +100,31 @@
</div> </div>
<div> <div>
<label for="image" class="block text-sm font-medium text-zinc-400" <label
>Image URL (optional)</label for="file-upload"
class="group cursor-pointer transition relative block w-full rounded-lg border-2 border-dashed border-zinc-600 p-12 text-center hover:border-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2"
> >
<ArrowUpTrayIcon
class="transition mx-auto h-6 w-6 text-zinc-600 group-hover:text-zinc-700"
stroke="currentColor"
fill="none"
viewBox="0 0 48 48"
aria-hidden="true"
/>
<span
class="transition mt-2 block text-sm font-semibold text-zinc-400 group-hover:text-zinc-500"
>Upload cover image</span
>
<p class="mt-1 text-xs text-zinc-400" v-if="currentFile">
{{ currentFile.name }}
</p>
</label>
<input <input
id="image" accept="image/*"
v-model="newArticle.image" @change="(e) => file = (e.target as any)?.files"
type="url" class="hidden"
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" type="file"
id="file-upload"
/> />
</div> </div>
@ -148,17 +167,30 @@
</div> </div>
<button type="submit" class="hidden" /> <button type="submit" class="hidden" />
<div v-if="error" class="mt-3 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ error }}
</h3>
</div>
</div>
</div>
</form> </form>
<template #buttons> <template #buttons>
<LoadingButton <LoadingButton
:loading="isSubmitting" :loading="loading"
@click="() => createArticle()" @click="() => createArticle()"
class="bg-blue-600 text-white hover:bg-blue-500" class="bg-blue-600 text-white hover:bg-blue-500"
> >
Submit Submit
</LoadingButton> </LoadingButton>
<button <button
@click="() => (isCreateExpanded = !isCreateExpanded)" @click="() => (modalOpen = !modalOpen)"
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" 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 Cancel
@ -169,7 +201,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { PlusIcon, XMarkIcon } from "@heroicons/vue/24/solid"; import {
ArrowUpTrayIcon,
PlusIcon,
XCircleIcon,
XMarkIcon,
} from "@heroicons/vue/24/solid";
import { micromark } from "micromark"; import { micromark } from "micromark";
const emit = defineEmits<{ const emit = defineEmits<{
@ -178,18 +215,27 @@ const emit = defineEmits<{
const user = useUser(); const user = useUser();
const news = useNews(); const news = useNews();
const isCreateExpanded = ref(false);
const isSubmitting = ref(false); const modalOpen = ref(false);
const loading = ref(false);
const newTagInput = ref(""); const newTagInput = ref("");
const newArticle = ref({ const newArticle = ref({
title: "", title: "",
description: "", description: "",
content: "", content: "",
image: "",
tags: [] as string[], tags: [] as string[],
}); });
const markdownPreview = computed(() => {
return micromark(newArticle.value.content);
});
const file = ref<FileList | undefined>();
const currentFile = computed(() => file.value?.item(0));
const error = ref<string | undefined>();
const contentEditor = ref<HTMLTextAreaElement>(); const contentEditor = ref<HTMLTextAreaElement>();
const markdownShortcuts = [ const markdownShortcuts = [
@ -201,7 +247,7 @@ const markdownShortcuts = [
{ label: "Heading", prefix: "## ", suffix: "", placeholder: "heading" }, { label: "Heading", prefix: "## ", suffix: "", placeholder: "heading" },
]; ];
const handleContentKeydown = (e: KeyboardEvent) => { function handleContentKeydown(e: KeyboardEvent) {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
@ -242,23 +288,23 @@ const handleContentKeydown = (e: KeyboardEvent) => {
start + insertion.length; start + insertion.length;
}); });
} }
}; }
const addTag = () => { function addTag() {
const tag = newTagInput.value.trim(); const tag = newTagInput.value.trim();
if (tag && !newArticle.value.tags.includes(tag)) { if (tag && !newArticle.value.tags.includes(tag)) {
newArticle.value.tags.push(tag); newArticle.value.tags.push(tag);
newTagInput.value = ""; // Clear the input newTagInput.value = ""; // Clear the input
} }
}; }
const removeTag = (tagToRemove: string) => { function removeTag(tagToRemove: string) {
newArticle.value.tags = newArticle.value.tags.filter( newArticle.value.tags = newArticle.value.tags.filter(
(tag) => tag !== tagToRemove (tag) => tag !== tagToRemove
); );
}; }
const applyMarkdown = (shortcut: (typeof markdownShortcuts)[0]) => { function applyMarkdown(shortcut: (typeof markdownShortcuts)[0]) {
const textarea = contentEditor.value; const textarea = contentEditor.value;
if (!textarea) return; if (!textarea) return;
@ -284,19 +330,27 @@ const applyMarkdown = (shortcut: (typeof markdownShortcuts)[0]) => {
const newEnd = newStart + replacement.length; const newEnd = newStart + replacement.length;
textarea.setSelectionRange(newStart, newEnd); textarea.setSelectionRange(newStart, newEnd);
}); });
}; }
const createArticle = async () => { async function createArticle() {
if (!user.value?.id) { if (!user.value) return;
console.error("User not authenticated");
return;
}
isSubmitting.value = true; loading.value = true;
try { try {
await news.create({ const formData = new FormData();
...newArticle.value,
authorId: user.value.id, if (currentFile.value) {
formData.append("image", currentFile.value);
}
formData.append("title", newArticle.value.title);
formData.append("description", newArticle.value.description);
formData.append("content", newArticle.value.content);
formData.append("tags", JSON.stringify(newArticle.value.tags));
await $fetch("/api/v1/admin/news", {
method: "POST",
body: formData,
}); });
// Reset form // Reset form
@ -304,23 +358,18 @@ const createArticle = async () => {
title: "", title: "",
description: "", description: "",
content: "", content: "",
image: "",
tags: [], tags: [],
}; };
emit("refresh"); emit("refresh");
isCreateExpanded.value = false; modalOpen.value = false;
} catch (error) { } catch (e) {
console.error("Failed to create article:", error); error.value = (e as any)?.statusMessage ?? "An unknown error occured.";
} finally { } finally {
isSubmitting.value = false; loading.value = false;
} }
}; }
const markdownPreview = computed(() => {
return micromark(newArticle.value.content);
});
</script> </script>
<style scoped> <style scoped>

View File

@ -1,12 +1,19 @@
<template> <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"> <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 --> <!-- Search and filters -->
<div class="space-y-6"> <div class="space-y-6">
<div> <div>
<label for="search" class="sr-only">Search articles</label> <label for="search" class="sr-only">Search articles</label>
<div class="relative"> <div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"> <div
<MagnifyingGlassIcon class="h-5 w-5 text-zinc-400" aria-hidden="true" /> 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> </div>
<input <input
id="search" id="search"
@ -19,7 +26,9 @@
</div> </div>
<div class="pt-2"> <div class="pt-2">
<label for="date" class="block text-sm font-medium text-zinc-400 mb-2">Date</label> <label for="date" class="block text-sm font-medium text-zinc-400 mb-2"
>Date</label
>
<select <select
id="date" id="date"
v-model="dateFilter" v-model="dateFilter"
@ -45,7 +54,7 @@
:class="[ :class="[
selectedTags.includes(tag) selectedTags.includes(tag)
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white'
: 'bg-zinc-800 text-zinc-300 hover:bg-zinc-700' : 'bg-zinc-800 text-zinc-300 hover:bg-zinc-700',
]" ]"
> >
{{ tag }} {{ tag }}
@ -61,35 +70,40 @@
:to="`/news/${article.id}`" :to="`/news/${article.id}`"
class="group block rounded-lg hover-lift" class="group block rounded-lg hover-lift"
> >
<div <div
class="relative flex flex-col gap-y-2 rounded-lg p-3 transition-all duration-200" class="relative flex flex-col gap-y-2 rounded-lg p-3 transition-all duration-200"
:class="[ :class="[
route.params.id === article.id route.params.id === article.id
? 'bg-zinc-800' ? 'bg-zinc-800'
: 'hover:bg-zinc-800/50' : 'hover:bg-zinc-800/50',
]" ]"
> >
<div <div
v-if="article.image" v-if="article.image"
class="absolute inset-0 rounded-lg transition-all duration-200 overflow-hidden" class="absolute inset-0 rounded-lg transition-all duration-200 overflow-hidden"
> >
<img <img
:src="article.image" :src="useObject(article.image)"
class="absolute inset-0 w-full h-full object-cover transition-all duration-200" class="absolute blur-sm inset-0 w-full h-full object-cover transition-all duration-200 group-hover:scale-110"
:class="[
'blur-sm group-hover:blur-none scale-105 group-hover:scale-100 duration-500'
]"
alt=""
/> />
<div <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" class="absolute inset-0 bg-gradient-to-b from-transparent to-zinc-800 transition-all duration-200"
/> />
</div> </div>
<h3 class="relative text-sm font-medium text-zinc-100">{{ article.title }}</h3> <h3 class="relative text-sm font-medium text-zinc-100">
<p class="relative mt-1 text-xs text-zinc-400 line-clamp-2" v-html="formatExcerpt(article.description)"></p> {{ article.title }}
<div class="relative mt-2 flex items-center gap-x-2 text-xs text-zinc-500"> </h3>
<time :datetime="article.publishedAt">{{ formatDate(article.publishedAt) }}</time> <p
class="relative mt-1 text-xs text-zinc-400 line-clamp-2"
v-html="formatExcerpt(article.description)"
></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>
</div> </div>
</NuxtLink> </NuxtLink>
@ -98,9 +112,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'; import { ref, computed } from "vue";
import { MagnifyingGlassIcon } from '@heroicons/vue/24/solid'; import { MagnifyingGlassIcon } from "@heroicons/vue/24/solid";
import { micromark } from 'micromark'; import { micromark } from "micromark";
const route = useRoute(); const route = useRoute();
const searchQuery = ref(""); const searchQuery = ref("");
@ -114,8 +128,8 @@ defineExpose({ refresh: refreshArticles });
const availableTags = computed(() => { const availableTags = computed(() => {
if (!articles.value) return []; if (!articles.value) return [];
const tags = new Set<string>(); const tags = new Set<string>();
articles.value.forEach(article => { articles.value.forEach((article) => {
article.tags.forEach(tag => tags.add(tag.name)); article.tags.forEach((tag) => tags.add(tag.name));
}); });
return Array.from(tags); return Array.from(tags);
}); });
@ -141,42 +155,48 @@ const formatExcerpt = (excerpt: string) => {
// Convert markdown to HTML // Convert markdown to HTML
const html = micromark(excerpt); const html = micromark(excerpt);
// Strip HTML tags using regex // Strip HTML tags using regex
return html.replace(/<[^>]*>/g, ''); return html.replace(/<[^>]*>/g, "");
}; };
const filteredArticles = computed(() => { const filteredArticles = computed(() => {
if (!articles.value) return []; if (!articles.value) return [];
// filter articles based on search, date, and tags // filter articles based on search, date, and tags
return articles.value.filter((article) => { return articles.value.filter((article) => {
const matchesSearch = const matchesSearch =
article.title.toLowerCase().includes(searchQuery.value.toLowerCase()) || article.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
article.description.toLowerCase().includes(searchQuery.value.toLowerCase()); article.description
.toLowerCase()
.includes(searchQuery.value.toLowerCase());
const articleDate = new Date(article.publishedAt); const articleDate = new Date(article.publishedAt);
const now = new Date(); const now = new Date();
let matchesDate = true; let matchesDate = true;
switch (dateFilter.value) { switch (dateFilter.value) {
case 'today': case "today":
matchesDate = articleDate.toDateString() === now.toDateString(); matchesDate = articleDate.toDateString() === now.toDateString();
break; break;
case 'week': case "week":
const weekAgo = new Date(now.setDate(now.getDate() - 7)); const weekAgo = new Date(now.setDate(now.getDate() - 7));
matchesDate = articleDate >= weekAgo; matchesDate = articleDate >= weekAgo;
break; break;
case 'month': case "month":
matchesDate = articleDate.getMonth() === now.getMonth() && matchesDate =
articleDate.getFullYear() === now.getFullYear(); articleDate.getMonth() === now.getMonth() &&
articleDate.getFullYear() === now.getFullYear();
break; break;
case 'year': case "year":
matchesDate = articleDate.getFullYear() === now.getFullYear(); matchesDate = articleDate.getFullYear() === now.getFullYear();
break; break;
} }
const matchesTags = selectedTags.value.length === 0 || const matchesTags =
selectedTags.value.every(tag => article.tags.find((e) => e.name == tag)); selectedTags.value.length === 0 ||
selectedTags.value.every((tag) =>
article.tags.find((e) => e.name == tag)
);
return matchesSearch && matchesDate && matchesTags; return matchesSearch && matchesDate && matchesTags;
}); });
}); });

View File

@ -2,17 +2,17 @@ export const useNews = () => {
const getAll = async (options?: { const getAll = async (options?: {
limit?: number; limit?: number;
skip?: number; skip?: number;
orderBy?: 'asc' | 'desc'; orderBy?: "asc" | "desc";
tags?: string[]; tags?: string[];
search?: string; search?: string;
}) => { }) => {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (options?.limit) query.set('limit', options.limit.toString()); if (options?.limit) query.set("limit", options.limit.toString());
if (options?.skip) query.set('skip', options.skip.toString()); if (options?.skip) query.set("skip", options.skip.toString());
if (options?.orderBy) query.set('order', options.orderBy); if (options?.orderBy) query.set("order", options.orderBy);
if (options?.tags?.length) query.set('tags', options.tags.join(',')); if (options?.tags?.length) query.set("tags", options.tags.join(","));
if (options?.search) query.set('search', options.search); if (options?.search) query.set("search", options.search);
return await useFetch(`/api/v1/news?${query.toString()}`); return await useFetch(`/api/v1/news?${query.toString()}`);
}; };
@ -21,30 +21,15 @@ export const useNews = () => {
return await useFetch(`/api/v1/news/${id}`); return await useFetch(`/api/v1/news/${id}`);
}; };
const create = async (article: {
title: string;
description: string;
content: string;
image?: string;
tags: string[];
authorId: string;
}) => {
return await $fetch('/api/v1/admin/news', {
method: 'POST',
body: article
});
};
const remove = async (id: string) => { const remove = async (id: string) => {
return await $fetch(`/api/v1/admin/news/${id}`, { return await $fetch(`/api/v1/admin/news/${id}`, {
method: 'DELETE' method: "DELETE",
}); });
}; };
return { return {
getAll, getAll,
getById, getById,
create, remove,
remove
}; };
}; };

View File

@ -2,19 +2,21 @@
<div v-if="article" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div v-if="article" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Banner header with blurred background --> <!-- Banner header with blurred background -->
<div class="relative w-full h-[300px] mb-8 rounded-lg overflow-hidden"> <div class="relative w-full h-[300px] mb-8 rounded-lg overflow-hidden">
<div class="absolute inset-0"> <div class="absolute inset-0" v-if="article.image">
<template v-if="article.image"> <img
<img :src="useObject(article.image)"
:src="article.image" alt=""
alt="" class="w-full h-full object-cover blur-sm scale-110"
class="w-full h-full object-cover blur-sm scale-110" />
/> <div
<div class="absolute inset-0 bg-gradient-to-b from-zinc-950/70 via-zinc-950/60 to-zinc-950/90"></div> class="absolute inset-0 bg-gradient-to-b from-transparent to-zinc-950"
</template> />
<template v-else> </div>
<!-- Fallback gradient background when no image --> <div v-else>
<div class="absolute inset-0 bg-gradient-to-b from-zinc-800 to-zinc-900"></div> <!-- Fallback gradient background when no image -->
</template> <div
class="absolute inset-0 bg-gradient-to-b from-zinc-800 to-zinc-900"
></div>
</div> </div>
<div class="relative h-full flex flex-col justify-end p-8"> <div class="relative h-full flex flex-col justify-end p-8">
@ -26,10 +28,10 @@
<ArrowLeftIcon class="h-4 w-4" aria-hidden="true" /> <ArrowLeftIcon class="h-4 w-4" aria-hidden="true" />
Back to News Back to News
</NuxtLink> </NuxtLink>
<button <button
v-if="user?.admin" v-if="user?.admin"
@click="() => currentlyDeleting = article" @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" 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" /> <TrashIcon class="h-4 w-4" aria-hidden="true" />
@ -38,11 +40,19 @@
</div> </div>
<div class="max-w-[calc(100%-2rem)]"> <div class="max-w-[calc(100%-2rem)]">
<h1 class="text-4xl font-bold text-white mb-3">{{ article.title }}</h1> <h1 class="text-4xl font-bold text-white mb-3">
<div class="flex flex-col gap-y-3 sm:flex-row sm:items-center sm:gap-x-4 text-zinc-300"> {{ 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"> <div class="flex items-center gap-x-4">
<time :datetime="article.publishedAt">{{ formatDate(article.publishedAt) }}</time> <time :datetime="article.publishedAt">{{
<span class="text-blue-400">{{ article.author?.displayName ?? "System" }}</span> formatDate(article.publishedAt)
}}</time>
<span class="text-blue-400">{{
article.author?.displayName ?? "System"
}}</span>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<span <span
@ -60,8 +70,8 @@
</div> </div>
<!-- Article content - markdown --> <!-- Article content - markdown -->
<div <div
class="max-w-[calc(100%-2rem)] mx-auto prose prose-invert prose-lg" class="mx-auto prose prose-invert prose-lg"
v-html="renderedContent" v-html="renderedContent"
/> />
</div> </div>
@ -72,7 +82,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ArrowLeftIcon } from "@heroicons/vue/20/solid"; import { ArrowLeftIcon } from "@heroicons/vue/20/solid";
import { TrashIcon } from "@heroicons/vue/24/outline"; import { TrashIcon } from "@heroicons/vue/24/outline";
import { micromark } from 'micromark'; import { micromark } from "micromark";
const route = useRoute(); const route = useRoute();
const { data: article } = await useNews().getById(route.params.id as string); const { data: article } = await useNews().getById(route.params.id as string);
@ -82,7 +92,7 @@ const user = useUser();
if (!article.value) { if (!article.value) {
throw createError({ throw createError({
statusCode: 404, statusCode: 404,
message: 'Article not found' message: "Article not found",
}); });
} }

View File

@ -10,17 +10,13 @@
Stay up to date with the latest updates and announcements. Stay up to date with the latest updates and announcements.
</p> </p>
</div> </div>
<NewsArticleCreate @refresh="refreshAll" /> <NewsArticleCreate @refresh="refreshAll" />
</div> </div>
</div> </div>
<!-- Articles list --> <!-- Articles list -->
<TransitionGroup <TransitionGroup name="article-list" tag="div" class="space-y-6">
name="article-list"
tag="div"
class="space-y-6"
>
<NuxtLink <NuxtLink
v-for="article in articles" v-for="article in articles"
:key="article.id" :key="article.id"
@ -32,7 +28,8 @@
> >
<div class="relative h-48 w-full overflow-hidden"> <div class="relative h-48 w-full overflow-hidden">
<img <img
:src="article.image || '/images/default-news-image.jpg'" v-if="article.image"
:src="useObject(article.image)"
alt="" alt=""
class="h-full w-full object-cover object-center transition-all duration-500 group-hover:scale-110 scale-105" class="h-full w-full object-cover object-center transition-all duration-500 group-hover:scale-110 scale-105"
/> />
@ -50,13 +47,20 @@
<div class="flex flex-1 flex-col justify-between p-6"> <div class="flex flex-1 flex-col justify-between p-6">
<div class="flex-1"> <div class="flex-1">
<div class="flex items-center gap-x-2"> <div class="flex items-center gap-x-2">
<time :datetime="article.publishedAt" class="text-sm text-zinc-400"> <time
:datetime="article.publishedAt"
class="text-sm text-zinc-400"
>
{{ formatDate(article.publishedAt) }} {{ formatDate(article.publishedAt) }}
</time> </time>
<span class="text-sm text-blue-400">{{ article.author?.displayName ?? "System" }}</span> <span class="text-sm text-blue-400">{{
article.author?.displayName ?? "System"
}}</span>
</div> </div>
<div class="mt-2"> <div class="mt-2">
<h3 class="text-xl font-semibold text-zinc-100 group-hover:text-primary-400"> <h3
class="text-xl font-semibold text-zinc-100 group-hover:text-primary-400"
>
{{ article.title }} {{ article.title }}
</h3> </h3>
<p class="mt-3 text-base text-zinc-400"> <p class="mt-3 text-base text-zinc-400">
@ -69,10 +73,7 @@
</NuxtLink> </NuxtLink>
</TransitionGroup> </TransitionGroup>
<div <div v-if="articles?.length === 0" class="text-center py-12">
v-if="articles?.length === 0"
class="text-center py-12"
>
<DocumentIcon class="mx-auto h-12 w-12 text-zinc-400" /> <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> <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> <p class="mt-1 text-sm text-zinc-500">Check back later for updates.</p>

View File

@ -1,23 +1,51 @@
import { defineEventHandler, createError, readBody } from "h3"; import { defineEventHandler, createError, readBody } from "h3";
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import newsManager from "~/server/internal/news"; import newsManager from "~/server/internal/news";
import { handleFileUpload } from "~/server/internal/utils/handlefileupload";
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["news:create"]); const allowed = await aclManager.allowSystemACL(h3, ["news:create"]);
if (!allowed) throw createError({ statusCode: 403 }); if (!allowed) throw createError({ statusCode: 403 });
const body = await readBody(h3); const form = await readMultipartFormData(h3);
if (!form)
throw createError({
statusCode: 400,
statusMessage: "This endpoint requires multipart form data.",
});
const uploadResult = await handleFileUpload(h3, {}, ["internal:read"]);
if (!uploadResult)
throw createError({
statusCode: 400,
statusMessage: "Failed to upload file",
});
const [imageId, options, pull, dump] = uploadResult;
const title = options.title;
const description = options.description;
const content = options.content;
const tags = options.tags ? (JSON.parse(options.tags) as string[]) : [];
if (!title || !description || !content)
throw createError({
statusCode: 400,
statusMessage: "Missing or invalid title, description or content.",
});
const article = await newsManager.create({ const article = await newsManager.create({
title: body.title, title: title,
description: body.description, description: description,
content: body.content, content: content,
tags: body.tags, tags: tags,
image: body.image, image: imageId,
authorId: body.authorId, authorId: "system",
}); });
await pull();
return article; return article;
}); });

View File

@ -1,5 +1,6 @@
import { triggerAsyncId } from "async_hooks"; import { triggerAsyncId } from "async_hooks";
import prisma from "../db/database"; import prisma from "../db/database";
import objectHandler from "../objects";
class NewsManager { class NewsManager {
async create(data: { async create(data: {
@ -115,9 +116,13 @@ class NewsManager {
} }
async delete(id: string) { async delete(id: string) {
return await prisma.article.delete({ const article = await prisma.article.delete({
where: { id }, where: { id },
}); });
if (article.image) {
return await objectHandler.delete(article.image);
}
return true;
} }
} }