feat: move article creation into a modal

This commit is contained in:
DecDuck
2025-03-11 12:20:56 +11:00
parent c3ee948682
commit 9515a21dc6
3 changed files with 202 additions and 192 deletions
+174 -164
View File
@@ -13,183 +13,196 @@
<span>New Article</span> <span>New Article</span>
</button> </button>
<Transition <ModalTemplate size-class="sm:max-w-[80vw]" v-model="isCreateExpanded">
enter-active-class="transition duration-200 ease-out" <h3 class="text-lg font-semibold text-zinc-100 mb-4">
enter-from-class="transform -translate-y-4 opacity-0" Create New Article
enter-to-class="transform translate-y-0 opacity-100" </h3>
leave-active-class="transition duration-200 ease-in" <form @submit.prevent="createArticle" class="space-y-4">
leave-from-class="transform translate-y-0 opacity-100" <div>
leave-to-class="transform -translate-y-4 opacity-0" <label for="title" class="block text-sm font-medium text-zinc-400"
> >Title</label
<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> <input
<form @submit.prevent="createArticle" class="space-y-4"> id="title"
<div> v-model="newArticle.title"
<label for="title" class="block text-sm font-medium text-zinc-400">Title</label> type="text"
<input autocomplete="off"
id="title" 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"
v-model="newArticle.title" required
type="text" />
autocomplete="off" </div>
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> <div>
<label for="excerpt" class="block text-sm font-medium text-zinc-400">Exercept</label> <label for="excerpt" class="block text-sm font-medium text-zinc-400"
<input >Exercept</label
id="excerpt" >
v-model="newArticle.description" <input
type="text" id="excerpt"
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" v-model="newArticle.description"
required 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"
</div> required
/>
</div>
<div> <div>
<label for="content" class="block text-sm font-medium text-zinc-400">Content (Markdown)</label> <label for="content" class="block text-sm font-medium text-zinc-400"
<div class="mt-1 flex flex-col gap-4"> >Content (Markdown)</label
<!-- Markdown shortcuts --> >
<div class="flex flex-wrap gap-2"> <div class="mt-1 flex flex-col gap-4">
<button <!-- Markdown shortcuts -->
v-for="shortcut in markdownShortcuts" <div class="flex flex-wrap gap-2">
: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 <button
v-for="shortcut in markdownShortcuts"
:key="shortcut.label"
type="button" type="button"
@click="addTag" @click="applyMarkdown(shortcut)"
class="mt-1 px-3 py-2 rounded-md bg-zinc-800 text-zinc-100 hover:bg-zinc-700" class="px-2 py-1 text-sm rounded bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors"
> >
Add {{ shortcut.label }}
</button> </button>
</div> </div>
</div>
<div class="flex justify-end"> <div class="grid grid-cols-2 gap-4 h-[400px]">
<button <!-- Editor -->
type="submit" <div class="flex flex-col">
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" <span class="text-sm text-zinc-500 mb-2">Editor</span>
:disabled="isSubmitting" <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"
> >
{{ isSubmitting ? 'Creating...' : 'Create Article' }} {{ 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> </button>
</div> </div>
</form> </div>
</div>
</Transition> <button type="submit" class="hidden" />
</form>
<template #buttons>
<LoadingButton
:loading="isSubmitting"
@click="() => createArticle()"
class="bg-blue-600 text-white hover:bg-blue-500"
>
Submit
</LoadingButton>
<button
@click="() => (isCreateExpanded = !isCreateExpanded)"
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>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { PlusIcon, XMarkIcon } from "@heroicons/vue/24/solid"; import { PlusIcon, XMarkIcon } from "@heroicons/vue/24/solid";
import { micromark } from 'micromark'; import { micromark } from "micromark";
const emit = defineEmits<{ const emit = defineEmits<{
'refresh': [] refresh: [];
}>(); }>();
const user = useUser(); const user = useUser();
const news = useNews(); const news = useNews();
const isCreateExpanded = ref(false); const isCreateExpanded = ref(false);
const isSubmitting = ref(false); const isSubmitting = ref(false);
const newTagInput = ref(''); const newTagInput = ref("");
const newArticle = ref({ const newArticle = ref({
title: '', title: "",
description: '', description: "",
content: '', content: "",
image: '', image: "",
tags: [] as string[] tags: [] as string[],
}); });
const contentEditor = ref<HTMLTextAreaElement>(); const contentEditor = ref<HTMLTextAreaElement>();
const markdownShortcuts = [ const markdownShortcuts = [
{ label: 'Bold', prefix: '**', suffix: '**', placeholder: 'bold text' }, { label: "Bold", prefix: "**", suffix: "**", placeholder: "bold text" },
{ label: 'Italic', prefix: '_', suffix: '_', placeholder: 'italic text' }, { label: "Italic", prefix: "_", suffix: "_", placeholder: "italic text" },
{ label: 'Link', prefix: '[', suffix: '](url)', placeholder: 'link text' }, { label: "Link", prefix: "[", suffix: "](url)", placeholder: "link text" },
{ label: 'Code', prefix: '`', suffix: '`', placeholder: 'code' }, { label: "Code", prefix: "`", suffix: "`", placeholder: "code" },
{ label: 'List Item', prefix: '- ', suffix: '', placeholder: 'list item' }, { label: "List Item", prefix: "- ", suffix: "", placeholder: "list item" },
{ label: 'Heading', prefix: '## ', suffix: '', placeholder: 'heading' }, { label: "Heading", prefix: "## ", suffix: "", placeholder: "heading" },
]; ];
const handleContentKeydown = (e: KeyboardEvent) => { const handleContentKeydown = (e: KeyboardEvent) => {
if (e.key === 'Enter') { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
const textarea = contentEditor.value; const textarea = contentEditor.value;
@@ -197,20 +210,19 @@ const handleContentKeydown = (e: KeyboardEvent) => {
const start = textarea.selectionStart; const start = textarea.selectionStart;
const text = textarea.value; const text = textarea.value;
const lineStart = text.lastIndexOf('\n', start - 1) + 1; const lineStart = text.lastIndexOf("\n", start - 1) + 1;
const currentLine = text.slice(lineStart, start); const currentLine = text.slice(lineStart, start);
// Check if the current line starts with a list marker // Check if the current line starts with a list marker
const listMatch = currentLine.match(/^(\s*)([-*+]|\d+\.)\s/); const listMatch = currentLine.match(/^(\s*)([-*+]|\d+\.)\s/);
let insertion = '\n'; let insertion = "\n";
if (listMatch) { if (listMatch) {
// If the line is empty except for the list marker, end the list // If the line is empty except for the list marker, end the list
if (currentLine.trim() === listMatch[0].trim()) { if (currentLine.trim() === listMatch[0].trim()) {
const removeLength = currentLine.length; const removeLength = currentLine.length;
newArticle.value.content = newArticle.value.content =
text.slice(0, lineStart) + text.slice(0, lineStart) + text.slice(lineStart + removeLength);
text.slice(lineStart + removeLength);
// Move cursor to new position after removing the list marker // Move cursor to new position after removing the list marker
nextTick(() => { nextTick(() => {
@@ -219,13 +231,11 @@ const handleContentKeydown = (e: KeyboardEvent) => {
return; return;
} }
// Otherwise, continue the list // Otherwise, continue the list
insertion = '\n' + listMatch[1] + listMatch[2] + ' '; insertion = "\n" + listMatch[1] + listMatch[2] + " ";
} }
newArticle.value.content = newArticle.value.content =
text.slice(0, start) + text.slice(0, start) + insertion + text.slice(start);
insertion +
text.slice(start);
nextTick(() => { nextTick(() => {
textarea.selectionStart = textarea.selectionEnd = textarea.selectionStart = textarea.selectionEnd =
@@ -238,15 +248,17 @@ const 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) => { const removeTag = (tagToRemove: string) => {
newArticle.value.tags = newArticle.value.tags.filter(tag => tag !== tagToRemove); newArticle.value.tags = newArticle.value.tags.filter(
(tag) => tag !== tagToRemove
);
}; };
const applyMarkdown = (shortcut: typeof markdownShortcuts[0]) => { const applyMarkdown = (shortcut: (typeof markdownShortcuts)[0]) => {
const textarea = contentEditor.value; const textarea = contentEditor.value;
if (!textarea) return; if (!textarea) return;
@@ -276,7 +288,7 @@ const applyMarkdown = (shortcut: typeof markdownShortcuts[0]) => {
const createArticle = async () => { const createArticle = async () => {
if (!user.value?.id) { if (!user.value?.id) {
console.error('User not authenticated'); console.error("User not authenticated");
return; return;
} }
@@ -289,19 +301,18 @@ const createArticle = async () => {
// Reset form // Reset form
newArticle.value = { newArticle.value = {
title: '', title: "",
description: '', description: "",
content: '', content: "",
image: '', image: "",
tags: [] tags: [],
}; };
emit('refresh'); emit("refresh");
isCreateExpanded.value = false; isCreateExpanded.value = false;
} catch (error) { } catch (error) {
console.error('Failed to create article:', error); console.error("Failed to create article:", error);
} finally { } finally {
isSubmitting.value = false; isSubmitting.value = false;
} }
@@ -313,7 +324,6 @@ const markdownPreview = computed(() => {
</script> </script>
<style scoped> <style scoped>
.prose { .prose {
max-width: none; max-width: none;
} }
+1 -1
View File
@@ -58,7 +58,7 @@
<NuxtLink <NuxtLink
v-for="article in filteredArticles" v-for="article in filteredArticles"
:key="article.id" :key="article.id"
:to="`/news/article/${article.id}`" :to="`/news/${article.id}`"
class="group block rounded-lg hover-lift" class="group block rounded-lg hover-lift"
> >
<div <div