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:
Yisroel Baum 2026-05-06 22:52:32 +03:00
parent bc80556d7c
commit 6ffa499c79
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
4 changed files with 558 additions and 6 deletions

View 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 };
});

View file

@ -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>

View file

@ -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>

View file

@ -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>