Compare commits
5 Commits
77085c5755
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| dcc28b1cd8 | |||
| 83fcdba93a | |||
| e5b102ce07 | |||
| cb8b7153b1 | |||
| b3e5769d39 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,6 +2,7 @@ node_modules/
|
||||
dist/
|
||||
config/
|
||||
log/
|
||||
media/
|
||||
!config/default.js
|
||||
assets/js/config/
|
||||
!assets/js/config/default.js
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
background: var(--primary);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@import 'inputs';
|
||||
@import 'forms';
|
||||
@import 'markdown';
|
||||
@import 'tooltip';
|
||||
|
||||
html,
|
||||
body,
|
||||
|
||||
6
assets/css/tooltip.css
Normal file
6
assets/css/tooltip.css
Normal file
@@ -0,0 +1,6 @@
|
||||
.tooltip {}
|
||||
|
||||
.menu-item {
|
||||
display: block;
|
||||
padding: .5rem;
|
||||
}
|
||||
@@ -90,4 +90,5 @@ export {
|
||||
post,
|
||||
patch,
|
||||
del,
|
||||
del as delete,
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
@submit.prevent="addComment"
|
||||
>
|
||||
<textarea
|
||||
ref="input"
|
||||
ref="inputRef"
|
||||
v-model="body"
|
||||
placeholder="Write a new comment"
|
||||
class="input"
|
||||
@@ -44,7 +44,7 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const body = ref('');
|
||||
const input = ref(null);
|
||||
const inputRef = ref(null);
|
||||
|
||||
async function addComment() {
|
||||
await api.post(`/posts/${props.post.id}/comments`, {
|
||||
@@ -57,7 +57,7 @@ async function addComment() {
|
||||
|
||||
onMounted(() => {
|
||||
if (props.comment) {
|
||||
input.value.focus();
|
||||
inputRef.value.focus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -22,12 +22,19 @@
|
||||
</div>
|
||||
|
||||
<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"
|
||||
class="title-link"
|
||||
>
|
||||
<img
|
||||
v-if="post.hasThumbnail"
|
||||
class="thumbnail"
|
||||
:src="`/media/thumbnails/${post.id}.jpeg`"
|
||||
>
|
||||
|
||||
<img
|
||||
v-else
|
||||
class="thumbnail missing"
|
||||
:src="blockedIcon"
|
||||
>
|
||||
</a>
|
||||
@@ -36,7 +43,7 @@
|
||||
<div class="header">
|
||||
<h2 class="title">
|
||||
<a
|
||||
:href="`/s/${post.shelf.slug}/post/${post.id}`"
|
||||
:href="`/s/${post.shelf.slug}/post/${post.id}/${post.slug}`"
|
||||
class="title-link"
|
||||
>{{ post.title }}</a>
|
||||
</h2>
|
||||
@@ -75,7 +82,7 @@
|
||||
</div>
|
||||
|
||||
<a
|
||||
:href="`/s/${post.shelf.slug}/post/${post.id}`"
|
||||
:href="`/s/${post.shelf.slug}/post/${post.id}/${post.slug}`"
|
||||
class="fill"
|
||||
/>
|
||||
</div>
|
||||
@@ -133,6 +140,7 @@ async function submitVote(value) {
|
||||
margin-bottom: .25rem;
|
||||
background: var(--background);
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
|
||||
& :hover {
|
||||
cursor: pointer;
|
||||
@@ -150,6 +158,7 @@ async function submitVote(value) {
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -170,11 +179,16 @@ async function submitVote(value) {
|
||||
width: 7rem;
|
||||
height: 4rem;
|
||||
box-sizing: border-box;
|
||||
padding: 1rem;
|
||||
border-radius: .25rem;
|
||||
margin: .5rem;
|
||||
object-fit: cover;
|
||||
|
||||
&.missing {
|
||||
padding: 1rem;
|
||||
background: var(--grey-light-10);
|
||||
opacity: .25;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.votes {
|
||||
@@ -223,7 +237,8 @@ async function submitVote(value) {
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 1rem;
|
||||
margin-bottom: .25rem;
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,24 @@ export async function up(knex) {
|
||||
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) => {
|
||||
table.text('id', 8)
|
||||
.primary()
|
||||
@@ -97,6 +115,10 @@ export async function up(knex) {
|
||||
.references('id')
|
||||
.inTable('users');
|
||||
|
||||
table.text('thumbnail')
|
||||
.notNullable()
|
||||
.default(false);
|
||||
|
||||
table.datetime('created_at')
|
||||
.notNullable()
|
||||
.defaultTo(knex.fn.now());
|
||||
@@ -187,6 +209,7 @@ export async function down(knex) {
|
||||
await knex.schema.dropTableIfExists('posts_votes');
|
||||
await knex.schema.dropTableIfExists('posts');
|
||||
await knex.schema.dropTableIfExists('shelves_settings');
|
||||
await knex.schema.dropTableIfExists('shelves_subscriptions');
|
||||
await knex.schema.dropTableIfExists('shelves');
|
||||
await knex.schema.dropTableIfExists('users');
|
||||
|
||||
|
||||
2471
package-lock.json
generated
2471
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "shack",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0",
|
||||
"description": "Shack is a self-hosted social news aggregate",
|
||||
"main": "src/web/server.js",
|
||||
"type": "module",
|
||||
@@ -20,6 +20,7 @@
|
||||
"build": "vite build",
|
||||
"server": "node --experimental-specifier-resolution=node ./src/web/server",
|
||||
"server:prod": "cross-env NODE_ENV=production node ./src/web/server",
|
||||
"thumbs": "node --experimental-specifier-resolution=node ./src/cli thumbs",
|
||||
"migrate-make": "knex migrate:make",
|
||||
"migrate": "knex migrate:latest",
|
||||
"rollback": "knex migrate:rollback"
|
||||
@@ -47,6 +48,7 @@
|
||||
"express": "^4.18.1",
|
||||
"express-promise-router": "^4.1.1",
|
||||
"express-session": "^1.17.3",
|
||||
"floating-vue": "^2.0.0-beta.22",
|
||||
"ip-cidr": "^3.1.0",
|
||||
"knex": "^2.4.2",
|
||||
"knex-migrate": "^1.7.4",
|
||||
@@ -54,8 +56,10 @@
|
||||
"pg": "^8.11.0",
|
||||
"pinia": "^2.1.3",
|
||||
"redis": "^4.6.6",
|
||||
"sharp": "^0.32.1",
|
||||
"short-uuid": "^4.2.2",
|
||||
"sirv": "^2.0.2",
|
||||
"unprint": "^0.9.3",
|
||||
"vite": "^4.0.3",
|
||||
"vite-plugin-ssr": "^0.4.126",
|
||||
"vite-plugin-vue-markdown": "^0.23.5",
|
||||
|
||||
@@ -121,7 +121,9 @@ async function signup() {
|
||||
<style scoped>
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,8 @@ async function signup() {
|
||||
<style scoped>
|
||||
.content {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { fetchShelves } from '../../src/shelves';
|
||||
import { fetchUserPosts, fetchAllPosts } from '../../src/posts';
|
||||
|
||||
async function getPageData() {
|
||||
const shelves = await fetchShelves();
|
||||
async function getPageData(pageContext) {
|
||||
const posts = pageContext.session.user
|
||||
? await fetchUserPosts(pageContext.session.user)
|
||||
: await fetchAllPosts();
|
||||
|
||||
return {
|
||||
shelves,
|
||||
posts,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,37 +1,20 @@
|
||||
<template>
|
||||
<div class="content">
|
||||
<ul>
|
||||
<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>
|
||||
<ul class="posts nolist">
|
||||
<li
|
||||
v-for="shelf in shelves"
|
||||
:key="shelf.id"
|
||||
><a
|
||||
:href="`/s/${shelf.slug}`"
|
||||
class="link"
|
||||
>{{ shelf.slug }}</a></li>
|
||||
v-for="post in posts"
|
||||
:key="post.id"
|
||||
>
|
||||
<Post :post="post" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { usePageContext } from '../../renderer/usePageContext';
|
||||
import Post from '../../components/posts/post.vue';
|
||||
|
||||
const { me, pageData } = usePageContext();
|
||||
const { shelves } = pageData;
|
||||
const { pageData } = usePageContext();
|
||||
const { posts } = pageData;
|
||||
</script>
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default '/s/@shelfId/post/@postId';
|
||||
export default '/s/@shelfId/post/@postId/@postSlug?';
|
||||
|
||||
@@ -65,7 +65,7 @@ const {
|
||||
|
||||
<style scoped>
|
||||
.content {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { fetchShelf } from '../../src/shelves';
|
||||
import { fetchShelfPosts } from '../../src/posts';
|
||||
|
||||
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 });
|
||||
|
||||
if (!shelf) {
|
||||
|
||||
@@ -33,6 +33,7 @@ const {
|
||||
.posts {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import { createApp } from './app';
|
||||
import { useUser } from '../stores/user';
|
||||
import logoUrl from './logo.svg';
|
||||
|
||||
import { fetchAllShelves } from '../src/shelves';
|
||||
|
||||
async function render(pageContext) {
|
||||
// See https://vite-plugin-ssr.com/head
|
||||
const { documentProps } = pageContext.exports;
|
||||
@@ -48,11 +50,15 @@ async function render(pageContext) {
|
||||
async function onBeforeRender(pageContext) {
|
||||
try {
|
||||
const pageData = await pageContext.exports.getPageData?.(pageContext, pageContext.session);
|
||||
const shelves = await fetchAllShelves({ user: pageContext.session.user });
|
||||
|
||||
return {
|
||||
pageContext: {
|
||||
// initialState: store.state.value,
|
||||
pageData,
|
||||
pageData: {
|
||||
...pageData,
|
||||
shelves,
|
||||
},
|
||||
me: pageContext.session.user,
|
||||
now: new Date(),
|
||||
},
|
||||
@@ -62,9 +68,7 @@ async function onBeforeRender(pageContext) {
|
||||
|
||||
throw RenderErrorPage({
|
||||
pageContext: {
|
||||
pageProps: {
|
||||
errorInfo: error.statusMessage,
|
||||
},
|
||||
pageProps: error,
|
||||
me: pageContext.session.user,
|
||||
now: new Date(),
|
||||
},
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<template>
|
||||
<div class="content">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<h1>500 Internal Error</h1>
|
||||
|
||||
<p v-if="errorInfo">{{ errorInfo }}</p>
|
||||
<p v-if="statusMessage">{{ errorInfo }}</p>
|
||||
<p v-else>Something went wrong.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -20,5 +20,5 @@
|
||||
import { usePageContext } from './usePageContext';
|
||||
|
||||
const { pageProps } = usePageContext();
|
||||
const { is404, errorInfo } = pageProps;
|
||||
const { is404, statusCode, statusMessage } = pageProps;
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { createSSRApp, h } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import FloatingVue from 'floating-vue';
|
||||
|
||||
import Container from './container.vue';
|
||||
import { setPageContext } from './usePageContext';
|
||||
|
||||
import '../assets/css/style.css';
|
||||
import 'floating-vue/dist/style.css';
|
||||
|
||||
function createApp(pageContext) {
|
||||
const PageWithLayout = {
|
||||
@@ -20,6 +23,7 @@ function createApp(pageContext) {
|
||||
const store = createPinia();
|
||||
|
||||
app.use(store);
|
||||
app.use(FloatingVue);
|
||||
|
||||
// We make pageContext available from any Vue component
|
||||
setPageContext(app, pageContext);
|
||||
|
||||
@@ -18,11 +18,63 @@
|
||||
>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="me"
|
||||
class="userpanel"
|
||||
<div class="actions">
|
||||
<VDropdown>
|
||||
<button class="button">Explore</button>
|
||||
|
||||
<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"
|
||||
>{{ me.username }}</span>
|
||||
>Log out</button>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</VDropdown>
|
||||
|
||||
<a
|
||||
v-else
|
||||
href="/account/login"
|
||||
class="link userpanel"
|
||||
>Log in</a>
|
||||
</header>
|
||||
|
||||
<div class="content-container">
|
||||
@@ -38,7 +90,9 @@ import { del } from '../assets/js/api';
|
||||
import { navigate } from '../assets/js/navigate';
|
||||
import { usePageContext } from './usePageContext';
|
||||
|
||||
const { me } = usePageContext();
|
||||
const { pageData, me } = usePageContext();
|
||||
const { shelves } = pageData;
|
||||
|
||||
const version = CLIENT_VERSION;
|
||||
|
||||
async function logout() {
|
||||
@@ -48,6 +102,13 @@ async function logout() {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.content {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.logo svg {
|
||||
height: 100%;
|
||||
width: auto;
|
||||
@@ -71,6 +132,7 @@ async function logout() {
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
background: var(--background);
|
||||
box-shadow: 0 0 3px var(--shadow-weak-10);
|
||||
@@ -99,11 +161,32 @@ async function logout() {
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.userpanel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
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 {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// import config from 'config';
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
|
||||
const { argv } = yargs
|
||||
const { argv } = yargs(hideBin(process.argv))
|
||||
.command('npm start')
|
||||
.option('log-level', {
|
||||
alias: 'level',
|
||||
|
||||
126
src/posts.js
126
src/posts.js
@@ -3,8 +3,14 @@ import { verifyPrivilege } from './privileges';
|
||||
import knex from './knex';
|
||||
import { HttpError } from './errors';
|
||||
|
||||
import { fetchShelf } from './shelves';
|
||||
import { fetchShelf, fetchShelves } from './shelves';
|
||||
import { fetchUsers } from './users';
|
||||
import { generateThumbnail } from './thumbnails';
|
||||
import slugify from './utils/slugify';
|
||||
|
||||
import initLogger from './logger';
|
||||
|
||||
const logger = initLogger();
|
||||
|
||||
const emptyVote = {
|
||||
tally: 0,
|
||||
@@ -14,18 +20,20 @@ const emptyVote = {
|
||||
};
|
||||
|
||||
function curateDatabasePost(post, {
|
||||
shelf, users, votes,
|
||||
shelf, shelves, users, vote, votes,
|
||||
}) {
|
||||
const curatedPost = {
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
slug: slugify(post.title, { limit: 50 }),
|
||||
body: post.body,
|
||||
link: post.link,
|
||||
shelfId: post.shelf_id,
|
||||
createdAt: post.created_at,
|
||||
shelf,
|
||||
hasThumbnail: post.thumbnail,
|
||||
shelf: shelf || shelves?.[post.shelf_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),
|
||||
};
|
||||
|
||||
@@ -58,13 +66,13 @@ async function fetchPostVotes(postIds, user) {
|
||||
return Object.fromEntries(votes.map((vote) => [vote.post_id, curatePostVote(vote)]));
|
||||
}
|
||||
|
||||
async function fetchShelfPosts(shelfId, { user, limit = 100 } = {}) {
|
||||
const shelf = await fetchShelf(shelfId);
|
||||
async function fetchShelfPosts(shelfIds, { user, limit = 100 } = {}) {
|
||||
const shelves = await fetchShelves([].concat(shelfIds));
|
||||
|
||||
const posts = await knex('posts')
|
||||
.select('posts.*', knex.raw('count(comments.id) as comment_count'))
|
||||
.leftJoin('comments', 'comments.post_id', 'posts.id')
|
||||
.where('shelf_id', shelf.id)
|
||||
.whereIn('shelf_id', Object.keys(shelves))
|
||||
.orderBy('created_at', 'desc')
|
||||
.groupBy('posts.id')
|
||||
.limit(limit);
|
||||
@@ -74,7 +82,50 @@ async function fetchShelfPosts(shelfId, { user, limit = 100 } = {}) {
|
||||
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) {
|
||||
@@ -102,6 +153,32 @@ async function fetchPost(postId, user) {
|
||||
return curateDatabasePost(post, { shelf, users, votes });
|
||||
}
|
||||
|
||||
async function votePost(postId, value, user) {
|
||||
if (value === 0) {
|
||||
await knex('posts_votes')
|
||||
.where({
|
||||
post_id: postId,
|
||||
user_id: user.id,
|
||||
})
|
||||
.delete();
|
||||
} else {
|
||||
await knex('posts_votes')
|
||||
.insert({
|
||||
value,
|
||||
post_id: postId,
|
||||
user_id: user.id,
|
||||
})
|
||||
.onConflict(['post_id', 'user_id'])
|
||||
.merge();
|
||||
}
|
||||
|
||||
logger.silly(`User ${user.username} voted ${value} on post ${postId}`);
|
||||
|
||||
const votes = await fetchPostVotes([postId], user);
|
||||
|
||||
return votes[postId] || emptyVote;
|
||||
}
|
||||
|
||||
async function createPost(post, shelfId, user) {
|
||||
await verifyPrivilege('createPost', user);
|
||||
|
||||
@@ -124,38 +201,23 @@ async function createPost(post, shelfId, user) {
|
||||
})
|
||||
.returning('*');
|
||||
|
||||
const users = await fetchUsers([postEntry.user_id]);
|
||||
const [users, vote] = await Promise.all([
|
||||
fetchUsers([postEntry.user_id]),
|
||||
votePost(postEntry.id, 1, user),
|
||||
]);
|
||||
|
||||
return curateDatabasePost(postEntry, { shelf, users });
|
||||
}
|
||||
logger.verbose(`User ${user.username} created post ${postEntry.id} on s/${shelf.slug}`);
|
||||
|
||||
async function votePost(postId, value, user) {
|
||||
if (value === 0) {
|
||||
await knex('posts_votes')
|
||||
.where({
|
||||
post_id: postId,
|
||||
user_id: user.id,
|
||||
})
|
||||
.delete();
|
||||
} else {
|
||||
await knex('posts_votes')
|
||||
.insert({
|
||||
value,
|
||||
post_id: postId,
|
||||
user_id: user.id,
|
||||
})
|
||||
.onConflict(['post_id', 'user_id'])
|
||||
.merge();
|
||||
}
|
||||
generateThumbnail(postEntry.id);
|
||||
|
||||
const votes = await fetchPostVotes([postId], user);
|
||||
|
||||
return votes[postId] || emptyVote;
|
||||
return curateDatabasePost(postEntry, { shelf, users, vote });
|
||||
}
|
||||
|
||||
export {
|
||||
createPost,
|
||||
fetchPost,
|
||||
fetchShelfPosts,
|
||||
fetchUserPosts,
|
||||
fetchAllPosts,
|
||||
votePost,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import knex from './knex';
|
||||
import { HttpError } from './errors';
|
||||
|
||||
function curateDatabaseShelf(shelf) {
|
||||
if (!shelf) {
|
||||
@@ -9,12 +10,11 @@ function curateDatabaseShelf(shelf) {
|
||||
id: shelf.id,
|
||||
slug: shelf.slug,
|
||||
name: shelf.slug,
|
||||
subscribed: !!shelf.subscribed,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchShelf(shelfId) {
|
||||
const shelfEntry = await knex('shelves')
|
||||
.where((builder) => {
|
||||
function identityQuery(builder, shelfId) {
|
||||
const id = Number(shelfId);
|
||||
|
||||
if (Number.isNaN(id)) {
|
||||
@@ -23,18 +23,57 @@ async function fetchShelf(shelfId) {
|
||||
}
|
||||
|
||||
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')
|
||||
.select('shelves.*')
|
||||
.modify((builder) => isMemberQuery(builder, user))
|
||||
.where((builder) => identityQuery(builder, shelfId))
|
||||
.first();
|
||||
|
||||
return curateDatabaseShelf(shelfEntry);
|
||||
}
|
||||
|
||||
async function fetchShelves({ limit = 10 } = {}) {
|
||||
const shelfEntries = await knex('shelves').limit(limit);
|
||||
async function fetchAllShelves({ user, limit = 100 } = {}) {
|
||||
const shelfEntries = await knex('shelves')
|
||||
.select('shelves.*')
|
||||
.modify((builder) => isMemberQuery(builder, user))
|
||||
.orderBy('slug', 'asc')
|
||||
.limit(limit);
|
||||
|
||||
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) {
|
||||
const [shelfEntry] = await knex('shelves')
|
||||
.insert({
|
||||
@@ -46,9 +85,57 @@ async function createShelf(shelf, user) {
|
||||
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 {
|
||||
fetchShelf,
|
||||
fetchShelves,
|
||||
fetchAllShelves,
|
||||
createShelf,
|
||||
curateDatabaseShelf,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
};
|
||||
|
||||
80
src/thumbnails.js
Normal file
80
src/thumbnails.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import bhttp from 'bhttp';
|
||||
import unprint from 'unprint';
|
||||
import fs from 'fs';
|
||||
import sharp from 'sharp';
|
||||
|
||||
import knex from './knex';
|
||||
import initLogger from './logger';
|
||||
|
||||
const logger = initLogger();
|
||||
|
||||
async function createThumbnail(data, post) {
|
||||
await fs.promises.mkdir('media/thumbnails', { recursive: true });
|
||||
|
||||
await sharp(data)
|
||||
.resize(300, 300)
|
||||
.jpeg({
|
||||
quality: 80,
|
||||
})
|
||||
.toFile(`media/thumbnails/${post.id}.jpeg`);
|
||||
|
||||
await knex('posts')
|
||||
.where('id', post.id)
|
||||
.update('thumbnail', true);
|
||||
|
||||
logger.debug(`Saved thumbnail for ${post.id}`);
|
||||
}
|
||||
|
||||
async function generateThumbnail(postId) {
|
||||
const post = await knex('posts').where('id', postId).first();
|
||||
|
||||
if (!post) {
|
||||
logger.warn(`Cannot generate thumbnail for non-existent post ${postId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!post.link) {
|
||||
logger.debug(`Skipped thumbnail generation for text-only post ${postId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await bhttp.get(post.link);
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
logger.warn(`Failed thumbnail generation for ${post.link} from ${postId} (${res.statusCode})`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.headers['content-type'].includes('image/')) {
|
||||
await createThumbnail(res.body, post);
|
||||
return;
|
||||
}
|
||||
|
||||
const { query } = unprint.init(res.body.toString());
|
||||
const ogHeader = query.attribute('meta[property="og:image"]', 'content');
|
||||
|
||||
if (ogHeader) {
|
||||
const imgRes = await bhttp.get(ogHeader);
|
||||
|
||||
if (imgRes.statusCode === 200 && imgRes.headers['content-type'].includes('image/')) {
|
||||
await createThumbnail(imgRes.body, post);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function generateMissingThumbnails(force = false) {
|
||||
const postsWithoutThumbnail = await knex('posts')
|
||||
.select('id')
|
||||
.modify((builder) => {
|
||||
if (!force) {
|
||||
builder.where('thumbnail', false);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(postsWithoutThumbnail.map(async (post) => generateThumbnail(post.id)));
|
||||
}
|
||||
|
||||
export {
|
||||
generateThumbnail,
|
||||
generateMissingThumbnails,
|
||||
};
|
||||
9
src/tools/generate-thumbs.js
Normal file
9
src/tools/generate-thumbs.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { generateMissingThumbnails } from '../thumbnails';
|
||||
import args from '../cli';
|
||||
|
||||
async function init() {
|
||||
await generateMissingThumbnails(!!args.force);
|
||||
process.exit();
|
||||
}
|
||||
|
||||
init();
|
||||
78
src/utils/slugify.js
Executable file
78
src/utils/slugify.js
Executable 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;
|
||||
@@ -23,7 +23,11 @@ import {
|
||||
createUser,
|
||||
} from './users';
|
||||
|
||||
import { createShelf } from './shelves';
|
||||
import {
|
||||
createShelf,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
} from './shelves';
|
||||
|
||||
import { createPost, votePost } from './posts';
|
||||
import { addComment } from './comments';
|
||||
@@ -68,6 +72,10 @@ async function startServer() {
|
||||
// SHELVES
|
||||
router.post('/api/shelves', createShelf);
|
||||
|
||||
// MEMBERS
|
||||
router.post('/api/shelves/:shelfId/members', subscribe);
|
||||
router.delete('/api/shelves/:shelfId/members', unsubscribe);
|
||||
|
||||
// POSTS
|
||||
router.post('/api/shelves/:shelfId/posts', createPost);
|
||||
router.post('/api/posts/:postId/votes', votePost);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createShelf } from '../shelves';
|
||||
import { createShelf, subscribe, unsubscribe } from '../shelves';
|
||||
|
||||
async function createShelfApi(req, res) {
|
||||
const shelf = await createShelf(req.body, req.user);
|
||||
@@ -6,6 +6,18 @@ async function createShelfApi(req, res) {
|
||||
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 {
|
||||
createShelfApi as createShelf,
|
||||
subscribeApi as subscribe,
|
||||
unsubscribeApi as unsubscribe,
|
||||
};
|
||||
|
||||
@@ -15,6 +15,18 @@
|
||||
<div class="sidebar">
|
||||
<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
|
||||
class="form compose"
|
||||
@submit.prevent="submitPost"
|
||||
@@ -44,7 +56,10 @@
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="button button-submit">Post</button>
|
||||
<button
|
||||
class="button button-submit"
|
||||
:disabled="!body && !link"
|
||||
>Post</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -53,7 +68,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, defineProps } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import * as api from '../../assets/js/api';
|
||||
import { navigate } from '../../assets/js/navigate';
|
||||
import { usePageContext } from '../../renderer/usePageContext';
|
||||
@@ -68,8 +83,9 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const title = ref();
|
||||
const link = ref();
|
||||
const body = ref();
|
||||
const link = ref(null);
|
||||
const body = ref(null);
|
||||
const subscribed = ref(props.shelf.subscribed);
|
||||
|
||||
async function submitPost() {
|
||||
const post = await api.post(`/shelves/${routeParams.id}/posts`, {
|
||||
@@ -78,7 +94,17 @@ async function submitPost() {
|
||||
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>
|
||||
|
||||
@@ -123,4 +149,9 @@ async function submitPost() {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.subscribe {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user