Move scroll animations to Rust/WASM, enable Hasura, and move bookmarks to home sidebar
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 12m7s

Port AutoScroll and Headline scroll logic from Vue/JS to Rust compiled
to WASM via wasm-pack. Add multi-stage Docker build for WASM compilation,
Vite WASM plugins, and top-level await for WASM init. Enable Hasura
service in docker-compose. Move bookmarks from a separate route to an
inline sidebar component on the home page. Fix ToggleHeader click
propagation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 16:34:17 +01:00
parent b56f8253d9
commit 2b5745b946
17 changed files with 831 additions and 162 deletions

3
.gitignore vendored
View File

@@ -17,6 +17,9 @@ gitea-runner/nohup.out
# Rust build artifacts # Rust build artifacts
**/target/ **/target/
# Generated WASM output
vue/src/wasm/
# Logs # Logs
logs logs
*.log *.log

View File

@@ -35,6 +35,7 @@ services:
- backend - backend
- icecast2 - icecast2
- gitea - gitea
- hasura
- quartz - quartz
- searxng - searxng
networks: networks:
@@ -112,8 +113,6 @@ services:
image: hasura/graphql-engine:v2.44.0 image: hasura/graphql-engine:v2.44.0
container_name: "${HASURA_HOST}" container_name: "${HASURA_HOST}"
restart: always restart: always
profiles:
- disabled
depends_on: depends_on:
- db - db
networks: networks:

View File

@@ -1,7 +1,17 @@
# Stage 1: Build WASM from Rust
FROM rust:slim AS wasm-builder
RUN rustup target add wasm32-unknown-unknown \
&& cargo install wasm-pack
WORKDIR /wasm
COPY crates/stp_wasm/ crates/stp_wasm/
RUN wasm-pack build crates/stp_wasm --target web --out-dir ../../src/wasm
# Stage 2: Build Vue frontend
FROM node:22-slim FROM node:22-slim
RUN apt-get update && apt-get install -y make git && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y make git && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
CMD ["sh", "-c", "npm run build -- --outDir /output --emptyOutDir"] COPY --from=wasm-builder /wasm/src/wasm/ src/wasm/
CMD ["sh", "-c", "npx vite build --outDir /output --emptyOutDir"]

View File

@@ -3,6 +3,9 @@ name = "stp_wasm"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies] [dependencies]
js-sys = "0.3.85" js-sys = "0.3.85"
wasm-bindgen = "0.2.108" wasm-bindgen = "0.2.108"
@@ -10,6 +13,13 @@ web-sys = { version = "0.3.85", features = [
"console", "console",
"Document", "Document",
"Element", "Element",
"HtmlElement",
"Window", "Window",
"Animation", "Animation",
"CssStyleDeclaration",
"ResizeObserver",
"ResizeObserverEntry",
"ResizeObserverSize",
"EventTarget",
"MouseEvent",
] } ] }

View File

