Compare commits

...

5 Commits

Author SHA1 Message Date
spinline
93e853977a feat: simplify theme toggle and improve sidebar layout
All checks were successful
Build MIPS Binary / build (push) Successful in 5m20s
- frontend/src/components/layout/sidebar.rs: Replaced theme selector with simple dark/light toggle button.
- Cleaned up profile section layout and added safe-area padding.
2026-02-11 01:31:30 +03:00
spinline
e3bc956256 feat: migrate to shadcn toast (sonner)
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
- frontend/src/app.rs: Replaced custom ToastContainer with SonnerProvider
- frontend/src/store.rs: Updated show_toast to use leptos_shadcn_toast::toast API
- frontend/src/components/toast.rs: Deleted custom toast component
- frontend/src/components/torrent/add_torrent.rs: Updated toast usage
- frontend/src/components/torrent/table.rs: Updated toast usage
2026-02-11 01:26:46 +03:00
spinline
5b016aca58 fix: iOS WASM Notification Crash
All checks were successful
Build MIPS Binary / build (push) Successful in 5m21s
- frontend/src/utils/notification.rs: Notification API kullanımı is_notification_supported() ile sarmalandı
- frontend/src/utils/notification.rs: leptos_use::use_web_notification yerine güvenli manuel implementasyon yapıldı
- task.md: iOS WASM hatası giderildi olarak işaretlendi
2026-02-11 01:12:04 +03:00
spinline
5bd3d31dd6 feat: Tema Butonu Sidebar'a Taşındı
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
- sidebar.rs: Tema state yönetimi ve UI butonu eklendi (profil alanının yanına)
- statusbar.rs: Tema ile ilgili eski kodlar ve UI kaldırıldı
- implementation_plan.md: Plan dosyası oluşturuldu
- task.md: Görev tamamlandı olarak işaretlendi
2026-02-11 01:08:18 +03:00
spinline
87ddd3bb93 fix: iOS Dark Mode ve Tema Değişimi Düzeltildi
All checks were successful
Build MIPS Binary / build (push) Successful in 5m17s
- tailwind.config.js: darkMode: 'class' olarak ayarlandı (iOS sistem tercihi yerine .dark class kullanımı için)
- statusbar.rs: Tema değişiminde .dark class ekleme mantığı tüm dark temaları kapsayacak şekilde genişletildi
- index.html: Sayfa yüklenirken .dark class ekleyen inline script güncellendi
- public/tailwind.css: PostCSS ile yeniden derlendi (nesting düzleştirildi + .dark seçiciler eklendi)
2026-02-11 01:00:04 +03:00
12 changed files with 523 additions and 708 deletions

View File

