Compare commits
15 Commits
release-20
...
release-20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3735d0931 | ||
|
|
55f00729ee | ||
|
|
275f4a91b2 | ||
|
|
025a0c4a57 | ||
|
|
b29f9f3cc2 | ||
|
|
feede5c5b4 | ||
|
|
c1306a32a9 | ||
|
|
ed5fba4b46 | ||
|
|
f149603ac8 | ||
|
|
89ad42f24d | ||
|
|
bec804131b | ||
|
|
79979e3e09 | ||
|
|
75efd877c4 | ||
|
|
52c6f45a91 | ||
|
|
f9a8fbccfd |
@@ -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)
|
||||||
|
|||||||
@@ -20,13 +20,12 @@
|
|||||||
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
|
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
|
||||||
|
|
||||||
<!-- Trunk Assets -->
|
<!-- Trunk Assets -->
|
||||||
<link data-trunk rel="rust" href="Cargo.toml" data-wasm-opt="0" data-no-preload />
|
<script data-trunk rel="rust" src="Cargo.toml" data-wasm-opt="0" data-preload="false"></script>
|
||||||
<link data-trunk rel="css" href="public/tailwind.css" />
|
<link data-trunk rel="css" href="public/tailwind.css" />
|
||||||
<link data-trunk rel="copy-file" href="manifest.json" />
|
<link data-trunk rel="copy-file" href="manifest.json" />
|
||||||
<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();
|
|
||||||
})();
|
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
use crate::components::layout::protected::Protected;
|
use crate::components::layout::protected::Protected;
|
||||||
use crate::components::ui::skeleton::Skeleton;
|
use crate::components::ui::skeleton::Skeleton;
|
||||||
use crate::components::ui::card::{Card, CardHeader, CardContent};
|
|
||||||
use crate::components::torrent::table::TorrentTable;
|
use crate::components::torrent::table::TorrentTable;
|
||||||
use crate::components::auth::login::Login;
|
use crate::components::auth::login::Login;
|
||||||
use crate::components::auth::setup::Setup;
|
use crate::components::auth::setup::Setup;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
use leptos_router::components::{Router, Routes, Route};
|
use leptos_router::components::{Router, Routes, Route};
|
||||||
use leptos_router::hooks::{use_navigate, use_location};
|
use leptos_router::hooks::use_navigate;
|
||||||
use crate::components::ui::toast::Toaster;
|
use crate::components::ui::toast::Toaster;
|
||||||
use crate::components::hooks::use_theme_mode::ThemeMode;
|
use crate::components::hooks::use_theme_mode::ThemeMode;
|
||||||
|
|
||||||
@@ -42,7 +41,6 @@ pub fn App() -> impl IntoView {
|
|||||||
fn InnerApp() -> impl IntoView {
|
fn InnerApp() -> impl IntoView {
|
||||||
crate::store::provide_torrent_store();
|
crate::store::provide_torrent_store();
|
||||||
let store = use_context::<crate::store::TorrentStore>();
|
let store = use_context::<crate::store::TorrentStore>();
|
||||||
let loc = use_location();
|
|
||||||
|
|
||||||
let is_loading = signal(true);
|
let is_loading = signal(true);
|
||||||
let is_authenticated = signal(false);
|
let is_authenticated = signal(false);
|
||||||
@@ -146,70 +144,43 @@ fn InnerApp() -> impl IntoView {
|
|||||||
});
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Show when=move || !is_loading.0.get() fallback=move || {
|
<Show when=move || !is_loading.0.get() fallback=|| {
|
||||||
let path = loc.pathname.get();
|
// Standard 1: Always show Dashboard Skeleton
|
||||||
if path == "/login" {
|
view! {
|
||||||
// Login Skeleton
|
<div class="flex h-screen bg-background text-foreground overflow-hidden">
|
||||||
view! {
|
// Sidebar skeleton
|
||||||
<div class="flex items-center justify-center min-h-screen bg-muted/40 px-4">
|
<div class="w-56 border-r border-border p-4 space-y-4">
|
||||||
<Card class="w-full max-w-sm shadow-lg border-none">
|
<Skeleton class="h-8 w-3/4" />
|
||||||
<CardHeader class="pb-2 items-center space-y-4">
|
<div class="space-y-2">
|
||||||
<Skeleton class="w-12 h-12 rounded-xl" />
|
<Skeleton class="h-6 w-full" />
|
||||||
<Skeleton class="h-8 w-32" />
|
<Skeleton class="h-6 w-full" />
|
||||||
<Skeleton class="h-4 w-48" />
|
<Skeleton class="h-6 w-4/5" />
|
||||||
</CardHeader>
|
<Skeleton class="h-6 w-full" />
|
||||||
<CardContent class="pt-4 space-y-6">
|
<Skeleton class="h-6 w-3/5" />
|
||||||
<div class="space-y-2">
|
<Skeleton class="h-6 w-full" />
|
||||||
<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
|
|
||||||
<div class="w-56 border-r border-border p-4 space-y-4">
|
|
||||||
<Skeleton class="h-8 w-3/4" />
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Skeleton class="h-6 w-full" />
|
|
||||||
<Skeleton class="h-6 w-full" />
|
|
||||||
<Skeleton class="h-6 w-4/5" />
|
|
||||||
<Skeleton class="h-6 w-full" />
|
|
||||||
<Skeleton class="h-6 w-3/5" />
|
|
||||||
<Skeleton class="h-6 w-full" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
// Main content skeleton
|
|
||||||
<div class="flex-1 flex flex-col">
|
|
||||||
<div class="border-b border-border p-4 flex items-center gap-4">
|
|
||||||
<Skeleton class="h-8 w-48" />
|
|
||||||
<Skeleton class="h-8 w-64" />
|
|
||||||
<div class="ml-auto"><Skeleton class="h-8 w-24" /></div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 p-4 space-y-3">
|
|
||||||
<Skeleton class="h-10 w-full" />
|
|
||||||
<Skeleton class="h-10 w-full" />
|
|
||||||
<Skeleton class="h-10 w-full" />
|
|
||||||
<Skeleton class="h-10 w-full" />
|
|
||||||
<Skeleton class="h-10 w-full" />
|
|
||||||
<Skeleton class="h-10 w-3/4" />
|
|
||||||
</div>
|
|
||||||
<div class="border-t border-border p-3">
|
|
||||||
<Skeleton class="h-5 w-96" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}.into_any()
|
// Main content skeleton
|
||||||
}
|
<div class="flex-1 flex flex-col min-w-0">
|
||||||
|
<div class="border-b border-border p-4 flex items-center gap-4">
|
||||||
|
<Skeleton class="h-8 w-48" />
|
||||||
|
<Skeleton class="h-8 w-64" />
|
||||||
|
<div class="ml-auto"><Skeleton class="h-8 w-24" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 p-4 space-y-3">
|
||||||
|
<Skeleton class="h-10 w-full" />
|
||||||
|
<Skeleton class="h-10 w-full" />
|
||||||
|
<Skeleton class="h-10 w-full" />
|
||||||
|
<Skeleton class="h-10 w-full" />
|
||||||
|
<Skeleton class="h-10 w-full" />
|
||||||
|
<Skeleton class="h-10 w-3/4" />
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-border p-3">
|
||||||
|
<Skeleton class="h-5 w-96" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
}>
|
}>
|
||||||
<Show when=move || is_authenticated.0.get() fallback=|| ()>
|
<Show when=move || is_authenticated.0.get() fallback=|| ()>
|
||||||
<Protected>
|
<Protected>
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use icons::{ArrowUpDown, Inbox, Settings2, Play, Square, Trash2, Ellipsis, ArrowUp, ArrowDown, Check, ListFilter};
|
use icons::{ArrowUpDown, Inbox, Settings2, Play, Square, Trash2, Ellipsis, ArrowUp, ArrowDown, Check, ListFilter};
|
||||||
use crate::store::{get_action_messages, show_toast};
|
use crate::store::{get_action_messages, show_toast};
|
||||||
@@ -9,12 +10,23 @@ use crate::components::context_menu::TorrentContextMenu;
|
|||||||
use crate::components::ui::card::{Card, CardHeader, CardTitle, CardContent as CardBody};
|
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;
|
||||||
use crate::components::ui::multi_select::*;
|
use crate::components::ui::multi_select::*;
|
||||||
use crate::components::ui::dropdown_menu::*;
|
use crate::components::ui::dropdown_menu::*;
|
||||||
use crate::components::ui::alert_dialog::*;
|
use crate::components::ui::alert_dialog::{
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogBody,
|
||||||
|
AlertDialogClose,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
};
|
||||||
use tailwind_fuse::tw_merge;
|
use tailwind_fuse::tw_merge;
|
||||||
|
|
||||||
const ALL_COLUMNS: [(&str, &str); 8] = [
|
const ALL_COLUMNS: [(&str, &str); 8] = [
|
||||||
@@ -220,66 +232,78 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Show when=move || has_selection.get()>
|
<Show when=move || has_selection.get()>
|
||||||
<DropdownMenu>
|
<div class="flex items-center gap-2">
|
||||||
<DropdownMenuTrigger class="w-[140px] h-9 gap-2">
|
<DropdownMenu>
|
||||||
<Ellipsis class="size-4" />
|
<DropdownMenuTrigger class="w-[140px] h-9 gap-2">
|
||||||
{move || format!("Toplu İşlem ({})", selected_count.get())}
|
<Ellipsis class="size-4" />
|
||||||
</DropdownMenuTrigger>
|
{move || format!("Toplu İşlem ({})", selected_count.get())}
|
||||||
<DropdownMenuContent class="w-48">
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuLabel>"Seçili Torrentler"</DropdownMenuLabel>
|
<DropdownMenuContent class="w-48">
|
||||||
<DropdownMenuGroup class="mt-2">
|
<DropdownMenuLabel>"Seçili Torrentler"</DropdownMenuLabel>
|
||||||
<DropdownMenuItem on:click=move |_| bulk_action("start")>
|
<DropdownMenuGroup class="mt-2">
|
||||||
<Play class="mr-2 size-4" /> "Başlat"
|
<DropdownMenuItem on:click=move |_| bulk_action("start")>
|
||||||
</DropdownMenuItem>
|
<Play class="mr-2 size-4" /> "Başlat"
|
||||||
<DropdownMenuItem on:click=move |_| bulk_action("stop")>
|
</DropdownMenuItem>
|
||||||
<Square class="mr-2 size-4" /> "Durdur"
|
<DropdownMenuItem on:click=move |_| bulk_action("stop")>
|
||||||
</DropdownMenuItem>
|
<Square class="mr-2 size-4" /> "Durdur"
|
||||||
|
</DropdownMenuItem>
|
||||||
<div class="my-1 h-px bg-border" />
|
|
||||||
|
<div class="my-1 h-px bg-border" />
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger class="w-full text-left">
|
// Trigger the hidden AlertDialog from this menu item
|
||||||
<div class="inline-flex gap-2 items-center w-full rounded-sm px-2 py-1.5 text-sm transition-colors text-destructive hover:bg-destructive/10 focus:bg-destructive/10">
|
<DropdownMenuItem class="text-destructive focus:bg-destructive/10 cursor-pointer" on:click=move |_| {
|
||||||
<Trash2 class="size-4" /> "Toplu Sil..."
|
if let Some(trigger) = document().get_element_by_id("bulk-delete-trigger") {
|
||||||
</div>
|
let _ = trigger.dyn_into::<web_sys::HtmlElement>().map(|el: web_sys::HtmlElement| el.click());
|
||||||
</AlertDialogTrigger>
|
}
|
||||||
<AlertDialogContent>
|
}>
|
||||||
<AlertDialogHeader>
|
<Trash2 class="mr-2 size-4" /> "Toplu Sil..."
|
||||||
<AlertDialogTitle class="text-destructive flex items-center gap-2">
|
</DropdownMenuItem>
|
||||||
<Trash2 class="size-5" />
|
</DropdownMenuGroup>
|
||||||
"Toplu Silme Onayı"
|
</DropdownMenuContent>
|
||||||
</AlertDialogTitle>
|
</DropdownMenu>
|
||||||
<AlertDialogDescription class="pt-2">
|
|
||||||
{move || format!("Seçili {} adet torrent silinecek. Lütfen silme yöntemini seçin:", selected_count.get())}
|
// Hidden AlertDialog moved outside the DropdownMenuContent to ensure proper centering
|
||||||
<div class="mt-4 p-3 bg-muted/50 rounded-md text-xs border border-border italic">
|
<AlertDialog>
|
||||||
"Dikkat: Verilerle birlikte silme işlemi dosyaları diskten de kalıcı olarak kaldıracaktır."
|
<AlertDialogTrigger attr:id="bulk-delete-trigger" class="hidden">""</AlertDialogTrigger>
|
||||||
</div>
|
<AlertDialogContent class="sm:max-w-[425px]">
|
||||||
</AlertDialogDescription>
|
<AlertDialogBody>
|
||||||
</AlertDialogHeader>
|
<AlertDialogHeader class="space-y-3">
|
||||||
<AlertDialogFooter class="gap-2 sm:gap-0">
|
<AlertDialogTitle class="text-destructive flex items-center gap-2 text-xl">
|
||||||
<div class="flex flex-col sm:flex-row gap-2 w-full justify-end">
|
<Trash2 class="size-6" />
|
||||||
<AlertDialogClose class="order-3 sm:order-1">"Vazgeç"</AlertDialogClose>
|
"Toplu Silme Onayı"
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription class="text-sm leading-relaxed text-left">
|
||||||
|
{move || format!("Seçili {} adet torrent silinecek. Lütfen silme yöntemini seçin:", selected_count.get())}
|
||||||
|
<div class="mt-4 p-4 bg-destructive/5 rounded-lg border border-destructive/10 text-xs text-destructive/80 font-medium">
|
||||||
|
"⚠️ Dikkat: Verilerle birlikte silme işlemi dosyaları diskten de kalıcı olarak kaldıracaktır."
|
||||||
|
</div>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter class="mt-6">
|
||||||
|
<div class="flex flex-col-reverse sm:flex-row gap-3 w-full sm:justify-end">
|
||||||
|
<AlertDialogClose class="sm:flex-1 md:flex-none">"Vazgeç"</AlertDialogClose>
|
||||||
|
<div class="flex flex-col sm:flex-row gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant=ButtonVariant::Outline
|
variant=ButtonVariant::Secondary
|
||||||
class="order-2 text-foreground"
|
class="w-full sm:w-auto font-medium"
|
||||||
on:click=move |_| bulk_action("delete")
|
on:click=move |_| bulk_action("delete")
|
||||||
>
|
>
|
||||||
"Sadece Listeden Sil"
|
"Sadece Sil"
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant=ButtonVariant::Destructive
|
variant=ButtonVariant::Destructive
|
||||||
class="order-1"
|
class="w-full sm:w-auto font-bold"
|
||||||
on:click=move |_| bulk_action("delete_with_data")
|
on:click=move |_| bulk_action("delete_with_data")
|
||||||
>
|
>
|
||||||
"Verilerle Birlikte Sil"
|
"Verilerle Sil"
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</AlertDialogFooter>
|
</div>
|
||||||
</AlertDialogContent>
|
</AlertDialogFooter>
|
||||||
</AlertDialog>
|
</AlertDialogBody>
|
||||||
</DropdownMenuGroup>
|
</AlertDialogContent>
|
||||||
</DropdownMenuContent>
|
</AlertDialog>
|
||||||
</DropdownMenu>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
// Mobile Sort Menu
|
// Mobile Sort Menu
|
||||||
@@ -364,7 +388,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 +507,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 +579,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 +634,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 +708,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 +739,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,25 +88,7 @@ self.addEventListener("fetch", (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special strategy for WASM and Main JS to prevent Preload warnings
|
// Cache-first strategy for static assets (JS, CSS, Images)
|
||||||
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