Added front page, moved shelf navigation to header.
This commit is contained in:
120
src/posts.js
120
src/posts.js
@@ -3,8 +3,9 @@ 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 slugify from './utils/slugify';
|
||||
|
||||
const emptyVote = {
|
||||
tally: 0,
|
||||
@@ -14,18 +15,19 @@ 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,
|
||||
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 +60,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 +76,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,33 +147,6 @@ async function fetchPost(postId, user) {
|
||||
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) {
|
||||
if (value === 0) {
|
||||
await knex('posts_votes')
|
||||
@@ -153,9 +171,41 @@ async function votePost(postId, value, user) {
|
||||
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 {
|
||||
createPost,
|
||||
fetchPost,
|
||||
fetchShelfPosts,
|
||||
fetchUserPosts,
|
||||
fetchAllPosts,
|
||||
votePost,
|
||||
};
|
||||
|
||||
113
src/shelves.js
113
src/shelves.js
@@ -1,4 +1,5 @@
|
||||
import knex from './knex';
|
||||
import { HttpError } from './errors';
|
||||
|
||||
function curateDatabaseShelf(shelf) {
|
||||
if (!shelf) {
|
||||
@@ -9,32 +10,70 @@ function curateDatabaseShelf(shelf) {
|
||||
id: shelf.id,
|
||||
slug: 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')
|
||||
.where((builder) => {
|
||||
const id = Number(shelfId);
|
||||
|
||||
if (Number.isNaN(id)) {
|
||||
builder.where('slug', shelfId);
|
||||
return;
|
||||
}
|
||||
|
||||
builder.where('id', shelfId);
|
||||
})
|
||||
.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,
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user