Compare commits

...

6 commits

23 changed files with 376 additions and 85 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "@yusurko/vigil", "name": "@yusurko/vigil",
"private": true, "private": true,
"version": "0.1.0-dev42", "version": "0.1.0-dev43",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",

2
src/app.d.ts vendored
View file

@ -14,7 +14,7 @@ declare global {
site: ServerHealth | null, site: ServerHealth | null,
me: UserEntry | null, me: UserEntry | null,
results?: object[], results?: object[],
query?: string query?: string,
} }
// interface PageState {} // interface PageState {}
// interface Platform {} // interface Platform {}

View file

@ -0,0 +1,24 @@
<script lang="ts">
import { RiChat3Line } from "svelte-remixicon";
let { count } = $props();
</script>
<div class="comment-count">
<RiChat3Line />
<strong>{count??'-'}</strong>
</div>
<style>
.comment-count {
display: flex;
flex-direction: column;
align-items: center;
}
.comment-count :global(svg) {
color: var(--border);
}
</style>

View file

@ -0,0 +1,16 @@
<script lang="ts">
import type { CommentEntry } from "./backend";
import Centered from "./Centered.svelte";
let comments: null | CommentEntry = $state(null);
let { post } = $props();
</script>
{#if comments === null}
<Centered>
<button class="inline">Show comments</button>
</Centered>
{/if}

35
src/lib/EditPost.svelte Normal file
View file

@ -0,0 +1,35 @@
<script lang="ts">
import PrivacySelect from "./PrivacySelect.svelte";
let { content = $bindable(""), privacy = $bindable(0) } = $props();
let contentLength = $derived(content.length);
</script>
<label>
<input type="text" name="title" maxlength=256 placeholder="An interesting title"/>
</label>
<label>
<textarea bind:value={content}></textarea>
<output><small class="faint">{contentLength} chars</small></output>
</label>
<PrivacySelect bind:value={privacy} />
<style>
textarea {
width: 100%;
background-color: inherit;
color: var(--text-primary);
min-height: 10em;
margin-top: 4px;
}
input[name="title"] {
width: 100%;
margin: 6px 0;
font-size: 1.25em;
}
</style>

View file

@ -1,25 +1,68 @@
<script lang="ts"> <script lang="ts">
import { DateTime } from "luxon";
import { RiHashtag, RiHistoryLine, RiUserLine } from "svelte-remixicon";
import type { PostEntry } from "./backend"; import type { PostEntry } from "./backend";
import PostMeta from "./PostMeta.svelte"; import PostMeta from "./PostMeta.svelte";
import { SvelteShowdown } from "svelte-showdown"; import { SvelteShowdown } from "svelte-showdown";
import VoteButton from "./VoteButton.svelte";
import CommentCount from "./CommentCount.svelte";
let { post }: { post: PostEntry } = $props(); let { post }: { post: PostEntry } = $props();
let { id, title, created_at, content } = post; let { id, title, content = "", votes, my_vote, comment_count } = post;
</script> </script>
<card class="post-frame"> <article class="card">
<h3 class="post-title"> <div class="post-frame">
<a href="/={id}">{title}</a> <h3 class="post-title">
</h3> <a href="/={id}">{title}</a>
<PostMeta {post} /> </h3>
<!-- TODO pist content --> <PostMeta {post} />
<div class="post-content shorten"> <div class="post-content shorten">
<SvelteShowdown content={ content || "" } /> <SvelteShowdown { content } />
</div>
<aside class="message-stats">
<VoteButton score={votes} vote={my_vote} {id} />
<CommentCount count={comment_count} />
</aside>
</div> </div>
</card> </article>
<style> <style>
.post-frame {
padding-inline-start: 2em;
position: relative;
}
.message-stats {
position: absolute;
inset-inline-start: 0;
top: 0;
width: 2em;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
}
.post-title {
line-height: 1.2;
}
.shorten {
max-height: 18em;
overflow-y: hidden;
position: relative;
}
.shorten::after {
content: '';
position: absolute;
z-index: 10;
top: 16em;
left: 0;
width: 100%;
height: 2em;
display: block;
background: linear-gradient(to bottom, rgba(0,0,0,0) 0%, var(--background) 100%);
}
</style> </style>

View file

@ -1,7 +1,5 @@
<script lang="ts"> <script lang="ts">
import { DateTime } from "luxon"; import { RiEditLine, RiFlagLine } from "svelte-remixicon";
import { RiEditLine, RiFlagLine, RiHistoryLine, RiHome2Line, RiUserLine } from "svelte-remixicon";
import type { PostEntry } from "./backend"; import type { PostEntry } from "./backend";
import SLayout from "./SLayout.svelte"; import SLayout from "./SLayout.svelte";
import GuildAbout from "./GuildAbout.svelte"; import GuildAbout from "./GuildAbout.svelte";
@ -12,13 +10,15 @@
import { SvelteShowdown } from "svelte-showdown"; import { SvelteShowdown } from "svelte-showdown";
import VoteButton from "./VoteButton.svelte"; import VoteButton from "./VoteButton.svelte";
import { getMe } from "./globals.svelte"; import { getMe } from "./globals.svelte";
import CommentCount from "./CommentCount.svelte";
import CommentSection from "./CommentSection.svelte";
let { post }: { post: PostEntry } = $props(); let { post }: { post: PostEntry } = $props();
let me = getMe(); let me = getMe();
let { title, created_at, id, content = '', to } = post; let { title, id, content = '', to, votes, my_vote, comment_count } = post;
</script> </script>
<SLayout title={to.display_name + (to.type === 'guild' ? ` (+${to.name})` : to.type === 'user' ? ` (@${to.username})` : '')}> <SLayout title={to.display_name + (to.type === 'guild' ? ` (+${to.name})` : to.type === 'user' ? ` (@${to.username})` : '')}>
@ -34,21 +34,24 @@ let { title, created_at, id, content = '', to } = post;
<!-- content, formatted as markdown --> <!-- content, formatted as markdown -->
</div> </div>
</div><!-- .post-body --> </div><!-- .post-body -->
<div class="message-stats"> <aside class="message-stats">
<!-- upvotes / downvotes --> <!-- upvotes / downvotes -->
<VoteButton /> <VoteButton score={votes} vote={my_vote} {id} />
</div> <CommentCount count={comment_count} />
</aside>
<ul class="message-options row"> <ul class="message-options row">
{#if me && me.id !== post.author?.id} {#if me && me.id !== post.author?.id}
<li><a href="/report/post/{id}"><RiFlagLine/> Report</a></li> <li><a href="/report/post/{id}"><RiFlagLine/> Report</a></li>
{/if} {/if}
{#if me && me.id === post.author?.id } {#if me && me.id === post.author?.id }
<li><a href="/edit/post/{id}"><RiEditLine/> Edit</a></li> <li><a href="/edit/={id}"><RiEditLine/> Edit</a></li>
{/if} {/if}
</ul> </ul>
</div> </div>
</article> </article>
<CommentSection {post} />
{#snippet left()} {#snippet left()}
{#if to.type === 'guild'} {#if to.type === 'guild'}
<GuildMenu guild={to} /> <GuildMenu guild={to} />
@ -74,13 +77,13 @@ let { title, created_at, id, content = '', to } = post;
overflow-x: auto; overflow-x: auto;
} }
.post-body { .post-body {
margin-inline-start: 3em; margin-inline-start: 2em;
} }
.message-stats { .message-stats {
position: absolute; position: absolute;
inset-inline-start: 0; inset-inline-start: 0;
top: 0; top: 0;
width: 3em; width: 2em;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;

View file

@ -9,7 +9,6 @@ let { guild }: { guild: GuildEntry } = $props();
</script> </script>
{#if guild} {#if guild}
<AsideCard title={'About ' + (guild.display_name? `${guild.display_name} (+${guild.name})`: `+${guild.name}`)}> <AsideCard title={'About ' + (guild.display_name? `${guild.display_name} (+${guild.name})`: `+${guild.name}`)}>
<ul> <ul>
@ -21,4 +20,4 @@ let { guild }: { guild: GuildEntry } = $props();
{/if} {/if}
</ul> </ul>
</AsideCard> </AsideCard>
{/if} {/if}

View file

@ -2,7 +2,7 @@
import { backend, type GuildEntry } from "./backend"; import { backend, type GuildEntry } from "./backend";
let value = $state(""); let { value = $bindable("") } = $props();
let suggestions: Promise<GuildEntry[]> = $derived(getSuggestions(value)); let suggestions: Promise<GuildEntry[]> = $derived(getSuggestions(value));

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { RiLoginBoxLine, RiLogoutBoxLine, RiSearch2Line, RiSearchLine, RiSettings3Line } from "svelte-remixicon"; import { RiAddLine, RiLoginBoxLine, RiLogoutBoxLine, RiSearch2Line, RiSearchLine, RiSettings3Line, RiShieldStarLine, RiUserLine } from "svelte-remixicon";
import { activePostCount } from "./globals.svelte"; import { activePostCount } from "./globals.svelte";
import type { UserEntry } from "./backend"; import type { UserEntry } from "./backend";
@ -35,7 +35,23 @@ let enable_search = $derived(user !== null);
<span><a href="/@{user.username}">@{user.username}</a></span> <span><a href="/@{user.username}">@{user.username}</a></span>
<span>{user.karma || 0} karma</span> <span>{user.karma || 0} karma</span>
</div> </div>
<a class="mobileonly" href="/@{user.username}">
<RiUserLine />
</a>
</li> </li>
<li class="nomobile">
<a href="/create">
<button>Create post</button>
</a>
</li>
{#if user.badges && user.badges.indexOf("administrator") >= 0}
<li>
<a href="/admin/">
<RiShieldStarLine />
</a>
</li>
{/if}
<li><a href="/logout" aria-label="Log out" title="Log out"><RiLogoutBoxLine /></a></li> <li><a href="/logout" aria-label="Log out" title="Log out"><RiLogoutBoxLine /></a></li>
{:else} {:else}
<li><a href="/login" aria-label="Log in" title="Log in"><RiLoginBoxLine /></a></li> <li><a href="/login" aria-label="Log in" title="Log in"><RiLoginBoxLine /></a></li>
@ -98,7 +114,9 @@ let enable_search = $derived(user !== null);
overflow: hidden; overflow: hidden;
} }
button {
font-size: 1rem;
}

View file

@ -1,23 +1,28 @@
<script lang="ts"> <script lang="ts">
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { RiAddCircleLine, RiChat2Line, RiCompassDiscoverLine, RiHome2Line, RiHomeLine, RiNotificationLine } from "svelte-remixicon"; import { RiAddCircleLine, RiChat2Line, RiCompassDiscoverLine, RiHome2Line, RiHomeLine, RiNotificationLine, RiUserLine } from "svelte-remixicon";
import { getMe } from "./globals.svelte";
function rickroll (){ function rickroll (){
if (browser) { if (browser) {
window.open( "https://youtu.be/dQw4w9WgXcQ" ); window.open( "https://youtu.be/dQw4w9WgXcQ" );
} }
} }
let me = $derived(getMe());
</script> </script>
{#if me}
<footer class="mobileonly"> <footer class="mobileonly">
<ul class="row"> <ul class="row">
<li><a href="/" title="Homepage"><RiHome2Line size="2em" /></a></li> <li><a href="/" title="Homepage"><RiHome2Line size="2em" /></a></li>
<li><a href="/" title="Discover"><RiCompassDiscoverLine size="2em" /></a></li> <li><a href="/" title="Discover"><RiCompassDiscoverLine size="2em" /></a></li>
<li><a href="/new" title="Create"><RiAddCircleLine size="2em" /></a></li> <li><a href="/create" title="Create"><RiAddCircleLine size="2em" /></a></li>
<li><a href="/user/yusur" title="Messages"><RiChat2Line size="2em" /></a></li> <li><a href="/@{me.username}" title="Profile"><RiUserLine size="2em" /></a></li>
<li><a href="?" onclick={rickroll} title="Notifications"><RiNotificationLine size="2em" /></a></li> <li><a href="?" onclick={rickroll} title="Notifications"><RiNotificationLine size="2em" /></a></li>
</ul> </ul>
</footer> </footer>
{/if}
<style> <style>

View file

@ -1,31 +1,46 @@
<script lang="ts"> <script lang="ts">
import { RiHeartFill, RiHeartLine, RiThumbDownFill, RiThumbDownLine } from "svelte-remixicon"; import { RiHeartFill, RiHeartLine, RiThumbDownFill, RiThumbDownLine } from "svelte-remixicon";
import { backend } from "./backend";
let vote = $state(0); let { score = $bindable(null), vote = $bindable(0), id } : { score?: number | null, vote?: 0 | 1 | -1, id: string } = $props();
let { score } : { score?: number | null } = $props();
async function castVote(v: 0 | 1 | -1) {
let readyBackend = await backend.withEvent(null).oath();
let result = await readyBackend.submitJson(`post/${id}/upvote`, {
vote: v
});
if (score === null) { return; }
if (result.status >= 400) {
// TODO toast error?
console.error("error:", (await result.json()));
return;
}
let {votes} = await result.json();
vote = v;
score = votes;
}
</script> </script>
<div class="upvote-button"> <div class="upvote-button">
{#if vote > 0} {#if vote > 0}
<button class="inline"> <button class="inline up" onclick={() => { castVote(0).then(() => {}); }}>
<RiHeartFill /> <RiHeartFill />
</button> </button>
{:else} {:else}
<button class="inline"> <button class="inline" onclick={() => { castVote(1).then(() => {}); }}>
<RiHeartLine /> <RiHeartLine />
</button> </button>
{/if} {/if}
<strong>{score ?? '-'}</strong> <strong>{score ?? '-'}</strong>
{#if vote > 0} {#if vote < 0}
<button class="inline"> <button class="inline down" onclick={() => { castVote(0).then(() => {}); }}>
<RiThumbDownFill /> <RiThumbDownFill />
</button> </button>
{:else} {:else}
<button class="inline"> <button class="inline" onclick={() => { castVote(-1).then(() => {}); }}>
<RiThumbDownLine /> <RiThumbDownLine />
</button> </button>
{/if} {/if}
@ -37,5 +52,18 @@ let { score } : { score?: number | null } = $props();
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
} }
button.inline {
color: var(--border);
}
button.inline.up {
color: var(--accent);
}
button.inline.down {
color: var(--c11-accent);
}
:global(.color-theme-11) button.inline.down, :global(.color-theme-9) button.inline.down {
color: var(--c14-accent);
}
</style> </style>

8
src/lib/Wip.svelte Normal file
View file

@ -0,0 +1,8 @@
<script lang="ts">
import { RiBarricadeLine } from "svelte-remixicon";
import Centered from "./Centered.svelte";
</script>
<Centered>
<faint><RiBarricadeLine /></faint>
</Centered>

View file

@ -29,8 +29,21 @@ export type PostEntry = {
title: string, title: string,
created_at: string, created_at: string,
author?: UserEntry | null, author?: UserEntry | null,
content?: string | null, content?: string,
to: UserEntry | GuildEntry to: UserEntry | GuildEntry ,
privacy?: number,
votes?: number | null,
my_vote?: 1 | -1 | 0,
comment_count?: number | null
};
export type CommentEntry = {
id: string,
parent?: {id: string},
locked?: boolean,
removed?: number | true,
content?: string,
created_at: string
}; };
export type ServerHealth = { export type ServerHealth = {

View file

@ -22,8 +22,6 @@ export function setHealth ({ name, version, post_count, user_count, color_theme
health.color_theme = color_theme; health.color_theme = color_theme;
} }
export function setMe (me: UserEntry | null) { export function setMe (me: UserEntry | null) {
health.me = me; health.me = me;
} }

View file

@ -36,7 +36,7 @@ let colorThemeCls = $derived(`color-scheme-${colorScheme} color-theme-${colorThe
<!-- end SEO tags --> <!-- end SEO tags -->
</svelte:head> </svelte:head>
<global-wrapper class={colorThemeCls}> <div class="{colorThemeCls} contents">
<header> <header>
<h1> <h1>
<a href="/">{appName()}</a> <a href="/">{appName()}</a>
@ -54,7 +54,7 @@ let colorThemeCls = $derived(`color-scheme-${colorScheme} color-theme-${colorThe
</main> </main>
<MobileFooter /> <MobileFooter />
</global-wrapper> </div>
<style> <style>
@ -88,8 +88,13 @@ let colorThemeCls = $derived(`color-scheme-${colorScheme} color-theme-${colorThe
@media screen and (max-width: 799px) { @media screen and (max-width: 799px) {
main { main {
height: 100vh; min-height: 100vh;
width: 100vw;
} }
} }
.contents {
position: relative;
}
</style> </style>

View file

@ -1,18 +1,19 @@
<script lang="ts"> <script lang="ts">
import Centered from "$lib/Centered.svelte"; import Centered from "$lib/Centered.svelte";
import EditPost from "$lib/EditPost.svelte";
import { getMe } from "$lib/globals.svelte"; import { getMe } from "$lib/globals.svelte";
import GuildSelect from "$lib/GuildSelect.svelte"; import GuildSelect from "$lib/GuildSelect.svelte";
import PrivacySelect from "$lib/PrivacySelect.svelte";
import SLayout from "$lib/SLayout.svelte"; import SLayout from "$lib/SLayout.svelte";
import Wip from "$lib/Wip.svelte";
import { RiErrorWarningLine } from "svelte-remixicon"; import { RiErrorWarningLine } from "svelte-remixicon";
let me = getMe(); let me = getMe();
let content = $state(""); let content = $state("");
let privacy = $state(0); let privacy = $state(0);
let guildName = $state("");
let contentLength = $derived(content.length); let enablePost = $derived(!!content && !!guildName && false);
</script> </script>
@ -22,34 +23,26 @@
<p>Posting as <strong>@{me.username}</strong></p> <p>Posting as <strong>@{me.username}</strong></p>
<label> <label>
Post to: <!-- TODO autocomplete! --> Post to:
<GuildSelect /> <GuildSelect bind:value={guildName} />
</label> </label>
<label> <EditPost bind:content bind:privacy />
<input type="text" name="title" maxlength=256 placeholder="An interesting title"/>
</label>
<label>
<textarea bind:value={content}></textarea>
<output><small class="faint">{contentLength} chars</small></output>
</label>
<PrivacySelect bind:value={privacy} />
{#if privacy === 0} {#if privacy === 0}
<span class="warning"><RiErrorWarningLine /> Your post will be PUBLIC!</span> <span class="warning"><RiErrorWarningLine /> Your post will be PUBLIC!</span>
{/if} {/if}
<button class="card primary" disabled>Create</button> <button class="card primary" disabled={!enablePost}>Create</button>
</form> </form>
{#snippet left()} {#snippet left()}
... <Wip />
{/snippet} {/snippet}
{#snippet right()} {#snippet right()}
... <Wip />
{/snippet} {/snippet}
</SLayout> </SLayout>
{:else} {:else}
@ -60,22 +53,10 @@
<style> <style>
textarea {
width: 100%;
background-color: inherit;
color: var(--text-primary);
min-height: 10em;
margin-top: 4px;
}
form { form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
input[name="title"] {
width: 100%;
margin: 6px 0;
font-size: 1.25em;
}
</style> </style>

View file

@ -0,0 +1,65 @@
<script lang="ts">
import type { PostEntry } from "$lib/backend";
import Centered from "$lib/Centered.svelte";
import EditPost from "$lib/EditPost.svelte";
import { getMe } from "$lib/globals.svelte";
import GuildSelect from "$lib/GuildSelect.svelte";
import SLayout from "$lib/SLayout.svelte";
import Wip from "$lib/Wip.svelte";
import { RiErrorWarningLine } from "svelte-remixicon";
let me = getMe();
let { data }: { data: PostEntry } = $props();
let post = $state(data) ;
let { content = "", privacy } = $derived(post);
</script>
{#if me?.id === post.author?.id}
<SLayout title="New post">
<form method="POST" class="card">
<p>Posting as <strong>@{me?.username}</strong></p>
<label>
Post to:
<GuildSelect />
</label>
<EditPost bind:content bind:privacy />
{#if privacy === 0}
<span class="warning"><RiErrorWarningLine /> Your post will be PUBLIC!</span>
{/if}
<button class="card primary" disabled>Create</button>
</form>
{#snippet left()}
<Wip />
{/snippet}
{#snippet right()}
<Wip />
{/snippet}
</SLayout>
{:else if me}
You can't edit posts that are not your own.
{:else}
<Centered>
You must be <a href="login">logged in</a> in order to edit your own posts.
</Centered>
{/if}
<style>
form {
display: flex;
flex-direction: column;
}
</style>

View file

@ -0,0 +1,51 @@
import { backend, type GuildEntry } from '$lib/backend.js';
import { getMe } from '$lib/globals.svelte';
import { error, isHttpError, redirect } from '@sveltejs/kit';
export async function load(event) {
const { params } = event;
const { id } = params;
const resp = await backend.withEvent(event).fetch('post/' + encodeURIComponent(id));
if(resp.status === 404) {
error(404);
}
let post;
try{
const respJ = await resp.json();
let { posts } = respJ;
post = posts[id];
let me = getMe();
if (!me) {
redirect(303, "/login?next=" + encodeURIComponent(event.url.pathname));
}
if ( post.author.id !== me?.id) {
error(403);
}
if (post?.to && post.to.type === 'guild') {
const guild: GuildEntry = post.to;
const guildResp = await backend.withEvent(event).fetch('guild/' + encodeURIComponent(guild.id));
const guildJson = await guildResp.json();
const guildInfo = guildJson?.guilds?.[guild.id];
guildInfo.type = 'guild';
post.to = guildInfo || guild;
console.log(post.to);
}
} catch (e) {
if (isHttpError(e)) throw e;
console.error(e);
error(502);
}
return post;
}

View file

@ -5,7 +5,6 @@ import { redirect } from 'sveltekit-flash-message/server';
export const actions = { export const actions = {
default: async (event) => { default: async (event) => {
// TODO login
const { request } = event; const { request } = event;

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { enhance } from "$app/forms"; import { enhance } from "$app/forms";
import Centered from "$lib/Centered.svelte"; import Centered from "$lib/Centered.svelte";
import Wip from "$lib/Wip.svelte";
import { RiInformationLine, RiUserLine } from "svelte-remixicon"; import { RiInformationLine, RiUserLine } from "svelte-remixicon";
let username = $state(""); let username = $state("");
@ -25,7 +26,7 @@ let disabled = $derived(username.length < 2 || username.length > 30 ||
<small class="faint">Must be a working e-mail address <abbr title="Will be used for password recovery and important communications"><RiInformationLine /></abbr></small> <small class="faint">Must be a working e-mail address <abbr title="Will be used for password recovery and important communications"><RiInformationLine /></abbr></small>
<input type="text" name="username" bind:value={email} /> <input type="text" name="username" bind:value={email} />
</label> </label>
... <Wip />
<button class="primary" disabled={disabled}>Sign up</button> <button class="primary" disabled={disabled}>Sign up</button>
</form> </form>

View file

@ -32,14 +32,12 @@ export const actions = {
event.locals.results = results; event.locals.results = results;
event.locals.query = query; event.locals.query = query;
console.log(event.locals); console.log(event.locals);
return
} }
} satisfies Actions; } satisfies Actions;
export async function load (event) { export function load (event) {
const { results, query } = event.locals; const { results, query } = event.locals;
console.log({ results, query }); return { results, query };
return { results, query };
} }

View file

@ -6,6 +6,7 @@
import type { PageProps } from "./$types"; import type { PageProps } from "./$types";
let { data }: PageProps = $props(); let { data }: PageProps = $props();
console.log(data);
let { query, results } = $derived(data); let { query, results } = $derived(data);
</script> </script>