finish sk restructure, a11y and optimisations

This commit is contained in:
ari melody 2024-06-29 14:48:34 +01:00
parent 9ef27fd2a2
commit 3ae05b3f9f
Signed by: ari
GPG key ID: CF99829C92678188
61 changed files with 416 additions and 429 deletions

13
package-lock.json generated
View file

@ -7,9 +7,10 @@
"": { "": {
"name": "spacesocial-client", "name": "spacesocial-client",
"version": "0.2.0_rev2", "version": "0.2.0_rev2",
"license": "ISC", "license": "GPL-3.0",
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^3.2.2", "@sveltejs/adapter-auto": "^3.2.2",
"@sveltejs/adapter-static": "^3.0.2",
"@sveltejs/kit": "^2.5.17", "@sveltejs/kit": "^2.5.17",
"@sveltejs/vite-plugin-svelte": "^3.1.1", "@sveltejs/vite-plugin-svelte": "^3.1.1",
"svelte": "^4.2.18", "svelte": "^4.2.18",
@ -718,6 +719,16 @@
"@sveltejs/kit": "^2.0.0" "@sveltejs/kit": "^2.0.0"
} }
}, },
"node_modules/@sveltejs/adapter-static": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.2.tgz",
"integrity": "sha512-/EBFydZDwfwFfFEuF1vzUseBoRziwKP7AoHAwv+Ot3M084sE/HTVBHf9mCmXfdM9ijprY5YEugZjleflncX5fQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@sveltejs/kit": "^2.0.0"
}
},
"node_modules/@sveltejs/kit": { "node_modules/@sveltejs/kit": {
"version": "2.5.17", "version": "2.5.17",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.17.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.17.tgz",

View file

@ -13,6 +13,7 @@
"license": "GPL-3.0", "license": "GPL-3.0",
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^3.2.2", "@sveltejs/adapter-auto": "^3.2.2",
"@sveltejs/adapter-static": "^3.0.2",
"@sveltejs/kit": "^2.5.17", "@sveltejs/kit": "^2.5.17",
"@sveltejs/vite-plugin-svelte": "^3.1.1", "@sveltejs/vite-plugin-svelte": "^3.1.1",
"svelte": "^4.2.18", "svelte": "^4.2.18",

View file

@ -1,221 +0,0 @@
<script>
import Navigation from './ui/Navigation.svelte';
import Widgets from './ui/Widgets.svelte';
import Feed from './ui/Feed.svelte';
import { Client } from './client/client.js';
import Button from './ui/Button.svelte';
import { get } from 'svelte/store';
let client = get(Client.get());
let ready = client.app && client.app.token;
let instance_url_error = false;
let logging_in = false;
let auth_code = new URLSearchParams(location.search).get("code");
if (auth_code) {
client.getToken(auth_code).then(() => {
client.save();
location = location.origin;
});
}
if (client.app && client.app.token) {
// this triggers the client actually getting the authenticated user's data.
client.verifyCredentials().then(res => {
if (res) {
console.log(`Logged in as @${client.user.username}@${client.user.host}`);
}
});
}
function log_in(event) {
logging_in = true;
event.preventDefault();
const host = event.target.host.value;
client.init(host).then(res => {
logging_in = false;
if (!res) return;
if (res.constructor === String) {
instance_url_error = res;
return;
};
let oauth_url = client.getOAuthUrl();
location = oauth_url;
});
}
</script>
<div id="spacesocial-app">
<header>
<Navigation />
</header>
<main>
{#if ready}
<Feed />
{:else}
<div>
<form on:submit={log_in} id="login">
<h1>Space Social</h1>
<p>Welcome, fediverse user!</p>
<p>Please enter your instance domain to log in.</p>
<div class="input-wrapper">
<input type="text" id="host" aria-label="instance domain" class={logging_in ? "throb" : ""}>
{#if instance_url_error}
<p class="error">{instance_url_error}</p>
{/if}
</div>
<br>
<button type="submit" id="login" class={logging_in ? "disabled" : ""}>Log in</button>
<p><small>
Please note this is
<strong><em>extremely experimental software</em></strong>;
things are likely to break!
<br>
If that's all cool with you, welcome aboard!
</small></p>
<p class="form-footer">made with ❤️ by <a href="https://arimelody.me">ari melody</a>, 2024</p>
</form>
</div>
{/if}
</main>
<div id="widgets">
<Widgets />
</div>
</div>
<style>
#spacesocial-app {
margin: auto 0;
padding: 0 16px;
display: flex;
flex-direction: row;
justify-content: center;
gap: 16px;
}
header, #widgets {
width: 300px;
}
main {
width: 732px;
}
div.pane {
margin-top: 16px;
padding: 20px 32px;
border: 1px solid #8884;
border-radius: 16px;
background-color: var(--bg1);
}
form#login {
margin: 25vh 0 32px 0;
text-align: center;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.input-wrapper {
width: 360px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
}
input[type=text] {
width: 100%;
padding: 12px;
display: block;
border-radius: 8px;
border: 1px solid var(--accent);
background-color: var(--bg-800);
font-family: inherit;
font-weight: bold;
font-size: inherit;
color: var(--text);
transition: box-shadow .2s;
}
input[type=text]::placeholder {
opacity: .8;
}
input[type=text]:focus {
outline: none;
box-shadow: 0 0 16px color-mix(in srgb, transparent, var(--accent) 25%);
}
.error {
margin: 6px;
font-style: italic;
font-size: .9em;
color: red;
opacity: .7;
}
button#login {
margin: -8px auto 0 auto;
padding: 12px 24px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
font-family: inherit;
font-size: 1rem;
font-weight: 600;
text-align: left;
border-radius: 8px;
border-width: 2px;
border-style: solid;
background-color: var(--bg-700);
color: var(--text);
border-color: transparent;
transition-property: border-color, background-color, color;
transition-timing-function: ease-out;
transition-duration: .1s;
cursor: pointer;
text-align: center;
justify-content: center;
}
button#login:hover {
background-color: color-mix(in srgb, var(--bg-700), var(--accent) 10%);
border-color: color-mix(in srgb, var(--bg-700), var(--accent) 20%);
}
button#login:active {
background-color: color-mix(in srgb, var(--bg-700), var(--bg-800) 50%);
border-color: color-mix(in srgb, var(--bg-700), var(--bg-800) 10%);
}
button#login.disabled {
opacity: .5;
cursor: initial;
}
.form-footer {
opacity: .7;
}
</style>