@@ -0,0 +1,259 @@
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::prelude::*;
use web_sys::HtmlElement;
const SPEED: f64 = 0.0005; // % per frame
const PAUSE: i32 = 2000; // ms at top/bottom
struct Inner {
el: HtmlElement,
pos: f64,
direction: f64, // 1.0 = down, -1.0 = up
hovered: bool,
cached_scroll_height: f64,
raf_id: Option<i32>,
pause_timeout_id: Option<i32>,
resize_observer: Option<web_sys::ResizeObserver>,
// closures kept alive
tick_closure: Option<Closure<dyn FnMut()>>,
resize_closure: Option<Closure<dyn FnMut(js_sys::Array)>>,
mouseenter_closure: Option<Closure<dyn FnMut()>>,
mouseleave_closure: Option<Closure<dyn FnMut()>>,
start_after_pause_closure: Option<Closure<dyn FnMut()>>,
}
#[wasm_bindgen]
pub struct AutoScroller {
inner: Rc<RefCell<Inner>>,
}
impl Inner {
fn measure_scroll_height(&mut self) {
self.cached_scroll_height = self.el.scroll_height() as f64;
}
fn stop_loop(&mut self) {
let window = web_sys::window().unwrap();
if let Some(id) = self.raf_id.take() {
window.cancel_animation_frame(id).ok();
}
if let Some(id) = self.pause_timeout_id.take() {
window.clear_timeout_with_handle(id);
}
}
}
fn start_loop(inner: &Rc<RefCell<Inner>>) {
{
let mut s = inner.borrow_mut();
s.stop_loop();
}
schedule_tick(inner);
}
fn schedule_tick(inner: &Rc<RefCell<Inner>>) {
let inner_clone = Rc::clone(inner);
// Create a fresh tick closure each frame
let closure = Closure::once(move || {
tick(&inner_clone);
});
let mut s = inner.borrow_mut();
let window = web_sys::window().unwrap();
let id = window
.request_animation_frame(closure.as_ref().unchecked_ref())
.unwrap();
s.raf_id = Some(id);
// Store the closure to keep it alive until the frame fires
s.tick_closure = Some(closure);
}
fn tick(inner: &Rc<RefCell<Inner>>) {
let should_continue;
{
let mut s = inner.borrow_mut();
s.raf_id = None;
if s.hovered {
return;
}
if s.cached_scroll_height == 0.0 {
drop(s);
schedule_tick(inner);
return;
}
let reached_bottom = s.pos >= 1.0;
let reached_top = s.pos <= 0.0;
if reached_bottom {
s.pos = 0.999;
s.direction = -1.0;
drop(s);
schedule_pause(inner);
return;
} else if reached_top && s.direction == -1.0 {
s.pos = 0.001;
s.direction = 1.0;
drop(s);
schedule_pause(inner);
return;
}
s.pos += s.direction * SPEED;
s.el.set_scroll_top((s.pos * s.cached_scroll_height) as i32);
should_continue = true;
}
if should_continue {
schedule_tick(inner);
}
}
fn schedule_pause(inner: &Rc<RefCell<Inner>>) {
{
let mut s = inner.borrow_mut();
s.stop_loop();
}
let inner_clone = Rc::clone(inner);
let closure = Closure::once(move || {
start_loop(&inner_clone);
});
let mut s = inner.borrow_mut();
let window = web_sys::window().unwrap();
let id = window
.set_timeout_with_callback_and_timeout_and_arguments_0(
closure.as_ref().unchecked_ref(),
PAUSE,
)
.unwrap();
s.pause_timeout_id = Some(id);
s.start_after_pause_closure = Some(closure);
}
#[wasm_bindgen]
impl AutoScroller {
#[wasm_bindgen(constructor)]
pub fn new(el: HtmlElement) -> AutoScroller {
let inner = Rc::new(RefCell::new(Inner {
el,
pos: 0.0,
direction: 1.0,
hovered: false,
cached_scroll_height: 0.0,
raf_id: None,
pause_timeout_id: None,
resize_observer: None,
tick_closure: None,
resize_closure: None,
mouseenter_closure: None,
mouseleave_closure: None,
start_after_pause_closure: None,
}));
// Set up mouseenter listener
{
let inner_clone = Rc::clone(&inner);
let closure = Closure::wrap(Box::new(move || {
let mut s = inner_clone.borrow_mut();
s.hovered = true;
s.stop_loop();
}) as Box<dyn FnMut()>);
let s = inner.borrow();
s.el
.add_event_listener_with_callback("mouseenter", closure.as_ref().unchecked_ref())
.unwrap();
drop(s);
inner.borrow_mut().mouseenter_closure = Some(closure);
}
// Set up mouseleave listener
{
let inner_clone = Rc::clone(&inner);
let closure = Closure::wrap(Box::new(move || {
{
let mut s = inner_clone.borrow_mut();
s.hovered = false;
if s.cached_scroll_height > 0.0 {
s.pos = s.el.scroll_top() as f64 / s.cached_scroll_height;
}
}
start_loop(&inner_clone);
}) as Box<dyn FnMut()>);
let s = inner.borrow();
s.el
.add_event_listener_with_callback("mouseleave", closure.as_ref().unchecked_ref())
.unwrap();
drop(s);
inner.borrow_mut().mouseleave_closure = Some(closure);
}
AutoScroller { inner }
}
pub fn start(&self) {
// Measure initial scroll height
self.inner.borrow_mut().measure_scroll_height();
// Set up resize observer
let inner_clone = Rc::clone(&self.inner);
let resize_closure = Closure::wrap(Box::new(move |_entries: js_sys::Array| {
inner_clone.borrow_mut().measure_scroll_height();
}) as Box<dyn FnMut(js_sys::Array)>);
let observer =
web_sys::ResizeObserver::new(resize_closure.as_ref().unchecked_ref()).unwrap();
// Clone the element ref and drop the borrow before calling observe(),
// because observe() can fire the resize callback synchronously,
// which would conflict with an active borrow.
let el_clone = self.inner.borrow().el.clone();
observer.observe(&el_clone);
{
let mut s = self.inner.borrow_mut();
s.resize_observer = Some(observer);
s.resize_closure = Some(resize_closure);
}
// Start with a pause then begin scrolling
schedule_pause(&self.inner);
}
pub fn destroy(&self) {
let mut s = self.inner.borrow_mut();
s.stop_loop();
if let Some(observer) = s.resize_observer.take() {
observer.disconnect();
}
if let Some(ref closure) = s.mouseenter_closure {
s.el
.remove_event_listener_with_callback(
"mouseenter",
closure.as_ref().unchecked_ref(),
)
.ok();
}
if let Some(ref closure) = s.mouseleave_closure {
s.el
.remove_event_listener_with_callback(
"mouseleave",
closure.as_ref().unchecked_ref(),
)
.ok();
}
s.mouseenter_closure = None;
s.mouseleave_closure = None;
s.resize_closure = None;
s.tick_closure = None;
s.start_after_pause_closure = None;
}
}

