Refactor: Better PostMetric API and Rendering
This commit is contained in:
parent
63ab10318f
commit
499fd6fa5b
@ -1,5 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import Rating from "./rating.svelte";
|
import Iconvalue from "./iconvalue.svelte";
|
||||||
|
import Rating from "./rating.svelte";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {import("$types/base").Post | import("$types/base").Comment}
|
* @type {import("$types/base").Post | import("$types/base").Comment}
|
||||||
@ -14,9 +15,11 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
gap: 24px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="actionBar">
|
<div class="actionBar">
|
||||||
<Rating rating={message.rating}></Rating>
|
<Rating rating={message.rating} style="gap: 24px;"></Rating>
|
||||||
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
32
src/comp/iconvalue.svelte
Normal file
32
src/comp/iconvalue.svelte
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<script>
|
||||||
|
import Tablericon from "./tablericon.svelte";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {string | undefined}
|
||||||
|
*/
|
||||||
|
export let name = undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
export let iconName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
export let iconSize = 16;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style type="scss">
|
||||||
|
.pair {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row nowrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="pair" title={name}>
|
||||||
|
<Tablericon name={iconName} size={iconSize} color="white"></Tablericon>
|
||||||
|
<span><slot /></span>
|
||||||
|
</div>
|
||||||
@ -3,7 +3,11 @@
|
|||||||
import Ago from "$comp/ago.svelte";
|
import Ago from "$comp/ago.svelte";
|
||||||
import Avatar from "$comp/avatar.svelte";
|
import Avatar from "$comp/avatar.svelte";
|
||||||
import CommentList from "$comp/commentlist.svelte";
|
import CommentList from "$comp/commentlist.svelte";
|
||||||
|
import Iconvalue from "$comp/iconvalue.svelte";
|
||||||
import Mention from "$comp/mention.svelte";
|
import Mention from "$comp/mention.svelte";
|
||||||
|
import Tablericon from "$comp/tablericon.svelte";
|
||||||
|
import { round10 } from "expected-round";
|
||||||
|
import moment from "moment";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {import("$types/base").Post}
|
* @type {import("$types/base").Post}
|
||||||
@ -15,7 +19,17 @@
|
|||||||
*/
|
*/
|
||||||
export let commentTree = null;
|
export let commentTree = null;
|
||||||
|
|
||||||
$: isPreview = commentTree == null;
|
/**
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
export let showMetrics = false;
|
||||||
|
|
||||||
|
let showStats = false;
|
||||||
|
|
||||||
|
function togglePostStats(/** @type {MouseEvent | TouchEvent} */ e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
showStats = !showStats;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@ -69,6 +83,40 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.3em 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricsSpacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
color: var(--white-dim);
|
||||||
|
|
||||||
|
& > table {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.8em;
|
||||||
|
|
||||||
|
& td {
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
padding-right: 1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsTitle {
|
||||||
|
color: var(--white-dim);
|
||||||
|
font-size: 0.8em;
|
||||||
|
text-align: left;
|
||||||
|
padding-left: 0.1em;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="body">
|
<div class="body">
|
||||||
@ -80,7 +128,7 @@
|
|||||||
<Mention user={post.author}></Mention>
|
<Mention user={post.author}></Mention>
|
||||||
<Ago date={post.postDate}></Ago>
|
<Ago date={post.postDate}></Ago>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="typeTitle">{post.name}</h1>
|
<h1 class="typeTitle title">{post.name}</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -88,7 +136,37 @@
|
|||||||
{post.content}
|
{post.content}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Actionbar message={post}></Actionbar>
|
<Actionbar message={post}>
|
||||||
|
{#if showMetrics && post.metrics}
|
||||||
|
<Iconvalue name="Comment Count" iconName="message">{post.metrics.commentCount}</Iconvalue>
|
||||||
|
<Iconvalue name="User Count" iconName="user">{post.metrics.userCount}</Iconvalue>
|
||||||
|
<Iconvalue name="Relevancy" iconName="chart-bar">{round10(post.metrics.relevancy, -2)}</Iconvalue>
|
||||||
|
{/if}
|
||||||
|
<div class="metricsSpacer"></div>
|
||||||
|
<button class="button transparent" on:click={togglePostStats}>
|
||||||
|
<Iconvalue name="More detailed metrics" iconName="database">stats</Iconvalue>
|
||||||
|
</button>
|
||||||
|
</Actionbar>
|
||||||
|
|
||||||
|
{#if showStats}
|
||||||
|
<div class="stats">
|
||||||
|
<h6 class="statsTitle">Debug stats</h6>
|
||||||
|
<table>
|
||||||
|
<tr><td>ID</td><td>{post.id}</td></tr>
|
||||||
|
<tr><td>Author</td><td>{post.author?.id}</td></tr>
|
||||||
|
<tr><td>Created Date</td><td>{post.postDate}</td></tr>
|
||||||
|
<tr><td>Score</td><td>{post.rating.likes - post.rating.dislikes}</td></tr>
|
||||||
|
{#if post.metrics}
|
||||||
|
<tr><td>Comment Count</td><td>{post.metrics.commentCount}</td></tr>
|
||||||
|
<tr><td>User Count</td><td>{post.metrics.userCount}</td></tr>
|
||||||
|
<tr><td>Latest Activity</td><td>{post.metrics.latestActivity}</td></tr>
|
||||||
|
<tr><td>Engagement</td><td>{post.metrics.engagement}</td></tr>
|
||||||
|
<tr><td>Age</td><td>{post.metrics.age}</td></tr>
|
||||||
|
<tr><td>Relevancy</td><td>{round10(post.metrics.relevancy, -5)}</td></tr>
|
||||||
|
{/if}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
{#if commentTree}
|
{#if commentTree}
|
||||||
|
|||||||
@ -9,8 +9,10 @@
|
|||||||
*/
|
*/
|
||||||
export let posts = [];
|
export let posts = [];
|
||||||
|
|
||||||
/** @type {import("$types/base").Post | null} */
|
/**
|
||||||
$: currentPreview = null;
|
* @type {import('$types/base').Post | null}
|
||||||
|
*/
|
||||||
|
export let glancePost = null;
|
||||||
|
|
||||||
/** @type {boolean[]} */
|
/** @type {boolean[]} */
|
||||||
$: hidden = Array(posts.length).fill(false);
|
$: hidden = Array(posts.length).fill(false);
|
||||||
@ -19,33 +21,33 @@
|
|||||||
/** @type {import("$types/base").Post} */
|
/** @type {import("$types/base").Post} */
|
||||||
const post = e.detail.post;
|
const post = e.detail.post;
|
||||||
|
|
||||||
currentPreview = post;
|
|
||||||
|
|
||||||
posts.forEach((p, index) => {
|
posts.forEach((p, index) => {
|
||||||
hidden[index] = p == post;
|
hidden[index] = p == post;
|
||||||
});
|
});
|
||||||
|
|
||||||
goto(`/posts?glance=${currentPreview.id}`, {
|
goto(`/posts?glance=${post.id}`, {
|
||||||
replaceState: true,
|
replaceState: true,
|
||||||
noScroll: true
|
noScroll: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function dismiss() {
|
function dismiss(/** @type {MouseEvent | TouchEvent} */ e) {
|
||||||
currentPreview = null;
|
e.stopPropagation();
|
||||||
|
|
||||||
hidden = Array(posts.length).fill(false);
|
hidden = Array(posts.length).fill(false);
|
||||||
|
|
||||||
// goto(`/posts`, {
|
goto(`/posts`, {
|
||||||
// replaceState: true,
|
replaceState: true,
|
||||||
// noScroll: true
|
noScroll: true
|
||||||
// });
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function expand() {
|
function expand(/** @type {MouseEvent | TouchEvent} */ e) {
|
||||||
if (currentPreview == null) return;
|
if (glancePost == null) return;
|
||||||
|
|
||||||
goto(`/posts/${getNamedId(currentPreview.id, currentPreview.name)}`, {
|
e.stopPropagation();
|
||||||
|
|
||||||
|
goto(`/posts/${getNamedId(glancePost.id, glancePost.name)}`, {
|
||||||
replaceState: false
|
replaceState: false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -109,10 +111,10 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="postPreview" data-visible={currentPreview != null || null} on:click={dismiss}>
|
<button class="postPreview" data-visible={glancePost != null || null} on:click={dismiss}>
|
||||||
<button class="preview" on:click={expand}>
|
<button class="preview" on:click={expand}>
|
||||||
{#if currentPreview}
|
{#if glancePost}
|
||||||
<Post post={currentPreview} commentTree={null}></Post>
|
<Post post={glancePost} commentTree={null} showMetrics={true}></Post>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import Iconvalue from "./iconvalue.svelte";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {import("$types/base").Rating}
|
* @type {import("$types/base").Rating}
|
||||||
*/
|
*/
|
||||||
@ -10,28 +12,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: row nowrap;
|
flex-flow: row nowrap;
|
||||||
|
|
||||||
gap: 10px;
|
gap: 24px;
|
||||||
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
svg.icon {
|
|
||||||
height: 16px;
|
|
||||||
width: 16px;
|
|
||||||
|
|
||||||
& > use {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="rating">
|
<div class="rating" {...$$restProps}>
|
||||||
<svg class="icon" viewBox="0 0 24 24">
|
<Iconvalue name="Likes" iconName="heart">{rating.likes}</Iconvalue>
|
||||||
<use href="/icon/heart.svg#icon"></use>
|
<Iconvalue name="Dislikes" iconName="thumb-down">{rating.dislikes}</Iconvalue>
|
||||||
</svg>
|
|
||||||
<span>{rating.likes}</span>
|
|
||||||
<svg class="icon" viewBox="0 0 24 24">
|
|
||||||
<use href="/icon/thumb-down.svg#icon"></use>
|
|
||||||
</svg>
|
|
||||||
<span>{rating.dislikes}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -46,15 +46,24 @@ function parsePostFromRow(author, category, row, withMetrics = false) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import('postgres').Sql} sql
|
* @param {import('postgres').Sql} sql
|
||||||
* @param {import('$types/base').Category | undefined} category
|
* @param {{
|
||||||
* @param {number} limit
|
* category?: import('$types/base').Category | undefined,
|
||||||
* @param {number} offset
|
* limit?: number,
|
||||||
* @param {boolean} withMetrics
|
* offset?: number,
|
||||||
|
* withMetrics?: boolean
|
||||||
|
* }} opts
|
||||||
* @returns {Promise<Post[]>}
|
* @returns {Promise<Post[]>}
|
||||||
*/
|
*/
|
||||||
export async function getPosts(sql, category = undefined, limit = 10, offset = 0, withMetrics = false) {
|
export async function getPosts(sql, opts = {}) {
|
||||||
const filter = category === undefined ? sql`` : sql`WHERE category_id = ${ category.id }`;
|
const {
|
||||||
const metrics = !withMetrics ? sql`` : sql`, comment_count, user_count, latest_activity, engagement, age, relevancy`;
|
category = undefined,
|
||||||
|
limit = 10,
|
||||||
|
offset = 0,
|
||||||
|
withMetrics = false,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
const filter = category ? sql`WHERE category_id = ${ category.id }` : sql``;
|
||||||
|
const metrics = withMetrics ? sql`, comment_count, user_count, latest_activity, engagement, age, relevancy` : sql``;
|
||||||
|
|
||||||
const query = sql`
|
const query = sql`
|
||||||
SELECT id, author_id, name, category_id, latest_content, created_date, likes, dislikes ${ metrics }
|
SELECT id, author_id, name, category_id, latest_content, created_date, likes, dislikes ${ metrics }
|
||||||
@ -83,11 +92,20 @@ export async function getPosts(sql, category = undefined, limit = 10, offset = 0
|
|||||||
*
|
*
|
||||||
* @param {import('postgres').Sql} sql
|
* @param {import('postgres').Sql} sql
|
||||||
* @param {number} post_id
|
* @param {number} post_id
|
||||||
|
* @param {{
|
||||||
|
* withMetrics?: boolean
|
||||||
|
* }} opts
|
||||||
* @returns {Promise<Post | import('$types/error').Error>}
|
* @returns {Promise<Post | import('$types/error').Error>}
|
||||||
*/
|
*/
|
||||||
export async function getPost(sql, post_id) {
|
export async function getPost(sql, post_id, opts = {}) {
|
||||||
|
const {
|
||||||
|
withMetrics = false
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
const metrics = withMetrics ? sql`, comment_count, user_count, latest_activity, engagement, age, relevancy` : sql``;
|
||||||
|
|
||||||
const query = sql`
|
const query = sql`
|
||||||
SELECT id, author_id, name, category_id, latest_content, created_date, likes, dislikes
|
SELECT id, author_id, name, category_id, latest_content, created_date, likes, dislikes ${ metrics }
|
||||||
FROM doki8902.message_post
|
FROM doki8902.message_post
|
||||||
WHERE id = ${ post_id };`;
|
WHERE id = ${ post_id };`;
|
||||||
|
|
||||||
@ -130,5 +148,5 @@ export async function getPost(sql, post_id) {
|
|||||||
/**
|
/**
|
||||||
* @type {Post}
|
* @type {Post}
|
||||||
*/
|
*/
|
||||||
return parsePostFromRow(author, category, post);
|
return parsePostFromRow(author, category, post, withMetrics);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,89 +0,0 @@
|
|||||||
// /**
|
|
||||||
// * @typedef {import('$types/base').PostMetrics} PostMetrics
|
|
||||||
// */
|
|
||||||
|
|
||||||
// import { sql } from '$lib/db.server';
|
|
||||||
|
|
||||||
// /**
|
|
||||||
// * @param {import('$types/base').Post} post
|
|
||||||
// * @param {import('postgres').Row} row
|
|
||||||
// * @returns {PostMetrics}
|
|
||||||
// */
|
|
||||||
// function parsePostMetricsFromRowAndPost(post, row) {
|
|
||||||
// return {
|
|
||||||
// postId: post.id,
|
|
||||||
// commentCount: BigInt(row['comment_count']),
|
|
||||||
// userCount: BigInt(row['user_count']),
|
|
||||||
// latestActivity: row['latest_activity'],
|
|
||||||
// rating: post.rating,
|
|
||||||
// score: BigInt(row['score']),
|
|
||||||
// engagement: BigInt(row['engagement']),
|
|
||||||
// age: row['age'],
|
|
||||||
// relevancy: row['relevancy']
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
|
|
||||||
// /**
|
|
||||||
// * @param {import('postgres').Row} row
|
|
||||||
// * @returns {PostMetrics}
|
|
||||||
// */
|
|
||||||
// function parsePostMetricsFromRow(row) {
|
|
||||||
// return {
|
|
||||||
// postId: row['post_id'],
|
|
||||||
// commentCount: BigInt(row['comment_count']),
|
|
||||||
// userCount: BigInt(row['user_count']),
|
|
||||||
// latestActivity: row['latest_activity'],
|
|
||||||
// rating: {
|
|
||||||
// likes: row['likes'],
|
|
||||||
// dislikes: row['dislikes']
|
|
||||||
// },
|
|
||||||
// score: BigInt(row['score']),
|
|
||||||
// engagement: BigInt(row['engagement']),
|
|
||||||
// age: row['age'],
|
|
||||||
// relevancy: row['relevancy']
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
|
|
||||||
// /**
|
|
||||||
// * @param {import('$types/base').Post} post
|
|
||||||
// * @returns {Promise<PostMetrics | import('$types/error').Error>}
|
|
||||||
// */
|
|
||||||
// export async function getPostMetricsForPost(post) {
|
|
||||||
// const query = sql`
|
|
||||||
// SELECT comment_count, user_count, latest_activity, score, engagement, age, relevancy
|
|
||||||
// FROM doki8902.post_metrics
|
|
||||||
// WHERE post_id = ${ post.id }`;
|
|
||||||
|
|
||||||
// const result = await query;
|
|
||||||
|
|
||||||
// if (result.length == 0) {
|
|
||||||
// return {
|
|
||||||
// error: true,
|
|
||||||
// msg: `Could not find PostMetrics for Post ID ${post.id}`
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return parsePostMetricsFromRowAndPost(post, result[0]);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// /**
|
|
||||||
// * @param {number} post_id
|
|
||||||
// * @returns {Promise<PostMetrics | import('$types/error').Error>}
|
|
||||||
// */
|
|
||||||
// export async function getPostMetricsForPostId(post_id) {
|
|
||||||
// const query = sql`
|
|
||||||
// SELECT post_id, comment_count, user_count, latest_activity, likes, dislikes, score, engagement, age, relevancy
|
|
||||||
// FROM doki8902.post_metrics
|
|
||||||
// WHERE post_id = ${ post_id }`;
|
|
||||||
|
|
||||||
// const result = await query;
|
|
||||||
|
|
||||||
// if (result.length == 0) {
|
|
||||||
// return {
|
|
||||||
// error: true,
|
|
||||||
// msg: `Could not find PostMetrics for Post ID ${post_id}`
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return parsePostMetricsFromRow(result[0]);
|
|
||||||
// }
|
|
||||||
@ -1,22 +1,17 @@
|
|||||||
import { getPosts } from '$lib/server/db/post';
|
import { getPost, getPosts } from '$lib/server/db/post';
|
||||||
import { getPostMetricsForPostId } from '$lib/server/db/postmertics';
|
|
||||||
|
|
||||||
|
|
||||||
/** @type {import('./$types').PageServerLoad} */
|
/** @type {import('./$types').PageServerLoad} */
|
||||||
export async function load({ locals, url }) {
|
export async function load({ locals, url }) {
|
||||||
const result = await getPosts(locals.sql, undefined, 10, 0, true);
|
const result = await getPosts(locals.sql);
|
||||||
|
|
||||||
// const glance = url.searchParams.get('glance');
|
const glance = url.searchParams.get('glance');
|
||||||
// const glanceID = glance ? parseInt(glance) : null
|
const glanceID = glance ? parseInt(glance) : null
|
||||||
|
|
||||||
// let postMetrics = null;
|
const glancePost = glanceID ? await getPost(locals.sql, glanceID, { withMetrics: true }) : null;
|
||||||
// if (glanceID) {
|
|
||||||
// postMetrics = await getPostMetricsForPostId(glanceID);
|
|
||||||
// }
|
|
||||||
|
|
||||||
console.log(result);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
posts: result
|
posts: result,
|
||||||
|
glancePost: glancePost
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,11 +3,12 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {{
|
* @type {{
|
||||||
* posts: import("$types/base").Post[]
|
* posts: import("$types/base").Post[],
|
||||||
|
* glancePost: import("$types/base").Post | null
|
||||||
* }}
|
* }}
|
||||||
*/
|
*/
|
||||||
export let data;
|
export let data;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h1>Posts</h1>
|
<h1>Posts</h1>
|
||||||
<Postlist posts={data.posts}></Postlist>
|
<Postlist posts={data.posts} glancePost={data.glancePost}></Postlist>
|
||||||
|
|||||||
1
static/icon/chart-bar.svg
Normal file
1
static/icon/chart-bar.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="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-chart-bar"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 12m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M9 8m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v10a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M15 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v14a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M4 20l14 0" /></svg>
|
||||||
|
After Width: | Height: | Size: 621 B |
1
static/icon/database.svg
Normal file
1
static/icon/database.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="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-database"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 6m-8 0a8 3 0 1 0 16 0a8 3 0 1 0 -16 0" /><path d="M4 6v6a8 3 0 0 0 16 0v-6" /><path d="M4 12v6a8 3 0 0 0 16 0v-6" /></svg>
|
||||||
|
After Width: | Height: | Size: 455 B |
1
static/icon/message.svg
Normal file
1
static/icon/message.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="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-message"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M8 9h8" /><path d="M8 13h6" /><path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12z" /></svg>
|
||||||
|
After Width: | Height: | Size: 465 B |
1
static/icon/user.svg
Normal file
1
static/icon/user.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="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-user"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" /><path d="M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" /></svg>
|
||||||
|
After Width: | Height: | Size: 422 B |
Loading…
x
Reference in New Issue
Block a user