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

13
src/app.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

245
src/app.html Normal file
View file

@ -0,0 +1,245 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!--
Copyright (c) 2025 Sakuragasaki46.
This Service is available "AS IS", with NO WARRANTY, explicit or implied.
Sakuragasaki46 is NOT legally liable for Your use of the Service.
This service is age-restricted; do not access if underage.
More info: https://{{ domain_name }}/terms
-->
%sveltekit.head%
<style>
/* CSS reset */
* { box-sizing: border-box; }
body { margin: 0; line-height: 1.5; font-size: 18px; background-color: var(--canvas); color: var(--text-primary); position: relative; }
body { font-family: var(--ui-font, sans-serif); }
button, input, select, textarea { font: inherit; }
a:link, a:visited { color: var(--accent); }
img { max-width: 100%; max-height: 100vh; }
faint, .faint { opacity: .75; }
strong faint, strong .faint { font-weight: 400; }
.callout { color: var(--text-alt); }
.success { color: var(--success); }
.error { color: var(--error); }
.warning { color: var(--warning); }
card, .card { display: block; background-color: var(--background); border: var(--canvas) 1px solid; border-radius: 12px; margin: 12px auto; padding: 12px; max-width: 960px; }
/* color themes */
:root {
--c0-accent: #ff7300;
--c1-accent: #ff7300;
--c2-accent: #f837ce;
--c3-accent: #38b8ff;
--c4-accent: #ffe338;
--c5-accent: #78f038;
--c6-accent: #ff9aae;
--c7-accent: #606080;
--c8-accent: #aeaac0;
--c9-accent: #3ae0b8;
--c10-accent: #8828ea;
--c11-accent: #1871d8;
--c12-accent: #885a18;
--c13-accent: #38a856;
--c14-accent: #ff3018;
--c15-accent: #ff1668;
--light-text-primary: #181818;
--light-text-alt: #444;
--light-border: #999;
--light-success: #73af00;
--light-error: #e04830;
--light-warning: #dea800;
--light-canvas: #eaecee;
--light-background: #f9f9f9;
--light-bg-sharp: #fdfdff;
--dark-text-primary: #e8e8e8;
--dark-text-alt: #c0cad3;
--dark-border: #777;
--dark-success: #93cf00;
--dark-error: #e04830;
--dark-warning: #dea800;
--dark-canvas: #0a0a0e;
--dark-background: #181a21;
--dark-bg-sharp: #080808;
--accent: var(--c0-accent);
--text-primary: var(--light-text-primary);
--text-alt: var(--light-text-alt);
--border: var(--light-border);
--success: var(--light-success);
--error: var(--light-error);
--warning: var(--light-warning);
--canvas: var(--light-canvas);
--background: var(--light-background);
--bg-sharp: var(--light-bg-sharp);
/* NOT color themes */
--ui-font: system-ui, -apple-system, BlinkMacSystemFont, "Noto Sans", sans-serif;
}
@media (prefers-color-scheme: dark) { :root {
--text-primary: var(--dark-text-primary);
--text-alt: var(--dark-text-alt);
--border: var(--dark-border);
--success: var(--dark-success);
--error: var(--dark-error);
--warning: var(--dark-warning);
--canvas: var(--dark-canvas);
--background: var(--dark-background);
--bg-sharp: var(--dark-bg-sharp);
} }
.color-scheme-light {
--text-primary: var(--light-text-primary);
--text-alt: var(--light-text-alt);
--border: var(--light-border);
--success: var(--light-success);
--error: var(--light-error);
--warning: var(--light-warning);
--canvas: var(--light-canvas);
--background: var(--light-background);
--bg-sharp: var(--light-bg-sharp);
}
.color-scheme-dark {
--text-primary: var(--dark-text-primary);
--text-alt: var(--dark-text-alt);
--border: var(--dark-border);
--success: var(--dark-success);
--error: var(--dark-error);
--warning: var(--dark-warning);
--canvas: var(--dark-canvas);
--background: var(--dark-background);
--bg-sharp: var(--dark-bg-sharp);
}
.color-theme-1 { --accent: var(--c1-accent); }
.color-theme-2 { --accent: var(--c2-accent); }
.color-theme-3 { --accent: var(--c3-accent); }
.color-theme-4 { --accent: var(--c4-accent); }
.color-theme-5 { --accent: var(--c5-accent); }
.color-theme-6 { --accent: var(--c6-accent); }
.color-theme-7 { --accent: var(--c7-accent); }
.color-theme-8 { --accent: var(--c8-accent); }
.color-theme-9 { --accent: var(--c9-accent); }
.color-theme-10 { --accent: var(--c10-accent); }
.color-theme-11 { --accent: var(--c11-accent); }
.color-theme-12 { --accent: var(--c12-accent); }
.color-theme-13 { --accent: var(--c13-accent); }
.color-theme-14 { --accent: var(--c14-accent); }
.color-theme-15 { --accent: var(--c15-accent); }
/* ... */
/* mobile styles */
@media screen and (min-width: 800px) { .mobileonly { display: none !important; } }
@media screen and (max-width: 799px) { .nomobile { display: none !important; } }
/* other global styles */
aside.card > ul {
list-style: none;
margin: 0;
padding: 0; }
aside.card > ul > li {
border-bottom: 1px solid var(--border);
padding: 12px; }
aside.card > ul > li:last-child {
border-bottom: none;
}
aside.card > p {
padding: 12px;
margin: 0;
}
ul.row {
list-style: none;
display: flex;
flex-direction: row;
margin: 0;
padding: 0;
}
ul.column {
list-style: none;
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
}
a svg {
text-decoration: none;
}
input:not([type="button"], [type="submit"], [type="reset"]) {
border-radius: 0;
border: 0;
border-bottom: 2px solid var(--border);
background-color: inherit;
color: var(--text-alt);
}
input:not([type="button"], [type="submit"], [type="reset"]):focus {
background-color: var(--bg-sharp);
border-color: var(--accent);
color: var(--text-primary);
}
button, select, button.card {
color: var(--accent);
background-color: transparent;
border: var(--accent) 1px solid;
}
button.primary, button.primary.card {
color: var(--bg-main);
background-color: var(--accent);
}
button.inline {
display: inline;
border: 0;
padding: 0;
margin: 0;
}
button, input:is([type="button"], [type="submit"], [type="reset"]), select, textarea {
border-radius: 9px;
padding: .5em;
font: inherit;
}
button.card {
width: 100%;
padding: .5em 1em;
border-radius: 1em;
}
article h1, article h2 {
font-weight: 500;
}
</style>
</head>
<body data-sveltekit-preload-data="hover">
<global-wrapper style="display: contents">%sveltekit.body%</global-wrapper>
</body>
</html>

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