View File

@@ -0,0 +1,135 @@
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::prelude::*;
use web_sys::HtmlElement;
const SPEED: f64 = 0.5; // pixels per frame
struct Inner {
container: HtmlElement,
item1: HtmlElement,
offset: f64,
cached_width: f64,
raf_id: Option<i32>,
resize_observer: Option<web_sys::ResizeObserver>,
// closures kept alive
animate_closure: Option<Closure<dyn FnMut()>>,
resize_closure: Option<Closure<dyn FnMut(js_sys::Array)>>,
}
#[wasm_bindgen]
pub struct HeadlineScroller {
inner: Rc<RefCell<Inner>>,
}
impl Inner {
fn measure_width(&mut self) {
let container_width = self.container.offset_width() as f64;
let item_width = self.item1.scroll_width() as f64;
self.cached_width = container_width.max(item_width);
}
}
fn schedule_frame(inner: &Rc<RefCell<Inner>>) {
let inner_clone = Rc::clone(inner);
let closure = Closure::once(move || {
animate(&inner_clone);
});
let mut s = inner.borrow_mut();
let window = web_sys::window().unwrap();
let id = window
.request_animation_frame(closure.as_ref().unchecked_ref())
.unwrap();
s.raf_id = Some(id);
s.animate_closure = Some(closure);
}
fn animate(inner: &Rc<RefCell<Inner>>) {
{
let mut s = inner.borrow_mut();
s.raf_id = None;
if s.cached_width == 0.0 {
drop(s);
schedule_frame(inner);
return;
}
s.offset -= SPEED;
if s.offset <= -s.cached_width {
s.offset += s.cached_width;
}
let transform = format!("translateX({}px)", s.offset);
s.container.style().set_property("transform", &transform).ok();
}
schedule_frame(inner);
}
#[wasm_bindgen]
impl HeadlineScroller {
#[wasm_bindgen(constructor)]
pub fn new(container: HtmlElement, item1: HtmlElement) -> HeadlineScroller {
let inner = Rc::new(RefCell::new(Inner {
container,
item1,
offset: 0.0,
cached_width: 0.0,
raf_id: None,
resize_observer: None,
animate_closure: None,
resize_closure: None,
}));
HeadlineScroller { inner }
}
pub fn start(&self) {
// Measure initial width
self.inner.borrow_mut().measure_width();
// Set up resize observer
let inner_clone = Rc::clone(&self.inner);
let resize_closure = Closure::wrap(Box::new(move |_entries: js_sys::Array| {
inner_clone.borrow_mut().measure_width();
}) as Box<dyn FnMut(js_sys::Array)>);
let observer =
web_sys::ResizeObserver::new(resize_closure.as_ref().unchecked_ref()).unwrap();
// Clone the element ref and drop the borrow before calling observe(),
// because observe() can fire the resize callback synchronously,
// which would conflict with an active borrow.
let container_clone = self.inner.borrow().container.clone();
observer.observe(&container_clone);
{
let mut s = self.inner.borrow_mut();
s.resize_observer = Some(observer);
s.resize_closure = Some(resize_closure);
}
// Start animation loop
schedule_frame(&self.inner);
}
pub fn destroy(&self) {
let mut s = self.inner.borrow_mut();
let window = web_sys::window().unwrap();
if let Some(id) = s.raf_id.take() {
window.cancel_animation_frame(id).ok();
}
if let Some(observer) = s.resize_observer.take() {
observer.disconnect();
}
s.animate_closure = None;
s.resize_closure = None;
}
}

View File

@@ -1,6 +1,5 @@
use wasm_bindgen::prelude::*; mod auto_scroll;
mod headline;
#[wasm_bindgen] pub use auto_scroll::AutoScroller;
pub struct BadApplePlayer { pub use headline::HeadlineScroller;
is_playing: bool,
}

349
vue/package-lock.json generated
View File

@@ -25,7 +25,9 @@
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.1.11", "vite": "^7.1.11",
"vite-plugin-vue-devtools": "^8.0.3" "vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-vue-devtools": "^8.0.3",
"vite-plugin-wasm": "^3.6.0"
}, },
"engines": { "engines": {
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
@@ -1024,6 +1026,24 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@rollup/plugin-virtual": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@rollup/plugin-virtual/-/plugin-virtual-3.0.2.tgz",
"integrity": "sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.59.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
@@ -1349,6 +1369,293 @@
"win32" "win32"
] ]
}, },
"node_modules/@swc/core": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.24.tgz",
"integrity": "sha512-5Hj8aNasue7yusUt8LGCUe/AjM7RMAce8ZoyDyiFwx7Al+GbYKL+yE7g4sJk8vEr1dKIkTRARkNIJENc4CjkBQ==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.26"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
"@swc/core-darwin-arm64": "1.15.24",
"@swc/core-darwin-x64": "1.15.24",
"@swc/core-linux-arm-gnueabihf": "1.15.24",
"@swc/core-linux-arm64-gnu": "1.15.24",
"@swc/core-linux-arm64-musl": "1.15.24",
"@swc/core-linux-ppc64-gnu": "1.15.24",
"@swc/core-linux-s390x-gnu": "1.15.24",
"@swc/core-linux-x64-gnu": "1.15.24",
"@swc/core-linux-x64-musl": "1.15.24",
"@swc/core-win32-arm64-msvc": "1.15.24",
"@swc/core-win32-ia32-msvc": "1.15.24",
"@swc/core-win32-x64-msvc": "1.15.24"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.17"
},
"peerDependenciesMeta": {
"@swc/helpers": {
"optional": true
}
}
},
"node_modules/@swc/core-darwin-arm64": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.24.tgz",
"integrity": "sha512-uM5ZGfFXjtvtJ+fe448PVBEbn/CSxS3UAyLj3O9xOqKIWy3S6hPTXSPbszxkSsGDYKi+YFhzAsR4r/eXLxEQ0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-darwin-x64": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.24.tgz",
"integrity": "sha512-fMIb/Zfn929pw25VMBhV7Ji2Dl+lCWtUPNdYJQYOke+00E5fcQ9ynxtP8+qhUo/HZc+mYQb1gJxwHM9vty+lXg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.24.tgz",
"integrity": "sha512-vOkjsyjjxnoYx3hMEWcGxQrMgnNrRm6WAegBXrN8foHtDAR+zpdhpGF5a4lj1bNPgXAvmysjui8cM1ov/Clkaw==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.24.tgz",
"integrity": "sha512-h/oNu+upkXJ6Cicnq7YGVj9PkdfarLCdQa8l/FlHYvfv8CEiMaeeTnpLU7gSBH/rGxosM6Qkfa/J9mThGF9CLA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm64-musl": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.24.tgz",
"integrity": "sha512-ZpF/pRe1guk6sKzQI9D1jAORtjTdNlyeXn9GDz8ophof/w2WhojRblvSDJaGe7rJjcPN8AaOkhwdRUh7q8oYIg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-ppc64-gnu": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.24.tgz",
"integrity": "sha512-QZEsZfisHTSJlmyChgDFNmKPb3W6Lhbfo/O76HhIngfEdnQNmukS38/VSe1feho+xkV5A5hETyCbx3sALBZKAQ==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-s390x-gnu": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.24.tgz",
"integrity": "sha512-DLdJKVsJgglqQrJBuoUYNmzm3leI7kUZhLbZGHv42onfKsGf6JDS3+bzCUQfte/XOqDjh/tmmn1DR/CF/tCJFw==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-x64-gnu": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.24.tgz",
"integrity": "sha512-IpLYfposPA/XLxYOKpRfeccl1p5dDa3+okZDHHTchBkXEaVCnq5MADPmIWwIYj1tudt7hORsEHccG5no6IUQRw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-x64-musl": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.24.tgz",
"integrity": "sha512-JHy3fMSc0t/EPWgo74+OK5TGr51aElnzqfUPaiRf2qJ/BfX5CUCfMiWVBuhI7qmVMBnk1jTRnL/xZnOSHDPLYg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.24.tgz",
"integrity": "sha512-Txj+qUH1z2bUd1P3JvwByfjKFti3cptlAxhWgmunBUUxy/IW3CXLZ6l6Gk4liANadKkU71nIU1X30Z5vpMT3BA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.24.tgz",
"integrity": "sha512-15D/nl3XwrhFpMv+MADFOiVwv3FvH9j8c6Rf8EXBT3Q5LoMh8YnDnSgPYqw1JzPnksvsBX6QPXLiPqmcR/Z4qQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-x64-msvc": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.24.tgz",
"integrity": "sha512-PR0PlTlPra2JbaDphrOAzm6s0v9rA0F17YzB+XbWD95B4g2cWcZY9LAeTa4xll70VLw9Jr7xBrlohqlQmelMFQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@swc/types": {
"version": "0.1.26",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz",
"integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3"
}
},
"node_modules/@swc/wasm": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/wasm/-/wasm-1.15.24.tgz",
"integrity": "sha512-vFjzOE8dhJcfeTbM4+HO9Qy58IINV0ysqStAgw81uds+KqCeUDM9huN+SZ5lWZ6U+5nf8VcZoEw5N81xMtAidg==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
@@ -3518,6 +3825,20 @@
"integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==",
"license": "(WTFPL OR MIT)" "license": "(WTFPL OR MIT)"
}, },
"node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "7.3.1", "version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
@@ -3661,6 +3982,22 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/vite-plugin-top-level-await": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/vite-plugin-top-level-await/-/vite-plugin-top-level-await-1.6.0.tgz",
"integrity": "sha512-bNhUreLamTIkoulCR9aDXbTbhLk6n1YE8NJUTTxl5RYskNRtzOR0ASzSjBVRtNdjIfngDXo11qOsybGLNsrdww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/plugin-virtual": "^3.0.2",
"@swc/core": "^1.12.14",
"@swc/wasm": "^1.12.14",
"uuid": "10.0.0"
},
"peerDependencies": {
"vite": ">=2.8"
}
},
"node_modules/vite-plugin-vue-devtools": { "node_modules/vite-plugin-vue-devtools": {
"version": "8.0.5", "version": "8.0.5",
"resolved": "https://registry.npmjs.org/vite-plugin-vue-devtools/-/vite-plugin-vue-devtools-8.0.5.tgz", "resolved": "https://registry.npmjs.org/vite-plugin-vue-devtools/-/vite-plugin-vue-devtools-8.0.5.tgz",
@@ -3736,6 +4073,16 @@
"vite": "^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0" "vite": "^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0"
} }
}, },
"node_modules/vite-plugin-wasm": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.6.0.tgz",
"integrity": "sha512-mL/QPziiIA4RAA6DkaZZzOstdwbW5jO4Vz7Zenj0wieKWBlNvIvX5L5ljum9lcUX0ShNfBgCNLKTjNkRVVqcsw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8"
}
},
"node_modules/vue": { "node_modules/vue": {
"version": "3.5.26", "version": "3.5.26",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",

View File

@@ -7,8 +7,9 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
}, },
"scripts": { "scripts": {
"build:wasm": "wasm-pack build crates/stp_wasm --target web --out-dir ../../src/wasm",
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "npm run build:wasm && vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -29,6 +30,8 @@
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.1.11", "vite": "^7.1.11",
"vite-plugin-vue-devtools": "^8.0.3" "vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-vue-devtools": "^8.0.3",
"vite-plugin-wasm": "^3.6.0"
} }
} }

