Compare commits
7 Commits
release-20
...
release-20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89ad42f24d | ||
|
|
bec804131b | ||
|
|
79979e3e09 | ||
|
|
75efd877c4 | ||
|
|
52c6f45a91 | ||
|
|
f9a8fbccfd | ||
|
|
4a0ebf0cb1 |
@@ -22,12 +22,32 @@ jobs:
|
|||||||
git fetch --depth=1 origin ${{ gitea.sha }}
|
git fetch --depth=1 origin ${{ gitea.sha }}
|
||||||
git checkout FETCH_HEAD
|
git checkout FETCH_HEAD
|
||||||
|
|
||||||
|
- name: Cache Cargo
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/bin/
|
||||||
|
~/.cargo/registry/index/
|
||||||
|
~/.cargo/registry/cache/
|
||||||
|
~/.cargo/git/db/
|
||||||
|
target/
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-
|
||||||
|
|
||||||
|
- name: Cache Node Modules
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: frontend/node_modules
|
||||||
|
key: ${{ runner.os }}-node-${{ hashFiles('frontend/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-
|
||||||
|
|
||||||
- name: Build Frontend
|
- name: Build Frontend
|
||||||
run: |
|
run: |
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm install
|
||||||
npx @tailwindcss/cli -i input.css -o public/tailwind.css --minify --content './src/**/*.rs'
|
npx @tailwindcss/cli -i input.css -o public/tailwind.css --minify --content './src/**/*.rs'
|
||||||
# Trunk'ın optimizasyonunu kapalı (0) tutuyoruz çünkü Cargo.toml'daki opt-level='z' zaten o işi yapıyor.
|
|
||||||
trunk build --release
|
trunk build --release
|
||||||
|
|
||||||
- name: Build Backend (MIPS)
|
- name: Build Backend (MIPS)
|
||||||
|
|||||||
@@ -26,7 +26,6 @@
|
|||||||
<link data-trunk rel="copy-file" href="icon-192.png" />
|
<link data-trunk rel="copy-file" href="icon-192.png" />
|
||||||
<link data-trunk rel="copy-file" href="icon-512.png" />
|
<link data-trunk rel="copy-file" href="icon-512.png" />
|
||||||
<link data-trunk rel="copy-file" href="public/lock_scroll.js" />
|
<link data-trunk rel="copy-file" href="public/lock_scroll.js" />
|
||||||
<script src="/lock_scroll.js"></script>
|
|
||||||
<link data-trunk rel="copy-file" href="sw.js" />
|
<link data-trunk rel="copy-file" href="sw.js" />
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
|||||||
@@ -1,253 +0,0 @@
|
|||||||
/**
|
|
||||||
* Scroll Lock Utility
|
|
||||||
* Handles locking and unlocking scroll for both window and all scrollable containers
|
|
||||||
* Similar to react-remove-scroll but in vanilla JavaScript
|
|
||||||
*/
|
|
||||||
|
|
||||||
(() => {
|
|
||||||
// Prevent multiple initializations
|
|
||||||
if (window.ScrollLock) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
class ScrollLock {
|
|
||||||
constructor() {
|
|
||||||
this.locked = false;
|
|
||||||
this.scrollableElements = [];
|
|
||||||
this.scrollPositions = new Map();
|
|
||||||
this.originalStyles = new Map();
|
|
||||||
this.fixedElements = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find all scrollable elements in the DOM (optimized)
|
|
||||||
* Uses more targeted selectors instead of querying all elements
|
|
||||||
*/
|
|
||||||
findScrollableElements() {
|
|
||||||
const scrollables = [];
|
|
||||||
|
|
||||||
// More targeted query - only look for elements with overflow properties
|
|
||||||
const candidates = document.querySelectorAll(
|
|
||||||
'[style*="overflow"], [class*="overflow"], [class*="scroll"], main, aside, section, div',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Batch all style reads first to minimize reflows
|
|
||||||
const elementsToCheck = [];
|
|
||||||
for (const el of candidates) {
|
|
||||||
// Skip the element itself or if it's inside these containers
|
|
||||||
const dataName = el.getAttribute("data-name");
|
|
||||||
const isExcludedElement =
|
|
||||||
dataName === "ScrollArea" ||
|
|
||||||
dataName === "CommandList" ||
|
|
||||||
dataName === "SelectContent" ||
|
|
||||||
dataName === "MultiSelectContent" ||
|
|
||||||
dataName === "DropdownMenuContent" ||
|
|
||||||
dataName === "ContextMenuContent";
|
|
||||||
|
|
||||||
if (
|
|
||||||
el !== document.body &&
|
|
||||||
el !== document.documentElement &&
|
|
||||||
!isExcludedElement &&
|
|
||||||
!el.closest('[data-name="ScrollArea"]') &&
|
|
||||||
!el.closest('[data-name="CommandList"]') &&
|
|
||||||
!el.closest('[data-name="SelectContent"]') &&
|
|
||||||
!el.closest('[data-name="MultiSelectContent"]') &&
|
|
||||||
!el.closest('[data-name="DropdownMenuContent"]') &&
|
|
||||||
!el.closest('[data-name="ContextMenuContent"]')
|
|
||||||
) {
|
|
||||||
elementsToCheck.push(el);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now batch read all computed styles and dimensions
|
|
||||||
elementsToCheck.forEach((el) => {
|
|
||||||
const style = window.getComputedStyle(el);
|
|
||||||
const hasOverflow =
|
|
||||||
style.overflow === "auto" ||
|
|
||||||
style.overflow === "scroll" ||
|
|
||||||
style.overflowY === "auto" ||
|
|
||||||
style.overflowY === "scroll";
|
|
||||||
|
|
||||||
// Only check scrollHeight if overflow is set
|
|
||||||
if (hasOverflow && el.scrollHeight > el.clientHeight) {
|
|
||||||
scrollables.push(el);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return scrollables;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lock scrolling on all scrollable elements (optimized)
|
|
||||||
* Batches all DOM reads before DOM writes to prevent forced reflows
|
|
||||||
*/
|
|
||||||
lock() {
|
|
||||||
if (this.locked) return;
|
|
||||||
|
|
||||||
this.locked = true;
|
|
||||||
|
|
||||||
// Find all scrollable elements
|
|
||||||
this.scrollableElements = this.findScrollableElements();
|
|
||||||
|
|
||||||
// ===== BATCH 1: READ PHASE - Read all layout properties first =====
|
|
||||||
const windowScrollY = window.scrollY;
|
|
||||||
const scrollbarWidth = window.innerWidth - document.body.clientWidth;
|
|
||||||
|
|
||||||
// Store window scroll position
|
|
||||||
this.scrollPositions.set("window", windowScrollY);
|
|
||||||
|
|
||||||
// Store original body styles
|
|
||||||
this.originalStyles.set("body", {
|
|
||||||
position: document.body.style.position,
|
|
||||||
top: document.body.style.top,
|
|
||||||
width: document.body.style.width,
|
|
||||||
overflow: document.body.style.overflow,
|
|
||||||
paddingRight: document.body.style.paddingRight,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Read all fixed-position elements and their padding (only if we have scrollbar)
|
|
||||||
if (scrollbarWidth > 0) {
|
|
||||||
// Use more targeted query for fixed elements
|
|
||||||
const fixedCandidates = document.querySelectorAll(
|
|
||||||
'[style*="fixed"], [class*="fixed"], header, nav, aside, [role="dialog"], [role="alertdialog"]',
|
|
||||||
);
|
|
||||||
|
|
||||||
this.fixedElements = Array.from(fixedCandidates).filter((el) => {
|
|
||||||
const style = window.getComputedStyle(el);
|
|
||||||
return (
|
|
||||||
style.position === "fixed" &&
|
|
||||||
!el.closest('[data-name="DropdownMenuContent"]') &&
|
|
||||||
!el.closest('[data-name="MultiSelectContent"]') &&
|
|
||||||
!el.closest('[data-name="ContextMenuContent"]')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Batch read all padding values
|
|
||||||
this.fixedElements.forEach((el) => {
|
|
||||||
const computedStyle = window.getComputedStyle(el);
|
|
||||||
const currentPadding = Number.parseInt(computedStyle.paddingRight, 10) || 0;
|
|
||||||
|
|
||||||
this.originalStyles.set(el, {
|
|
||||||
paddingRight: el.style.paddingRight,
|
|
||||||
computedPadding: currentPadding,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read scrollable elements info
|
|
||||||
const scrollableInfo = this.scrollableElements.map((el) => {
|
|
||||||
const scrollTop = el.scrollTop;
|
|
||||||
const elementScrollbarWidth = el.offsetWidth - el.clientWidth;
|
|
||||||
const computedStyle = window.getComputedStyle(el);
|
|
||||||
const currentPadding = Number.parseInt(computedStyle.paddingRight, 10) || 0;
|
|
||||||
|
|
||||||
this.scrollPositions.set(el, scrollTop);
|
|
||||||
this.originalStyles.set(el, {
|
|
||||||
overflow: el.style.overflow,
|
|
||||||
overflowY: el.style.overflowY,
|
|
||||||
paddingRight: el.style.paddingRight,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { el, elementScrollbarWidth, currentPadding };
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===== BATCH 2: WRITE PHASE - Apply all styles at once =====
|
|
||||||
|
|
||||||
// Apply body lock
|
|
||||||
document.body.style.position = "fixed";
|
|
||||||
document.body.style.top = `-${windowScrollY}px`;
|
|
||||||
document.body.style.width = "100%";
|
|
||||||
document.body.style.overflow = "hidden";
|
|
||||||
|
|
||||||
if (scrollbarWidth > 0) {
|
|
||||||
document.body.style.paddingRight = `${scrollbarWidth}px`;
|
|
||||||
|
|
||||||
// Apply padding compensation to fixed elements
|
|
||||||
this.fixedElements.forEach((el) => {
|
|
||||||
const stored = this.originalStyles.get(el);
|
|
||||||
if (stored) {
|
|
||||||
el.style.paddingRight = `${stored.computedPadding + scrollbarWidth}px`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lock all scrollable containers
|
|
||||||
scrollableInfo.forEach(({ el, elementScrollbarWidth, currentPadding }) => {
|
|
||||||
el.style.overflow = "hidden";
|
|
||||||
|
|
||||||
if (elementScrollbarWidth > 0) {
|
|
||||||
el.style.paddingRight = `${currentPadding + elementScrollbarWidth}px`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unlock scrolling on all elements (optimized)
|
|
||||||
* @param {number} delay - Delay in milliseconds before unlocking (for animations)
|
|
||||||
*/
|
|
||||||
unlock(delay = 0) {
|
|
||||||
if (!this.locked) return;
|
|
||||||
|
|
||||||
const performUnlock = () => {
|
|
||||||
// Restore body scroll
|
|
||||||
const bodyStyles = this.originalStyles.get("body");
|
|
||||||
if (bodyStyles) {
|
|
||||||
document.body.style.position = bodyStyles.position;
|
|
||||||
document.body.style.top = bodyStyles.top;
|
|
||||||
document.body.style.width = bodyStyles.width;
|
|
||||||
document.body.style.overflow = bodyStyles.overflow;
|
|
||||||
document.body.style.paddingRight = bodyStyles.paddingRight;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore window scroll position
|
|
||||||
const windowScrollY = this.scrollPositions.get("window") || 0;
|
|
||||||
window.scrollTo(0, windowScrollY);
|
|
||||||
|
|
||||||
// Restore all scrollable containers
|
|
||||||
this.scrollableElements.forEach((el) => {
|
|
||||||
const originalStyles = this.originalStyles.get(el);
|
|
||||||
if (originalStyles) {
|
|
||||||
el.style.overflow = originalStyles.overflow;
|
|
||||||
el.style.overflowY = originalStyles.overflowY;
|
|
||||||
el.style.paddingRight = originalStyles.paddingRight;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore scroll position
|
|
||||||
const scrollPosition = this.scrollPositions.get(el) || 0;
|
|
||||||
el.scrollTop = scrollPosition;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restore fixed-position elements padding
|
|
||||||
this.fixedElements.forEach((el) => {
|
|
||||||
const styles = this.originalStyles.get(el);
|
|
||||||
if (styles && styles.paddingRight !== undefined) {
|
|
||||||
el.style.paddingRight = styles.paddingRight;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear storage
|
|
||||||
this.scrollableElements = [];
|
|
||||||
this.fixedElements = [];
|
|
||||||
this.scrollPositions.clear();
|
|
||||||
this.originalStyles.clear();
|
|
||||||
this.locked = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (delay > 0) {
|
|
||||||
setTimeout(performUnlock, delay);
|
|
||||||
} else {
|
|
||||||
performUnlock();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if scrolling is currently locked
|
|
||||||
*/
|
|
||||||
isLocked() {
|
|
||||||
return this.locked;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export as singleton
|
|
||||||
window.ScrollLock = new ScrollLock();
|
|
||||||
})();
|
|
||||||
@@ -131,51 +131,25 @@ fn InnerApp() -> impl IntoView {
|
|||||||
view! { <Setup /> }
|
view! { <Setup /> }
|
||||||
} />
|
} />
|
||||||
|
|
||||||
<Route path=leptos_router::path!("/") view=move || {
|
<Route path=leptos_router::path!("/") view=move || {
|
||||||
let navigate = use_navigate();
|
let navigate = use_navigate();
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
if !is_loading.0.get() {
|
if !is_loading.0.get() {
|
||||||
if needs_setup.0.get() {
|
if needs_setup.0.get() {
|
||||||
log::info!("Setup not completed, redirecting to setup");
|
log::info!("Setup not completed, redirecting to setup");
|
||||||
navigate("/setup", Default::default());
|
navigate("/setup", Default::default());
|
||||||
} else if !is_authenticated.0.get() {
|
} else if !is_authenticated.0.get() {
|
||||||
log::info!("Not authenticated, redirecting to login");
|
log::info!("Not authenticated, redirecting to login");
|
||||||
navigate("/login", Default::default());
|
navigate("/login", Default::default());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
|
view! {
|
||||||
view! {
|
<Show when=move || !is_loading.0.get() fallback=|| {
|
||||||
<Show when=move || !is_loading.0.get() fallback=move || {
|
// Standard 1: Always show Dashboard Skeleton
|
||||||
let path = loc.pathname.get();
|
|
||||||
if path == "/login" {
|
|
||||||
// Login Skeleton
|
|
||||||
view! {
|
view! {
|
||||||
<div class="flex items-center justify-center min-h-screen bg-muted/40 px-4">
|
<div class="flex h-screen bg-background text-foreground overflow-hidden">
|
||||||
<Card class="w-full max-w-sm shadow-lg border-none">
|
|
||||||
<CardHeader class="pb-2 items-center space-y-4">
|
|
||||||
<Skeleton class="w-12 h-12 rounded-xl" />
|
|
||||||
<Skeleton class="h-8 w-32" />
|
|
||||||
<Skeleton class="h-4 w-48" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="pt-4 space-y-6">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Skeleton class="h-4 w-24" />
|
|
||||||
<Skeleton class="h-10 w-full" />
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Skeleton class="h-4 w-24" />
|
|
||||||
<Skeleton class="h-10 w-full" />
|
|
||||||
</div>
|
|
||||||
<Skeleton class="h-10 w-full rounded-md mt-4" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
}.into_any()
|
|
||||||
} else {
|
|
||||||
// Dashboard Skeleton
|
|
||||||
view! {
|
|
||||||
<div class="flex h-screen bg-background">
|
|
||||||
// Sidebar skeleton
|
// Sidebar skeleton
|
||||||
<div class="w-56 border-r border-border p-4 space-y-4">
|
<div class="w-56 border-r border-border p-4 space-y-4">
|
||||||
<Skeleton class="h-8 w-3/4" />
|
<Skeleton class="h-8 w-3/4" />
|
||||||
@@ -189,7 +163,7 @@ fn InnerApp() -> impl IntoView {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
// Main content skeleton
|
// Main content skeleton
|
||||||
<div class="flex-1 flex flex-col">
|
<div class="flex-1 flex flex-col min-w-0">
|
||||||
<div class="border-b border-border p-4 flex items-center gap-4">
|
<div class="border-b border-border p-4 flex items-center gap-4">
|
||||||
<Skeleton class="h-8 w-48" />
|
<Skeleton class="h-8 w-48" />
|
||||||
<Skeleton class="h-8 w-64" />
|
<Skeleton class="h-8 w-64" />
|
||||||
@@ -209,20 +183,19 @@ fn InnerApp() -> impl IntoView {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}.into_any()
|
}.into_any()
|
||||||
}
|
}>
|
||||||
}>
|
<Show when=move || is_authenticated.0.get() fallback=|| ()>
|
||||||
<Show when=move || is_authenticated.0.get() fallback=|| ()>
|
<Protected>
|
||||||
<Protected>
|
<div class="flex flex-col h-full overflow-hidden">
|
||||||
<div class="flex flex-col h-full overflow-hidden">
|
<div class="flex-1 overflow-hidden">
|
||||||
<div class="flex-1 overflow-hidden">
|
<TorrentTable />
|
||||||
<TorrentTable />
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Protected>
|
||||||
</Protected>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
}.into_any()
|
||||||
}.into_any()
|
}/>
|
||||||
}/>
|
|
||||||
|
|
||||||
<Route path=leptos_router::path!("/settings") view=move || {
|
<Route path=leptos_router::path!("/settings") view=move || {
|
||||||
let authenticated = is_authenticated.0.get();
|
let authenticated = is_authenticated.0.get();
|
||||||
|
|||||||
@@ -1,31 +1,5 @@
|
|||||||
use std::collections::hash_map::DefaultHasher;
|
use leptos::prelude::*;
|
||||||
use std::hash::{Hash, Hasher};
|
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
||||||
|
|
||||||
const PREFIX: &str = "rust_ui"; // Must NOT contain "/" or "-"
|
pub fn use_random_id_for(prefix: &str) -> String {
|
||||||
|
format!("{}_{}", prefix, js_sys::Math::random().to_string().replace(".", ""))
|
||||||
pub fn use_random_id() -> String {
|
|
||||||
format!("_{PREFIX}_{}", generate_hash())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn use_random_id_for(element: &str) -> String {
|
|
||||||
format!("{}_{PREFIX}_{}", element, generate_hash())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn use_random_transition_name() -> String {
|
|
||||||
let random_id = use_random_id();
|
|
||||||
format!("view-transition-name: {random_id}")
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================================== */
|
|
||||||
/* ✨ FUNCTIONS ✨ */
|
|
||||||
/* ========================================================== */
|
|
||||||
|
|
||||||
static COUNTER: AtomicUsize = AtomicUsize::new(1);
|
|
||||||
|
|
||||||
fn generate_hash() -> u64 {
|
|
||||||
let mut hasher = DefaultHasher::new();
|
|
||||||
let counter = COUNTER.fetch_add(1, Ordering::SeqCst);
|
|
||||||
counter.hash(&mut hasher);
|
|
||||||
hasher.finish()
|
|
||||||
}
|
|
||||||
@@ -6,9 +6,9 @@ use crate::store::{get_action_messages, show_toast};
|
|||||||
use crate::api;
|
use crate::api;
|
||||||
use shared::NotificationLevel;
|
use shared::NotificationLevel;
|
||||||
use crate::components::context_menu::TorrentContextMenu;
|
use crate::components::context_menu::TorrentContextMenu;
|
||||||
use crate::components::ui::card::{Card, CardHeader, CardTitle, CardContent as CardBody};
|
|
||||||
use crate::components::ui::data_table::*;
|
use crate::components::ui::data_table::*;
|
||||||
use crate::components::ui::checkbox::Checkbox;
|
use crate::components::ui::checkbox::Checkbox;
|
||||||
|
use crate::components::ui::badge::{Badge, BadgeVariant};
|
||||||
use crate::components::ui::button::{Button, ButtonVariant};
|
use crate::components::ui::button::{Button, ButtonVariant};
|
||||||
use crate::components::ui::empty::*;
|
use crate::components::ui::empty::*;
|
||||||
use crate::components::ui::input::Input;
|
use crate::components::ui::input::Input;
|
||||||
@@ -364,7 +364,6 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
<div class="flex-1 min-h-0 overflow-hidden">
|
<div class="flex-1 min-h-0 overflow-hidden">
|
||||||
// Desktop Table View
|
// Desktop Table View
|
||||||
<DataTableWrapper class="hidden md:block h-full bg-card/50">
|
<DataTableWrapper class="hidden md:block h-full bg-card/50">
|
||||||
// ... (Masaüstü tablosu aynı kalıyor)
|
|
||||||
<div class="h-full overflow-auto">
|
<div class="h-full overflow-auto">
|
||||||
<DataTable>
|
<DataTable>
|
||||||
<DataTableHeader class="sticky top-0 bg-muted/80 backdrop-blur-sm z-10">
|
<DataTableHeader class="sticky top-0 bg-muted/80 backdrop-blur-sm z-10">
|
||||||
@@ -484,7 +483,7 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
</DataTableWrapper>
|
</DataTableWrapper>
|
||||||
|
|
||||||
// Mobile Card View
|
// Mobile Card View
|
||||||
<div class="block md:hidden h-full overflow-y-auto space-y-4 pb-32">
|
<div class="block md:hidden h-full overflow-y-auto space-y-4 pb-32 px-1">
|
||||||
<Show
|
<Show
|
||||||
when=move || !filtered_hashes.get().is_empty()
|
when=move || !filtered_hashes.get().is_empty()
|
||||||
fallback=move || view! {
|
fallback=move || view! {
|
||||||
@@ -556,7 +555,6 @@ fn TorrentRow(
|
|||||||
move || {
|
move || {
|
||||||
let t = torrent.get().unwrap();
|
let t = torrent.get().unwrap();
|
||||||
let t_name = t.name.clone();
|
let t_name = t.name.clone();
|
||||||
let status_color = match t.status { shared::TorrentStatus::Seeding => "text-green-500", shared::TorrentStatus::Downloading => "text-blue-500", shared::TorrentStatus::Paused => "text-yellow-500", shared::TorrentStatus::Error => "text-red-500", _ => "text-muted-foreground" };
|
|
||||||
|
|
||||||
let is_active_selection = Memo::new(move |_| {
|
let is_active_selection = Memo::new(move |_| {
|
||||||
let selected = store.selected_torrent.get();
|
let selected = store.selected_torrent.get();
|
||||||
@@ -612,8 +610,18 @@ fn TorrentRow(
|
|||||||
|
|
||||||
{move || visible_columns.get().contains("Status").then({
|
{move || visible_columns.get().contains("Status").then({
|
||||||
let status_text = format!("{:?}", t.status);
|
let status_text = format!("{:?}", t.status);
|
||||||
let color = status_color;
|
let variant = match t.status {
|
||||||
move || view! { <DataTableCell class={format!("text-xs font-semibold whitespace-nowrap {}", color)}>{status_text.clone()}</DataTableCell> }
|
shared::TorrentStatus::Seeding => BadgeVariant::Success,
|
||||||
|
shared::TorrentStatus::Downloading => BadgeVariant::Info,
|
||||||
|
shared::TorrentStatus::Paused => BadgeVariant::Warning,
|
||||||
|
shared::TorrentStatus::Error => BadgeVariant::Destructive,
|
||||||
|
_ => BadgeVariant::Secondary,
|
||||||
|
};
|
||||||
|
move || view! {
|
||||||
|
<DataTableCell class="whitespace-nowrap">
|
||||||
|
<Badge variant=variant>{status_text.clone()}</Badge>
|
||||||
|
</DataTableCell>
|
||||||
|
}
|
||||||
}).into_any()}
|
}).into_any()}
|
||||||
|
|
||||||
{move || visible_columns.get().contains("DownSpeed").then({
|
{move || visible_columns.get().contains("DownSpeed").then({
|
||||||
@@ -676,12 +684,12 @@ fn TorrentCard(
|
|||||||
move || {
|
move || {
|
||||||
let t = torrent.get().unwrap();
|
let t = torrent.get().unwrap();
|
||||||
let t_name = t.name.clone();
|
let t_name = t.name.clone();
|
||||||
let status_badge_class = match t.status {
|
let status_variant = match t.status {
|
||||||
shared::TorrentStatus::Seeding => "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 border-green-200 dark:border-green-800",
|
shared::TorrentStatus::Seeding => BadgeVariant::Success,
|
||||||
shared::TorrentStatus::Downloading => "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 border-blue-200 dark:border-blue-800",
|
shared::TorrentStatus::Downloading => BadgeVariant::Info,
|
||||||
shared::TorrentStatus::Paused => "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800",
|
shared::TorrentStatus::Paused => BadgeVariant::Warning,
|
||||||
shared::TorrentStatus::Error => "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 border-red-200 dark:border-red-800",
|
shared::TorrentStatus::Error => BadgeVariant::Destructive,
|
||||||
_ => "bg-muted text-muted-foreground"
|
_ => BadgeVariant::Secondary
|
||||||
};
|
};
|
||||||
let h_for_menu = stored_hash.get_value();
|
let h_for_menu = stored_hash.get_value();
|
||||||
|
|
||||||
@@ -707,9 +715,9 @@ fn TorrentCard(
|
|||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h3 class="text-sm font-bold leading-tight line-clamp-2 break-all">{t_name.clone()}</h3>
|
<h3 class="text-sm font-bold leading-tight line-clamp-2 break-all">{t_name.clone()}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class={format!("shrink-0 inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider {}", status_badge_class)}>
|
<Badge variant=status_variant class="uppercase tracking-wider text-[10px]">
|
||||||
{format!("{:?}", t.status)}
|
{format!("{:?}", t.status)}
|
||||||
</div>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
|
|||||||
43
frontend/src/components/ui/badge.rs
Normal file
43
frontend/src/components/ui/badge.rs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use tailwind_fuse::tw_merge;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)]
|
||||||
|
pub enum BadgeVariant {
|
||||||
|
#[default]
|
||||||
|
Default,
|
||||||
|
Secondary,
|
||||||
|
Outline,
|
||||||
|
Destructive,
|
||||||
|
Success,
|
||||||
|
Warning,
|
||||||
|
Info,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Badge(
|
||||||
|
children: Children,
|
||||||
|
#[prop(optional, default = BadgeVariant::Default)] variant: BadgeVariant,
|
||||||
|
#[prop(optional, into)] class: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let variant_classes = match variant {
|
||||||
|
BadgeVariant::Default => "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
BadgeVariant::Secondary => "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
BadgeVariant::Outline => "text-foreground",
|
||||||
|
BadgeVariant::Destructive => "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
|
BadgeVariant::Success => "border-transparent bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20",
|
||||||
|
BadgeVariant::Warning => "border-transparent bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border-yellow-500/20",
|
||||||
|
BadgeVariant::Info => "border-transparent bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
|
||||||
|
};
|
||||||
|
|
||||||
|
let class = tw_merge!(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
variant_classes,
|
||||||
|
class
|
||||||
|
);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class=class>
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,7 +93,7 @@ pub fn DialogContent(
|
|||||||
let backdrop_behavior = if close_on_backdrop_click { "auto" } else { "manual" };
|
let backdrop_behavior = if close_on_backdrop_click { "auto" } else { "manual" };
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<script src="/hooks/lock_scroll.js"></script>
|
<script src="/lock_scroll.js"></script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
data-name=backdrop_data_name
|
data-name=backdrop_data_name
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
use leptos::prelude::*;
|
|
||||||
use leptos_ui::clx;
|
|
||||||
|
|
||||||
mod components {
|
|
||||||
use super::*;
|
|
||||||
clx! {Draggable, div, "flex flex-col gap-4 w-full max-w-4xl"}
|
|
||||||
clx! {DraggableZone, div, "dragabble__container", "bg-neutral-600 p-4 mt-4"}
|
|
||||||
|
|
||||||
// TODO. ItemRoot (needs `draggable` as clx attribute).
|
|
||||||
}
|
|
||||||
|
|
||||||
pub use components::*;
|
|
||||||
|
|
||||||
/* ========================================================== */
|
|
||||||
/* ✨ FUNCTIONS ✨ */
|
|
||||||
/* ========================================================== */
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn DraggableItem(#[prop(into)] text: String) -> impl IntoView {
|
|
||||||
view! {
|
|
||||||
<div class="p-4 border cursor-move border-input bg-card draggable" draggable="true">
|
|
||||||
{text}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod accordion;
|
pub mod accordion;
|
||||||
pub mod alert_dialog;
|
pub mod alert_dialog;
|
||||||
|
pub mod badge;
|
||||||
pub mod button;
|
pub mod button;
|
||||||
pub mod card;
|
pub mod card;
|
||||||
pub mod checkbox;
|
pub mod checkbox;
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
use leptos::prelude::*;
|
|
||||||
use tw_merge::*;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)]
|
|
||||||
pub enum ToastType {
|
|
||||||
#[default]
|
|
||||||
Default,
|
|
||||||
Success,
|
|
||||||
Error,
|
|
||||||
Warning,
|
|
||||||
Info,
|
|
||||||
Loading,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)]
|
|
||||||
pub enum SonnerPosition {
|
|
||||||
TopLeft,
|
|
||||||
TopCenter,
|
|
||||||
TopRight,
|
|
||||||
#[default]
|
|
||||||
BottomRight,
|
|
||||||
BottomCenter,
|
|
||||||
BottomLeft,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)]
|
|
||||||
pub enum SonnerDirection {
|
|
||||||
TopDown,
|
|
||||||
#[default]
|
|
||||||
BottomUp,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SonnerTrigger(
|
|
||||||
children: Children,
|
|
||||||
#[prop(into, optional)] class: String,
|
|
||||||
#[prop(optional, default = ToastType::default())] variant: ToastType,
|
|
||||||
#[prop(into)] title: String,
|
|
||||||
#[prop(into)] description: String,
|
|
||||||
#[prop(into, optional)] position: String,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let variant_classes = match variant {
|
|
||||||
ToastType::Default => "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
|
||||||
ToastType::Success => "bg-success text-success-foreground hover:bg-success/90",
|
|
||||||
ToastType::Error => "bg-destructive text-white shadow-xs hover:bg-destructive/90 dark:bg-destructive/60",
|
|
||||||
ToastType::Warning => "bg-warning text-warning-foreground hover:bg-warning/90",
|
|
||||||
ToastType::Info => "bg-info text-info-foreground shadow-xs hover:bg-info/90",
|
|
||||||
ToastType::Loading => "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
|
||||||
};
|
|
||||||
|
|
||||||
let merged_class = tw_merge!(
|
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] w-fit cursor-pointer h-9 px-4 py-2",
|
|
||||||
variant_classes,
|
|
||||||
class
|
|
||||||
);
|
|
||||||
|
|
||||||
// Only set position attribute if not empty
|
|
||||||
let position_attr = if position.is_empty() { None } else { Some(position) };
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<button
|
|
||||||
class=merged_class
|
|
||||||
data-name="SonnerTrigger"
|
|
||||||
data-variant=variant.to_string()
|
|
||||||
data-toast-title=title
|
|
||||||
data-toast-description=description
|
|
||||||
data-toast-position=position_attr
|
|
||||||
>
|
|
||||||
{children()}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SonnerContainer(
|
|
||||||
children: Children,
|
|
||||||
#[prop(into, optional)] class: String,
|
|
||||||
#[prop(optional, default = SonnerPosition::default())] position: SonnerPosition,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let merged_class = tw_merge!("toast__container fixed z-50", class);
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<div class=merged_class data-position=position.to_string()>
|
|
||||||
{children()}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SonnerList(
|
|
||||||
children: Children,
|
|
||||||
#[prop(into, optional)] class: String,
|
|
||||||
#[prop(optional, default = SonnerPosition::default())] position: SonnerPosition,
|
|
||||||
#[prop(optional, default = SonnerDirection::default())] direction: SonnerDirection,
|
|
||||||
#[prop(into, default = "false".to_string())] expanded: String,
|
|
||||||
#[prop(into, optional)] style: String,
|
|
||||||
) -> impl IntoView {
|
|
||||||
// pointer-events-none: container doesn't block clicks when empty
|
|
||||||
// [&>*]:pointer-events-auto: toast items still receive clicks
|
|
||||||
let merged_class = tw_merge!(
|
|
||||||
"flex relative flex-col opacity-100 gap-[15px] h-[100px] w-[400px] pointer-events-none [&>*]:pointer-events-auto",
|
|
||||||
class
|
|
||||||
);
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<ol
|
|
||||||
class=merged_class
|
|
||||||
data-name="SonnerList"
|
|
||||||
data-sonner-toaster="true"
|
|
||||||
data-sonner-theme="light"
|
|
||||||
data-position=position.to_string()
|
|
||||||
data-expanded=expanded
|
|
||||||
data-direction=direction.to_string()
|
|
||||||
style=style
|
|
||||||
>
|
|
||||||
{children()}
|
|
||||||
</ol>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SonnerToaster(#[prop(default = SonnerPosition::default())] position: SonnerPosition) -> impl IntoView {
|
|
||||||
// Auto-derive direction from position
|
|
||||||
let direction = match position {
|
|
||||||
SonnerPosition::TopLeft | SonnerPosition::TopCenter | SonnerPosition::TopRight => SonnerDirection::TopDown,
|
|
||||||
_ => SonnerDirection::BottomUp,
|
|
||||||
};
|
|
||||||
|
|
||||||
let container_class = match position {
|
|
||||||
SonnerPosition::TopLeft => "left-6 top-6",
|
|
||||||
SonnerPosition::TopRight => "right-6 top-6",
|
|
||||||
SonnerPosition::TopCenter => "left-1/2 -translate-x-1/2 top-6",
|
|
||||||
SonnerPosition::BottomCenter => "left-1/2 -translate-x-1/2 bottom-6",
|
|
||||||
SonnerPosition::BottomLeft => "left-6 bottom-6",
|
|
||||||
SonnerPosition::BottomRight => "right-6 bottom-6",
|
|
||||||
};
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<SonnerContainer class=container_class position=position>
|
|
||||||
<SonnerList position=position direction=direction>
|
|
||||||
""
|
|
||||||
</SonnerList>
|
|
||||||
</SonnerContainer>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -49,6 +49,7 @@ pub fn SonnerTrigger(
|
|||||||
is_expanded: Signal<bool>,
|
is_expanded: Signal<bool>,
|
||||||
#[prop(optional)] on_dismiss: Option<Callback<()>>,
|
#[prop(optional)] on_dismiss: Option<Callback<()>>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
|
let _ = is_expanded; // Silence unused warning while keeping prop name intact for builder
|
||||||
let variant_classes = match toast.variant {
|
let variant_classes = match toast.variant {
|
||||||
ToastType::Default => "bg-background text-foreground border-border",
|
ToastType::Default => "bg-background text-foreground border-border",
|
||||||
ToastType::Success => "bg-background text-foreground border-border [&_.icon]:text-green-500",
|
ToastType::Success => "bg-background text-foreground border-border [&_.icon]:text-green-500",
|
||||||
|
|||||||
@@ -88,7 +88,25 @@ self.addEventListener("fetch", (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache-first strategy for static assets (JS, CSS, Images)
|
// Special strategy for WASM and Main JS to prevent Preload warnings
|
||||||
|
if (url.pathname.endsWith(".wasm") || (url.pathname.endsWith(".js") && url.pathname.includes("frontend-"))) {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request)
|
||||||
|
.then((response) => {
|
||||||
|
const responseToCache = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
|
cache.put(event.request, responseToCache);
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
return caches.match(event.request);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache-first strategy for other static assets (CSS, Images, etc.)
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(event.request).then((response) => {
|
caches.match(event.request).then((response) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user