web ui redesign

This commit is contained in:
tezlm 2023-07-30 00:55:38 -07:00
parent 6bbd874162
commit 594319a32d
Signed by: tezlm
GPG key ID: 649733FCD94AFBBA
19 changed files with 673 additions and 176 deletions

View file

@ -210,7 +210,7 @@ const aclEvent = {
Summary of core event types
- `x.user`: creates a user
- `x.file`: creates a file
- `x.file`: creates a file (also the only event able to reference blobs)
- `x.redact`: removes another event
- `x.acl`: TODO
- `x.annotate`: TODO

View file

@ -5,27 +5,60 @@
import Notes from "./scenes/Notes.svelte";
import Forum from "./scenes/Forum";
import Settings from "./scenes/Settings.svelte";
import Popup from "./scenes/Files/Popup.svelte";
import Audio from "./status/Audio.svelte";
import Status from "./status/Status.svelte";
import { api } from "./lib/api";
import type { Event } from "./lib/api";
import { events } from "./events";
import { onDestroy } from "svelte";
let selected;
let options;
function setScene() {
const url = new URL(location.hash.slice(1), location.origin);
options = url.searchParams;
selected = getScene(url.pathname);
interface Parts {
sidebar: boolean,
pins: boolean,
statusMusic: Event | null,
popup: Event | null,
}
function getScene(name: string) {
switch (name) {
case "/files": return Files;
case "/links": return Links;
case "/notes": return Notes;
case "/settings": return Settings;
case "/forum": return Forum;
default: return Home;
let options;
let parts: Parts = {
sidebar: true,
pins: true,
statusMusic: null,
popup: null,
};
let selected;
const items = [
{ type: "home", id: "home", name: "home" },
{ type: "files", id: "files", name: "files" },
{ type: "links", id: "links", name: "links" },
{ type: "notes", id: "notes", name: "notes" },
{ type: "forum", id: "forum", name: "forum" },
{ type: "settings", id: "settings", name: "settings" },
];
function updateScene() {
const url = new URL(location.hash.slice(1), location.origin);
options = url.searchParams;
const idx = items.findIndex(i => i.id === url.pathname.slice(1));
selected = items[idx === -1 ? 0 : idx];
}
function getComponent(type: string) {
switch (type) {
case "home": return Home;
case "files": return Files;
case "links": return Links;
case "notes": return Notes;
case "settings": return Settings;
case "forum": return Forum;
default: throw new Error("invalid/unsupported type");
}
}
updateScene();
// this always bothered me, but there was never a good way to sync animations... until now
const syncedAnims = new Set(["hover"]);
function syncAnimations(e: AnimationEvent) {
@ -36,22 +69,148 @@
}
}
window.addEventListener("hashchange", setScene);
window.addEventListener("load", setScene);
function handleOpen(event: Event) {
const mime = event.derived?.file?.mime ?? "application/octet-stream";
console.log("handle open", event, mime);
if (mime.startsWith("audio/")) {
parts.statusMusic = event;
} else {
parts.popup = event;
}
}
function handleClose(thing: string) {
console.log("close " + thing)
switch (thing) {
case "statusMusic": return parts.statusMusic = null;
}
}
events.on("open", handleOpen);
events.on("close", handleClose);
onDestroy(() => {
events.off("open", handleOpen);
events.off("close", handleClose);
});
</script>
<div id="wrapper">
<header id="header">
<nav>
<a href="#/home">home</a>
<a href="#/files">files</a>
<a href="#/links">links</a>
<a href="#/notes">notes</a>
<a href="#/forum">forum</a>
<a href="#/settings">settings</a>
</nav>
</header>
<header id="header">header goes here</header>
{#if parts.pins}
<nav id="pins"></nav>
{/if}
<nav id="nav">
{#each items as item (item.id)}
<a class:selected={item.id === selected.id} href="#/{item.id}"><div>{item.name}</div></a>
{/each}
<a href="#" on:click|preventDefault={() => parts.sidebar = !parts.sidebar}><div>sidebar</div></a>
<a href="#" on:click|preventDefault={() => parts.pins = !parts.pins}><div>pins</div></a>
</nav>
<main id="main">
<svelte:component this={selected} {options} />
<svelte:component this={getComponent(selected.type)} {options} />
</main>
{#if parts.sidebar}
<aside id="side"></aside>
{/if}
<footer id="status">
{#if parts.statusMusic}
<div>
<Audio event={parts.statusMusic} small={!parts.pins} />
</div>
{/if}
<div><Status /></div>
</footer>
<header id="nav-header"></header>
</div>
<svelte:window on:animationstart={syncAnimations} />
{#if parts.popup}
<Popup event={parts.popup} close={() => parts.popup = null}/>
{/if}
<svelte:window on:animationstart={syncAnimations} on:hashchange={updateScene} />
<style lang="scss">
#wrapper {
display: grid;
grid-template-rows: auto 48px 1fr 1fr auto;
grid-template-columns: fit-content(64px) 240px 1fr auto;
grid-template-areas:
"pin notice notice notice"
"pin nav-header header header"
"pin nav main side"
"pin nav main side"
"status status main side";
height: 100vh;
width: 100vw;
gap: 1px;
background: var(--borders);
}
#pins {
grid-area: pin;
background: var(--bg-quartiary);
width: 64px;
}
#nav {
grid-area: nav;
display: flex;
flex-direction: column;
background: var(--bg-secondary);
padding: 2px 0;
& > a {
padding: 2px 4px;
& > div {
padding: 4px;
border-radius: 2px;
}
&:is(:hover, :focus-visible) > div {
background: #ffffff22;
}
&.selected > div {
background: #ffffff33;
}
}
}
#side {
grid-area: side;
background: var(--bg-secondary);
width: 240px;
}
#status {
grid-area: status;
background: var(--bg-tertiary);
width: 240px;
& > div + div {
border-top: solid var(--borders) 1px;
}
}
#pins ~ #status {
width: calc(64px + 240px);
}
#nav-header {
grid-area: nav-header;
background: var(--bg-tertiary);
// border-top-left-radius: 8px;
}
#main {
grid-area: main;
background: var(--bg-primary);
overflow-x: hidden;
}
#header {
grid-area: header;
background: var(--bg-tertiary);
display: flex;
align-items: center;
padding: 8px;
}
</style>