@@ -1,101 +1,102 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<title>VibeTorrent</title>
<!-- PWA & Mobile Capable -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="VibeTorrent" />
<meta name="theme-color" content="#111827" />
<link rel="manifest" href="manifest.json" />
<link rel="icon" type="image/png" href="icon-192.png" />
<link rel="apple-touch-icon" href="icon-192.png" />
<link rel="apple-touch-icon" sizes="192x192" href="icon-192.png" />
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
<head>
<meta charset="utf-8" />
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>VibeTorrent</title>
<!-- Trunk Assets -->
<link data-trunk rel="rust" href="Cargo.toml" data-wasm-opt="0" />
<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="icon-192.png" />
<link data-trunk rel="copy-file" href="icon-512.png" />
<link data-trunk rel="copy-file" href="sw.js" />
<script>
(function () {
var localTheme = localStorage.getItem("vibetorrent_theme");
var t = localTheme || "dark";
if (t === "Amoled") t = "black";
if (t === "Light") t = "light";
if (t === "Dark" || t === "Midnight") t = "dark";
<!-- PWA & Mobile Capable -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="VibeTorrent" />
<meta name="theme-color" content="#111827" />
<link rel="manifest" href="manifest.json" />
<link rel="icon" type="image/png" href="icon-192.png" />
<link rel="apple-touch-icon" href="icon-192.png" />
<link rel="apple-touch-icon" sizes="192x192" href="icon-192.png" />
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
var theme = t.toLowerCase();
document.documentElement.setAttribute("data-theme", theme);
if (!localTheme) {
localStorage.setItem("vibetorrent_theme", "dark");
}
<!-- Trunk Assets -->
<link data-trunk rel="rust" href="Cargo.toml" data-wasm-opt="0" />
<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="icon-192.png" />
<link data-trunk rel="copy-file" href="icon-512.png" />
<link data-trunk rel="copy-file" href="sw.js" />
<script>
(function () {
var localTheme = localStorage.getItem("vibetorrent_theme");
var t = localTheme || "dark";
if (t === "Amoled") t = "black";
if (t === "Light") t = "light";
if (t === "Dark" || t === "Midnight") t = "dark";
var meta = document.querySelector('meta[name="theme-color"]');
if (meta) {
var colorMap = {
light: "#ffffff",
cupcake: "#faf7f5",
bumblebee: "#ffffff",
emerald: "#ffffff",
corporate: "#ffffff",
synthwave: "#2d1b69",
retro: "#ece3ca",
cyberpunk: "#ffee00",
valentine: "#f0d6e8",
halloween: "#212121",
garden: "#e9e7e7",
forest: "#171212",
aqua: "#345da7",
lofi: "#ffffff",
pastel: "#ffffff",
fantasy: "#ffffff",
wireframe: "#ffffff",
black: "#000000",
luxury: "#09090b",
dracula: "#282a36",
cmyk: "#ffffff",
autumn: "#8C0327",
business: "#202020",
acid: "#fafafa",
lemonade: "#F1F8E8",
night: "#0f1729",
coffee: "#20161f",
winter: "#ffffff",
dark: "#1d232a",
};
var color = colorMap[theme] || "#1d232a";
meta.setAttribute("content", color);
}
})();
</script>
</head>
var theme = t.toLowerCase();
document.documentElement.setAttribute("data-theme", theme);
// Shadcn dark mode CSS değişkenleri .dark class ile çalışıyor
var darkThemes = ["dark", "black", "night", "coffee", "luxury", "business", "dracula", "halloween", "forest", "synthwave", "dim", "nord", "sunset", "cyberpunk", "abyss"];
if (darkThemes.indexOf(theme) !== -1) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
if (!localTheme) {
localStorage.setItem("vibetorrent_theme", "dark");
}
<body style="cursor: pointer;">
<div
id="app-loading"
style="
var meta = document.querySelector('meta[name="theme-color"]');
if (meta) {
var colorMap = {
light: "#ffffff",
cupcake: "#faf7f5",
bumblebee: "#ffffff",
emerald: "#ffffff",
corporate: "#ffffff",
synthwave: "#2d1b69",
retro: "#ece3ca",
cyberpunk: "#ffee00",
valentine: "#f0d6e8",
halloween: "#212121",
garden: "#e9e7e7",
forest: "#171212",
aqua: "#345da7",
lofi: "#ffffff",
pastel: "#ffffff",
fantasy: "#ffffff",
wireframe: "#ffffff",
black: "#000000",
luxury: "#09090b",
dracula: "#282a36",
cmyk: "#ffffff",
autumn: "#8C0327",
business: "#202020",
acid: "#fafafa",
lemonade: "#F1F8E8",
night: "#0f1729",
coffee: "#20161f",
winter: "#ffffff",
dark: "#1d232a",
};
var color = colorMap[theme] || "#1d232a";
meta.setAttribute("content", color);
}
})();
</script>
</head>
<body style="cursor: pointer;">
<div id="app-loading" style="
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
font-family: sans-serif;
"
>
<div
id="app-loading-spinner"
style="
">
<div id="app-loading-spinner" style="
width: 40px;
height: 40px;
border: 3px solid currentColor;
@@ -103,21 +104,15 @@
border-radius: 50%;
animation: spin 0.8s linear infinite;
opacity: 0.5;
"
></div>
<div
id="app-loading-error"
style="display: none; text-align: center; margin-top: 20px; padding: 0 20px"
>
<p style="color: #ef4444; font-weight: bold; margin-bottom: 8px">
Uygulama yüklenemedi
</p>
<p style="font-size: 14px; opacity: 0.7">
Bağlantınız yavaş olabilir veya bir sistem hatası oluşmuş olabilir.
</p>
<button
onclick="location.reload()"
style="
"></div>
<div id="app-loading-error" style="display: none; text-align: center; margin-top: 20px; padding: 0 20px">
<p style="color: #ef4444; font-weight: bold; margin-bottom: 8px">
Uygulama yüklenemedi
</p>
<p style="font-size: 14px; opacity: 0.7">
Bağlantınız yavaş olabilir veya bir sistem hatası oluşmuş olabilir.
</p>
<button onclick="location.reload()" style="
margin-top: 16px;
padding: 8px 16px;
background: #3b82f6;
@@ -126,104 +121,105 @@
border-radius: 6px;
cursor: pointer;
font-weight: 500;
"
>
Sayfayı Yenile
</button>
</div>
">
Sayfayı Yenile
</button>
</div>
<style>
@keyframes spin {
to {
transform: rotate(360deg);
</div>
<style>
@keyframes spin {
to {
transform: rotate(360deg);
}
}
body.app-loaded #app-loading {
display: none !important;
}
/* iOS Safari Click Fixes */
body {
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
summary {
list-style: none;
}
summary::-webkit-details-marker {
display: none;
}
</style>
<script>
// App loading timeout handler
(function () {
var timeout = setTimeout(function () {
if (!document.body.classList.contains("app-loaded")) {
var spinner = document.getElementById("app-loading-spinner");
var error = document.getElementById("app-loading-error");
if (spinner) spinner.style.display = "none";
if (error) error.style.display = "block";
}
}
}, 15000); // 15 seconds timeout
body.app-loaded #app-loading {
display: none !important;
}
/* iOS Safari Click Fixes */
body {
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
summary {
list-style: none;
}
summary::-webkit-details-marker {
display: none;
}
</style>
<script>
// App loading timeout handler
(function () {
var timeout = setTimeout(function () {
if (!document.body.classList.contains("app-loaded")) {
var spinner = document.getElementById("app-loading-spinner");
var error = document.getElementById("app-loading-error");
if (spinner) spinner.style.display = "none";
if (error) error.style.display = "block";
}
}, 15000); // 15 seconds timeout
// Clean up timeout if app loads
var observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (
mutation.attributeName === "class" &&
document.body.classList.contains("app-loaded")
) {
clearTimeout(timeout);
observer.disconnect();
}
});
});
observer.observe(document.body, { attributes: true });
})();
</script>
<!-- Service Worker Registration & PWA Setup -->
<script>
// Global Dropdown Closer for iOS/Mobile
document.addEventListener('click', function(event) {
const details = document.querySelectorAll('details[open]');
details.forEach(detail => {
// Eğer tıklanan yer bu details'in içinde değilse kapat
if (!detail.contains(event.target)) {
detail.removeAttribute('open');
// Clean up timeout if app loads
var observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (
mutation.attributeName === "class" &&
document.body.classList.contains("app-loaded")
) {
clearTimeout(timeout);
observer.disconnect();
}
});
}, true); // Use capture phase for better mobile support
});
observer.observe(document.body, { attributes: true });
})();
</script>
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
console.log("✅ Service Worker registered:", registration);
// Request notification permission after a delay (better UX)
setTimeout(() => {
if ("Notification" in window && Notification.permission === "default") {
// Only request if user hasn't decided yet
const shouldRequest = localStorage.getItem("vibetorrent_notification_prompt_shown");
if (!shouldRequest) {
Notification.requestPermission().then((permission) => {
console.log("Notification permission:", permission);
localStorage.setItem("vibetorrent_notification_prompt_shown", "true");
});
}
<!-- Service Worker Registration & PWA Setup -->
<script>
// Global Dropdown Closer for iOS/Mobile
document.addEventListener('click', function (event) {
const details = document.querySelectorAll('details[open]');
details.forEach(detail => {
// Eğer tıklanan yer bu details'in içinde değilse kapat
if (!detail.contains(event.target)) {
detail.removeAttribute('open');
}
});
}, true); // Use capture phase for better mobile support
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
console.log("✅ Service Worker registered:", registration);
// Request notification permission after a delay (better UX)
setTimeout(() => {
if ("Notification" in window && Notification.permission === "default") {
// Only request if user hasn't decided yet
const shouldRequest = localStorage.getItem("vibetorrent_notification_prompt_shown");
if (!shouldRequest) {
Notification.requestPermission().then((permission) => {
console.log("Notification permission:", permission);
localStorage.setItem("vibetorrent_notification_prompt_shown", "true");
});
}
}, 3000); // Wait 3 seconds before asking
})
.catch((error) => {
console.warn("⚠️ Service Worker registration failed:", error);
});
});
}
</script>
</body>
</html>
}
}, 3000); // Wait 3 seconds before asking
})
.catch((error) => {
console.warn("⚠️ Service Worker registration failed:", error);
});
});
}
</script>
</body>
</html>

