merge frontend-home-and-search
This commit is contained in:
commit
bc80556d7c
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">
|
<script setup lang="ts">
|
||||||
// Home page placeholder. Featured posts, recent posts and user
|
import { onMounted, ref } from "vue";
|
||||||
// search land here in a later branch.
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="home">
|
<section class="home">
|
||||||
<h1>TIDE</h1>
|
<section class="search">
|
||||||
<p class="lead">A blog. Sign up to start posting.</p>
|
<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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.home {
|
.home {
|
||||||
padding: 32px 0;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lead {
|
h2 {
|
||||||
color: var(--color-secondary);
|
margin: 0 0 12px;
|
||||||
font-size: 16px;
|
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>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue