From 11b16bd692c2b99ee02f02b9830576280a004bfe Mon Sep 17 00:00:00 2001
From: Ben Little
Date: Thu, 25 Jul 2024 11:45:26 -0600
Subject: [PATCH] Feature/notifications (#9)
* basic mock with no styles
* new media appears in list
* media inits properly
* mock with notifications about new media
* rename toast -> notification
* notifs with sse
* update TODOs
* rm unnused type def
---
TODO.md | 28 +++--
api/main.go | 57 ++++++++-
env.sh | 8 +-
nginx.conf | 4 +-
ui/src/client/http.rs | 9 ++
ui/src/client/mock.rs | 39 +++++-
ui/src/components.rs | 33 +-----
ui/src/components/dashboard.rs | 98 ++++++++-------
ui/src/components/notification_tray.rs | 38 ++++++
ui/src/data.rs | 27 ++++-
ui/src/lib.rs | 157 ++++++++++++-------------
ui/style.scss | 19 ++-
12 files changed, 327 insertions(+), 190 deletions(-)
create mode 100644 ui/src/components/notification_tray.rs
diff --git a/TODO.md b/TODO.md
index f6cb8b4..4c47104 100644
--- a/TODO.md
+++ b/TODO.md
@@ -8,10 +8,12 @@
- [x] Select should preserve scroll
- [x] Filter media in selector by query
- [x] Media detail on hover
-- [ ] Toaster notifications
- - [ ] new media
+- [x] notifications
+ - [x] new media
- [x] Uploads
-
+- [ ] hightlight new media items
+- [ ] improve notification tray look and feel
+
### API
- [ ] Persist storage on server
@@ -20,10 +22,18 @@
- [ ] on-disk kv storage
- [ ] sqlite storage
- [ ] garbage collection instead of deletion
-- [ ] Improve metadata automation
+- [x] Improve metadata automation
- [x] `ffprobe` to mine metadata
- ~~[ ] Use collections for nested directories~~
- [x] Recurse directories for media discovery
+
+### Protocol
+
+- [ ] Bidi communication
+ - [ ] Send ack for accepted updates
+- [ ] Abstractions
+ - [ ] Transport (req+sse, ws, quic)
+ - [ ] Remotes (servers, peers)
## Maintenance
@@ -42,7 +52,7 @@
- [ ] Resumable
- [ ] Upload progress bars
- [ ] Use object storage
-- [ ] Alternative protocols (ws,quic)
+- ~~[ ] Alternative protocols (ws,quic)~~
- [ ] API tests
## PR
@@ -50,11 +60,3 @@
- [x] Dry demo
- [ ] User docs
- [x] GitHub pages automation
-
-## Under Consideration
-
-- [x] better data model and sync procedure (CRDT?)
- - [x] database in browser storage
- - [x] only pull out-of-date items from server
- - [ ] ~~treat browser storage as write-back cache~~
-- [ ] ~~Consider HTTP/2 for event streams~~
diff --git a/api/main.go b/api/main.go
index 59328a5..0b8b5ab 100644
--- a/api/main.go
+++ b/api/main.go
@@ -4,12 +4,14 @@ import (
"bytes"
"encoding/json"
"fmt"
+ "io"
"log"
"net/http"
"os"
"os/exec"
"path"
"strings"
+ "sync"
"github.com/fsnotify/fsnotify"
"github.com/gin-gonic/gin"
@@ -39,7 +41,7 @@ func main() {
path_to_id := make(map[string]string)
media := NewMemCollection()
- index := func(p string) {
+ index := func(p string) gin.H {
title, format := extract_title_format(p)
if format == "unknown" {
title, format = ffprobe(p)
@@ -48,25 +50,72 @@ func main() {
id, err := media.Create(url, title, format)
if err != nil {
log.Print(err)
+ return nil
}
path_to_id[p] = id
+ return gin.H{
+ "id": id,
+ "title": title,
+ "format": format,
+ "url": url,
+ }
+
}
walk("/data", index)
+
+ clients := make(map[chan gin.H]struct{})
+ mu := sync.Mutex{}
watch("/data", func(ev fsnotify.Event) {
switch ev.Op {
case fsnotify.Create:
log.Printf("create %s", ev.Name)
- index(ev.Name)
+ item := index(ev.Name)
+ mu.Lock()
+ log.Printf("clients: %d", len(clients))
+ for client := range clients {
+ client <- item
+ }
+ mu.Unlock()
case fsnotify.Rename, fsnotify.Remove:
log.Printf("remove %s", ev.Name)
id := path_to_id[ev.Name]
- media.Drop(id)
+ if err := media.Drop(id); err != nil {
+ log.Print(err)
+ }
}
})
router := gin.Default()
add_collection(router, media, "media")
+ router.GET("/events/media", func(c *gin.Context) {
+ c.Writer.Header().Set("Content-Type", "text/event-stream")
+ c.Writer.Header().Set("Cache-Control", "no-cache")
+ c.Writer.Header().Set("Connection", "keep-alive")
+ c.Writer.Header().Set("Transfer-Encoding", "chunked")
+ ch := make(chan gin.H)
+ log.Printf("new client")
+ mu.Lock()
+ clients[ch] = struct{}{}
+ mu.Unlock()
+ defer func() {
+ log.Printf("closing client channel")
+ mu.Lock()
+ close(ch)
+ delete(clients, ch)
+ mu.Unlock()
+ }()
+ c.Stream(func(_ io.Writer) bool {
+ if event, ok := <-ch; ok {
+ log.Printf("sending event to client")
+ c.SSEvent("message", event)
+ log.Printf("event sent")
+ return true
+ }
+ return false
+ })
+ })
+
log.Fatal(router.Run())
}
@@ -134,7 +183,7 @@ func add_collection(router *gin.Engine, coll Collection, name string) {
})
}
-func walk(dir string, f func(string)) {
+func walk(dir string, f func(string) gin.H) {
if entries, err := os.ReadDir(dir); err != nil {
log.Print(err)
} else {
diff --git a/env.sh b/env.sh
index dcc2735..683ff0d 100644
--- a/env.sh
+++ b/env.sh
@@ -1,5 +1,11 @@
#!/bin/bash
function serve {
- RUSTFLAGS=--cfg=web_sys_unstable_apis APP_BASE_PATH=media-manager trunk serve --features=demo
+ pushd ui || exit 1
+ RUSTFLAGS=--cfg=web_sys_unstable_apis APP_BASE_PATH=media-manager trunk serve --features=demo
+ popd || exit 1
+}
+
+function nginx-reload {
+ podman-compose -f compose.yaml exec nginx nginx -s reload
}
diff --git a/nginx.conf b/nginx.conf
index 0aa1ab4..9b40a56 100644
--- a/nginx.conf
+++ b/nginx.conf
@@ -68,8 +68,8 @@ http {
proxy_pass http://api:8080/;
}
- location ~ /api/(media|jobs)/subscribe {
- # proxy_pass http://api:8080/events;
+ location /api/events/ {
+ proxy_pass http://api:8080/events/;
# add_header 'Access-Control-Allow-Origin' '*' always;
proxy_http_version 1.1;
proxy_read_timeout 300s;
diff --git a/ui/src/client/http.rs b/ui/src/client/http.rs
index 10d0feb..7dcb35c 100644
--- a/ui/src/client/http.rs
+++ b/ui/src/client/http.rs
@@ -1,4 +1,5 @@
use crate::{data::MediaItem, log};
+use leptos::*;
#[inline]
fn origin() -> String {
@@ -64,3 +65,11 @@ pub async fn upload_file(file: web_sys::File) {
}
};
}
+
+pub fn new_media() -> Signal
}
- }>
- {move || {
- item()
- .map(|item| {
- view! {
-
-
-
- }
- })
- }}
-
-
}
}
@@ -142,9 +128,7 @@ where
fn DetailTable(item: MediaItem) -> impl IntoView {
let params = use_params_map();
let id = move || params.with(|p| p.get("id").unwrap().clone());
- let update =
- use_context::>>()
- .unwrap();
+ let update = use_context::>>().unwrap();
view! {
@@ -154,7 +138,14 @@ fn DetailTable(item: MediaItem) -> impl IntoView {
{view! {
}
.into_view()}
@@ -168,7 +159,14 @@ fn DetailTable(item: MediaItem) -> impl IntoView {
{view! {
}
.into_view()}
diff --git a/ui/src/components/notification_tray.rs b/ui/src/components/notification_tray.rs
new file mode 100644
index 0000000..5e331e1
--- /dev/null
+++ b/ui/src/components/notification_tray.rs
@@ -0,0 +1,38 @@
+use std::collections::HashMap;
+
+use leptos::*;
+
+#[component]
+pub fn NotificationTray(message: F) -> impl IntoView
+where
+ F: Fn() -> Option + Copy + 'static,
+{
+ let notifs = create_rw_signal(HashMap::::new());
+ create_effect(move |_| {
+ if let Some(item) = message() {
+ let id = uuid::Uuid::new_v4().to_string();
+ notifs.update(|notifs| {
+ notifs.insert(id.clone(), item);
+ });
+ let timeout_fn = leptos_use::use_timeout_fn(
+ move |_| {
+ notifs.update(|notifs| {
+ notifs.remove(&id);
+ });
+ },
+ 5_000.0,
+ );
+ (timeout_fn.start)(());
+ }
+ });
+ view! {
+
+
"Notification Tray"
+ {notif} }
+ />
+
+ }
+}
diff --git a/ui/src/data.rs b/ui/src/data.rs
index eeb2ccc..df6b767 100644
--- a/ui/src/data.rs
+++ b/ui/src/data.rs
@@ -1,9 +1,34 @@
use serde::{Deserialize, Serialize};
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MediaItem {
pub id: String,
pub url: String,
pub title: String,
pub format: String,
}
+
+impl MediaItem {
+ pub fn kind(&self) -> &'static str {
+ match self.format.as_str() {
+ "mkv" | "mp4" | "ogg" | "webm" => "video",
+ "jpeg" | "jpg" | "png" | "webp" => "image",
+ _ => "unknown",
+ }
+ }
+
+ pub fn update(&mut self, field: String, value: String) {
+ match field.as_str() {
+ "title" => self.title = value,
+ "format" => self.format = value,
+ _ => {}
+ }
+ }
+}
+
+#[derive(Clone)]
+pub struct MediaUpdate {
+ pub id: String,
+ pub field: String,
+ pub value: String,
+}
diff --git a/ui/src/lib.rs b/ui/src/lib.rs
index 1782a09..8d4b384 100644
--- a/ui/src/lib.rs
+++ b/ui/src/lib.rs
@@ -2,7 +2,6 @@
use std::collections::HashMap;
-use futures::{channel::mpsc::channel, SinkExt};
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
@@ -13,9 +12,10 @@ mod components;
mod data;
mod pages;
-use data::MediaItem;
+use data::{MediaItem, MediaUpdate};
use components::dashboard::{Editor, Selector};
+use components::notification_tray::NotificationTray;
#[macro_export]
macro_rules! log {
@@ -30,16 +30,6 @@ macro_rules! log {
));
}
-#[macro_export]
-macro_rules! unwrap_js {
- ($result:expr) => {
- match $result {
- Ok(v) => v,
- Err(e) => anyhow::bail!(e.as_string().unwrap()),
- }
- };
-}
-
/// Return the relative path from `APP_BASE_PATH`
pub(crate) fn path(p: &str) -> String {
if let Some(base) = option_env!("APP_BASE_PATH") {
@@ -55,40 +45,55 @@ pub(crate) fn path(p: &str) -> String {
#[component]
pub fn App() -> impl IntoView {
- let server_media = create_local_resource(|| (), |_| async { crate::client::get_media().await });
- let media = create_rw_signal(Vec::>::new());
- create_effect(move |_| {
- if let Some(m) = server_media.get() {
- media.set(m.into_iter().map(|item| create_rw_signal(item)).collect())
+ let (media, set_media) = create_signal(HashMap::::new());
+ let get_media_action = create_action(|_: &()| async move { client::get_media().await });
+ create_effect({
+ let val = get_media_action.value();
+ move |_| {
+ if let Some(items) = val.get() {
+ for item in items {
+ set_media.update(|m| {
+ m.insert(item.id.clone(), item);
+ })
+ }
+ }
}
});
- let update = create_action(move |v: &(String, String, String)| {
- let (id, field, value) = v.clone();
+ get_media_action.dispatch(());
+ let update_item_action = create_action(|update: &MediaUpdate| {
+ let u = update.clone();
async move {
- match client::update_media(id.clone(), field.clone(), value.clone()).await {
- Ok(true) => Some((id, field, value)),
+ match client::update_media(u.id.clone(), u.field.clone(), u.value.clone()).await {
+ Ok(true) => Some(u),
_ => None,
}
}
});
- let update_value = update.value();
- create_effect(move |_| {
- if let Some((id, field, value)) = update_value.get().flatten() {
- if let Some(item) = media
- .get_untracked()
- .into_iter()
- .find(|item| item.get_untracked().id == id)
- {
- item.update(move |item| match field.as_str() {
- "title" => item.title = value,
- "format" => item.format = value,
- f => log!("unknown field: {}", f),
+ create_effect({
+ let val = update_item_action.value();
+ move |_| {
+ if let Some(u) = val.get().flatten() {
+ set_media.update(|m| {
+ if let Some(item) = m.get_mut(&u.id) {
+ item.update(u.field, u.value)
+ }
})
}
}
});
+ let new_media_source = client::new_media();
+ let (new_media, set_new_media) = create_signal(None::<(String, MediaItem)>);
+ create_effect(move |_| {
+ if let Some(item) = new_media_source.get() {
+ let id = item.id.clone();
+ set_media.update(|m| {
+ m.insert(id.clone(), item.clone());
+ });
+ set_new_media.set(Some((id, item)))
+ }
+ });
+ provide_context(update_item_action);
provide_context(media);
- provide_context(update);
provide_meta_context();
view! {
@@ -103,10 +108,10 @@ pub fn App() -> impl IntoView {
"Home"
- "Videos"
+ "Videos"
- "Images"
+ "Images"
@@ -123,18 +128,30 @@ pub fn App() -> impl IntoView {
+ "New Media! " {item.title}
+ }
+ .into_view()
+ })
+ }/>
@@ -144,58 +161,50 @@ pub fn App() -> impl IntoView {
}
>
- "No Video Selected" }/>
+
-
-
-
-
-
+
+
}
}
>
- "No Image Selected" }/>
+
}
- }
- />
+ }
+ }/>
}
}
/>
@@ -207,17 +216,3 @@ pub fn App() -> impl IntoView {
}
}
-
-fn video_filter(m: &&MediaItem) -> bool {
- match m.format.as_str() {
- "mp4" | "webm" | "mkv" | "ogg" => true,
- _ => false,
- }
-}
-
-fn image_filter(m: &&MediaItem) -> bool {
- match m.format.as_str() {
- "jpg" | "jpeg" | "png" | "webp" => true,
- _ => false,
- }
-}
diff --git a/ui/style.scss b/ui/style.scss
index 14c22cb..8ea6833 100644
--- a/ui/style.scss
+++ b/ui/style.scss
@@ -197,8 +197,8 @@ nav ul {
video,
img {
- width: 100%;
height: 100%;
+ max-width: 100%;
}
}
@@ -431,4 +431,19 @@ button:hover {
a:hover {
color: #5ad;
}
-}
\ No newline at end of file
+}
+
+#notification-tray {
+ z-index: 1;
+ position: absolute;
+ bottom: 0;
+ right: 2rem;
+ width: 16rem;
+ border-style: solid;
+ border-radius: 8px;
+ border-color: #9643ca;
+ border-width: 1px;
+ border-bottom: none;
+ border-bottom-right-radius: 0;
+ border-bottom-left-radius: 0;
+}