View File

@@ -3166,216 +3166,160 @@ button:not(#\#):not(#\#), input:where([type="button"], [type="reset"], [type="su
display: none;
}
}
@media (prefers-color-scheme: dark) {
.dark\:border-blue-800:not(#\#):not(#\#):not(#\#):not(#\#) {
border-color: rgb(25, 60, 184);
border-color: var(--color-blue-800);
}
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:border-blue-800 {
border-color: rgb(25, 60, 184);
border-color: var(--color-blue-800);
}
@media (prefers-color-scheme: dark) {
.dark\:border-destructive:not(#\#):not(#\#):not(#\#):not(#\#) {
border-color: hsl(0, 84%, 60%);
border-color: var(--color-destructive);
}
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:border-destructive {
border-color: hsl(0, 84%, 60%);
border-color: var(--color-destructive);
}
@media (prefers-color-scheme: dark) {
.dark\:border-gray-800:not(#\#):not(#\#):not(#\#):not(#\#) {
border-color: #1f2937;
}
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:border-gray-800 {
border-color: #1f2937;
}
@media (prefers-color-scheme: dark) {
.dark\:border-green-800:not(#\#):not(#\#):not(#\#):not(#\#) {
border-color: rgb(1, 102, 48);
border-color: var(--color-green-800);
}
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:border-green-800 {
border-color: rgb(1, 102, 48);
border-color: var(--color-green-800);
}
@media (prefers-color-scheme: dark) {
.dark\:border-red-800:not(#\#):not(#\#):not(#\#):not(#\#) {
border-color: rgb(159, 7, 18);
border-color: var(--color-red-800);
}
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:border-red-800 {
border-color: rgb(159, 7, 18);
border-color: var(--color-red-800);
}
@media (prefers-color-scheme: dark) {
.dark\:border-yellow-800:not(#\#):not(#\#):not(#\#):not(#\#) {
border-color: rgb(135, 76, 0);
border-color: color(display-p3 0.50318 0.30478 0.07554);
border-color: var(--color-yellow-800);
}
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:border-yellow-800 {
border-color: rgb(135, 76, 0);
border-color: color(display-p3 0.50318 0.30478 0.07554);
border-color: var(--color-yellow-800);
}
@media (prefers-color-scheme: dark) {
.dark\:bg-blue-900:not(#\#):not(#\#):not(#\#):not(#\#) {
background-color: rgb(28, 57, 142);
background-color: var(--color-blue-900);
}
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:bg-blue-900 {
background-color: rgb(28, 57, 142);
background-color: var(--color-blue-900);
}
@media (prefers-color-scheme: dark) {
.dark\:bg-blue-900\/30:not(#\#):not(#\#):not(#\#):not(#\#) {
background-color: rgba(28, 57, 142, 0.3);
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:bg-blue-900\/30 {
background-color: rgba(28, 57, 142, 0.3);
}
@supports (color: color-mix(in lab, red, red)) {
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:bg-blue-900\/30 {
background-color: color-mix(in oklab, rgb(28, 57, 142) 30%, transparent);
}
@supports (color: color-mix(in lab, red, red)) {
.dark\:bg-blue-900\/30:not(#\#):not(#\#):not(#\#):not(#\#) {
background-color: color-mix(in oklab, rgb(28, 57, 142) 30%, transparent);
}
@supports (color: color-mix(in lch, red, blue)) {
.dark\:bg-blue-900\/30:not(#\#):not(#\#):not(#\#):not(#\#) {
background-color: color-mix(in oklab, var(--color-blue-900) 30%, transparent);
}
}
}
}
@media (prefers-color-scheme: dark) {
.dark\:bg-gray-800:not(#\#):not(#\#):not(#\#):not(#\#) {
background-color: #1f2937;
}
}
@media (prefers-color-scheme: dark) {
.dark\:bg-gray-900:not(#\#):not(#\#):not(#\#):not(#\#) {
background-color: #111827;
}
}
@media (prefers-color-scheme: dark) {
.dark\:bg-green-900:not(#\#):not(#\#):not(#\#):not(#\#) {
background-color: rgb(13, 84, 43);
background-color: var(--color-green-900);
}
}
@media (prefers-color-scheme: dark) {
.dark\:bg-green-900\/30:not(#\#):not(#\#):not(#\#):not(#\#) {
background-color: rgba(13, 84, 43, 0.3);
}
@supports (color: color-mix(in lab, red, red)) {
.dark\:bg-green-900\/30:not(#\#):not(#\#):not(#\#):not(#\#) {
background-color: color-mix(in oklab, rgb(13, 84, 43) 30%, transparent);
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:bg-blue-900\/30 {
background-color: color-mix(in oklab, var(--color-blue-900) 30%, transparent);
}
}
}
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:bg-gray-800 {
background-color: #1f2937;
}
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:bg-gray-900 {
background-color: #111827;
}
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:bg-green-900 {
background-color: rgb(13, 84, 43);
background-color: var(--color-green-900);
}
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:bg-green-900\/30 {
background-color: rgba(13, 84, 43, 0.3);
}
@supports (color: color-mix(in lab, red, red)) {
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:bg-green-900\/30 {
background-color: color-mix(in oklab, rgb(13, 84, 43) 30%, transparent);
}
@supports (color: color-mix(in lch, red, blue)) {
.dark\:bg-green-900\/30:not(#\#):not(#\#):not(#\#):not(#\#) {
background-color: color-mix(in oklab, var(--color-green-900) 30%, transparent);
}
}
}
}
@media (prefers-color-scheme: dark) {
.dark\:bg-red-900:not(#\#):not(#\#):not(#\#):not(#\#) {
background-color: rgb(130, 24, 26);
background-color: var(--color-red-900);
}
}
@media (prefers-color-scheme: dark) {
.dark\:bg-red-900\/30:not(#\#):not(#\#):not(#\#):not(#\#) {
background-color: rgba(130, 24, 26, 0.3);
}
@supports (color: color-mix(in lab, red, red)) {
.dark\:bg-red-900\/30:not(#\#):not(#\#):not(#\#):not(#\#) {
background-color: color-mix(in oklab, rgb(130, 24, 26) 30%, transparent);
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:bg-green-900\/30 {
background-color: color-mix(in oklab, var(--color-green-900) 30%, transparent);
}
}
}
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:bg-red-900 {
background-color: rgb(130, 24, 26);
background-color: var(--color-red-900);
}
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:bg-red-900\/30 {
background-color: rgba(130, 24, 26, 0.3);
}
@supports (color: color-mix(in lab, red, red)) {
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:bg-red-900\/30 {
background-color: color-mix(in oklab, rgb(130, 24, 26) 30%, transparent);
}
@supports (color: color-mix(in lch, red, blue)) {
.dark\:bg-red-900\/30:not(#\#):not(#\#):not(#\#):not(#\#) {
background-color: color-mix(in oklab, var(--color-red-900) 30%, transparent);
}
}
}
}
@media (prefers-color-scheme: dark) {
.dark\:bg-yellow-900:not(#\#):not(#\#):not(#\#):not(#\#) {
background-color: rgb(115, 62, 10);
background-color: var(--color-yellow-900);
}
}
@media (prefers-color-scheme: dark) {
.dark\:bg-yellow-900\/30:not(#\#):not(#\#):not(#\#):not(#\#) {
background-color: rgba(115, 62, 10, 0.3);
}
@supports (color: color-mix(in lab, red, red)) {
.dark\:bg-yellow-900\/30:not(#\#):not(#\#):not(#\#):not(#\#) {
background-color: color-mix(in oklab, rgb(115, 62, 10) 30%, transparent);
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:bg-red-900\/30 {
background-color: color-mix(in oklab, var(--color-red-900) 30%, transparent);
}
}
}
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:bg-yellow-900 {
background-color: rgb(115, 62, 10);
background-color: var(--color-yellow-900);
}
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:bg-yellow-900\/30 {
background-color: rgba(115, 62, 10, 0.3);
}
@supports (color: color-mix(in lab, red, red)) {
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:bg-yellow-900\/30 {
background-color: color-mix(in oklab, rgb(115, 62, 10) 30%, transparent);
}
@supports (color: color-mix(in lch, red, blue)) {
.dark\:bg-yellow-900\/30:not(#\#):not(#\#):not(#\#):not(#\#) {
background-color: color-mix(in oklab, var(--color-yellow-900) 30%, transparent);
}
}
}
}
@media (prefers-color-scheme: dark) {
.dark\:text-blue-100:not(#\#):not(#\#):not(#\#):not(#\#) {
color: rgb(219, 234, 254);
color: var(--color-blue-100);
}
}
@media (prefers-color-scheme: dark) {
.dark\:text-blue-400:not(#\#):not(#\#):not(#\#):not(#\#) {
color: rgb(86, 162, 255);
color: color(display-p3 0.39744 0.62813 0.99212);
color: var(--color-blue-400);
}
}
@media (prefers-color-scheme: dark) {
.dark\:text-blue-500:not(#\#):not(#\#):not(#\#):not(#\#) {
color: rgb(50, 128, 255);
color: color(display-p3 0.26642 0.49122 0.98862);
color: var(--color-blue-500);
}
}
@media (prefers-color-scheme: dark) {
.dark\:text-gray-100:not(#\#):not(#\#):not(#\#):not(#\#) {
color: rgb(243, 244, 246);
color: var(--color-gray-100);
}
}
@media (prefers-color-scheme: dark) {
.dark\:text-green-100:not(#\#):not(#\#):not(#\#):not(#\#) {
color: rgb(220, 252, 231);
color: var(--color-green-100);
}
}
@media (prefers-color-scheme: dark) {
.dark\:text-green-400:not(#\#):not(#\#):not(#\#):not(#\#) {
color: rgb(5, 223, 114);
color: var(--color-green-400);
}
}
@media (prefers-color-scheme: dark) {
.dark\:text-green-500:not(#\#):not(#\#):not(#\#):not(#\#) {
color: rgb(0, 198, 90);
color: color(display-p3 0.30873 0.77475 0.37431);
color: var(--color-green-500);
}
}
@media (prefers-color-scheme: dark) {
.dark\:text-red-100:not(#\#):not(#\#):not(#\#):not(#\#) {
color: rgb(255, 226, 226);
color: var(--color-red-100);
}
}
@media (prefers-color-scheme: dark) {
.dark\:text-red-400:not(#\#):not(#\#):not(#\#):not(#\#) {
color: rgb(255, 101, 104);
color: color(display-p3 0.93353 0.43168 0.42349);
color: var(--color-red-400);
}
}
@media (prefers-color-scheme: dark) {
.dark\:text-yellow-100:not(#\#):not(#\#):not(#\#):not(#\#) {
color: rgb(254, 249, 194);
color: var(--color-yellow-100);
}
}
@media (prefers-color-scheme: dark) {
.dark\:text-yellow-400:not(#\#):not(#\#):not(#\#):not(#\#) {
color: rgb(247, 201, 0);
color: color(display-p3 0.95994 0.79017 0.05852);
color: var(--color-yellow-400);
}
}
@media (prefers-color-scheme: dark) {
@media (hover: hover) {
.dark\:hover\:bg-gray-700:hover:not(#\#):not(#\#):not(#\#):not(#\#) {
background-color: #374151;
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:bg-yellow-900\/30 {
background-color: color-mix(in oklab, var(--color-yellow-900) 30%, transparent);
}
}
}
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:text-blue-100 {
color: rgb(219, 234, 254);
color: var(--color-blue-100);
}
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:text-blue-400 {
color: rgb(86, 162, 255);
color: color(display-p3 0.39744 0.62813 0.99212);
color: var(--color-blue-400);
}
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:text-blue-500 {
color: rgb(50, 128, 255);
color: color(display-p3 0.26642 0.49122 0.98862);
color: var(--color-blue-500);
}
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:text-gray-100 {
color: rgb(243, 244, 246);
color: var(--color-gray-100);
}
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:text-green-100 {
color: rgb(220, 252, 231);
color: var(--color-green-100);
}
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:text-green-400 {
color: rgb(5, 223, 114);
color: var(--color-green-400);
}
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:text-green-500 {
color: rgb(0, 198, 90);
color: color(display-p3 0.30873 0.77475 0.37431);
color: var(--color-green-500);
}
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:text-red-100 {
color: rgb(255, 226, 226);
color: var(--color-red-100);
}
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:text-red-400 {
color: rgb(255, 101, 104);
color: color(display-p3 0.93353 0.43168 0.42349);
color: var(--color-red-400);
}
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:text-yellow-100 {
color: rgb(254, 249, 194);
color: var(--color-yellow-100);
}
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:text-yellow-400 {
color: rgb(247, 201, 0);
color: color(display-p3 0.95994 0.79017 0.05852);
color: var(--color-yellow-400);
}
@media (hover: hover) {
.dark:not(#\#):not(#\#):not(#\#):not(#\#) .dark\:hover\:bg-gray-700:hover {
background-color: #374151;
}
}
.\[\&_p\]\:leading-relaxed:not(#\#):not(#\#):not(#\#):not(#\#) p {

View File

@@ -1,5 +1,4 @@
use crate::components::layout::protected::Protected;
use crate::components::toast::ToastContainer;
use crate::components::torrent::table::TorrentTable;
use crate::components::torrent::detail::TorrentDetail;
use crate::components::auth::login::Login;
@@ -9,6 +8,7 @@ use leptos::task::spawn_local;
use leptos_router::components::{Router, Routes, Route};
use leptos_router::hooks::use_navigate;
use leptos_shadcn_skeleton::Skeleton;
use leptos_shadcn_toast::SonnerProvider;
#[component]
pub fn App() -> impl IntoView {
@@ -70,126 +70,126 @@ pub fn App() -> impl IntoView {
});
view! {
<div class="relative w-full h-screen" style="height: 100dvh;">
<Router>
<Routes fallback=|| view! { <div class="p-4">"404 Not Found"</div> }>
<Route path=leptos_router::path!("/login") view=move || {
let authenticated = is_authenticated.0.get();
let setup_needed = needs_setup.0.get();
Effect::new(move |_| {
if setup_needed {
let navigate = use_navigate();
navigate("/setup", Default::default());
} else if authenticated {
log::info!("Already authenticated, redirecting to home");
let navigate = use_navigate();
navigate("/", Default::default());
}
});
view! { <Login /> }
} />
<Route path=leptos_router::path!("/setup") view=move || {
Effect::new(move |_| {
if is_authenticated.0.get() {
let navigate = use_navigate();
navigate("/", Default::default());
}
});
view! { <Setup /> }
} />
<Route path=leptos_router::path!("/") view=move || {
let navigate = use_navigate();
Effect::new(move |_| {
if !is_loading.0.get() {
if needs_setup.0.get() {
log::info!("Setup not completed, redirecting to setup");
<SonnerProvider>
<div class="relative w-full h-screen" style="height: 100dvh;">
<Router>
<Routes fallback=|| view! { <div class="p-4">"404 Not Found"</div> }>
<Route path=leptos_router::path!("/login") view=move || {
let authenticated = is_authenticated.0.get();
let setup_needed = needs_setup.0.get();
Effect::new(move |_| {
if setup_needed {
let navigate = use_navigate();
navigate("/setup", Default::default());
} else if !is_authenticated.0.get() {
log::info!("Not authenticated, redirecting to login");
} else if authenticated {
log::info!("Already authenticated, redirecting to home");
let navigate = use_navigate();
navigate("/", Default::default());
}
});
view! { <Login /> }
} />
<Route path=leptos_router::path!("/setup") view=move || {
Effect::new(move |_| {
if is_authenticated.0.get() {
let navigate = use_navigate();
navigate("/", Default::default());
}
});
view! { <Setup /> }
} />
<Route path=leptos_router::path!("/") view=move || {
let navigate = use_navigate();
Effect::new(move |_| {
if !is_loading.0.get() {
if needs_setup.0.get() {
log::info!("Setup not completed, redirecting to setup");
navigate("/setup", Default::default());
} else if !is_authenticated.0.get() {
log::info!("Not authenticated, redirecting to login");
navigate("/login", Default::default());
}
}
});
view! {
<Show when=move || !is_loading.0.get() fallback=|| 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">
// Header skeleton
<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>
// Table skeleton rows
<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>
// Status bar skeleton
<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=|| ()>
<Protected>
<div class="flex flex-col h-full overflow-hidden">
<div class="flex-1 overflow-hidden">
<TorrentTable />
</div>
<TorrentDetail />
</div>
</Protected>
</Show>
</Show>
}.into_any()
}/>
<Route path=leptos_router::path!("/settings") view=move || {
Effect::new(move |_| {
if !is_authenticated.0.get() {
let navigate = use_navigate();
navigate("/login", Default::default());
}
}
});
view! {
<Show when=move || !is_loading.0.get() fallback=|| 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">
// Header skeleton
<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>
// Table skeleton rows
<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>
// Status bar skeleton
<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=|| ()>
<Protected>
<div class="flex flex-col h-full overflow-hidden">
<div class="flex-1 overflow-hidden">
<TorrentTable />
</div>
<TorrentDetail />
</div>
</Protected>
});
view! {
<Show when=move || !is_loading.0.get() fallback=|| ()>
<Show when=move || is_authenticated.0.get() fallback=|| ()>
<Protected>
<div class="p-4">"Settings Page (Coming Soon)"</div>
</Protected>
</Show>
</Show>
</Show>
}.into_any()
}/>
<Route path=leptos_router::path!("/settings") view=move || {
Effect::new(move |_| {
if !is_authenticated.0.get() {
let navigate = use_navigate();
navigate("/login", Default::default());
}
});
view! {
<Show when=move || !is_loading.0.get() fallback=|| ()>
<Show when=move || is_authenticated.0.get() fallback=|| ()>
<Protected>
<div class="p-4">"Settings Page (Coming Soon)"</div>
</Protected>
</Show>
</Show>
}
}/>
</Routes>
</Router>
<ToastContainer />
</div>
}/>
</Routes>
</Router>
</div>
</SonnerProvider>
}
}

View File

@@ -4,6 +4,9 @@ use leptos_shadcn_button::{Button, ButtonVariant, ButtonSize};
use leptos_shadcn_avatar::{Avatar, AvatarFallback};
use leptos_shadcn_separator::Separator;
use leptos_use::storage::use_local_storage;
use ::codee::string::FromToStringCodec;
#[component]
pub fn Sidebar() -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
@@ -67,6 +70,37 @@ pub fn Sidebar() -> impl IntoView {
username().chars().next().unwrap_or('?').to_uppercase().to_string()
};
// --- THEME LOGIC START ---
let (current_theme, set_current_theme, _) = use_local_storage::<String, FromToStringCodec>("vibetorrent_theme");
// Initialize with default if empty
let current_theme_val = current_theme.get();
if current_theme_val.is_empty() {
set_current_theme.set("dark".to_string());
}
// Automatically sync theme to document attribute
Effect::new(move |_| {
let theme = current_theme.get().to_lowercase();
if let Some(doc) = document().document_element() {
let _ = doc.set_attribute("data-theme", &theme);
// Also set class for Shadcn dark mode support
if theme == "dark" || theme == "dracula" || theme == "dim" || theme == "abyss" || theme == "sunset" || theme == "cyberpunk" || theme == "nord" || theme == "business" || theme == "night" || theme == "black" || theme == "luxury" || theme == "coffee" || theme == "forest" || theme == "halloween" || theme == "synthwave" {
let _ = doc.class_list().add_1("dark");
} else {
let _ = doc.class_list().remove_1("dark");
}
}
});
let toggle_theme = move |_| {
let new_theme = if current_theme.get() == "dark" { "light" } else { "dark" };
set_current_theme.set(new_theme.to_string());
};
// --- THEME LOGIC END ---
view! {
<div class="w-full h-full flex flex-col bg-card" style="padding-top: env(safe-area-inset-top);">
<div class="p-4 flex-1 overflow-y-auto">
@@ -158,7 +192,7 @@ pub fn Sidebar() -> impl IntoView {
<Separator />
<div class="p-4 bg-card">
<div class="p-4 bg-card" style="padding-bottom: calc(1rem + env(safe-area-inset-bottom));">
<div class="flex items-center gap-3">
<Avatar class="h-8 w-8">
<AvatarFallback class="bg-primary text-primary-foreground text-xs font-medium">
@@ -169,6 +203,27 @@ pub fn Sidebar() -> impl IntoView {
<div class="font-medium text-sm truncate text-foreground">{username}</div>
<div class="text-[10px] text-muted-foreground truncate">"Online"</div>
</div>
// --- THEME BUTTON ---
<Button
variant=ButtonVariant::Ghost
size=ButtonSize::Icon
class="h-8 w-8 text-muted-foreground hover:text-foreground"
on_click=Callback::new(toggle_theme)
>
// Sun icon for dark mode (to switch to light), Moon for light (to switch to dark)
// Actually show current state or action? Usually action.
// If dark, show Sun. If light, show Moon.
<Show when=move || current_theme.get() == "dark" fallback=|| view! {
<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="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
}>
<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="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</Show>
</Button>
<Button
variant=ButtonVariant::Ghost
size=ButtonSize::Icon

View File

@@ -1,7 +1,5 @@
use leptos::prelude::*;
use leptos::html;
use leptos_use::storage::use_local_storage;
use ::codee::string::FromToStringCodec;
use shared::GlobalLimitRequest;
use crate::api;
@@ -30,27 +28,7 @@ pub fn StatusBar() -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let stats = store.global_stats;
let (current_theme, set_current_theme, _) = use_local_storage::<String, FromToStringCodec>("vibetorrent_theme");
// Initialize with default if empty
let current_theme_val = current_theme.get();
if current_theme_val.is_empty() {
set_current_theme.set("dark".to_string());
}
// Automatically sync theme to document attribute
Effect::new(move |_| {
let theme = current_theme.get().to_lowercase();
if let Some(doc) = document().document_element() {
let _ = doc.set_attribute("data-theme", &theme);
// Also set class for Shadcn dark mode support
if theme == "dark" || theme == "dracula" || theme == "dim" || theme == "abyss" {
let _ = doc.class_list().add_1("dark");
} else {
let _ = doc.class_list().remove_1("dark");
}
}
});
// Preset limits in bytes/s
let limits: Vec<(i64, &str)> = vec!(
@@ -91,7 +69,6 @@ pub fn StatusBar() -> impl IntoView {
let down_details_ref = NodeRef::<html::Details>::new();
let up_details_ref = NodeRef::<html::Details>::new();
let theme_details_ref = NodeRef::<html::Details>::new();
let close_details = move |node_ref: NodeRef<html::Details>| {
if let Some(el) = node_ref.get_untracked() {
@@ -201,49 +178,6 @@ pub fn StatusBar() -> impl IntoView {
</details>
<div class="ml-auto flex items-center gap-4">
<details class="group relative" node_ref=theme_details_ref>
<summary class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-7 w-7 cursor-pointer outline-none list-none [&::-webkit-details-marker]:hidden">
<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" />
</svg>
</summary>
<div class="absolute bottom-full right-0 mb-2 z-[100] min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md hidden group-open:block animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2 max-h-96 overflow-y-auto">
<ul class="w-full">
{
let themes = vec![
"light", "dark", "dim", "nord", "cupcake", "dracula", "cyberpunk", "emerald", "sunset", "abyss"
];
themes.into_iter().map(|theme| {
let theme_name = theme.to_string();
let theme_name_for_class = theme_name.clone();
let theme_name_for_onclick = theme_name.clone();
let is_active = move || current_theme.get() == theme_name_for_class;
view! {
<li>
<button
class=move || {
let base = "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-xs outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-accent hover:text-accent-foreground capitalize";
if is_active() { format!("{} bg-accent text-accent-foreground font-medium", base) } else { base.to_string() }
}
on:click=move |_| {
set_current_theme.set(theme_name_for_onclick.clone());
close_details(theme_details_ref);
}
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<Show when=is_active.clone() fallback=|| ()>
<span>""</span>
</Show>
</span>
{theme_name}
</button> </li>
}
}).collect::<Vec<_>>()
}
</ul>
</div>
</details>
<button
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-7 w-7"
title="Settings & Notification Permissions"

View File

@@ -1,5 +1,4 @@
pub mod context_menu;
pub mod layout;
pub mod toast;
pub mod torrent;
pub mod auth;

View File

@@ -1,86 +0,0 @@
use leptos::prelude::*;
use shared::NotificationLevel;
use leptos_shadcn_alert::{Alert, AlertVariant};
// ============================================================================
// Toast Components - Using ShadCN Alert
// ============================================================================
fn level_to_variant(level: &NotificationLevel) -> AlertVariant {
match level {
NotificationLevel::Info => AlertVariant::Default,
NotificationLevel::Success => AlertVariant::Success,
NotificationLevel::Warning => AlertVariant::Warning,
NotificationLevel::Error => AlertVariant::Destructive,
}
}
fn level_icon(level: &NotificationLevel) -> impl IntoView {
match level {
NotificationLevel::Info => view! {
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 opacity-90">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
}.into_any(),
NotificationLevel::Success => view! {
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 opacity-90">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
}.into_any(),
NotificationLevel::Warning => view! {
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 opacity-90">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
}.into_any(),
NotificationLevel::Error => view! {
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 opacity-90">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
}.into_any(),
}
}
/// Individual toast item component
#[component]
fn ToastItem(
level: NotificationLevel,
message: String,
) -> impl IntoView {
let variant = level_to_variant(&level);
let icon = level_icon(&level);
view! {
<Alert variant=variant class="pointer-events-auto shadow-lg">
<div class="flex items-center gap-3">
{icon}
<div class="text-sm font-medium">{message}</div>
</div>
</Alert>
}
}
/// Main toast container - renders all active notifications
#[component]
pub fn ToastContainer() -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("TorrentStore not provided");
let notifications = store.notifications;
view! {
<div
class="fixed bottom-0 right-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px] gap-2"
>
<For
each=move || notifications.get()
key=|item| item.id
children=move |item| {
view! {
<ToastItem
level=item.notification.level
message=item.notification.message
/>
}
}
/>
</div>
}
}

View File

@@ -11,7 +11,6 @@ pub fn AddTorrentDialog(
on_close: Callback<()>,
) -> impl IntoView {
let store = use_context::<TorrentStore>().expect("TorrentStore not provided");
let notifications = store.notifications;
let uri = signal(String::new());
let is_loading = signal(false);
@@ -34,11 +33,7 @@ pub fn AddTorrentDialog(
match api::torrent::add(&uri_val).await {
Ok(_) => {
log::info!("Torrent added successfully");
crate::store::show_toast_with_signal(
notifications,
shared::NotificationLevel::Success,
"Torrent başarıyla eklendi"
);
crate::store::toast_success("Torrent başarıyla eklendi");
on_close.run(());
}
Err(e) => {

View File

@@ -1,6 +1,6 @@
use leptos::prelude::*;
use leptos::task::spawn_local;
use crate::store::{get_action_messages, show_toast_with_signal};
use crate::store::{get_action_messages, show_toast};
use crate::api;
use shared::NotificationLevel;
use crate::components::context_menu::TorrentContextMenu;
@@ -116,7 +116,6 @@ pub fn TorrentTable() -> impl IntoView {
let (success_msg_str, error_msg_str): (&'static str, &'static str) = get_action_messages(&action);
let success_msg = success_msg_str.to_string();
let error_msg = error_msg_str.to_string();
let notifications = store.notifications;
spawn_local(async move {
let result = match action.as_str() {
"delete" => api::torrent::delete(&hash).await,
@@ -126,8 +125,8 @@ pub fn TorrentTable() -> impl IntoView {
_ => api::torrent::action(&hash, &action).await,
};
match result {
Ok(_) => show_toast_with_signal(notifications, NotificationLevel::Success, success_msg),
Err(e) => show_toast_with_signal(notifications, NotificationLevel::Error, format!("{}: {:?}", error_msg, e)),
Ok(_) => show_toast(NotificationLevel::Success, success_msg),
Err(e) => show_toast(NotificationLevel::Error, format!("{}: {:?}", error_msg, e)),
}
});
});

View File

@@ -7,40 +7,18 @@ use std::collections::HashMap;
use struct_patch::traits::Patch;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
#[derive(Clone, Debug, PartialEq)]
pub struct NotificationItem {
pub id: u64,
pub notification: SystemNotification,
}
pub fn show_toast_with_signal(
notifications: RwSignal<Vec<NotificationItem>>,
level: NotificationLevel,
message: impl Into<String>,
) {
let id = js_sys::Date::now() as u64;
let notification = SystemNotification {
level,
message: message.into(),
};
let item = NotificationItem { id, notification };
notifications.update(|list| list.push(item));
leptos::prelude::set_timeout(
move || {
notifications.update(|list| list.retain(|i| i.id != id));
},
std::time::Duration::from_secs(5),
);
}
pub fn show_toast(level: NotificationLevel, message: impl Into<String>) {
if let Some(store) = use_context::<TorrentStore>() {
show_toast_with_signal(store.notifications, level, message);
let msg = message.into();
match level {
NotificationLevel::Info => { leptos_shadcn_toast::toast::info(&msg).show(); },
NotificationLevel::Success => { leptos_shadcn_toast::toast::success(&msg).show(); },
NotificationLevel::Warning => { leptos_shadcn_toast::toast::warning(&msg).show(); },
NotificationLevel::Error => { leptos_shadcn_toast::toast::error(&msg).show(); },
}
}
pub fn toast_success(message: impl Into<String>) { show_toast(NotificationLevel::Success, message); }
pub fn toast_error(message: impl Into<String>) { show_toast(NotificationLevel::Error, message); }
@@ -67,7 +45,6 @@ pub struct TorrentStore {
pub filter: RwSignal<FilterStatus>,
pub search_query: RwSignal<String>,
pub global_stats: RwSignal<GlobalStats>,
pub notifications: RwSignal<Vec<NotificationItem>>,
pub user: RwSignal<Option<String>>,
pub selected_torrent: RwSignal<Option<String>>,
}
@@ -77,16 +54,14 @@ pub fn provide_torrent_store() {
let filter = RwSignal::new(FilterStatus::All);
let search_query = RwSignal::new(String::new());
let global_stats = RwSignal::new(GlobalStats::default());
let notifications = RwSignal::new(Vec::<NotificationItem>::new());
let user = RwSignal::new(Option::<String>::None);
let selected_torrent = RwSignal::new(Option::<String>::None);
let show_browser_notification = crate::utils::notification::use_app_notification();
let store = TorrentStore { torrents, filter, search_query, global_stats, notifications, user, selected_torrent };
let store = TorrentStore { torrents, filter, search_query, global_stats, user, selected_torrent };
provide_context(store);
let notifications_for_sse = notifications;
let global_stats_for_sse = global_stats;
let torrents_for_sse = torrents;
let show_browser_notification = show_browser_notification.clone();
@@ -112,7 +87,7 @@ pub fn provide_torrent_store() {
got_first_message = true;
backoff_ms = 1000;
if was_connected && disconnect_notified {
show_toast_with_signal(notifications_for_sse, NotificationLevel::Success, "Sunucu bağlantısı yeniden kuruldu");
show_toast(NotificationLevel::Success, "Sunucu bağlantısı yeniden kuruldu");
disconnect_notified = false;
}
was_connected = true;
@@ -149,7 +124,7 @@ pub fn provide_torrent_store() {
}
AppEvent::Stats(stats) => { global_stats_for_sse.set(stats); }
AppEvent::Notification(n) => {
show_toast_with_signal(notifications_for_sse, n.level.clone(), n.message.clone());
show_toast(n.level.clone(), n.message.clone());
if n.message.contains("tamamlandı") || n.level == shared::NotificationLevel::Error {
show_browser_notification("VibeTorrent", &n.message);
}
@@ -164,14 +139,14 @@ pub fn provide_torrent_store() {
}
}
if was_connected && !disconnect_notified {
show_toast_with_signal(notifications_for_sse, NotificationLevel::Warning, "Sunucu bağlantısı kesildi, yeniden bağlanılıyor...");
show_toast(NotificationLevel::Warning, "Sunucu bağlantısı kesildi, yeniden bağlanılıyor...");
disconnect_notified = true;
}
}
}
Err(_) => {
if was_connected && !disconnect_notified {
show_toast_with_signal(notifications_for_sse, NotificationLevel::Warning, "Sunucu bağlantısı kurulamıyor...");
show_toast(NotificationLevel::Warning, "Sunucu bağlantısı kurulamıyor...");
disconnect_notified = true;
}
}

View File

@@ -1,10 +1,12 @@
use wasm_bindgen::prelude::*;
use web_sys::{Notification, NotificationOptions};
use leptos::prelude::*;
use leptos_use::{use_web_notification, UseWebNotificationReturn, NotificationPermission};
/// Request browser notification permission from user
pub async fn request_notification_permission() -> bool {
if !is_notification_supported() {
return false;
}
if let Ok(promise) = Notification::request_permission() {
if let Ok(result) = wasm_bindgen_futures::JsFuture::from(promise).await {
return result.as_string().unwrap_or_default() == "granted";
@@ -21,6 +23,9 @@ pub fn is_notification_supported() -> bool {
/// Get current notification permission status
pub fn get_notification_permission() -> String {
if !is_notification_supported() {
return "denied".to_string();
}
match Notification::permission() {
web_sys::NotificationPermission::Granted => "granted".to_string(),
web_sys::NotificationPermission::Denied => "denied".to_string(),
@@ -32,8 +37,6 @@ pub fn get_notification_permission() -> String {
/// Hook for using browser notifications within Leptos components or effects.
/// This uses leptos-use for reactive permission tracking.
pub fn use_app_notification() -> impl Fn(&str, &str) + Clone {
let UseWebNotificationReturn { permission, .. } = use_web_notification();
move |title: &str, body: &str| {
// Check user preference from localStorage
let window = web_sys::window().expect("no global window");
@@ -42,8 +45,8 @@ pub fn use_app_notification() -> impl Fn(&str, &str) + Clone {
.and_then(|s| s.get_item("vibetorrent_browser_notifications").ok().flatten())
.unwrap_or_else(|| "true".to_string());
// Use the reactive permission signal from leptos-use
if enabled == "true" && permission.get() == NotificationPermission::Granted {
// Check platform support and permission
if enabled == "true" && is_notification_supported() && get_notification_permission() == "granted" {
show_browser_notification(title, body);
}
}

View File

@@ -9,6 +9,7 @@ const cargoRegistry = path.join(
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
content: [
"./index.html",
"./src/**/*.{rs,html}",