38
web/Clock.svelte Normal file
View file

@ -0,0 +1,38 @@
<script lang="ts">
import { onDestroy } from "svelte";
export let speed = 1;
let t = 0;
$: minuteX = Math.cos(t * 6) * 10;
$: minuteY = Math.sin(t * 6) * 10;
$: hourX = Math.cos(t) * 6;
$: hourY = Math.sin(t) * 6;
const interval = setInterval(() => t += .03 * speed, 1000 / 60);
onDestroy(() => clearInterval(interval));
</script>
<svg viewBox="-2 -2 24 24">
<path style="stroke: var(--color-accent)" d="
M {hourX + 10} {hourY + 10}
L 10,10
L {minuteX + 10} {minuteY + 10}
" />
<path style="stroke: var(--fg-text)" d="
M 0,10
A 5 5 180 0 0 20 10
A 5 5 180 0 0 0 10
" />
</svg>
<style lang="scss">
svg {
height: 24px;
width: 24px;
& > path {
fill: none;
stroke: white;
stroke-width: 2px;
stroke-linejoin: round;
}
}
</style>

3
web/DESIGN.md Normal file
View file

@ -0,0 +1,3 @@
# design guidelines
tbd

View file

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View file

@ -1,6 +1,6 @@
<svelte:options immutable />
<script lang="ts">
import brokenImageUrl from "../broken.png";
import brokenImageUrl from "../assets/broken.png";
import { api } from "../lib/api";
import type { Event } from "../lib/api";
export let event: Event;

