10 Commits

Author SHA1 Message Date
spinline
6554c4248c ci: add Gitea Actions workflow for MIPS build
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-06 17:31:32 +03:00
spinline
14f811fd79 fix: convert sort dropdown to signal-based for iOS Safari 2026-02-06 17:19:47 +03:00
spinline
2a919211d9 fix: fixed statusbar at bottom, fix dropdown toggle race, fix theme buttons on iOS
- StatusBar now uses fixed positioning at viewport bottom (z-[99])
- Dropdown toggle uses skip_next_close guard to prevent close-then-reopen
- Global close listener uses click instead of pointerdown (fires after toggle)
- Theme/limit buttons use on:pointerdown instead of on:click for iOS compat
- Main content has pb-8 to prevent overlap with fixed statusbar
- Rebuilt tailwind.css
2026-02-06 17:17:07 +03:00
spinline
2c25d3bdf1 chore: rebuild tailwind.css 2026-02-06 14:21:17 +03:00
spinline
1d06906475 fix: wrap toolbar+main in overflow-hidden, free statusbar from clipping 2026-02-06 14:19:46 +03:00
spinline
e4cc6322bf fix: dropdown clipped by overflow-hidden, change to overflow-x-hidden 2026-02-06 14:09:34 +03:00
spinline
d1604fb8fb frontend(statusbar): use signal-based dropdowns to fix iOS Safari toggle 2026-02-06 13:57:26 +03:00
spinline
3b34b0e61a fix: prevent toast spam on server disconnect - show once, reconnect silently with backoff 2026-02-06 13:37:12 +03:00
spinline
19d9d7f30f fix: use details-based dropdowns for iOS Safari toggle behavior 2026-02-06 13:34:07 +03:00
spinline
a3321c7cf1 fix: properly check notification permission result, show toast feedback on Safari 2026-02-06 13:26:37 +03:00
7 changed files with 477 additions and 174 deletions

View File

@@ -0,0 +1,109 @@
name: Build MIPS Binary
on:
push:
branches: [ "main" ]
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Rust
uses: dtolnay/rust-toolchain@nightly
with:
targets: wasm32-unknown-unknown
components: rust-src
- name: Rust Cache
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-mips-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-mips-
- name: Install Trunk
run: |
if ! command -v trunk &> /dev/null; then
cargo install trunk --locked
fi
- name: Build Frontend
run: |
cd frontend
npm install
trunk build --release
- name: Install Cross
run: |
if ! command -v cross &> /dev/null; then
cargo install cross --locked
fi
- name: Build Backend (MIPS)
env:
RUSTUP_TOOLCHAIN: nightly
CROSS_NO_WARNINGS: 0
run: |
cd backend
cross build --target mips-unknown-linux-musl --release -Z build-std=std,panic_abort
- name: Rename Binary
run: mv target/mips-unknown-linux-musl/release/backend target/mips-unknown-linux-musl/release/vibetorrent-mips
- name: Generate Release Tag
id: tag
run: echo "release_tag=release-$(date +'%Y%m%d-%H%M')" >> "$GITHUB_OUTPUT"
- name: Create Release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
TAG="${{ steps.tag.outputs.release_tag }}"
REPO="admin/vibetorrent"
API_URL="${{ gitea.server_url }}/api/v1"
# Create release
RELEASE_RESPONSE=$(curl -s -X POST "${API_URL}/repos/${REPO}/releases" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"${TAG}\",
\"name\": \"Release ${TAG}\",
\"body\": \"Automated build from commit ${{ gitea.sha }}\",
\"draft\": false,
\"prerelease\": false
}")
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id')
echo "Release ID: $RELEASE_ID"
if [ "$RELEASE_ID" = "null" ] || [ -z "$RELEASE_ID" ]; then
echo "Failed to create release:"
echo "$RELEASE_RESPONSE"
exit 1
fi
# Upload binary as release asset
curl -s -X POST "${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=vibetorrent-mips" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary @target/mips-unknown-linux-musl/release/vibetorrent-mips
echo "Release ${TAG} created with binary attached."

View File

@@ -10,7 +10,7 @@
@layer base { @layer base {
html, html,
body { body {
@apply min-h-[100dvh] w-full overflow-hidden bg-base-100 text-base-content overscroll-y-none; @apply min-h-dvh w-full overflow-hidden bg-base-100 text-base-content overscroll-y-none;
} }
} }

View File

@@ -767,6 +767,141 @@
} }
} }
} }
.toggle {
@layer daisyui.l1.l2.l3 {
border: var(--border) solid currentColor;
color: var(--input-color);
position: relative;
display: inline-grid;
flex-shrink: 0;
cursor: pointer;
appearance: none;
place-content: center;
vertical-align: middle;
webkit-user-select: none;
user-select: none;
grid-template-columns: 0fr 1fr 1fr;
--radius-selector-max: calc(
var(--radius-selector) + var(--radius-selector) + var(--radius-selector)
);
border-radius: calc( var(--radius-selector) + min(var(--toggle-p), var(--radius-selector-max)) + min(var(--border), var(--radius-selector-max)) );
padding: var(--toggle-p);
box-shadow: 0 1px currentColor inset;
@supports (color: color-mix(in lab, red, red)) {
box-shadow: 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000) inset;
}
transition: color 0.3s, grid-template-columns 0.2s;
--input-color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
--input-color: color-mix(in oklab, var(--color-base-content) 50%, #0000);
}
--toggle-p: calc(var(--size) * 0.125);
--size: calc(var(--size-selector, 0.25rem) * 6);
width: calc((var(--size) * 2) - (var(--border) + var(--toggle-p)) * 2);
height: var(--size);
> * {
z-index: 1;
grid-column: span 1 / span 1;
grid-column-start: 2;
grid-row-start: 1;
height: 100%;
cursor: pointer;
appearance: none;
background-color: transparent;
padding: calc(0.25rem * 0.5);
transition: opacity 0.2s, rotate 0.4s;
border: none;
&:focus {
--tw-outline-style: none;
outline-style: none;
@media (forced-colors: active) {
outline: 2px solid transparent;
outline-offset: 2px;
}
}
&:nth-child(2) {
color: var(--color-base-100);
rotate: 0deg;
}
&:nth-child(3) {
color: var(--color-base-100);
opacity: 0%;
rotate: -15deg;
}
}
&:has(:checked) {
> :nth-child(2) {
opacity: 0%;
rotate: 15deg;
}
> :nth-child(3) {
opacity: 100%;
rotate: 0deg;
}
}
&:before {
position: relative;
inset-inline-start: calc(0.25rem * 0);
grid-column-start: 2;
grid-row-start: 1;
aspect-ratio: 1 / 1;
height: 100%;
border-radius: var(--radius-selector);
background-color: currentcolor;
translate: 0;
--tw-content: "";
content: var(--tw-content);
transition: background-color 0.1s, translate 0.2s, inset-inline-start 0.2s;
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px currentColor;
@supports (color: color-mix(in lab, red, red)) {
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000);
}
background-size: auto, calc(var(--noise) * 100%);
background-image: none, var(--fx-noise);
}
@media (forced-colors: active) {
&:before {
outline-style: var(--tw-outline-style);
outline-width: 1px;
outline-offset: calc(1px * -1);
}
}
@media print {
&:before {
outline: 0.25rem solid;
outline-offset: -1rem;
}
}
&:focus-visible, &:has(:focus-visible) {
outline: 2px solid currentColor;
outline-offset: 2px;
}
&:checked, &[aria-checked="true"], &:has(> input:checked) {
grid-template-columns: 1fr 1fr 0fr;
background-color: var(--color-base-100);
--input-color: var(--color-base-content);
&:before {
background-color: currentcolor;
}
@starting-style {
&:before {
opacity: 0;
}
}
}
&:indeterminate {
grid-template-columns: 0.5fr 1fr 0.5fr;
}
&:disabled {
cursor: not-allowed;
opacity: 30%;
&:before {
background-color: transparent;
border: var(--border) solid currentColor;
}
}
}
}
.input { .input {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
cursor: text; cursor: text;
@@ -1171,6 +1306,9 @@
} }
} }
} }
.absolute {
position: absolute;
}
.fixed { .fixed {
position: fixed; position: fixed;
} }
@@ -1190,72 +1328,6 @@
--toast-x: 0; --toast-x: 0;
} }
} }
.dropdown-end {
@layer daisyui.l1.l2 {
--anchor-h: span-left;
:where(.dropdown-content) {
inset-inline-end: calc(0.25rem * 0);
translate: 0 0;
[dir="rtl"] & {
translate: 0 0;
}
}
&.dropdown-left {
--anchor-h: left;
--anchor-v: span-top;
.dropdown-content {
top: auto;
bottom: calc(0.25rem * 0);
}
}
&.dropdown-right {
--anchor-h: right;
--anchor-v: span-top;
.dropdown-content {
top: auto;
bottom: calc(0.25rem * 0);
}
}
}
}
.dropdown-start {
@layer daisyui.l1.l2 {
--anchor-h: span-right;
:where(.dropdown-content) {
inset-inline-end: auto;
translate: 0 0;
[dir="rtl"] & {
translate: 0 0;
}
}
&.dropdown-left {
--anchor-h: left;
--anchor-v: span-bottom;
.dropdown-content {
top: calc(0.25rem * 0);
bottom: auto;
}
}
&.dropdown-right {
--anchor-h: right;
--anchor-v: span-bottom;
.dropdown-content {
top: calc(0.25rem * 0);
bottom: auto;
}
}
}
}
.dropdown-top {
@layer daisyui.l1.l2 {
--anchor-v: top;
.dropdown-content {
top: auto;
bottom: 100%;
transform-origin: bottom;
}
}
}
.toast-bottom { .toast-bottom {
@layer daisyui.l1.l2 { @layer daisyui.l1.l2 {
top: auto; top: auto;
@@ -1263,6 +1335,21 @@
--toast-y: 0; --toast-y: 0;
} }
} }
.top-full {
top: 100%;
}
.right-0 {
right: calc(var(--spacing) * 0);
}
.bottom-0 {
bottom: calc(var(--spacing) * 0);
}
.bottom-full {
bottom: 100%;
}
.left-0 {
left: calc(var(--spacing) * 0);
}
.modal-backdrop { .modal-backdrop {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
grid-column-start: 1; grid-column-start: 1;
@@ -1484,6 +1571,9 @@
gap: calc(0.25rem * 2); gap: calc(0.25rem * 2);
} }
} }
.mt-1 {
margin-top: calc(var(--spacing) * 1);
}
.mt-2 { .mt-2 {
margin-top: calc(var(--spacing) * 2); margin-top: calc(var(--spacing) * 2);
} }
@@ -1678,6 +1768,9 @@
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
} }
.block {
display: block;
}
.flex { .flex {
display: flex; display: flex;
} }
@@ -1738,8 +1831,8 @@
.h-10 { .h-10 {
height: calc(var(--spacing) * 10); height: calc(var(--spacing) * 10);
} }
.h-14 { .h-auto {
height: calc(var(--spacing) * 14); height: auto;
} }
.h-full { .h-full {
height: 100%; height: 100%;
@@ -2060,6 +2153,9 @@
.pt-1 { .pt-1 {
padding-top: calc(var(--spacing) * 1); padding-top: calc(var(--spacing) * 1);
} }
.pb-8 {
padding-bottom: calc(var(--spacing) * 8);
}
.pb-20 { .pb-20 {
padding-bottom: calc(var(--spacing) * 20); padding-bottom: calc(var(--spacing) * 20);
} }
@@ -2255,10 +2351,6 @@
} }
} }
} }
.blur {
--tw-blur: blur(8px);
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
}
.filter { .filter {
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
} }

