Added voting. Improved comments.

This commit is contained in:
DebaucheryLibrarian 2023-06-25 19:52:00 +02:00
parent 754a89b913
commit f42daa2f83
29 changed files with 916 additions and 154 deletions

235
; Normal file
View File

@ -0,0 +1,235 @@
<template>
<div class="post">
<div class="votes">
<div
class="vote bump"
:class="{ active: post.hasBump }"
@click="vote(1)"
>+</div>
<div class="tally">{{ tally }}</div>
<div
class="vote sink"
:class="{ active: post.hasSink }"
@click="vote(-1)"
>-</div>
</div>
<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">
<div class="header">
<h2 class="title">
<a
:href="`/s/${post.shelf.slug}/post/${post.id}`"
class="title-link"
>{{ post.title }}</a>
</h2>
<a
v-if="post.link"
:href="post.link"
target="_blank"
class="link"
>{{ post.link }}</a>
</div>
<div class="meta">
<a
:href="`/s/${post.shelf.slug}`"
class="shelf link"
>s/{{ post.shelf.slug }}</a>
<a
:href="`/user/${post.user.username}`"
class="username link"
>u/{{ post.user.username }}</a>
<span
:title="format(post.createdAt, 'MMMM d, yyyy hh:mm:ss')"
class="timestamp"
>{{ formatDistance(post.createdAt, now, { includeSeconds: true }) }} ago</span>
</div>
<div class="actions">
<a
:href="`/s/${post.shelf.slug}/post/${post.id}`"
class="link comments"
>{{ post.commentCount }} comments</a>
</div>
</div>
<a
:href="`/s/${post.shelf.slug}/post/${post.id}`"
class="fill"
/>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { format, formatDistance } from 'date-fns';
import blockedIcon from '../../assets/icons/blocked.svg?url'; // eslint-disable-line import/no-unresolved
import { usePageContext } from '../../renderer/usePageContext';
import * as api from '../../assets/js/api';
const { now } = usePageContext();
const props = defineProps({
post: {
type: Object,
default: null,
},
shelf: {
type: Object,
default: null,
},
});
const tally = ref(props.post.tally);
async function vote(value) {
const effect = props.post.hasBump || props.post.hasSink ? 0 : value;
await api.post(`/posts/${props.post.id}/votes`, { value: effect });
tally.value += effect;
}
</script>
<style scoped>
.post {
display: flex;
color: var(--text);
border-radius: .25rem;
margin-bottom: .25rem;
background: var(--background);
text-decoration: none;
& :hover {
cursor: pointer;
.title {
color: var(--text);
}
}
}
.body {
margin-left: 1rem;
}
.header {
display: flex;
align-items: center;
}
.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 {
width: 7rem;
height: 4rem;
box-sizing: border-box;
padding: 1rem;
border-radius: .25rem;
margin: .5rem;
background: var(--grey-light-10);
opacity: .25;
}
.votes {
width: 2rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.vote,
.tally {
display: flex;
align-items: center;
height: 1rem;
font-weight: bold;
}
.vote {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1rem;
box-sizing: border-box;
padding: .6rem;
margin: .25rem 0;
border-radius: .5rem;
background: var(--shadow-weak-40);
}
.tally {
color: var(--shadow-strong-20);
font-size: .9rem;
}
.bump.active {
color: var(--text-light);
background: var(--bump);
}
.sink.active {
color: var(--text-light);
background: var(--sink);
}
.meta {
display: flex;
gap: 1rem;
margin-bottom: .25rem;
font-size: .9rem;
}
.username {
color: inherit;
}
.shelf {
color: inherit;
font-weight: bold;
}
.timestamp {
color: var(--grey-dark-20);
}
.comments {
color: inherit;
font-size: .9rem;
}
.fill {
flex-grow: 1;
}
</style>

View File

@ -41,6 +41,21 @@
} }
} }
.button-cancel {
background: none;
color: var(--shadow-strong-10);
font-weight: normal;
&:hover:not(:disabled) {
color: var(--error);
cursor: pointer;
}
&:disabled {
color: var(--shadow-weak-10);
}
}
.radio { .radio {
margin: 0 .5rem 0 0; margin: 0 .5rem 0 0;
} }

View File

