fix relations and impove web ui
This commit is contained in:
parent
3604cc1fd4
commit
a63d0d6122
33 changed files with 1126 additions and 194 deletions
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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 => (),
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
4
spec/README.md
Normal 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.
|
|
@ -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 {
|
||||
|
|
|
@ -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
51
web/atoms/Popup.svelte
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
175
web/scenes/Document/Document.svelte
Normal file
175
web/scenes/Document/Document.svelte
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
}
|
||||
</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>
|
16
web/scenes/Document/EventDocScene.svelte
Normal file
16
web/scenes/Document/EventDocScene.svelte
Normal 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>
|
22
web/scenes/Document/EventDocument.svelte
Normal file
22
web/scenes/Document/EventDocument.svelte
Normal 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}
|
4
web/scenes/Document/index.ts
Normal file
4
web/scenes/Document/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import Document from "./Document.svelte";
|
||||
import EventDocument from "./EventDocument.svelte";
|
||||
import EventDocScene from "./EventDocScene.svelte";
|
||||
export { Document, EventDocument, EventDocScene };
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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>
|
||||
|
|
134
web/scenes/Forum/Post.svelte
Normal file
134
web/scenes/Forum/Post.svelte
Normal 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>
|
|
@ -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 {
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
</table>
|
||||
<style lang="scss">
|
||||
table {
|
||||
padding: 1em;
|
||||
margin: 1em;
|
||||
|
||||
& td:first-child {
|
||||
padding-right: 8px;
|
||||
|
|
44
web/state.ts
44
web/state.ts
|
@ -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;
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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;
|
Loading…
Reference in a new issue