fix relations and impove web ui

This commit is contained in:
tezlm 2023-08-01 00:56:29 -07:00
parent 3604cc1fd4
commit a63d0d6122
Signed by: tezlm
GPG key ID: 649733FCD94AFBBA
33 changed files with 1126 additions and 194 deletions

View file

@ -446,8 +446,17 @@ async fn main() -> Result<(), Error> {
.map(|i| {
let parts: Vec<_> = i.split('/').collect();
match parts.as_slice() {
[source_type, rel_type] => Some(QueryRelation::from_rel(source_type.to_string(), rel_type.to_string())),
[source_type, rel_type, target_type] => Some(QueryRelation::from_rel_to(source_type.to_string(), rel_type.to_string(), target_type.to_string())),
[source_type, rel_type] => Some(QueryRelation::from_rel(
source_type.to_string(),
rel_type.to_string(),
)),
[source_type, rel_type, target_type] => {
Some(QueryRelation::from_rel_to(
source_type.to_string(),
rel_type.to_string(),
target_type.to_string(),
))
}
_ => None,
}
})

View file

@ -115,7 +115,7 @@ pub struct FileEvent {
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct UpdateEvent(Box<EventContent>);
pub struct UpdateEvent(pub Box<EventContent>);
impl EventContent {
pub fn get_type(&self) -> &str {

View file

@ -1,5 +1,8 @@
use serde::{Deserialize, Serialize};
use std::{fmt::{Display, Debug}, str::FromStr};
use std::{
fmt::{Debug, Display},
str::FromStr,
};
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]

View file

@ -78,7 +78,7 @@ impl Query {
{
return true;
}
},
}
QueryRelation::FromRelTo(QueryFromRelTo(source_type, rel_type, target_type)) => {
if &rel_info.rel_type == rel_type
&& source.content.get_type() == source_type
@ -87,7 +87,7 @@ impl Query {
{
return true;
}
},
}
}
}
@ -113,7 +113,7 @@ impl QueryRelation {
pub fn from_rel(source_type: String, rel_type: String) -> Self {
Self::FromRel(QueryFromRel(source_type, rel_type))
}
pub fn from_rel_to(source_type: String, rel_type: String, target_type: String) -> Self {
Self::FromRelTo(QueryFromRelTo(source_type, rel_type, target_type))
}

View file

@ -40,6 +40,7 @@ pub async fn get_relations(db: &Sqlite, event: &Event) -> Result<Relations, Erro
pub enum DelayedAction {
Redact(Vec<ItemRef>),
Tag(Vec<ItemRef>, Vec<String>),
Edit(Vec<ItemRef>, EventContent),
None,
}
@ -109,6 +110,32 @@ pub async fn prepare_special(
EventContent::Acl(_) => {
return Ok(DelayedAction::None);
}
EventContent::Update(content) => {
let content = &content.0;
if relations
.values()
.any(|(_, rel)| rel.rel_type != "edit" || rel.key.is_some())
{
return Err(Error::Validation("invalid relation"));
}
if relations
.values()
.any(|(event, _)| event.get_type() != content.get_type())
{
return Err(Error::Validation("edited event types must match"));
}
if relations.values().any(|(event, _)| event.is_redacted()) {
return Err(Error::Validation("cannot edit redacted event"));
}
return Ok(DelayedAction::Edit(
relations.keys().cloned().collect(),
*content.clone(),
));
}
EventContent::Other { event_type, .. } => {
if event_type.starts_with("x.") {
return Err(Error::Validation("unknown core event"));
@ -125,10 +152,10 @@ pub async fn prepare_special(
pub async fn commit_special(me: &Items, action: &DelayedAction) -> Result<(), Error> {
debug!("commit special {:?}", action);
// TODO (performance): batch transactions
match action {
DelayedAction::Redact(refs) => {
// TODO: garbage collect unreferenced blobs
// TODO (performance): batch transactions
for item_ref in refs {
let mutex = me.mutex_for(item_ref).await;
let lock = mutex.lock().await;
@ -138,7 +165,6 @@ pub async fn commit_special(me: &Items, action: &DelayedAction) -> Result<(), Er
}
}
DelayedAction::Tag(refs, tags) => {
// TODO (performance): batch transactions
for item_ref in refs {
let mutex = me.mutex_for(item_ref).await;
let lock = mutex.lock().await;
@ -146,6 +172,28 @@ pub async fn commit_special(me: &Items, action: &DelayedAction) -> Result<(), Er
drop(lock);
}
}
DelayedAction::Edit(refs, content) => {
for item_ref in refs {
let mutex = me.mutex_for(item_ref).await;
let lock = mutex.lock().await;
let event = me
.db
.update_event(&item_ref.clone(), content.clone())
.await?
.expect("tried to edit event that doesn't exist");
drop(lock);
me.finish_event_create(crate::items::WipCreate {
item_ref: item_ref.clone(),
create: crate::items::Create {
event,
relations: Default::default(),
rowid: 0,
},
action: DelayedAction::None,
}).await?;
}
}
DelayedAction::None => (),
};

View file

@ -120,6 +120,7 @@ impl Items {
/// finish extra processing, eg. deriving metadata or indexing for fts
/// split out to continue in background
#[async_recursion::async_recursion]
pub async fn finish_event_create(&self, wip: WipCreate) -> Result<Create, Error> {
let create = wip.create;
let event = create.event;

View file

@ -13,7 +13,8 @@ use ufh::{
acl::Acl,
actor::ActorId,
event::{Event, EventContent, WipEvent},
item::ItemRef, query::QueryRelation,
item::ItemRef,
query::QueryRelation,
};
// TODO: find out how to cache this
@ -115,7 +116,10 @@ pub async fn can_send_event(db: &Sqlite, wip: &WipEvent) -> Result<bool, Error>
return Ok(false);
}
}
trace!("event is valid because all {} relations are valid", relations.len());
trace!(
"event is valid because all {} relations are valid",
relations.len()
);
Ok(true)
}
@ -131,7 +135,10 @@ async fn validate_relations(db: &Sqlite, event: &Event, ctx: &Context<'_>) -> Re
return Ok(false);
}
}
trace!("relation is valid because all {} (sub)relations are valid", relations.len());
trace!(
"relation is valid because all {} (sub)relations are valid",
relations.len()
);
Ok(true)
}

View file

@ -1,7 +1,7 @@
use crate::items::events::get_relations;
use crate::perms::can_view_event;
use crate::state::db::Database;
use crate::{ServerState, Relations};
use crate::{Relations, ServerState};
use axum::extract::{Query, State};
use axum::response::IntoResponse;
use reqwest::StatusCode;
@ -101,18 +101,26 @@ pub async fn route(
// TODO (performance): reduce database queries (reuse the recursive sql query?)
#[async_recursion::async_recursion]
async fn check_relations(state: &ServerState, query: &ufh::query::Query, relations: &Relations) -> Result<bool, Error> {
async fn check_relations(
state: &ServerState,
query: &ufh::query::Query,
relations: &Relations,
) -> Result<bool, Error> {
for (rel, _) in relations.values() {
let rel_relations = get_relations(&state.db, rel).await?;
trace!("received non-matching event");
dbg!(&rel, &rel_relations, &query);
match query.matches(rel, &rel_relations) {
MatchType::Event => unreachable!("matching events should already be handled"),
MatchType::Event => {
unreachable!("matching events should already be handled")
}
MatchType::Relation => return Ok(true),
MatchType::None => match check_relations(state, query, &rel_relations).await? {
true => return Ok(true),
false => (),
},
MatchType::None => {
match check_relations(state, query, &rel_relations).await? {
true => return Ok(true),
false => (),
}
}
}
}
Ok(false)
@ -134,9 +142,9 @@ pub async fn route(
if check_relations(&state, &query, &relations).await? {
Ok((MatchType::Relation, event, rowid))
} else {
continue
continue;
}
},
}
}
}
Ok(_) => continue,

View file

@ -2,7 +2,7 @@ use std::collections::HashMap;
use bytes::Bytes;
use ufh::derived::Derived;
use ufh::event::Event;
use ufh::event::{Event, EventContent};
use ufh::item::ItemRef;
use ufh::query::{Query, QueryRelation};
@ -51,6 +51,11 @@ pub trait Database {
async fn create_blob(&self, item_ref: &ItemRef, size: u32) -> Result<(), Self::Error>;
async fn redact_event(&self, item_ref: &ItemRef) -> Result<(), Self::Error>;
async fn get_event(&self, item_ref: &ItemRef) -> Result<Option<Event>, Self::Error>;
async fn update_event(
&self,
item_ref: &ItemRef,
content: EventContent,
) -> Result<Option<Event>, Self::Error>;
async fn query_events(
&self,
query: &Query,

View file

@ -121,6 +121,41 @@ impl Database for Sqlite {
Ok(())
}
#[tracing::instrument(skip_all)]
async fn update_event(
&self,
item_ref: &ItemRef,
content: EventContent,
) -> Result<Option<Event>, Self::Error> {
let item_ref_str = item_ref.to_string();
let mut tx = self.pool.begin().await?;
let row = sql!("SELECT json FROM events WHERE ref = ?", item_ref_str)
.fetch_optional(&mut tx)
.await?;
let Some(row) = row else {
return Ok(None);
};
debug!("found event and will patch it");
let mut event: Event = serde_json::from_str(&row.json)?;
assert!(!event.is_redacted());
assert_eq!(content.get_type(), event.get_type());
event.content = content;
let event_str = json_canon::to_string(&event)?;
sql!(
"UPDATE events SET json = ? WHERE ref = ?",
event_str,
item_ref_str
)
.execute(&mut tx)
.await?;
sql!("DELETE FROM derived WHERE ref = ?", item_ref_str)
.execute(&mut tx)
.await?;
tx.commit().await?;
debug!("event has been updated");
Ok(Some(event))
}
#[tracing::instrument(skip_all)]
async fn redact_event(&self, item_ref: &ItemRef) -> Result<(), Self::Error> {
let item_ref_str = item_ref.to_string();
@ -268,7 +303,8 @@ impl Database for Sqlite {
LEFT JOIN derived ON derived.ref = events_from.ref
WHERE 1 = 1
*/
let mut builder = sqlx::QueryBuilder::new("
let mut builder = sqlx::QueryBuilder::new(
"
WITH RECURSIVE graph(ref_from, ref_to, rel_type) AS (
SELECT * FROM relations
",
@ -278,7 +314,8 @@ impl Database for Sqlite {
for item_ref in for_events {
sep.push_bind(item_ref.to_string());
}
builder.push(")
builder.push(
")
UNION
SELECT relations.ref_from, relations.ref_to, relations.rel_type FROM relations
JOIN graph ON graph.ref_from = relations.ref_to
@ -289,7 +326,8 @@ impl Database for Sqlite {
JOIN events AS events_to ON events_to.ref = graph.ref_to
LEFT JOIN derived ON derived.ref = events_from.ref
WHERE 1 = 0
");
",
);
let mut rels_from_rel = Vec::new();
let mut rels_from_rel_to = Vec::new();
for relation in relations {

4
spec/README.md Normal file
View file

@ -0,0 +1,4 @@
# spec
This is a loose collection of notes for now! A real spec will come once
I work out how I want to implement stuff.

View file

@ -5,18 +5,24 @@
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 EventPopup from "./scenes/Files/Popup.svelte";
import Audio from "./status/Audio.svelte";
import Status from "./status/Status.svelte";
import { EventDocScene } from "./scenes/Document";
import { api } from "./lib/api";
import { collectEvents } from "./lib/util";
import type { Event } from "./lib/api";
import { events } from "./events";
import { state } from "./state";
import { onDestroy, onMount } from "svelte";
import Popup from "./atoms/Popup.svelte";
let audioEl: HTMLAudioElement;
onMount(() => $state.audio.set(audioEl));
let { events: itemsEvents, stop } = startLoad();
let selected = null;
let selectedProm = null;
$: items = [
{ type: "home", id: "home", content: { name: "home" } },
{ type: "settings", id: "settings", content: { name: "settings" } },
@ -40,13 +46,17 @@
};
}
let options;
let options = new URLSearchParams();
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));
const id = url.pathname.slice(1);
const idx = items.findIndex(i => i.id === id);
selected = items[idx] ?? null;
if (idx === -1) {
selectedProm = api.fetch(id);
}
}
$: if ($itemsEvents && !selected) updateScene();
@ -58,8 +68,11 @@
case "l.notes": return Notes;
case "l.links": return Links; // TODO: merge with notes
case "l.forum": return Forum;
case "l.doc": return EventDocScene;
case "settings": return Settings;
default: throw new Error("invalid/unsupported type");
default: return null;
// no Result<T, E> in js so handling errors in svelte would be very annying...
// default: throw new Error("invalid/unsupported type");
}
}
@ -73,7 +86,6 @@
}
}
onMount(() => updateScene());
onDestroy(() => stop());
</script>
<div id="wrapper">
@ -89,6 +101,20 @@
<main id="main">
{#if selected}
<svelte:component this={getComponent(selected.type)} {options} bucket={selected} />
{:else if selectedProm}
{#await selectedProm}
<div class="empty">fetching event...</div>
{:then selected}
{#if selected instanceof ArrayBuffer}
<div class="empty">you can't view a raw blob here!</div>
{:else if getComponent(selected.type) === null}
<div class="empty">unsupported event type!</div>
{:else}
<svelte:component this={getComponent(selected.type)} bucket={selected} />
{/if}
{:catch _}
<div class="empty">failed to load that!</div>
{/await}
{:else}
<div class="empty">hmm, nothing here?</div>
{/if}
@ -99,16 +125,23 @@
<footer id="status">
{#if $state.statusMusic}
<div>
<Audio event={$state.statusMusic} small={!$state.pins} />
<Audio event={$state.statusMusic.event} small={!$state.pins} time={$state.statusMusic.time} />
</div>
{/if}
<div><Status /></div>
</footer>
<header id="nav-header"></header>
</div>
{#if $state.popup}
<Popup event={$state.popup} close={() => events.emit("close", "popup")}/>
{/if}
{#each $state.popups as popup}
{#if popup.type === "event"}
<EventPopup event={popup.event} close={() => events.emit("close", "popup")}/>
{:else if popup.type === "text"}
<Popup>{popup.text}</Popup>
{:else}
<Popup>unknown popup</Popup>
{/if}
{/each}
<audio bind:this={audioEl} />
<svelte:window on:animationstart={syncAnimations} on:hashchange={updateScene} />
<style lang="scss">
#wrapper {

View file

@ -1,14 +1,320 @@
<svelte:options immutable />
<script lang="ts">
import brokenImgUrl from "../assets/broken.png";
import { api } from "../lib/api";
import { events } from "../events";
import Clock from "../Clock.svelte";
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 PopoutIc from "carbon-icons-svelte/lib/Launch.svelte";
import { onDestroy } from "svelte";
import { formatTime } from "../lib/util";
import { state } from "../state";
import type { Event } from "../lib/api";
export let event: Event;
export let style = "";
export let url = api.getBlobUrl(event.id);
export let autoplay = false;
$: url = api.getBlobUrl(event.id);
$: media = event.derived?.media ?? {};
let audio;
let duration: number;
let currentTime: number = 0;
let volume: number = 1;
let muted: boolean;
let paused: boolean = !autoplay;
let loop: boolean;
let loading = true;
let loadingDebounce: number;
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);
switch (e.type) {
case "loadstart": {
duration = null;
// fallthrough;
}
case "stalled":
case "waiting": {
loadingDebounce = setTimeout(() => loading = true, 30);
break;
}
case "loadedmetadata":
case "playing": {
duration = audio.duration;
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) {
audio.currentTime = previewTime;
}
}
function endScrub() {
previewTime = null;
}
function scrubWheel(e: WheelEvent) {
if (e.deltaY > 0) {
audio.currentTime = Math.max(currentTime - 5, 0);
} else {
audio.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);
}
}
function popout() {
audio.pause();
events.emit("open", "statusMusic", { event, time: currentTime });
}
function hijackMedia() {
for (let el of document.querySelectorAll("audio")) {
if (el !== audio) el.pause();
}
navigator.mediaSession.metadata = getMetadata(event);
}
function getMetadata(event) {
return new MediaMetadata({
title: event.derived?.media?.title,
album: event.derived?.media?.album,
artist: event.derived?.media?.artist,
artwork: [
{ src: api.getThumbUrl(event.id, 128, 128), sizes: "128x128" },
{ src: api.getThumbUrl(event.id, 256, 256), sizes: "256x256" },
],
});
}
navigator.mediaSession?.setActionHandler("stop", () => events.emit("close", "statusMusic"));
onDestroy(() => {
if ("mediaSession" in navigator && !$state.statusMusic) {
navigator.mediaSession.metadata = null;
}
});
</script>
<audio title="{event.content.name}" src={url} autoplay controls loop />
<style>
audio {
display: block;
<div class="wrapper">
<div
class="progress"
bind:this={progressEl}
on:mousedown={handleScrub}
on:mousemove={handleScrub}
on:mouseout={endScrub}
on:wheel|preventDefault={scrubWheel}
>
<div class="background" />
<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|preventDefault={scrubWheel}>
<span class:preview={previewTime !== null}>
{formatTime(previewTime ?? currentTime ?? 0)}
</span>
/ {formatTime(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|preventDefault={volumeWheel}>
{#if muted}
<VolumeMuteIc />
{:else if volume <= 0.5}
<VolumeDownIc />
{:else}
<VolumeUpIc />
{/if}
</button>
<button on:click={popout}>
<PopoutIc />
</button>
</div>
<audio
bind:this={audio}
src={url}
{loop}
{autoplay}
bind:currentTime
bind:muted
bind:volume
bind:paused
bind:duration
on:waiting={handleLoading}
on:playing={handleLoading}
on:stalled={handleLoading}
on:loadstart={handleLoading}
on:loadedmetadata={handleLoading}
on:ended={() => events.emit("close", "statusMusic")}
on:play={hijackMedia}
/>
</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";
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
overflow: hidden;
&.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;
cursor: pointer;
height: 12px;
& > .background, & > .fill {
position: absolute;
bottom: 0;
height: 4px;
}
& > .background {
width: 100%;
background: var(--bg-secondary);
}
& > .fill {
background: var(--color-accent);
}
&:hover > .background, &:hover > .fill {
height: 12px;
}
}
& > .thumb {
grid-area: thumb;
display: flex;
position: relative;
background: var(--bg-primary);
& > 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);
background: var(--bg-secondary);
& > .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;
background: var(--bg-secondary);
& > .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>

51
web/atoms/Popup.svelte Normal file
View file

@ -0,0 +1,51 @@
<svelte:options immutable />
<script lang="ts">
import { quadOut } from "svelte/easing";
import { events } from "../events";
function opacity() {
return {
duration: 150,
css: (t: number) => `opacity: ${t}`,
};
}
function pop() {
return {
duration: 150,
easing: quadOut,
css: (t: number) => `transform: scale(${t / 4 + .75}); opacity: ${t}`,
};
}
</script>
<div class="background" on:click={() => events.emit("close", "popup")} transition:opacity|global>
<div class="popup" on:click|stopPropagation transition:pop|global role="dialog">
<slot></slot>
</div>
</div>
<style lang="scss">
.background {
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 100vw;
background: #0a0e11ec;
display: flex;
align-items: center;
justify-content: center;
}
.popup {
display: flex;
flex-direction: column;
background: #2a2a33;
border-radius: 4px;
white-space: normal;
max-width: 80vw;
max-height: 80vh;
overflow: auto;
font-family: monospace;
padding: 8px;
}
</style>

View file

@ -5,8 +5,9 @@
export let event: Event;
export let style = "";
export let url = api.getBlobUrl(event.id);
export let autoplay = false;
</script>
<video title="{event.content.name}" {style} autoplay controls loop>
<video title="{event.content.name}" {style} {autoplay} controls loop>
<source src={url} type={event.derived?.file?.mime} />
</video>
<style>

View file

@ -2,9 +2,23 @@ import EventEmitter from "events";
import TypedEmitter from "typed-emitter";
import type { Event } from "./lib/api";
export type Singletons = {
audio: HTMLAudioElement,
};
export type Popup =
{ type: "event", event: Event } |
{ type: "edit", event: Event } |
{ type: "text", text: string };
type Events = {
open: (thing: Event | "sidebar" | "pins") => void,
close: (thing: "statusMusic" | "sidebar" | "pins") => void,
}
open: ((thing: | "sidebar" | "pins") => void)
| ((thing: "statusMusic", ctx: { event: Event, time?: number }) => void),
take: (thing: keyof Singletons) => void,
popup: (popup: Popup) => void,
};
export const events = new EventEmitter() as TypedEmitter<Events>
globalThis.events = events;

View file

@ -4,7 +4,7 @@
<meta charset="utf8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Thing</title>
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="style.scss" />
<link rel="stylesheet" href="animations.css" />
</head>
<body>

View file

@ -37,6 +37,21 @@ export function formatSize(size: number): string {
return "very big";
}
export function formatTime(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")}`;
}
}
export async function asyncIterArray(iter) {
const arr = [];
for await (const item of iter) arr.push(item);

View file

@ -0,0 +1,175 @@
<script lang="ts">
import type { Event } from "../../lib/api";
import Img from "../../atoms/Img.svelte";
import Video from "../../atoms/Video.svelte";
import Audio from "../../atoms/Audio.svelte";
import { events } from "../../events";
export let doc: Document;
export let embeds: Map<string, Event> = new Map();
type Document = Array<DocBlock>;
type DocBlock =
{ type: "text", content: DocInline } |
{ type: "code", content: DocInline, lang: string } |
{ type: "quote", content: DocInline } |
{ type: "table", headers?: Array<DocInline>, rows: Array<Array<DocInline>> } |
{ type: "ul", items: Array<DocInline> } |
{ type: "ol", items: Array<DocInline> } | // TODO: find out how to nested lists
{ type: "header", content: DocInline, level: number } |
{ type: "embed", ref: string };
type DocInline = DocInlinePart | Array<DocInlinePart>;
type DocInlinePart = string | { text: string } & TextStyle;
// type DocInlinePart = string |
// { text: string } & TextStyle |
// { icon: string, alt: string, href: string } |
// { math: string }; // latex?
type TextStyle = Partial<{
bold: boolean,
italic: boolean,
strike: boolean,
script: "sup" | "sub",
color: string,
bgcolor: string,
href: string,
code: boolean | string,
}>;
function render(doc: Document) {
return doc.map(i => renderBlock(i)).join("");
}
function renderBlock(doc: DocBlock): string {
switch (doc.type) {
case "text": return `<p>${renderInline(doc.content)}</p>`;
case "code": return `<pre>${renderInline(doc.content)}</pre>`;
case "quote": return `<blockquote>${renderInline(doc.content)}</blockquote>`;
case "ol": return `<ol>${doc.items.map(li => `<li>${renderInline(li)}</li>`).join("")}</ol>`;
case "ul": return `<ul>${doc.items.map(li => `<li>${renderInline(li)}</li>`).join("")}</ul>`;
case "header": return `<h${doc.level}>${renderInline(doc.content)}</h${doc.level}>`;
case "embed": {
if (embeds.has(doc.ref)) {
const event = embeds.get(doc.ref);
// new Img
globalThis.Img = Img;
return `<div class="embed">can't render ${event.type} event</div>`;
} else {
return `<div class="embed"><i>loading...</i></div>`;
}
}
case "table": {
const columns = doc.headers?.length && doc.rows[0].length;
if (columns === 0 || (!doc.headers && !doc.rows.length)) return "";
let html = "<table>"
if (doc.headers) {
const row = doc.headers.map(i => `<th>${renderInline(i)}</th>`).join("");
html += `<thead><tr>${row}</tr></thead>`;
}
html += "<tbody>";
for (let row of doc.rows) {
html += `<tr>${row.map(i => `<td>${renderInline(i)}</td>`).join("")}</tr>`;
}
return html + "</tbody></table>";
}
}
}
function renderInline(doc: DocInline): string {
if (Array.isArray(doc)) {
return doc.map(i => renderInlinePart(i)).join("");
} else {
return renderInlinePart(doc);
}
}
function renderInlinePart(inline: DocInlinePart): string {
if (typeof inline === "string") return sanitize(inline);
let html = sanitize(inline.text);
if (inline.bold) html = `<b>${html}</b>`;
if (inline.italic) html = `<i>${html}</i>`;
if (inline.strike) html = `<s>${html}</s>`;
if (inline.code) html = `<code>${html}</code>`;
// if (typeof inline.code === "string") html = `<code lang=${inline.code}}>${html}</code>`;
return html;
}
function sanitize(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
}
</script>
<div class="document">
{#each doc as block}
<!-- svelte doesn't have match or even switch/case aaaaaaaa -->
{#if false}
{:else if block.type === "text"} <p>{@html renderInline(block.content)}</p>
{:else if block.type === "code"} <pre>{@html renderInline(block.content)}</pre>
{:else if block.type === "quote"} <blockquote>{@html renderInline(block.content)}</blockquote>
{:else if block.type === "ol"} <ol>{@html block.items.map(li => `<li>${renderInline(li)}</li>`).join("")}</ol>
{:else if block.type === "ul"} <ul>{@html block.items.map(li => `<li>${renderInline(li)}</li>`).join("")}</ul>
{:else if block.type === "header"} <svelte:element this="h{block.level}">{@html renderInline(block.content)}</svelte:element>
{:else if block.type === "embed"}
{#if embeds.has(block.ref)}
{@const event = embeds.get(block.ref)}
{#if event.type === "x.file"}
{@const mime = event.derived?.file?.mime}
{#if mime.startsWith("image/")}
<div class="embed" on:click={events.emit("open", event)}>
<Img {event} style="object-fit: contain; max-height: 100%; cursor: pointer" />
</div>
{:else if mime.startsWith("video/")}
<div class="embed" on:click={events.emit("open", event)}>
<Video {event} style="max-height: 100%; cursor: pointer" />
</div>
{:else if mime.startsWith("audio/")}
<Audio {event} />
{:else}
<div class="embed">can't render this file</div>
{/if}
{:else}
<div class="embed">can't render {event.type} event</div>
{/if}
{:else}
<div class="embed"><i>loading...</i></div>
{/if}
{:else if block.type === "table"}
{@const columns = block.headers?.length && block.rows[0].length}
{#if columns !== 0 && (block.headers || block.rows.length)}
<table>
{#if block.headers}
<thead>
<tr>{@html block.headers.map(i => `<th>${renderInline(i)}</th>`).join("")}</tr>
</thead>
{/if}
<tbody>
{#each block.rows as row}
<tr>{@html row.map(i => `<td>${renderInline(i)}</td>`).join("")}</tr>
{/each}
</tbody>
</table>
{/if}
{/if}
{/each}
</div>
<style lang="scss">
.document {
display: flex;
flex-direction: column;
gap: 1em;
white-space: pre-wrap;
}
:global(.embed) {
background: linear-gradient(120deg, var(--bg-tertiary), var(--bg-secondary), var(--bg-tertiary));
display: flex;
align-items: center;
justify-content: center;
height: 300px;
overflow: hidden;
border-radius: 4px;
border: solid var(--borders) 1px;
}
</style>

View file

@ -0,0 +1,16 @@
<script lang="ts">
import EventDocument from "./EventDocument.svelte";
import type { Event } from "../../lib/api";
export let bucket: Event;
</script>
<div class="wrapper">
<EventDocument event={bucket} />
</div>
<style lang="scss">
.wrapper {
padding: 1em;
padding-bottom: 50%;
max-width: 75ch;
margin: 0 auto;
}
</style>

View file

@ -0,0 +1,22 @@
<script lang="ts">
import Document from "./Document.svelte";
import { api } from "../../lib/api";
import { asyncIterArray } from "../../lib/util";
import type { Event } from "../../lib/api";
export let event: Event;
$: embeds = fetchEmbeds(event);
async function fetchEmbeds(event: Event): Promise<Map<string, Event>> {
if (!event.relations) return new Map();
const relations = Object.entries(event.relations)
.filter(([_, rel]) => rel.type === "embed")
.map(([id, _]) => id);
const query = await asyncIterArray(api.query({ refs: relations }, false).events);
return new Map(query.map(i => [i.event.id, i.event]));
}
</script>
{#await embeds}
<Document doc={event.content.doc} />
{:then embeds}
<Document doc={event.content.doc} {embeds} />
{/await}

View file

@ -0,0 +1,4 @@
import Document from "./Document.svelte";
import EventDocument from "./EventDocument.svelte";
import EventDocScene from "./EventDocScene.svelte";
export { Document, EventDocument, EventDocScene };

View file

@ -43,7 +43,7 @@
}
function loadImages(options: URLSearchParams) {
const tags = options.getAll("tag");
const tags = options?.getAll("tag") ?? [];
const query = api.query({
refs: [bucket.id],
tags: tags.length ? tags : null,

View file

@ -1,6 +1,7 @@
<script lang="ts">
import Img from "../../atoms/Img.svelte";
import { events } from "../../events";
import type { Event } from "../../lib/api";
export let items;
@ -17,6 +18,14 @@
function handleMouseout(e: MouseEvent) {
tooltip.text = null;
}
function handleOpen(event: Event) {
if (event.derived?.file?.mime?.startsWith("audio/")) {
events.emit("open", "statusMusic", { event });
} else {
events.emit("popup", { type: "event", event });
}
}
</script>
<div>
<div
@ -27,7 +36,7 @@
>
{#each items as event (event.id)}
{@const info = event.derived?.file ?? {}}
<div class="item" on:click|preventDefault={() => events.emit("open", event)}>
<div class="item" on:click|preventDefault={() => handleOpen(event)}>
<Img {event} thumb={[100, 100]} errorPlaceholder={/^(image|video)\//.test(info.mime)} />
<div class="name">{event.content.name}</div>
</div>

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { timeAgo, formatSize } from "../../lib/util";
import { events } from "../../events";
import type { Event } from "../../lib/api";
export let items;
@ -17,6 +18,14 @@
function handleMouseout(e: MouseEvent) {
tooltip.text = null;
}
function handleOpen(event: Event) {
if (event.derived?.file?.mime?.startsWith("audio/")) {
events.emit("open", "statusMusic", { event });
} else {
events.emit("popup", { type: "event", event });
}
}
</script>
<div>
<table class="items">
@ -31,7 +40,7 @@
<tbody>
{#each items as event (event.id)}
<tr class="item">
<td class="name"><a href="#" on:click|preventDefault={() => events.emit("open", event)}>{event.content.name}</a></td>
<td class="name"><a href="#" on:click|preventDefault={() => handleOpen(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

@ -27,15 +27,15 @@
}
</script>
<!-- TODO (qol+future): syntax highlighting for source file -->
<div class="popup" on:click={close} transition:opacity>
<div on:click|stopPropagation transition:pop role="dialog">
<div class="popup" on:click={close} transition:opacity|global>
<div on:click|stopPropagation transition:pop|global role="dialog">
<div style="border-radius: 4px; overflow: hidden;">
{#if info.mime?.startsWith("image/")}
<Img {event} style="max-width: 80vw; max-height: 80vh" />
{:else if info.mime?.startsWith("audio/")}
<Audio {event} />
<Audio {event} autoplay />
{:else if info.mime?.startsWith("video/")}
<Video {event} style="max-width: 80vw; max-height: 80vh" />
<Video {event} style="max-width: 80vw; max-height: 80vh" autoplay />
{:else if info.mime?.startsWith("text/") || info.mime === "application/json"}
<div class="text">
<Text {event} />

View file

@ -4,22 +4,10 @@
import { api } from "../../lib/api";
import { timeAgo, collectEvents } from "../../lib/util";
import { events } from "./events";
import Comments from "./Comments.svelte";
import Post from "./Post.svelte";
export let options: URLSearchParams;
export let bucket: Event;
let { events: postEvents, stop: stopPosts } = loadPosts();
let commentTree, stopComments;
$: if (viewedPost) {
let load = loadComments(viewedPost);
commentTree = load.tree;
stopComments = load.stop;
}
// let searchBodyEl;
// let searchBody = "";
// $: search = searchBody?.toLocaleLowerCase() ?? "";
// $: filtered = $events.filter(ev => ev.content.body.toLocaleLowerCase().includes(search));
let { events: posts, stop } = loadPosts();
function loadPosts() {
const query = api.query({ refs: [bucket.id], relations: [["l.forum.post", "in"]] }, true);
@ -28,52 +16,9 @@
stop: query.stop,
};
}
function loadComments(event) {
// TODO: stream relations?
const query = api.query({ refs: [event.id], relations: [["l.forum.comment", "comment"]] });
let update;
const root = { event, children: [] };
const treeIndex = new Map();
const treeCollector = readable(root, (set) => { update = (val) => set(val) });
treeIndex.set(event.id, root);
(async () => {
for await (let { type, event } of query.events) {
if (type !== "relation") continue;
// TODO: circular comment check (presumably impossible if hashes are unique?)
let item;
if (treeIndex.has(event.id)) {
item = treeIndex.get(event.id);
item.event = event;
} else {
item = { event, children: [] };
treeIndex.set(event.id, item);
}
let parentId = Object.entries(event.relations).find(([_, rel]) => rel.type === "comment")?.[0];
const parent = treeIndex.get(parentId);
if (parent) {
parent.children.push(item);
treeIndex.set(event.id, { event, children: [] });
} else {
treeIndex.set(parentId, { event: null, children: [item] });
}
treeIndex.set(event.id, item);
}
update(root);
})();
return {
tree: treeCollector,
stop: query.stop,
};
}
let viewedPost;
let submitTitle, submitBody;
let commentBody, commentReply;
async function handlePost(e) {
const event = await api.makeEvent("l.forum.post", {
@ -85,66 +30,26 @@
submitTitle = submitBody = "";
await api.upload(event);
}
async function handleComment(e) {
const event = await api.makeEvent("l.forum.comment", {
body: commentBody || undefined,
}, {
[$selected ?? viewedPost.id]: { type: "comment" },
});
commentBody = "";
await api.upload(event);
}
let selected = writable(null);
function handleSelect(event) {
if (event) {
$selected = event.id;
} else {
$selected = null;
}
}
setContext("comment", { selected });
events.on("selectComment", handleSelect);
onDestroy(() => stopPosts());
onDestroy(() => stopComments?.());
onDestroy(() => events.off("selectComment", handleSelect));
onDestroy(() => stop());
</script>
<div>
<div class="wrapper">
{#if viewedPost}
<details open>
<summary>new comment</summary>
<form on:submit|preventDefault={handleComment}>
body: <textarea bind:value={commentBody} ></textarea><br />
<input type="submit" value="post">
</form>
</details>
<button on:click={() => viewedPost = ""}>back</button>
<ul>
<b style="font-weight: bold">{$commentTree.event.content.title || "no title"}</b><br />
{$commentTree.event.content.body || "no body"}
<hr />
{#each $commentTree.children as item}
<li><Comments root={item} /></li>
{/each}
</ul>
<Post forum={bucket} bind:post={viewedPost} />
{:else}
<details open>
<summary>new post</summary>
<form on:submit|preventDefault={handlePost}>
title: <input bind:value={submitTitle} /><br />
body: <textarea bind:value={submitBody} ></textarea><br />
<input type="submit" value="post">
</form>
</details>
<ul>
{#each $postEvents as event}
<form on:submit|preventDefault={handlePost}>
<em>new post</em>
<table>
<tr><td>title:</td><td><input bind:value={submitTitle} /></td></tr>
<tr><td>body:</td><td><textarea bind:value={submitBody} ></textarea></td></tr>
<tr><td></td><td><input type="submit" value="post"></td></tr>
</table>
</form>
<hr />
<ul class="posts">
{#each $posts as event (event.id)}
<li>
<a on:click={() => viewedPost = event}>{event.content.title || "no title"}</a>
<a on:click={() => viewedPost = event}>{event.content.title || "no title"}</a> -
{event.content.body || "no body"}
<!--{event.sender}
{event.origin_ts} -->
@ -153,5 +58,22 @@
</ul>
{/if}
</div>
<style>
<style lang="scss">
.wrapper {
padding: 8px;
}
.posts {
margin-left: 1em;
}
hr {
border-top: none;
border-bottom: solid var(--borders) 1px;
margin: 8px -1em;
}
em {
font-style: italic;
}
</style>

View file

@ -0,0 +1,134 @@
<script lang="ts">
import { onDestroy, setContext } from "svelte";
import { readable, writable } from "svelte/store";
import { api } from "../../lib/api";
import type { Event } from "../../lib/api";
import { timeAgo, collectEvents } from "../../lib/util";
import { events } from "./events";
import Comments from "./Comments.svelte";
export let forum: Event;
export let post: Event;
let commentBody: string;
let commentTree, stopComments;
$: if (post) {
stopComments?.();
({ tree: commentTree, stop: stopComments } = loadComments(post));
}
function loadComments(event) {
// TODO: stream relations?
const query = api.query({ refs: [event.id], relations: [["l.forum.comment", "comment"]] });
let update;
const root = { event, children: [] };
const treeIndex = new Map();
const treeCollector = readable(root, (set) => { update = (val) => set(val) });
treeIndex.set(event.id, root);
(async () => {
for await (let { type, event } of query.events) {
if (type !== "relation") continue;
if (!event.content.body) continue;
// TODO: circular comment check (presumably impossible if hashes are unique?)
let item;
if (treeIndex.has(event.id)) {
item = treeIndex.get(event.id);
item.event = event;
} else {
item = { event, children: [] };
treeIndex.set(event.id, item);
}
let parentId = Object.entries(event.relations).find(([_, rel]) => rel.type === "comment")?.[0];
const parent = treeIndex.get(parentId);
if (parent) {
parent.children.push(item);
treeIndex.set(event.id, { event, children: [] });
} else {
treeIndex.set(parentId, { event: null, children: [item] });
}
treeIndex.set(event.id, item);
}
update(root);
})();
return {
tree: treeCollector,
stop: query.stop,
};
}
async function handleComment(e) {
const event = await api.makeEvent("l.forum.comment", {
body: commentBody || undefined,
}, {
[$selected ?? post.id]: { type: "comment" },
});
commentBody = "";
await api.upload(event);
}
let selected = writable(null);
function handleSelect(event) {
if (event) {
$selected = event.id;
} else {
$selected = null;
}
}
setContext("comment", { selected });
events.on("selectComment", handleSelect);
onDestroy(() => stopComments?.());
onDestroy(() => events.off("selectComment", handleSelect));
</script>
<div class="wrapper">
<div>
<a href="#" on:click|preventDefault={() => post = null}>back</a> - {forum.content.name || "unnamed"}
</div>
<div class="post">
<h1 style="font-weight: bold">{$commentTree.event.content.title || "no title"}</h1>
<p>{$commentTree.event.content.body || "no body"}</p>
</div>
<hr />
<ul class="comments">
{#each $commentTree.children as item}
<li><Comments root={item} /></li>
{:else}
<em>no comments</em>
{/each}
</ul>
<hr />
<form on:submit|preventDefault={handleComment}>
<table>
<em>new comment</em>
{#if $selected}
<tr><td>reply:</td><td><button on:click={() => events.emit("selectComment", null)}>deselect</button></td></tr>
{/if}
<tr><td>comment:</td><td><textarea bind:value={commentBody}></textarea></td></tr>
<tr><td></td><td><input type="submit" value="post"></td></tr>
</table>
</form>
</div>
<style lang="scss">
.post {
margin: 1em 0;
}
.comments {
margin-left: 1em;
}
hr {
border-top: none;
border-bottom: solid var(--borders) 1px;
margin: 8px -1em;
}
em {
font-style: italic;
}
</style>

View file

@ -3,7 +3,7 @@
import { api } from "../lib/api";
let name: string;
async function create(type: string) {
const event = await api.makeEvent(type, { name });
const ref = await api.upload(event);
@ -13,7 +13,9 @@
<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>
<p>
If you have a private key and token, you can set it in <a href="#/settings">settings</a>.
</p>
<hr />
<Menu>
<Item>foo</Item>
@ -23,8 +25,18 @@
<Item>two</Item>
<Item>more</Item>
</Submenu>
<Submenu name="list">
<List items={["apple", "banana", "orange", "grapefruit", "papaya", "snozzberries", "chorusfruit"]} />
<Submenu name="fruit">
<List
items={[
"apple",
"banana",
"orange",
"grapefruit",
"papaya",
"snozzberries",
"chorusfruit",
]}
/>
</Submenu>
</Menu>
<hr />
@ -33,6 +45,12 @@
<!-- <button on:click={() => create("l.links")}>create links</button><br /> -->
<button on:click={() => create("l.notes")}>create notes</button><br />
<button on:click={() => create("l.forum")}>create forum</button><br />
<hr />
internal links
<ul>
<li><a href="/#/sha224-6460818d593e06740f810da63606e46ca80d91bc4c4ab264447e5f60">document</a></li>
<li><a href="/#/sha224-58291f97369b5c4026efcd42692569d9603117d46fabbd7bc14787ca">document</a></li>
</ul>
</div>
<style lang="scss">
.wrapper {

View file

@ -50,7 +50,7 @@
</table>
<style lang="scss">
table {
padding: 1em;
margin: 1em;
& td:first-child {
padding-right: 8px;

View file

@ -1,20 +1,28 @@
import { events } from "./events";
import { readable } from "svelte/store";
import type { Popup, Singletons } from "./events";
interface FakeMutex<T> {
take(): T,
set(thing: T): void,
}
interface State {
sidebar: boolean,
pins: boolean,
statusMusic: Event | null,
popup: Event | null,
statusMusic: { event: Event, time?: number } | null,
popups: Array<Popup>,
audio: FakeMutex<HTMLAudioElement>,
}
function update(_, update) {
const merge = (obj) => update(state => ({ ...state, ...obj }));
const merge = (obj: Partial<State>) => update(state => ({ ...state, ...obj }));
function handleOpen(val) {
function handleOpen(val, ctx) {
switch (val) {
case "pins": return merge({ pins: true });
case "sidebar": return merge({ sidebar: true });
case "statusMusic": return merge({ statusMusic: ctx });
}
// hacky basic event assertion
@ -23,33 +31,55 @@ function update(_, update) {
if (val.derived?.file?.mime?.startsWith("audio/")) {
merge({ statusMusic: val });
} else {
merge({ popup: val });
update(state => ({ ...state, popups: [...state.popups, { type: "event", event: val }]}));
}
}
function handlePopup(popup: Popup) {
update(state => ({ ...state, popups: [...state.popups, popup] }));
}
function handleClose(val) {
switch (val) {
case "pins": return merge({ pins: false });
case "sidebar": return merge({ sidebar: false });
case "statusMusic": return merge({ statusMusic: null });
case "popup": return merge({ popup: null });
case "popup": return update(state => ({ ...state, popups: state.popups.slice(0, -1) }));
}
}
events.on("open", handleOpen);
events.on("popup", handlePopup);
events.on("close", handleClose);
return function stop() {
events.off("open", handleOpen);
events.off("popup", handlePopup);
events.off("close", handleClose);
}
}
function fakeMutex<K extends keyof Singletons, T extends Singletons[K]>(name: K, thing: T | null): FakeMutex<T> {
return {
take(): T {
events.emit("take", name)
return thing!;
},
set(newthing: T) {
events.emit("take", name)
thing = newthing;
},
};
}
const initial: State = {
sidebar: window.innerWidth > 1080,
pins: true,
statusMusic: null,
popup: null,
popups: [],
audio: fakeMutex("audio", null),
};
export const state = readable(initial, update);
globalThis.state = state;

View file

@ -1,4 +1,5 @@
<script lang="ts">
// TODO: merge/deduplicate with audio atom?
import brokenImgUrl from "../assets/broken.png";
import { api } from "../lib/api";
import { events } from "../events";
@ -11,9 +12,11 @@
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";
import { onDestroy } from "svelte";
import { onDestroy, onMount } from "svelte";
import { formatTime } from "../lib/util";
import type { Event } from "../lib/api";
export let event: Event;
export let time: number = 0;
export let small: boolean;
$: url = api.getBlobUrl(event.id);
$: media = event.derived?.media ?? {};
@ -27,20 +30,6 @@
let loading = true;
let loadingDebounce: number;
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;
@ -100,6 +89,12 @@
}
}
function hijackMedia() {
for (let el of document.querySelectorAll("audio")) {
if (el !== audio) el.pause();
}
}
$: navigator.mediaSession.metadata = new MediaMetadata({
title: event.derived?.media?.title,
album: event.derived?.media?.album,
@ -111,9 +106,19 @@
});
navigator.mediaSession?.setActionHandler("stop", () => events.emit("close", "statusMusic"));
function handleUpdate(thing, ctx) {
if (thing !== "statusMusic") return;
if (ctx.time) queueMicrotask(() => currentTime = ctx.time);
}
events.on("open", handleUpdate);
onMount(() => currentTime = time);
onDestroy(() => {
if ("mediaSession" in navigator) navigator.mediaSession.metadata = null;
events.off("open", handleUpdate);
});
</script>
<div class="wrapper" class:small>
@ -145,9 +150,9 @@
<div class="controls">
<div class="time" on:wheel={scrubWheel}>
<span class:preview={previewTime !== null}>
{fmtTime(previewTime ?? currentTime ?? 0)}
{formatTime(previewTime ?? currentTime ?? 0)}
</span>
/ {fmtTime(duration ?? event.derived?.file?.duration)}
/ {formatTime(duration ?? event.derived?.file?.duration)}
</div>
<button on:click={() => (paused ? audio.play() : audio.pause())}>
{#if paused}<PlayIc />{:else}<PauseIc />{/if}
@ -183,6 +188,7 @@
on:stalled={handleLoading}
on:loadstart={handleLoading}
on:ended={() => events.emit("close", "statusMusic")}
on:play={hijackMedia}
/>
</div>
<style lang="scss">

View file

@ -37,6 +37,50 @@ a:hover, a:focus-visible {
text-decoration: underline;
}
b, strong {
font-weight: bold;
}
// yes, i know italic and oblique are different and i don't care
i, em {
font-style: italic;
}
// seems strange why they go off the page by default
ol, ul {
margin-left: 1em;
}
pre, code {
font-family: monospace;
background: var(--bg-secondary);
padding: 0 2px;
border-radius: 2px;
}
pre {
overflow-x: auto;
padding: 4px;
}
table {
border: solid var(--borders) 1px;
border-radius: 4px;
border-spacing: 0;
& > thead {
background: var(--bg-tertiary);
}
& > tbody > tr:nth-child(even) {
background: var(--bg-secondary);
}
& td, th {
padding: 8px;
}
}
input, textarea, button, select {
background: var(--bg-secondary);
color: inherit;