@ -24,6 +24,23 @@
text-decoration: none; text-decoration: none;
} }
.nolink-active {
display: inline-block;
color: inherit;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.nobutton {
background: none;
border: none;
font-size: 1rem;
padding: 0;
}
.nobar { .nobar {
scrollbar-width: none; scrollbar-width: none;
-mis-overflow-style: none; -mis-overflow-style: none;

View File

@ -22,6 +22,7 @@
--background-dark-10: #f8f8f8; --background-dark-10: #f8f8f8;
--background: #fff; --background: #fff;
--shadow-weak-40: rgba(0, 0, 0, .05);
--shadow-weak-30: rgba(0, 0, 0, .1); --shadow-weak-30: rgba(0, 0, 0, .1);
--shadow-weak-20: rgba(0, 0, 0, .2); --shadow-weak-20: rgba(0, 0, 0, .2);
--shadow-weak-10: rgba(0, 0, 0, .35); --shadow-weak-10: rgba(0, 0, 0, .35);
@ -35,4 +36,9 @@
--link: #48f; --link: #48f;
--error: #f66; --error: #f66;
--bump: var(--primary);
--sink: var(--error);
--op: #5be;
} }

View File

@ -14,7 +14,7 @@ function getQuery(data) {
return `?${new URLSearchParams(data).toString()}`; return `?${new URLSearchParams(data).toString()}`;
} }
export async function get(path, query = {}) { async function get(path, query = {}) {
const res = await fetch(`/api${path}${getQuery(query)}`); const res = await fetch(`/api${path}${getQuery(query)}`);
const body = await res.json(); const body = await res.json();
@ -25,7 +25,7 @@ export async function get(path, query = {}) {
throw new Error(body.message); throw new Error(body.message);
} }
export async function post(path, data, { query } = {}) { async function post(path, data, { query } = {}) {
const res = await fetch(`/api${path}${getQuery(query)}`, { const res = await fetch(`/api${path}${getQuery(query)}`, {
method: 'POST', method: 'POST',
body: JSON.stringify(data), body: JSON.stringify(data),
@ -45,7 +45,7 @@ export async function post(path, data, { query } = {}) {
throw new Error(body.statusMessage); throw new Error(body.statusMessage);
} }
export async function patch(path, data, { query } = {}) { async function patch(path, data, { query } = {}) {
const res = await fetch(`/api${path}${getQuery(query)}`, { const res = await fetch(`/api${path}${getQuery(query)}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(data), body: JSON.stringify(data),
@ -65,7 +65,7 @@ export async function patch(path, data, { query } = {}) {
throw new Error(body.message); throw new Error(body.message);
} }
export async function del(path, { data, query } = {}) { async function del(path, { data, query } = {}) {
const res = await fetch(`/api${path}${getQuery(query)}`, { const res = await fetch(`/api${path}${getQuery(query)}`, {
method: 'DELETE', method: 'DELETE',
body: JSON.stringify(data), body: JSON.stringify(data),
@ -84,3 +84,10 @@ export async function del(path, { data, query } = {}) {
throw new Error(body.message); throw new Error(body.message);
} }
export {
get,
post,
patch,
del,
};

View File

@ -1,5 +1,5 @@
// centralize navigation to simplify switching between client and server routing // centralize navigation to simplify switching between client and server routing
export default function navigate(path) { export function navigate(path) {
window.location.href = path; window.location.href = path;
} }

View File

@ -1,11 +1,38 @@
<template> <template>
<div class="comment"> <div class="container">
<div class="frame">
<div class="crumbs">
<div
v-for="index in depth"
:key="`${comment.id}-${index}`"
:style="{ color: `hsl(${index * 60}, 50%, 75%)`, 'border-color': `hsl(${index * 60}, 50%, 90%)` }"
class="crumb"
>{{ String.fromCharCode(96 + index) }}</div>
</div>
<div
class="comment"
:class="{ nested: comment.parentId }"
:style="{ 'border-left': `solid 2px hsl(${(depth + 1) * 60}, 50%, 90%)` }"
>
<div class="header"> <div class="header">
<img
src="/assets/icons/blocked.svg"
class="avatar"
>
<a <a
:href="`/user/${comment.user.username}`" :href="`/user/${comment.user.username}`"
class="username link" class="username link"
>u/{{ comment.user.username }}</a> >u/{{ comment.user.username }}</a>
<ul class="labels nolist">
<li
v-if="comment.user.id === post.user.id"
class="label op"
>op</li>
</ul>
<span <span
:title="format(comment.createdAt, 'MMM d, yyyy hh:mm:ss')" :title="format(comment.createdAt, 'MMM d, yyyy hh:mm:ss')"
class="timestamp" class="timestamp"
@ -13,11 +40,45 @@
</div> </div>
<p class="body">{{ comment.body }}</p> <p class="body">{{ comment.body }}</p>
<div class="actions">
<button
type="button"
class="action link nobutton"
@click="isReplying = !isReplying"
>reply</button>
</div>
<Writer
v-if="isReplying"
:comment="comment"
:post="post"
@cancel="isReplying = false"
/>
</div>
</div>
<ul class="replies nolist">
<li
v-for="reply in comment.comments"
:key="reply.id"
>
<Comment
:comment="reply"
:post="post"
:depth="depth + 1"
/>
</li>
</ul>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue';
import { format, formatDistance } from 'date-fns'; import { format, formatDistance } from 'date-fns';
import Writer from './writer.vue';
import { usePageContext } from '../../renderer/usePageContext'; import { usePageContext } from '../../renderer/usePageContext';
const { now } = usePageContext(); const { now } = usePageContext();
@ -27,20 +88,37 @@ defineProps({
type: Object, type: Object,
default: null, default: null,
}, },
post: {
type: Object,
default: null,
},
depth: {
type: Number,
default: 0,
},
}); });
const isReplying = ref(false);
</script> </script>
<style scoped> <style scoped>
.comment { .comment {
display: block;
flex-grow: 1;
background: var(--background); background: var(--background);
padding: .5rem; box-sizing: border-box;
border-radius: .25rem; border-radius: .25rem .25rem .25rem 0;
margin-bottom: .25rem; margin: .25rem 0;
&.nested {
position: relative;
}
} }
.header { .header {
display: flex;
font-size: .9rem; font-size: .9rem;
margin-bottom: .5rem; padding: .5rem .5rem .25rem .5rem;
} }
.username { .username {
@ -54,6 +132,67 @@ defineProps({
} }
.body { .body {
padding: .25rem .5rem .5rem .5rem;
margin: 0; margin: 0;
} }
.actions {
border-top: solid 1px var(--shadow-weak-40);
}
.action {
padding: .5rem;
color: var(--grey-dark-20);
cursor: pointer;
}
.labels {
display: inline-block;
margin-right: .5rem;
}
.label {
background: var(--grey);
border-radius: .25rem;
color: var(--text-light);
font-weight: bold;
&.op {
color: var(--op);
background: none;
}
}
.avatar {
width: 1rem;
height: 1rem;
margin-right: .5rem;
opacity: .25;
}
.replies {
display: flex;
flex-direction: column;
}
.frame {
display: flex;
align-items: stretch;
}
.crumbs {
display: flex;
align-items: stretch;
margin-top: -.5rem;
}
.crumb {
display: flex;
align-items: center;
box-sizing: border-box;
padding: .5rem;
border-left: solid 2px var(--shadow-weak-40);
color: var(--shadow-weak-20);
font-size: .8rem;
}
</style> </style>

View File

@ -0,0 +1,88 @@
<template>
<form
class="writer"
@submit.prevent="addComment"
>
<textarea
ref="input"
v-model="body"
placeholder="Write a new comment"
class="input"
/>
<div class="actions">
<button
:disabled="body.length === 0"
class="button button-submit action submit"
>Comment</button>
<button
v-if="comment"
type="button"
class="button button-cancel action cancel"
@click="$emit('cancel')"
>Cancel</button>
</div>
</form>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import * as api from '../../assets/js/api';
import { reload } from '../../assets/js/navigate';
const props = defineProps({
comment: {
type: Object,
default: null,
},
post: {
type: Object,
default: null,
},
});
const body = ref('');
const input = ref(null);
async function addComment() {
await api.post(`/posts/${props.post.id}/comments`, {
body: body.value,
parentId: props.comment?.id,
});
reload();
}
onMounted(() => {
if (props.comment) {
input.value.focus();
}
});
</script>
<style scoped>
.writer {
width: 100%;
background: var(--background);
box-sizing: border-box;
padding: .5rem;
border-radius: .5rem;
margin-bottom: .5rem;
}
.input {
width: 100%;
margin-bottom: .25rem;
}
.actions {
display: flex;
justify-content: flex-start;
}
.action {
margin-right: .25rem;
}
</style>

View File

@ -1,5 +1,26 @@
<template> <template>
<div class="post"> <div class="post">
<div class="votes noselect">
<div
class="vote bump"
:class="{ active: hasBump }"
title="Bump"
@click="vote(1)"
>+</div>
<div
class="tally"
:title="`${post.votes.total} ${post.votes.total === 1 ? 'vote' : 'votes'}`"
>{{ tally }}</div>
<div
class="vote sink"
:class="{ active: hasSink }"
title="Sink"
@click="vote(-1)"
>-</div>
</div>
<a <a
:href="post.link || `/s/${post.shelf.slug}/post/${post.id}`" :href="post.link || `/s/${post.shelf.slug}/post/${post.id}`"
target="_blank" target="_blank"
@ -47,32 +68,61 @@
<div class="actions"> <div class="actions">
<a <a
:href="`/s/shack/post/${post.id}`" :href="`/s/${post.shelf.slug}/post/${post.id}`"
class="link comments" class="link comments"
>{{ post.commentCount }} comments</a> >{{ post.commentCount }} comments</a>
</div> </div>
</div> </div>
<a <a
:href="`/s/shack/post/${post.id}`" :href="`/s/${post.shelf.slug}/post/${post.id}`"
class="fill" class="fill"
/> />
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue';
import { format, formatDistance } from 'date-fns'; import { format, formatDistance } from 'date-fns';
import blockedIcon from '../../assets/icons/blocked.svg?url'; // eslint-disable-line import/no-unresolved import blockedIcon from '../../assets/icons/blocked.svg?url'; // eslint-disable-line import/no-unresolved
import { usePageContext } from '../../renderer/usePageContext'; import { usePageContext } from '../../renderer/usePageContext';
import * as api from '../../assets/js/api';
const { now } = usePageContext(); const { me, now } = usePageContext();
defineProps({ const props = defineProps({
post: { post: {
type: Object, type: Object,
default: null, default: null,
}, },
shelf: {
type: Object,
default: null,
},
}); });
const tally = ref(props.post.votes.tally);
const hasBump = ref(props.post.votes.bump);
const hasSink = ref(props.post.votes.sink);
const voting = ref(false);
async function vote(value) {
if (!me || voting.value) {
return;
}
voting.value = true;
const undo = (value > 0 && hasBump.value) || (value < 0 && hasSink.value);
const votes = await api.post(`/posts/${props.post.id}/votes`, { value: undo ? 0 : value });
tally.value = votes.tally;
hasBump.value = votes.bump;
hasSink.value = votes.sink;
voting.value = false;
}
</script> </script>
<style scoped> <style scoped>
@ -127,6 +177,50 @@ defineProps({
opacity: .25; opacity: .25;
} }
.votes {
width: 2rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.vote,
.tally {
display: flex;
align-items: center;
height: 1rem;
font-weight: bold;
}
.vote {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1rem;
box-sizing: border-box;
padding: .6rem;
margin: .25rem 0;
border-radius: .5rem;
background: var(--shadow-weak-40);
}
.tally {
color: var(--shadow-strong-20);
font-size: .9rem;
}
.bump.active {
color: var(--text-light);
background: var(--bump);
}
.sink.active {
color: var(--text-light);
background: var(--sink);
}
.meta { .meta {
display: flex; display: flex;
gap: 1rem; gap: 1rem;

View File

@ -39,6 +39,8 @@ export async function up(knex) {
table.increments('id'); table.increments('id');
table.text('slug') table.text('slug')
.unique()
.index()
.notNullable(); .notNullable();
table.integer('founder_id') table.integer('founder_id')
@ -102,6 +104,30 @@ export async function up(knex) {
await knex.raw('ALTER TABLE posts ADD CONSTRAINT post_content CHECK (body IS NOT NULL OR link 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('posts_votes', (table) => {
table.increments('id');
table.tinyint('value')
.notNullable()
.defaultTo(1);
table.integer('user_id')
.notNullable()
.references('id')
.inTable('users');
table.string('post_id')
.notNullable()
.references('id')
.inTable('posts');
table.datetime('created_at')
.notNullable()
.defaultTo(knex.fn.now());
table.unique(['user_id', 'post_id']);
});
await knex.schema.createTable('comments', (table) => { await knex.schema.createTable('comments', (table) => {
table.text('id', 8) table.text('id', 8)
.primary() .primary()
@ -112,7 +138,7 @@ export async function up(knex) {
.references('id') .references('id')
.inTable('posts'); .inTable('posts');
table.integer('parent_comment_id') table.text('parent_id')
.references('id') .references('id')
.inTable('comments'); .inTable('comments');
@ -123,6 +149,32 @@ export async function up(knex) {
table.text('body'); table.text('body');
table.boolean('deleted')
.notNullable()
.defaultTo(false);
table.datetime('created_at')
.notNullable()
.defaultTo(knex.fn.now());
});
await knex.schema.createTable('comments_votes', (table) => {
table.increments('id');
table.tinyint('value')
.notNullable()
.defaultTo(1);
table.integer('user_id')
.notNullable()
.references('id')
.inTable('users');
table.string('comment_id')
.notNullable()
.references('id')
.inTable('comments');
table.datetime('created_at') table.datetime('created_at')
.notNullable() .notNullable()
.defaultTo(knex.fn.now()); .defaultTo(knex.fn.now());
@ -130,11 +182,13 @@ export async function up(knex) {
} }
export async function down(knex) { export async function down(knex) {
await knex.schema.dropTable('comments'); await knex.schema.dropTableIfExists('comments_votes');
await knex.schema.dropTable('posts'); await knex.schema.dropTableIfExists('comments');
await knex.schema.dropTable('shelves_settings'); await knex.schema.dropTableIfExists('posts_votes');
await knex.schema.dropTable('shelves'); await knex.schema.dropTableIfExists('posts');
await knex.schema.dropTable('users'); await knex.schema.dropTableIfExists('shelves_settings');
await knex.schema.dropTableIfExists('shelves');
await knex.schema.dropTableIfExists('users');
await knex.raw(` await knex.raw(`
DROP FUNCTION IF EXISTS shack_id; DROP FUNCTION IF EXISTS shack_id;

View File

@ -88,7 +88,7 @@ import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
import Checkbox from '../../components/form/checkbox.vue'; import Checkbox from '../../components/form/checkbox.vue';
import navigate from '../../assets/js/navigate'; import { navigate } from '../../assets/js/navigate';
import { post } from '../../assets/js/api'; import { post } from '../../assets/js/api';
const config = CONFIG; const config = CONFIG;

View File

@ -65,7 +65,7 @@ import { ref } from 'vue';
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha'; import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
import { post } from '../../assets/js/api'; import { post } from '../../assets/js/api';
import navigate from '../../assets/js/navigate'; import { navigate } from '../../assets/js/navigate';
const config = CONFIG; const config = CONFIG;

View File

@ -6,7 +6,7 @@
class="link" class="link"
>Create new shelf</a></li> >Create new shelf</a></li>
<li v-if="!user"><a <li v-if="!me"><a
href="/account/login" href="/account/login"
class="link" class="link"
>Log in</a></li> >Log in</a></li>
@ -32,6 +32,6 @@
<script setup> <script setup>
import { usePageContext } from '../../renderer/usePageContext'; import { usePageContext } from '../../renderer/usePageContext';
const { user, pageData } = usePageContext(); const { me, pageData } = usePageContext();
const { shelves } = pageData; const { shelves } = pageData;
</script> </script>

View File

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

View File

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

View File

@ -1,28 +1,46 @@
<template> <template>
<div class="page"> <div class="page">
<div class="body"> <div class="content">
<Post :post="post" /> <Post
:post="post"
<form :shelf="shelf"
class="writer"
@submit.prevent="addComment"
>
<textarea
v-model="newComment"
placeholder="Write a new comment"
class="input"
/> />
<div class="actions"> <p
<button class="button">Comment</button> v-if="post.body"
class="body"
>{{ post.body }}</p>
<Writer
v-if="me"
:post="post"
/>
<div
v-else
class="body"
>
<a
href="/account/login"
class="link"
>Log in</a> or
<a
href="/account/create"
class="link"
>sign up</a> to comment
</div> </div>
</form>
<ul class="comments nolist"> <ul class="comments nolist">
<li <li
v-for="comment in comments" v-for="comment in comments"
:key="comment.id" :key="comment.id"
><Comment :comment="comment" /></li> >
<Comment
:comment="comment"
:post="post"
:shelf="shelf"
/>
</li>
</ul> </ul>
</div> </div>
@ -33,70 +51,46 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue';
import Post from '../../components/posts/post.vue'; import Post from '../../components/posts/post.vue';
import Comment from '../../components/comments/comment.vue'; import Comment from '../../components/comments/comment.vue';
import * as api from '../../assets/js/api'; import Writer from '../../components/comments/writer.vue';
import { reload } from '../../assets/js/navigate';
import { usePageContext } from '../../renderer/usePageContext'; import { usePageContext } from '../../renderer/usePageContext';
const { pageData } = usePageContext(); const { me, pageData } = usePageContext();
const { const {
shelf, shelf,
post, post,
comments, comments,
} = pageData; } = pageData;
const newComment = ref('');
async function addComment() {
await api.post(`/posts/${post.id}/comments`, {
body: newComment.value,
parentId: null,
});
reload();
}
</script> </script>
<style scoped> <style scoped>
.page { .page {
display: flex; display: flex;
padding: .5rem; }
.content {
flex-grow: 1;
}
.body {
box-sizing: border-box;
padding: 1rem;
border-radius: .5rem;
margin: 0 0 .5rem 0;
background: var(--background);
} }
.title { .title {
margin: 0 0 .5rem 0; margin: 0 0 .5rem 0;
} }
.body {
flex-grow: 1;
}
.post { .post {
margin-bottom: .5rem; 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 { .sidebar {
background: var(--background); background: var(--background);
width: 20rem; width: 20rem;

View File

@ -0,0 +1 @@
export default '/shelf/create';

View File

@ -126,6 +126,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import Checkbox from '../../components/form/checkbox.vue'; import Checkbox from '../../components/form/checkbox.vue';
import { post } from '../../assets/js/api'; import { post } from '../../assets/js/api';
import { navigate } from '../../assets/js/navigate';
const slug = ref(); const slug = ref();
const title = ref(); const title = ref();
@ -137,7 +138,7 @@ const postAccess = ref('registered');
const isNsfw = ref(false); const isNsfw = ref(false);
async function create() { async function create() {
await post('/shelves', { const shelf = await post('/shelves', {
slug: slug.value, slug: slug.value,
title: title.value, title: title.value,
description: description.value, description: description.value,
@ -147,6 +148,9 @@ async function create() {
isNsfw: isNsfw.value, isNsfw: isNsfw.value,
}, },
}); });
console.log(shelf);
navigate(`/s/${shelf.slug}`);
} }
</script> </script>

View File

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

View File

@ -1,10 +1,10 @@
import { RenderErrorPage } from 'vite-plugin-ssr/RenderErrorPage'; import { RenderErrorPage } from 'vite-plugin-ssr/RenderErrorPage';
import { fetchShelf } from '../../../src/shelves'; import { fetchShelf } from '../../src/shelves';
import { fetchShelfPosts } from '../../../src/posts'; import { fetchShelfPosts } from '../../src/posts';
async function getPageData(pageContext) { async function getPageData(pageContext) {
const shelf = await fetchShelf(pageContext.routeParams.id); const shelf = await fetchShelf(pageContext.routeParams.id);
const posts = await fetchShelfPosts(pageContext.routeParams.id, { limit: 50 }); const posts = await fetchShelfPosts(pageContext.routeParams.id, { user: pageContext.session.user, limit: 50 });
if (!shelf) { if (!shelf) {
throw RenderErrorPage({ throw RenderErrorPage({

View File

@ -6,7 +6,12 @@
<li <li
v-for="post in posts" v-for="post in posts"
:key="post.id" :key="post.id"
><Post :post="post" /></li> >
<Post
:post="post"
:shelf="shelf"
/>
</li>
</ul> </ul>
<form <form
@ -46,10 +51,11 @@
<script setup> <script setup>
import { ref } from 'vue'; import { ref } from 'vue';
import Post from '../../../components/posts/post.vue'; import Post from '../../components/posts/post.vue';
import * as api from '../../../assets/js/api'; import * as api from '../../assets/js/api';
import { usePageContext } from '../../../renderer/usePageContext'; import { navigate } from '../../assets/js/navigate';
import { usePageContext } from '../../renderer/usePageContext';
const { pageData, routeParams } = usePageContext(); const { pageData, routeParams } = usePageContext();
@ -63,11 +69,13 @@ const link = ref();
const body = ref(); const body = ref();
async function submitPost() { async function submitPost() {
await api.post(`/api/shelves/${routeParams.id}/posts`, { const post = await api.post(`/shelves/${routeParams.id}/posts`, {
title: title.value, title: title.value,
link: link.value, link: link.value,
body: body.value, body: body.value,
}); });
navigate(`/s/${shelf.slug}/post/${post.id}`);
} }
</script> </script>

View File

@ -1,6 +1,7 @@
// import { renderToString as renderToString_ } from '@vue/server-renderer'; // import { renderToString as renderToString_ } from '@vue/server-renderer';
import { renderToNodeStream } from '@vue/server-renderer'; import { renderToNodeStream } from '@vue/server-renderer';
import { escapeInject } from 'vite-plugin-ssr/server'; import { escapeInject } from 'vite-plugin-ssr/server';
import { RenderErrorPage } from 'vite-plugin-ssr/RenderErrorPage';
import { createApp } from './app'; import { createApp } from './app';
import { useUser } from '../stores/user'; import { useUser } from '../stores/user';
@ -45,16 +46,30 @@ async function render(pageContext) {
} }
async function onBeforeRender(pageContext) { async function onBeforeRender(pageContext) {
try {
const pageData = await pageContext.exports.getPageData?.(pageContext, pageContext.session); const pageData = await pageContext.exports.getPageData?.(pageContext, pageContext.session);
return { return {
pageContext: { pageContext: {
// initialState: store.state.value, // initialState: store.state.value,
pageData, pageData,
user: pageContext.session.user, me: pageContext.session.user,
now: new Date(), now: new Date(),
}, },
}; };
} catch (error) {
console.error(error);
throw RenderErrorPage({
pageContext: {
pageProps: {
errorInfo: error.statusMessage,
},
me: pageContext.session.user,
now: new Date(),
},
});
}
} }
export { export {
@ -62,4 +77,4 @@ export {
onBeforeRender, onBeforeRender,
}; };
export const passToClient = ['urlPathname', 'initialState', 'pageData', 'pageProps', 'routeParams', 'user', 'now']; export const passToClient = ['urlPathname', 'initialState', 'pageData', 'pageProps', 'routeParams', 'me', 'now'];

View File

@ -19,10 +19,10 @@
</div> </div>
<span <span
v-if="user" v-if="me"
class="userpanel" class="userpanel"
@click="logout" @click="logout"
>{{ user.username }}</span> >{{ me.username }}</span>
</header> </header>
<div class="content-container"> <div class="content-container">
@ -35,14 +35,15 @@
<script setup> <script setup>
import logo from '../assets/img/logo.svg?raw'; // eslint-disable-line import/no-unresolved import logo from '../assets/img/logo.svg?raw'; // eslint-disable-line import/no-unresolved
import { del } from '../assets/js/api'; import { del } from '../assets/js/api';
import { navigate } from '../assets/js/navigate';
import { usePageContext } from './usePageContext'; import { usePageContext } from './usePageContext';
const { user } = usePageContext(); const { me } = usePageContext();
const version = CLIENT_VERSION; const version = CLIENT_VERSION;
async function logout() { async function logout() {
await del('/api/session'); await del('/session');
window.location.href = '/account/login'; navigate('/account/login');
} }
</script> </script>

View File

@ -1,41 +1,80 @@
import knex from './knex'; import knex from './knex';
import { fetchUsers } from './users'; import { fetchUsers } from './users';
import { verifyPrivilege } from './privileges'; import { verifyPrivilege } from './privileges';
import { HttpError } from './errors';
function curateDatabaseComment(comment, { users }) { function curateDatabaseComment(comment, { users }) {
return { return {
id: comment.id, id: comment.id,
body: comment.body, body: comment.body,
parentId: comment.parent_id,
userId: comment.user_id, userId: comment.user_id,
user: users.find((user) => user.id === comment.user_id), user: users.find((user) => user.id === comment.user_id),
createdAt: comment.created_at, createdAt: comment.created_at,
}; };
} }
function threadComments(thread, commentsByParentId) {
if (!thread) {
return [];
}
return thread.map((comment) => ({
...comment,
comments: threadComments(commentsByParentId[comment.id], commentsByParentId),
}));
}
function nestComments(comments) {
const commentsByParentId = comments.reduce((acc, comment) => {
if (!acc[comment.parentId]) {
acc[comment.parentId] = [];
}
acc[comment.parentId].push(comment);
return acc;
}, {});
return threadComments(commentsByParentId.null, commentsByParentId);
}
async function fetchPostComments(postId, { limit = 100 } = {}) { async function fetchPostComments(postId, { limit = 100 } = {}) {
const comments = await knex('comments') const comments = await knex('comments')
.where('post_id', postId) .where('post_id', postId)
.orderBy('created_at', 'asc') .orderBy('created_at', 'desc')
.limit(limit); .limit(limit);
const users = await fetchUsers(comments.map((comment) => comment.user_id)); const users = await fetchUsers(comments.map((comment) => comment.user_id));
const curatedComments = comments.map((comment) => curateDatabaseComment(comment, { users }));
return comments.map((comment) => curateDatabaseComment(comment, { users })); const nestedComments = nestComments(curatedComments);
return nestedComments;
} }
async function addComment(comment, postId, user) { async function addComment(comment, postId, user) {
await verifyPrivilege('addComment', user); await verifyPrivilege('addComment', user);
const commentEntry = await knex('comments') if (!comment.body) {
throw new HttpError({
statusMessage: 'Comment cannot be empty',
statusCode: 400,
});
}
const [commentEntry] = await knex('comments')
.insert({ .insert({
body: comment.body, body: comment.body,
post_id: postId, post_id: postId,
parent_id: comment.parentId,
user_id: user.id, user_id: user.id,
}) })
.returning('*'); .returning('*');
console.log(comment, user); const users = await fetchUsers([commentEntry.user_id]);
return curateDatabaseComment(commentEntry);
return curateDatabaseComment(commentEntry, { users });
} }
export { export {

View File

@ -6,7 +6,9 @@ import { HttpError } from './errors';
import { fetchShelf } from './shelves'; import { fetchShelf } from './shelves';
import { fetchUsers } from './users'; import { fetchUsers } from './users';
function curatePost(post, { shelf, users }) { function curateDatabasePost(post, {
shelf, users, votes,
}) {
const curatedPost = { const curatedPost = {
id: post.id, id: post.id,
title: post.title, title: post.title,
@ -16,13 +18,40 @@ function curatePost(post, { shelf, users }) {
createdAt: post.created_at, createdAt: post.created_at,
shelf, shelf,
user: users.find((user) => user.id === post.user_id), user: users.find((user) => user.id === post.user_id),
votes: votes[post.id],
commentCount: Number(post.comment_count), commentCount: Number(post.comment_count),
}; };
return curatedPost; return curatedPost;
} }
async function fetchShelfPosts(shelfId, { limit = 100 } = {}) { function curatePostVote(vote) {
return {
tally: Number(vote.tally),
total: Number(vote.total),
bump: !!vote.bump,
sink: !!vote.sink,
};
}
async function fetchPostVotes(postIds, user) {
const votes = await knex('posts_votes')
.select(
'post_id',
knex.raw('sum(value) as tally'),
knex.raw('count(id) as total'),
...(user ? [
knex.raw('bool_or(user_id = :userId and value = 1) as bump', { userId: knex.raw(user.id) }),
knex.raw('bool_or(user_id = :userId and value = -1) as sink', { userId: knex.raw(user.id) })]
: []),
)
.whereIn('post_id', postIds)
.groupBy('post_id');
return Object.fromEntries(votes.map((vote) => [vote.post_id, curatePostVote(vote)]));
}
async function fetchShelfPosts(shelfId, { user, limit = 100 } = {}) {
const shelf = await fetchShelf(shelfId); const shelf = await fetchShelf(shelfId);
const posts = await knex('posts') const posts = await knex('posts')
@ -33,15 +62,19 @@ async function fetchShelfPosts(shelfId, { limit = 100 } = {}) {
.groupBy('posts.id') .groupBy('posts.id')
.limit(limit); .limit(limit);
const users = await fetchUsers(posts.map((post) => post.user_id)); const [users, votes] = await Promise.all([
fetchUsers(posts.map((post) => post.user_id)),
fetchPostVotes(posts.map((post) => post.id), user),
]);
return posts.map((post) => curatePost(post, { shelf, users })); return posts.map((post) => curateDatabasePost(post, { shelf, users, votes }));
} }
async function fetchPost(postId) { async function fetchPost(postId, user) {
const post = await knex('posts') const post = await knex('posts')
.select('posts.*', knex.raw('count(comments.id) as comment_count')) .select('posts.*', knex.raw('count(comments.id) as comment_count'))
.leftJoin('comments', 'comments.post_id', 'posts.id') .leftJoin('comments', 'comments.post_id', 'posts.id')
.leftJoin('posts_votes', 'posts_votes.post_id', 'posts.id')
.where('posts.id', postId) .where('posts.id', postId)
.groupBy('posts.id') .groupBy('posts.id')
.first(); .first();
@ -53,12 +86,13 @@ async function fetchPost(postId) {
}); });
} }
const [shelf, users] = await Promise.all([ const [shelf, users, votes] = await Promise.all([
fetchShelf(post.shelf_id), fetchShelf(post.shelf_id),
fetchUsers([post.user_id]), fetchUsers([post.user_id]),
fetchPostVotes([post.id], user),
]); ]);
return curatePost(post, { shelf, users }); return curateDatabasePost(post, { shelf, users, votes });
} }
async function createPost(post, shelfId, user) { async function createPost(post, shelfId, user) {
@ -73,9 +107,7 @@ async function createPost(post, shelfId, user) {
}); });
} }
console.log(post); const [postEntry] = await knex('posts')
const postId = await knex('posts')
.insert({ .insert({
title: post.title, title: post.title,
body: post.body, body: post.body,
@ -83,13 +115,32 @@ async function createPost(post, shelfId, user) {
shelf_id: shelf.id, shelf_id: shelf.id,
user_id: user.id, user_id: user.id,
}) })
.returning('id'); .returning('*');
return postId; const users = await fetchUsers([postEntry.user_id]);
return curateDatabasePost(postEntry, { shelf, users });
}
async function votePost(postId, value, user) {
await knex('posts_votes')
.insert({
value,
post_id: postId,
user_id: user.id,
})
.onConflict(['post_id', 'user_id'])
.merge()
.returning('value');
const votes = await fetchPostVotes([postId], user);
return votes[postId];
} }
export { export {
createPost, createPost,
fetchPost, fetchPost,
fetchShelfPosts, fetchShelfPosts,
votePost,
}; };

View File

@ -36,15 +36,13 @@ async function fetchShelves({ limit = 10 } = {}) {
} }
async function createShelf(shelf, user) { async function createShelf(shelf, user) {
const shelfEntry = await knex('shelves') const [shelfEntry] = await knex('shelves')
.insert({ .insert({
slug: shelf.slug, slug: shelf.slug,
founder_id: user.id, founder_id: user.id,
}) })
.returning('*'); .returning('*');
console.log('entry', shelfEntry);
return curateDatabaseShelf(shelfEntry); return curateDatabaseShelf(shelfEntry);
} }

View File

@ -1,4 +1,4 @@
import { createPost } from '../posts'; import { createPost, votePost } from '../posts';
async function createPostApi(req, res) { async function createPostApi(req, res) {
const post = await createPost(req.body, req.params.shelfId, req.user); const post = await createPost(req.body, req.params.shelfId, req.user);
@ -6,6 +6,13 @@ async function createPostApi(req, res) {
res.send(post); res.send(post);
} }
async function votePostApi(req, res) {
const votes = await votePost(req.params.postId, req.body.value, req.user);
res.send(votes);
}
export { export {
createPostApi as createPost, createPostApi as createPost,
votePostApi as votePost,
}; };

View File

@ -25,7 +25,7 @@ import {
import { createShelf } from './shelves'; import { createShelf } from './shelves';
import { createPost } from './posts'; import { createPost, votePost } from './posts';
import { addComment } from './comments'; import { addComment } from './comments';
const logger = initLogger(); const logger = initLogger();
@ -70,6 +70,7 @@ async function startServer() {
// POSTS // POSTS
router.post('/api/shelves/:shelfId/posts', createPost); router.post('/api/shelves/:shelfId/posts', createPost);
router.post('/api/posts/:postId/votes', votePost);
// COMMENTS // COMMENTS
router.post('/api/posts/:postId/comments', addComment); router.post('/api/posts/:postId/comments', addComment);

View File

@ -1,9 +1,9 @@
import { createShelf } from '../shelves'; import { createShelf } from '../shelves';
async function createShelfApi(req) { async function createShelfApi(req, res) {
const shelf = await createShelf(req.body, req.user); const shelf = await createShelf(req.body, req.user);
return shelf; res.send(shelf);
} }
export { export {