diff --git a/src/comp/actionbar.svelte b/src/comp/actionbar.svelte
index b63a90e..c80f942 100644
--- a/src/comp/actionbar.svelte
+++ b/src/comp/actionbar.svelte
@@ -20,6 +20,6 @@
-
-
+
diff --git a/src/comp/page/posts/postcard.svelte b/src/comp/page/posts/postcard.svelte
index ac69390..fdbc78a 100644
--- a/src/comp/page/posts/postcard.svelte
+++ b/src/comp/page/posts/postcard.svelte
@@ -17,6 +17,8 @@
export let opened = false;
const dispatch = createEventDispatcher();
+
+console.log(post);
- {rating.likes}
- {rating.dislikes}
+ {#if ownVote == 1}
+
+ {:else}
+
+ {/if}
+
+ {#if ownVote == -1}
+
+ {:else}
+
+ {/if}
diff --git a/src/comp/topbar.svelte b/src/comp/topbar.svelte
new file mode 100644
index 0000000..4758856
--- /dev/null
+++ b/src/comp/topbar.svelte
@@ -0,0 +1,91 @@
+
+
+
+
+
diff --git a/src/lib/memory/session.js b/src/lib/memory/session.js
new file mode 100644
index 0000000..77761b5
--- /dev/null
+++ b/src/lib/memory/session.js
@@ -0,0 +1,17 @@
+import { writable } from "svelte/store";
+
+/** @type {import("svelte/store").Writable
} */
+const activeSession = writable(null);
+
+export default activeSession;
+
+/**
+ * @param {import("$types/base").User} user
+ */
+export function logInAs(user) {
+ activeSession.set(user);
+}
+
+export function logOut() {
+ activeSession.set(null);
+}
diff --git a/src/lib/server/db/comment.js b/src/lib/server/db/comment.js
index ca09627..3072fa7 100644
--- a/src/lib/server/db/comment.js
+++ b/src/lib/server/db/comment.js
@@ -1,5 +1,6 @@
import { sql } from '$lib/db.server';
-import { getUsersCachedByRef } from './user';
+import { getUsersCachedByRef, sqlUserFromToken } from './user';
+import { sqlOwnVote } from './vote';
/**
* @typedef {import('$types/base').Comment} Comment
@@ -22,17 +23,21 @@ function parseCommentFromRow(author, row) {
rating: {
likes: BigInt(row['likes']),
dislikes: BigInt(row['dislikes']),
- }
+ },
+ ownVote: row['own_vote'],
};
}
/**
+ * @param {string | null} token
* @param {number} post_id
* @returns {Promise}
*/
-export async function getCommentsForPost(post_id) {
+export async function getCommentsForPost(token, post_id) {
+ const ownVote = token ? sql`, (${ sqlOwnVote( sql`( ${sqlUserFromToken(token)} )` ) } AND message_id = id) as "own_vote"` : sql``;
+
const query = sql`
- SELECT id, author_id, latest_content, edit_count, parent_comment_id, created_date, likes, dislikes
+ SELECT id, author_id, latest_content, edit_count, parent_comment_id, created_date, likes, dislikes ${ ownVote }
FROM doki8902.message_comment
WHERE post_id = ${ post_id };`;
diff --git a/src/lib/server/db/post.js b/src/lib/server/db/post.js
index 126f19c..063de25 100644
--- a/src/lib/server/db/post.js
+++ b/src/lib/server/db/post.js
@@ -1,7 +1,8 @@
import { sql } from '$lib/db.server';
import { getCategoriesCachedByRef, getCategoryCached } from './category';
import { isPostgresError } from './root';
-import { getUser, getUsersCachedByRef, sqlUserFromToken } from './user';
+import { getUser, getUsersCachedByRef, sqlElevatedUserFromToken, sqlUserFromToken } from './user';
+import { sqlOwnVote } from './vote';
/**
* @typedef {import('$types/base').Post} Post
@@ -46,6 +47,7 @@ function parsePostFromRow(author, category, row, withMetrics = false) {
dislikes: BigInt(row['dislikes']),
},
metrics: withMetrics ? parsePostMetricsFromRow(row) : null,
+ ownVote: row['own_vote'],
};
}
@@ -73,6 +75,7 @@ export async function getPostCount(opts = {}) {
}
/**
+ * @param {string | null} token
* @param {{
* category?: import('$types/base').Category | undefined,
* limit?: number,
@@ -81,7 +84,7 @@ export async function getPostCount(opts = {}) {
* }} opts
* @returns {Promise}
*/
-export async function getPosts(opts = {}) {
+export async function getPosts(token = null, opts = {}) {
const {
category = undefined,
limit = 10,
@@ -92,8 +95,10 @@ export async function getPosts(opts = {}) {
const filter = category ? sql`AND category_id = ${ category.id }` : sql``;
const metrics = withMetrics ? sql`, comment_count, user_count, latest_activity, engagement, age, relevancy` : sql``;
+ const ownVote = token ? sql`, (${ sqlOwnVote( sql`( ${sqlUserFromToken(token)} )` ) } AND message_id = id) as "own_vote"` : sql``;
+
const query = sql`
- SELECT id, author_id, name, category_id, latest_content, reviewed, edit_count, created_date, likes, dislikes ${ metrics }
+ SELECT id, author_id, name, category_id, latest_content, reviewed, edit_count, created_date, likes, dislikes ${ metrics } ${ ownVote }
FROM doki8902.message_post
WHERE reviewed ${ filter }
FETCH FIRST ${ limit } ROWS ONLY
@@ -131,10 +136,12 @@ export async function getPost(post_id, token = null, opts = {}) {
const metrics = withMetrics ? sql`, comment_count, user_count, latest_activity, engagement, age, relevancy` : sql``;
+ const ownVote = token ? sql`, (${ sqlOwnVote( sql`( ${sqlUserFromToken(token)} )` ) } AND message_id = id) as "own_vote"` : sql``;
+
const allowOwn = token ? sql`OR author_id = (${ sqlUserFromToken(token) })` : sql``;
const query = sql`
- SELECT id, author_id, name, category_id, latest_content, reviewed, edit_count, created_date, likes, dislikes ${ metrics }
+ SELECT id, author_id, name, category_id, latest_content, reviewed, edit_count, created_date, likes, dislikes ${ metrics } ${ ownVote }
FROM doki8902.message_post
WHERE id = ${ post_id } AND (reviewed ${ allowOwn });`;
@@ -189,21 +196,45 @@ export async function getPost(post_id, token = null, opts = {}) {
* @param {import('$types/base').Category} category
* @param {string} name
* @param {string} content
+ * @param {{
+ * auto_approve?: boolean
+ * }} opts
* @returns {Promise}
*/
-export async function createPost(token, category, name, content) {
- const insert = sql`
- INSERT INTO doki8902.message_post (author_id, category_id, name, latest_content)
- VALUES (
- (${ sqlUserFromToken(token) }),
- ${ category.id }, ${ name }, ${ content }
- )
- RETURNING id;`;
+export async function createPost(token, category, name, content, opts = {}) {
+ const {
+ auto_approve = false
+ } = opts;
+
+ const transaction = sql.begin(async sql => {
+ if (auto_approve) {
+ const result = await sql`
+ INSERT INTO doki8902.message_post (author_id, category_id, name, latest_content, reviewed)
+ VALUES (
+ (${ sqlElevatedUserFromToken(token) }),
+ ${ category.id }, ${ name }, ${ content }, TRUE
+ )
+ RETURNING id;`;
+
+ await sql`
+ REFRESH MATERIALIZED VIEW doki8902.message_content_latest;`;
+
+ return result;
+ }
+
+ return await sql`
+ INSERT INTO doki8902.message_post (author_id, category_id, name, latest_content)
+ VALUES (
+ (${ sqlUserFromToken(token) }),
+ ${ category.id }, ${ name }, ${ content }
+ )
+ RETURNING id;`
+ })
let result;
try {
- result = await insert;
+ result = await transaction;
} catch (e) {
const pgerr = isPostgresError(e);
diff --git a/src/lib/server/db/user.js b/src/lib/server/db/user.js
index dffb877..f561b37 100644
--- a/src/lib/server/db/user.js
+++ b/src/lib/server/db/user.js
@@ -23,7 +23,9 @@ function parseUserFromRow(row) {
return {
id: row['id'],
name: row['username'],
- joinDate: row['join_time']
+ about: row['about'],
+ joinDate: row['join_time'],
+ access: row['access'],
};
}
@@ -38,7 +40,23 @@ const updateUserCache = cacheUpdater(cache);
* @returns {import('postgres').PendingQuery}
*/
export function sqlUserFromToken(token) {
- return sql`SELECT user_id FROM doki8902.user_session WHERE token = ${ token }`;
+ return sql`
+ SELECT user_id
+ FROM doki8902.user_session
+ WHERE token = ${ token }`;
+}
+
+/**
+ * @param {string} token
+ * @returns {import('postgres').PendingQuery}
+ */
+export function sqlElevatedUserFromToken(token) {
+ return sql`
+ SELECT user_id
+ FROM doki8902.user_session
+ JOIN doki8902.user ON user_id = id
+ WHERE token = ${ token }
+ AND access = 'elevated'`;
}
/**
@@ -57,7 +75,7 @@ export async function getUsers(user_ids) {
if (user_ids.length == 0) return new Map();
const query = sql`
- SELECT id, username, join_time
+ SELECT id, username, about, join_time, access
FROM doki8902.user
WHERE id IN ${ sql(user_ids) };`;
@@ -84,7 +102,9 @@ export async function getUser(user_id) {
return users.get(user_id) || {
error: true,
- msg: `Could not find user of ID ${user_id}`
+ title: 'User not found',
+ msg: `Could not find user of ID ${user_id}`,
+ expected: true,
};
}
@@ -152,6 +172,32 @@ export async function createUser(username, password) {
};
}
+/**
+ * @param {string} token
+ * @returns {Promise}
+ */
+export async function deleteUser(token) {
+ const del = sql`
+ DELETE FROM doki8902.user
+ WHERE id = (${ sqlUserFromToken(token) });`;
+
+ try {
+ await del;
+ } catch (e) {
+ console.log(e);
+ return {
+ error: true,
+ title: 'Fail to process',
+ msg: 'Unknown error (notify dev)',
+ expected: false,
+ };
+ }
+
+ return {
+ success: true,
+ };
+}
+
/**
* @param {string} username
* @param {string} password
@@ -200,6 +246,32 @@ export async function createUserSession(username, password) {
};
}
+/**
+ * @param {string} token
+ * @returns {Promise}
+ */
+export async function removeUserSession(token) {
+ const del = sql`
+ DELETE FROM doki8902.user_session
+ WHERE token = ${ token };`;
+
+ try {
+ await del;
+ } catch (e) {
+ console.log(e);
+ return {
+ error: true,
+ title: 'Fail to process',
+ msg: 'Unknown error (notify dev)',
+ expected: false,
+ };
+ }
+
+ return {
+ success: true,
+ };
+}
+
/**
* @param {string} token
* @returns {Promise}
@@ -230,9 +302,10 @@ export async function getUserIDOfSession(token) {
*/
export async function getUserOfSession(token) {
const query = sql`
- SELECT user_id
+ SELECT id, username, about, join_time, access
FROM doki8902.user_session
- WHERE token = ${ token }`;
+ JOIN doki8902.user ON user_id = id
+ WHERE token = ${ token };`;
const result = await query;
@@ -245,5 +318,100 @@ export async function getUserOfSession(token) {
};
}
- return result[0]['user_id'];
+ return parseUserFromRow(result[0]);
+}
+
+/**
+ * @param {string} token
+ * @param {string} about
+ * @returns {Promise}
+ */
+export async function updateUserAbout(token, about) {
+ const update = sql`
+ UPDATE doki8902.user
+ SET about = ${ about }
+ WHERE id = (${ sqlUserFromToken(token) });`;
+
+ try {
+ await update;
+ } catch (e) {
+ console.log(e);
+ return {
+ error: true,
+ title: 'Fail to process',
+ msg: 'Unknown error (notify dev)',
+ expected: false,
+ };
+ }
+
+ return {
+ success: true,
+ };
+}
+
+/**
+ * @param {string} token
+ * @param {string} currentPassword
+ * @param {string} newPassword
+ * @returns {Promise}
+ */
+export async function changeUserPassword(token, currentPassword, newPassword) {
+ const select = sql`
+ SELECT password, id
+ FROM doki8902.user_session
+ JOIN doki8902.user ON id = user_id
+ WHERE token = ${ token };`;
+
+ const result = await select;
+
+ if (result.length == 0) {
+ return {
+ error: true,
+ title: 'Invalid data',
+ msg: 'Username or password is incorrect',
+ expected: true,
+ };
+ }
+
+ const hashedPassword = result[0]['password'];
+
+ const isMatch = await verify(hashedPassword, currentPassword);
+
+ if (!isMatch) {
+ return {
+ error: true,
+ title: 'Invalid data',
+ msg: 'Username or password is incorrect',
+ expected: true,
+ };
+ }
+
+ const newHashedPassword = await hash(newPassword, {
+ type: argon2id,
+ memoryCost: 2 ** 16,
+ timeCost: 4,
+ parallelism: 1,
+ hashLength: 64,
+ });
+
+ const update = sql`
+ UPDATE doki8902.user
+ SET password = ${ newHashedPassword }
+ WHERE id = (${ result[0]['id'] });`;
+
+ try {
+ await update;
+ } catch (e) {
+ console.log(e);
+ return {
+ error: true,
+ title: 'Fail to process',
+ msg: 'Unknown error (notify dev)',
+ expected: false,
+ };
+ }
+
+ return {
+ success: true,
+ };
}
diff --git a/src/lib/server/db/vote.js b/src/lib/server/db/vote.js
new file mode 100644
index 0000000..c4f341e
--- /dev/null
+++ b/src/lib/server/db/vote.js
@@ -0,0 +1,107 @@
+import { sql } from '$lib/db.server';
+import { sqlUserFromToken } from './user';
+
+/**
+ * @param {number | import('postgres').PendingQuery} user_id
+ * @returns {import('postgres').PendingQuery}
+ */
+export function sqlOwnVote(user_id) {
+ return sql`
+ SELECT vote
+ FROM doki8902.message_vote
+ WHERE user_id = ${ user_id }`;
+}
+
+/**
+ * @param {string} token
+ * @param {number} message_id
+ * @returns {Promise}
+ */
+export async function getVote(token, message_id) {
+ const insert = await sql`
+ SELECT vote
+ FROM doki8902.message_vote
+ WHERE user_id = ${ sqlUserFromToken(token) }
+ AND message_id = ${ message_id };`;
+
+ try {
+ const [result] = await insert;
+
+ return {
+ success: true,
+ result: result,
+ }
+ } catch (e) {
+ console.log(e);
+ return {
+ error: true,
+ title: 'Fail to process',
+ msg: 'Unknown error (notify dev)',
+ expected: false,
+ };
+ }
+}
+
+/**
+ * @param {string} token
+ * @param {number} message_id
+ * @param {number} value
+ * @returns {Promise}
+ */
+export async function createVote(token, message_id, value) {
+ const insert = await sql`
+ INSERT INTO doki8902.message_vote (message_id, user_id, vote)
+ VALUES (
+ ${ message_id },
+ (${ sqlUserFromToken(token) }),
+ ${ value }
+ )
+ ON CONFLICT (message_id, user_id) DO UPDATE
+ SET vote = ${ value };`;
+
+ try {
+ const result = await insert;
+
+ console.log(result);
+ } catch (e) {
+ console.log(e);
+ return {
+ error: true,
+ title: 'Fail to process',
+ msg: 'Unknown error (notify dev)',
+ expected: false,
+ };
+ }
+
+ return {
+ success: true,
+ };
+}
+
+/**
+ * @param {string} token
+ * @param {number} message_id
+ * @returns {Promise}
+ */
+export async function removeVote(token, message_id) {
+ const del = await sql`
+ DELETE FROM doki8902.message_vote
+ WHERE user_id = (${ sqlUserFromToken(token) })
+ AND message_id = ${ message_id };`;
+
+ try {
+ await del;
+ } catch (e) {
+ console.log(e);
+ return {
+ error: true,
+ title: 'Fail to process',
+ msg: 'Unknown error (notify dev)',
+ expected: false,
+ };
+ }
+
+ return {
+ success: true,
+ };
+}
diff --git a/src/routes/(app)/+layout.server.js b/src/routes/(app)/+layout.server.js
index 9758501..b2fc9c5 100644
--- a/src/routes/(app)/+layout.server.js
+++ b/src/routes/(app)/+layout.server.js
@@ -1,10 +1,16 @@
import { getCategories } from "$lib/server/db/category";
+import { getUserOfSession } from "$lib/server/db/user";
/** @type {import("@sveltejs/kit").ServerLoad} */
-export async function load() {
+export async function load({ cookies }) {
+ const token = cookies.get('token')?.toString() ?? null;
+
const categories = await getCategories();
+ const loggedInUser = token ? await getUserOfSession(token) : null;
+
return {
- categories: categories
+ categories: categories,
+ loggedInUser: loggedInUser
};
}
diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte
index 7709bc8..2bbef7d 100644
--- a/src/routes/(app)/+layout.svelte
+++ b/src/routes/(app)/+layout.svelte
@@ -1,18 +1,36 @@
diff --git a/src/routes/(app)/compose/+page.server.js b/src/routes/(app)/compose/+page.server.js
index 32ef0a6..e95f841 100644
--- a/src/routes/(app)/compose/+page.server.js
+++ b/src/routes/(app)/compose/+page.server.js
@@ -33,6 +33,7 @@ async function POST({ request, cookies }) {
const categoryId = parseIntNull(data.get('category')?.toString());
const name = data.get('name')?.toString();
const content = data.get('content')?.toString();
+ const autoapprove = data.get('autoapprove')?.toString() === 'on';
if (!categoryId) {
return fail(400, {
@@ -60,7 +61,9 @@ async function POST({ request, cookies }) {
});
}
- const result = await createPost(userToken, category, name, content);
+ const result = await createPost(userToken, category, name, content, {
+ auto_approve: autoapprove
+ });
runIfSuccess(result, (success) => {
redirect(303, `/posts/${success.result}`);
diff --git a/src/routes/(app)/compose/+page.svelte b/src/routes/(app)/compose/+page.svelte
index b35ad62..3116212 100644
--- a/src/routes/(app)/compose/+page.svelte
+++ b/src/routes/(app)/compose/+page.svelte
@@ -1,6 +1,7 @@
-{data.user.name}
-{data.user.joinDate}
+
+
+
+
+
+
+{#if showAboutModal}
+
+
+{/if}
+
+{#if showPasswordModal}
+
+
+{/if}
+
+
+
+
@ {user.name}
+
{user.about}
+
Joined {moment(user.joinDate).fromNow()}
+
+ {#if user.id == $activeSession?.id}
+
+
+
+
Self-actions
+ These actions are only available to you as the account owner
+
+
+
+
+ Change my about me
+
+
+
+
+ Change my current password
+
+
+
+
+ Log out of the current session
+
+
+
+
+ Permanently delete the account
+
+
+
+
+
+ {/if}
+
diff --git a/src/routes/(app)/vote/+page.server.js b/src/routes/(app)/vote/+page.server.js
new file mode 100644
index 0000000..c9bbe60
--- /dev/null
+++ b/src/routes/(app)/vote/+page.server.js
@@ -0,0 +1,79 @@
+import { createUserSession } from "$lib/server/db/user";
+import { createVote, removeVote } from "$lib/server/db/vote";
+import { runIfError } from "$lib/status";
+import { errorToFail } from "$lib/status.server";
+import { parseIntNull } from "$lib/util";
+import { fail, redirect } from "@sveltejs/kit";
+
+/** @type {import("@sveltejs/kit").Action} */
+async function createVoteAction({ url, cookies }) {
+ const userToken = cookies.get('token');
+
+ if (!userToken) {
+ return fail(401, {
+ error: true,
+ title: 'Invalid session',
+ msg: 'Need to be logged in to perform this operation',
+ });
+ }
+
+ const messageID = parseIntNull(url.searchParams.get('messageid'));
+ const value = parseIntNull(url.searchParams.get('value'));
+
+ if (value != 1 && value != -1) {
+ return fail(400, {
+ error: true,
+ title: 'Bad data',
+ msg: 'Value can only be 1 or -1',
+ });
+ }
+
+ if (!messageID) {
+ return fail(400, {
+ error: true,
+ title: 'Bad data',
+ msg: 'MessageID cannot be empty',
+ });
+ }
+
+ const result = await createVote(userToken, messageID, value);
+
+ return runIfError(result, (error) => {
+ return errorToFail(error);
+ });
+}
+
+/** @type {import("@sveltejs/kit").Action} */
+async function removeVoteAction({ url, cookies }) {
+ const userToken = cookies.get('token');
+
+ if (!userToken) {
+ return fail(401, {
+ error: true,
+ title: 'Invalid session',
+ msg: 'Need to be logged in to perform this operation',
+ });
+ }
+
+ const messageID = parseIntNull(url.searchParams.get('messageid'));
+
+ if (!messageID) {
+ return fail(400, {
+ error: true,
+ title: 'Bad data',
+ msg: 'MessageID cannot be empty',
+ });
+ }
+
+ const result = await removeVote(userToken, messageID);
+
+ return runIfError(result, (error) => {
+ return errorToFail(error);
+ });
+}
+
+/** @type {import("@sveltejs/kit").Actions} */
+export let actions = {
+ create: createVoteAction,
+ remove: removeVoteAction,
+};
diff --git a/src/routes/(entry)/logout/+page.server.js b/src/routes/(entry)/logout/+page.server.js
new file mode 100644
index 0000000..fa55af0
--- /dev/null
+++ b/src/routes/(entry)/logout/+page.server.js
@@ -0,0 +1,16 @@
+import { createUserSession, removeUserSession } from "$lib/server/db/user";
+import { runIfError } from "$lib/status";
+import { errorToFail } from "$lib/status.server";
+import { fail, redirect } from "@sveltejs/kit";
+
+/** @type {import("@sveltejs/kit").ServerLoad} */
+export async function load({ cookies }) {
+ const userToken = cookies.get('token');
+
+ if (userToken) {
+ await removeUserSession(userToken);
+ cookies.delete('token', { path: '/' })
+ }
+
+ redirect(302, '/');
+}
diff --git a/src/routes/api/user/+server.js b/src/routes/api/user/+server.js
new file mode 100644
index 0000000..ee664ca
--- /dev/null
+++ b/src/routes/api/user/+server.js
@@ -0,0 +1,19 @@
+import { jsonSerialize } from "$lib/serialize/base";
+import { getUser } from "$lib/server/db/user";
+import { parseIntNull } from "$lib/util";
+import { error } from "@sveltejs/kit";
+
+/** @type {import("@sveltejs/kit").Action} */
+export async function GET({ url }) {
+ const user_id = parseIntNull(url.searchParams.get('id'));
+
+ if (user_id === null) {
+ error(404, `No User of ID ${url.searchParams.get('id')}`);
+ }
+
+ const user = await getUser(user_id);
+
+ return new Response(jsonSerialize({
+ user: user,
+ }));
+}
diff --git a/src/types/base.ts b/src/types/base.ts
index 3be6757..c4cc550 100644
--- a/src/types/base.ts
+++ b/src/types/base.ts
@@ -3,7 +3,9 @@ export type Result = Map;
export type User = {
id: number,
name: string,
+ about: string,
joinDate: Date,
+ access: 'normal' | 'elevated'
};
export type Rating = {
@@ -37,6 +39,7 @@ export type Post = {
postDate: Date,
rating: Rating,
metrics: PostMetrics | null,
+ ownVote: number | null,
};
export type Comment = {
@@ -48,6 +51,7 @@ export type Comment = {
commentDate: Date,
rating: Rating,
parentCommentId: number,
+ ownVote: number | null,
};
export type CommentTreeNode = {
diff --git a/static/css/base.scss b/static/css/base.scss
index e075407..9685b4e 100644
--- a/static/css/base.scss
+++ b/static/css/base.scss
@@ -2,6 +2,7 @@
font-size: 16px;
--background: #111118;
+ --background-black: #060608c0;
--accent-gray: #b182a3;
diff --git a/static/css/form.scss b/static/css/form.scss
index 0ecbc91..26485bf 100644
--- a/static/css/form.scss
+++ b/static/css/form.scss
@@ -5,7 +5,8 @@ form {
justify-content: center;
gap: 32px;
- button[type=submit] {
+ button[type=submit],
+ .formButton {
background: var(--accent-dim);
border-radius: 4px;
padding: 0.3em 0.8em;
diff --git a/static/icon/heart-fill.svg b/static/icon/heart-fill.svg
new file mode 100644
index 0000000..b5d3657
--- /dev/null
+++ b/static/icon/heart-fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/static/icon/thumb-down-fill.svg b/static/icon/thumb-down-fill.svg
new file mode 100644
index 0000000..c0d5496
--- /dev/null
+++ b/static/icon/thumb-down-fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file