feat: 1-to-1 message render + web data-lake backend

This commit is contained in:
hh committed 2026-05-31 01:45:05 +02:00
1 parent f0afb7ec5b
commit 75425d1bee
110 files changed
+10199 -54

No files matched your search

@@ -0,0 +1,104 @@
<script lang="ts">
import { type AvatarKind, loadAvatar } from "$lib/api/avatars";
import { initials as toInitials } from "$lib/format/peer";
interface Props {
avatar?: { kind: AvatarKind; id: number } | null;
colorKey?: number;
deleted?: boolean;
hasAvatar?: boolean;
name: string;
size?: number;
}
let {
name,
colorKey = 0,
size = 3.375,
deleted = false,
avatar = null,
hasAvatar = false,
}: Props = $props();
const gradients = [
["#ff885e", "#ff516a"],
["#ffcd6a", "#ffa85c"],
["#82b1ff", "#665fff"],
["#a0de7e", "#54cb68"],
["#53edd6", "#28c9b7"],
["#72d5fd", "#2a9ef1"],
["#e0a2f3", "#d669ed"],
];
const pair = $derived(gradients[Math.abs(colorKey) % gradients.length]);
const initials = $derived(toInitials(name));
let url = $state<string | null>(null);
$effect(() => {
url = null;
if (deleted || !(hasAvatar && avatar)) {
return;
}
let active = true;
loadAvatar(avatar.kind, avatar.id).then((resolved) => {
if (active) {
url = resolved;
}
});
return () => {
active = false;
};
});
</script>
<div
class="Avatar"
class:deleted
style="
--avatar-size: {size}rem;
--avatar-from: {pair[0]};
--avatar-to: {pair[1]};
"
>
{#if url}
<img class="photo" src={url} alt={name}>
{:else}
<span class="initials">{initials}</span>
{/if}
</div>
<style lang="scss">
.Avatar {
position: relative;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
overflow: hidden;
width: var(--avatar-size);
height: var(--avatar-size);
border-radius: 50%;
font-size: calc(var(--avatar-size) * 0.4);
font-weight: var(--font-weight-medium);
line-height: 1;
color: var(--color-white);
text-transform: uppercase;
user-select: none;
background-image: linear-gradient(var(--avatar-from), var(--avatar-to));
&.deleted {
background-image: none;
background-color: var(--color-deleted-account);
}
}
.photo {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
@@ -0,0 +1,211 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { HTMLButtonAttributes } from "svelte/elements";
import { ripple } from "$lib/actions/ripple";
type Variant =
| "primary"
| "secondary"
| "gray"
| "danger"
| "green"
| "translucent"
| "text";
interface Props extends HTMLButtonAttributes {
children: Snippet;
fluid?: boolean;
loading?: boolean;
pill?: boolean;
round?: boolean;
smaller?: boolean;
tiny?: boolean;
variant?: Variant;
}
let {
variant = "primary",
round = false,
tiny = false,
smaller = false,
pill = false,
fluid = false,
loading = false,
type = "button",
disabled = false,
children,
...rest
}: Props = $props();
</script>
<button
class="Button {variant}"
class:round
class:tiny
class:smaller
class:pill
class:fluid
class:loading
class:disabled
{type}
{disabled}
use:ripple
{...rest}
>
{@render children()}
</button>
<style lang="scss">
.Button {
--button-text-color: white;
--button-background-color: transparent;
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
height: 3rem;
padding: 0.625rem;
border: 0;
border-radius: var(--border-radius-button);
font-weight: var(--font-weight-medium);
font-size: 1rem;
line-height: 1.2;
color: var(--button-text-color);
text-decoration: none;
text-transform: uppercase;
background-color: var(--button-background-color);
outline: none;
transition:
background-color 0.2s,
color 0.2s,
opacity 0.2s;
&.disabled {
cursor: default;
opacity: 0.5;
pointer-events: none;
}
&:not(.disabled):active,
&:not(.disabled):focus {
color: var(--button-active-text-color, var(--button-text-color));
background-color: var(
--button-active-background-color,
var(--button-background-color)
);
}
@media (hover: hover) {
&:not(.disabled):hover {
color: var(--button-active-text-color, var(--button-text-color));
background-color: var(
--button-active-background-color,
var(--button-background-color)
);
}
}
&.primary {
--ripple-color: rgba(0, 0, 0, 0.08);
--button-text-color: var(--color-white);
--button-background-color: var(--color-primary);
--button-active-background-color: var(--color-primary-shade);
}
&.secondary {
--ripple-color: rgba(0, 0, 0, 0.08);
--button-text-color: var(--color-text-secondary);
--button-background-color: var(--color-background);
--button-active-text-color: white;
--button-active-background-color: var(--color-primary);
}
&.gray {
--ripple-color: rgba(0, 0, 0, 0.08);
--button-text-color: var(--color-text-secondary);
--button-background-color: var(--color-background);
--button-active-text-color: var(--color-primary);
}
&.danger {
--ripple-color: rgba(var(--color-error-rgb), 0.16);
--button-text-color: var(--color-error);
--button-background-color: var(--color-background);
--button-active-text-color: var(--color-white);
--button-active-background-color: var(--color-error);
}
&.green {
--ripple-color: rgba(0, 0, 0, 0.08);
--button-text-color: var(--color-white);
--button-background-color: var(--color-green);
--button-active-background-color: var(--color-green-darker);
}
&.translucent {
--ripple-color: var(--color-interactive-element-hover);
--button-text-color: var(--color-text-secondary);
--button-background-color: transparent;
--button-active-background-color: var(--color-interactive-element-hover);
}
&.text {
--button-background-color: transparent;
--button-text-color: var(--color-primary);
--button-active-background-color: rgba(var(--color-primary-shade-rgb), 0.08);
text-transform: none;
}
&.fluid {
padding-right: 1.75rem;
padding-left: 1.75rem;
}
&.pill {
padding-right: 1.75rem;
padding-left: 1.75rem;
border-radius: 1.75rem;
text-transform: none;
}
&.smaller {
height: 2.5rem;
padding: 0.3125rem;
&.round {
width: 2.5rem;
}
}
&.tiny {
height: 2.25rem;
padding: 0.4375rem;
border-radius: var(--border-radius-button-tiny);
font-size: 0.875rem;
&.round {
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
}
}
&.round {
width: 3rem;
border-radius: 50%;
:global(.icon) {
font-size: 1.5rem;
}
}
}
</style>
@@ -0,0 +1,41 @@
<script lang="ts">
interface Props {
description?: string;
title: string;
}
let { title, description }: Props = $props();
</script>
<div class="root">
<div class="title">{title}</div>
{#if description}
<div class="description">{description}</div>
{/if}
</div>
<style lang="scss">
.root {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 80%;
}
.title {
max-width: 100%;
margin-bottom: 0.125rem;
font-size: 1.25rem;
text-align: center;
overflow-wrap: anywhere;
}
.description {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,15 @@
<script lang="ts">
interface Props {
class?: string;
name: string;
size?: string;
}
let { name, size, class: extra = "" }: Props = $props();
</script>
<i
class="icon icon-{name} {extra}"
style={size ? `font-size: ${size}` : undefined}
aria-hidden="true"
></i>
@@ -0,0 +1,58 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { ripple } from "$lib/actions/ripple";
interface Props {
children: Snippet;
onclick?: () => void;
selected?: boolean;
}
let { selected = false, onclick, children }: Props = $props();
</script>
<button
type="button"
class="ListItem-button"
class:selected
use:ripple
{onclick}
>
{@render children()}
</button>
<style lang="scss">
.ListItem-button {
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
width: 100%;
min-height: 3rem;
padding: 0.5rem 0.75rem;
border: 0;
border-radius: 0.625rem;
font-size: 1rem;
text-align: start;
color: var(--color-text);
background-color: transparent;
transition: background-color 0.15s ease;
--ripple-color: var(--color-interactive-element-hover);
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
&.selected {
color: var(--color-white);
background-color: var(--color-chat-active);
}
}
</style>
@@ -0,0 +1,22 @@
<script lang="ts">
interface Props {
circle?: boolean;
height?: string;
radius?: string;
width?: string;
}
let {
width = "100%",
height = "1rem",
radius = "0.375rem",
circle = false,
}: Props = $props();
</script>
<div
class="skeleton"
style:width
style:height
style:border-radius={circle ? "50%" : radius}
></div>
@@ -0,0 +1,39 @@
<script lang="ts">
interface Props {
color?: "blue" | "white" | "gray";
size?: string;
}
let { size = "2rem", color = "blue" }: Props = $props();
</script>
<div class="Spinner {color}" style="--spinner-size: {size}"></div>
<style lang="scss">
.Spinner {
width: var(--spinner-size);
height: var(--spinner-size);
background-repeat: no-repeat;
background-position: center;
background-size: contain;
animation: spinner-rotate 800ms linear infinite;
&.blue {
background-image: var(--spinner-blue-data);
}
&.white {
background-image: var(--spinner-white-data);
}
&.gray {
background-image: var(--spinner-gray-data);
}
}
@keyframes spinner-rotate {
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,57 @@
<script lang="ts">
import { toasts } from "$lib/stores/toasts.svelte";
</script>
<div class="Notification-container">
{#each toasts.items as toast (toast.id)}
<button
type="button"
class="Notification {toast.type}"
onclick={() => toasts.dismiss(toast.id)}
>
{toast.message}
</button>
{/each}
</div>
<style lang="scss">
.Notification-container {
pointer-events: none;
position: fixed;
z-index: var(--z-notification);
right: 0;
bottom: 1.25rem;
left: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.Notification {
pointer-events: auto;
cursor: pointer;
max-width: 22rem;
padding: 0.75rem 1.25rem;
border: 0;
border-radius: var(--border-radius-toast);
font-size: 0.9375rem;
text-align: start;
color: var(--color-white);
background-color: var(--color-toast-background);
backdrop-filter: blur(10px);
box-shadow: 0 0.25rem 0.75rem var(--color-default-shadow);
&.error {
background-color: var(--color-error);
}
&.success {
background-color: var(--color-green);
}
}
</style>