10
web/events.ts Normal file
View file

@ -0,0 +1,10 @@
import EventEmitter from "events";
import TypedEmitter from "typed-emitter";
import type { Event } from "./lib/api";
type Events = {
open: (event: Event) => void,
close: (thing: "statusMusic") => void,
}
export const events = new EventEmitter() as TypedEmitter<Events>

View file

@ -14,6 +14,7 @@
"dependencies": {
"@noble/ed25519": "^2.0.0",
"canonicalize": "^2.0.0",
"carbon-icons-svelte": "^12.1.0",
"events": "^3.3.0",
"typed-emitter": "^2.1.0",
"uint8-to-base64": "^0.2.0"
@ -21,6 +22,7 @@
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^2.4.2",
"@tsconfig/svelte": "^5.0.0",
"sass": "^1.64.1",
"svelte": "^4.0.3",
"svelte-check": "^3.4.4",
"tslib": "^2.6.0",

View file

@ -11,6 +11,9 @@ dependencies:
canonicalize:
specifier: ^2.0.0
version: 2.0.0
carbon-icons-svelte:
specifier: ^12.1.0
version: 12.1.0
events:
specifier: ^3.3.0
version: 3.3.0
@ -28,12 +31,15 @@ devDependencies:
'@tsconfig/svelte':
specifier: ^5.0.0
version: 5.0.0
sass:
specifier: ^1.64.1
version: 1.64.1
svelte:
specifier: ^4.0.3
version: 4.0.3
svelte-check:
specifier: ^3.4.4
version: 3.4.4(svelte@4.0.3)
version: 3.4.4(sass@1.64.1)(svelte@4.0.3)
tslib:
specifier: ^2.6.0
version: 2.6.0
@ -42,7 +48,7 @@ devDependencies:
version: 5.1.6
vite:
specifier: ^4.3.9
version: 4.3.9
version: 4.3.9(sass@1.64.1)
packages:
@ -322,7 +328,7 @@ packages:
'@sveltejs/vite-plugin-svelte': 2.4.2(svelte@4.0.3)(vite@4.3.9)
debug: 4.3.4
svelte: 4.0.3
vite: 4.3.9
vite: 4.3.9(sass@1.64.1)
transitivePeerDependencies:
- supports-color
dev: true
@ -341,7 +347,7 @@ packages:
magic-string: 0.30.0
svelte: 4.0.3
svelte-hmr: 0.15.2(svelte@4.0.3)
vite: 4.3.9
vite: 4.3.9(sass@1.64.1)
vitefu: 0.2.4(vite@4.3.9)
transitivePeerDependencies:
- supports-color
@ -421,6 +427,10 @@ packages:
resolution: {integrity: sha512-ulDEYPv7asdKvqahuAY35c1selLdzDwHqugK92hfkzvlDCwXRRelDkR+Er33md/PtnpqHemgkuDPanZ4fiYZ8w==}
dev: false
/carbon-icons-svelte@12.1.0:
resolution: {integrity: sha512-RPU+W/AhkdPRqHMaShOdFluQZXMT6PNYrqEwPxDBvmrX2ioOYhZo1J508e0WeVX69iOqyUv6KjxrdbsoDdogeg==}
dev: false
/chokidar@3.5.3:
resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==}
engines: {node: '>= 8.10.0'}
@ -588,6 +598,10 @@ packages:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
dev: true
/immutable@4.3.1:
resolution: {integrity: sha512-lj9cnmB/kVS0QHsJnYKD1uo3o39nrbKxszjnqS9Fr6NB7bZzW45U6WSGBPKXDL/CvDKqDNPA4r3DoDQ8GTxo2A==}
dev: true
/import-fresh@3.3.0:
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
engines: {node: '>=6'}
@ -829,6 +843,16 @@ packages:
rimraf: 2.7.1
dev: true
/sass@1.64.1:
resolution: {integrity: sha512-16rRACSOFEE8VN7SCgBu1MpYCyN7urj9At898tyzdXFhC+a+yOX5dXwAR7L8/IdPJ1NB8OYoXmD55DM30B2kEQ==}
engines: {node: '>=14.0.0'}
hasBin: true
dependencies:
chokidar: 3.5.3
immutable: 4.3.1
source-map-js: 1.0.2
dev: true
/sorcery@0.11.0:
resolution: {integrity: sha512-J69LQ22xrQB1cIFJhPfgtLuI6BpWRiWu1Y3vSsIwK/eAScqJxd/+CJlUuHQRdX2C9NGFamq+KqNywGgaThwfHw==}
hasBin: true
@ -851,7 +875,7 @@ packages:
min-indent: 1.0.1
dev: true
/svelte-check@3.4.4(svelte@4.0.3):
/svelte-check@3.4.4(sass@1.64.1)(svelte@4.0.3):
resolution: {integrity: sha512-Uys9+R65cj8TmP8f5UpS7B2xKpNLYNxEWJsA5ZoKcWq/uwvABFF7xS6iPQGLoa7hxz0DS6xU60YFpmq06E4JxA==}
hasBin: true
peerDependencies:
@ -864,7 +888,7 @@ packages:
picocolors: 1.0.0
sade: 1.8.1
svelte: 4.0.3
svelte-preprocess: 5.0.4(svelte@4.0.3)(typescript@5.1.6)
svelte-preprocess: 5.0.4(sass@1.64.1)(svelte@4.0.3)(typescript@5.1.6)
typescript: 5.1.6
transitivePeerDependencies:
- '@babel/core'
@ -887,7 +911,7 @@ packages:
svelte: 4.0.3
dev: true
/svelte-preprocess@5.0.4(svelte@4.0.3)(typescript@5.1.6):
/svelte-preprocess@5.0.4(sass@1.64.1)(svelte@4.0.3)(typescript@5.1.6):
resolution: {integrity: sha512-ABia2QegosxOGsVlsSBJvoWeXy1wUKSfF7SWJdTjLAbx/Y3SrVevvvbFNQqrSJw89+lNSsM58SipmZJ5SRi5iw==}
engines: {node: '>= 14.10.0'}
requiresBuild: true
@ -928,6 +952,7 @@ packages:
'@types/pug': 2.0.6
detect-indent: 6.1.0
magic-string: 0.27.0
sass: 1.64.1
sorcery: 0.11.0
strip-indent: 3.0.0
svelte: 4.0.3
@ -979,7 +1004,7 @@ packages:
resolution: {integrity: sha512-r13jrghEYZAN99GeYpEjM107DOxqB65enskpwce8rRHVAGEtaWmsF5GqoGdPMf8DIXc9XyAJTdvlvRZi4LsszA==}
dev: false
/vite@4.3.9:
/vite@4.3.9(sass@1.64.1):
resolution: {integrity: sha512-qsTNZjO9NoJNW7KnOrgYwczm0WctJ8m/yqYAMAK9Lxt4SoySUfS5S8ia9K7JHpa3KEeMfyF8LoJ3c5NeBJy6pg==}
engines: {node: ^14.18.0 || >=16.0.0}
hasBin: true
@ -1007,6 +1032,7 @@ packages:
esbuild: 0.17.19
postcss: 8.4.24
rollup: 3.26.0
sass: 1.64.1
optionalDependencies:
fsevents: 2.3.2
dev: true
@ -1019,7 +1045,7 @@ packages:
vite:
optional: true
dependencies:
vite: 4.3.9
vite: 4.3.9(sass@1.64.1)
dev: true
/wrappy@1.0.2:

View file

@ -4,11 +4,9 @@
import { onDestroy } from "svelte";
import Gallery from "./Gallery.svelte";
import List from "./List.svelte";
import Popup from "./Popup.svelte";
export let options: URLSearchParams;
let { events: items, stop } = loadImages(options);
let popup = null;
let tooltip = { x: 0, y: 0, text: null };
$: if (options) {
@ -66,10 +64,6 @@
function handleMouseout(e: MouseEvent) {
tooltip.text = null;
}
function showPopup(event) {
popup = event;
}
onDestroy(() => stop());
@ -92,7 +86,7 @@
let viewAs = "gallery";
</script>
<div>
<div class="wrapper">
<!-- TODO: option to view as list instead of gallery -->
<!-- TODO (qol+future): syntax highlighting for source file -->
<div style="margin-bottom: 8px">
@ -121,55 +115,13 @@
<button on:click={() => viewAs = (viewAs === "gallery" ? "list" : "gallery")}>{viewAs}</button>
</div>
{#if viewAs === "gallery"}
<Gallery items={filtered.map(i => i.event)} showPopup={(event) => popup = event} />
<Gallery items={filtered.map(i => i.event)} />
{:else if viewAs === "list"}
<List items={filtered.map(i => i.event)} showPopup={(event) => popup = event} />
{/if}
{#if popup}
<Popup event={popup} close={() => popup = null}/>
<List items={filtered.map(i => i.event)} />
{/if}
</div>
<style>
.gallery {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
margin: 0 auto;
gap: 4px;
}
.item {
position: relative;
overflow: hidden;
width: 100%;
height: 100px;
background-color: #2a2a33;
cursor: pointer;
border-radius: 4px;
}
.item .name {
display: none;
position: absolute;
width: 100%;
padding: 4px;
background-color: #000000aa;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
bottom: 0;
}
.tooltip {
position: absolute;
top: 0;
left: 0;
background: var(--bg-primary);
border: solid var(--borders) 1px;
border-radius: 4px;
padding: 4px;
pointer-events: none;
animation: hover 1.5s ease-in-out infinite;
transform-origin: top left;
box-shadow: #00000055 4px 4px 4px;
.wrapper {
padding: 1em;
}
</style>

View file

@ -1,8 +1,8 @@
<script lang="ts">
import Img from "../../atoms/Img.svelte";
import { events } from "../../events";
export let items;
export let showPopup;
let tooltip = { x: 0, y: 0, text: null };
@ -27,7 +27,7 @@
>
{#each items as event (event.id)}
{@const info = event.derived?.file ?? {}}
<div class="item" on:click|preventDefault={() => showPopup(event)}>
<div class="item" on:click|preventDefault={() => events.emit("open", event)}>
<Img {event} thumb={[100, 100]} errorPlaceholder={/^(image|video)\//.test(info.mime)} />
<div class="name">{event.content.name}</div>
</div>

View file

@ -1,10 +1,9 @@
<script lang="ts">
import { api } from "../../lib/api";
import { timeAgo, formatSize } from "../../lib/util";
import { events } from "../../events";
export let items;
export let showPopup;
let tooltip = { x: 0, y: 0, text: null };
function handleMouseover(e: MouseEvent) {
@ -32,7 +31,7 @@
<tbody>
{#each items as event (event.id)}
<tr class="item">
<td class="name"><a href="#" on:click|preventDefault={() => showPopup(event)}>{event.content.name}</a></td>
<td class="name"><a href="#" on:click|preventDefault={() => events.emit("open", event)}>{event.content.name}</a></td>
<td class="size">{formatSize(event.derived?.file?.size ?? 0)}</td>
<td>{event.derived?.file?.mime}</td>
<td>{timeAgo(event.origin_ts)}</td>

View file

@ -1,3 +1,10 @@
<h1>Hello there</h1>
<p>Welcome to the web ui. keep in mind everything here is beta.</p>
<p>If you have a private key and token, you can set it in <a href="#/settings">settings</a>.</p>
<div class="wrapper">
<h1>Hello there</h1>
<p>Welcome to the web ui. keep in mind everything here is beta.</p>
<p>If you have a private key and token, you can set it in <a href="#/settings">settings</a>.</p>
</div>
<style lang="scss">
.wrapper {
padding: 1em;
}
</style>

View file

@ -51,6 +51,7 @@
ul {
max-width: 850px;
margin: 0 auto;
padding: 1em;
list-style-type: none;
}

View file

@ -83,6 +83,7 @@
ul {
max-width: 850px;
margin: 0 auto;
padding: 1em;
list-style-type: none;
}

View file

@ -1,31 +1,59 @@
<script lang="ts">
import { api, base64, ed25519 } from "../lib/api";
</script>
Server: <input
type="text"
bind:value={api.baseUrl}
on:input={(e) => localStorage.setItem("server", e.target.value)}
>
<br />
Token: <input
type="text"
bind:value={api.token}
on:input={(e) => localStorage.setItem("token", e.target.value)}
>
<br />
Secret key: <input
type="text"
value={ed25519.etc.bytesToHex(api.key)}
on:input={(e) => {
const newKey = e.target.value;
if (newKey.length !== 64) return;
api.key = ed25519.etc.hexToBytes(newKey);
localStorage.setItem("key", newKey);
}}
>
<br />
{#await ed25519.getPublicKeyAsync(api.key)}
User id: <input type="text" readonly value="loading...">
{:then pubkey}
User id: <input type="text" readonly value={"%" + base64.encode(pubkey)}>
{/await}
<table>
<tr>
<td>Server:</td>
<td>
<input
type="text"
bind:value={api.baseUrl}
on:input={(e) => localStorage.setItem("server", e.target.value)}
/>
</td>
</tr>
<tr>
<td>Token: </td>
<td>
<input
type="text"
bind:value={api.token}
on:input={(e) => localStorage.setItem("token", e.target.value)}
/>
</td>
</tr>
<tr>
<td>Secret key:</td>
<td>
<input
type="text"
value={ed25519.etc.bytesToHex(api.key)}
on:input={(e) => {
const newKey = e.target.value;
if (newKey.length !== 64) return;
api.key = ed25519.etc.hexToBytes(newKey);
localStorage.setItem("key", newKey);
}}
/>
</td>
</tr>
<tr>
<td>User id:</td>
<td>
{#await ed25519.getPublicKeyAsync(api.key)}
<input type="text" readonly value="loading..." />
{:then pubkey}
<input type="text" readonly value={"%" + base64.encode(pubkey)} />
{/await}
</td>
</tr>
</table>
<style lang="scss">
table {
padding: 1em;
& td:first-child {
padding-right: 8px;
}
}
</style>

280
web/status/Audio.svelte Normal file
View file

@ -0,0 +1,280 @@
<script lang="ts">
import brokenImgUrl from "../assets/broken.png";
import { api } from "../lib/api";
import { events } from "../events";
import Clock from "../Clock.svelte";
import "carbon-icons-svelte"; // import everything so vite optimizes correctly
import PlayIc from "carbon-icons-svelte/lib/PlayFilledAlt.svelte";
import PauseIc from "carbon-icons-svelte/lib/PauseFilled.svelte";
import RepeatIc from "carbon-icons-svelte/lib/Repeat.svelte";
import VolumeDownIc from "carbon-icons-svelte/lib/VolumeDownFilled.svelte";
import VolumeUpIc from "carbon-icons-svelte/lib/VolumeUpFilled.svelte";
import VolumeMuteIc from "carbon-icons-svelte/lib/VolumeMuteFilled.svelte";
import StopIc from "carbon-icons-svelte/lib/StopFilledAlt.svelte";
export let event;
export let small;
$: url = api.getBlobUrl(event.id);
$: media = event.derived?.media ?? {};
let audio;
let duration: number;
let currentTime: number;
let volume: number = 1;
let muted: boolean;
let paused: boolean;
let loop: boolean;
let loading = true;
let loadingDebounce: number;
function quantize<T>(n: number, values: Array<T>): T {
return values[Math.floor(n * values.length)] ?? values.at(-1);
}
function fmtTime(time?: number): string {
if (isNaN(time)) return "?:??";
const seconds = Math.floor(time) % 60;
const minutes = Math.floor((time / 60) % 60);
const hours = Math.floor(time / (60 * 60));
if (hours) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds
.toString()
.padStart(2, "0")}`;
} else {
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
}
function fmtArtistAlbum(media): string {
const artist = media.artist;
const album = media.album;
if (!artist && !album) return "";
if (artist && !album) return artist;
if (!artist && album) return "in " + album;
return `${artist} - ${album}`;
}
function handleLoading(e) {
clearTimeout(loadingDebounce);
console.log(e.type);
switch (e.type) {
case "loadstart": {
duration = null;
// fallthrough;
}
case "stalled":
case "waiting": {
loadingDebounce = setTimeout(() => loading = true, 1);
break;
}
case "playing": {
loading = false;
break;
}
}
}
let progressEl: HTMLDivElement;
let previewTime: number | null = null;
function handleScrub(e) {
const frac = e.layerX / progressEl.offsetWidth;
previewTime = frac * duration;
if (e.type === "mousedown" || e.buttons === 1) {
currentTime = previewTime;
}
}
function endScrub() {
previewTime = null;
}
function scrubWheel(e: WheelEvent) {
if (e.deltaY > 0) {
currentTime = Math.max(currentTime - 5, 0);
} else {
currentTime = Math.min(currentTime + 5, duration);
}
}
function volumeWheel(e: WheelEvent) {
if (e.deltaY > 0) {
volume = Math.max(volume - .05, 0);
} else {
volume = Math.min(volume + .05, 1);
}
}
</script>
<div class="wrapper" class:small>
<div
class="progress"
bind:this={progressEl}
on:mousedown={handleScrub}
on:mousemove={handleScrub}
on:mouseout={endScrub}
on:wheel={scrubWheel}
>
<div class="fill" style:width="{(100 * currentTime) / duration}%" />
</div>
<div class="thumb">
{#if loading}<div class="load"><Clock /></div>{/if}
<img
src={api.getThumbUrl(event.id, 128, 128)}
on:error={(e) => (e.target.src = brokenImgUrl)}
/>
</div>
<div class="info">
<div class="title">
{media.title ?? event.content.name ?? "Unnamed Audio"}
</div>
<div class="misc" title={fmtArtistAlbum(media)}>
{fmtArtistAlbum(media)}
</div>
</div>
<div class="controls">
<div class="time" on:wheel={scrubWheel}>
<span class:preview={previewTime !== null}>
{fmtTime(previewTime ?? currentTime ?? 0)}
</span>
/ {fmtTime(duration ?? event.derived?.file?.duration)}
</div>
<button on:click={() => (paused ? audio.play() : audio.pause())}>
{#if paused}<PlayIc />{:else}<PauseIc />{/if}
</button>
<button on:click={() => loop = !loop}>
<RepeatIc fill={loop ? "var(--color-accent)" : "var(--fg-text)"} />
</button>
<button on:click={() => (muted = !muted)} on:wheel={volumeWheel}>
{#if muted}
<VolumeMuteIc />
{:else if volume <= 0.5}
<VolumeDownIc />
{:else}
<VolumeUpIc />
{/if}
</button>
<button on:click={() => events.emit("close", "statusMusic")}>
<StopIc />
</button>
</div>
<audio
bind:this={audio}
src={url}
{loop}
bind:currentTime
bind:muted
bind:volume
bind:paused
bind:duration
autoplay
on:waiting={handleLoading}
on:playing={handleLoading}
on:stalled={handleLoading}
on:loadstart={handleLoading}
on:ended={() => events.emit("close", "statusMusic")}
/>
</div>
<style lang="scss">
.wrapper {
display: grid;
grid-template-columns: 64px auto;
grid-template-rows: auto 36px 28px;
grid-template-areas: "progress progress" "thumb info" "thumb controls";
height: 100%;
&.small {
grid-template-columns: 36px auto;
grid-template-rows: auto 36px 28px;
grid-template-areas: "progress progress" "thumb info" "controls controls";
}
& > .progress {
grid-area: progress;
position: relative;
background: var(--bg-primary);
cursor: pointer;
& > .fill {
background: var(--color-accent);
height: 4px;
}
&:hover > .fill {
height: 12px;
}
}
& > .thumb {
grid-area: thumb;
background: var(--bg-primary);
position: relative;
& > img {
object-fit: cover;
height: 100%;
width: 100%;
}
& > .load {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1;
}
& > .load + img {
opacity: 0.2;
}
}
& > .info {
grid-area: info;
padding: 4px;
color: var(--fg-text);
& > .title {
font-weight: bold;
}
& > div {
overflow: hidden;
text-overflow: ellipsis;
}
}
& > .controls {
grid-area: controls;
display: flex;
padding: 0 2px;
align-items: center;
justify-content: end;
gap: 2px;
& > .time {
flex: 1;
padding: 2px;
font-family: monospace;
white-space: nowrap;
font-size: 0.8rem;
& > .preview {
color: var(--color-accent);
}
}
& > button {
display: flex;
align-items: center;
justify-content: center;
height: 24px;
width: 24px;
border-radius: 4px;
background: #cccccc11;
border: none;
&:hover {
background: #ffffff33;
}
}
}
}
</style>

29
web/status/Status.svelte Normal file
View file

@ -0,0 +1,29 @@
<script lang="ts">
import { api, ed25519, base64 } from "../lib/api";
</script>
<div class="wrapper">
stuff here soon...
{#await ed25519.getPublicKeyAsync(api.key) then pubkey}
<div class="userid">user id: <code>{"%" + base64.encode(pubkey)}</code></div>
{/await}
</div>
<style lang="scss">
.wrapper {
min-height: 64px;
padding: 4px;
}
.userid {
font-size: .8rem;
white-space: nowrap;
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
& > code {
font-family: monospace;
user-select: all;
}
}
</style>

View file

@ -7,15 +7,20 @@
:root {
--bg-primary: #1a1923;
--bg-secondary: #131019;
--bg-tertiary: #0a0e11;
--fg-primary: #c7c6ca;
--bg-secondary: #14131d;
--bg-tertiary: #100e18;
--bg-quartiary: #0a0e11;
--never-see-this-color: #00ff1f;
--fg-text: #c7c6ca;
--fg-link: #b18cf3;
--color-accent: #b18cf3;
--borders: #222a30;
}
body {
overflow: hidden;
color: var(--fg-text);
font: 16px/1.3 sans-serif;
}
h1 { font-size: 2em }
@ -56,49 +61,6 @@ summary {
cursor: pointer;
}
#wrapper {
display: grid;
grid-template-areas: "header" "main";
grid-template-rows: auto 1fr;
grid-template-columns: 1fr;
color: var(--fg-primary);
font: 16px/1.3 sans-serif;
height: 100vh;
width: 100vw;
}
#main {
background: var(--bg-primary);
padding: 2em;
overflow-x: hidden;
}
#header {
grid-area: header;
background: var(--bg-secondary);
border-bottom: solid var(--borders) 1px;
z-index: 1;
}
#header nav {
display: flex;
gap: 4px;
align-items: stretch;
height: 100%;
padding: 0 4px;
}
#header nav a {
display: inline-block;
height: 100%;
padding: 8px 4px;
}
#header nav a:hover,
#header nav a:focus-visible {
background: #ffffff33;
}
.description, .info {
white-space: nowrap;
overflow: hidden;