implement home page with featured posts, recent posts, and user search
This commit is contained in:
parent
f4f2d22440
commit
1997870da5
4 changed files with 353 additions and 7 deletions
78
frontend/blog_portal/src/components/PostCard.vue
Normal file
78
frontend/blog_portal/src/components/PostCard.vue
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<script setup lang="ts">
|
||||
import type { PostSummary } from "@/stores/posts";
|
||||
|
||||
const props = defineProps<{
|
||||
post: PostSummary;
|
||||
showAuthor?: boolean;
|
||||
}>();
|
||||
|
||||
function snippet(body: string): string {
|
||||
if (body.length <= 200) return body;
|
||||
return body.slice(0, 200) + "...";
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="post-card">
|
||||
<h3 class="title">
|
||||
<router-link :to="{ name: 'post', params: { id: props.post.id } }">
|
||||
{{ props.post.title }}
|
||||
</router-link>
|
||||
</h3>
|
||||
<p class="meta">
|
||||
<template v-if="props.showAuthor !== false">
|
||||
by
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'profile',
|
||||
params: { displayName: props.post.authorDisplayName },
|
||||
}"
|
||||
>{{ props.post.authorDisplayName }}</router-link
|
||||
>
|
||||
·
|
||||
</template>
|
||||
{{ formatDate(props.post.createdAt) }}
|
||||
</p>
|
||||
<p class="body">{{ snippet(props.post.body) }}</p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.post-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.title a {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.title a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.body {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
124
frontend/blog_portal/src/stores/posts.ts
Normal file
124
frontend/blog_portal/src/stores/posts.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { apiDelete, apiGet, apiPost } from "@/api/client";
|
||||
|
||||
export interface PostSummary {
|
||||
id: number;
|
||||
userId: number;
|
||||
authorDisplayName: string;
|
||||
title: string;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
featureSlot: number | null;
|
||||
}
|
||||
|
||||
interface PostListPayload {
|
||||
posts: PostSummary[];
|
||||
}
|
||||
|
||||
interface PostPayload {
|
||||
post: PostSummary;
|
||||
}
|
||||
|
||||
interface UserPostsPayload {
|
||||
user: { id: number; displayName: string };
|
||||
posts: PostSummary[];
|
||||
}
|
||||
|
||||
export const usePostsStore = defineStore("posts", () => {
|
||||
const recent = ref<PostSummary[]>([]);
|
||||
const featured = ref<PostSummary[]>([]);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
async function fetchRecent(): Promise<void> {
|
||||
const result = await apiGet<PostListPayload>("/posts");
|
||||
if (result.ok && result.data) {
|
||||
recent.value = result.data.posts;
|
||||
} else {
|
||||
error.value = result.error;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFeatured(): Promise<void> {
|
||||
const result = await apiGet<PostListPayload>("/posts/featured");
|
||||
if (result.ok && result.data) {
|
||||
featured.value = result.data.posts;
|
||||
} else {
|
||||
error.value = result.error;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPost(id: number): Promise<PostSummary | null> {
|
||||
const result = await apiGet<PostPayload>(`/posts/${id}`);
|
||||
if (result.ok && result.data) return result.data.post;
|
||||
error.value = result.error;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchUserPosts(displayName: string): Promise<UserPostsPayload | null> {
|
||||
const result = await apiGet<UserPostsPayload>(
|
||||
`/users/${encodeURIComponent(displayName)}/posts`,
|
||||
);
|
||||
if (result.ok && result.data) return result.data;
|
||||
error.value = result.error;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function createPost(title: string, body: string): Promise<PostSummary | null> {
|
||||
const result = await apiPost<PostPayload>("/posts", { title, body });
|
||||
if (result.ok && result.data) return result.data.post;
|
||||
error.value = result.error;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function deletePost(id: number): Promise<boolean> {
|
||||
const result = await apiDelete<null>(`/posts/${id}`);
|
||||
if (!result.ok) {
|
||||
error.value = result.error;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function feature(postId: number, slot: number): Promise<boolean> {
|
||||
const result = await apiPost<PostPayload>("/admin/posts/feature", {
|
||||
postId,
|
||||
slot,
|
||||
});
|
||||
if (!result.ok) {
|
||||
error.value = result.error;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function unfeature(postId: number): Promise<boolean> {
|
||||
const result = await apiPost<null>("/admin/posts/unfeature", {
|
||||
postId,
|
||||
});
|
||||
if (!result.ok) {
|
||||
error.value = result.error;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearError(): void {
|
||||
error.value = null;
|
||||
}
|
||||
|
||||
return {
|
||||
recent,
|
||||
featured,
|
||||
error,
|
||||
fetchRecent,
|
||||
fetchFeatured,
|
||||
fetchPost,
|
||||
fetchUserPosts,
|
||||
createPost,
|
||||
deletePost,
|
||||
feature,
|
||||
unfeature,
|
||||
clearError,
|
||||
};
|
||||
});
|
||||
49
frontend/blog_portal/src/stores/users.ts
Normal file
49
frontend/blog_portal/src/stores/users.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { apiGet, apiPost } from "@/api/client";
|
||||
|
||||
export interface UserSearchResult {
|
||||
id: number;
|
||||
email: string;
|
||||
displayName: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
interface SearchPayload {
|
||||
users: UserSearchResult[];
|
||||
}
|
||||
|
||||
interface PromotePayload {
|
||||
user: UserSearchResult;
|
||||
}
|
||||
|
||||
export const useUsersStore = defineStore("users", () => {
|
||||
const results = ref<UserSearchResult[]>([]);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
async function search(query: string): Promise<void> {
|
||||
const trimmed = query.trim();
|
||||
if (trimmed === "") {
|
||||
results.value = [];
|
||||
return;
|
||||
}
|
||||
const result = await apiGet<SearchPayload>(`/users?q=${encodeURIComponent(trimmed)}`);
|
||||
if (result.ok && result.data) {
|
||||
results.value = result.data.users;
|
||||
} else {
|
||||
error.value = result.error;
|
||||
results.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function promote(userId: number): Promise<boolean> {
|
||||
const result = await apiPost<PromotePayload>("/admin/users/promote", { userId });
|
||||
if (!result.ok) {
|
||||
error.value = result.error;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return { results, error, search, promote };
|
||||
});
|
||||
|
|
@ -1,22 +1,117 @@
|
|||
<script setup lang="ts">
|
||||
// Home page placeholder. Featured posts, recent posts and user
|
||||
// search land here in a later branch.
|
||||
import { onMounted, ref } from "vue";
|
||||
import { usePostsStore } from "@/stores/posts";
|
||||
import { useUsersStore } from "@/stores/users";
|
||||
import PostCard from "@/components/PostCard.vue";
|
||||
|
||||
const postsStore = usePostsStore();
|
||||
const usersStore = useUsersStore();
|
||||
|
||||
const query = ref("");
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function handleSearchInput() {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
void usersStore.search(query.value);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await postsStore.fetchFeatured();
|
||||
await postsStore.fetchRecent();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="home">
|
||||
<h1>TIDE</h1>
|
||||
<p class="lead">A blog. Sign up to start posting.</p>
|
||||
<section class="search">
|
||||
<h2>Find people</h2>
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
placeholder="Search by name or email"
|
||||
@input="handleSearchInput"
|
||||
/>
|
||||
<ul v-if="usersStore.results.length > 0" class="results">
|
||||
<li v-for="entry in usersStore.results" :key="entry.id">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'profile',
|
||||
params: { displayName: entry.displayName },
|
||||
}"
|
||||
>
|
||||
{{ entry.displayName }}
|
||||
</router-link>
|
||||
<span class="muted">{{ entry.email }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section v-if="postsStore.featured.length > 0" class="featured">
|
||||
<h2>Featured</h2>
|
||||
<div class="grid">
|
||||
<PostCard v-for="post in postsStore.featured" :key="post.id" :post="post" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="recent">
|
||||
<h2>Recent posts</h2>
|
||||
<div v-if="postsStore.recent.length === 0" class="empty">Nothing here yet.</div>
|
||||
<div v-else class="grid">
|
||||
<PostCard v-for="post in postsStore.recent" :key="post.id" :post="post" />
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
padding: 32px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.lead {
|
||||
color: var(--color-secondary);
|
||||
h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.search input {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.results {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 12px 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.results li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--color-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue