Rush: Voting + Account management
This commit is contained in:
parent
73a2e20cf7
commit
494f2209c0
@ -20,6 +20,6 @@
|
||||
</style>
|
||||
|
||||
<div class="actionBar">
|
||||
<Rating rating={message.rating} style="gap: 24px;"></Rating>
|
||||
<Rating messageId={message.id} rating={message.rating} ownVote={message.ownVote} style="gap: 24px;"></Rating>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@ -20,7 +20,7 @@
|
||||
/**
|
||||
* @type {string | undefined}
|
||||
*/
|
||||
export let iconSrc;
|
||||
export let iconSrc = undefined;
|
||||
|
||||
/**
|
||||
* @type {number}
|
||||
|
||||
@ -15,6 +15,21 @@
|
||||
* @type {number}
|
||||
*/
|
||||
export let iconSize = 16;
|
||||
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
export let color = "white";
|
||||
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
export let iconColor = "white";
|
||||
|
||||
/**
|
||||
* @type {string | undefined}
|
||||
*/
|
||||
export let textStyle = undefined;
|
||||
</script>
|
||||
|
||||
<style type="scss">
|
||||
@ -26,7 +41,7 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="pair" title={name}>
|
||||
<Tablericon name={iconName} size={iconSize} color="white"></Tablericon>
|
||||
<span><slot /></span>
|
||||
<div class="pair" title={name} style="color: {color};">
|
||||
<Tablericon name={iconName} size={iconSize} color={iconColor}></Tablericon>
|
||||
<span style="color: {color}; {textStyle}"><slot /></span>
|
||||
</div>
|
||||
|
||||
@ -17,6 +17,8 @@
|
||||
export let opened = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
console.log(post);
|
||||
</script>
|
||||
|
||||
<style type="scss">
|
||||
|
||||
@ -1,10 +1,21 @@
|
||||
<script>
|
||||
import { enhance } from "$app/forms";
|
||||
import Iconvalue from "./iconvalue.svelte";
|
||||
|
||||
/**
|
||||
* @type {number}
|
||||
*/
|
||||
export let messageId;
|
||||
|
||||
/**
|
||||
* @type {import("$types/base").Rating}
|
||||
*/
|
||||
export let rating;
|
||||
|
||||
/**
|
||||
* @type {number | null}
|
||||
*/
|
||||
export let ownVote = null;
|
||||
</script>
|
||||
|
||||
<style type="scss">
|
||||
@ -16,9 +27,42 @@
|
||||
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
form {
|
||||
display: contents;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="rating" {...$$restProps}>
|
||||
{#if ownVote == 1}
|
||||
<form action="/vote?/remove&messageid={messageId}" method="post" use:enhance>
|
||||
<button>
|
||||
<Iconvalue name="Likes" iconName="heart-fill" color="var(--accent)" textStyle="font-weight: bold;" iconColor="var(--accent)">{rating.likes}</Iconvalue>
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<form action="/vote?/create&messageid={messageId}&value={1}" method="post" use:enhance>
|
||||
<button>
|
||||
<Iconvalue name="Likes" iconName="heart">{rating.likes}</Iconvalue>
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if ownVote == -1}
|
||||
<form action="/vote?/remove&messageid={messageId}" method="post" use:enhance>
|
||||
<button>
|
||||
<Iconvalue name="Dislikes" iconName="thumb-down-fill" color="var(--accent)" textStyle="font-weight: bold;" iconColor="var(--accent)">{rating.dislikes}</Iconvalue>
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<form action="/vote?/create&messageid={messageId}&value={-1}" method="post" use:enhance>
|
||||
<button>
|
||||
<Iconvalue name="Dislikes" iconName="thumb-down">{rating.dislikes}</Iconvalue>
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
91
src/comp/topbar.svelte
Normal file
91
src/comp/topbar.svelte
Normal file
@ -0,0 +1,91 @@
|
||||
<script>
|
||||
import activeSession from "$lib/memory/session";
|
||||
import { getNamedId } from "$lib/util";
|
||||
import Avatar from "./avatar.svelte";
|
||||
import Useritem from "./useritem.svelte";
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.topBar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
padding: 8px;
|
||||
background: var(--background-black);
|
||||
z-index: 99999;
|
||||
backdrop-filter: blur(10px);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.topContent {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
max-width: 1028px;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.loggedInAs {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
align-items: end;
|
||||
|
||||
> .label {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
> .name {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.user {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.webTitle {
|
||||
font-size: 2rem;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="topBar">
|
||||
|
||||
<div class="topContent">
|
||||
<a class="webTitle typeDisplay" href="/posts">ECHO</a>
|
||||
|
||||
<a href="/compose">Compose</a>
|
||||
|
||||
<div class="separator"></div>
|
||||
|
||||
{#if $activeSession}
|
||||
<a class="user" href="/users/{getNamedId($activeSession.id, $activeSession.name)}">
|
||||
<div class="loggedInAs">
|
||||
<span class="label">Logged in as</span>
|
||||
<span class="name">{$activeSession.name}</span>
|
||||
</div>
|
||||
<Avatar user={$activeSession} size={40}></Avatar>
|
||||
</a>
|
||||
{:else}
|
||||
<a class="user" href="/login">
|
||||
<div class="loggedInAs">
|
||||
<span class="label">Private</span>
|
||||
<span class="name">Log in now</span>
|
||||
</div>
|
||||
<Avatar user={null} size={40}></Avatar>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
17
src/lib/memory/session.js
Normal file
17
src/lib/memory/session.js
Normal file
@ -0,0 +1,17 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
/** @type {import("svelte/store").Writable<import("$types/base").User | null>} */
|
||||
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);
|
||||
}
|
||||
@ -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<Comment[]>}
|
||||
*/
|
||||
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 };`;
|
||||
|
||||
|
||||
@ -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<Post[]>}
|
||||
*/
|
||||
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<import('$types/status').Success | import('$types/status').Error>}
|
||||
*/
|
||||
export async function createPost(token, category, name, content) {
|
||||
const insert = sql`
|
||||
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;`;
|
||||
RETURNING id;`
|
||||
})
|
||||
|
||||
let result;
|
||||
|
||||
try {
|
||||
result = await insert;
|
||||
result = await transaction;
|
||||
} catch (e) {
|
||||
const pgerr = isPostgresError(e);
|
||||
|
||||
|
||||
@ -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<import('postgres').Row[]>}
|
||||
*/
|
||||
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<import('postgres').Row[]>}
|
||||
*/
|
||||
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<import('$types/status').Success | import('$types/status').Error>}
|
||||
*/
|
||||
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<import('$types/status').Success | import('$types/status').Error>}
|
||||
*/
|
||||
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<number | import('$types/status').Error>}
|
||||
@ -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<import('$types/status').Success | import('$types/status').Error>}
|
||||
*/
|
||||
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<import('$types/status').Success | import('$types/status').Error>}
|
||||
*/
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
107
src/lib/server/db/vote.js
Normal file
107
src/lib/server/db/vote.js
Normal file
@ -0,0 +1,107 @@
|
||||
import { sql } from '$lib/db.server';
|
||||
import { sqlUserFromToken } from './user';
|
||||
|
||||
/**
|
||||
* @param {number | import('postgres').PendingQuery<import('postgres').Row[]>} user_id
|
||||
* @returns {import('postgres').PendingQuery<import('postgres').Row[]>}
|
||||
*/
|
||||
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<import('$types/status').Success | import('$types/status').Error>}
|
||||
*/
|
||||
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<import('$types/status').Success | import('$types/status').Error>}
|
||||
*/
|
||||
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<import('$types/status').Success | import('$types/status').Error>}
|
||||
*/
|
||||
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,
|
||||
};
|
||||
}
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,18 +1,36 @@
|
||||
<script>
|
||||
import Sidebar from "$comp/sidebar.svelte";
|
||||
import Topbar from "$comp/topbar.svelte";
|
||||
import { logInAs, logOut } from "$lib/memory/session";
|
||||
|
||||
/**
|
||||
* @type {{
|
||||
* categories: import("$types/base").Category[]
|
||||
* categories: import("$types/base").Category[],
|
||||
* loggedInUser: import("$types/base").User | null
|
||||
* }}
|
||||
*/
|
||||
export let data;
|
||||
|
||||
$: categories = data.categories;
|
||||
$: loggedInUser = data.loggedInUser;
|
||||
|
||||
$: {
|
||||
if (loggedInUser) {
|
||||
logInAs(loggedInUser);
|
||||
} else {
|
||||
logOut();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sideContent {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
/* width: calc(100% - 16px); */
|
||||
@ -32,8 +50,12 @@
|
||||
</style>
|
||||
|
||||
<div class="content">
|
||||
<Topbar></Topbar>
|
||||
|
||||
<div class="sideContent">
|
||||
<Sidebar categories={categories}></Sidebar>
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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}`);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { applyAction, enhance } from "$app/forms";
|
||||
import { page } from "$app/stores";
|
||||
import activeSession from "$lib/memory/session";
|
||||
import toasts, { addToast } from "$lib/memory/toast";
|
||||
|
||||
/**
|
||||
@ -62,6 +63,13 @@
|
||||
max-width: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
.checkboxInput {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
}
|
||||
</style>
|
||||
|
||||
<svelte:head>
|
||||
@ -95,6 +103,13 @@
|
||||
<label for="content">Content</label>
|
||||
<textarea name="content" id="content" rows="4" placeholder="write something nice..."></textarea>
|
||||
</div>
|
||||
|
||||
{#if $activeSession?.access == 'elevated'}
|
||||
<div class="checkboxInput">
|
||||
<input type="checkbox" name="autoapprove" id="autoapprove">
|
||||
<label for="autoapprove">Automatically approve? (This is an administrator feature)</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="typeTitle">Post!</button>
|
||||
|
||||
@ -3,7 +3,9 @@ import { parseIntNull } from '$lib/util';
|
||||
|
||||
|
||||
/** @type {import('./$types').PageServerLoad} */
|
||||
export async function load({ url }) {
|
||||
export async function load({ cookies, url }) {
|
||||
const userToken = cookies.get('token');
|
||||
|
||||
const count = await getPostCount();
|
||||
|
||||
const page = parseIntNull(url.searchParams.get('page')) ?? 0;
|
||||
@ -11,7 +13,7 @@ export async function load({ url }) {
|
||||
|
||||
const pageSize = Math.min(Math.max(items, 1), 30);
|
||||
|
||||
const result = await getPosts({
|
||||
const result = await getPosts(userToken, {
|
||||
limit: pageSize,
|
||||
offset: pageSize * page,
|
||||
});
|
||||
|
||||
@ -21,7 +21,7 @@ export async function load({ params, cookies }) {
|
||||
error(404, postError.msg);
|
||||
}
|
||||
|
||||
const comments = await getCommentsForPost(post_id);
|
||||
const comments = await getCommentsForPost(token, post_id);
|
||||
|
||||
return {
|
||||
post: post,
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { getUser } from "$lib/server/db/user";
|
||||
import { changeUserPassword, createUserSession, deleteUser, getUser, updateUserAbout } from "$lib/server/db/user";
|
||||
import { runIfError } from "$lib/status";
|
||||
import { errorToFail } from "$lib/status.server";
|
||||
import { getIdFromName } from "$lib/util";
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { error, fail, redirect } from "@sveltejs/kit";
|
||||
|
||||
/** @type {import("@sveltejs/kit").ServerLoad} */
|
||||
export async function load({ params }) {
|
||||
@ -20,3 +22,90 @@ export async function load({ params }) {
|
||||
user: user
|
||||
};
|
||||
}
|
||||
|
||||
/** @type {import("@sveltejs/kit").Action} */
|
||||
async function changeAboutAction({ request, 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 data = await request.formData();
|
||||
const about = data.get('about')?.toString();
|
||||
|
||||
if (!about) {
|
||||
return fail(400, {
|
||||
error: true,
|
||||
title: 'Bad data',
|
||||
msg: 'About cannot be empty',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await updateUserAbout(userToken, about);
|
||||
|
||||
return runIfError(result, (error) => {
|
||||
return errorToFail(error);
|
||||
});
|
||||
}
|
||||
|
||||
/** @type {import("@sveltejs/kit").Action} */
|
||||
async function deleteAccountAction({ cookies }) {
|
||||
const userToken = cookies.get('token');
|
||||
|
||||
if (userToken) {
|
||||
await deleteUser(userToken);
|
||||
cookies.delete('token', { path: '/' })
|
||||
}
|
||||
|
||||
redirect(302, '/');
|
||||
}
|
||||
|
||||
/** @type {import("@sveltejs/kit").Action} */
|
||||
async function changePasswordAction({ request, 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 data = await request.formData();
|
||||
const oldPassword = data.get('oldpassword')?.toString();
|
||||
const newPassword = data.get('newpassword')?.toString();
|
||||
|
||||
if (!oldPassword || !newPassword) {
|
||||
return fail(400, {
|
||||
error: true,
|
||||
title: 'Bad data',
|
||||
msg: 'Fields cannot be empty',
|
||||
});
|
||||
}
|
||||
|
||||
if (userToken) {
|
||||
const result = await changeUserPassword(userToken, oldPassword, newPassword);
|
||||
|
||||
if ('error' in result) {
|
||||
return runIfError(result, (error) => {
|
||||
return errorToFail(error);
|
||||
});
|
||||
}
|
||||
|
||||
cookies.delete('token', { path: '/' });
|
||||
redirect(302, '/');
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {import("@sveltejs/kit").Actions} */
|
||||
export let actions = {
|
||||
changeAbout: changeAboutAction,
|
||||
deleteAccount: deleteAccountAction,
|
||||
changePassword: changePasswordAction,
|
||||
};
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
<script>
|
||||
import { enhance } from "$app/forms";
|
||||
import { goto } from "$app/navigation";
|
||||
import Avatar from "$comp/avatar.svelte";
|
||||
import Glowfx from "$comp/fx/glowfx.svelte";
|
||||
import activeSession from "$lib/memory/session";
|
||||
import { gotoNamedId } from "$lib/util";
|
||||
import moment from "moment";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
/**
|
||||
@ -14,7 +20,184 @@
|
||||
onMount(() => {
|
||||
gotoNamedId(user.id, user.name);
|
||||
});
|
||||
|
||||
/** @type {HTMLInputElement} */
|
||||
let aboutInput;
|
||||
|
||||
let showAboutModal = false;
|
||||
|
||||
function updateAboutMe() {
|
||||
if ($activeSession) {
|
||||
$activeSession.about = aboutInput.value;
|
||||
}
|
||||
showAboutModal = false;
|
||||
}
|
||||
|
||||
let showPasswordModal = false;
|
||||
</script>
|
||||
|
||||
<h1>{data.user.name}</h1>
|
||||
<p>{data.user.joinDate}</p>
|
||||
<style lang="scss">
|
||||
.user {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.joinTime {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.me {
|
||||
position: relative;
|
||||
padding: 32px;
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
background: var(--gray-dim);
|
||||
border-radius: 16px;
|
||||
gap: 32px;
|
||||
align-items: normal;
|
||||
|
||||
> .text {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
|
||||
> h4 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
}
|
||||
|
||||
> .list {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actionItem {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.button {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modalBg {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
background: #0005;
|
||||
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.aboutModal {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-50%);
|
||||
|
||||
background: var(--gray-dim);
|
||||
|
||||
z-index: 100;
|
||||
|
||||
border-radius: 16px;
|
||||
|
||||
padding: 32px;
|
||||
box-shadow: 0px 10px 32px #0005;
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="stylesheet" href="/css/form.scss">
|
||||
</svelte:head>
|
||||
|
||||
{#if showAboutModal}
|
||||
<div class="modalBg"></div>
|
||||
<div class="aboutModal">
|
||||
<form action="?/changeAbout" method="post" use:enhance on:submit={updateAboutMe}>
|
||||
<label for="about">New About me</label>
|
||||
<input type="text" name="about" id="about" bind:this={aboutInput}>
|
||||
<div class="buttons">
|
||||
<button class="button formButton" type="button" on:click={() => {showAboutModal = false;}}>Cancel</button>
|
||||
<button class="button formButton" type="submit">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showPasswordModal}
|
||||
<div class="modalBg"></div>
|
||||
<div class="aboutModal">
|
||||
<form action="?/changePassword" method="post" use:enhance on:submit={() => {showPasswordModal = false;}}>
|
||||
<label for="oldpassword">Current password</label>
|
||||
<input type="password" name="oldpassword" id="oldpassword" autocomplete="current-password">
|
||||
<label for="newpassword">New password</label>
|
||||
<input type="password" name="newpassword" id="newpassword" autocomplete="new-password">
|
||||
<div class="buttons">
|
||||
<button class="button formButton" type="button" on:click={() => {showPasswordModal = false;}}>Cancel</button>
|
||||
<button class="button formButton" type="submit">Change</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="user">
|
||||
<Avatar user={user} size={128}></Avatar>
|
||||
<h1 class="typeTitle">@ {user.name}</h1>
|
||||
<span>{user.about}</span>
|
||||
<p class="joinTime">Joined {moment(user.joinDate).fromNow()}</p>
|
||||
|
||||
{#if user.id == $activeSession?.id}
|
||||
<Glowfx borderRadius={16}>
|
||||
<div class="me">
|
||||
<div class="text">
|
||||
<h4 class="typeTitle">Self-actions</h4>
|
||||
<span>These actions are only available to you as the account owner</span>
|
||||
</div>
|
||||
|
||||
<div class="list">
|
||||
<div class="actionItem">
|
||||
<span>Change my about me</span>
|
||||
<form action="">
|
||||
<button class="typeTitle button formButton" type="button" on:click={() => {showAboutModal = true;}}>Edit</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="actionItem">
|
||||
<span>Change my current password</span>
|
||||
<form action="">
|
||||
<button class="typeTitle button formButton" type="button" on:click={() => {showPasswordModal = true;}}>Change</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="actionItem">
|
||||
<span>Log out of the current session</span>
|
||||
<form action="">
|
||||
<button class="typeTitle button formButton" type="button" on:click={() => {goto('/logout')}}>Log out</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="actionItem">
|
||||
<span>Permanently delete the account</span>
|
||||
<form action="?/deleteAccount" method="post">
|
||||
<button class="typeTitle button formButton" type="submit">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Glowfx>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
79
src/routes/(app)/vote/+page.server.js
Normal file
79
src/routes/(app)/vote/+page.server.js
Normal file
@ -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,
|
||||
};
|
||||
16
src/routes/(entry)/logout/+page.server.js
Normal file
16
src/routes/(entry)/logout/+page.server.js
Normal file
@ -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, '/');
|
||||
}
|
||||
19
src/routes/api/user/+server.js
Normal file
19
src/routes/api/user/+server.js
Normal file
@ -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,
|
||||
}));
|
||||
}
|
||||
@ -3,7 +3,9 @@ export type Result<T> = Map<number, T>;
|
||||
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 = {
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
font-size: 16px;
|
||||
|
||||
--background: #111118;
|
||||
--background-black: #060608c0;
|
||||
|
||||
--accent-gray: #b182a3;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
1
static/icon/heart-fill.svg
Normal file
1
static/icon/heart-fill.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="icon" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-heart"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19.5 12.572l-7.5 7.428l-7.5 -7.428a5 5 0 1 1 7.5 -6.566a5 5 0 1 1 7.5 6.572" /></svg>
|
||||
|
After Width: | Height: | Size: 420 B |
1
static/icon/thumb-down-fill.svg
Normal file
1
static/icon/thumb-down-fill.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="icon" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-thumb-down"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 13v-8a1 1 0 0 0 -1 -1h-2a1 1 0 0 0 -1 1v7a1 1 0 0 0 1 1h3a4 4 0 0 1 4 4v1a2 2 0 0 0 4 0v-5h3a2 2 0 0 0 2 -2l-1 -5a2 3 0 0 0 -2 -2h-7a3 3 0 0 0 -3 3" /></svg>
|
||||
|
After Width: | Height: | Size: 499 B |
Loading…
x
Reference in New Issue
Block a user