View File

@@ -1,55 +1,22 @@
<script setup> <script setup>
import { onMounted, useTemplateRef, onUnmounted } from "vue"; import { onMounted, useTemplateRef, onUnmounted } from "vue";
import { HeadlineScroller } from "@/wasm/stp_wasm.js";
const container = useTemplateRef("container"); const container = useTemplateRef("container");
const item1 = useTemplateRef("item1"); const item1 = useTemplateRef("item1");
let offset = 0; let scroller = null;
let cachedWidth = 0;
let rafId;
const speed = 0.5; // pixels per frame
function measureWidth() {
const ctnr = container.value;
const it1 = item1.value;
if (ctnr && it1) {
cachedWidth = Math.max(ctnr.offsetWidth, it1.scrollWidth);
}
}
function animate() {
const ctnr = container.value;
if (!ctnr || cachedWidth === 0) {
rafId = requestAnimationFrame(animate);
return;
}
offset -= speed;
if (offset <= -cachedWidth) {
offset += cachedWidth;
}
ctnr.style.transform = `translateX(${offset}px)`;
rafId = requestAnimationFrame(animate);
}
let resizeObserver;
onMounted(() => { onMounted(() => {
measureWidth(); if (!container.value || !item1.value) return;
rafId = requestAnimationFrame(animate); scroller = new HeadlineScroller(container.value, item1.value);
scroller.start();
resizeObserver = new ResizeObserver(measureWidth);
resizeObserver.observe(container.value);
}); });
onUnmounted(() => { onUnmounted(() => {
cancelAnimationFrame(rafId); scroller?.destroy();
resizeObserver?.disconnect(); scroller?.free();
scroller = null;
}); });
</script> </script>

