Added front page, moved shelf navigation to header.

This commit is contained in:
DebaucheryLibrarian 2023-06-25 23:50:08 +02:00
parent 77085c5755
commit b3e5769d39
27 changed files with 554 additions and 109 deletions

View File

@ -22,6 +22,12 @@
font-size: 1rem; font-size: 1rem;
font-weight: bold; font-weight: bold;
&:hover {
cursor: pointer;
background: var(--primary);
color: var(--text-light);
}
&:focus { &:focus {
outline: none; outline: none;
} }

View File

@ -3,6 +3,7 @@
@import 'inputs'; @import 'inputs';
@import 'forms'; @import 'forms';
@import 'markdown'; @import 'markdown';
@import 'tooltip';
html, html,
body, body,

6
assets/css/tooltip.css Normal file
View File

@ -0,0 +1,6 @@
.tooltip {}
.menu-item {
display: block;
padding: .5rem;
}

View File

@ -90,4 +90,5 @@ export {
post, post,
patch, patch,
del, del,
del as delete,
}; };

View File

@ -4,7 +4,7 @@
@submit.prevent="addComment" @submit.prevent="addComment"
> >
<textarea <textarea
ref="input" ref="inputRef"
v-model="body" v-model="body"
placeholder="Write a new comment" placeholder="Write a new comment"
class="input" class="input"
@ -44,7 +44,7 @@ const props = defineProps({
}); });
const body = ref(''); const body = ref('');
const input = ref(null); const inputRef = ref(null);
async function addComment() { async function addComment() {
await api.post(`/posts/${props.post.id}/comments`, { await api.post(`/posts/${props.post.id}/comments`, {
@ -57,7 +57,7 @@ async function addComment() {
onMounted(() => { onMounted(() => {
if (props.comment) { if (props.comment) {
input.value.focus(); inputRef.value.focus();
} }
}); });
</script> </script>

View File

@ -22,7 +22,7 @@
</div> </div>
<a <a
:href="post.link || `/s/${post.shelf.slug}/post/${post.id}`" :href="post.link || `/s/${post.shelf.slug}/post/${post.id}/${post.slug}`"
target="_blank" target="_blank"
class="title-link" class="title-link"
> >
@ -36,7 +36,7 @@
<div class="header"> <div class="header">
<h2 class="title"> <h2 class="title">
<a <a
:href="`/s/${post.shelf.slug}/post/${post.id}`" :href="`/s/${post.shelf.slug}/post/${post.id}/${post.slug}`"
class="title-link" class="title-link"
>{{ post.title }}</a> >{{ post.title }}</a>
</h2> </h2>
@ -75,7 +75,7 @@
</div> </div>
<a <a
:href="`/s/${post.shelf.slug}/post/${post.id}`" :href="`/s/${post.shelf.slug}/post/${post.id}/${post.slug}`"
class="fill" class="fill"
/> />
</div> </div>
@ -133,6 +133,7 @@ async function submitVote(value) {
margin-bottom: .25rem; margin-bottom: .25rem;
background: var(--background); background: var(--background);
text-decoration: none; text-decoration: none;
overflow: hidden;
& :hover { & :hover {
cursor: pointer; cursor: pointer;
@ -150,6 +151,7 @@ async function submitVote(value) {
.header { .header {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
} }
.title { .title {
@ -223,7 +225,8 @@ async function submitVote(value) {
.meta { .meta {
display: flex; display: flex;
gap: 1rem; flex-wrap: wrap;
gap: 0 1rem;
margin-bottom: .25rem; margin-bottom: .25rem;
font-size: .9rem; font-size: .9rem;
} }

View File

@ -76,6 +76,24 @@ export async function up(knex) {
table.boolean('is_nsfw'); table.boolean('is_nsfw');
}); });
await knex.schema.createTable('shelves_subscriptions', (table) => {
table.increments('id');
table.integer('shelf_id')
.references('id')
.inTable('shelves');
table.integer('user_id')
.references('id')
.inTable('users');
table.unique(['shelf_id', 'user_id']);
table.datetime('created_at')
.notNullable()
.defaultTo(knex.fn.now());
});
await knex.schema.createTable('posts', (table) => { await knex.schema.createTable('posts', (table) => {
table.text('id', 8) table.text('id', 8)
.primary() .primary()

62
package-lock.json generated
View File

@ -30,6 +30,7 @@
"express": "^4.18.1", "express": "^4.18.1",
"express-promise-router": "^4.1.1", "express-promise-router": "^4.1.1",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"floating-vue": "^2.0.0-beta.22",
"ip-cidr": "^3.1.0", "ip-cidr": "^3.1.0",
"knex": "^2.4.2", "knex": "^2.4.2",
"knex-migrate": "^1.7.4", "knex-migrate": "^1.7.4",
@ -2126,6 +2127,19 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.3.1.tgz",
"integrity": "sha512-Bu+AMaXNjrpjh41znzHqaz3r2Nr8hHuHZT6V2LBKMhyMl0FgKA62PNYbqnfgmzOhoWZj70Zecisbo4H1rotP5g=="
},
"node_modules/@floating-ui/dom": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.1.1.tgz",
"integrity": "sha512-TpIO93+DIujg3g7SykEAGZMDtbJRrmnYRCNYSjJlvIbGhBjRSNTLVbNeDQBrzy9qDgUbiWdc7KA0uZHZ2tJmiw==",
"dependencies": {
"@floating-ui/core": "^1.1.0"
}
},
"node_modules/@hcaptcha/vue3-hcaptcha": { "node_modules/@hcaptcha/vue3-hcaptcha": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@hcaptcha/vue3-hcaptcha/-/vue3-hcaptcha-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@hcaptcha/vue3-hcaptcha/-/vue3-hcaptcha-1.2.1.tgz",
@ -4553,6 +4567,18 @@
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz",
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==" "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ=="
}, },
"node_modules/floating-vue": {
"version": "2.0.0-beta.22",
"resolved": "https://registry.npmjs.org/floating-vue/-/floating-vue-2.0.0-beta.22.tgz",
"integrity": "sha512-1iqpX3Rc3KpghLgBZ7zXfn6EceezujBUDKXYcTKPxv9P8CZxhS+3oouayd1fv3o+x/xeVSryWhF0Q7+4HEM7rA==",
"dependencies": {
"@floating-ui/dom": "~1.1.1",
"vue-resize": "^2.0.0-alpha.1"
},
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/fn.name": { "node_modules/fn.name": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz",
@ -7663,6 +7689,14 @@
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
}, },
"node_modules/vue-resize": {
"version": "2.0.0-alpha.1",
"resolved": "https://registry.npmjs.org/vue-resize/-/vue-resize-2.0.0-alpha.1.tgz",
"integrity": "sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==",
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -9203,6 +9237,19 @@
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.41.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.41.0.tgz",
"integrity": "sha512-LxcyMGxwmTh2lY9FwHPGWOHmYFCZvbrFCBZL4FzSSsxsRPuhrYUg/49/0KDfW8tnIEaEHtfmn6+NPN+1DqaNmA==" "integrity": "sha512-LxcyMGxwmTh2lY9FwHPGWOHmYFCZvbrFCBZL4FzSSsxsRPuhrYUg/49/0KDfW8tnIEaEHtfmn6+NPN+1DqaNmA=="
}, },
"@floating-ui/core": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.3.1.tgz",
"integrity": "sha512-Bu+AMaXNjrpjh41znzHqaz3r2Nr8hHuHZT6V2LBKMhyMl0FgKA62PNYbqnfgmzOhoWZj70Zecisbo4H1rotP5g=="
},
"@floating-ui/dom": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.1.1.tgz",
"integrity": "sha512-TpIO93+DIujg3g7SykEAGZMDtbJRrmnYRCNYSjJlvIbGhBjRSNTLVbNeDQBrzy9qDgUbiWdc7KA0uZHZ2tJmiw==",
"requires": {
"@floating-ui/core": "^1.1.0"
}
},
"@hcaptcha/vue3-hcaptcha": { "@hcaptcha/vue3-hcaptcha": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@hcaptcha/vue3-hcaptcha/-/vue3-hcaptcha-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@hcaptcha/vue3-hcaptcha/-/vue3-hcaptcha-1.2.1.tgz",
@ -11047,6 +11094,15 @@
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz",
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==" "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ=="
}, },
"floating-vue": {
"version": "2.0.0-beta.22",
"resolved": "https://registry.npmjs.org/floating-vue/-/floating-vue-2.0.0-beta.22.tgz",
"integrity": "sha512-1iqpX3Rc3KpghLgBZ7zXfn6EceezujBUDKXYcTKPxv9P8CZxhS+3oouayd1fv3o+x/xeVSryWhF0Q7+4HEM7rA==",
"requires": {
"@floating-ui/dom": "~1.1.1",
"vue-resize": "^2.0.0-alpha.1"
}
},
"fn.name": { "fn.name": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz",
@ -13211,6 +13267,12 @@
} }
} }
}, },
"vue-resize": {
"version": "2.0.0-alpha.1",
"resolved": "https://registry.npmjs.org/vue-resize/-/vue-resize-2.0.0-alpha.1.tgz",
"integrity": "sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==",
"requires": {}
},
"which": { "which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -47,6 +47,7 @@
"express": "^4.18.1", "express": "^4.18.1",
"express-promise-router": "^4.1.1", "express-promise-router": "^4.1.1",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"floating-vue": "^2.0.0-beta.22",
"ip-cidr": "^3.1.0", "ip-cidr": "^3.1.0",
"knex": "^2.4.2", "knex": "^2.4.2",
"knex-migrate": "^1.7.4", "knex-migrate": "^1.7.4",

View File

@ -121,7 +121,9 @@ async function signup() {
<style scoped> <style scoped>
.content { .content {
display: flex; display: flex;
flex-direction: column;
justify-content: center; justify-content: center;
flex-grow: 1;
align-items: center; align-items: center;
} }

View File

@ -91,6 +91,8 @@ async function signup() {
<style scoped> <style scoped>
.content { .content {
display: flex; display: flex;
flex-grow: 1;
flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }

View File

@ -1,10 +1,12 @@
import { fetchShelves } from '../../src/shelves'; import { fetchUserPosts, fetchAllPosts } from '../../src/posts';
async function getPageData() { async function getPageData(pageContext) {
const shelves = await fetchShelves(); const posts = pageContext.session.user
? await fetchUserPosts(pageContext.session.user)
: await fetchAllPosts();
return { return {
shelves, posts,
}; };
} }

View File

@ -1,37 +1,20 @@
<template> <template>
<div class="content"> <div class="content">
<ul> <ul class="posts nolist">
<li><a
href="/shelf/create"
class="link"
>Create new shelf</a></li>
<li v-if="!me"><a
href="/account/login"
class="link"
>Log in</a></li>
<li><a
href="/account/create"
class="link"
>Sign up</a></li>
</ul>
<ul>
<li <li
v-for="shelf in shelves" v-for="post in posts"
:key="shelf.id" :key="post.id"
><a >
:href="`/s/${shelf.slug}`" <Post :post="post" />
class="link" </li>
>{{ shelf.slug }}</a></li>
</ul> </ul>
</div> </div>
</template> </template>
<script setup> <script setup>
import { usePageContext } from '../../renderer/usePageContext'; import { usePageContext } from '../../renderer/usePageContext';
import Post from '../../components/posts/post.vue';
const { me, pageData } = usePageContext(); const { pageData } = usePageContext();
const { shelves } = pageData; const { posts } = pageData;
</script> </script>

View File

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

View File

@ -65,7 +65,7 @@ const {
<style scoped> <style scoped>
.content { .content {
width: 100%; padding: 0;
} }
.body { .body {

View File

@ -3,7 +3,7 @@ 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, { user: pageContext.session.user });
const posts = await fetchShelfPosts(pageContext.routeParams.id, { user: pageContext.session.user, limit: 50 }); const posts = await fetchShelfPosts(pageContext.routeParams.id, { user: pageContext.session.user, limit: 50 });
if (!shelf) { if (!shelf) {

View File

@ -33,6 +33,7 @@ const {
.posts { .posts {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column;
margin-bottom: 1rem; margin-bottom: 1rem;
} }

View File

@ -7,6 +7,8 @@ import { createApp } from './app';
import { useUser } from '../stores/user'; import { useUser } from '../stores/user';
import logoUrl from './logo.svg'; import logoUrl from './logo.svg';
import { fetchAllShelves } from '../src/shelves';
async function render(pageContext) { async function render(pageContext) {
// See https://vite-plugin-ssr.com/head // See https://vite-plugin-ssr.com/head
const { documentProps } = pageContext.exports; const { documentProps } = pageContext.exports;
@ -48,11 +50,15 @@ async function render(pageContext) {
async function onBeforeRender(pageContext) { async function onBeforeRender(pageContext) {
try { try {
const pageData = await pageContext.exports.getPageData?.(pageContext, pageContext.session); const pageData = await pageContext.exports.getPageData?.(pageContext, pageContext.session);
const shelves = await fetchAllShelves({ user: pageContext.session.user });
return { return {
pageContext: { pageContext: {
// initialState: store.state.value, // initialState: store.state.value,
pageData, pageData: {
...pageData,
shelves,
},
me: pageContext.session.user, me: pageContext.session.user,
now: new Date(), now: new Date(),
}, },
@ -62,9 +68,7 @@ async function onBeforeRender(pageContext) {
throw RenderErrorPage({ throw RenderErrorPage({
pageContext: { pageContext: {
pageProps: { pageProps: error,
errorInfo: error.statusMessage,
},
me: pageContext.session.user, me: pageContext.session.user,
now: new Date(), now: new Date(),
}, },

View File

@ -1,16 +1,16 @@
<template> <template>
<div class="content"> <div class="content">
<div v-if="is404"> <div v-if="is404">
<h1>404 Page Not Found</h1> <h1>{{ statusCode }}</h1>
<p v-if="errorInfo">{{ errorInfo }}</p> <p v-if="statusMessage">{{ statusMessage }}</p>
<p v-else>This page could not be found.</p> <p v-else>This page could not be found.</p>
</div> </div>
<div v-else> <div v-else>
<h1>500 Internal Error</h1> <h1>500 Internal Error</h1>
<p v-if="errorInfo">{{ errorInfo }}</p> <p v-if="statusMessage">{{ errorInfo }}</p>
<p v-else>Something went wrong.</p> <p v-else>Something went wrong.</p>
</div> </div>
</div> </div>
@ -20,5 +20,5 @@
import { usePageContext } from './usePageContext'; import { usePageContext } from './usePageContext';
const { pageProps } = usePageContext(); const { pageProps } = usePageContext();
const { is404, errorInfo } = pageProps; const { is404, statusCode, statusMessage } = pageProps;
</script> </script>

View File

@ -1,9 +1,12 @@
import { createSSRApp, h } from 'vue'; import { createSSRApp, h } from 'vue';
import { createPinia } from 'pinia'; import { createPinia } from 'pinia';
import FloatingVue from 'floating-vue';
import Container from './container.vue'; import Container from './container.vue';
import { setPageContext } from './usePageContext'; import { setPageContext } from './usePageContext';
import '../assets/css/style.css'; import '../assets/css/style.css';
import 'floating-vue/dist/style.css';
function createApp(pageContext) { function createApp(pageContext) {
const PageWithLayout = { const PageWithLayout = {
@ -20,6 +23,7 @@ function createApp(pageContext) {
const store = createPinia(); const store = createPinia();
app.use(store); app.use(store);
app.use(FloatingVue);
// We make pageContext available from any Vue component // We make pageContext available from any Vue component
setPageContext(app, pageContext); setPageContext(app, pageContext);

View File

@ -18,11 +18,63 @@
> >
</div> </div>
<span <div class="actions">
v-if="me" <VDropdown>
class="userpanel" <button class="button">Explore</button>
@click="logout"
>{{ me.username }}</span> <template #popper>
<ul class="tooltip nolist">
<li class="tooltip-item">
<a
href="/shelf/create"
class="menu-item"
>
<button class="button button-submit">New shelf</button>
</a>
</li>
<li
v-for="shelf in shelves"
:key="shelf.id"
class="tooltip-item"
>
<a
:href="`/s/${shelf.slug}`"
class="link shelf"
>s/{{ shelf.slug }}</a>
</li>
</ul>
</template>
</VDropdown>
</div>
<VDropdown v-if="me">
<span class="userpanel">{{ me.username }}</span>
<template #popper>
<ul class="tooltip nolist">
<li>
<a
:href="`/user/${me.username}`"
class="link menu-item"
>Profile</a>
</li>
<li class="menu-item">
<button
class="button"
@click="logout"
>Log out</button>
</li>
</ul>
</template>
</VDropdown>
<a
v-else
href="/account/login"
class="link userpanel"
>Log in</a>
</header> </header>
<div class="content-container"> <div class="content-container">
@ -38,7 +90,9 @@ import { del } from '../assets/js/api';
import { navigate } from '../assets/js/navigate'; import { navigate } from '../assets/js/navigate';
import { usePageContext } from './usePageContext'; import { usePageContext } from './usePageContext';
const { me } = usePageContext(); const { pageData, me } = usePageContext();
const { shelves } = pageData;
const version = CLIENT_VERSION; const version = CLIENT_VERSION;
async function logout() { async function logout() {
@ -48,6 +102,13 @@ async function logout() {
</script> </script>
<style> <style>
.content {
display: flex;
flex-grow: 1;
flex-direction: column;
padding: 1rem;
}
.logo svg { .logo svg {
height: 100%; height: 100%;
width: auto; width: auto;
@ -71,6 +132,7 @@ async function logout() {
.header { .header {
display: flex; display: flex;
align-items: center;
flex-shrink: 0; flex-shrink: 0;
background: var(--background); background: var(--background);
box-shadow: 0 0 3px var(--shadow-weak-10); box-shadow: 0 0 3px var(--shadow-weak-10);
@ -99,11 +161,32 @@ async function logout() {
} }
} }
.actions {
display: flex;
align-items: center;
}
.userpanel { .userpanel {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 1rem; padding: 1rem;
font-weight: bold; font-weight: bold;
cursor: pointer;
}
.tooltip {
width: 10rem;
padding-bottom: .25rem;
.button {
width: 100%;
}
.shelf {
display: block;
box-sizing: border-box;
padding: .25rem .5rem;
}
} }
.footer { .footer {

View File

@ -3,8 +3,9 @@ import { verifyPrivilege } from './privileges';
import knex from './knex'; import knex from './knex';
import { HttpError } from './errors'; import { HttpError } from './errors';
import { fetchShelf } from './shelves'; import { fetchShelf, fetchShelves } from './shelves';
import { fetchUsers } from './users'; import { fetchUsers } from './users';
import slugify from './utils/slugify';
const emptyVote = { const emptyVote = {
tally: 0, tally: 0,
@ -14,18 +15,19 @@ const emptyVote = {
}; };
function curateDatabasePost(post, { function curateDatabasePost(post, {
shelf, users, votes, shelf, shelves, users, vote, votes,
}) { }) {
const curatedPost = { const curatedPost = {
id: post.id, id: post.id,
title: post.title, title: post.title,
slug: slugify(post.title, { limit: 50 }),
body: post.body, body: post.body,
link: post.link, link: post.link,
shelfId: post.shelf_id, shelfId: post.shelf_id,
createdAt: post.created_at, createdAt: post.created_at,
shelf, shelf: shelf || shelves?.[post.shelf_id],
user: users.find((user) => user.id === post.user_id), user: users.find((user) => user.id === post.user_id),
vote: votes[post.id] || emptyVote, vote: vote || votes?.[post.id] || emptyVote,
commentCount: Number(post.comment_count), commentCount: Number(post.comment_count),
}; };
@ -58,13 +60,13 @@ async function fetchPostVotes(postIds, user) {
return Object.fromEntries(votes.map((vote) => [vote.post_id, curatePostVote(vote)])); return Object.fromEntries(votes.map((vote) => [vote.post_id, curatePostVote(vote)]));
} }
async function fetchShelfPosts(shelfId, { user, limit = 100 } = {}) { async function fetchShelfPosts(shelfIds, { user, limit = 100 } = {}) {
const shelf = await fetchShelf(shelfId); const shelves = await fetchShelves([].concat(shelfIds));
const posts = await knex('posts') const posts = 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')
.where('shelf_id', shelf.id) .whereIn('shelf_id', Object.keys(shelves))
.orderBy('created_at', 'desc') .orderBy('created_at', 'desc')
.groupBy('posts.id') .groupBy('posts.id')
.limit(limit); .limit(limit);
@ -74,7 +76,50 @@ async function fetchShelfPosts(shelfId, { user, limit = 100 } = {}) {
fetchPostVotes(posts.map((post) => post.id), user), fetchPostVotes(posts.map((post) => post.id), user),
]); ]);
return posts.map((post) => curateDatabasePost(post, { shelf, users, votes })); return posts.map((post) => curateDatabasePost(post, { shelves, users, votes }));
}
async function fetchUserPosts(user, { limit = 20 } = {}) {
if (!user) {
throw new HttpError({
statusMessage: 'You are not logged in',
statusCode: 401,
});
}
const posts = await knex('shelves_subscriptions')
.select('posts.*', knex.raw('count(comments.id) as comment_count'))
.leftJoin('posts', 'posts.shelf_id', 'shelves_subscriptions.shelf_id')
.leftJoin('comments', 'comments.post_id', 'posts.id')
.where('shelves_subscriptions.user_id', user.id)
.groupBy('posts.id')
.orderBy('created_at', 'desc')
.limit(limit);
const [shelves, users, votes] = await Promise.all([
fetchShelves(posts.map((post) => post.shelf_id)),
fetchUsers(posts.map((post) => post.user_id)),
fetchPostVotes(posts.map((post) => post.id), user),
]);
return posts.map((post) => curateDatabasePost(post, { shelves, users, votes }));
}
async function fetchAllPosts({ user, limit = 20 } = {}) {
const posts = await knex('posts')
.select('posts.*', knex.raw('count(comments.id) as comment_count'))
.leftJoin('comments', 'comments.post_id', 'posts.id')
.orderBy('created_at', 'desc')
.groupBy('posts.id')
.limit(limit);
const [shelves, users, votes] = await Promise.all([
fetchShelves(posts.map((post) => post.shelf_id)),
fetchUsers(posts.map((post) => post.user_id)),
fetchPostVotes(posts.map((post) => post.id), user),
]);
return posts.map((post) => curateDatabasePost(post, { shelves, users, votes }));
} }
async function fetchPost(postId, user) { async function fetchPost(postId, user) {
@ -102,33 +147,6 @@ async function fetchPost(postId, user) {
return curateDatabasePost(post, { shelf, users, votes }); return curateDatabasePost(post, { shelf, users, votes });
} }
async function createPost(post, shelfId, user) {
await verifyPrivilege('createPost', user);
const shelf = await fetchShelf(shelfId);
if (!shelf) {
throw new HttpError({
statusMessage: 'The target shelf does not exist',
statusCode: 404,
});
}
const [postEntry] = await knex('posts')
.insert({
title: post.title,
body: post.body,
link: post.link,
shelf_id: shelf.id,
user_id: user.id,
})
.returning('*');
const users = await fetchUsers([postEntry.user_id]);
return curateDatabasePost(postEntry, { shelf, users });
}
async function votePost(postId, value, user) { async function votePost(postId, value, user) {
if (value === 0) { if (value === 0) {
await knex('posts_votes') await knex('posts_votes')
@ -153,9 +171,41 @@ async function votePost(postId, value, user) {
return votes[postId] || emptyVote; return votes[postId] || emptyVote;
} }
async function createPost(post, shelfId, user) {
await verifyPrivilege('createPost', user);
const shelf = await fetchShelf(shelfId);
if (!shelf) {
throw new HttpError({
statusMessage: 'The target shelf does not exist',
statusCode: 404,
});
}
const [postEntry] = await knex('posts')
.insert({
title: post.title,
body: post.body,
link: post.link,
shelf_id: shelf.id,
user_id: user.id,
})
.returning('*');
const [users, vote] = await Promise.all([
fetchUsers([postEntry.user_id]),
votePost(postEntry.id, 1, user),
]);
return curateDatabasePost(postEntry, { shelf, users, vote });
}
export { export {
createPost, createPost,
fetchPost, fetchPost,
fetchShelfPosts, fetchShelfPosts,
fetchUserPosts,
fetchAllPosts,
votePost, votePost,
}; };

View File

@ -1,4 +1,5 @@
import knex from './knex'; import knex from './knex';
import { HttpError } from './errors';
function curateDatabaseShelf(shelf) { function curateDatabaseShelf(shelf) {
if (!shelf) { if (!shelf) {
@ -9,32 +10,70 @@ function curateDatabaseShelf(shelf) {
id: shelf.id, id: shelf.id,
slug: shelf.slug, slug: shelf.slug,
name: shelf.slug, name: shelf.slug,
subscribed: !!shelf.subscribed,
}; };
} }
async function fetchShelf(shelfId) { function identityQuery(builder, shelfId) {
const id = Number(shelfId);
if (Number.isNaN(id)) {
builder.where('slug', shelfId);
return;
}
builder.where('id', shelfId);
}
function identitiesQuery(builder, shelfIds) {
const ids = Array.from(new Set(shelfIds.filter((shelfId) => !Number.isNaN(Number(shelfId)))));
const slugs = Array.from(new Set(shelfIds.filter((shelfId) => Number.isNaN(Number(shelfId)))));
builder
.whereIn('id', ids)
.orWhereIn('slug', slugs);
}
function isMemberQuery(builder, user) {
if (user) {
builder.select(knex.raw('shelves_subscriptions.id IS NOT NULL as subscribed'));
builder.leftJoin('shelves_subscriptions', (joinBuilder) => {
joinBuilder.on('shelf_id', 'shelves.id');
joinBuilder.andOnVal('user_id', user.id);
});
}
}
async function fetchShelf(shelfId, { user } = {}) {
const shelfEntry = await knex('shelves') const shelfEntry = await knex('shelves')
.where((builder) => { .select('shelves.*')
const id = Number(shelfId); .modify((builder) => isMemberQuery(builder, user))
.where((builder) => identityQuery(builder, shelfId))
if (Number.isNaN(id)) {
builder.where('slug', shelfId);
return;
}
builder.where('id', shelfId);
})
.first(); .first();
return curateDatabaseShelf(shelfEntry); return curateDatabaseShelf(shelfEntry);
} }
async function fetchShelves({ limit = 10 } = {}) { async function fetchAllShelves({ user, limit = 100 } = {}) {
const shelfEntries = await knex('shelves').limit(limit); const shelfEntries = await knex('shelves')
.select('shelves.*')
.modify((builder) => isMemberQuery(builder, user))
.orderBy('slug', 'asc')
.limit(limit);
return shelfEntries.map((shelfEntry) => curateDatabaseShelf(shelfEntry)); return shelfEntries.map((shelfEntry) => curateDatabaseShelf(shelfEntry));
} }
async function fetchShelves(shelfIds, { user } = {}) {
const shelfEntries = await knex('shelves')
.select('shelves.*')
.where((builder) => identitiesQuery(builder, shelfIds))
.modify((builder) => isMemberQuery(builder, user));
return Object.fromEntries(shelfEntries.map((shelfEntry) => [shelfEntry.id, curateDatabaseShelf(shelfEntry)]));
}
async function createShelf(shelf, user) { async function createShelf(shelf, user) {
const [shelfEntry] = await knex('shelves') const [shelfEntry] = await knex('shelves')
.insert({ .insert({
@ -46,9 +85,57 @@ async function createShelf(shelf, user) {
return curateDatabaseShelf(shelfEntry); return curateDatabaseShelf(shelfEntry);
} }
async function subscribe(shelfId, user) {
const shelf = await fetchShelf(shelfId);
if (!shelf) {
throw new HttpError({
statusMessage: `Shelf ${shelfId} does not exist`,
statusCode: 404,
});
}
try {
await knex('shelves_subscriptions').insert({
shelf_id: shelf.id,
user_id: user.id,
});
} catch (error) {
if (error.code === '23505') {
throw new HttpError({
statusMessage: `You are already subscribed to s/${shelf.slug}`,
statusCode: 409,
});
}
throw error;
}
}
async function unsubscribe(shelfId, user) {
const shelf = await fetchShelf(shelfId);
if (!shelf) {
throw new HttpError({
statusMessage: `Shelf ${shelfId} does not exist`,
statusCode: 404,
});
}
await knex('shelves_subscriptions')
.where({
shelf_id: shelf.id,
user_id: user.id,
})
.delete();
}
export { export {
fetchShelf, fetchShelf,
fetchShelves, fetchShelves,
fetchAllShelves,
createShelf, createShelf,
curateDatabaseShelf, curateDatabaseShelf,
subscribe,
unsubscribe,
}; };

78
src/utils/slugify.js Executable file
View File

@ -0,0 +1,78 @@
const substitutes = {
à: 'a',
á: 'a',
ä: 'a',
å: 'a',
ã: 'a',
æ: 'ae',
ç: 'c',
è: 'e',
é: 'e',
ë: 'e',
: 'e',
ì: 'i',
í: 'i',
ï: 'i',
ĩ: 'i',
ǹ: 'n',
ń: 'n',
ñ: 'n',
ò: 'o',
ó: 'o',
ö: 'o',
õ: 'o',
ø: 'o',
œ: 'oe',
ß: 'ss',
ù: 'u',
ú: 'u',
ü: 'u',
ũ: 'u',
: 'y',
ý: 'y',
ÿ: 'y',
: 'y',
};
function slugify(strings, {
delimiter = '-',
encode = false,
removeAccents = true,
removePunctuation = false,
limit = 1000,
} = {}) {
if (!strings || (typeof strings !== 'string' && !Array.isArray(strings))) {
return strings;
}
const slugComponents = []
.concat(strings)
.filter(Boolean)
.flatMap((string) => string
.trim()
.toLowerCase()
.replace(removePunctuation && /[.,:;'"_-]/g, '')
.match(/[A-Za-zÀ-ÖØ-öø-ÿ0-9]+/g));
if (!slugComponents) {
return '';
}
const slug = slugComponents.reduce((acc, component, index) => {
const accSlug = `${acc}${index > 0 ? delimiter : ''}${component}`;
if (accSlug.length < limit) {
if (removeAccents) {
return accSlug.replace(/[à-ÿ]/g, (match) => substitutes[match] || '');
}
return accSlug;
}
return acc;
}, '');
return encode ? encodeURI(slug) : slug;
}
export default slugify;

View File

@ -23,7 +23,11 @@ import {
createUser, createUser,
} from './users'; } from './users';
import { createShelf } from './shelves'; import {
createShelf,
subscribe,
unsubscribe,
} from './shelves';
import { createPost, votePost } from './posts'; import { createPost, votePost } from './posts';
import { addComment } from './comments'; import { addComment } from './comments';
@ -68,6 +72,10 @@ async function startServer() {
// SHELVES // SHELVES
router.post('/api/shelves', createShelf); router.post('/api/shelves', createShelf);
// MEMBERS
router.post('/api/shelves/:shelfId/members', subscribe);
router.delete('/api/shelves/:shelfId/members', unsubscribe);
// POSTS // POSTS
router.post('/api/shelves/:shelfId/posts', createPost); router.post('/api/shelves/:shelfId/posts', createPost);
router.post('/api/posts/:postId/votes', votePost); router.post('/api/posts/:postId/votes', votePost);

View File

@ -1,4 +1,4 @@
import { createShelf } from '../shelves'; import { createShelf, subscribe, unsubscribe } from '../shelves';
async function createShelfApi(req, res) { async function createShelfApi(req, res) {
const shelf = await createShelf(req.body, req.user); const shelf = await createShelf(req.body, req.user);
@ -6,6 +6,18 @@ async function createShelfApi(req, res) {
res.send(shelf); res.send(shelf);
} }
async function subscribeApi(req, res) {
await subscribe(req.params.shelfId, req.user);
res.status(204).send();
}
async function unsubscribeApi(req, res) {
await unsubscribe(req.params.shelfId, req.user);
res.status(204).send();
}
export { export {
createShelfApi as createShelf, createShelfApi as createShelf,
subscribeApi as subscribe,
unsubscribeApi as unsubscribe,
}; };

View File

@ -15,6 +15,18 @@
<div class="sidebar"> <div class="sidebar">
<h4 class="sidebar-title">{{ shelf.slug }}</h4> <h4 class="sidebar-title">{{ shelf.slug }}</h4>
<button
v-if="subscribed"
class="button button-submit subscribe"
@click="unsubscribe"
>Unsubscribe</button>
<button
v-else
class="button button-submit subscribe"
@click="subscribe"
>Subscribe</button>
<form <form
class="form compose" class="form compose"
@submit.prevent="submitPost" @submit.prevent="submitPost"
@ -44,7 +56,10 @@
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button class="button button-submit">Post</button> <button
class="button button-submit"
:disabled="!body && !link"
>Post</button>
</div> </div>
</form> </form>
</div> </div>
@ -53,7 +68,7 @@
</template> </template>
<script setup> <script setup>
import { ref, defineProps } from 'vue'; import { ref } from 'vue';
import * as api from '../../assets/js/api'; import * as api from '../../assets/js/api';
import { navigate } from '../../assets/js/navigate'; import { navigate } from '../../assets/js/navigate';
import { usePageContext } from '../../renderer/usePageContext'; import { usePageContext } from '../../renderer/usePageContext';
@ -68,8 +83,9 @@ const props = defineProps({
}); });
const title = ref(); const title = ref();
const link = ref(); const link = ref(null);
const body = ref(); const body = ref(null);
const subscribed = ref(props.shelf.subscribed);
async function submitPost() { async function submitPost() {
const post = await api.post(`/shelves/${routeParams.id}/posts`, { const post = await api.post(`/shelves/${routeParams.id}/posts`, {
@ -78,7 +94,17 @@ async function submitPost() {
body: body.value, body: body.value,
}); });
navigate(`/s/${props.shelf.slug}/post/${post.id}`); navigate(`/s/${props.shelf.slug}/post/${post.id}/${post.slug}`);
}
async function subscribe() {
subscribed.value = true;
await api.post(`/shelves/${routeParams.id}/members`);
}
async function unsubscribe() {
subscribed.value = false;
await api.delete(`/shelves/${routeParams.id}/members`);
} }
</script> </script>
@ -123,4 +149,9 @@ async function submitPost() {
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
font-size: 1.25rem; font-size: 1.25rem;
} }
.subscribe {
width: 100%;
margin-bottom: 1rem;
}
</style> </style>