View file

@ -1,4 +1,4 @@
@import url("/font/inter/inter.css"); @import url("../font/inter/inter.css");
:root { :root {
--bg-1000: #fff6de; --bg-1000: #fff6de;
@ -40,6 +40,32 @@ body {
box-sizing: border-box; box-sizing: border-box;
} }
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
#spacesocial-app {
margin: auto 0;
padding: 0 16px;
display: flex;
flex-direction: row;
justify-content: center;
gap: 16px;
}
header, #widgets {
width: 300px;
}
main {
width: 732px;
}
.throb { .throb {
animation: .25s throb alternate infinite ease-in; animation: .25s throb alternate infinite ease-in;
} }

View file

@ -8,7 +8,7 @@ import { get } from 'svelte/store';
export async function createApp(host) { export async function createApp(host) {
let form = new FormData(); let form = new FormData();
form.append("client_name", "space social"); form.append("client_name", "space social");
form.append("redirect_uris", `${location.origin}/callback`); form.append("redirect_uris", `${location.origin}`);
form.append("scopes", "read write push"); form.append("scopes", "read write push");
form.append("website", "https://spacesocial.arimelody.me"); form.append("website", "https://spacesocial.arimelody.me");
@ -35,7 +35,7 @@ export function getOAuthUrl() {
return `https://${client.instance.host}/oauth/authorize` + return `https://${client.instance.host}/oauth/authorize` +
`?client_id=${client.app.id}` + `?client_id=${client.app.id}` +
"&scope=read+write+push" + "&scope=read+write+push" +
`&redirect_uri=${location.origin}/callback` + `&redirect_uri=${location.origin}` +
"&response_type=code"; "&response_type=code";
} }
@ -44,7 +44,7 @@ export async function getToken(code) {
let form = new FormData(); let form = new FormData();
form.append("client_id", client.app.id); form.append("client_id", client.app.id);
form.append("client_secret", client.app.secret); form.append("client_secret", client.app.secret);
form.append("redirect_uri", `${location.origin}/callback`); form.append("redirect_uri", `${location.origin}`);
form.append("grant_type", "authorization_code"); form.append("grant_type", "authorization_code");
form.append("code", code); form.append("code", code);
form.append("scope", "read write push"); form.append("scope", "read write push");
@ -107,7 +107,7 @@ export async function getTimeline(last_post_id) {
return data; return data;
} }
export async function getPost(post_id, parent_replies) { export async function getPost(post_id, ancestor_count) {
let client = get(Client.get()); let client = get(Client.get());
let url = `https://${client.instance.host}/api/v1/statuses/${post_id}`; let url = `https://${client.instance.host}/api/v1/statuses/${post_id}`;
const data = await fetch(url, { const data = await fetch(url, {
@ -208,7 +208,7 @@ export async function unreactPost(post_id, shortcode) {
return data; return data;
} }
export async function parsePost(data, parent_replies, child_replies) { export async function parsePost(data, ancestor_count, with_context) {
let client = get(Client.get()); let client = get(Client.get());
let post = new Post(); let post = new Post();
@ -218,9 +218,12 @@ export async function parsePost(data, parent_replies, child_replies) {
post.text = data.content; post.text = data.content;
post.reply = null; post.reply = null;
if ((data.in_reply_to_id || data.reply) && parent_replies !== 0) { if (!with_context && // ancestor replies are handled in full later
const reply_data = data.reply || await getPost(data.in_reply_to_id, parent_replies - 1); (data.in_reply_to_id || data.reply) &&
post.reply = await parsePost(reply_data, parent_replies - 1, false); ancestor_count !== 0
) {
const reply_data = data.reply || await getPost(data.in_reply_to_id, ancestor_count - 1);
post.reply = await parsePost(reply_data, ancestor_count - 1, false);
// if the post returns false, we probably don't have permission to read it. // if the post returns false, we probably don't have permission to read it.
// we'll respect the thread's privacy, and leave it alone :) // we'll respect the thread's privacy, and leave it alone :)
if (post.reply === false) return false; if (post.reply === false) return false;
@ -228,11 +231,22 @@ export async function parsePost(data, parent_replies, child_replies) {
post.boost = data.reblog ? await parsePost(data.reblog, 1, false) : null; post.boost = data.reblog ? await parsePost(data.reblog, 1, false) : null;
post.replies = []; post.replies = [];
if (child_replies) { if (with_context) {
const replies_data = await getPostContext(data.id); const replies_data = await getPostContext(data.id);
if (replies_data && replies_data.descendants) { if (replies_data) {
for (let i in replies_data.descendants) { // posts this is replying to
post.replies.push(await parsePost(replies_data.descendants[i], 0, false)); if (replies_data.ancestors) {
let head = post;
while (replies_data.ancestors.length > 0) {
head.reply = await parsePost(replies_data.ancestors.pop(), 0, false);
head = head.reply;
}
}
// posts in reply to this
if (replies_data.descendants) {
for (let i in replies_data.descendants) {
post.replies.push(await parsePost(replies_data.descendants[i], 0, false));
}
} }
} }
} }

View file

@ -17,6 +17,7 @@ export default class Post {
files; files;
url; url;
reply; reply;
reply_id;
replies; replies;
boost; boost;
visibility; visibility;

View file

@ -1,10 +1,13 @@
import sound_log from '../sound/log.ogg';
import sound_hello from '../sound/hello.ogg';
import sound_success from '../sound/success.ogg';
let sounds; let sounds;
if (typeof Audio !== typeof undefined) { if (typeof Audio !== typeof undefined) {
sounds = { sounds = {
"default": new Audio("/sound/log.ogg"), "default": new Audio(sound_log),
"post": new Audio("/sound/success.ogg"), "post": new Audio(sound_hello),
"boost": new Audio("/sound/hello.ogg"), "boost": new Audio(sound_success),
}; };
} }

43
src/lib/timeline.js Normal file
View file

@ -0,0 +1,43 @@
import { Client } from '$lib/client/client.js';
import { get, writable } from 'svelte/store';
import { parsePost } from '$lib/client/api.js';
export let posts = writable([]);
let client = get(Client.get());
let loading = false;
export async function getTimeline(clean) {
if (loading) return; // no spamming!!
loading = true;
let timeline_data;
if (get(posts).length === 0) timeline_data = await client.getTimeline()
else timeline_data = await client.getTimeline(get(posts)[get(posts).length - 1].id);
if (!timeline_data) {
console.error(`Failed to retrieve timeline.`);
loading = false;
return;
}
if (clean) posts.set([]);
for (let i in timeline_data) {
const post_data = timeline_data[i];
const post = await parsePost(post_data, 1, false);
if (!post) {
if (post === null || post === undefined) {
if (post_data.id) {
console.warn("Failed to parse post #" + post_data.id);
} else {
console.warn("Failed to parse post:");
console.warn(post_data);
}
}
continue;
}
posts.update(current => [...current, post]);
}
loading = false;
}

View file

@ -2,104 +2,33 @@
import Button from './Button.svelte'; import Button from './Button.svelte';
import Post from './post/Post.svelte'; import Post from './post/Post.svelte';
import Error from './Error.svelte'; import Error from './Error.svelte';
import { Client } from '../client/client.js'; import { Client } from '$lib/client/client.js';
import { parsePost } from '../client/api.js'; import { parsePost } from '$lib/client/api.js';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { posts, getTimeline } from '$lib/timeline.js';
let params = new URLSearchParams(location.search); getTimeline();
document.addEventListener("scroll", event => {
let client = get(Client.get()); if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 2048) {
let posts = []; getTimeline();
let loading = false;
let focus_post_id = location.pathname.startsWith("/post/") ? location.pathname.substring(6) : false;
let error;
async function getTimeline() {
if (loading) return; // no spamming!!
loading = true;
let timeline_data;
if (posts.length === 0) timeline_data = await client.getTimeline()
else timeline_data = await client.getTimeline(posts[posts.length - 1].id);
if (!timeline_data) {
console.error(`Failed to retrieve timeline.`);
loading = false;
return;
} }
});
for (let i in timeline_data) {
const post_data = timeline_data[i];
const post = await parsePost(post_data, 1, false);
if (!post) {
if (post === null || post === undefined) {
if (post_data.id) {
console.warn("Failed to parse post #" + post_data.id);
} else {
console.warn("Failed to parse post:");
console.warn(post_data);
}
}
continue;
}
posts = [...posts, post];
}
loading = false;
}
async function getPost(post_id) {
loading = true;
const post_data = await client.getPost(post_id);
if (!post_data) {
console.error(`Failed to retrieve post ${post_id}.`);
loading = false;
return;
}
const post = await parsePost(post_data, 10, true);
posts = [post];
for (let i in post.replies) {
posts = [...posts, post.replies[i]];
}
loading = false;
}
if (focus_post_id) {
getPost(focus_post_id);
} else {
getTimeline();
document.addEventListener("scroll", event => {
if (!loading && window.innerHeight + window.scrollY >= document.body.offsetHeight - 2048) {
getTimeline();
}
});
}
</script> </script>
<header> <div id="feed" role="feed">
<h1>Home</h1>
<nav>
<Button centered active>Home</Button>
<Button centered disabled>Local</Button>
<Button centered disabled>Federated</Button>
</nav>
</header>
<div id="feed">
{#if posts.length <= 0} {#if posts.length <= 0}
<div class="throb"> <div class="loading throb">
<span>just a moment...</span> <span>getting the feed...</span>
</div> </div>
{/if} {/if}
{#each posts as post} {#each $posts as post}
<Post post_data={post} focused={post.id === focus_post_id} /> <Post post_data={post} />
{/each} {/each}
</div> </div>
<style> <style>
header { header {
width: 100%;
margin: 16px 0 8px 0; margin: 16px 0 8px 0;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -120,7 +49,7 @@
margin-bottom: 20vh; margin-bottom: 20vh;
} }
.throb { .loading {
width: 100%; width: 100%;
height: 80vh; height: 80vh;
display: flex; display: flex;

View file

@ -5,6 +5,8 @@
import { Client } from '../client/client.js'; import { Client } from '../client/client.js';
import { play_sound } from '../sound.js'; import { play_sound } from '../sound.js';
const VERSION = APP_VERSION;
let client = false; let client = false;
Client.get().subscribe(c => { Client.get().subscribe(c => {
client = c; client = c;
@ -14,6 +16,13 @@
if (notification_count > 99) notification_count = "99+"; if (notification_count > 99) notification_count = "99+";
function goTimeline() { function goTimeline() {
if (location.pathname === "/") {
window.scrollTo({
top: 0,
behavior: "smooth"
});
return;
}
location = "/"; location = "/";
} }
@ -61,7 +70,7 @@
</div> </div>
<div id="account-button"> <div id="account-button">
<img src={client.user.avatar_url} class="account-avatar" height="64px" aria-hidden="true" on:click={() => play_sound()}> <img src={client.user.avatar_url} class="account-avatar" height="64px" alt="" aria-hidden="true" on:click={() => play_sound()}>
<div class="account-name" aria-hidden="true"> <div class="account-name" aria-hidden="true">
<span class="nickname" title={client.user.nickname}>{client.user.nickname}</span> <span class="nickname" title={client.user.nickname}>{client.user.nickname}</span>
<span class="username" title={`@${client.user.username}@${client.user.host}`}> <span class="username" title={`@${client.user.username}@${client.user.host}`}>
@ -73,7 +82,7 @@
</div> </div>
{/if} {/if}
<span class="version"> <span class="version">
space social v{APP_VERSION} space social v{VERSION}
<br> <br>
<ul> <ul>
<li><a href="https://git.arimelody.me/ari/spacesocial-client">source</a></li> <li><a href="https://git.arimelody.me/ari/spacesocial-client">source</a></li>
@ -252,26 +261,6 @@
font-size: .65em; font-size: .65em;
} }
.settings {
width: 32px;
height: 32px;
padding: none;
border: none;
font-size: inherit;
font-family: inherit;
background: none;
border-radius: 8px;
transition: background-color .1s;
}
.settings:hover {
background-color: color-mix(in srgb, var(--bg-700), var(--text) 15%);
}
.settings:active {
background-color: color-mix(in srgb, var(--bg-700), var(--bg-1000) 30%);
}
.flex-row { .flex-row {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View file

@ -15,22 +15,24 @@
<span class="post-context-time"> <span class="post-context-time">
<time title="{time_string}">{short_time(post.created_at)}</time> <time title="{time_string}">{short_time(post.created_at)}</time>
{#if post.visibility !== "public"} {#if post.visibility !== "public"}
<span class="post-visibility">({post.visibility})</span> <span class="post-visibility">- {post.visibility}</span>
{/if} {/if}
</span> </span>
</div> </div>
<style> <style>
.post-context { .post-context {
margin-bottom: 8px; padding: 12px 16px 0 74px;
padding-left: 58px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
font-size: .8em;
font-weight: 600; font-weight: 600;
color: var(--text); color: var(--text);
opacity: .8; opacity: .8;
transition: opacity .1s; transition: opacity .1s, background-color .1s;
border-radius: 8px;
z-index: 1;
} }
.post-context-icon { .post-context-icon {
@ -49,4 +51,8 @@
.post-context-time { .post-context-time {
margin-left: auto; margin-left: auto;
} }
.post-visibility {
opacity: .7;
}
</style> </style>

View file

@ -11,6 +11,7 @@
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { Client } from '../../client/client.js'; import { Client } from '../../client/client.js';
import * as api from '../../client/api.js'; import * as api from '../../client/api.js';
import { goto } from '$app/navigation';
export let post_data; export let post_data;
export let focused = false; export let focused = false;
@ -25,7 +26,9 @@
} }
function gotoPost() { function gotoPost() {
location = `/post/${post.id}`; if (focused) return;
if (event.key && event.key !== "Enter") return;
goto(`/post/${post.id}`);
} }
async function toggleBoost() { async function toggleBoost() {
@ -80,25 +83,30 @@
let el; let el;
onMount(() => { onMount(() => {
if (focused) { if (focused) {
window.scrollTo(0, el.scrollHeight - 700); window.scrollTo(0, el.scrollHeight);
} }
}); });
let aria_label = post.user.username + '; ' + post.text + '; ' + post.created_at; let aria_label = post.user.username + '; ' + post.text + '; ' + post.created_at;
</script> </script>
<div class="post-container" aria-label={aria_label} bind:this={el}> <div class="post-container">
{#if post.reply} {#if post.reply}
<ReplyContext post={post.reply} /> <ReplyContext post={post.reply} />
{/if} {/if}
{#if is_boost && !post_context.text} {#if is_boost && !post_context.text}
<BoostContext post={post_context} /> <BoostContext post={post_context} />
{/if} {/if}
<article class={"post" + (focused ? " focused" : "")} on:click={!focused ? gotoPost() : null}> <article
class={"post" + (focused ? " focused" : "")}
aria-label={aria_label}
bind:this={el}
on:click={gotoPost}
on:keydown={gotoPost}>
<PostHeader post={post} /> <PostHeader post={post} />
<Body post={post} /> <Body post={post} />
<footer class="post-footer"> <footer class="post-footer">
<div class="post-reactions" on:click|stopPropagation> <div class="post-reactions" aria-label="Reactions" on:click|stopPropagation on:keydown|stopPropagation>
{#each post.reactions as reaction} {#each post.reactions as reaction}
<ReactionButton <ReactionButton
type="reaction" type="reaction"
@ -116,7 +124,7 @@
</ReactionButton> </ReactionButton>
{/each} {/each}
</div> </div>
<div class="post-actions" on:click|stopPropagation> <div class="post-actions" aria-label="Post actions" on:click|stopPropagation on:keydown|stopPropagation>
<ActionButton type="reply" label="Reply" bind:count={post.reply_count} sound="post" disabled>🗨️</ActionButton> <ActionButton type="reply" label="Reply" bind:count={post.reply_count} sound="post" disabled>🗨️</ActionButton>
<ActionButton type="boost" label="Boost" on:click={() => toggleBoost()} bind:active={post.boosted} bind:count={post.boost_count} sound="boost">🔁</ActionButton> <ActionButton type="boost" label="Boost" on:click={() => toggleBoost()} bind:active={post.boosted} bind:count={post.boost_count} sound="boost">🔁</ActionButton>
<ActionButton type="favourite" label="Favourite" on:click={() => toggleFavourite()} bind:active={post.favourited} bind:count={post.favourite_count}>⭐</ActionButton> <ActionButton type="favourite" label="Favourite" on:click={() => toggleFavourite()} bind:active={post.favourited} bind:count={post.favourite_count}>⭐</ActionButton>
@ -130,37 +138,39 @@
<style> <style>
.post-container { .post-container {
width: 700px; width: 732px;
max-width: 700px; max-width: 732px;
margin-bottom: 8px; margin-bottom: 8px;
padding: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-radius: 8px; border-radius: 8px;
background-color: var(--bg-800); background-color: var(--bg-800);
}
.post {
padding: 16px;
border-radius: 8px;
transition: background-color .1s; transition: background-color .1s;
} }
.post-container:hover {
background-color: color-mix(in srgb, var(--bg-800), black 5%);
}
.post-container:hover :global(.post-context) {
opacity: 1;
}
.post:not(.focused) { .post:not(.focused) {
cursor: pointer; cursor: pointer;
} }
.post.focused { .post.focused {
padding: 16px;
margin: -16px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, transparent, var(--accent) 20%); border: 1px solid color-mix(in srgb, transparent, var(--accent) 20%);
box-shadow: 0 0 16px color-mix(in srgb, transparent, var(--accent) 20%); box-shadow: 0 0 16px color-mix(in srgb, transparent, var(--accent) 20%);
} }
.post:hover {
background-color: color-mix(in srgb, var(--bg-800), black 5%);
}
.post-container:has(.post-context) .post {
padding-top: 40px;
margin-top: -32px;
}
:global(.post-reactions) { :global(.post-reactions) {
width: fit-content; width: fit-content;
display: flex; display: flex;

View file

@ -9,12 +9,16 @@
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { Client } from '../../client/client.js'; import { Client } from '../../client/client.js';
import * as api from '../../client/api.js'; import * as api from '../../client/api.js';
import { goto } from '$app/navigation';
export let post; export let post;
let time_string = post.created_at.toLocaleString(); let time_string = post.created_at.toLocaleString();
let aria_label = post.user.username + '; ' + post.text + '; ' + post.created_at;
function gotoPost() { function gotoPost() {
location = `/post/${post.id}`; if (focused) return;
if (event.key && event.key !== "Enter") return;
goto(`/post/${post.id}`);
} }
async function toggleBoost() { async function toggleBoost() {
@ -71,7 +75,11 @@
<svelte:self post={post.reply} /> <svelte:self post={post.reply} />
{/if} {/if}
<article class="post-reply" on:click={() => gotoPost()}> <article
class="post-reply"
aria-label={aria_label}
on:click={gotoPost}
on:keydown={gotoPost}>
<div class="line"></div> <div class="line"></div>
<div class="post-reply-main"> <div class="post-reply-main">
@ -80,7 +88,7 @@
<Body post={post} /> <Body post={post} />
<footer class="post-footer"> <footer class="post-footer">
<div class="post-reactions" on:click|stopPropagation> <div class="post-reactions" aria-label="Reactions" on:click|stopPropagation on:keydown|stopPropagation>
{#each post.reactions as reaction} {#each post.reactions as reaction}
<ReactionButton <ReactionButton
type="reaction" type="reaction"
@ -98,7 +106,7 @@
</ReactionButton> </ReactionButton>
{/each} {/each}
</div> </div>
<div class="post-actions" on:click|stopPropagation> <div class="post-actions" aria-label="Post actions" on:click|stopPropagation on:keydown|stopPropagation>
<ActionButton type="reply" label="Reply" bind:count={post.reply_count} sound="post" disabled>🗨️</ActionButton> <ActionButton type="reply" label="Reply" bind:count={post.reply_count} sound="post" disabled>🗨️</ActionButton>
<ActionButton type="boost" label="Boost" on:click={() => toggleBoost()} bind:active={post.boosted} bind:count={post.boost_count} sound="boost">🔁</ActionButton> <ActionButton type="boost" label="Boost" on:click={() => toggleBoost()} bind:active={post.boosted} bind:count={post.boost_count} sound="boost">🔁</ActionButton>
<ActionButton type="favourite" label="Favourite" on:click={() => toggleFavourite()} bind:active={post.favourited} bind:count={post.favourite_count}>⭐</ActionButton> <ActionButton type="favourite" label="Favourite" on:click={() => toggleFavourite()} bind:active={post.favourited} bind:count={post.favourite_count}>⭐</ActionButton>
@ -112,11 +120,18 @@
<style> <style>
.post-reply { .post-reply {
padding-bottom: 24px; padding: 16px 16px 16px 16px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
color: var(--text); color: var(--text);
align-items: stretch; align-items: stretch;
border-radius: 8px;
transition: background-color .1s;
cursor: pointer;
}
.post-reply:hover {
background-color: color-mix(in srgb, var(--bg-800), black 5%);
} }
.post-avatar-container { .post-avatar-container {
@ -125,8 +140,8 @@
.line { .line {
position: relative; position: relative;
top: 24px; top: 32px;
left: 25px; left: 23px;
border-right: 2px solid var(--bg-700); border-right: 2px solid var(--bg-700);
} }

View file

@ -8,7 +8,8 @@
import { get } from 'svelte/store'; import { get } from 'svelte/store';
let client = get(Client.get()); let client = get(Client.get());
let ready = client.app && client.app.token; let ready = client.app;
let logged_in = ready && client.app.token;
let instance_url_error = false; let instance_url_error = false;
let logging_in = false; let logging_in = false;
@ -57,31 +58,46 @@
<main> <main>
{#if ready} {#if ready}
<Feed /> {#if logged_in}
{:else} <header>
<div> <h1>Home</h1>
<form on:submit={log_in} id="login"> <nav>
<h1>Space Social</h1> <Button centered active>Home</Button>
<p>Welcome, fediverse user!</p> <Button centered disabled>Local</Button>
<p>Please enter your instance domain to log in.</p> <Button centered disabled>Federated</Button>
<div class="input-wrapper"> </nav>
<input type="text" id="host" aria-label="instance domain" class={logging_in ? "throb" : ""}> </header>
{#if instance_url_error}
<p class="error">{instance_url_error}</p>
{/if}
</div>
<br>
<button type="submit" id="login" class={logging_in ? "disabled" : ""}>Log in</button>
<p><small>
Please note this is
<strong><em>extremely experimental software</em></strong>;
things are likely to break!
<br>
If that's all cool with you, welcome aboard!
</small></p>
<p class="form-footer">made with ❤️ by <a href="https://arimelody.me">ari melody</a>, 2024</p> <Feed />
</form> {:else}
<div>
<form on:submit={log_in} id="login">
<h1>Space Social</h1>
<p>Welcome, fediverse user!</p>
<p>Please enter your instance domain to log in.</p>
<div class="input-wrapper">
<input type="text" id="host" aria-label="instance domain" class={logging_in ? "throb" : ""}>
{#if instance_url_error}
<p class="error">{instance_url_error}</p>
{/if}
</div>
<br>
<button type="submit" id="login" class={logging_in ? "disabled" : ""}>Log in</button>
<p><small>
Please note this is
<strong><em>extremely experimental software</em></strong>;
things are likely to break!
<br>
If that's all cool with you, welcome aboard!
</small></p>
<p class="form-footer">made with ❤️ by <a href="https://arimelody.me">ari melody</a>, 2024</p>
</form>
</div>
{/if}
{:else}
<div class="loading throb">
<span>just a moment...</span>
</div> </div>
{/if} {/if}
</main> </main>
@ -93,31 +109,6 @@
</div> </div>
<style> <style>
#spacesocial-app {
margin: auto 0;
padding: 0 16px;
display: flex;
flex-direction: row;
justify-content: center;
gap: 16px;
}
header, #widgets {
width: 300px;
}
main {
width: 732px;
}
div.pane {
margin-top: 16px;
padding: 20px 32px;
border: 1px solid #8884;
border-radius: 16px;
background-color: var(--bg1);
}
form#login { form#login {
margin: 25vh 0 32px 0; margin: 25vh 0 32px 0;
text-align: center; text-align: center;
@ -221,4 +212,32 @@
.form-footer { .form-footer {
opacity: .7; opacity: .7;
} }
.loading {
width: 100%;
height: 80vh;
display: flex;
justify-content: center;
align-items: center;
font-size: 2em;
font-weight: bold;
}
main header {
width: 100%;
margin: 16px 0 8px 0;
display: flex;
flex-direction: row;
}
main header h1 {
font-size: 1.5em;
}
main header nav {
margin-left: auto;
display: flex;
flex-direction: row;
gap: 8px;
}
</style> </style>

View file

@ -0,0 +1,50 @@
<script>
import Navigation from '$lib/ui/Navigation.svelte';
import Widgets from '$lib/ui/Widgets.svelte';
import Button from '$lib/ui/Button.svelte';
</script>
<div id="spacesocial-app">
<header>
<Navigation />
</header>
<main>
<header>
<h1>Home</h1>
<nav>
<Button centered active>Home</Button>
<Button centered disabled>Local</Button>
<Button centered disabled>Federated</Button>
</nav>
</header>
<slot></slot>
</main>
<div id="widgets">
<Widgets />
</div>
</div>
<style>
main header {
width: 100%;
margin: 16px 0 8px 0;
display: flex;
flex-direction: row;
}
main header h1 {
font-size: 1.5em;
}
main header nav {
margin-left: auto;
display: flex;
flex-direction: row;
gap: 8px;
}
</style>

View file

@ -0,0 +1,46 @@
import Post from '$lib/ui/post/Post.svelte';
import { Client } from '$lib/client/client.js';
import { parsePost } from '$lib/client/api.js';
import { get } from 'svelte/store';
export const prerender = true;
export const ssr = false;
export async function load({ params }) {
let client = get(Client.get());
if (client.app && client.app.token) {
// this triggers the client actually getting the authenticated user's data.
const res = await client.verifyCredentials()
if (res) {
console.log(`Logged in as @${client.user.username}@${client.user.host}`);
} else {
return null;
}
} else {
return null;
}
const post_id = params.id;
const post_data = await client.getPost(post_id);
if (!post_data) {
console.error(`Failed to retrieve post ${post_id}.`);
return null;
}
const post = await parsePost(post_data, 10, true);
let posts = [post];
for (let i in post.replies) {
const reply = post.replies[i];
// if (i > 1 && reply.reply_id === post.replies[i - 1].id) {
// let reply_head = posts.pop();
// reply.reply = reply_head;
// }
posts.push(reply);
// console.log(reply);
}
return {
posts: posts
};
}

View file

@ -0,0 +1,38 @@
<script>
import '$lib/app.css';
import Post from '$lib/ui/post/Post.svelte';
export let data;
const main_post = data.posts[0];
const replies = data.posts.slice(1);
</script>
<div id="feed" role="feed">
{#if data.posts.length <= 0}
<div class="throb">
<span>just a moment...</span>
</div>
{:else}
<Post post_data={main_post} focused />
<br>
{#each replies as post}
<Post post_data={post} />
{/each}
{/if}
</div>
<style>
#feed {
margin-bottom: 20vh;
}
.throb {
width: 100%;
height: 80vh;
display: flex;
justify-content: center;
align-items: center;
font-size: 2em;
font-weight: bold;
}
</style>

View file

@ -1,4 +1,5 @@
import adapter from '@sveltejs/adapter-auto'; // import adapter from '@sveltejs/adapter-auto';
import adapter from '@sveltejs/adapter-static';
import * as child_process from 'node:child_process'; import * as child_process from 'node:child_process';
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
@ -7,7 +8,13 @@ const config = {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter. // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters. // See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter(), adapter: adapter({
pages: 'build',
assets: 'build',
fallback: undefined,
precompress: false,
strict: true,
}),
version: { version: {
name: child_process.execSync('git rev-parse HEAD').toString().trim() name: child_process.execSync('git rev-parse HEAD').toString().trim()
} }