View File

@@ -31,6 +31,7 @@ const handleClick = () => {
class="pointer-events-none" class="pointer-events-none"
:model-value="props.modelValue" :model-value="props.modelValue"
@update:model-value="updateValue" @update:model-value="updateValue"
@click.stop
ref="toggleButtonRef" ref="toggleButtonRef"
/> />
</div> </div>

View File

@@ -1,108 +1,26 @@
<template> <template>
<div ref="container" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" class="overflow-y-auto"> <div ref="container" class="overflow-y-auto">
<slot /> <slot />
</div> </div>
</template> </template>
<script setup> <script setup>
import { useTemplateRef, onMounted, onBeforeUnmount } from "vue"; import { useTemplateRef, onMounted, onBeforeUnmount } from "vue";
import { AutoScroller } from "@/wasm/stp_wasm.js";
const container = useTemplateRef("container"); const container = useTemplateRef("container");
const SPEED = 0.0005; // % per frame let scroller = null;
const PAUSE = 2000; // ms at top/bottom
let pos = 0;
let direction = 1; // 1 = down, -1 = up
let hovered = false;
let rafId = null;
let pauseTimeoutId = null;
let cachedScrollHeight = 0;
function measureScrollHeight() {
const el = container.value;
if (el) cachedScrollHeight = el.scrollHeight;
}
function stopLoop() {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
if (pauseTimeoutId !== null) {
clearTimeout(pauseTimeoutId);
pauseTimeoutId = null;
}
}
function startLoop() {
stopLoop();
rafId = requestAnimationFrame(tick);
}
function onMouseEnter() {
hovered = true;
stopLoop();
}
function onMouseLeave() {
hovered = false;
const el = container.value;
if (el && cachedScrollHeight > 0) {
pos = el.scrollTop / cachedScrollHeight;
}
startLoop();
}
function schedulePause(callback) {
stopLoop();
pauseTimeoutId = setTimeout(callback, PAUSE);
}
function tick() {
rafId = null;
const el = container.value;
if (hovered) return;
if (!el || cachedScrollHeight === 0) {
rafId = requestAnimationFrame(tick);
return;
}
const reachedBottom = pos >= 1;
const reachedTop = pos <= 0;
if (reachedBottom) {
pos = 0.999;
direction = -1;
schedulePause(startLoop);
return;
} else if (reachedTop && direction === -1) {
pos = 0.001;
direction = 1;
schedulePause(startLoop);
return;
}
pos += direction * SPEED;
el.scrollTop = pos * cachedScrollHeight;
rafId = requestAnimationFrame(tick);
}
let resizeObserver;
onMounted(() => { onMounted(() => {
measureScrollHeight(); if (!container.value) return;
schedulePause(startLoop); scroller = new AutoScroller(container.value);
scroller.start();
resizeObserver = new ResizeObserver(measureScrollHeight);
resizeObserver.observe(container.value);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
stopLoop(); scroller?.destroy();
resizeObserver?.disconnect(); scroller?.free();
scroller = null;
}); });
</script> </script>

View File

@@ -3,6 +3,9 @@ import { createPinia } from "pinia";
import App from "./App.vue"; import App from "./App.vue";
import router from "./router"; import router from "./router";
import "./assets/styles.css"; import "./assets/styles.css";
import init from "@/wasm/stp_wasm.js";
await init();
const app = createApp(App); const app = createApp(App);

View File

@@ -34,11 +34,6 @@ const router = createRouter({
component: () => import("@/views/admin/Admin.vue"), component: () => import("@/views/admin/Admin.vue"),
meta: { requiresAdmin: true }, meta: { requiresAdmin: true },
}, },
{
path: "bookmarks",
name: "bookmarks",
component: () => import("@/views/home/bookmarks/Bookmarks.vue"),
},
{ {
path: "shrines", path: "shrines",
name: "shrine links", name: "shrine links",

View File

@@ -21,6 +21,7 @@ import Favorites from "./Favorites.vue";
import Gym2 from "./Gym2.vue"; import Gym2 from "./Gym2.vue";
import Consumption from "./Consumption.vue"; import Consumption from "./Consumption.vue";
import Steam from "./Steam.vue"; import Steam from "./Steam.vue";
import Bookmarks from "./bookmarks/Bookmarks.vue";
</script> </script>
<template> <template>
@@ -61,6 +62,7 @@ import Steam from "./Steam.vue";
</div> </div>
<div class="sidebar"> <div class="sidebar">
<Steam class="steam-sidebar sidebar-cell" /> <Steam class="steam-sidebar sidebar-cell" />
<Bookmarks class="bookmarks-sidebar sidebar-cell" />
<Chat <Chat
class="chat-sidebar flex-1 min-h-0 chat-home sidebar-cell" class="chat-sidebar flex-1 min-h-0 chat-home sidebar-cell"
/> />
@@ -180,7 +182,8 @@ import Steam from "./Steam.vue";
} }
.commits-sidebar, .commits-sidebar,
.steam-sidebar { .steam-sidebar,
.bookmarks-sidebar {
width: 100%; width: 100%;
max-height: 300px; max-height: 300px;
} }

View File

@@ -1,6 +1,7 @@
<script setup> <script setup>
import { computed } from "vue"; import { computed } from "vue";
import LinkTable from "@/components/util/LinkTable.vue"; import LinkTable from "@/components/util/LinkTable.vue";
import Header from "@/components/text/Header.vue";
import { useHomeDataStore } from "@/stores/homeData"; import { useHomeDataStore } from "@/stores/homeData";
const homeData = useHomeDataStore(); const homeData = useHomeDataStore();
@@ -16,18 +17,30 @@ const groupedBookmarks = computed(() => {
</script> </script>
<template> <template>
<main class="items-center flex flex-col"> <div class="bookmarks-wrapper">
<div <Header class="text-left">Bookmarks</Header>
class="a4page-portrait bdr-1 flex flex-row flex-wrap overflow-x-auto gap-1" <div class="bookmarks-scroll">
>
<div class="w-full h-fit">
<LinkTable <LinkTable
class="flex flex-col flex-wrap"
v-for="group in groupedBookmarks" v-for="group in groupedBookmarks"
:key="group[0]"
:title="group[0]" :title="group[0]"
:items="group[1]" :items="group[1]"
/> />
</div> </div>
</div> </div>
</main>
</template> </template>
<style scoped>
.bookmarks-wrapper {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.bookmarks-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
}
</style>

View File

@@ -4,6 +4,8 @@ import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import vue from "@vitejs/plugin-vue"; import vue from "@vitejs/plugin-vue";
import vueDevTools from "vite-plugin-vue-devtools"; import vueDevTools from "vite-plugin-vue-devtools";
import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await";
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
@@ -11,6 +13,8 @@ export default defineConfig({
vue(), vue(),
...(process.env.NODE_ENV !== "production" ? [vueDevTools()] : []), ...(process.env.NODE_ENV !== "production" ? [vueDevTools()] : []),
tailwindcss(), tailwindcss(),
wasm(),
topLevelAwait(),
], ],
resolve: { resolve: {
alias: { alias: {