implement profile, post, and new-post views
ProfilePage shows a user's posts and exposes a New Post button when the logged-in user is the owner; admins see a Promote control. PostPage shows the post, its comments, and forms for adding/deleting comments. Post-author and admin can delete posts; admins can feature or unfeature any post into one of the two slots from this view.
This commit is contained in:
parent
bc80556d7c
commit
6ffa499c79
4 changed files with 558 additions and 6 deletions
64
frontend/blog_portal/src/stores/comments.ts
Normal file
64
frontend/blog_portal/src/stores/comments.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { apiDelete, apiGet, apiPost } from "@/api/client";
|
||||||
|
|
||||||
|
export interface CommentItem {
|
||||||
|
id: number;
|
||||||
|
postId: number;
|
||||||
|
userId: number;
|
||||||
|
authorDisplayName: string;
|
||||||
|
body: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentListPayload {
|
||||||
|
comments: CommentItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentPayload {
|
||||||
|
comment: CommentItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCommentsStore = defineStore("comments", () => {
|
||||||
|
const items = ref<CommentItem[]>([]);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
async function fetchForPost(postId: number): Promise<void> {
|
||||||
|
const result = await apiGet<CommentListPayload>(`/posts/${postId}/comments`);
|
||||||
|
if (result.ok && result.data) {
|
||||||
|
items.value = result.data.comments;
|
||||||
|
} else {
|
||||||
|
error.value = result.error;
|
||||||
|
items.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(postId: number, body: string): Promise<boolean> {
|
||||||
|
const result = await apiPost<CommentPayload>(`/posts/${postId}/comments`, { body });
|
||||||
|
if (!result.ok || !result.data) {
|
||||||
|
error.value = result.error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
items.value = [...items.value, result.data.comment];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number): Promise<boolean> {
|
||||||
|
const result = await apiDelete<null>(`/comments/${id}`);
|
||||||
|
if (!result.ok) {
|
||||||
|
error.value = result.error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
items.value = items.value.filter(function (existing) {
|
||||||
|
return existing.id !== id;
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear(): void {
|
||||||
|
items.value = [];
|
||||||
|
error.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items, error, fetchForPost, create, remove, clear };
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,103 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// NewPostPage - filled in by a later branch.
|
import { ref } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { usePostsStore } from "@/stores/posts";
|
||||||
|
|
||||||
|
const props = defineProps<{ displayName: string }>();
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const postsStore = usePostsStore();
|
||||||
|
|
||||||
|
const title = ref("");
|
||||||
|
const body = ref("");
|
||||||
|
const submitting = ref(false);
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (submitting.value) return;
|
||||||
|
submitting.value = true;
|
||||||
|
const post = await postsStore.createPost(title.value, body.value);
|
||||||
|
submitting.value = false;
|
||||||
|
if (post) {
|
||||||
|
router.push({ name: "post", params: { id: post.id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth.user && auth.user.displayName !== props.displayName) {
|
||||||
|
router.replace({ name: "home" });
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section><h1>NewPostPage</h1></section>
|
<section class="new-post">
|
||||||
|
<h1>New post</h1>
|
||||||
|
<form @submit.prevent="handleSubmit">
|
||||||
|
<label>
|
||||||
|
<span>Title</span>
|
||||||
|
<input v-model="title" type="text" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Body</span>
|
||||||
|
<textarea v-model="body" rows="10" required />
|
||||||
|
</label>
|
||||||
|
<p v-if="postsStore.error" class="error">{{ postsStore.error }}</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" :disabled="submitting">Publish</button>
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'profile',
|
||||||
|
params: { displayName: props.displayName },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<button class="secondary" type="button">Cancel</button>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.new-post {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #b00020;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,288 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// PostPage - filled in by a later branch.
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { usePostsStore, type PostSummary } from "@/stores/posts";
|
||||||
|
import { useCommentsStore } from "@/stores/comments";
|
||||||
|
|
||||||
|
const props = defineProps<{ id: string }>();
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const postsStore = usePostsStore();
|
||||||
|
const commentsStore = useCommentsStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const post = ref<PostSummary | null>(null);
|
||||||
|
const notFound = ref(false);
|
||||||
|
const newComment = ref("");
|
||||||
|
const commenting = ref(false);
|
||||||
|
const featuringSlot = ref<number | null>(null);
|
||||||
|
|
||||||
|
const postId = computed(function () {
|
||||||
|
return Number.parseInt(props.id, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
const canDelete = computed(function () {
|
||||||
|
if (!post.value || !auth.user) return false;
|
||||||
|
return auth.isAdmin || auth.user.id === post.value.userId;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
commentsStore.clear();
|
||||||
|
notFound.value = false;
|
||||||
|
const fetched = await postsStore.fetchPost(postId.value);
|
||||||
|
if (!fetched) {
|
||||||
|
notFound.value = true;
|
||||||
|
post.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
post.value = fetched;
|
||||||
|
await commentsStore.fetchForPost(postId.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeletePost() {
|
||||||
|
if (!post.value) return;
|
||||||
|
const ok = await postsStore.deletePost(post.value.id);
|
||||||
|
if (ok) router.push({ name: "home" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateComment() {
|
||||||
|
if (commenting.value) return;
|
||||||
|
commenting.value = true;
|
||||||
|
const ok = await commentsStore.create(postId.value, newComment.value);
|
||||||
|
commenting.value = false;
|
||||||
|
if (ok) newComment.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteComment(commentId: number) {
|
||||||
|
await commentsStore.remove(commentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFeature(slot: number) {
|
||||||
|
if (!post.value) return;
|
||||||
|
featuringSlot.value = slot;
|
||||||
|
await postsStore.feature(post.value.id, slot);
|
||||||
|
featuringSlot.value = null;
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUnfeature() {
|
||||||
|
if (!post.value) return;
|
||||||
|
await postsStore.unfeature(post.value.id);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
function canDeleteComment(commentUserId: number): boolean {
|
||||||
|
if (!auth.user) return false;
|
||||||
|
return auth.isAdmin || auth.user.id === commentUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
watch(
|
||||||
|
() => props.id,
|
||||||
|
function () {
|
||||||
|
void load();
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section><h1>PostPage</h1></section>
|
<article v-if="post" class="post">
|
||||||
|
<header>
|
||||||
|
<h1>{{ post.title }}</h1>
|
||||||
|
<p class="meta">
|
||||||
|
by
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'profile',
|
||||||
|
params: { displayName: post.authorDisplayName },
|
||||||
|
}"
|
||||||
|
>{{ post.authorDisplayName }}</router-link
|
||||||
|
>
|
||||||
|
· {{ formatDate(post.createdAt) }}
|
||||||
|
<span v-if="post.featureSlot !== null" class="badge">
|
||||||
|
Featured (slot {{ post.featureSlot }})
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button v-if="canDelete" class="secondary" @click="handleDeletePost">Delete post</button>
|
||||||
|
<template v-if="auth.isAdmin">
|
||||||
|
<button class="secondary" :disabled="featuringSlot === 1" @click="handleFeature(1)">
|
||||||
|
Feature in slot 1
|
||||||
|
</button>
|
||||||
|
<button class="secondary" :disabled="featuringSlot === 2" @click="handleFeature(2)">
|
||||||
|
Feature in slot 2
|
||||||
|
</button>
|
||||||
|
<button v-if="post.featureSlot !== null" class="secondary" @click="handleUnfeature">
|
||||||
|
Unfeature
|
||||||
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="body">{{ post.body }}</div>
|
||||||
|
|
||||||
|
<section class="comments">
|
||||||
|
<h2>Comments ({{ commentsStore.items.length }})</h2>
|
||||||
|
<ul v-if="commentsStore.items.length > 0" class="comment-list">
|
||||||
|
<li v-for="comment in commentsStore.items" :key="comment.id">
|
||||||
|
<div class="comment-meta">
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'profile',
|
||||||
|
params: { displayName: comment.authorDisplayName },
|
||||||
|
}"
|
||||||
|
>{{ comment.authorDisplayName }}</router-link
|
||||||
|
>
|
||||||
|
· {{ formatDate(comment.createdAt) }}
|
||||||
|
<button
|
||||||
|
v-if="canDeleteComment(comment.userId)"
|
||||||
|
class="secondary delete"
|
||||||
|
@click="handleDeleteComment(comment.id)"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p>{{ comment.body }}</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="empty">No comments yet.</p>
|
||||||
|
|
||||||
|
<form v-if="auth.user" class="comment-form" @submit.prevent="handleCreateComment">
|
||||||
|
<textarea v-model="newComment" rows="3" placeholder="Add a comment" required />
|
||||||
|
<button type="submit" :disabled="commenting">Comment</button>
|
||||||
|
</form>
|
||||||
|
<p v-else class="muted">
|
||||||
|
<router-link :to="{ name: 'login' }">Log in</router-link>
|
||||||
|
to comment.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
<section v-else-if="notFound" class="not-found">
|
||||||
|
<h1>Post not found</h1>
|
||||||
|
<p><router-link :to="{ name: 'home' }">Back home</router-link></p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.post {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
margin-left: 8px;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
padding-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-list li {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-meta .delete {
|
||||||
|
margin-left: auto;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-list p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form textarea {
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form button {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty,
|
||||||
|
.muted {
|
||||||
|
color: var(--color-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 48px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,118 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// ProfilePage - filled in by a later branch.
|
import { onMounted, ref, watch } from "vue";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { usePostsStore, type PostSummary } from "@/stores/posts";
|
||||||
|
import { useUsersStore } from "@/stores/users";
|
||||||
|
import PostCard from "@/components/PostCard.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{ displayName: string }>();
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const postsStore = usePostsStore();
|
||||||
|
const usersStore = useUsersStore();
|
||||||
|
|
||||||
|
const profile = ref<{ id: number; displayName: string } | null>(null);
|
||||||
|
const posts = ref<PostSummary[]>([]);
|
||||||
|
const notFound = ref(false);
|
||||||
|
const promoting = ref(false);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
notFound.value = false;
|
||||||
|
const result = await postsStore.fetchUserPosts(props.displayName);
|
||||||
|
if (result === null) {
|
||||||
|
notFound.value = true;
|
||||||
|
profile.value = null;
|
||||||
|
posts.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
profile.value = result.user;
|
||||||
|
posts.value = result.posts;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePromote() {
|
||||||
|
if (!profile.value || promoting.value) return;
|
||||||
|
promoting.value = true;
|
||||||
|
await usersStore.promote(profile.value.id);
|
||||||
|
promoting.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
watch(
|
||||||
|
() => props.displayName,
|
||||||
|
function () {
|
||||||
|
void load();
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section><h1>ProfilePage</h1></section>
|
<section class="profile" v-if="!notFound && profile">
|
||||||
|
<header class="header">
|
||||||
|
<h1>{{ profile.displayName }}</h1>
|
||||||
|
<div class="actions">
|
||||||
|
<router-link
|
||||||
|
v-if="auth.user?.displayName === profile.displayName"
|
||||||
|
:to="{
|
||||||
|
name: 'new-post',
|
||||||
|
params: { displayName: profile.displayName },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<button>New post</button>
|
||||||
|
</router-link>
|
||||||
|
<button
|
||||||
|
v-if="auth.isAdmin && auth.user?.id !== profile.id"
|
||||||
|
class="secondary"
|
||||||
|
:disabled="promoting"
|
||||||
|
@click="handlePromote"
|
||||||
|
>
|
||||||
|
Promote to admin
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="posts.length === 0" class="empty">No posts yet.</div>
|
||||||
|
<div v-else class="grid">
|
||||||
|
<PostCard v-for="post in posts" :key="post.id" :post="post" :show-author="false" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section v-else class="not-found">
|
||||||
|
<h1>User not found</h1>
|
||||||
|
<p>
|
||||||
|
<router-link :to="{ name: 'home' }">Back home</router-link>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.profile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 48px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue