Compare commits

..

7 Commits

Author SHA1 Message Date
e0fe175566 fix: improve rendering on narrow desktop screens 2023-02-14 00:37:41 +01:00
7d581afcaf feat: implement whisper, (double)click on avatar
Users can insert a mention by clicking, and initiate a whisper by
double-clicking on the avatar on a user's chat message
2023-02-14 00:23:07 +01:00
38f926b32f feat: implement live channel sidebar 2023-02-14 00:22:57 +01:00
a1bd44ffb3 style: improve top screen rendering on desktop 2023-02-13 19:53:02 +01:00
a29d11f385 feat: implement theme changing
quick settings are now properly implemented
2023-02-13 19:08:29 +01:00
e40518d68f style: improve top screen styling on desktop 2023-02-12 21:23:46 +01:00
380d3a7d89 refactor: tighten up css
created a bunch of utility classes and use those instead
2023-02-12 20:57:46 +01:00
18 changed files with 723 additions and 298 deletions

8
package-lock.json generated
View File

@@ -13,7 +13,7 @@
"@fortawesome/free-solid-svg-icons": "^6.2.1", "@fortawesome/free-solid-svg-icons": "^6.2.1",
"@fortawesome/svelte-fontawesome": "^0.2.0", "@fortawesome/svelte-fontawesome": "^0.2.0",
"detect-it": "^4.0.1", "detect-it": "^4.0.1",
"fedwave-chat-client": "^0.0.1", "fedwave-chat-client": "^0.0.2",
"jose": "^4.11.2", "jose": "^4.11.2",
"svrollbar": "^0.12.0" "svrollbar": "^0.12.0"
}, },
@@ -1605,9 +1605,9 @@
} }
}, },
"node_modules/fedwave-chat-client": { "node_modules/fedwave-chat-client": {
"version": "0.0.1", "version": "0.0.2",
"resolved": "https://registry.npmjs.org/fedwave-chat-client/-/fedwave-chat-client-0.0.1.tgz", "resolved": "https://registry.npmjs.org/fedwave-chat-client/-/fedwave-chat-client-0.0.2.tgz",
"integrity": "sha512-VP7OJELCx1V0c26QbgF+mLoMq4EyPLbrjIylAvS12hv2XVlmGJRqnrh6kCVHqSr6ZwirjlM/eLSDgJ2jWXMiyw==", "integrity": "sha512-r4rvOGVEvb09LrIrWOF4D1yEd/9duxameFlnJltOZxXagPkzS5RhJagIOKO0L+37moUHOVv/cMu3Gg+YFDA0Pg==",
"dependencies": { "dependencies": {
"socket.io-client": "^3.1.3" "socket.io-client": "^3.1.3"
} }

View File

@@ -38,7 +38,7 @@
"@fortawesome/free-solid-svg-icons": "^6.2.1", "@fortawesome/free-solid-svg-icons": "^6.2.1",
"@fortawesome/svelte-fontawesome": "^0.2.0", "@fortawesome/svelte-fontawesome": "^0.2.0",
"detect-it": "^4.0.1", "detect-it": "^4.0.1",
"fedwave-chat-client": "^0.0.1", "fedwave-chat-client": "^0.0.2",
"jose": "^4.11.2", "jose": "^4.11.2",
"svrollbar": "^0.12.0" "svrollbar": "^0.12.0"
} }

View File

@@ -2,13 +2,13 @@
import { version } from '$app/environment'; import { version } from '$app/environment';
</script> </script>
<div class="about d-flex col no-select"> <div class="about d-flex col no-select text-center">
<h1 class="litechat">litechat<span class="plus">plus</span></h1> <h1 class="litechat mb-0 color-300">litechat<span class="plus color-cyan super">plus</span></h1>
<p>A [<i>fedwave</i>] chat client.</p> <p>A [<i>fedwave</i>] chat client.</p>
<div class="d-flex build-info"> <div class="d-flex build-info px-24 select-text color-400">
<p class="version">Version: {version}</p> <p class="version">Version: {version}</p>
<div class="spacer" /> <div class="spacer" />
<p class="commit">{__COMMIT__}</p> <p class="commit select-all">{__COMMIT__}</p>
</div> </div>
</div> </div>
@@ -16,29 +16,6 @@
.about { .about {
height: var(--top-screen-cutout-height); height: var(--top-screen-cutout-height);
width: var(--sidebar-width, 350px); width: var(--sidebar-width, 350px);
text-align: center;
} }
.litechat {
color: var(--base-300);
margin-bottom: 0px;
& .plus {
vertical-align: super;
color: var(--cyan);
}
}
.build-info {
padding-left: 24px;
padding-right: 24px;
& > * {
user-select: text;
}
}
.version {
color: var(--base-400);
}
</style> </style>

View File

@@ -5,9 +5,9 @@
export let size: string = 'var(--avatar-size)'; export let size: string = 'var(--avatar-size)';
</script> </script>
<div class="avatar" style:--color="{color}" style:--size="{size}" on:click> <button class="avatar pa-0 border-0 shrink-0" style:--color="{color}" style:--size="{size}" on:click>
<img src="{avatar}" alt="{`${username}'s avatar`}"> <img src="{avatar}" alt="{`${username}'s avatar`}">
</div> </button>
<style> <style>
.avatar, .avatar > img { .avatar, .avatar > img {

View File

@@ -1,20 +1,28 @@
<script lang="ts"> <script lang="ts">
import type { Message } from "fedwave-chat-client"; import type { Message } from "fedwave-chat-client";
import { room, username } from "./chat"; import { room } from "./chat";
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
export let globalMode = false; export let globalMode = false;
export let message: Message; export let message: Message;
export let isHeader: boolean = true; export let isHeader: boolean = true;
</script> </script>
<div class="message" class:subseq="{!isHeader}"> <div class="message d-flex p-relative pl-8 pr-24" class:subseq="{!isHeader}">
{#if isHeader} {#if isHeader}
<div class="avatar" style="background-color: {message.color}"> <!-- TODO: Use <Avatar/> here -->
<img alt="{message.username}'s avatar" src="{message.avatar || '/troll_haz2.png'}"> <div class="avatar radius-pill overflow-hidden no-select clickable"
on:click="{() => dispatch('avatar:click', message)}"
on:dblclick="{() => dispatch('avatar:dblclick', message)}"
style="background-color: {message.color}">
<img class="w-100" alt="{message.username}'s avatar" src="{message.avatar || '/troll_haz2.png'}">
</div> </div>
{/if} {/if}
<div class="container"> <div class="container">
{#if isHeader} {#if isHeader}
<div class="top"> <div class="top d-flex grow-1">
<div class="username"> <div class="username">
{message.username} {message.username}
</div> </div>
@@ -25,24 +33,25 @@
</div> </div>
<div class="spacer" /> <div class="spacer" />
{#if globalMode} {#if globalMode}
<a href="/{message.channel}" class="channel" <a href="/{message.channel}" class="channel radius-4 px-2 text-end color-700 no-select"
class:local="{$room == message.channel.toLowerCase()}"> class:local="{$room == message.channel.toLowerCase()}">
{message.channel} {message.channel}
</a> </a>
{/if} {/if}
</div> </div>
{/if} {/if}
<div class="text"> <div class="text basis-0">
{@html message.message} {@html message.message}
</div> </div>
</div> </div>
</div> </div>
<style> <style lang="scss">
:root { :root {
--message-line-height: 22px; --message-line-height: 22px;
--avatar-size: 35px; --avatar-size: 35px;
--greentext-color: #789922; --greentext-color: #789922;
--message-group-spacing: 8px;
} }
* { * {
@@ -51,54 +60,26 @@
font-family: IBM Plex Sans font-family: IBM Plex Sans
} }
.message {
position: relative;
padding-left: 8px;
padding-right: 24px;
display: flex;
}
.message:last-child {
margin-bottom: 12px;
}
.message:hover { .message:hover {
background: var(--base-700); background: var(--base-700);
} }
.avatar { .avatar {
border-radius: 9999px;
overflow: hidden;
min-height: var(--avatar-size); min-height: var(--avatar-size);
min-width: var(--avatar-size); min-width: var(--avatar-size);
max-height: var(--avatar-size); max-height: var(--avatar-size);
max-width: var(--avatar-size); max-width: var(--avatar-size);
user-select: none;
}
.avatar > img {
width: 100%;
height: auto
} }
.message:not(.subseq) { .message:not(.subseq) {
padding-top: 4px; padding-top: 4px;
margin-top: 8px; margin-top: var(--message-group-spacing);
} }
.subseq { .subseq {
margin-top: 0; margin-top: 0;
padding-top: 0; padding-top: 0;
padding-left: calc(16px + var(--avatar-size)); padding-left: calc(16px + var(--avatar-size)) !important;
}
.subseq > .container {
margin-left: 0;
padding-top: 2px;
padding-bottom: 2px;
} }
.container { .container {
@@ -107,87 +88,74 @@
justify-content: space-between; justify-content: space-between;
margin-left: 8px; margin-left: 8px;
width: 100%; width: 100%;
}
.top { .subseq > & {
display: flex; margin-left: 0;
height: 18px; padding-top: 2px;
padding-bottom: 2px;
} }
.top .spacer {
flex-grow: 1;
} }
.username { .username {
font-weight: bold; font-weight: bold;
font-optical-sizing: auto;
height: 12px; height: 12px;
font-family: IBM Plex Mono, Consolas, monospace; font-family: IBM Plex Mono, Consolas, monospace;
} }
.channel { .channel {
--background: var(--base-600); background-color: var(--base-600);
border-radius: 4px;
background-color: var(--background);
padding-left: 2px;
padding-right: 2px;
text-align: right;
font-family: IBM Plex Mono, Consolas, monospace; font-family: IBM Plex Mono, Consolas, monospace;
text-transform: lowercase; text-transform: lowercase;
color: var(--base-700);
user-select: none;
} }
.top { height: 18px; }
.channel.local { .channel.local {
--background: var(--cyan); background-color: var(--cyan);
} }
.text { .text {
flex-basis: 0; & :global(.mention) {
}
.text :global(.mention) {
font-weight: 800; font-weight: 800;
color: var(--yellow); color: var(--yellow);
} }
.text :global(p:empty), .text :global(br:nth-last-child(1)), .text :global(br) { & :global(p:empty), & :global(br:nth-last-child(1)), & :global(br) {
display: none; display: none;
} }
.text > :global(blockquote) { & > :global(blockquote) {
margin: 0; margin: 0;
color: var(--greentext-color); color: var(--greentext-color);
} }
.text > :global(p), .text > :global(blockquote) { & > :global(p), & > :global(blockquote) {
line-height: var(--message-line-height); line-height: var(--message-line-height);
} }
.text :global(h1) { & :global(h1) {
margin: 0; margin: 0;
margin-top: 8px; margin-top: 8px;
margin-bottom: 8px; margin-bottom: 8px;
font-size: 1.5em; font-size: 1.5em;
} }
.text :global(*) { & :global(*) {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.text :global(p) { & :global(p) {
margin: 0; margin: 0;
} }
.text :global(img) { & :global(img) {
height: var(--message-line-height); height: var(--message-line-height);
vertical-align: bottom; vertical-align: bottom;
} }
.text :global(h1 img) { & :global(h1 img) {
height: calc(2 * var(--message-line-height)); height: calc(2 * var(--message-line-height));
vertical-align: bottom; vertical-align: bottom;
} }
}
</style> </style>

View File

@@ -18,6 +18,8 @@
let viewport: Element, contents: Element; let viewport: Element, contents: Element;
let tweet: HTMLAudioElement; let tweet: HTMLAudioElement;
let input: string = '';
shouldTweet.subscribe((should) => { shouldTweet.subscribe((should) => {
if (should) { if (should) {
if (tweet) { if (tweet) {
@@ -73,28 +75,40 @@
stickToBottom = true; stickToBottom = true;
} }
} }
</script> </script>
<div class="scroller"> <div class="scroller grow-1 basis-0 p-relative">
<audio src="/tweet.mp3" bind:this={tweet} /> <audio src="/tweet.mp3" bind:this={tweet} />
<Svrollbar {viewport} {contents} margin={{ right: 6, bottom: 12, top: 12 }} <Svrollbar
{viewport}
{contents}
margin={{ right: 6, bottom: 12, top: 12 }}
alwaysVisible alwaysVisible
--svrollbar-track-width="8px" --svrollbar-thumb-width="4px" /> --svrollbar-track-width="8px"
<MessageView messages="{$messages}" showChannel="{$globalMode}" --svrollbar-thumb-width="4px"
bind:viewport bind:contents on:scroll={onScroll} /> />
<MessageView
messages={$messages}
showChannel={$globalMode}
bind:viewport
bind:contents
on:scroll={onScroll}
on:avatar:click={(e) => (
(input += ` @${e.detail.username}#${e.detail.unum} `), (input = input.trim())
)}
on:avatar:dblclick={(e) => (input = `/w ${e.detail.username}#${e.detail.unum} `)}
/>
{#if !stickToBottom} {#if !stickToBottom}
<FAB --background="{'var(--base-700)'}" on:click={() => scrollToBottom()}><FontAwesomeIcon icon="{faArrowDown}" /></FAB> <FAB --background={'var(--base-700)'} on:click={() => scrollToBottom()}
><FontAwesomeIcon icon={faArrowDown} /></FAB
>
{/if} {/if}
</div> </div>
<Input /> <Input bind:input />
<style> <style>
.scroller { .scroller {
min-height: 0; min-height: 0;
flex-grow: 1;
flex-basis: 0;
position: relative;
} }
.scroller > :global(.viewport) { .scroller > :global(.viewport) {
@@ -107,13 +121,11 @@
} }
.scroller > :global(*) { .scroller > :global(*) {
/* hide scrollbar */
-ms-overflow-style: none !important; -ms-overflow-style: none !important;
scrollbar-width: none !important; scrollbar-width: none !important;
} }
.scroller > :global(*::-webkit-scrollbar) { .scroller > :global(*::-webkit-scrollbar) {
/* hide scrollbar */
display: none !important; display: none !important;
} }

View File

@@ -2,7 +2,7 @@
export let flex = false; export let flex = false;
</script> </script>
<div class="header" class:flex="{flex}"> <div class="header p-relative" class:d-flex="{flex}">
<slot /> <slot />
</div> </div>
@@ -11,19 +11,12 @@
--header-size: 48px; --header-size: 48px;
--header-color: var(--base-600); --header-color: var(--base-600);
--header-z: 101; --header-z: 101;
} --header-shadow: rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.06) 0px 1px 2px 0px;
.flex {
display: flex;
flex-direction: row;
} }
.header { .header {
position: relative;
height: var(--header-size); height: var(--header-size);
box-shadow: none; /* var(--header-shadow) */
/* box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.06) 0px 1px 2px 0px; */
background-color: var(--header-color); background-color: var(--header-color);
} }

View File

@@ -1,11 +1,20 @@
<script lang="ts"> <script lang="ts">
import { tick } from "svelte" import { tick } from "svelte"
import { send } from "./chat"; import { send, whisper } from "./chat";
let input = ""; export let input = "";
async function keydown(e: KeyboardEvent) { async function keydown(e: KeyboardEvent) {
if(e.key === "Enter") { if(e.key === "Enter") {
console.log(e);
const tokens = input.trim().split(" ");
if(tokens[0] === '/w') {
const who = tokens[1];
const m = tokens.slice(2).join(' ');
whisper(who, m);
} else {
send(input); send(input);
}
e.preventDefault(); e.preventDefault();
await tick(); await tick();
input = ""; input = "";
@@ -13,19 +22,15 @@
} }
</script> </script>
<div class="container"> <div class="container p-relative d-flex pa-16 pt-0">
<textarea bind:value="{input}" on:keydown={keydown} class="input" /> <textarea bind:value="{input}" on:keydown={keydown} class="input grow-1 radius-16 border-0 pa-8 bg-700 color-300" />
</div> </div>
<style> <style>
.container { .container {
height: calc(2 * var(--message-line-height)); height: calc(2 * var(--message-line-height));
display: flex;
padding: 16px;
padding-top: 0;
margin-top: -12px; margin-top: -12px;
z-index: 100; z-index: 100;
position: relative
} }
.container::before, .container::after { .container::before, .container::after {
@@ -41,14 +46,8 @@
.container::after { right: 0; } .container::after { right: 0; }
.input { .input {
flex-grow: 1;
resize: none; resize: none;
border-radius: 16px;
border: none;
z-index: 100; z-index: 100;
padding: 8px;
background: var(--base-700);
color: var(--base-300);
} }
.input:focus { .input:focus {

View File

@@ -12,7 +12,10 @@
<div class="viewport" bind:this={viewport} on:scroll> <div class="viewport" bind:this={viewport} on:scroll>
<div class="contents" bind:this={contents}> <div class="contents" bind:this={contents}>
{#each messages as message} {#each messages as message}
<ChatMessage message={message.m} isHeader={message.isHeader} globalMode={showChannel} /> <ChatMessage message={message.m}
isHeader={message.isHeader}
globalMode={showChannel}
on:avatar:click on:avatar:dblclick />
{/each} {/each}
</div> </div>
</div> </div>

146
src/lib/QuickSetting.svelte Normal file
View File

@@ -0,0 +1,146 @@
<script lang="ts">
import { FontAwesomeIcon } from '@fortawesome/svelte-fontawesome';
import type { IconDefinition } from '@fortawesome/free-solid-svg-icons'
import { crossfade, fade } from 'svelte/transition';
import { expoInOut } from 'svelte/easing';
const [send, receive] = crossfade({
duration: 350,
easing: expoInOut
});
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
export let enabled: boolean = false;
export let icon: IconDefinition | undefined = undefined;
export let chevron: IconDefinition | undefined = undefined;
export let title: string;
export let subtitle: string = "";
export let showMenu: boolean = false;
export let menu: {
title: string,
items?: {
value: string,
enabled?: boolean,
}[]
} = { title: "Menu" };
export let column: number | undefined = undefined, row: number | undefined = undefined;
</script>
{#if showMenu}
<div class="modal-background clickable" transition:fade="{{easing: expoInOut}}"
on:click={() => showMenu = false} />
<div class="menu" in:send="{{key: 0}}" out:receive="{{key: 0}}">
<h1 class="no-select">{menu.title}</h1>
<div class="items d-flex col">
{#each menu.items || [] as item}
<button class="item mb-8 radius-24 border-0 px-24 py-24"
class:enabled="{item.enabled}"
on:click={() => dispatch("menu:click", item.value)}
>{item.value}</button>
{/each}
</div>
</div>
{:else}
<button in:send="{{key: 0}}" out:receive="{{key: 0}}"
style:grid-row-start="{row}"
style:grid-column-start="{column}"
class:enabled="{enabled}"
class="border-0 ma-8 radius-32 d-flex pa-8 no-select"
on:click>
<div class="button-icon px-8">
{#if icon}
<FontAwesomeIcon icon="{icon}" />
{/if}
</div>
<div class="button-text d-flex col grow-1">
<div class="title mb-2">
{title}
</div>
<div class="subtitle overflow-hidden">
{subtitle}
</div>
</div>
<div class="button-icon px-8">
{#if chevron}
<FontAwesomeIcon icon="{chevron}" />
{/if}
</div>
</button>
{/if}
<style lang="scss">
.hide {
visibility: hidden;
}
button {
height: auto;
align-items: center;
text-align: start;
/* padding-left: 24px; */
font-size: 1.2em;
transition: background-color;
transition-duration: 250ms;
font-size: min(calc(15px + 0.390625vw), 18.5px);
}
button.enabled {
background: var(--base-600);
color: var(--base-700);
}
button:not(.enabled) {
background: var(--base-800);
}
.button-text {
min-width: 0;
}
button .title {
font-weight: bold;
font-size: .9em;
text-overflow: ellipsis;
white-space: nowrap;
}
button .subtitle {
font-size: .85em;
font-weight: 300;
text-overflow: ellipsis;
white-space: nowrap;
}
.menu {
position: absolute;
left: 50%;
top: 50%;
width: calc(100vw - 4em);
max-width: 32em;
max-height: calc(100vh - 4em);
overflow: auto;
transform: translate(-50%,-50%);
padding: 1em;
border-radius: 12px;
background: var(--base-700);
z-index: 300;
}
.modal-background {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.3);
z-index: 300;
}
.items {
}
</style>

View File

@@ -0,0 +1,79 @@
<script lang="ts">
import QuickSetting from "./QuickSetting.svelte";
import { globalMode, themes, theme, type Theme } from "./settings";
import { faEarthEurope, faPaintRoller, faChevronRight } from '@fortawesome/free-solid-svg-icons';
import type { ComponentProps } from "svelte";
import { writable } from "svelte/store";
export let rows = 2;
export let cols = 2;
let showMenus = writable(Array.from(Array(rows*cols).keys()).map(() => false));
// TODO: figure out how deep object reactivity works, this doesn't feel clean
let settings: (ComponentProps<QuickSetting> & Record<string, any>)[];
$: {
settings = [
{
title: "Global Mode",
subtitle: $globalMode ? 'Enabled' : 'Disabled',
enabled: $globalMode,
icon: faEarthEurope,
click: () => $globalMode = !$globalMode,
},
{
title: "Theme",
subtitle: $theme,
enabled: true,
icon: faPaintRoller,
chevron: faChevronRight,
showMenu: $showMenus[1],
click: () => $showMenus[1] = !$showMenus[1],
menu: {
title: "Theme",
items: themes.map(t => ({
value: t,
enabled: $theme === t,
}))
},
menuClick: (theme: CustomEvent<Theme>) => $theme = theme.detail
},
{
title: "",
subtitle: '',
},
{
title: "",
subtitle: '',
}
];
}
</script>
<div class="quick-settings h-100 w-100"
style:--rows="{rows}"
style:--cols="{cols}">
{#each settings as setting, i}
<QuickSetting
row="{Math.floor(i / rows) + 1}"
column="{(i % rows) + 1}"
enabled="{setting.enabled}"
icon="{setting.icon}"
chevron="{setting.chevron}"
title="{setting.title}"
bind:showMenu="{$showMenus[i]}"
menu="{setting.menu}"
subtitle="{setting.subtitle}"
on:click="{setting.click}"
on:menu:click="{setting.menuClick}" />
{/each}
</div>
<style lang="scss">
.quick-settings {
display: grid;
grid-template-rows: repeat(var(--rows), calc(100% / var(--rows)));
grid-template-columns: repeat(var(--cols), calc(100% / var(--cols)));
}
</style>

View File

@@ -3,19 +3,54 @@
export let mobile = false; export let mobile = false;
import { FontAwesomeIcon as FA } from '@fortawesome/svelte-fontawesome'; import { FontAwesomeIcon as FA } from '@fortawesome/svelte-fontawesome';
import { faHome, faPerson } from '@fortawesome/free-solid-svg-icons'; import { faHome, faPerson, faCircle } from '@fortawesome/free-solid-svg-icons';
import { channels, room } from './chat';
import Avatar from './Avatar.svelte';
let selected = "channels";
</script> </script>
<div class="sidebar h-100vh d-flex" bind:clientWidth class:mobile="{mobile}"> <div class="sidebar h-100vh d-flex" bind:clientWidth class:mobile="{mobile}">
<div class="overlay" /> <div class="overlay" />
<div class="servers d-flex col"> <div class="servers shrink-0 d-flex col">
<button class="selected"> <button
class:selected="{selected === 'whispers'}"
on:click="{() => selected = 'whispers'}">
<FA size="2x" icon="{faPerson}" /> <FA size="2x" icon="{faPerson}" />
</button> </button>
<button> <button
class:selected="{selected === 'channels'}"
on:click="{() => selected = 'channels'}">
<FA size="2x" icon="{faHome}" /> <FA size="2x" icon="{faHome}" />
</button> </button>
</div> </div>
{#if selected === 'whispers'}
<div class="whispers grow-1 mw-0"></div>
{:else if selected === 'channels'}
<div class="channels grow-1 d-flex col mw-0 py-8">
{#each $channels.sort((a, b) => b.viewCount - a.viewCount) as channel}
<a href="{channel.name}"
class="channel d-flex align-center mb-8 mx-8 pa-6 radius-12"
class:selected="{$room === channel.name.toLowerCase()}">
<Avatar username="{channel.name}"
avatar="{channel.avatar}" />
<div class="channel-name mw-0 ellipsis ml-8">
{channel.name}
</div>
{#if false && channel.live}
<div class="channel-live ml-2 mt-8 color-red">
<FA icon="{faCircle}" size="xs" />
</div>
{/if}
<div class="spacer"/>
<div class="channel-viewers px-6 py-2 radius-pill">
{channel.viewCount + channel.viewCountRTC}
</div>
</a>
{/each}
</div>
{/if}
</div> </div>
<style lang="scss"> <style lang="scss">
@@ -48,6 +83,15 @@
pointer-events: var(--should-display); pointer-events: var(--should-display);
} }
.channel-live {
align-self: normal;
font-size: 8px;
}
.channel-viewers {
font-weight: 700;
}
.servers { .servers {
width: var(--sidebar-server-select-width); width: var(--sidebar-server-select-width);
background-color: var(--base-500); background-color: var(--base-500);
@@ -75,6 +119,29 @@
background-color: var(--base-700); background-color: var(--base-700);
color: var(--base-300); color: var(--base-300);
} }
}
}
a {
background-color: var(--base-400);
color: var(--base-700);
transition-property: background-color, color, border-radius;
transition-duration: 80ms;
transition-timing-function: ease-out;
&:hover {
background-color: var(--base-300);
}
&.selected {
background-color: var(--base-700);
color: var(--base-300);
& .channel-viewers {
color: var(--red);
}
} }
} }
</style> </style>

View File

@@ -47,13 +47,13 @@
<p class="no-select">Method:</p> <p class="no-select">Method:</p>
<div class="signin-buttons grow-1 d-flex"> <div class="signin-buttons grow-1 d-flex">
<button <button
class="grow-1" class="p-relative grow-1"
disabled disabled
on:click={() => (selectedMethod = 'userpass')} on:click={() => (selectedMethod = 'userpass')}
class:selected={selectedMethod == 'userpass'}>Username</button class:selected={selectedMethod == 'userpass'}>Username</button
> >
<button <button
class="grow-1" class="p-relative grow-1"
on:click={() => (selectedMethod = 'token')} on:click={() => (selectedMethod = 'token')}
class:selected={selectedMethod == 'token'}>Token</button class:selected={selectedMethod == 'token'}>Token</button
> >
@@ -67,6 +67,7 @@
<textarea <textarea
class:error class:error
spellcheck="false" spellcheck="false"
class="w-100 bg-800 radius-4 pa-8"
on:change={() => (error = false)} on:change={() => (error = false)}
placeholder="Paste token here" placeholder="Paste token here"
bind:value={token} bind:value={token}
@@ -90,7 +91,6 @@
.selected { .selected {
background-color: var(--base-600); background-color: var(--base-600);
color: var(--base-700); color: var(--base-700);
position: relative;
margin-left: -32px; margin-left: -32px;
margin-right: -32px; margin-right: -32px;
border-radius: 24px; border-radius: 24px;
@@ -129,13 +129,9 @@
} }
textarea { textarea {
width: 100%;
max-width: 100%; max-width: 100%;
height: 150px; height: 150px;
background-color: var(--base-800);
border-radius: 4px;
box-sizing: border-box; box-sizing: border-box;
padding: 8px;
word-break: break-all; word-break: break-all;
} }

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import Header from './Header.svelte'; import Header from './Header.svelte';
import QuickSettings from './QuickSettings.svelte';
import { globalMode, theme } from './settings'; import { globalMode, theme } from './settings';
import { mentions } from './chat'; import { mentions } from './chat';
import MessageView from './MessageView.svelte'; import MessageView from './MessageView.svelte';
@@ -52,8 +53,8 @@
} }
</script> </script>
<div transition:slide class="top screen" bind:clientHeight> <div transition:slide class="top screen p-relative d-flex col" bind:clientHeight>
<div class="about"> <div class="about p-absolute">
<About /> <About />
</div> </div>
@@ -63,42 +64,11 @@
<div class="contents"> <div class="contents">
<div class="settings"> <div class="settings">
<div class="quick-settings"> <div class="quick-settings">
<div class="button" on:click="{globalModeClick}" <QuickSettings />
class:enabled="{$globalMode}">
<div class="button-icon">
<FontAwesomeIcon icon="{faEarthEurope}" />
</div>
<div class="button-text">
<div class="title">
Global Mode
</div>
<div class="subtitle">
{$globalMode ? 'Enabled' : 'Disabled'}
</div>
</div>
</div> </div>
<div class="button">
<div class="button-icon">
<FontAwesomeIcon icon="{faPaintRoller}" />
</div>
<div class="button-text">
<div class="title">
Theme
</div>
<div class="subtitle">
{$theme}
</div>
</div>
<div class="button-icon">
<FontAwesomeIcon icon="{faChevronRight}" />
</div>
</div>
<div class="button" />
<div class="button" />
</div>
<div class="spacer"/> <div class="spacer"/>
<div class="options-bar"> <div class="options-bar">
<div class="button enabled">Edit</div> <div class="button enabled">Edit</div>
<div class="button enabled">Theme</div> <div class="button enabled">Theme</div>
@@ -128,7 +98,9 @@
</div> </div>
</div> </div>
<style> <style lang="scss">
@use "$lib/styles/utils";
:global(body) { :global(body) {
--top-screen-cutout-height: 20vh; --top-screen-cutout-height: 20vh;
--top-screen-background-color: var(--base-700); --top-screen-background-color: var(--base-700);
@@ -139,21 +111,17 @@
height: calc(100vh - var(--top-screen-cutout-height)); height: calc(100vh - var(--top-screen-cutout-height));
max-height: calc(100vh - var(--top-screen-cutout-height)); max-height: calc(100vh - var(--top-screen-cutout-height));
background-color: var(--top-screen-background-color); background-color: var(--top-screen-background-color);
display: flex;
flex-direction: column;
position: relative;
} }
:global(.container.mobile) .about {
display: none;
}
.about { .about {
position: absolute;
z-index: 500; z-index: 500;
bottom: calc(-1 * var(--top-screen-cutout-height)); bottom: calc(-1 * var(--top-screen-cutout-height));
opacity: var(--about-opacity); opacity: var(--about-opacity);
display: var(--about-display); display: var(--about-display);
:global(.container.mobile) & {
display: none;
}
} }
:global(.container.mobile) .settings { :global(.container.mobile) .settings {
@@ -161,8 +129,10 @@
} }
:global(.container:not(.mobile)) .settings { :global(.container:not(.mobile)) .settings {
max-width: 500px;
flex-grow: 1; flex-grow: 1;
flex-basis: 0; flex-basis: 0;
margin-right: 8px;
} }
:global(.container.mobile) .contents { :global(.container.mobile) .contents {
@@ -180,10 +150,17 @@
} }
.quick-settings { .quick-settings {
display: grid; /* display: grid; */
grid-template-rows: repeat(2, 1fr); /* grid-template-rows: repeat(2, 1fr); */
grid-template-columns: repeat(2, 50%); /* grid-template-columns: repeat(2, 50%); */
height: 35%; height: 35%;
:global(.container:not(.mobile)) & {
height: 150px;
@extend .mt-18;
}
} }
.button { .button {
margin: 8px; margin: 8px;
@@ -235,6 +212,13 @@
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
height: 10%; height: 10%;
:global(.container:not(.mobile)) & {
height: 50px;
& .button {
font-size: 16px;
}
}
} }
.options-bar .button { .options-bar .button {
margin-top: 8px; margin-top: 8px;

View File

@@ -20,6 +20,10 @@ export function send(m: string | OutgoingMessage) {
chat.sendMessage(m); chat.sendMessage(m);
} }
export function whisper(who: string, m: string) {
chat.sendWhisper(who, m);
}
let setConnected: Subscriber<boolean> | undefined; let setConnected: Subscriber<boolean> | undefined;
export const connected = readable<boolean>(false, (setf) => { export const connected = readable<boolean>(false, (setf) => {
setConnected = setf; setConnected = setf;
@@ -97,10 +101,46 @@ globalMode.subscribe((v) => {
export const shouldTweet = writable(false); export const shouldTweet = writable(false);
export type User = {
color: string;
page: string;
username: string;
unum: number;
};
export type Channel = Record<
| 'avatar'
| 'channel'
| 'cover'
| 'desc'
| 'name'
| 'src'
| 'thumbnail'
| 'title'
| 'type'
| 'url'
| 'user',
string
> &
Record<'live' | 'nsfw', boolean> &
Record<'viewCount' | 'viewCountRTC', number> & {
users: Record<string, { watching: string[]; data: User }>[];
};
let setChannels: Subscriber<any> | undefined;
export const channels = readable<Channel[]>(chat.channelViewers, (setf) => {
setChannels = setf;
return () => {
setChannels = undefined;
};
});
chat.onUpdateUsernames = (vs) => setChannels?.(vs) as any;
// Essentially a shared_ptr idiom. When nobody is subscribed to this, the chat // Essentially a shared_ptr idiom. When nobody is subscribed to this, the chat
// d/c's. // d/c's.
export const chat_lock = readable(null, () => { export const chat_lock = readable(null, () => {
const release_connected = connected.subscribe(() => {}); const release_connected = connected.subscribe(() => {});
const release_channels = channels.subscribe(() => {});
const creds = get(credentials); const creds = get(credentials);
@@ -111,6 +151,7 @@ export const chat_lock = readable(null, () => {
setConnected?.(false); setConnected?.(false);
release_connected(); release_connected();
release_channels();
}; };
}); });

View File

@@ -2,6 +2,7 @@ import { writable } from 'svelte/store';
export const globalMode = writable(true); export const globalMode = writable(true);
export type Theme = 'solarized-light' | 'solarized-dark'; export const themes = ['solarized-light', 'solarized-dark'] as const;
export type Theme = (typeof themes)[number];
export const theme = writable<Theme>('solarized-light'); export const theme = writable<Theme>('solarized-light');

View File

@@ -14,6 +14,16 @@
.no-select { .no-select {
user-select: none; user-select: none;
} }
.select-text {
user-select: text;
}
.select-all {
user-select: all;
}
.select-contain {
user-select: contain;
}
.clickable { .clickable {
cursor: pointer; cursor: pointer;
@@ -27,6 +37,10 @@
flex-grow: 0; flex-grow: 0;
} }
.shrink-0 {
flex-shrink: 0;
}
.basis-0 { .basis-0 {
flex-basis: 0; flex-basis: 0;
} }
@@ -52,8 +66,10 @@
} }
} }
.d-flex { @each $i in (block, inline, inline-block, flex, grid, contents) {
display: flex; .d-#{$i} {
display: #{$i};
}
} }
.col { .col {
@@ -72,9 +88,16 @@
width: 100vw; width: 100vw;
} }
.mw-0 { min-width: 0 }
.w-100 { width: 100%; } .w-100 { width: 100%; }
.h-100 { height: 100%; } .h-100 { height: 100%; }
.ellipsis {
text-overflow: ellipsis;
overflow: hidden;
}
.hide-scrollbar { .hide-scrollbar {
/* hide scrollbar */ /* hide scrollbar */
-ms-overflow-style: none !important; -ms-overflow-style: none !important;
@@ -89,3 +112,123 @@
margin-top: 2px; margin-top: 2px;
margin-bottom: 2px; margin-bottom: 2px;
} }
@mixin gen-directions($prefix, $prop, $i, $sz) {
.#{$prefix}l-#{$i} {
#{$prop}-left: #{$sz} !important;
}
.#{$prefix}r-#{$i} {
#{$prop}-right: #{$sz} !important;
}
.#{$prefix}x-#{$i} {
#{$prop}-left: #{$sz};
#{$prop}-right: #{$sz};
}
.#{$prefix}t-#{$i} {
#{$prop}-top: #{$sz} !important;
}
.#{$prefix}b-#{$i} {
#{$prop}-bottom: #{$sz} !important;
}
.#{$prefix}y-#{$i} {
#{$prop}-top: #{$sz};
#{$prop}-bottom: #{$sz};
}
.#{$prefix}a-#{$i} {
#{$prop}-left: #{$sz};
#{$prop}-right: #{$sz};
#{$prop}-top: #{$sz};
#{$prop}-bottom: #{$sz};
}
}
@mixin border-directions($i ,$sz) {
.radius-tl-#{$i} {
border-top-left-radius: #{$sz};
}
.radius-tr-#{$i} {
border-top-right-radius: #{$sz};
}
.radius-t-#{$i} {
@extend .radius-tl-#{$i};
@extend .radius-tr-#{$i};
}
.radius-bl-#{$i} {
border-bottom-left-radius: #{$sz};
}
.radius-br-#{$i} {
border-bottom-right-radius: #{$sz};
}
.radius-b-#{$i} {
@extend .radius-bl-#{$i};
@extend .radius-br-#{$i};
}
.radius-l-#{$i} {
@extend .radius-tl-#{$i};
@extend .radius-bl-#{$i};
}
.radius-r-#{$i} {
@extend .radius-tr-#{$i};
@extend .radius-br-#{$i};
}
.radius-#{$i} {
@extend .radius-l-#{$i};
@extend .radius-r-#{$i};
}
}
$spacings: (0 0px, 1 1px, 2 2px, 4 4px, 6 6px, 8 8px, 12 12px, 16 16px, 18 18px, 24 24px, 32 32px, 36 36px, 48 48px);
@each $i, $sz in $spacings {
@include gen-directions(p, padding, $i, $sz);
@include gen-directions(m, margin, $i, $sz);
@include border-directions($i, $sz);
}
@include border-directions(pill, 9999px);
.border-0 { border: 0; }
$colors: (100 base-100, 200 base-200, 300 base-300, 400 base-400, 500 base-500, 600 base-600, 700 base-700, 800 base-800, yellow yellow, orange orange, red red, magenta magenta, violet violet, blue blue, cyan cyan, green green);
@each $i, $name in $colors {
.color-#{$i} {
color: var(--#{$name}) !important;
}
.bg-#{$i} {
background-color: var(--#{$name});
}
}
@each $i in (start, end, center, justify, left, right) {
.text-#{$i} {
text-align: #{$i};
}
}
.super {
vertical-align: super;
}
@each $i in (hidden, scroll) {
.overflow-x-#{$i} {
overflow-x: #{$i};
}
.overflow-y-#{$i} {
overflow-y: #{$i};
}
.overflow-#{$i} {
@extend .overflow-x-#{$i};
@extend .overflow-y-#{$i};
}
}
@each $i in (relative, absolute, fixed, sticky) {
.p-#{$i} {
position: #{$i};
}
}

View File

@@ -20,6 +20,8 @@
import { clickOutside } from "$lib/ui/clickOutside"; import { clickOutside } from "$lib/ui/clickOutside";
import { theme } from "$lib/settings";
import { offset, flip, shift } from "@floating-ui/dom"; import { offset, flip, shift } from "@floating-ui/dom";
import { createFloatingActions } from "svelte-floating-ui"; import { createFloatingActions } from "svelte-floating-ui";
import UserSettings from "$lib/UserSettings.svelte"; import UserSettings from "$lib/UserSettings.svelte";
@@ -58,7 +60,9 @@
updateCredentials(undefined); updateCredentials(undefined);
} }
let mobile = primaryInput === 'touch'; let viewportWidth;
let mobile: boolean;
$: mobile = (primaryInput === 'touch' || viewportWidth <= 550);
let scrolledToTop = false, scrolledToBottom = false, let scrolledToTop = false, scrolledToBottom = false,
scrolledToLeft = false, scrolledToRight = false; scrolledToLeft = false, scrolledToRight = false;
@@ -140,8 +144,20 @@
} }
} }
} }
function updateTheme(node, theme) {
node.className = theme;
return {
update: (theme) => {
node.className = theme;
}
}
}
</script> </script>
<svelte:body use:updateTheme="{$theme}" />
<svelte:window bind:innerWidth={viewportWidth}/>
<div class="container hide-scrollbar snap-y h-100vh" <div class="container hide-scrollbar snap-y h-100vh"
class:mobile="{mobile}" class:mobile="{mobile}"
bind:this="{container}" on:scroll="{onScroll}" bind:this="{container}" on:scroll="{onScroll}"