Added basic comments.

This commit is contained in:
DebaucheryLibrarian 2023-06-11 05:32:02 +02:00
parent 9a9b92a6b1
commit 0d5744e3ff
26 changed files with 441 additions and 89 deletions

View File

@ -1,14 +1,16 @@
.input {
box-sizing: border-box;
padding: .5rem .75rem;
font-size: 1rem;
flex-basis: 0;
border: solid 1px var(--grey-light-30);
border-radius: .25rem;
background: var(--grey-light-60);
font: inherit;
&:focus {
outline: none;
border-color: var(--primary-light-30);
border-color: var(--primary-light-50);
}
}

View File

@ -3,6 +3,8 @@
--primary-light-10: hsl(300, 50%, 40%);
--primary-light-20: hsl(300, 50%, 50%);
--primary-light-30: hsl(300, 50%, 60%);
--primary-light-40: hsl(300, 50%, 75%);
--primary-light-50: hsl(300, 50%, 80%);
--grey-dark-40: #222;
--grey-dark-30: #444;
@ -13,6 +15,8 @@
--grey-light-20: #ccc;
--grey-light-30: #ddd;
--grey-light-40: #eee;
--grey-light-50: #fafafa;
--grey-light-60: #fcfcfc;
--background-dark-20: #eee;
--background-dark-10: #f8f8f8;

View File

