Merge branch 'mae-redesign'
This commit is contained in:
commit
648f53f40c
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
*.afdesign filter=lfs diff=lfs merge=lfs -text
|
||||
*.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
@ -8,7 +8,7 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<noscript>fuck you allissa <3</noscript>
|
||||
<noscript>you need to enable javascript to use this app! :(</noscript>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -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.
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.
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.
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.
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Before Width: | Height: | Size: 90 KiB |
Binary file not shown.
Before Width: | Height: | Size: 14 KiB |
Binary file not shown.
Before Width: | Height: | Size: 22 KiB |
BIN
res/spacesocial-logo.afdesign
(Stored with Git LFS)
Normal file
BIN
res/spacesocial-logo.afdesign
(Stored with Git LFS)
Normal file
Binary file not shown.
198
src/App.svelte
198
src/App.svelte
|
@ -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>
|
||||
|
||||
<div id="spacesocial-app">
|
||||
|
||||
<header>
|
||||
<h1>space social</h1>
|
||||
<p>social media for the galaxy-wide-web! 🌌</p>
|
||||
<button id="logout" on:click={log_out}>log out</button>
|
||||
<Navigation />
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{#if ready}
|
||||
<Feed />
|
||||
{:else}
|
||||
<div class="pane">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<hr>
|
||||
|
||||
<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>;
|
||||
even if you use the exact same instance as me, you may encounter problems.
|
||||
if that's all cool with you, welcome aboard!
|
||||
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>made with ❤️ by <a href="https://arimelody.me">ari melody</a>, 2024</p>
|
||||
<p class="form-footer">made with ❤️ by <a href="https://arimelody.me">ari melody</a>, 2024</p>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
</footer>
|
||||
<div id="widgets">
|
||||
<Widgets />
|
||||
</div>
|
||||
|
||||
</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>
|
||||
|
|
101
src/Feed.svelte
101
src/Feed.svelte
|
@ -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>
|
50
src/app.css
50
src/app.css
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,21 +171,8 @@ 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 = [];
|
||||
if (data.emojis) {
|
||||
data.emojis.forEach(emoji_data => {
|
||||
let name = emoji_data.shortcode.split('@')[0];
|
||||
post.emojis.push(parseEmoji({
|
||||
|
@ -157,8 +182,9 @@ export async function parsePost(data, num_replies) {
|
|||
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',
|
||||
|
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
29
src/img/spacesocial-logo.svg
Normal file
29
src/img/spacesocial-logo.svg
Normal 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 Width: | Height: | Size: 2.1 KiB |
|
@ -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;
|
||||
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
134
src/ui/Button.svelte
Normal 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
132
src/ui/Feed.svelte
Normal 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
281
src/ui/Navigation.svelte
Normal 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
39
src/ui/Widgets.svelte
Normal 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>
|
|
@ -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>
|
|
@ -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}   <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;
|
||||
}
|
|
@ -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
113
src/ui/post/Post.svelte
Normal 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>
|
|
@ -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>
|
66
src/ui/post/ReactionButton.svelte
Normal file
66
src/ui/post/ReactionButton.svelte
Normal 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>
|
81
src/ui/post/ReplyContext.svelte
Normal file
81
src/ui/post/ReplyContext.svelte
Normal 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>
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue