initial commit

This commit is contained in:
Yusur 2025-09-12 19:20:30 +02:00
commit 155aa524f3
48 changed files with 3943 additions and 0 deletions

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

@ -0,0 +1,35 @@
<script lang="ts">
let { children, title = null } = $props();
</script>
<aside class="card">
{#if title}
<h3>{title}</h3>
{/if}
{@render children()}
</aside>
<style lang="scss">
aside {
overflow: hidden;
border: var(--border) 1px solid;
}
h3 {
background-color: var(--accent);
color: var(--bg-main);
padding: 6px 12px;
margin: -12px -12px 0 -12px;
position: relative;
font: inherit;
a {
color: inherit;
text-decoration: underline;
}
}
</style>

14
src/lib/Centered.svelte Normal file
View file

@ -0,0 +1,14 @@
<script lang="ts">
let { narrow = false, children } : { narrow?: boolean, children: any } = $props();
</script>
<div class="centered" class:narrow={narrow}>
{@render children () }
</div>
<style>
.centered {text-align: center;}
.centered.narrow { max-width: 400px; margin: auto; }
</style>

View file

@ -0,0 +1,55 @@
<script lang="ts">
let { children, name, value = '1' } = $props();
let checked = $state(false);
</script>
<label>
<span class="toggleout" class:checked role="checkbox" aria-checked={checked}></span>
<input type="checkbox" bind:checked {name} {value} />
{@render children()}
</label>
<style>
[type="checkbox"] {
display: none;
}
.toggleout {
display: inline-block;
height: 1em;
width: 2em;
vertical-align: middle;
background-color: var(--bg-sharp);
border-radius: 1em;
position: relative;
transition: ease .5s;
}
.toggleout::before {
display: inline-block;
background-color: var(--text-primary);
width: .8em;
height: .8em;
border-radius: 1em;
content: '\00d7';
color: var(--bg-sharp);
position: absolute;
left: .1em;
top: .1em;
line-height: .6;
transition: ease .5s left;
}
.toggleout.checked {
background-color: var(--accent);
}
.toggleout.checked::before {
content: '\2713';
left: 1.1em;
top: .1em;
}
</style>

34
src/lib/Feed.svelte Normal file
View file

@ -0,0 +1,34 @@
<script lang="ts">
import { RiChatOffLine } from "svelte-remixicon";
import Centered from "./Centered.svelte";
import FeedPost from "./FeedPost.svelte";
let { posts, emptymsg = "No posts, how empty" } = $props();
</script>
<ul>
{#each posts as post}
<li><FeedPost {post} /></li>
{:else}
<Centered>
<p class="big"><RiChatOffLine /></p>
{emptymsg}</Centered>
{/each}
</ul>
<style>
ul {
list-style: none;
padding: 0 1em;
}
ul > li {
margin-top: 3px;
margin-bottom: 3px;
}
p.big {
font-size: 2em;
}
</style>

25
src/lib/FeedPost.svelte Normal file
View file

@ -0,0 +1,25 @@
<script lang="ts">
import { DateTime } from "luxon";
import { RiHashtag, RiHistoryLine, RiUserLine } from "svelte-remixicon";
import type { PostEntry } from "./backend";
import PostMeta from "./PostMeta.svelte";
import { SvelteShowdown } from "svelte-showdown";
let { post }: { post: PostEntry } = $props();
let { id, title, created_at, content } = post;
</script>
<card class="post-frame">
<h3 class="post-title">
<a href="/={id}">{title}</a>
</h3>
<PostMeta {post} />
<!-- TODO pist content -->
<div class="post-content shorten">
<SvelteShowdown content={ content || "" } />
</div>
</card>
<style>
</style>

View file

@ -0,0 +1,15 @@
<script lang="ts"
>
let { message } = $props();
</script>
<div class="flash card">
{message}
</div>
<style>
.flash {
border-color: yellow;
background-color: #fff00040;
}
</style>

62
src/lib/FullPost.svelte Normal file
View file

@ -0,0 +1,62 @@
<script lang="ts">
import { DateTime } from "luxon";
import { RiHistoryLine, RiHome2Line, RiUserLine } from "svelte-remixicon";
import type { PostEntry } from "./backend";
import SLayout from "./SLayout.svelte";
import GuildAbout from "./GuildAbout.svelte";
import UserAbout from "./UserAbout.svelte";
import UserMenu from "./UserMenu.svelte";
import PostMeta from "./PostMeta.svelte";
import GuildMenu from "./GuildMenu.svelte";
import { SvelteShowdown } from "svelte-showdown";
let { post }: { post: PostEntry } = $props();
let { title, created_at, id, content = '', to } = post;
</script>
<SLayout title={to.display_name + (to.type === 'guild' ? ` (+${to.name})` : to.type === 'user' ? ` (@${to.username})` : '')}>
<article class="card">
<div class="post-frame" id={id}>
<div class="post-body">
<h1>{title}</h1>
<PostMeta {post} />
<!-- here go reports -->
<!-- here goes removal message -->
<div class="post-content">
<SvelteShowdown content={ content || "" } />
<!-- content, formatted as markdown -->
</div>
</div><!-- .post-body -->
<div class="message-stats">
<!-- upvotes / downvotes -->
</div>
<ul class="message-options row">
</ul>
</div>
</article>
{#snippet left()}
{#if to.type === 'guild'}
<GuildMenu guild={to} />
{:else if to.type == 'user'}
<UserMenu user={to} />
{/if}
{/snippet}
{#snippet right()}
{#if to.type === 'guild'}
<GuildAbout guild={to} />
{:else if to.type === 'user'}
<UserAbout user={to} />
{/if}
{/snippet}
</SLayout>
<style>
.post-content {
overflow-x: auto;
}
</style>

24
src/lib/GuildAbout.svelte Normal file
View file

@ -0,0 +1,24 @@
<script lang="ts">
import { RiCakeLine, RiInfoCardLine } from "svelte-remixicon";
import AsideCard from "./AsideCard.svelte";
import type { GuildEntry } from "./backend";
import { DateTime } from "luxon";
let { guild }: { guild: GuildEntry } = $props();
</script>
{#if guild}
<AsideCard title={'About ' + (guild.display_name? `${guild.display_name} (+${guild.name})`: `+${guild.name}`)}>
<ul>
{#if guild.description}
<li><RiInfoCardLine /> {guild.description}</li>
{/if}
{#if guild.created_at}
<li><RiCakeLine /> Cake day: {DateTime.fromISO(guild.created_at).toFormat('d LLL yyyy')}</li>
{/if}
</ul>
</AsideCard>
{/if}

15
src/lib/GuildMenu.svelte Normal file
View file

@ -0,0 +1,15 @@
<script lang="ts">
import { RiHome2Line, RiUserLine } from "svelte-remixicon";
import type { GuildEntry } from "./backend";
let { guild } : { guild: GuildEntry } = $props();
</script>
<ul class="column">
<li>
<a href="/+{guild.name}">
<RiHome2Line />
Home</a>
</li>
</ul>

101
src/lib/MetaNav.svelte Normal file
View file

@ -0,0 +1,101 @@
<script lang="ts">
import { RiLoginBoxLine, RiLogoutBoxLine, RiSearch2Line, RiSearchLine, RiSettings3Line } from "svelte-remixicon";
import { activePostCount } from "./globals.svelte";
import type { UserEntry } from "./backend";
let { user } : {user: UserEntry} = $props();
</script>
<div class="metanav">
<ul>
<li>
<form action="/search"
method="POST"
class="mini-search-bar nomobile">
<!-- csrf_token() -->
<input type="search" disabled={true} name="q" placeholder="Search among {activePostCount()} posts" />
<button type="submit">Search</button>
</form>
<a href="/search" aria-label="Search" title="Search" class="mobileonly">
<RiSearchLine />
</a>
</li>
{#if user}
<li>
<div class="header-username nomobile">
<span>@{user.username}</span>
<span>{0} karma</span>
</div>
</li>
<li><a href="/logout" aria-label="Log out" title="Log out"><RiLogoutBoxLine /></a></li>
{:else}
<li><a href="/login" aria-label="Log in" title="Log in"><RiLoginBoxLine /></a></li>
{/if}
</ul>
</div>
<style>
.metanav {
align-self: flex-end;
font-size: 1.5em;
margin: auto;
margin-inline-start: 2em;
}
.metanav ul {
list-style: none;
padding: 0;
margin: 0;
}
.metanav ul > li {
margin: 0 6px;
}
.metanav ul,.metanav ul > li {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
}
.metanav, .metanav > ul,.metanav > ul > li:has(.mini-search-bar) {
flex: 1;
}
.metanav .header-username {
display: flex;
flex-direction: column;
color: var(--text-primary);
font-size: .6em;
justify-content: flex-start;
}
.mini-search-bar {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
flex: 1;
font-size: 1.2rem;
}.mini-search-bar [type="search"] {
flex: 1;
}
.mini-search-bar [type="submit"]{
height: 0;
width: 0;
padding: 0;
margin: 0;
border-radius: 0;
opacity: 0;
overflow: hidden;
}
.mini-search-bar + a {display: none}
</style>

View file

@ -0,0 +1,48 @@
<script lang="ts">
import { browser } from "$app/environment";
import { RiAddCircleLine, RiChat2Line, RiCompassDiscoverLine, RiHome2Line, RiHomeLine, RiNotificationLine } from "svelte-remixicon";
function rickroll (){
if (browser) {
window.open( "https://youtu.be/dQw4w9WgXcQ" );
}
}
</script>
<footer class="mobileonly">
<ul class="row">
<li><a href="/" title="Homepage"><RiHome2Line 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="/user/yusur" title="Messages"><RiChat2Line size="2em" /></a></li>
<li><a href="?" onclick={rickroll} title="Notifications"><RiNotificationLine size="2em" /></a></li>
</ul>
</footer>
<style>
footer {
position: sticky;
bottom: 0;
left: 0;
width: 100%;
overflow: hidden;
margin: 0;
padding: 0;
background-color: var(--background);
box-shadow: 0 0 6px var(--border);
z-index: 150;
}
ul.row {
align-items: stretch;
justify-content: stretch;
}
ul.row > li {
flex: 1;
padding: .5em;
margin: 0;
text-align: center;
}
</style>

View file

@ -0,0 +1,34 @@
<script lang="ts">
import { RiEyeLine, RiEyeOffLine } from "svelte-remixicon";
let showPassword: boolean = $state(false);
let { name } : { name: string } = $props();
</script>
<div class="inset">
<input type={showPassword? 'text': 'password'} {name} />
<span>
{#if showPassword}
<button class="inline" onclick={() => { showPassword = false; }}><RiEyeOffLine /></button>
{:else}
<button class="inline" onclick={() => { showPassword = true; }}><RiEyeLine /></button>
{/if}
</span>
</div>
<style>
.inset {
position: relative;
display: flex;
flex-direction: row;
}
.inset input {
flex: 1;
}
.inset :has(button) {
position: absolute;
right: 0;
}
</style>

20
src/lib/PostMeta.svelte Normal file
View file

@ -0,0 +1,20 @@
<script lang="ts">
import { RiHashtag, RiHistoryLine, RiUserLine } from "svelte-remixicon";
import type { PostEntry } from "./backend";
import { DateTime } from "luxon";
let { post } : {post: PostEntry }= $props();
</script>
<ul class="post-meta row">
<li><RiUserLine />
{#if post.author}<a href="/@{post.author.username}">@{post.author.username}</a>
{:else}<i>Someone</i>{/if}</li>
{#if post.to.type == 'guild'}
<li><RiHashtag /> <a href="/+{post.to.name}">+{post.to.name}</a></li>
{/if}
<li><RiHistoryLine /> <time datetime={post.created_at}>{ DateTime.fromISO(post.created_at).toFormat('d LLL yyyy') }</time></li>
</ul>
<style>
ul.post-meta > li { margin-inline-end: 9px; }
</style>

76
src/lib/SLayout.svelte Normal file
View file

@ -0,0 +1,76 @@
<script lang="ts">
import { RiInformationLine, RiMenu3Line, RiShieldLine } from "svelte-remixicon";
let { children, title, left, right } = $props();
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="layout">
<div class="layout-title">{title}</div>
<div class="layout-left">{@render left()}</div>
<div class="layout-content">{@render children()}</div>
<div class="layout-right">{@render right()}</div>
<div class="layout-licon" onclick={() => {}}><RiMenu3Line /></div>
<div class="layout-ricon" onclick={() => {}}><RiInformationLine /></div>
</div>
<style>
.layout {
display: grid;
grid-template-columns: 300px auto 300px;
grid-template-rows: 2em auto;
grid-template-areas:
". title ."
"left center right";
margin: 1em 2em;
}
.layout-left { grid-area: left; }
.layout-right { grid-area: right; }
.layout-content { grid-area: center; }
.layout-title { grid-area: title; text-align: center; font-size: 1.4em; }
@media (min-width: 800px) {
.layout {
grid-template-columns: 240px auto 240px;
}
.layout-licon, .layout-ricon { display: none; }
}
@media (min-width: 960px) {
.layout {
grid-template-columns: 270px auto 270px;
}
}
@media (min-width: 1080px) {
.layout {
grid-template-columns: 300px auto 300px;
}
}
@media (min-width: 1280px) {
.layout {
grid-template-columns: 330px auto 330px;
}
}
@media (max-width: 799px) {
.layout {
grid-template-areas:
"licon title ricon"
"center center center";
grid-template-columns: 2em auto 2em;
}
.layout-left, .layout-right { display: none; }
}
</style>

38
src/lib/UserAbout.svelte Normal file
View file

@ -0,0 +1,38 @@
<script lang="ts">
import { browser } from "$app/environment";
import AsideCard from "./AsideCard.svelte";
import { RiCake2Line, RiCakeLine, RiInfoCardLine, RiShieldStarLine } from 'svelte-remixicon';
import { DateTime } from 'luxon';
import type { UserEntry } from "./backend";
let { user }: { user: UserEntry } = $props();
let { badges = [] } = user;
</script>
{#if user}
<AsideCard title={'About ' + (user.display_name? `${user.display_name} (@${user.username})` : `@${user.username}`)}>
<ul>
{#if user.biography}
<li><RiInfoCardLine /> {user.biography}</li>
{/if}
<li><RiCake2Line /> {user.age} years old
{#if user.age < 18}<span class="error">(MINOR)</span>{/if}
</li>
{#if user.joined_at}
<li><RiCakeLine /> Cake day: {DateTime.fromISO(user.joined_at).toFormat('d LLL yyyy')}</li>
{/if}
{#if badges.indexOf('administrator') >= 0}
<li><RiShieldStarLine /> Administrator</li>
{/if}
</ul>
</AsideCard>
<button class="card">Follow</button>
{/if}
<style>
</style>

11
src/lib/UserMenu.svelte Normal file
View file

@ -0,0 +1,11 @@
<script lang="ts">
import { RiUserLine } from "svelte-remixicon";
import type { UserEntry } from "./backend";
let { user } : { user: UserEntry } = $props();
</script>
<ul class="column">
<li><RiUserLine /> <a href="/@{user.username}">Profile</a></li>
</ul>

104
src/lib/backend.ts Normal file
View file

@ -0,0 +1,104 @@
export type UserEntry = {
id: string,
username: string,
display_name?: string,
biography?: string,
joined_at?: string,
age: number, // actually not just that
badges?: string[]
type?: 'user',
karma?: number
};
export type GuildEntry = {
type?: 'guild',
id: string,
name: string,
display_name?: string,
description?: string,
created_at?: string
};
export type PostEntry = {
id: string,
url: string,
title: string,
created_at: string,
author?: UserEntry | null,
content?: string | null,
to: UserEntry | GuildEntry
};
export type ServerHealth = {
version: string,
name: string,
post_count: number,
user_count: number,
me: string | null,
csrf_token?: string
};
export class Backend {
static ENDPOINT_BASE = '/v1';
constructor () {
}
async fetch(url: string, options?: RequestInit) {
console.info(`fetch ${Backend.ENDPOINT_BASE}/${encodeURIComponent(url)}`)
return await fetch(`${Backend.ENDPOINT_BASE}/${encodeURIComponent(url)}`, options);
}
withEvent(event: any) {
return new EventBackend(event);
}
}
class EventBackend extends Backend {
event: any;
constructor (event: any) {
super();
this.event = event;
}
async oath (): Promise<CsrfEventBackend> {
const resp = await this.fetch("oath");
if (resp.status !== 200) {
throw new Error();
}
const respJ = await resp.json();
const { csrfToken } = respJ;
return new CsrfEventBackend(this.event, csrfToken);
}
async fetch(url: string, options?: RequestInit): Promise<Response> {
return await this.event.fetch(`${Backend.ENDPOINT_BASE}/${url.replace(/^\/+/, '')}`, options);
}
}
class CsrfEventBackend extends EventBackend {
csrfToken: string;
constructor(event:EventBackend, csrfToken: string) {
super(event);
this.csrfToken = csrfToken;
}
async submitJson(url: string, data: object) {
return await this.fetch(url, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'content-type': 'application/json',
'x-csrftoken': this.csrfToken
}
});
}
}
export const backend = new Backend();

49
src/lib/globals.svelte.ts Normal file
View file

@ -0,0 +1,49 @@
import type { ServerHealth, UserEntry } from "$lib/backend";
let health : {
app_name: string, version: string, post_count: number,
user_count: number, me: null | UserEntry
} = $state({
app_name: 'app_name',
version: "?.?",
post_count: NaN,
user_count: NaN,
me: null
});
export function setHealth ({ name, version, post_count, user_count }: ServerHealth) {
health.app_name = name;
health.version = version;
health.post_count = post_count;
health.user_count = user_count;
}
export function setMe (me: UserEntry | null) {
health.me = me;
}
export function getMe () {
return health.me;
}
export function appName(): string {
return health.app_name;
}
export function version () {
return health.version;
}
export function activePostCount (): number{
return health.post_count;
}
export function activeUserCount (): number{
return health.user_count || 0;
}

1
src/lib/index.ts Normal file
View file

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

1
src/lib/snowflake.ts Normal file
View file

@ -0,0 +1 @@