Added voting. Improved comments.
This commit is contained in:
parent
754a89b913
commit
f42daa2f83
|
@ -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>
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
export default '/s/@id/post/@postId';
|
export default '/s/@shelfId/post/@postId';
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
export default '/shelf/create';
|
|
@ -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>
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
export default '/s/@id';
|
|
@ -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({
|
|
@ -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>
|
||||||
|
|
|
@ -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'];
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
75
src/posts.js
75
src/posts.js
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
Loading…
Reference in New Issue