Rush: Voting + Account management

This commit is contained in:
Donatas Kirda 2024-05-31 00:25:30 +03:00
parent 73a2e20cf7
commit 494f2209c0
Signed by: bloodwiing
GPG Key ID: 63020D8D3F4A164F
27 changed files with 971 additions and 49 deletions

View File

@ -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>

View File

@ -20,7 +20,7 @@
/**
* @type {string | undefined}
*/
export let iconSrc;
export let iconSrc = undefined;
/**
* @type {number}

View File

@ -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>

View File

@ -17,6 +17,8 @@
export let opened = false;
const dispatch = createEventDispatcher();
console.log(post);
</script>
<style type="scss">

View File

@ -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
View 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
View 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);
}

View File

@ -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 };`;

View File

@ -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);

View File

@ -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
View 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,
};
}

View File

@ -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
};
}

View File

@ -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>

View File

@ -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}`);

View File

@ -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>

View File

@ -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,
});

View File

@ -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,

View File

@ -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,
};

View File

@ -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>

View 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,
};

View 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, '/');
}

View 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,
}));
}

View File

@ -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 = {

View File

@ -2,6 +2,7 @@
font-size: 16px;
--background: #111118;
--background-black: #060608c0;
--accent-gray: #b182a3;

View File

@ -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;

View 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

View 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