@ -15,7 +15,7 @@ function getQuery(data) {
}
export async function get(path, query = {}) {
const res = await fetch(`${path}${getQuery(query)}`);
const res = await fetch(`/api${path}${getQuery(query)}`);
const body = await res.json();
if (res.ok) {
@ -26,7 +26,7 @@ export async function get(path, query = {}) {
}
export async function post(path, data, { query } = {}) {
const res = await fetch(`${path}${getQuery(query)}`, {
const res = await fetch(`/api${path}${getQuery(query)}`, {
method: 'POST',
body: JSON.stringify(data),
...postHeaders,
@ -46,7 +46,7 @@ export async function post(path, data, { query } = {}) {
}
export async function patch(path, data, { query } = {}) {
const res = await fetch(`${path}${getQuery(query)}`, {
const res = await fetch(`/api${path}${getQuery(query)}`, {
method: 'PATCH',
body: JSON.stringify(data),
...postHeaders,
@ -66,7 +66,7 @@ export async function patch(path, data, { query } = {}) {
}
export async function del(path, { data, query } = {}) {
const res = await fetch(`${path}${getQuery(query)}`, {
const res = await fetch(`/api${path}${getQuery(query)}`, {
method: 'DELETE',
body: JSON.stringify(data),
...postHeaders,

View File

@ -2,3 +2,7 @@
export default function navigate(path) {
window.location.href = path;
}
export function reload() {
window.location.reload();
}

View File

@ -0,0 +1,59 @@
<template>
<div class="comment">
<div class="header">
<a
:href="`/user/${comment.user.username}`"
class="username link"
>u/{{ comment.user.username }}</a>
<span
:title="format(comment.createdAt, 'MMM d, yyyy hh:mm:ss')"
class="timestamp"
>{{ formatDistance(comment.createdAt, now, { includeSeconds: true }) }} ago</span>
</div>
<p class="body">{{ comment.body }}</p>
</div>
</template>
<script setup>
import { format, formatDistance } from 'date-fns';
import { usePageContext } from '../../renderer/usePageContext';
const { now } = usePageContext();
defineProps({
comment: {
type: Object,
default: null,
},
});
</script>
<style scoped>
.comment {
background: var(--background);
padding: .5rem;
border-radius: .25rem;
margin-bottom: .25rem;
}
.header {
font-size: .9rem;
margin-bottom: .5rem;
}
.username {
color: inherit;
font-weight: bold;
margin-right: .5rem;
}
.timestamp {
color: var(--shadow);
}
.body {
margin: 0;
}
</style>

View File

@ -1,24 +1,36 @@
<template>
<a
:href="`/s/shack/posts/${post.id}`"
class="post"
>
<img
class="thumbnail"
:src="blockedIcon"
<div class="post">
<a
:href="post.link || `/s/${post.shelf.slug}/post/${post.id}`"
target="_blank"
class="title-link"
>
<img
class="thumbnail"
:src="blockedIcon"
>
</a>
<div class="body">
<h2 class="title">
<div class="header">
<h2 class="title">
<a
:href="`/s/${post.shelf.slug}/post/${post.id}`"
class="title-link"
>{{ post.title }}</a>
</h2>
<a
:href="`/s/shack/posts/${post.id}`"
v-if="post.link"
:href="post.link"
target="_blank"
class="link"
>{{ post.title }}</a>
</h2>
>{{ post.link }}</a>
</div>
<div class="meta">
<a
:href="`/user/${post.shelf.slug}`"
:href="`/s/${post.shelf.slug}`"
class="shelf link"
>s/{{ post.shelf.slug }}</a>
@ -30,15 +42,30 @@
<span
:title="format(post.createdAt, 'MMMM d, yyyy hh:mm:ss')"
class="timestamp"
>{{ formatDistance(post.createdAt, new Date(), { includeSeconds: true }) }} ago</span>
>{{ formatDistance(post.createdAt, now, { includeSeconds: true }) }} ago</span>
</div>
<div class="actions">
<a
:href="`/s/shack/post/${post.id}`"
class="link comments"
>{{ post.commentCount }} comments</a>
</div>
</div>
</a>
<a
:href="`/s/shack/post/${post.id}`"
class="fill"
/>
</div>
</template>
<script setup>
import { format, formatDistance } from 'date-fns';
import blockedIcon from '../../assets/icons/blocked.svg?url'; // eslint-ignore import/no-unresolved
import blockedIcon from '../../assets/icons/blocked.svg?url'; // eslint-disable-line import/no-unresolved
import { usePageContext } from '../../renderer/usePageContext';
const { now } = usePageContext();
defineProps({
post: {
@ -53,6 +80,7 @@ defineProps({
display: flex;
color: var(--text);
border-radius: .25rem;
margin-bottom: .25rem;
background: var(--background);
text-decoration: none;
@ -69,17 +97,23 @@ defineProps({
margin-left: 1rem;
}
.title {
padding: .5rem 0;
margin: 0;
font-size: 1.25rem;
font-weight: normal;
color: var(--grey-dark-30);
.header {
display: flex;
align-items: center;
}
.link {
color: inherit;
text-decoration: none;
}
.title {
display: inline-block;
padding: .25rem 0;
margin: .25rem 1rem 0 0;
font-size: 1rem;
font-weight: bold;
color: var(--grey-dark-30);
}
.title-link {
color: inherit;
text-decoration: none;
}
.thumbnail {
@ -96,9 +130,14 @@ defineProps({
.meta {
display: flex;
gap: 1rem;
margin-bottom: .25rem;
font-size: .9rem;
}
.username {
color: inherit;
}
.shelf {
color: inherit;
font-weight: bold;
@ -107,4 +146,13 @@ defineProps({
.timestamp {
color: var(--grey-dark-20);
}
.comments {
color: inherit;
font-size: .9rem;
}
.fill {
flex-grow: 1;
}
</style>

View File

@ -83,7 +83,7 @@ export async function up(knex) {
.notNullable();
table.text('body');
table.text('url');
table.text('link');
table.integer('shelf_id')
.notNullable()
@ -100,7 +100,7 @@ export async function up(knex) {
.defaultTo(knex.fn.now());
});
await knex.raw('ALTER TABLE posts ADD CONSTRAINT post_content CHECK (body IS NOT NULL OR url IS NOT NULL)');
await knex.raw('ALTER TABLE posts ADD CONSTRAINT post_content CHECK (body IS NOT NULL OR link IS NOT NULL)');
await knex.schema.createTable('comments', (table) => {
table.text('id', 8)
@ -112,6 +112,10 @@ export async function up(knex) {
.references('id')
.inTable('posts');
table.integer('parent_comment_id')
.references('id')
.inTable('comments');
table.integer('user_id')
.notNullable()
.references('id')

View File

@ -104,7 +104,7 @@ const errorMsg = ref(null);
async function signup() {
try {
await post('/api/users', {
await post('/users', {
username: username.value,
email: email.value,
password: password.value,

View File

@ -76,7 +76,7 @@ const errorMsg = ref(null);
async function signup() {
try {
await post('/api/session', {
await post('/session', {
username: username.value,
password: password.value,
});

View File

@ -1,15 +1,11 @@
import { fetchShelves } from '../../src/shelves';
async function onBeforeRender(_pageContext) {
async function getPageData() {
const shelves = await fetchShelves();
return {
pageContext: {
pageData: {
shelves,
},
},
shelves,
};
}
export { onBeforeRender };
export { getPageData };

View File

@ -1,17 +1,12 @@
<template>
<div class="content">
<ul>
<li><a
href="/shelf/1"
class="link"
>Go to shelf</a></li>
<li><a
href="/shelf/create"
class="link"
>Create new shelf</a></li>
<li><a
<li v-if="!user"><a
href="/account/login"
class="link"
>Log in</a></li>
@ -37,6 +32,6 @@
<script setup>
import { usePageContext } from '../../renderer/usePageContext';
const { pageData } = usePageContext();
const { user, pageData } = usePageContext();
const { shelves } = pageData;
</script>

View File

@ -0,0 +1 @@
export default '/s/@id/post/@postId';

View File

@ -0,0 +1,40 @@
import { RenderErrorPage } from 'vite-plugin-ssr/RenderErrorPage';
import { fetchShelf } from '../../src/shelves';
import { fetchPost } from '../../src/posts';
import { fetchPostComments } from '../../src/comments';
async function getPageData(pageContext) {
const [shelf, post, comments] = await Promise.all([
fetchShelf(pageContext.routeParams.id),
fetchPost(pageContext.routeParams.postId),
fetchPostComments(pageContext.routeParams.postId),
]);
if (!shelf) {
throw RenderErrorPage({
pageContext: {
pageProps: {
errorInfo: 'This shelf does not exist',
},
},
});
}
if (!post) {
throw RenderErrorPage({
pageContext: {
pageProps: {
errorInfo: 'This post does not exist',
},
},
});
}
return {
shelf,
post,
comments,
};
}
export { getPageData };

107
pages/posts/post.page.vue Normal file
View File

@ -0,0 +1,107 @@
<template>
<div class="page">
<div class="body">
<Post :post="post" />
<form
class="writer"
@submit.prevent="addComment"
>
<textarea
v-model="newComment"
placeholder="Write a new comment"
class="input"
/>
<div class="actions">
<button class="button">Comment</button>
</div>
</form>
<ul class="comments nolist">
<li
v-for="comment in comments"
:key="comment.id"
><Comment :comment="comment" /></li>
</ul>
</div>
<div class="sidebar">
{{ shelf.name }}
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import Post from '../../components/posts/post.vue';
import Comment from '../../components/comments/comment.vue';
import * as api from '../../assets/js/api';
import { reload } from '../../assets/js/navigate';
import { usePageContext } from '../../renderer/usePageContext';
const { pageData } = usePageContext();
const {
shelf,
post,
comments,
} = pageData;
const newComment = ref('');
async function addComment() {
await api.post(`/posts/${post.id}/comments`, {
body: newComment.value,
parentId: null,
});
reload();
}
</script>
<style scoped>
.page {
display: flex;
padding: .5rem;
}
.title {
margin: 0 0 .5rem 0;
}
.body {
flex-grow: 1;
}
.post {
margin-bottom: .5rem;
}
.writer {
width: 40rem;
background: var(--background);
padding: .5rem;
border-radius: .5rem;
margin-bottom: .5rem;
.input {
width: 100%;
margin-bottom: .25rem;
}
.actions {
display: flex;
justify-content: flex-end;
}
}
.sidebar {
background: var(--background);
width: 20rem;
padding: .5rem;
border-radius: .5rem;
margin-left: .5rem;
}
</style>

View File

@ -2,7 +2,7 @@ import { RenderErrorPage } from 'vite-plugin-ssr/RenderErrorPage';
import { fetchShelf } from '../../../src/shelves';
import { fetchShelfPosts } from '../../../src/posts';
async function onBeforeRender(pageContext) {
async function getPageData(pageContext) {
const shelf = await fetchShelf(pageContext.routeParams.id);
const posts = await fetchShelfPosts(pageContext.routeParams.id, { limit: 50 });
@ -17,13 +17,9 @@ async function onBeforeRender(pageContext) {
}
return {
pageContext: {
pageData: {
shelf,
posts,
},
},
shelf,
posts,
};
}
export { onBeforeRender };
export { getPageData };

View File

@ -1,10 +1,5 @@
<template>
<div class="content">
<a
href="/"
class="link"
>Go back home</a>
<h3>{{ shelf.slug }}</h3>
<ul class="posts nolist">

View File

@ -137,7 +137,7 @@ const postAccess = ref('registered');
const isNsfw = ref(false);
async function create() {
await post('/api/shelves', {
await post('/shelves', {
slug: slug.value,
title: title.value,
description: description.value,

View File

@ -12,7 +12,7 @@ async function render(pageContext) {
const title = (documentProps && documentProps.title) || 'shack';
const desc = (documentProps && documentProps.description) || 'Shack';
const { app, store } = createApp(pageContext);
const { app } = createApp(pageContext);
const stream = renderToNodeStream(app);
const documentHtml = escapeInject`
@ -38,14 +38,28 @@ async function render(pageContext) {
return {
documentHtml,
pageContext: {
initialState: store.state.value,
// initialState: store.state.value,
enableEagerStreaming: true,
},
};
}
async function onBeforeRender(pageContext) {
const pageData = await pageContext.exports.getPageData?.(pageContext, pageContext.session);
return {
pageContext: {
// initialState: store.state.value,
pageData,
user: pageContext.session.user,
now: new Date(),
},
};
}
export {
render,
onBeforeRender,
};
export const passToClient = ['urlPathname', 'initialState', 'pageData', 'pageProps', 'routeParams'];
export const passToClient = ['urlPathname', 'initialState', 'pageData', 'pageProps', 'routeParams', 'user', 'now'];

View File

@ -33,20 +33,13 @@
</template>
<script setup>
// import { onMounted } from 'vue';
// import { usePageContext } from './usePageContext';
import { storeToRefs } from 'pinia';
import logo from '../assets/img/logo.svg?raw';
import logo from '../assets/img/logo.svg?raw'; // eslint-disable-line import/no-unresolved
import { del } from '../assets/js/api';
import { useUser } from '../stores/user';
import { usePageContext } from './usePageContext';
// const pageContext = usePageContext();
const { user } = usePageContext();
const version = CLIENT_VERSION;
const userStore = useUser();
const { user } = storeToRefs(userStore);
async function logout() {
await del('/api/session');
window.location.href = '/account/login';

44
src/comments.js Normal file
View File

@ -0,0 +1,44 @@
import knex from './knex';
import { fetchUsers } from './users';
import { verifyPrivilege } from './privileges';
function curateDatabaseComment(comment, { users }) {
return {
id: comment.id,
body: comment.body,
userId: comment.user_id,
user: users.find((user) => user.id === comment.user_id),
createdAt: comment.created_at,
};
}
async function fetchPostComments(postId, { limit = 100 } = {}) {
const comments = await knex('comments')
.where('post_id', postId)
.orderBy('created_at', 'asc')
.limit(limit);
const users = await fetchUsers(comments.map((comment) => comment.user_id));
return comments.map((comment) => curateDatabaseComment(comment, { users }));
}
async function addComment(comment, postId, user) {
await verifyPrivilege('addComment', user);
const commentEntry = await knex('comments')
.insert({
body: comment.body,
post_id: postId,
user_id: user.id,
})
.returning('*');
console.log(comment, user);
return curateDatabaseComment(commentEntry);
}
export {
fetchPostComments,
addComment,
};

View File

@ -3,36 +3,62 @@ import { verifyPrivilege } from './privileges';
import knex from './knex';
import { HttpError } from './errors';
import { fetchShelf, curateDatabaseShelf } from './shelves';
import { curateDatabaseUser } from './users';
import { fetchShelf } from './shelves';
import { fetchUsers } from './users';
function curatePost(post) {
function curatePost(post, { shelf, users }) {
const curatedPost = {
id: post.id,
title: post.title,
body: post.body,
url: post.url,
link: post.link,
shelfId: post.shelf_id,
createdAt: post.created_at,
shelf: curateDatabaseShelf(post.shelf),
user: curateDatabaseUser(post.user),
shelf,
user: users.find((user) => user.id === post.user_id),
commentCount: Number(post.comment_count),
};
return curatedPost;
}
async function fetchShelfPosts(shelfId, limit = 100) {
async function fetchShelfPosts(shelfId, { limit = 100 } = {}) {
const shelf = await fetchShelf(shelfId);
const posts = await knex('posts')
.select('posts.*', knex.raw('row_to_json(users) as user'), knex.raw('row_to_json(shelves) as shelf'))
.leftJoin('users', 'users.id', 'posts.user_id')
.leftJoin('shelves', 'shelves.id', 'posts.shelf_id')
.select('posts.*', knex.raw('count(comments.id) as comment_count'))
.leftJoin('comments', 'comments.post_id', 'posts.id')
.where('shelf_id', shelf.id)
.orderBy('created_at', 'desc')
.groupBy('posts.id')
.limit(limit);
return posts.map((post) => curatePost(post));
const users = await fetchUsers(posts.map((post) => post.user_id));
return posts.map((post) => curatePost(post, { shelf, users }));
}
async function fetchPost(postId) {
const post = await knex('posts')
.select('posts.*', knex.raw('count(comments.id) as comment_count'))
.leftJoin('comments', 'comments.post_id', 'posts.id')
.where('posts.id', postId)
.groupBy('posts.id')
.first();
if (!post) {
throw new HttpError({
statusMessage: 'This post does not exist',
statusCode: 404,
});
}
const [shelf, users] = await Promise.all([
fetchShelf(post.shelf_id),
fetchUsers([post.user_id]),
]);
return curatePost(post, { shelf, users });
}
async function createPost(post, shelfId, user) {
@ -47,11 +73,13 @@ async function createPost(post, shelfId, user) {
});
}
console.log(post);
const postId = await knex('posts')
.insert({
title: post.title,
body: post.body,
url: post.url,
link: post.link,
shelf_id: shelf.id,
user_id: user.id,
})
@ -62,5 +90,6 @@ async function createPost(post, shelfId, user) {
export {
createPost,
fetchPost,
fetchShelfPosts,
};

View File

@ -8,6 +8,7 @@ function curateDatabaseShelf(shelf) {
return {
id: shelf.id,
slug: shelf.slug,
name: shelf.slug,
};
}

View File

@ -89,6 +89,12 @@ async function login(credentials) {
return curateDatabaseUser(user);
}
async function fetchUsers(userIds) {
const users = await knex('users').whereIn('id', userIds);
return users.map((user) => curateDatabaseUser(user));
}
async function createUser(credentials, context) {
if (!credentials.username) {
throw new HttpError({
@ -145,5 +151,6 @@ async function createUser(credentials, context) {
export {
curateDatabaseUser,
createUser,
fetchUsers,
login,
};

11
src/web/comments.js Normal file
View File

@ -0,0 +1,11 @@
import { addComment } from '../comments';
async function addCommentApi(req, res) {
const comment = await addComment(req.body, req.params.postId, req.user);
res.send(comment);
}
export {
addCommentApi as addComment,
};

View File

@ -21,8 +21,6 @@ export default async function initDefaultHandler() {
const body = await httpResponse.getBody();
console.log(pageContext.pageData);
if (res.writeEarlyHints) {
res.writeEarlyHints({ link: earlyHints.map((e) => e.earlyHintLink) });
}

View File

@ -26,6 +26,7 @@ import {
import { createShelf } from './shelves';
import { createPost } from './posts';
import { addComment } from './comments';
const logger = initLogger();
@ -70,6 +71,9 @@ async function startServer() {
// POSTS
router.post('/api/shelves/:shelfId/posts', createPost);
// COMMENTS
router.post('/api/posts/:postId/comments', addComment);
router.get('*', defaultHandler);
router.use(errorHandler);