you wouldn't redesign a whole app in one commit

This commit is contained in:
ari melody 2024-06-28 06:19:00 +01:00
parent 957a067568
commit 7669c5b4d6
Signed by: ari
GPG key ID: CF99829C92678188
69 changed files with 1232 additions and 506 deletions

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
*.afdesign filter=lfs diff=lfs merge=lfs -text
*.woff2 filter=lfs diff=lfs merge=lfs -text

View file

@ -8,7 +8,7 @@
</head>
<body>
<div id="app"></div>
<noscript>fk you allissa &lt;3</noscript>
<noscript>you need to enable javascript to use this app! :(</noscript>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View file

@ -1,6 +1,6 @@
{
"name": "spacesocial-client",
"version": "0.1.1",
"version": "0.2.0_rev1",
"description": "social media for the galaxy-wide-web! 🌌",
"type": "module",
"scripts": {

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

(image error) Size: 90 KiB

Binary file not shown.

Before

(image error) Size: 14 KiB

Binary file not shown.

Before

(image error) Size: 22 KiB

BIN
res/spacesocial-logo.afdesign (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -1,92 +1,109 @@
<script>
import Feed from './Feed.svelte';
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 ready = Client.get().app && Client.get().app.token;
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) {
let client = Client.get();
client.getToken(auth_code).then(() => {
client.save();
location = location.origin;
});
}
if (client.app && client.app.token) {
client.verifyCredentials().then(res => {
if (res) {
console.log(`Logged in as @${client.user.username}@${client.user.host}`);
}
});
}
function log_in(event) {
let client = Client.get();
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;
});
}
function log_out() {
Client.get().logout().then(() => {
ready = false;
});
}
</script>
<header>
<h1>space social</h1>
<p>social media for the galaxy-wide-web! 🌌</p>
<button id="logout" on:click={log_out}>log out</button>
</header>
<div id="spacesocial-app">
<main>
{#if ready}
<Feed />
{:else}
<div class="pane">
<form on:submit={log_in} id="login">
<h1>welcome!</h1>
<p>please enter your instance domain to log in.</p>
<input type="text" id="host" aria-label="instance domain">
<button type="submit" id="login">log in</button>
</form>
<header>
<Navigation />
</header>
<hr>
<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><small>
please note this is <strong><em>extremely experimental software</em></strong>;
even if you use the exact same instance as me, you may encounter problems.
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>
<p>made with ❤️ by <a href="https://arimelody.me">ari melody</a>, 2024</p>
</div>
{/if}
</main>
<div id="widgets">
<Widgets />
</div>
<footer>
</footer>
</div>
<style>
header {
width: min(768px, calc(100vw - 32px));
margin: 16px auto;
#spacesocial-app {
margin: auto 0;
padding: 0 16px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 16px;
}
header h1 {
margin: 0 16px 0 0;
}
h1 {
color: var(--accent);
header, #widgets {
width: 300px;
}
main {
width: min(800px, calc(100vw - 16px));
margin: 0 auto;
width: 732px;
}
div.pane {
@ -98,7 +115,7 @@
}
form#login {
margin: 64px 0;
margin: 25vh 0 32px 0;
text-align: center;
}
@ -111,32 +128,93 @@
text-decoration: underline;
}
input[type="text"] {
margin: 8px 0;
padding: 4px 6px;
font-family: inherit;
border: none;
.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;
}
button#login, button#logout {
margin-left: auto;
padding: 8px 12px;
font-size: 1em;
background-color: var(--bg2);
color: inherit;
border: none;
border-radius: 16px;
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;
transition: color .1s, background-color .1s;
text-align: center;
justify-content: center;
}
button#login:hover, button#logout:hover {
color: var(--bg0);
background: var(--fg0);
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, button#logout:active {
background: #0001;
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,101 +0,0 @@
<script>
import Post from './post/Post.svelte';
import Error from './Error.svelte';
import { Client } from './client/client.js';
import { parsePost } from './client/api.js';
let client = Client.get();
let posts = [];
let loading = false;
let error;
async function load_posts() {
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);
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;
}
load_posts();
document.addEventListener("scroll", event => {
if (!loading && window.innerHeight + window.scrollY >= document.body.offsetHeight - 2048) {
load_posts();
}
});
/*
client.getPost("", 1).then(post => {
posts = [...posts, post];
console.log(post);
});
*/
</script>
<div id="feed">
{#if error}
<Error msg={error.replaceAll('\n', '<br>')} />
{/if}
{#if posts.length <= 0}
<div class="loading">
<span>just a moment...</span>
</div>
{/if}
{#each posts as post}
<Post post_data={post} />
{/each}
</div>
<style>
.loading {
width: 100%;
height: 80vh;
display: flex;
justify-content: center;
align-items: center;
font-size: 2em;
font-weight: bold;
}
.loading span {
animation: pulse 1s infinite;
}
@keyframes pulse {
from {
opacity: .5;
}
50% {
opacity: 1;
}
to {
opacity: .5;
}
}
</style>

View file

@ -1,20 +1,54 @@
@import url("/font/inter/inter.css");
:root {
--fg0: #eee;
--bg0: #080808;
--bg1: #101010;
--bg2: #121212;
--accent: #b7fd49;
--accent-bg: #242b1a;
--bg-1000: #fff6de;
--bg-900: #f9f1db;
--bg-800: #f1e8cf;
--bg-700: #d2c9b1;
--bg-600: #f0f6c2;
--accent: #8d9936;
--text: #322e1f;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-1000: #141016;
--bg-900: #1B141E;
--bg-800: #2A202F;
--bg-700: #443749;
--bg-600: #513D60;
--accent: #CDA1EC;
--text: #E2DFE3;
}
}
@supports (font-variation-settings: normal) {
body { font-family: InterVariable, sans-serif; }
}
body {
width: 100vw;
margin: 0;
padding: 0;
color: var(--fg0);
background-color: var(--bg0);
color: var(--text);
background-color: var(--bg-1000);
font-family: "Inter", sans-serif;
font-feature-settings: 'liga' 1, 'calt' 1; /* fix for Chrome */
box-sizing: border-box;
}
.throb {
animation: .25s throb alternate infinite ease-in;
}
@keyframes throb {
from {
opacity: .5;
}
to {
opacity: 1;
}
}

View file

@ -1,8 +1,9 @@
import { Client } from '../client/client.js';
import { capabilities } from '../client/instance.js';
import Post from '../post/post.js';
import Post from '../post.js';
import User from '../user/user.js';
import Emoji from '../emoji.js';
import { get } from 'svelte/store';
export async function createApp(host) {
let form = new FormData();
@ -30,7 +31,7 @@ export async function createApp(host) {
}
export function getOAuthUrl() {
let client = Client.get();
let client = get(Client.get());
return `https://${client.instance.host}/oauth/authorize` +
`?client_id=${client.app.id}` +
"&scope=read+write+push" +
@ -39,7 +40,7 @@ export function getOAuthUrl() {
}
export async function getToken(code) {
let client = Client.get();
let client = get(Client.get());
let form = new FormData();
form.append("client_id", client.app.id);
form.append("client_secret", client.app.secret);
@ -64,7 +65,7 @@ export async function getToken(code) {
}
export async function revokeToken() {
let client = Client.get();
let client = get(Client.get());
let form = new FormData();
form.append("client_id", client.app.id);
form.append("client_secret", client.app.secret);
@ -83,8 +84,19 @@ export async function revokeToken() {
return true;
}
export async function verifyCredentials() {
let client = get(Client.get());
let url = `https://${client.instance.host}/api/v1/accounts/verify_credentials`;
const data = await fetch(url, {
method: 'GET',
headers: { "Authorization": "Bearer " + client.app.token }
}).then(res => res.json());
return data;
}
export async function getTimeline(last_post_id) {
let client = Client.get();
let client = get(Client.get());
let url = `https://${client.instance.host}/api/v1/timelines/home`;
if (last_post_id) url += "?max_id=" + last_post_id;
const data = await fetch(url, {
@ -95,8 +107,8 @@ export async function getTimeline(last_post_id) {
return data;
}
export async function getPost(post_id, num_replies) {
let client = Client.get();
export async function getPost(post_id, parent_replies) {
let client = get(Client.get());
let url = `https://${client.instance.host}/api/v1/statuses/${post_id}`;
const data = await fetch(url, {
method: 'GET',
@ -104,23 +116,49 @@ export async function getPost(post_id, num_replies) {
}).then(res => { return res.ok ? res.json() : false });
if (data === false) return false;
const post = await parsePost(data, num_replies);
if (post === null || post === undefined) {
if (data.id) {
console.warn("Failed to parse post data #" + data.id);
} else {
console.warn("Failed to parse post data:");
console.warn(data);
}
return false;
}
return post;
return data;
}
export async function parsePost(data, num_replies) {
let client = Client.get();
let post = new Post()
export async function getPostContext(post_id) {
let client = get(Client.get());
let url = `https://${client.instance.host}/api/v1/statuses/${post_id}/context`;
const data = await fetch(url, {
method: 'GET',
headers: { "Authorization": "Bearer " + client.app.token }
}).then(res => { return res.ok ? res.json() : false });
if (data === false) return false;
return data;
}
export async function parsePost(data, parent_replies, child_replies) {
let client = get(Client.get());
let post = new Post();
// if (client.instance.capabilities.includes(capabilities.MARKDOWN_CONTENT))
// post.text = data.text;
// else
post.text = data.content;
post.reply = null;
if ((data.in_reply_to_id || data.reply) && parent_replies !== 0) {
const reply_data = data.reply || await getPost(data.in_reply_to_id, parent_replies - 1);
post.reply = await parsePost(reply_data, parent_replies - 1, false);
// 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 :)
if (post.reply === false) return false;
}
post.boost = data.reblog ? await parsePost(data.reblog, 1, false) : null;
post.replies = [];
if (child_replies) {
const replies_data = await getPostContext(data.id);
if (replies_data && replies_data.descendants) {
for (let i in replies_data.descendants) {
post.replies.push(await parsePost(replies_data.descendants[i], 0, false));
}
}
}
post.id = data.id;
post.created_at = new Date(data.created_at);
@ -133,32 +171,20 @@ export async function parsePost(data, num_replies) {
post.url = data.url;
post.visibility = data.visibility;
if (client.instance.capabilities.includes(capabilities.MARKDOWN_CONTENT))
post.text = data.text;
else
post.text = data.content;
post.reply = null;
if (data.in_reply_to_id && num_replies > 0) {
post.reply = await getPost(data.in_reply_to_id, num_replies - 1);
// 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 :)
if (post.reply === false) return false;
}
post.boost = data.reblog ? await parsePost(data.reblog, 1) : null;
post.emojis = [];
data.emojis.forEach(emoji_data => {
let name = emoji_data.shortcode.split('@')[0];
post.emojis.push(parseEmoji({
id: name + '@' + post.user.host,
name: name,
host: post.user.host,
url: emoji_data.url,
}));
});
if (data.emojis) {
data.emojis.forEach(emoji_data => {
let name = emoji_data.shortcode.split('@')[0];
post.emojis.push(parseEmoji({
id: name + '@' + post.user.host,
name: name,
host: post.user.host,
url: emoji_data.url,
}));
});
}
if (client.instance.capabilities.includes(capabilities.REACTIONS)) {
if (data.reactions && client.instance.capabilities.includes(capabilities.REACTIONS)) {
post.reactions = [];
data.reactions.forEach(reaction_data => {
if (/^[\w\-.@]+$/g.exec(reaction_data.name)) {
@ -191,7 +217,17 @@ export async function parsePost(data, num_replies) {
}
export async function parseUser(data) {
let user = new User();
if (!data) {
console.error("Attempted to parse user data but no data was provided");
return null;
}
let client = get(Client.get());
let user = await client.getCacheUser(data.id);
if (user) return user;
// cache miss!
user = new User();
user.id = data.id;
user.nickname = data.display_name;
user.username = data.username;
@ -201,7 +237,7 @@ export async function parseUser(data) {
if (data.acct.includes('@'))
user.host = data.acct.split('@')[1];
else
user.host = Client.get().instance.host;
user.host = get(Client.get()).instance.host;
user.emojis = [];
data.emojis.forEach(emoji_data => {
@ -211,7 +247,7 @@ export async function parseUser(data) {
user.emojis.push(parseEmoji(emoji_data));
});
Client.get().putCacheUser(user);
get(Client.get()).putCacheUser(user);
return user;
}
@ -222,12 +258,12 @@ export function parseEmoji(data) {
data.host,
data.url,
);
Client.get().putCacheEmoji(emoji);
get(Client.get()).putCacheEmoji(emoji);
return emoji;
}
export async function getUser(user_id) {
let client = Client.get();
let client = get(Client.get());
let url = `https://${client.instance.host}/api/v1/accounts/${user_id}`;
const data = await fetch(url, {
method: 'GET',

View file

@ -1,14 +1,16 @@
import { version as APP_VERSION } from '../../package.json';
import { Instance, server_types } from './instance.js';
import * as api from './api.js';
import { get, writable } from 'svelte/store';
let client = false;
let client = writable(false);
const save_name = "spacesocial";
export class Client {
instance;
app;
user;
#cache;
constructor() {
@ -21,10 +23,11 @@ export class Client {
}
static get() {
if (client) return client;
client = new Client();
window.peekie = client;
client.load();
if (get(client)) return client;
let new_client = new Client();
window.peekie = new_client;
new_client.load();
client.set(new_client);
return client;
}
@ -34,8 +37,7 @@ export class Client {
const data = await fetch(url).then(res => res.json()).catch(error => { console.error(error) });
if (!data) {
console.error(`Failed to connect to ${host}`);
alert(`Failed to connect to ${host}! Please try again later.`);
return false;
return `Failed to connect to ${host}!`;
}
this.instance = new Instance(host, data.version);
@ -59,6 +61,8 @@ export class Client {
this.save();
client.set(this);
return true;
}
@ -73,31 +77,38 @@ export class Client {
return false;
}
this.app.token = token;
client.set(this);
}
async revokeToken() {
return await api.revokeToken();
}
async verifyCredentials() {
const data = await api.verifyCredentials();
if (!data) return false;
this.user = await api.parseUser(data);
client.set(this);
return data;
}
async getTimeline(last_post_id) {
return await api.getTimeline(last_post_id);
}
async getPost(post_id, num_replies) {
return await api.getPost(post_id, num_replies);
async getPost(post_id, parent_replies, child_replies) {
return await api.getPost(post_id, parent_replies, child_replies);
}
putCacheUser(user) {
this.cache.users[user.id] = user;
client.set(this);
}
async getUser(user_id) {
async getCacheUser(user_id) {
let user = this.cache.users[user_id];
if (user) return user;
user = await api.getUser(user_id);
if (user) return user;
return false;
}
@ -112,6 +123,7 @@ export class Client {
putCacheEmoji(emoji) {
this.cache.emojis[emoji.id] = emoji;
client.set(this);
}
getEmoji(emoji_id) {
@ -142,6 +154,7 @@ export class Client {
}
this.instance = new Instance(saved.instance.host, saved.instance.version);
this.app = saved.app;
client.set(this);
return true;
}
@ -151,7 +164,7 @@ export class Client {
console.warn("Failed to log out correctly; ditching the old tokens anyways.");
}
localStorage.removeItem(save_name);
client = new Client();
client.set(false);
console.log("Logged out successfully.");
}
}

View file

@ -1,4 +1,5 @@
import { Client } from './client/client.js';
import { get } from 'svelte/store';
export const EMOJI_REGEX = /:[\w\-.]{0,32}@[\w\-.]{0,32}:/g;
export const EMOJI_NAME_REGEX = /:[\w\-.]{0,32}:/g;
@ -31,7 +32,7 @@ export function parseText(text, host) {
let length = text.substring(index + 1).search(':');
if (length <= 0) return text;
let emoji_name = text.substring(index + 1, index + length + 1);
let emoji = Client.get().getEmoji(emoji_name + '@' + host);
let emoji = get(Client.get()).getEmoji(emoji_name + '@' + host);
if (emoji) {
return text.substring(0, index) + emoji.html +
@ -44,7 +45,7 @@ export function parseText(text, host) {
export function parseOne(emoji_id) {
if (emoji_id == '❤') return '❤️'; // stupid heart unicode
if (EMOJI_REGEX.exec(':' + emoji_id + ':')) return emoji_id;
let cached_emoji = Client.get().getEmoji(emoji_id);
let cached_emoji = get(Client.get()).getEmoji(emoji_id);
if (!cached_emoji) return emoji_id;
return cached_emoji.html;
}

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 226 89" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.0592476,0,0,0.0592476,19.3835,-44.4646)">
<clipPath id="_clip1">
<path d="M3471.16,750.487L3471.16,2249.51L-327.161,2249.51L-327.161,750.487L3471.16,750.487ZM2317.49,1260.74L1795.39,1260.74L1795.39,1763.02L2317.49,1763.02L2317.49,1260.74Z"/>
</clipPath>
<g clip-path="url(#_clip1)">
<g transform="matrix(3.12421,0,0,3.12421,13.4825,-77.5092)">
<g id="spinner">
<path d="M380,380C476.218,283.782 659.849,234.849 710,285C760.151,335.151 756.844,478.156 634,601C511.156,723.844 358.099,784.099 291,717C249.901,675.901 257.955,619 257.955,619C260.181,637.245 251.818,720.443 352.404,720.443C452.989,720.443 530.426,645.937 610.046,566.318C689.665,486.699 778.651,275.064 635.273,275.064C491.896,275.064 380,380 380,380Z"/>
</g>
</g>
<g transform="matrix(44.394,0.545455,-44.394,0.545455,899.136,728.049)">
<g id="star-shine" serif:id="star shine">
<rect x="383" y="377" width="11" height="11"/>
</g>
</g>
<g id="star" transform="matrix(1.45161,0,0,1.45161,531.871,522.581)">
<path d="M436.5,384L449.059,417.941L483,430.5L449.059,443.059L436.5,477L423.941,443.059L390,430.5L423.941,417.941L436.5,384Z"/>
</g>
</g>
</g>
<g transform="matrix(2.67689,0,0,2.67689,-1226.58,-333.741)">
<g transform="matrix(13.6363,0,0,13.6363,542.101,145.423)">
</g>
<text x="457.966px" y="145.423px" style="font-family:'Inter-BoldItalic', 'Inter';font-weight:700;font-style:italic;font-size:13.636px;">space social</text>
</g>
</svg>

After

(image error) Size: 2.1 KiB

View file

@ -1,4 +1,4 @@
import { parseText as parseEmoji } from '../emoji.js';
import { parseText as parseEmoji } from './emoji.js';
export default class Post {
id;
@ -14,6 +14,7 @@ export default class Post {
files;
url;
reply;
replies;
boost;
visibility;

View file

@ -1,85 +0,0 @@
<script>
import BoostContext from './BoostContext.svelte';
import ReplyContext from './ReplyContext.svelte';
import Header from './Header.svelte';
import Body from './Body.svelte';
import FooterButton from './FooterButton.svelte';
import { parseOne as parseEmoji } from '../emoji.js';
import { play_sound } from '../sound.js';
export let post_data;
let post_context = undefined;
let post = post_data;
let is_boost = false;
if (post_data.boost) {
is_boost = true;
post_context = post_data;
post = post_data.boost;
}
let aria_label = post.user.username + '; ' + post.text + '; ' + post.created_at;
</script>
<div class="post-container" aria-label={aria_label}>
{#if post.reply}
<ReplyContext post={post.reply} />
{/if}
{#if is_boost && !post_context.text}
<BoostContext post={post_context} />
{/if}
<article class="post">
<Header post={post} />
<Body post={post} />
<footer class="post-footer">
<div class="post-reactions">
{#each post.reactions as reaction}
<FooterButton icon={reaction.emoji.html} type="reaction" bind:count={reaction.count} title={reaction.emoji.id} label="" />
{/each}
</div>
<div class="post-actions">
<FooterButton icon="🗨️" type="reply" label="Reply" bind:count={post.reply_count} sound="post" />
<FooterButton icon="🔁" type="boost" label="Boost" bind:count={post.boost_count} sound="boost" />
<FooterButton icon="⭐" type="favourite" label="Favourite" />
<FooterButton icon="😃" type="react" label="React" />
<FooterButton icon="🗣️" type="quote" label="Quote" />
<FooterButton icon="🛠️" type="more" label="More" />
</div>
</footer>
</article>
</div>
<style>
.post-container {
margin-top: 16px;
padding: 28px 32px 20px 32px;
border: 1px solid #8884;
border-radius: 16px;
background-color: var(--bg1);
transition: background-color .1s;
}
.post-container:hover {
background-color: var(--bg2);
}
.post-container:hover :global(.post-context) {
opacity: 1;
}
:global(.post-reactions) {
display: flex;
flex-direction: row;
}
:global(.post-actions) {
margin-top: 8px;
display: flex;
flex-direction: row;
}
.post-container :global(.emoji) {
height: 20px;
}
</style>

View file

@ -1,153 +0,0 @@
<script>
import Header from './Header.svelte';
import Body from './Body.svelte';
import FooterButton from './FooterButton.svelte';
import Post from './Post.svelte';
import { parseText as parseEmojis, parseOne as parseEmoji } from '../emoji.js';
import { shorthand as short_time } from '../time.js';
export let post;
let time_string = post.created_at.toLocaleString();
</script>
<article class="post-reply">
<div class="post-reply-avatar-container">
<a href={post.user.url} target="_blank" class="post-avatar-container">
<img src={post.user.avatar_url} type={post.user.avatar_type} alt="" width="48" height="48" class="post-avatar" loading="lazy" decoding="async">
</a>
<div class="line">
</div>
</div>
<div class="post-reply-main">
<div class="post-header-container">
<header class="post-header">
<div class="post-user-info">
<a href={post.user.url} target="_blank" class="name">{@html post.user.rich_name}</a>
<span class="username">{post.user.mention}</span>
</div>
<div class="post-info">
<a href={post.url} target="_blank" class="created-at">
<time title={time_string}>{short_time(post.created_at)}</time>
{#if post.visibility !== "public"}
<span class="post-visibility">({post.visibility})</span>
{/if}
</a>
</div>
</header>
</div>
<Body post={post} />
<footer class="post-footer">
<div class="post-reactions">
{#each post.reactions as reaction}
<FooterButton icon={reaction.emoji.html} type="reaction" bind:count={reaction.count} title={reaction.emoji.id} label="" />
{/each}
</div>
<div class="post-actions">
<FooterButton icon="🗨️" type="reply" label="Reply" bind:count={post.reply_count} />
<FooterButton icon="🔁" type="boost" label="Boost" bind:count={post.boost_count} />
<FooterButton icon="⭐" type="favourite" label="Favourite" />
<FooterButton icon="😃" type="react" label="React" />
<FooterButton icon="🗣️" type="quote" label="Quote" />
<FooterButton icon="🛠️" type="more" label="More" />
</div>
</footer>
</div>
</article>
<style>
.post-reply {
padding-bottom: 24px;
display: flex;
flex-direction: row;
}
.post-avatar-container {
display: flex;
}
.post-reply-avatar-container {
margin-right: 12px;
margin-bottom: -24px;
}
.post-reply-avatar-container .line {
position: relative;
left: -1px;
width: 50%;
height: calc(100% - 48px);
border-right: 2px solid #8888;
}
.post-reply-main {
flex-grow: 1;
}
.post-header-container {
margin-bottom: -6px;
display: flex;
flex-direction: row;
}
.post-header-container a,
.post-header-container a:visited {
color: inherit;
text-decoration: none;
}
.post-header-container a:hover {
text-decoration: underline;
}
.post-avatar {
border-radius: 8px;
box-shadow: 2px 2px #0004;
}
.post-header {
height: 48px;
display: flex;
flex-grow: 1;
flex-direction: row;
}
.post-info {
margin-left: auto;
}
.post-user-info {
margin-top: -4px;
display: flex;
flex-direction: column;
justify-content: center;
}
.post-user-info a {
display: block;
}
.post-user-info .name :global(.emoji) {
position: relative;
top: 4px;
max-height: 1.25em;
}
.post-user-info .username {
opacity: .5;
font-size: .9em;
}
.post-info .created-at {
font-size: .8em;
}
:global(.post-body) {
margin-top: 0;
}
:global(.post-body p) {
margin: 0;
}
</style>

View file

@ -12,6 +12,7 @@ export function play_sound(name) {
return;
}
sound.pause();
sound.volume = 0.25;
sound.currentTime = 0;
sound.play();
}

134
src/ui/Button.svelte Normal file
View file

@ -0,0 +1,134 @@
<script>
import { play_sound } from '../sound.js';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
export let active = false;
export let filled = false;
export let disabled = false;
export let centered = false;
export let label = undefined;
export let sound = "default";
export let href = false;
let classes = [];
if (active) classes = ["active"];
if (filled) classes = ["filled"];
if (disabled) classes = ["disabled"];
if (centered) classes.push("centered");
function click() {
if (href) {
location = href;
return;
}
if (disabled) return;
play_sound(sound);
dispatch('click');
}
</script>
<button
type="button"
class={classes.join(' ')}
title={label}
aria-label={label}
on:click={() => click()}>
<slot/>
</button>
<style>
button {
/* min-width: 64px; */
width: 100%;
height: 54px;
padding: 16px;
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;
}
button.centered {
text-align: center;
justify-content: center;
}
button: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: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.active {
background-color: var(--bg-600);
color: var(--accent);
border-color: var(--accent);
text-shadow: 0px 2px 32px var(--accent);
}
button.active:hover {
color: color-mix(in srgb, var(--accent), var(--bg-1000) 20%);
border-color: color-mix(in srgb, var(--accent), var(--bg-1000) 20%);
background-color: color-mix(in srgb, var(--bg-600), var(--accent) 10%);
}
button.active:active {
color: color-mix(in srgb, var(--accent), var(--bg-800) 10%);
border-color: color-mix(in srgb, var(--accent), var(--bg-800) 10%);
background-color: color-mix(in srgb, var(--bg-600), var(--bg-800) 10%);
}
button.filled {
background-color: var(--accent);
color: var(--bg-800);
border-color: transparent;
}
button.filled:hover {
color: color-mix(in srgb, var(--bg-800), white 10%);
background-color: color-mix(in srgb, var(--accent), white 20%);
}
button.filled:active {
color: color-mix(in srgb, var(--bg-800), black 10%);
background-color: color-mix(in srgb, var(--accent), black 20%);
}
button.disabled {
background-color: var(--bg-700);
color: var(--text);
opacity: .5;
border-color: transparent;
cursor: initial;
}
button.disabled:hover {
}
button.disabled:active {
}
</style>

132
src/ui/Feed.svelte Normal file
View file

@ -0,0 +1,132 @@
<script>
import Button from './Button.svelte';
import Post from './post/Post.svelte';
import Error from './Error.svelte';
import { Client } from '../client/client.js';
import { parsePost } from '../client/api.js';
import { get } from 'svelte/store';
let params = new URLSearchParams(location.search);
let client = get(Client.get());
let posts = [];
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>
<header>
<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}
<div class="throb">
<span>just a moment...</span>
</div>
{/if}
{#each posts as post}
<Post post_data={post} focused={post.id === focus_post_id} />
{/each}
</div>
<style>
header {
margin: 16px 0 8px 0;
display: flex;
flex-direction: row;
}
header h1 {
font-size: 1.5em;
}
nav {
margin-left: auto;
display: flex;
flex-direction: row;
gap: 8px;
}
#feed {
margin-bottom: 20vh;
}
.throb {
width: 100%;
height: 80vh;
display: flex;
justify-content: center;
align-items: center;
font-size: 2em;
font-weight: bold;
}
</style>

281
src/ui/Navigation.svelte Normal file
View file

@ -0,0 +1,281 @@
<script>
import { version as APP_VERSION } from '../../package.json';
import Logo from '../img/spacesocial-logo.svg';
import Button from './Button.svelte';
import Feed from './Feed.svelte';
import { Client } from '../client/client.js';
import { play_sound } from '../sound.js';
let client = false;
Client.get().subscribe(c => {
client = c;
});
let notification_count = 0;
if (notification_count > 99) notification_count = "99+";
function goTimeline() {
location = "/";
}
function log_out() {
if (!confirm("This will log you out. Are you sure?")) return;
client.logout().then(() => {
location = "/";
});
}
</script>
<div id="navigation">
<header id="instance-header"> <!-- style={`background-image: url(${banner_url})`}> -->
<!-- <img src={icon_url} class="instance-icon" height="92px" aria-hidden="true"> -->
<div class="instance-icon instance-icon-mask" style={`mask-image: url(${Logo})`} height="92px" aria-hidden="true">
<!-- <img src={Logo} class="instance-icon" height="92px" aria-hidden="true"> -->
</header>
<div id="nav-items">
<Button label="Timeline" on:click={() => goTimeline()} active>🖼️ Timeline</Button>
<Button label="Notifications" disabled>
🔔 Notifications
{#if notification_count}
<span class="notification-count">{notification_count}</span>
{/if}
</Button>
<Button label="Explore" disabled>🌍 Explore</Button>
<Button label="Lists" disabled>🗒️ Lists</Button>
<div class="flex-row">
<Button centered label="Favourites" disabled></Button>
<Button centered label="Bookmarks" disabled>🔖</Button>
<Button centered label="Hashtags" disabled>#</Button>
</div>
<Button filled label="Post" disabled>✏️ Post</Button>
</div>
{#if (client.user)}
<div id="account-items">
<div class="flex-row">
<Button centered label="Profile information" disabled></Button>
<Button centered label="Settings" disabled>⚙️</Button>
<Button centered label="Log out" on:click={() => log_out()}>🚪</Button>
</div>
<div id="account-button">
<img src={client.user.avatar_url} class="account-avatar" height="64px" aria-hidden="true" on:click={() => play_sound()}>
<div class="account-name" aria-hidden="true">
<span class="nickname" title={client.user.nickname}>{client.user.nickname}</span>
<span class="username" title={`@${client.user.username}@${client.user.host}`}>
{`@${client.user.username}@${client.user.host}`}
</span>
</div>
<!-- <button class="settings" aria-label={`Account: ${client.user.username}@${client.user.host}`} on:click={() => play_sound()}>🔧</button> -->
</div>
</div>
{/if}
<span class="version">
space social v{APP_VERSION}
<br>
<ul>
<li><a href="https://git.arimelody.me/ari/spacesocial-client">source</a></li>
</ul>
</span>
</div>
<style>
#navigation {
display: flex;
flex-direction: column;
position: fixed;
top: 16px;
width: 300px;
height: calc(100vh - 32px);
border-radius: 8px;
background-color: var(--bg-800);
}
#instance-header {
width: 100%;
height: 172px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
background-position: center;
background-size: cover;
background-color: var(--bg-600);
background-image: linear-gradient(to top, var(--bg-800), var(--bg-600));
}
.instance-icon {
height: 92px;
border-radius: 8px;
}
.instance-icon-mask {
width: 80%;
margin: auto;
background-color: var(--text);
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-origin: border-box;
-webkit-mask-origin: border-box;
}
#nav-items {
margin-bottom: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.notification-count {
position: relative;
transform: translate(22px, -16px);
min-width: 12px;
height: 28px;
padding: 0 8px;
display: flex;
justify-content: center;
align-items: center;
text-align: right;
border-radius: 8px;
font-weight: 700;
color: var(--bg-1000);
background-color: var(--accent);
box-shadow: 0 0 32px color-mix(in srgb, transparent, var(--accent) 100%);
}
#account-items {
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.version {
margin-bottom: 16px;
font-style: italic;
font-size: .9em;
opacity: .6;
text-align: center;
}
.version ul {
margin: 0;
padding: 0;
display: flex;
gap: 8px;
justify-content: center;
list-style: none;
}
.version ul li {
margin: 0;
}
.version ul li:not(:first-child):before {
content: '•';
margin-right: 8px;
color: inherit;
opacity: .7;
}
.version a {
color: inherit;
text-decoration: none;
opacity: .7;
}
.version a:hover {
text-decoration: underline;
}
#account-button {
width: calc(100% - 16px);
height: 48px;
padding: 8px;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
font-family: inherit;
font-size: 1rem;
font-weight: 600;
border-radius: 8px;
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;
}
.account-avatar {
width: 48px;
height: 48px;
border-radius: 8px;
transition: transform .1s ease-out, box-shadow .2s;
}
.account-avatar:hover {
transform: scale(1.05);
box-shadow: 0 0 16px color-mix(in srgb, transparent, var(--accent) 25%);
}
.account-avatar:active {
transform: scale(.95);
box-shadow: 0 0 16px var(--bg-1000);
}
.account-name {
/* width: 152px; */
display: flex;
flex-direction: column;
gap: 2px;
}
.username, .nickname {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
font-size: .8em;
}
.username {
opacity: .8;
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 {
display: flex;
flex-direction: row;
gap: 8px;
}
</style>

39
src/ui/Widgets.svelte Normal file
View file

@ -0,0 +1,39 @@
<div id="widgets">
<input type="text" id="search" placeholder="🔍 Search">
</div>
<style>
#widgets {
position: fixed;
top: 16px;
width: 300px;
height: calc(100vh - 32px);
display: flex;
flex-direction: column;
gap: 8px;
}
#search {
padding: 16px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, transparent, var(--accent) 25%);
background-color: var(--bg-800);
font-family: inherit;
font-weight: 600;
font-size: inherit;
color: var(--text);
transition: box-shadow .2s;
}
#search::placeholder {
opacity: .8;
}
#search:focus {
outline: none;
box-shadow: 0 0 16px color-mix(in srgb, transparent, var(--accent) 25%);
}
</style>

View file

@ -1,5 +1,5 @@
<script>
import { play_sound } from '../sound.js';
import { play_sound } from '../../sound.js';
export let icon = "🔧";
export let type = "action";
@ -14,7 +14,7 @@
class="{type}"
aria-label="{label}"
title="{title}"
on:click={() => (play_sound(sound))}>
on:click|stopPropagation={() => (play_sound(sound))}>
<span class="icon">{@html icon}</span>
{#if count}
<span class="count">{count}</span>

View file

@ -8,7 +8,7 @@
<div class="post-body">
{#if post.warning}
<button class="post-warning" on:click={() => { open_warned = !open_warned }}>
<button class="post-warning" on:click|stopPropagation={() => { open_warned = !open_warned }}>
<strong>
{post.warning}
<span class="warning-instructions">
@ -27,14 +27,14 @@
{/if}
<div class="post-media-container" data-count={post.files.length}>
{#each post.files as file}
<div class="post-media {file.type}">
<div class="post-media {file.type}" on:click|stopPropagation={null}>
{#if file.type === "image"}
<a href={file.url} target="_blank">
<img src={file.url} alt={file.description} height="200" loading="lazy" decoding="async">
<img src={file.url} alt={file.description} title={file.description} height="200" loading="lazy" decoding="async">
</a>
{:else if file.type === "video"}
<video controls height="200">
<source src={file.url} type={file.url.endsWith('.mp4') ? 'video/mp4' : 'video/webm'}>
<source src={file.url} alt={file.description} title={file.description} type={file.url.endsWith('.mp4') ? 'video/mp4' : 'video/webm'}>
<p>{file.description} &ensp; <a href={file.url}>[link]</a></p>
<!-- <media src={file.url} alt={file.description} loading="lazy" decoding="async"> -->
</video>
@ -58,8 +58,9 @@
width: 100%;
margin-bottom: 10px;
padding: 4px 8px;
--warn-bg: rgba(255,220,30,.1);
--warn-bg: color-mix(in srgb, var(--bg-700), var(--accent) 1%);
background: repeating-linear-gradient(-45deg, transparent, transparent 10px, var(--warn-bg) 10px, var(--warn-bg) 20px);
font-family: inherit;
font-size: inherit;
color: inherit;
text-align: left;
@ -125,9 +126,10 @@
}
.post-text :global(a.mention) {
color: var(--accent);
color: inherit;
font-weight: 600;
padding: 3px 6px;
background: var(--accent-bg);
background: var(--bg-700);
border-radius: 6px;
text-decoration: none;
}

View file

@ -1,6 +1,6 @@
<script>
import { parseText as parseEmojis } from '../emoji.js';
import { shorthand as short_time } from '../time.js';
import { parseText as parseEmojis } from '../../emoji.js';
import { shorthand as short_time } from '../../time.js';
export let post;
@ -27,7 +27,8 @@
display: flex;
flex-direction: row;
align-items: center;
color: var(--accent);
font-weight: 600;
color: var(--text);
opacity: .8;
transition: opacity .1s;
}

113
src/ui/post/Post.svelte Normal file
View file

@ -0,0 +1,113 @@
<script>
import BoostContext from './BoostContext.svelte';
import ReplyContext from './ReplyContext.svelte';
import PostHeader from './PostHeader.svelte';
import Body from './Body.svelte';
import ReactionButton from './ReactionButton.svelte';
import ActionButton from './ActionButton.svelte';
import { parseOne as parseEmoji } from '../../emoji.js';
import { play_sound } from '../../sound.js';
import { onMount } from 'svelte';
export let post_data;
export let focused = false;
let post_context = undefined;
let post = post_data;
let is_boost = false;
if (post_data.boost) {
is_boost = true;
post_context = post_data;
post = post_data.boost;
}
function gotoPost() {
location = `/post/${post.id}`;
}
let el;
onMount(() => {
if (focused) {
window.scrollTo(0, el.scrollHeight - 700);
}
});
let aria_label = post.user.username + '; ' + post.text + '; ' + post.created_at;
</script>
<div class="post-container" aria-label={aria_label} bind:this={el}>
{#if post.reply}
<ReplyContext post={post.reply} />
{/if}
{#if is_boost && !post_context.text}
<BoostContext post={post_context} />
{/if}
<article class={"post" + (focused ? " focused" : "")} on:click={!focused ? gotoPost() : null}>
<PostHeader post={post} />
<Body post={post} />
<footer class="post-footer">
<div class="post-reactions">
{#each post.reactions as reaction}
<ReactionButton icon={reaction.emoji.html} type="reaction" bind:count={reaction.count} title={reaction.emoji.id} label="" />
{/each}
</div>
<div class="post-actions">
<ActionButton icon="🗨️" type="reply" label="Reply" bind:count={post.reply_count} sound="post" />
<ActionButton icon="🔁" type="boost" label="Boost" bind:count={post.boost_count} sound="boost" />
<ActionButton icon="⭐" type="favourite" label="Favourite" />
<ActionButton icon="😃" type="react" label="React" />
<ActionButton icon="🗣️" type="quote" label="Quote" />
<ActionButton icon="🛠️" type="more" label="More" />
</div>
</footer>
</article>
</div>
<style>
.post-container {
width: 700px;
max-width: 700px;
margin-bottom: 8px;
padding: 16px;
display: flex;
flex-direction: column;
border-radius: 8px;
background-color: var(--bg-800);
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) {
cursor: pointer;
}
.post.focused {
padding: 16px;
margin: -16px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, transparent, var(--accent) 20%);
box-shadow: 0 0 16px color-mix(in srgb, transparent, var(--accent) 20%);
}
:global(.post-reactions) {
display: flex;
flex-direction: row;
}
:global(.post-actions) {
margin-top: 8px;
display: flex;
flex-direction: row;
}
.post-container :global(.emoji) {
height: 20px;
}
</style>

View file

@ -1,13 +1,14 @@
<script>
import { parseText as parseEmojis } from '../emoji.js';
import { shorthand as short_time } from '../time.js';
import { parseText as parseEmojis } from '../../emoji.js';
import { shorthand as short_time } from '../../time.js';
export let post;
export let reply = undefined;
let time_string = post.created_at.toLocaleString();
</script>
<div class="post-header-container">
<div class={"post-header-container" + (reply ? " reply" : "")}>
<a href={post.user.url} target="_blank" class="post-avatar-container">
<img src={post.user.avatar_url} type={post.user.avatar_type} alt="" width="48" height="48" class="post-avatar" loading="lazy" decoding="async">
</a>
@ -20,7 +21,8 @@
<a href={post.url} target="_blank" class="created-at">
<time title={time_string}>{short_time(post.created_at)}</time>
{#if post.visibility !== "public"}
<span class="post-visibility">({post.visibility})</span>
<br>
<span class="post-visibility">{post.visibility}</span>
{/if}
</a>
</div>
@ -29,10 +31,16 @@
<style>
.post-header-container {
width: 100%;
display: flex;
flex-direction: row;
}
.post-header-container.reply {
width: calc(100% + 60px);
margin-left: -60px;
}
.post-header-container a,
.post-header-container a:visited {
color: inherit;
@ -49,7 +57,6 @@
.post-avatar {
border-radius: 8px;
box-shadow: 2px 2px #0004;
}
.post-header {
@ -80,11 +87,21 @@
}
.post-user-info .username {
opacity: .5;
opacity: .8;
font-size: .9em;
}
.post-info .created-at {
height: 100%;
display: flex;
flex-direction: column;
align-items: end;
justify-content: center;
font-size: .8em;
}
.post-visibility {
font-size: .9em;
opacity: .8;
}
</style>

View file

@ -0,0 +1,66 @@
<script>
import { play_sound } from '../../sound.js';
export let icon = "🔧";
export let type = "action";
export let label = "Action";
export let title = label;
export let count = 0;
export let sound = "default";
</script>
<button
type="button"
class="{type}"
aria-label="{label}"
title="{title}"
on:click|stopPropagation={() => (play_sound(sound))}>
<span class="icon">{@html icon}</span>
{#if count}
<span class="count">{count}</span>
{/if}
</button>
<style>
button {
height: 32px;
padding: 6px 8px;
display: flex;
flex-direction: row;
gap: 4px;
font-size: 1em;
background: none;
color: inherit;
border: none;
border-radius: 8px;
}
button.active {
background: var(--accent);
color: var(--bg0);
}
button:hover {
background: #8881;
}
button:active {
background: #0001;
}
.icon {
width: 20px;
height: 20px;
display: flex;
justify-content: center;
align-items: center;
}
.count {
opacity: .5;
}
button:hover .count {
opacity: 1;
}
</style>

View file

@ -0,0 +1,81 @@
<script>
import PostHeader from './PostHeader.svelte';
import Body from './Body.svelte';
import ReactionButton from './ReactionButton.svelte';
import ActionButton from './ActionButton.svelte';
import Post from './Post.svelte';
import { parseText as parseEmojis, parseOne as parseEmoji } from '../../emoji.js';
import { shorthand as short_time } from '../../time.js';
export let post;
let time_string = post.created_at.toLocaleString();
function gotoPost() {
location = `/post/${post.id}`;
}
</script>
{#if post.reply}
<svelte:self post={post.reply} />
{/if}
<article class="post-reply" on:click={() => gotoPost()}>
<div class="line"></div>
<div class="post-reply-main">
<PostHeader post={post} reply />
<Body post={post} />
<footer class="post-footer">
<div class="post-reactions">
{#each post.reactions as reaction}
<ReactionButton icon={reaction.emoji.html} type="reaction" bind:count={reaction.count} title={reaction.emoji.id} label="" />
{/each}
</div>
<div class="post-actions">
<ActionButton icon="🗨️" type="reply" label="Reply" bind:count={post.reply_count} sound="post" />
<ActionButton icon="🔁" type="boost" label="Boost" bind:count={post.boost_count} sound="boost" />
<ActionButton icon="⭐" type="favourite" label="Favourite" />
<ActionButton icon="😃" type="react" label="React" />
<ActionButton icon="🗣️" type="quote" label="Quote" />
<ActionButton icon="🛠️" type="more" label="More" />
</div>
</footer>
</div>
</article>
<style>
.post-reply {
padding-bottom: 24px;
display: flex;
flex-direction: row;
color: var(--text);
align-items: stretch;
}
.post-avatar-container {
display: flex;
}
.line {
position: relative;
top: 24px;
left: 25px;
border-right: 2px solid var(--bg-700);
}
.post-reply-main {
width: 100%;
padding-left: 60px;
z-index: 1;
}
:global(.post-body) {
margin-top: 0;
}
:global(.post-body p) {
margin: 0;
}
</style>

View file

@ -1,5 +1,6 @@
import { Client } from '../client/client.js';
import { parseText as parseEmojis } from '../emoji.js';
import { get } from 'svelte/store';
export default class User {
id;
@ -16,7 +17,7 @@ export default class User {
get mention() {
let res = "@" + this.username;
if (this.host != Client.get().instance.host)
if (this.host != get(Client.get()).instance.host)
res += "@" + this.host;
return res;
}