View File

@@ -65,10 +65,9 @@ pub fn App() -> impl IntoView {
<input id="my-drawer" type="checkbox" class="drawer-toggle" /> <input id="my-drawer" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col h-full overflow-hidden bg-base-100 text-base-content text-sm select-none"> <div class="drawer-content flex flex-col h-full overflow-hidden bg-base-100 text-base-content text-sm select-none">
// Toolbar at the top
<Toolbar /> <Toolbar />
<main class="flex-1 flex flex-col min-w-0 bg-base-100 overflow-hidden"> <main class="flex-1 flex flex-col min-w-0 bg-base-100 overflow-hidden pb-8">
<Router> <Router>
<Routes> <Routes>
<Route path="/" view=move || view! { <TorrentTable /> } /> <Route path="/" view=move || view! { <TorrentTable /> } />
@@ -77,7 +76,7 @@ pub fn App() -> impl IntoView {
</Router> </Router>
</main> </main>
// Status Bar at the bottom // StatusBar is rendered via fixed positioning, just mount it here
<StatusBar /> <StatusBar />
</div> </div>

View File

@@ -1,6 +1,5 @@
use leptos::*; use leptos::*;
use shared::GlobalLimitRequest; use shared::GlobalLimitRequest;
use wasm_bindgen::JsCast;
fn format_bytes(bytes: i64) -> String { fn format_bytes(bytes: i64) -> String {
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"]; const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
@@ -110,43 +109,49 @@ pub fn StatusBar() -> impl IntoView {
}); });
}; };
// Helper to close dropdowns by blurring the active element // Signal-based dropdown state: 0=none, 1=download, 2=upload, 3=theme
let close_dropdown = move || { let (active_dropdown, set_active_dropdown) = create_signal(0u8);
if let Some(doc) = web_sys::window().and_then(|w| w.document()) { // Guard to prevent global close from firing right after toggle opens
if let Some(active) = doc.active_element() { let skip_next_close = store_value(false);
let _ = active
.dyn_into::<web_sys::HtmlElement>() // Toggle a specific dropdown
.map(|el| el.blur()); let toggle = move |id: u8| {
} let current = active_dropdown.get_untracked();
if current == id {
set_active_dropdown.set(0);
} else {
set_active_dropdown.set(id);
// Mark that the next global close should be skipped
skip_next_close.set_value(true);
} }
}; };
// Close all dropdowns
let close_all = move || {
set_active_dropdown.set(0);
};
// Global listener to force blur on touchstart (for iOS "tap outside" closing) // Close dropdowns when tapping outside — uses click (fires after pointerdown)
let force_blur = move |_| { let _ = window_event_listener(ev::click, move |_| {
if let Some(doc) = web_sys::window().and_then(|w| w.document()) { if skip_next_close.get_value() {
if let Some(active) = doc.active_element() { skip_next_close.set_value(false);
// If something is focused, blur it to close dropdowns return;
let _ = active
.dyn_into::<web_sys::HtmlElement>()
.map(|el| el.blur());
}
} }
}; set_active_dropdown.set(0);
let _ = window_event_listener(ev::touchstart, force_blur); });
view! { view! {
<div class="h-8 min-h-8 bg-base-200 border-t border-base-300 flex items-center px-4 text-xs gap-4 text-base-content/70"> <div class="fixed bottom-0 left-0 right-0 h-8 min-h-8 bg-base-200 border-t border-base-300 flex items-center px-4 text-xs gap-4 text-base-content/70 z-[99]">
// --- DOWNLOAD SPEED DROPDOWN --- // --- DOWNLOAD SPEED DROPDOWN ---
<div <div class="relative">
class="dropdown dropdown-top dropdown-start"
on:touchstart=move |e| e.stop_propagation()
>
<div <div
tabindex="0"
role="button"
class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none" class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none"
title="Global Download Speed - Click to Set Limit" title="Global Download Speed - Click to Set Limit"
on:pointerdown=move |e| {
e.stop_propagation();
toggle(1);
}
> >
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3" /> <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3" />
@@ -160,8 +165,9 @@ pub fn StatusBar() -> impl IntoView {
</div> </div>
<ul <ul
tabindex="0" class="absolute bottom-full left-0 z-[100] menu p-2 shadow bg-base-200 rounded-box w-40 mb-2 border border-base-300"
class="dropdown-content z-[100] menu p-2 shadow bg-base-200 rounded-box w-40 mb-2 border border-base-300" style=move || if active_dropdown.get() == 1 { "display: block" } else { "display: none" }
on:pointerdown=move |e| e.stop_propagation()
> >
{ {
limits.clone().into_iter().map(|(val, label)| { limits.clone().into_iter().map(|(val, label)| {
@@ -169,14 +175,14 @@ pub fn StatusBar() -> impl IntoView {
let current = stats.get().down_limit.unwrap_or(0); let current = stats.get().down_limit.unwrap_or(0);
(current - val).abs() < 1024 (current - val).abs() < 1024
}; };
let close = close_dropdown.clone();
view! { view! {
<li> <li>
<button <button
class=move || if is_active() { "bg-primary/10 text-primary font-bold text-xs flex justify-between" } else { "text-xs flex justify-between" } class=move || if is_active() { "bg-primary/10 text-primary font-bold text-xs flex justify-between" } else { "text-xs flex justify-between" }
on:click=move |_| { on:pointerdown=move |e| {
e.stop_propagation();
set_limit("down", val); set_limit("down", val);
close(); close_all();
} }
> >
{label} {label}
@@ -192,15 +198,14 @@ pub fn StatusBar() -> impl IntoView {
</div> </div>
// --- UPLOAD SPEED DROPDOWN --- // --- UPLOAD SPEED DROPDOWN ---
<div <div class="relative">
class="dropdown dropdown-top dropdown-start"
on:touchstart=move |e| e.stop_propagation()
>
<div <div
tabindex="0"
role="button"
class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none" class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none"
title="Global Upload Speed - Click to Set Limit" title="Global Upload Speed - Click to Set Limit"
on:pointerdown=move |e| {
e.stop_propagation();
toggle(2);
}
> >
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 10.5L12 3m0 0l7.5 7.5M12 3v18" /> <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 10.5L12 3m0 0l7.5 7.5M12 3v18" />
@@ -214,8 +219,9 @@ pub fn StatusBar() -> impl IntoView {
</div> </div>
<ul <ul
tabindex="0" class="absolute bottom-full left-0 z-[100] menu p-2 shadow bg-base-200 rounded-box w-40 mb-2 border border-base-300"
class="dropdown-content z-[100] menu p-2 shadow bg-base-200 rounded-box w-40 mb-2 border border-base-300" style=move || if active_dropdown.get() == 2 { "display: block" } else { "display: none" }
on:pointerdown=move |e| e.stop_propagation()
> >
{ {
limits.clone().into_iter().map(|(val, label)| { limits.clone().into_iter().map(|(val, label)| {
@@ -223,14 +229,14 @@ pub fn StatusBar() -> impl IntoView {
let current = stats.get().up_limit.unwrap_or(0); let current = stats.get().up_limit.unwrap_or(0);
(current - val).abs() < 1024 (current - val).abs() < 1024
}; };
let close = close_dropdown.clone();
view! { view! {
<li> <li>
<button <button
class=move || if is_active() { "bg-primary/10 text-primary font-bold text-xs flex justify-between" } else { "text-xs flex justify-between" } class=move || if is_active() { "bg-primary/10 text-primary font-bold text-xs flex justify-between" } else { "text-xs flex justify-between" }
on:click=move |_| { on:pointerdown=move |e| {
e.stop_propagation();
set_limit("up", val); set_limit("up", val);
close(); close_all();
} }
> >
{label} {label}
@@ -246,15 +252,14 @@ pub fn StatusBar() -> impl IntoView {
</div> </div>
<div class="ml-auto flex items-center gap-4"> <div class="ml-auto flex items-center gap-4">
<div <div class="relative">
class="dropdown dropdown-top dropdown-end"
on:touchstart=move |e| e.stop_propagation()
>
<div <div
tabindex="0"
role="button"
class="btn btn-ghost btn-xs btn-square" class="btn btn-ghost btn-xs btn-square"
title="Change Theme" title="Change Theme"
on:pointerdown=move |e| {
e.stop_propagation();
toggle(3);
}
> >
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" /> <path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" />
@@ -262,21 +267,21 @@ pub fn StatusBar() -> impl IntoView {
</div> </div>
<ul <ul
tabindex="0" class="absolute bottom-full right-0 z-[100] menu p-2 shadow bg-base-200 rounded-box w-52 mb-2 border border-base-300 max-h-96 overflow-y-auto"
class="dropdown-content z-[100] menu p-2 shadow bg-base-200 rounded-box w-52 mb-2 border border-base-300 max-h-96 overflow-y-auto" style=move || if active_dropdown.get() == 3 { "display: block" } else { "display: none" }
on:pointerdown=move |e| e.stop_propagation()
> >
{ {
let themes = vec![ let themes = vec![
"light", "dark", "dim", "nord", "cupcake", "dracula", "cyberpunk", "emerald", "sunset", "abyss" "light", "dark", "dim", "nord", "cupcake", "dracula", "cyberpunk", "emerald", "sunset", "abyss"
]; ];
let close = close_dropdown.clone();
themes.into_iter().map(|theme| { themes.into_iter().map(|theme| {
let close = close.clone();
view! { view! {
<li> <li>
<button <button
class=move || if current_theme.get() == theme { "bg-primary/10 text-primary font-bold text-xs capitalize" } else { "text-xs capitalize" } class=move || if current_theme.get() == theme { "bg-primary/10 text-primary font-bold text-xs capitalize" } else { "text-xs capitalize" }
on:click=move |_| { on:pointerdown=move |e| {
e.stop_propagation();
set_current_theme.set(theme.to_string()); set_current_theme.set(theme.to_string());
if let Some(win) = web_sys::window() { if let Some(win) = web_sys::window() {
if let Some(doc) = win.document() { if let Some(doc) = win.document() {
@@ -286,7 +291,7 @@ pub fn StatusBar() -> impl IntoView {
let _ = storage.set_item("vibetorrent_theme", theme); let _ = storage.set_item("vibetorrent_theme", theme);
} }
} }
close(); close_all();
} }
> >
{theme} {theme}
@@ -305,7 +310,45 @@ pub fn StatusBar() -> impl IntoView {
// Request push notification permission when settings button is clicked // Request push notification permission when settings button is clicked
spawn_local(async { spawn_local(async {
log::info!("Settings button clicked - requesting push notification permission"); log::info!("Settings button clicked - requesting push notification permission");
// Check current permission state before requesting
let window = web_sys::window().expect("window should exist");
let _current_perm = js_sys::Reflect::get(&window, &"Notification".into())
.ok()
.and_then(|n| js_sys::Reflect::get(&n, &"permission".into()).ok())
.and_then(|p| p.as_string())
.unwrap_or_default();
crate::store::subscribe_to_push_notifications().await; crate::store::subscribe_to_push_notifications().await;
// Check permission after request
let new_perm = js_sys::Reflect::get(&window, &"Notification".into())
.ok()
.and_then(|n| js_sys::Reflect::get(&n, &"permission".into()).ok())
.and_then(|p| p.as_string())
.unwrap_or_default();
if let Some(store) = use_context::<crate::store::TorrentStore>() {
if new_perm == "granted" {
crate::store::show_toast_with_signal(
store.notifications,
shared::NotificationLevel::Success,
"Bildirimler etkinleştirildi! Torrent tamamlandığında bildirim alacaksınız.".to_string(),
);
} else if new_perm == "denied" {
crate::store::show_toast_with_signal(
store.notifications,
shared::NotificationLevel::Error,
"Bildirim izni reddedildi. Tarayıcı ayarlarından izin verebilirsiniz.".to_string(),
);
} else {
crate::store::show_toast_with_signal(
store.notifications,
shared::NotificationLevel::Warning,
"Bildirim izni verilemedi. Açılan izin penceresinde 'İzin Ver' seçeneğini seçin.".to_string(),
);
}
}
}); });
} }
> >

View File

@@ -152,6 +152,17 @@ pub fn TorrentTable() -> impl IntoView {
} }
}; };
// Signal-based sort dropdown for mobile
let (sort_open, set_sort_open) = create_signal(false);
let sort_skip_close = store_value(false);
let _ = window_event_listener(ev::click, move |_| {
if sort_skip_close.get_value() {
sort_skip_close.set_value(false);
return;
}
set_sort_open.set(false);
});
let sort_arrow = move |col: SortColumn| { let sort_arrow = move |col: SortColumn| {
if sort_col.get() == col { if sort_col.get() == col {
match sort_dir.get() { match sort_dir.get() {
@@ -325,21 +336,31 @@ pub fn TorrentTable() -> impl IntoView {
<div class="px-3 py-2 border-b border-base-200 flex justify-between items-center bg-base-100/95 backdrop-blur z-10 shrink-0"> <div class="px-3 py-2 border-b border-base-200 flex justify-between items-center bg-base-100/95 backdrop-blur z-10 shrink-0">
<span class="text-xs font-bold opacity-50 uppercase tracking-wider">"Torrents"</span> <span class="text-xs font-bold opacity-50 uppercase tracking-wider">"Torrents"</span>
<div <div class="relative">
class="dropdown dropdown-end"
on:touchstart=move |e| e.stop_propagation()
>
<div <div
tabindex="0"
role="button" role="button"
class="btn btn-ghost btn-xs gap-1 opacity-70 font-normal" class="btn btn-ghost btn-xs gap-1 opacity-70 font-normal"
on:pointerdown=move |e| {
e.stop_propagation();
let cur = sort_open.get_untracked();
if cur {
set_sort_open.set(false);
} else {
set_sort_open.set(true);
sort_skip_close.set_value(true);
}
}
> >
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7.5L7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5" /> <path stroke-linecap="round" stroke-linejoin="round" d="M3 7.5L7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5" />
</svg> </svg>
"Sort" "Sort"
</div> </div>
<ul tabindex="0" class="dropdown-content z-[100] menu p-2 shadow bg-base-100 rounded-box w-48 border border-base-200 text-xs"> <ul
class="absolute top-full right-0 z-[100] menu p-2 shadow bg-base-100 rounded-box w-48 mt-1 border border-base-200 text-xs"
style=move || if sort_open.get() { "display: block" } else { "display: none" }
on:pointerdown=move |e| e.stop_propagation()
>
<li class="menu-title px-2 py-1 opacity-50 text-[10px] uppercase font-bold">"Sort By"</li> <li class="menu-title px-2 py-1 opacity-50 text-[10px] uppercase font-bold">"Sort By"</li>
{ {
let columns = vec![ let columns = vec![
@@ -352,26 +373,18 @@ pub fn TorrentTable() -> impl IntoView {
(SortColumn::ETA, "ETA"), (SortColumn::ETA, "ETA"),
]; ];
let close_dropdown = move || {
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
if let Some(active) = doc.active_element() {
let _ = active.dyn_into::<web_sys::HtmlElement>().map(|el| el.blur());
}
}
};
columns.into_iter().map(|(col, label)| { columns.into_iter().map(|(col, label)| {
let is_active = move || sort_col.get() == col; let is_active = move || sort_col.get() == col;
let current_dir = move || sort_dir.get(); let current_dir = move || sort_dir.get();
let close = close_dropdown.clone();
view! { view! {
<li> <li>
<button <button
class=move || if is_active() { "bg-primary/10 text-primary font-bold flex justify-between" } else { "flex justify-between" } class=move || if is_active() { "bg-primary/10 text-primary font-bold flex justify-between" } else { "flex justify-between" }
on:click=move |_| { on:pointerdown=move |e| {
e.stop_propagation();
handle_sort(col); handle_sort(col);
close(); set_sort_open.set(false);
} }
> >
{label} {label}

View File

@@ -144,6 +144,8 @@ pub fn provide_torrent_store() {
let mut backoff_ms: u32 = 1000; // Start with 1 second let mut backoff_ms: u32 = 1000; // Start with 1 second
let max_backoff_ms: u32 = 30000; // Max 30 seconds let max_backoff_ms: u32 = 30000; // Max 30 seconds
let mut was_connected = false; let mut was_connected = false;
let mut disconnect_notified = false; // Track if we already showed disconnect toast
let mut got_first_message = false; // Only count as "connected" after receiving data
loop { loop {
let es_result = EventSource::new("/api/events"); let es_result = EventSource::new("/api/events");
@@ -152,20 +154,28 @@ pub fn provide_torrent_store() {
Ok(mut es) => { Ok(mut es) => {
match es.subscribe("message") { match es.subscribe("message") {
Ok(mut stream) => { Ok(mut stream) => {
// Connection established // Don't show "connected" toast yet - wait for first real message
if was_connected { got_first_message = false;
// We were previously connected and lost connection, now reconnected
show_toast_with_signal(
notifications,
NotificationLevel::Success,
"Sunucu bağlantısı yeniden kuruldu",
);
}
was_connected = true;
backoff_ms = 1000; // Reset backoff on successful connection
// Process messages // Process messages
while let Some(Ok((_, msg))) = stream.next().await { while let Some(Ok((_, msg))) = stream.next().await {
// First successful message = truly connected
if !got_first_message {
got_first_message = true;
backoff_ms = 1000; // Reset backoff on real data
if was_connected && disconnect_notified {
// We were previously connected, lost connection, and now truly reconnected
show_toast_with_signal(
notifications,
NotificationLevel::Success,
"Sunucu bağlantısı yeniden kuruldu",
);
disconnect_notified = false;
}
was_connected = true;
}
if let Some(data_str) = msg.data().as_string() { if let Some(data_str) = msg.data().as_string() {
if let Ok(event) = serde_json::from_str::<AppEvent>(&data_str) { if let Ok(event) = serde_json::from_str::<AppEvent>(&data_str) {
match event { match event {
@@ -218,9 +228,6 @@ pub fn provide_torrent_store() {
// Show browser notification for critical events // Show browser notification for critical events
let is_critical = n.message.contains("tamamlandı") let is_critical = n.message.contains("tamamlandı")
|| n.message.contains("Reconnected")
|| n.message.contains("yeniden kuruldu")
|| n.message.contains("Lost connection")
|| n.level == shared::NotificationLevel::Error; || n.level == shared::NotificationLevel::Error;
if is_critical { if is_critical {
@@ -243,34 +250,37 @@ pub fn provide_torrent_store() {
} }
// Stream ended - connection lost // Stream ended - connection lost
if was_connected { if was_connected && !disconnect_notified {
show_toast_with_signal( show_toast_with_signal(
notifications, notifications,
NotificationLevel::Warning, NotificationLevel::Warning,
"Sunucu bağlantısı kesildi, yeniden bağlanılıyor...", "Sunucu bağlantısı kesildi, yeniden bağlanılıyor...",
); );
disconnect_notified = true;
} }
} }
Err(_) => { Err(_) => {
// Failed to subscribe // Failed to subscribe - only notify once
if was_connected { if was_connected && !disconnect_notified {
show_toast_with_signal( show_toast_with_signal(
notifications, notifications,
NotificationLevel::Warning, NotificationLevel::Warning,
"Sunucu bağlantısı kesildi, yeniden bağlanılıyor...", "Sunucu bağlantısı kesildi, yeniden bağlanılıyor...",
); );
disconnect_notified = true;
} }
} }
} }
} }
Err(_) => { Err(_) => {
// Failed to create EventSource // Failed to create EventSource - only notify once
if was_connected { if was_connected && !disconnect_notified {
show_toast_with_signal( show_toast_with_signal(
notifications, notifications,
NotificationLevel::Warning, NotificationLevel::Warning,
"Sunucu bağlantısı kesildi, yeniden bağlanılıyor...", "Sunucu bağlantısı kesildi, yeniden bağlanılıyor...",
); );
disconnect_notified = true;
} }
} }
} }
@@ -309,34 +319,71 @@ pub async fn subscribe_to_push_notifications() {
// First, request notification permission if not already granted // First, request notification permission if not already granted
let window = web_sys::window().expect("window should exist"); let window = web_sys::window().expect("window should exist");
if let Ok(notification_class) = js_sys::Reflect::get(&window, &"Notification".into()) { let permission_granted = if let Ok(notification_class) = js_sys::Reflect::get(&window, &"Notification".into()) {
if !notification_class.is_undefined() { if notification_class.is_undefined() {
log::error!("Notification API not available");
return;
}
// Check current permission
let current_permission = js_sys::Reflect::get(&notification_class, &"permission".into())
.ok()
.and_then(|p| p.as_string())
.unwrap_or_default();
if current_permission == "granted" {
log::info!("Notification permission already granted");
true
} else if current_permission == "denied" {
log::warn!("Notification permission was denied");
return;
} else {
// Permission is "default" - need to request
log::info!("Requesting notification permission...");
if let Ok(request_fn) = js_sys::Reflect::get(&notification_class, &"requestPermission".into()) { if let Ok(request_fn) = js_sys::Reflect::get(&notification_class, &"requestPermission".into()) {
if request_fn.is_function() { if request_fn.is_function() {
let request_fn_typed = js_sys::Function::from(request_fn); let request_fn_typed = js_sys::Function::from(request_fn);
match request_fn_typed.call0(&notification_class) { match request_fn_typed.call0(&notification_class) {
Ok(promise_val) => { Ok(promise_val) => {
let request_future = wasm_bindgen_futures::JsFuture::from( let request_future = wasm_bindgen_futures::JsFuture::from(
web_sys::js_sys::Promise::from(promise_val) js_sys::Promise::from(promise_val)
); );
match request_future.await { match request_future.await {
Ok(_) => { Ok(result) => {
log::info!("Notification permission requested"); let result_str = result.as_string().unwrap_or_default();
log::info!("Permission request result: {}", result_str);
result_str == "granted"
} }
Err(e) => { Err(e) => {
log::warn!("Failed to request notification permission: {:?}", e); log::error!("Failed to request notification permission: {:?}", e);
false
} }
} }
} }
Err(e) => { Err(e) => {
log::warn!("Failed to call requestPermission: {:?}", e); log::error!("Failed to call requestPermission: {:?}", e);
false
} }
} }
} else {
false
} }
} else {
false
} }
} }
} else {
log::error!("Cannot access Notification class");
return;
};
if !permission_granted {
log::warn!("Notification permission not granted, cannot subscribe to push");
return;
} }
log::info!("Notification permission granted! Proceeding with push subscription...");
// Get VAPID public key from backend // Get VAPID public key from backend
let public_key_response = match Request::get("/api/push/public-key").send().await { let public_key_response = match Request::get("/api/push/public-key").send().await {
Ok(resp) => resp, Ok(resp) => resp,