23
src/routes/+error.svelte Normal file
View file

@ -0,0 +1,23 @@
<script lang="ts">
import { page } from "$app/state";
import Centered from "$lib/Centered.svelte";
let errorMessage = page.error?.message || null;
if (errorMessage?.startsWith('Error: ')) { errorMessage = errorMessage.slice(7); }
if (errorMessage && /^[0-9]+$/.test(errorMessage) && +errorMessage === page.status) {
errorMessage = null;
}
</script>
<Centered>
<h1>{page.status}</h1>
{#if errorMessage}
<p>{errorMessage}</p>
{/if}
{#if page.status >= 500}
<p><button onclick={() => {history.go(0);}} class="inline">Refresh</button></p>
{:else}
<p><a href="/">Back to homepage</a></p>
{/if}
</Centered>

86
src/routes/+layout.svelte Normal file
View file

@ -0,0 +1,86 @@
<script lang="ts">
import { version } from '$app/environment';
import type { Snippet } from 'svelte';
import type { ServerHealth, UserEntry } from '$lib/backend';
import { appName, version as bkVersion } from '$lib/globals.svelte';
import MobileFooter from '$lib/MobileFooter.svelte';
import MetaNav from '$lib/MetaNav.svelte';
import { getFlash } from 'sveltekit-flash-message';
import { page } from "$app/state";
import FlashMessage from '$lib/FlashMessage.svelte';
let { data, children } : {
data: {me: UserEntry},
children: Snippet
} = $props();
let {me} = $derived(data);
const flash = getFlash(page);
</script>
<svelte:head>
<meta name="og:site_name" content="app_name" />
{#if data}
<meta name="generator" content={`app_name (frontend ${version} + Svelte 5, backend ${bkVersion()})`} />
{/if}
<!-- TODO csrf token? -->
<!-- icon styles? -->
<!-- SEO tags start -->
<!-- end SEO tags -->
</svelte:head>
<header>
<h1>
<a href="/">{appName()}</a>
</h1>
<!-- .metanav -->
<MetaNav user={me} />
</header>
<main>
{#if $flash}
<FlashMessage message={$flash?.message}/>
{/if}
{@render children()}
</main>
<MobileFooter />
<style>
header {
background-color: var(--background);
display: flex;
flex-direction: row;
justify-content: space-between;
overflow: hidden;
height: 3em;
padding: .75em 1.5em;
line-height: 1;
}
header h1 {
margin: 0;
padding: 0;
font-size: 1.5em;
font-weight: 500;
}
header a {
text-decoration: none;
}
main {
min-height: 70vh;
margin: 12px auto;
max-width: 1600px;
}
@media screen and (max-width: 799px) {
main {
height: 100vh;
}
}
</style>

45
src/routes/+layout.ts Normal file
View file

@ -0,0 +1,45 @@
import { backend, type ServerHealth, type UserEntry } from '$lib/backend.js';
import { setHealth, setMe } from '$lib/globals.svelte.js';
import type { LoadEvent } from '@sveltejs/kit';
async function loadSite (event: LoadEvent): Promise<ServerHealth | null> {
const resp = await backend.withEvent(event).fetch('health');
if ([200].indexOf(resp.status) < 0) return null;
try {
const respJ = await resp.json();
setHealth(respJ);
return respJ;
} catch (e) {
return null;
}
}
async function loadMe(event: LoadEvent, me?: string): Promise<UserEntry | null> {
if (!me) return null;
const resp = await backend.withEvent(event).fetch('user/@me');
if ([200].indexOf(resp.status) < 0) return null;
try {
const respJ = await resp.json();
const { users } = respJ;
const meJ = users[me];
setMe(meJ);
return meJ;
} catch (e) {
return null;
}
}
export async function load(event): Promise<{site: ServerHealth | null, me: UserEntry | null} > {
let site = await loadSite(event);
let me = await loadMe (event, site?.me || void 0);
return { site, me };
}

40
src/routes/+page.svelte Normal file
View file

@ -0,0 +1,40 @@
<script lang="ts">
import type { PostEntry } from "$lib/backend";
import Centered from "$lib/Centered.svelte";
import Feed from "$lib/Feed.svelte";
import { activePostCount, activeUserCount, appName, getMe } from "$lib/globals.svelte";
import SLayout from "$lib/SLayout.svelte";
let me = getMe();
let { data } : { data: { feed: PostEntry[] } } = $props();
let feed: PostEntry[] = $state([]);
let feedIndex = $state(0);
$effect(() => {
if (me && feed) {
feed.push(...data.feed.slice(feedIndex));
feedIndex += feed.length;
} else if (me) {
console.log('feed is', feed)
}
});
</script>
{#if me}
<SLayout title={appName()}>
<Feed posts={feed} />
{#snippet left()}
...
{/snippet}
{#snippet right()}
...
{/snippet}
</SLayout>
{:else}
<Centered>
<p>{appName()} is a social media platform made by people like you.<br />
<a href="/login">Log in</a> or (sign up) to see {activePostCount()} posts and talk with {activeUserCount()} users right now!</p>
</Centered>
{/if}

32
src/routes/+page.ts Normal file
View file

@ -0,0 +1,32 @@
import { backend, type PostEntry } from '$lib/backend.js';
import { getMe } from '$lib/globals.svelte.js';
import type { LoadEvent } from '@sveltejs/kit';
async function loadFeed (event: LoadEvent): Promise<PostEntry[] | null> {
const resp = await backend.withEvent(event).fetch('home/feed');
if ([200].indexOf(resp.status) < 0) return null;
try {
const respJ = await resp.json();
const { feed } : { feed: PostEntry[] } = respJ;
return feed;
} catch (e) {
return null;
}
}
export async function load(event): Promise<{feed: PostEntry[] | null}> {
let feed = null;
let me = getMe();
if (me) {
feed = await loadFeed(event);
}
return { feed };
}

View file

@ -0,0 +1,23 @@
<script lang="ts">
import type { GuildEntry, PostEntry } from "$lib/backend";
import Feed from "$lib/Feed.svelte";
import GuildAbout from "$lib/GuildAbout.svelte";
import GuildMenu from "$lib/GuildMenu.svelte";
import SLayout from "$lib/SLayout.svelte";
let { data } : { data: { guild: GuildEntry , feed: PostEntry[] } } = $props();
let { guild, feed = [] } = $derived(data);
</script>
<SLayout title={guild.display_name? `${guild.display_name} (+${guild.name})` :`+${guild.name}`}>
<Feed posts={feed} />
{#snippet left()}
<GuildMenu {guild} />
{/snippet}
{#snippet right()}
<GuildAbout {guild} />
{/snippet}
</SLayout>

View file

@ -0,0 +1,36 @@
import { backend } from '$lib/backend.js';
import { error } from '@sveltejs/kit';
export async function load(event) {
const { params } = event;
const { name } = params;
const resp = await backend.withEvent(event).fetch('guild/@' + encodeURIComponent(name) + "/feed");
if(resp.status === 404) {
error(404);
}
try{
const respJ = await resp.json();
let { guilds, feed } = respJ;
let guild = null;
for (let g in guilds) {
if (guilds[g].name === name) {
guild = guilds[g];
}
}
if (!guild) error(404);
return { guild, feed };
}
catch (e) {
console.error(e);
error(502);
}
}

View file

@ -0,0 +1,11 @@
<script lang="ts">
import type { PostEntry } from "$lib/backend.ts";
import FullPost from "$lib/FullPost.svelte";
let { data } : { data: PostEntry } = $props();
</script>
<FullPost post={data} />

View file

@ -0,0 +1,31 @@
import { backend } from '$lib/backend.js';
import { error } 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);
}
try{
const respJ = await resp.json();
let { posts } = respJ;
return posts[id];
//return {};
}
catch (e) {
console.error(e);
error(502);
}
}

View file

@ -0,0 +1,11 @@
<script lang="ts">
import type { PostEntry } from "$lib/backend.ts";
import FullPost from "$lib/FullPost.svelte";
let { data } : { data: PostEntry } = $props();
</script>
<FullPost post={data} />

View file

@ -0,0 +1,28 @@
<script lang="ts">
import type { PostEntry, UserEntry } from "$lib/backend.js";
import Feed from "$lib/Feed.svelte";
import SLayout from "$lib/SLayout.svelte";
import UserAbout from "$lib/UserAbout.svelte";
import UserMenu from "$lib/UserMenu.svelte";
import { RiUserLine } from "svelte-remixicon";
let { data } : { data: { user: UserEntry, feed: PostEntry[] } } = $props();
// TEMP make it work!
let { user, feed } = $derived(data);
let username = $derived(user.username);
</script>
{#if username}
<SLayout title={'@' + username}>
<Feed posts={feed} />
{#snippet left()}
<UserMenu {user} />
{/snippet}
{#snippet right()}
<UserAbout {user} />
{/snippet}
</SLayout>
{/if}

View file

@ -0,0 +1,34 @@
import { backend } from '$lib/backend.js';
import { error } from '@sveltejs/kit';
export async function load(event) {
const { params } = event;
const { name } = params;
const resp = await backend.withEvent(event).fetch('user/@' + encodeURIComponent(name) + '/feed');
if(resp.status === 404) {
error(404);
}
try{
const respJ = await resp.json();
let { users, feed } = respJ;
let user;
for (let u in users) {
if (users[u].username === name) {
user = users[u];
}
}
return { user, feed };
}
catch (e) {
console.error(e);
error(502);
}
}

View file

@ -0,0 +1,53 @@
import { backend } from "$lib/backend";
import { error, type Actions } from "@sveltejs/kit";
import { redirect } from 'sveltekit-flash-message/server';
export const actions = {
default: async (event) => {
// TODO login
const { request } = event;
const data = await request.formData();
const username = data.get('username');
const password = data.get('password');
const remember = !!data.get('remember');
const backend2 = await backend.withEvent(event).oath();
const resp = await backend2.submitJson('login', {
username,
password,
remember
});
const { status } = resp;
const respData = await resp.json();
if ([200, 204].indexOf(status) < 0) {
// login error
console.log(`/login: status ${status}, data below`);
console.debug(respData);
switch(status) {
case 400:
redirect({message: 'Invalid login'}, event);
break;
case 404:
redirect({message: 'Invalid username or password'}, event);
break;
case 403:
redirect({message: 'Login not allowed'}, event);
break;
default:
redirect({message: `Unknown error (HTTP ${status})`}, event);
break;
}
} else {
// login success
const { id: myId } = respData;
redirect(303, "/user/" + myId);
}
}
} satisfies Actions;

View file

@ -0,0 +1,48 @@
<script lang="ts">
import { enhance } from "$app/forms";
import Centered from "$lib/Centered.svelte";
import CheckboxLabel from "$lib/CheckboxLabel.svelte";
import PasswordInput from "$lib/PasswordInput.svelte";
import { RiEyeLine, RiEyeOffLine, RiKeyLine, RiMailLine, RiUserLine } from "svelte-remixicon";
</script>
<Centered narrow>
<card>
<form method="POST" use:enhance>
<label>
<span><RiUserLine /> Username / <RiMailLine /> E-mail:</span>
<input type="text" name="username" />
</label>
<label>
<span><RiKeyLine /> Password</span>
<PasswordInput name="password" />
</label>
<CheckboxLabel name="remember">
Remember me
</CheckboxLabel>
<button class="primary">Log in</button>
</form>
</card>
</Centered>
<style>
label {
display: flex;
flex-direction: column;
margin: 1em auto;
max-width: 300px;
}
button.primary {
display: block;
width: 100%;
margin-top: 1em;
}
</style>

View file

@ -0,0 +1,26 @@
import { backend } from '$lib/backend.js';
import { getMe, setMe } from '$lib/globals.svelte.js';
import { error, redirect } from '@sveltejs/kit';
export async function load (event) {
const me = getMe();
const next = "/";
// already logged out
if (me == null) {
redirect(303, next);
}
const resp = await backend.withEvent(event).fetch("logout", {method: 'POST'});
if ([200, 204].indexOf(resp.status) >= 0) {
setMe(null);
redirect(303, next);
}
console.error(`status ${resp.status} received, not logging out`)
error(500);
}

View file

View file

@ -0,0 +1,28 @@
import { backend } from '$lib/backend';
import { error, redirect } from '@sveltejs/kit';
export async function load(event) {
const { params } = event;
const { id } = params;
const resp = await backend.withEvent(event).fetch('user/' + encodeURIComponent(id));
if(resp.status === 404) {
error(404);
}
const respJ = await resp.json();
let { users } = respJ;
console.log(users);
if (users[id]) {
if (users[id].username) {
redirect(302, "/@" +users[id].username );
}
}
error(404);
}