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">
|
||||
// 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>
|
||||
|
||||
<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>
|
||||
|
||||
<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">
|
||||
// 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>
|
||||
|
||||
<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>
|
||||
</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">
|
||||
// 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>
|
||||
|
||||
<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>
|
||||
|
||||
<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