merge frontend-home-and-search

This commit is contained in:
Yisroel Baum 2026-05-06 22:50:56 +03:00
commit bc80556d7c
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
4 changed files with 353 additions and 7 deletions

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

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

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

View file

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