feat: enable Immich ML, dark mode fixes, sidebar improvements #6

Merged
jimmy merged 6 commits from dev into main 2026-03-01 16:14:38 +08:00
12 changed files with 517 additions and 167 deletions
+12
View File
@@ -61,6 +61,18 @@ def get_pages(username: str, role: str) -> list | str:
return rbac.get("role_defaults", {}).get(role, {}).get("pages", []) return rbac.get("role_defaults", {}).get(role, {}).get("pages", [])
def get_sidebar_order(username: str) -> dict | None:
rbac = load_rbac()
overrides = rbac.get("user_overrides", {})
return overrides.get(username, {}).get("sidebar_order")
def save_sidebar_order(username: str, order: dict):
rbac = load_rbac()
rbac.setdefault("user_overrides", {}).setdefault(username, {})["sidebar_order"] = order
save_rbac(rbac)
def has_page_access(user: User, page: str) -> bool: def has_page_access(user: User, page: str) -> bool:
if user.pages == "*": if user.pages == "*":
return True return True
+19 -1
View File
@@ -364,10 +364,28 @@ async def rbac_set_override(username: str, request: Request, current_user = Depe
if pages is None: if pages is None:
raise HTTPException(status_code=400, detail="Missing pages field") raise HTTPException(status_code=400, detail="Missing pages field")
rbac = load_rbac() rbac = load_rbac()
rbac.setdefault("user_overrides", {})[username] = {"pages": pages} existing = rbac.setdefault("user_overrides", {}).get(username, {})
existing["pages"] = pages
rbac["user_overrides"][username] = existing
save_rbac(rbac) save_rbac(rbac)
return {"ok": True} return {"ok": True}
@router.get("/preferences")
async def get_preferences(current_user = Depends(auth.get_current_user)):
from rbac import get_sidebar_order
order = get_sidebar_order(current_user.username)
return {"sidebar_order": order}
@router.put("/preferences")
async def save_preferences(request: Request, current_user = Depends(auth.get_current_user)):
from rbac import save_sidebar_order
body = await request.json()
order = body.get("sidebar_order")
if order is None or not isinstance(order, dict):
raise HTTPException(status_code=400, detail="Missing sidebar_order dict")
save_sidebar_order(current_user.username, order)
return {"ok": True}
@router.delete("/rbac/overrides/{username}") @router.delete("/rbac/overrides/{username}")
async def rbac_delete_override(username: str, current_user = Depends(auth.get_current_user)): async def rbac_delete_override(username: str, current_user = Depends(auth.get_current_user)):
if current_user.role != "admin": if current_user.role != "admin":
+19 -4
View File
@@ -11,7 +11,7 @@
import Security from "./routes/Security.svelte"; import Security from "./routes/Security.svelte";
import Login from "./routes/Login.svelte"; import Login from "./routes/Login.svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { getToken, checkAuth, setToken, setRefreshToken, currentUser, setCurrentUser } from "./lib/api.js"; import { getToken, checkAuth, setToken, setRefreshToken, currentUser, setCurrentUser, getPreferences, savePreferences } from "./lib/api.js";
let page = $state("dashboard"); let page = $state("dashboard");
let dark = $state(false); let dark = $state(false);
@@ -20,6 +20,8 @@
let loading = $state(true); let loading = $state(true);
let userRole = $state(""); let userRole = $state("");
let allowedPages = $state([]); let allowedPages = $state([]);
let sidebarOrder = $state(null);
let orderSaveTimer = null;
async function fetchUserInfo() { async function fetchUserInfo() {
try { try {
@@ -27,11 +29,24 @@
setCurrentUser(data); setCurrentUser(data);
userRole = data.role || "admin"; userRole = data.role || "admin";
allowedPages = data.pages || "*"; allowedPages = data.pages || "*";
// Fetch sidebar preferences
try {
const prefs = await getPreferences();
sidebarOrder = prefs.sidebar_order || null;
} catch {}
} catch (e) { } catch (e) {
console.error("Failed to fetch user info:", e); console.error("Failed to fetch user info:", e);
} }
} }
function handleOrderChange(newOrder) {
sidebarOrder = newOrder;
clearTimeout(orderSaveTimer);
orderSaveTimer = setTimeout(() => {
savePreferences({ sidebar_order: newOrder }).catch(() => {});
}, 500);
}
function hasPageAccess(pageId) { function hasPageAccess(pageId) {
if (allowedPages === "*") return true; if (allowedPages === "*") return true;
return Array.isArray(allowedPages) && allowedPages.includes(pageId); return Array.isArray(allowedPages) && allowedPages.includes(pageId);
@@ -108,12 +123,12 @@
{:else if !authorized} {:else if !authorized}
<Login /> <Login />
{:else} {:else}
<div class="flex h-screen overflow-hidden bg-surface-50 {dark ? 'dark bg-surface-900' : ''}"> <div class="flex h-screen overflow-hidden bg-surface-50 dark:bg-surface-900">
<Sidebar bind:page {dark} {toggleTheme} {logout} bind:sidebarOpen {allowedPages} {userRole} /> <Sidebar bind:page {dark} {toggleTheme} {logout} bind:sidebarOpen {allowedPages} {userRole} {sidebarOrder} onOrderChange={handleOrderChange} />
<main class="flex-1 overflow-auto"> <main class="flex-1 overflow-auto">
<div class="max-w-[1400px] mx-auto p-6 lg:p-8"> <div class="max-w-[1400px] mx-auto p-6 lg:p-8">
<!-- Mobile hamburger --> <!-- Mobile hamburger -->
<button class="md:hidden mb-4 p-2 rounded-lg hover:bg-surface-200 {dark ? 'hover:bg-surface-700 text-surface-300' : 'text-surface-600'}" onclick={() => sidebarOpen = true} aria-label="Open menu"> <button class="md:hidden mb-4 p-2 rounded-lg text-surface-600 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700" onclick={() => sidebarOpen = true} aria-label="Open menu">
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" /></svg> <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" /></svg>
</button> </button>
{#if page === "dashboard" && hasPageAccess("dashboard")} {#if page === "dashboard" && hasPageAccess("dashboard")}
+8 -3
View File
@@ -1,5 +1,7 @@
@import "tailwindcss"; @import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme { @theme {
--font-sans: "Inter", system-ui, -apple-system, sans-serif; --font-sans: "Inter", system-ui, -apple-system, sans-serif;
@@ -22,6 +24,7 @@
--color-surface-700: #334155; --color-surface-700: #334155;
--color-surface-800: #1e293b; --color-surface-800: #1e293b;
--color-surface-900: #0f172a; --color-surface-900: #0f172a;
--color-surface-950: #020617;
--color-emerald-400: #34d399; --color-emerald-400: #34d399;
--color-emerald-500: #10b981; --color-emerald-500: #10b981;
@@ -57,8 +60,10 @@ body {
::-webkit-scrollbar-thumb:hover { background: var(--color-surface-400); } ::-webkit-scrollbar-thumb:hover { background: var(--color-surface-400); }
/* Dark mode overrides */ /* Dark mode overrides */
.dark body, html.dark body {
.dark { background: var(--color-surface-950);
background: var(--color-surface-900);
color: var(--color-surface-200); color: var(--color-surface-200);
} }
html.dark ::-webkit-scrollbar-thumb { background: var(--color-surface-600); }
html.dark ::-webkit-scrollbar-thumb:hover { background: var(--color-surface-500); }
+241 -115
View File
@@ -1,13 +1,13 @@
<script> <script>
let { page = $bindable("dashboard"), dark = false, toggleTheme = () => {}, logout = () => {}, sidebarOpen = $bindable(false), allowedPages = "*", userRole = "admin" } = $props(); let { page = $bindable("dashboard"), dark = false, toggleTheme = () => {}, logout = () => {}, sidebarOpen = $bindable(false), allowedPages = "*", userRole = "admin", sidebarOrder = null, onOrderChange = () => {} } = $props();
function canAccess(id) { function canAccess(id) {
if (!id) return true; // external links always shown if (!id) return true;
if (allowedPages === "*") return true; if (allowedPages === "*") return true;
return Array.isArray(allowedPages) && allowedPages.includes(id); return Array.isArray(allowedPages) && allowedPages.includes(id);
} }
const links = [ const defaultLinks = [
{ id: "dashboard", label: "Overview", icon: "grid" }, { id: "dashboard", label: "Overview", icon: "grid" },
{ id: "docker", label: "Docker", icon: "box" }, { id: "docker", label: "Docker", icon: "box" },
{ id: "files", label: "Files", icon: "folder" }, { id: "files", label: "Files", icon: "folder" },
@@ -25,14 +25,14 @@
}).catch(() => {}); }).catch(() => {});
} }
const media = [ const defaultMedia = [
{ label: "Navidrome", remoteHref: "https://music.jimmygan.com:8443", icon: "music" }, { label: "Navidrome", remoteHref: "https://music.jimmygan.com:8443", icon: "music" },
{ label: "Jellyfin", remoteHref: "https://media.jimmygan.com:8443", icon: "play" }, { label: "Jellyfin", remoteHref: "https://media.jimmygan.com:8443", icon: "play" },
{ label: "Audiobookshelf", remoteHref: "https://books.jimmygan.com:8443", icon: "headphones" }, { label: "Audiobookshelf", remoteHref: "https://books.jimmygan.com:8443", icon: "headphones" },
{ label: "Immich", remoteHref: "https://photos.jimmygan.com:8443", icon: "image" }, { label: "Immich", remoteHref: "https://photos.jimmygan.com:8443", icon: "image" },
]; ];
const tools = [ const defaultTools = [
{ id: "openclaw", label: "OpenClaw", icon: "openclaw" }, { id: "openclaw", label: "OpenClaw", icon: "openclaw" },
{ id: "chat-digest", label: "Chat Digest", icon: "chat" }, { id: "chat-digest", label: "Chat Digest", icon: "chat" },
{ id: "gitea", label: "Repos", icon: "git" }, { id: "gitea", label: "Repos", icon: "git" },
@@ -44,6 +44,125 @@
{ label: "Speedtest", remoteHref: "https://speed.jimmygan.com:8443", icon: "speedtest", external: true }, { label: "Speedtest", remoteHref: "https://speed.jimmygan.com:8443", icon: "speedtest", external: true },
]; ];
const defaultTools2 = [];
function itemKey(item) { return item.id || item.label; }
// All items indexed by key for cross-category lookup
const allItems = new Map([...defaultLinks, ...defaultMedia, ...defaultTools, ...defaultTools2].map(i => [itemKey(i), i]));
// Build category lists from saved order, supporting cross-category moves
const categories = ["main", "media", "tools", "tools2"];
const categoryDefaults = { main: defaultLinks, media: defaultMedia, tools: defaultTools, tools2: defaultTools2 };
function buildLists(savedOrder) {
if (!savedOrder) return { main: [...defaultLinks], media: [...defaultMedia], tools: [...defaultTools], tools2: [...defaultTools2] };
const used = new Set();
const result = { main: [], media: [], tools: [], tools2: [] };
for (const cat of categories) {
const keys = savedOrder[cat] || [];
for (const k of keys) {
if (allItems.has(k) && !used.has(k)) {
result[cat].push(allItems.get(k));
used.add(k);
}
}
}
// Append any new items not in saved order to their default category
for (const cat of categories) {
for (const item of categoryDefaults[cat]) {
if (!used.has(itemKey(item))) {
result[cat].push(item);
used.add(itemKey(item));
}
}
}
return result;
}
let categoryLists = $derived(buildLists(sidebarOrder));
let links = $derived(categoryLists.main);
let media = $derived(categoryLists.media);
let tools = $derived(categoryLists.tools);
let tools2 = $derived(categoryLists.tools2);
// Collapsed state for categories (persisted in sidebarOrder)
let collapsed = $state(sidebarOrder?.collapsed ? { ...sidebarOrder.collapsed } : {});
// Drag-and-drop state
let dragCategory = $state(null);
let dragIndex = $state(-1);
let dropCategory = $state(null);
let dropIndex = $state(-1);
function handleDragStart(category, index, e) {
dragCategory = category;
dragIndex = index;
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", "");
}
function handleDragOver(category, index, e) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
dropCategory = category;
dropIndex = index;
}
function handleDragEnd() {
dragCategory = null;
dragIndex = -1;
dropCategory = null;
dropIndex = -1;
}
function handleDrop(category, index, e) {
e.preventDefault();
if (dragCategory === null) { handleDragEnd(); return; }
if (dragCategory === category && dragIndex === index) { handleDragEnd(); return; }
const lists = { main: [...links], media: [...media], tools: [...tools], tools2: [...tools2] };
const [moved] = lists[dragCategory].splice(dragIndex, 1);
lists[category].splice(index, 0, moved);
const newOrder = {
main: lists.main.map(itemKey),
media: lists.media.map(itemKey),
tools: lists.tools.map(itemKey),
tools2: lists.tools2.map(itemKey),
collapsed,
};
onOrderChange(newOrder);
handleDragEnd();
}
// Allow dropping at end of a category (on the section divider/header area)
function handleCategoryDragOver(category, e) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
dropCategory = category;
dropIndex = -1;
}
function handleCategoryDrop(category, e) {
e.preventDefault();
if (dragCategory === null) { handleDragEnd(); return; }
const lists = { main: [...links], media: [...media], tools: [...tools], tools2: [...tools2] };
const [moved] = lists[dragCategory].splice(dragIndex, 1);
lists[category].push(moved);
const newOrder = {
main: lists.main.map(itemKey),
media: lists.media.map(itemKey),
tools: lists.tools.map(itemKey),
tools2: lists.tools2.map(itemKey),
collapsed,
};
onOrderChange(newOrder);
handleDragEnd();
}
function extHref(svc) { function extHref(svc) {
if (lanReachable && svc.port) return `http://${LAN_IP}:${svc.port}`; if (lanReachable && svc.port) return `http://${LAN_IP}:${svc.port}`;
if (svc.remoteHref) return svc.remoteHref; if (svc.remoteHref) return svc.remoteHref;
@@ -58,144 +177,151 @@
page = id; page = id;
sidebarOpen = false; sidebarOpen = false;
} }
function dragClasses(category, index) {
if (dragCategory === category && index === dragIndex) return "opacity-30";
if (dropCategory === category && index === dropIndex) return "border-t-2 border-primary-500";
return "";
}
function toggleCollapse(cat) {
collapsed[cat] = !collapsed[cat];
const newOrder = {
main: links.map(itemKey),
media: media.map(itemKey),
tools: tools.map(itemKey),
tools2: tools2.map(itemKey),
collapsed,
};
onOrderChange(newOrder);
}
</script> </script>
{#snippet iconSvg(icon)}
<span class="w-5 h-5 flex items-center justify-center">
{#if icon === "grid"}
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" /></svg>
{:else if icon === "box"}
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
{:else if icon === "folder"}
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /></svg>
{:else if icon === "terminal"}
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
{:else if icon === "shield"}
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
{:else if icon === "music"}
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z" /></svg>
{:else if icon === "play"}
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{:else if icon === "headphones"}
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M3 18v-6a9 9 0 0118 0v6M3 18a3 3 0 003 3h0a3 3 0 003-3v-2a3 3 0 00-3-3h0a3 3 0 00-3 3v2zm18 0a3 3 0 01-3 3h0a3 3 0 01-3-3v-2a3 3 0 013-3h0a3 3 0 013 3v2z" /></svg>
{:else if icon === "image"}
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
{:else if icon === "openclaw"}
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg>
{:else if icon === "chat"}
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a2 2 0 01-2-2v-1m0-3V6a2 2 0 012-2h8a2 2 0 012 2v3a2 2 0 01-2 2H9l-4 4V9z" /></svg>
{:else if icon === "git"}
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /></svg>
{:else if icon === "pdf"}
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>
{:else if icon === "lock"}
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
{:else if icon === "speedtest"}
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
{:else if icon === "users"}
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
{:else if icon === "n8n"}
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" /><circle cx="8" cy="6" r="1.5" fill="currentColor"/><circle cx="16" cy="12" r="1.5" fill="currentColor"/><circle cx="10" cy="18" r="1.5" fill="currentColor"/></svg>
{/if}
</span>
{/snippet}
<!-- Mobile overlay --> <!-- Mobile overlay -->
{#if sidebarOpen} {#if sidebarOpen}
<button class="fixed inset-0 bg-black/40 z-40 md:hidden" onclick={() => sidebarOpen = false} aria-label="Close sidebar"></button> <button class="fixed inset-0 bg-black/40 z-40 md:hidden" onclick={() => sidebarOpen = false} aria-label="Close sidebar"></button>
{/if} {/if}
<aside class="fixed inset-y-0 left-0 z-50 w-[240px] h-screen flex flex-col shrink-0 border-r border-surface-200 bg-white transition-transform duration-200 md:static md:translate-x-0 {dark ? 'bg-surface-800 border-surface-700' : ''} {sidebarOpen ? 'translate-x-0' : '-translate-x-full'}"> <aside class="fixed inset-y-0 left-0 z-50 w-[240px] h-screen flex flex-col shrink-0 border-r border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 transition-transform duration-200 md:static md:translate-x-0 {sidebarOpen ? 'translate-x-0' : '-translate-x-full'}">
<!-- Logo --> <!-- Logo -->
<div class="px-5 py-5 flex items-center gap-3"> <div class="px-5 py-5 flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center shadow-md"> <div class="w-8 h-8 rounded-lg bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center shadow-md">
<svg class="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" /></svg> <svg class="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" /></svg>
</div> </div>
<div> <div>
<h1 class="text-sm font-bold tracking-tight {dark ? 'text-white' : 'text-surface-900'}">NAS Dashboard</h1> <h1 class="text-sm font-bold tracking-tight text-surface-900 dark:text-white">NAS Dashboard</h1>
<p class="text-[10px] text-surface-400 font-medium">DS224+ · Synology</p> <p class="text-[10px] text-surface-400 font-medium">DS224+ · Synology</p>
</div> </div>
</div> </div>
<!-- Nav Links --> <!-- Nav Links -->
<nav class="flex-1 px-3 mt-2 overflow-y-auto"> <nav class="flex-1 px-3 mt-2 overflow-y-auto">
<p class="text-[10px] font-semibold uppercase tracking-wider text-surface-400 px-3 mb-2">Main</p> {#each [["main", "Main", links], ["media", "Media", media], ["tools", "Tools", tools], ["tools2", "Tools 2", tools2]] as [cat, label, items], ci}
{#each links as link} {#if ci > 0}
{#if canAccess(link.id)} <div class="my-4 border-t border-surface-200 dark:border-surface-700"></div>
{/if}
<button <button
class="w-full text-left px-3 py-2.5 rounded-lg text-[13px] font-medium flex items-center gap-2.5 mb-0.5 transition-all duration-150 class="w-full text-left text-[10px] font-semibold uppercase tracking-wider text-surface-400 px-3 mb-2 flex items-center justify-between {cat !== 'main' ? 'cursor-pointer' : ''}"
{page === link.id ondragover={(e) => handleCategoryDragOver(cat, e)}
? 'bg-primary-50 text-primary-700 shadow-sm ' + (dark ? 'bg-primary-900/30 text-primary-300' : '') ondrop={(e) => handleCategoryDrop(cat, e)}
: 'text-surface-500 hover:bg-surface-100 hover:text-surface-700 ' + (dark ? 'hover:bg-surface-700 hover:text-surface-200 text-surface-400' : '') onclick={() => cat !== 'main' && toggleCollapse(cat)}
}" disabled={cat === 'main'}
onclick={() => navigate(link.id)}
> >
<span class="w-5 h-5 flex items-center justify-center"> {label}
{#if link.icon === "grid"} {#if cat !== "main"}
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" /></svg> <svg class="w-3 h-3 opacity-30 hover:opacity-50 transition-all duration-150 {collapsed[cat] ? '-rotate-90' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /></svg>
{:else if link.icon === "box"} {/if}
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
{:else if link.icon === "folder"}
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /></svg>
{:else if link.icon === "terminal"}
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
{:else if link.icon === "shield"}
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
{/if}
</span>
{link.label}
</button> </button>
{/if} {#if cat === "main" || !collapsed[cat]}
{/each} {#each items as item, i}
{#if canAccess(item.id)}
<div class="my-4 border-t border-surface-200 {dark ? 'border-surface-700' : ''}"></div> {#if item.external || item.remoteHref || item.port}
<p class="text-[10px] font-semibold uppercase tracking-wider text-surface-400 px-3 mb-2">Media</p> <a
{#each media as svc} href={extHref(item)}
<a target="_blank"
href={extHref(svc)} rel="noopener"
target="_blank" draggable="true"
rel="noopener" ondragstart={(e) => handleDragStart(cat, i, e)}
class="w-full text-left px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 mb-0.5 text-surface-500 hover:bg-surface-100 hover:text-surface-700 transition-all duration-150 {dark ? 'hover:bg-surface-700 hover:text-surface-200 text-surface-400' : ''}" ondragover={(e) => handleDragOver(cat, i, e)}
> ondrop={(e) => handleDrop(cat, i, e)}
<span class="w-5 h-5 flex items-center justify-center"> ondragend={handleDragEnd}
{#if svc.icon === "music"} class="w-full text-left px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 mb-0.5 text-surface-500 hover:bg-surface-100 dark:hover:bg-surface-700 hover:text-surface-700 dark:hover:text-surface-200 dark:text-surface-400 transition-all duration-150 group {dragClasses(cat, i)}"
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z" /></svg> >
{:else if svc.icon === "play"} <svg class="w-3 h-3 opacity-0 group-hover:opacity-30 -ml-1 mr-0.5 shrink-0 cursor-grab" fill="currentColor" viewBox="0 0 24 24"><circle cx="9" cy="6" r="2"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="15" cy="12" r="2"/><circle cx="9" cy="18" r="2"/><circle cx="15" cy="18" r="2"/></svg>
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> {@render iconSvg(item.icon)}
{:else if svc.icon === "headphones"} {item.label}
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M3 18v-6a9 9 0 0118 0v6M3 18a3 3 0 003 3h0a3 3 0 003-3v-2a3 3 0 00-3-3h0a3 3 0 00-3 3v2zm18 0a3 3 0 01-3 3h0a3 3 0 01-3-3v-2a3 3 0 013-3h0a3 3 0 013 3v2z" /></svg> <svg class="w-3 h-3 ml-auto opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
{:else if svc.icon === "image"} </a>
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg> {:else}
<button
draggable="true"
ondragstart={(e) => handleDragStart(cat, i, e)}
ondragover={(e) => handleDragOver(cat, i, e)}
ondrop={(e) => handleDrop(cat, i, e)}
ondragend={handleDragEnd}
class="w-full text-left px-3 py-2.5 rounded-lg text-[13px] font-medium flex items-center gap-2.5 mb-0.5 transition-all duration-150 group
{page === item.id
? 'bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 shadow-sm'
: 'text-surface-500 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-700 hover:text-surface-700 dark:hover:text-surface-200'
} {dragClasses(cat, i)}"
onclick={() => navigate(item.id)}
>
<svg class="w-3 h-3 opacity-0 group-hover:opacity-30 -ml-1 mr-0.5 shrink-0 cursor-grab" fill="currentColor" viewBox="0 0 24 24"><circle cx="9" cy="6" r="2"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="15" cy="12" r="2"/><circle cx="9" cy="18" r="2"/><circle cx="15" cy="18" r="2"/></svg>
{@render iconSvg(item.icon)}
{item.label}
</button>
{/if} {/if}
</span> {/if}
{svc.label} {/each}
<svg class="w-3 h-3 ml-auto opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
</a>
{/each}
<div class="my-4 border-t border-surface-200 {dark ? 'border-surface-700' : ''}"></div>
<p class="text-[10px] font-semibold uppercase tracking-wider text-surface-400 px-3 mb-2">Tools</p>
{#each tools as item}
{#if canAccess(item.id)}
{#if item.external || item.port}
<a
href={extHref(item)}
target="_blank"
rel="noopener"
class="w-full text-left px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 mb-0.5 text-surface-500 hover:bg-surface-100 hover:text-surface-700 transition-all duration-150 {dark ? 'hover:bg-surface-700 hover:text-surface-200 text-surface-400' : ''}"
>
<span class="w-5 h-5 flex items-center justify-center">
{#if item.icon === "openclaw"}
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg>
{:else if item.icon === "chat"}
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a2 2 0 01-2-2v-1m0-3V6a2 2 0 012-2h8a2 2 0 012 2v3a2 2 0 01-2 2H9l-4 4V9z" /></svg>
{:else if item.icon === "git"}
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /></svg>
{:else if item.icon === "pdf"}
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>
{:else if item.icon === "lock"}
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
{:else if item.icon === "speedtest"}
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
{:else if item.icon === "users"}
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
{:else if item.icon === "n8n"}
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" /><circle cx="8" cy="6" r="1.5" fill="currentColor"/><circle cx="16" cy="12" r="1.5" fill="currentColor"/><circle cx="10" cy="18" r="1.5" fill="currentColor"/></svg>
{/if}
</span>
{item.label}
<svg class="w-3 h-3 ml-auto opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
</a>
{:else}
<button
class="w-full text-left px-3 py-2.5 rounded-lg text-[13px] font-medium flex items-center gap-2.5 mb-0.5 transition-all duration-150
{page === item.id
? 'bg-primary-50 text-primary-700 shadow-sm ' + (dark ? 'bg-primary-900/30 text-primary-300' : '')
: 'text-surface-500 hover:bg-surface-100 hover:text-surface-700 ' + (dark ? 'hover:bg-surface-700 hover:text-surface-200 text-surface-400' : '')
}"
onclick={() => navigate(item.id)}
>
<span class="w-5 h-5 flex items-center justify-center">
{#if item.icon === "openclaw"}
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg>
{:else if item.icon === "chat"}
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a2 2 0 01-2-2v-1m0-3V6a2 2 0 012-2h8a2 2 0 012 2v3a2 2 0 01-2 2H9l-4 4V9z" /></svg>
{:else if item.icon === "git"}
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
{/if}
</span>
{item.label}
</button>
{/if}
{/if} {/if}
{/each} {/each}
</nav> </nav>
<!-- Bottom actions --> <!-- Bottom actions -->
<div class="px-4 pb-4 pt-2 border-t border-surface-200 {dark ? 'border-surface-700' : ''}"> <div class="px-4 pb-4 pt-2 border-t border-surface-200 dark:border-surface-700">>
{#if userRole === "admin"} {#if userRole === "admin"}
<button <button
class="w-full px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 text-surface-500 hover:bg-surface-100 transition-all duration-150 mb-1 {dark ? 'hover:bg-surface-700 text-surface-400' : ''} {page === 'settings' ? 'bg-primary-50 text-primary-700 shadow-sm ' + (dark ? 'bg-primary-900/30 text-primary-300' : '') : ''}" class="w-full px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 text-surface-500 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-700 transition-all duration-150 mb-1 {page === 'settings' ? 'bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 shadow-sm' : ''}"
onclick={() => navigate('settings')} onclick={() => navigate('settings')}
> >
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg> <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
@@ -204,7 +330,7 @@
{/if} {/if}
<button <button
onclick={toggleTheme} onclick={toggleTheme}
class="w-full px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 text-surface-500 hover:bg-surface-100 transition-all duration-150 mb-1 {dark ? 'hover:bg-surface-700 text-surface-400' : ''}" class="w-full px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 text-surface-500 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-700 transition-all duration-150 mb-1"
> >
{#if dark} {#if dark}
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg> <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
@@ -216,7 +342,7 @@
</button> </button>
<button <button
onclick={logout} onclick={logout}
class="w-full px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 text-surface-500 hover:bg-surface-100 transition-all duration-150 hover:text-red-600 {dark ? 'hover:bg-surface-700 text-surface-400 hover:text-red-400' : ''}" class="w-full px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 text-surface-500 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-700 transition-all duration-150 hover:text-red-600 dark:hover:text-red-400"
> >
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /></svg> <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /></svg>
Sign out Sign out
+12
View File
@@ -130,3 +130,15 @@ export function login(creds) {
export function checkAuth() { export function checkAuth() {
return request("/auth/me"); return request("/auth/me");
} }
export function getPreferences() {
return get("/auth/preferences");
}
export function savePreferences(data) {
return request("/auth/preferences", { method: "PUT", json: data });
}
export function put(path, data) {
return request(path, { method: "PUT", json: data });
}
+16 -16
View File
@@ -40,21 +40,21 @@
<div class="space-y-8"> <div class="space-y-8">
<div> <div>
<h1 class="text-2xl font-bold text-surface-900 tracking-tight">Overview</h1> <h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Overview</h1>
<p class="text-sm text-surface-400 mt-1">System status at a glance</p> <p class="text-sm text-surface-400 mt-1">System status at a glance</p>
</div> </div>
{#if loading} {#if loading}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{#each Array(4) as _} {#each Array(4) as _}
<div class="h-[120px] rounded-xl bg-surface-100 animate-pulse"></div> <div class="h-[120px] rounded-xl bg-surface-100 dark:bg-surface-800 animate-pulse"></div>
{/each} {/each}
</div> </div>
{:else} {:else}
<!-- Stats Cards --> <!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- CPU --> <!-- CPU -->
<div class="bg-white rounded-xl border border-surface-200 p-5 shadow-sm hover:shadow-md transition-shadow duration-200"> <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm hover:shadow-md transition-shadow duration-200">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<p class="text-xs font-semibold uppercase tracking-wider text-surface-400">CPU</p> <p class="text-xs font-semibold uppercase tracking-wider text-surface-400">CPU</p>
<div class="w-8 h-8 rounded-lg bg-sky-50 flex items-center justify-center"> <div class="w-8 h-8 rounded-lg bg-sky-50 flex items-center justify-center">
@@ -66,7 +66,7 @@
</div> </div>
<!-- Memory --> <!-- Memory -->
<div class="bg-white rounded-xl border border-surface-200 p-5 shadow-sm hover:shadow-md transition-shadow duration-200"> <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm hover:shadow-md transition-shadow duration-200">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<p class="text-xs font-semibold uppercase tracking-wider text-surface-400">Memory</p> <p class="text-xs font-semibold uppercase tracking-wider text-surface-400">Memory</p>
<div class="w-8 h-8 rounded-lg bg-violet-50 flex items-center justify-center"> <div class="w-8 h-8 rounded-lg bg-violet-50 flex items-center justify-center">
@@ -78,7 +78,7 @@
</div> </div>
<!-- Disk --> <!-- Disk -->
<div class="bg-white rounded-xl border border-surface-200 p-5 shadow-sm hover:shadow-md transition-shadow duration-200"> <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm hover:shadow-md transition-shadow duration-200">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<p class="text-xs font-semibold uppercase tracking-wider text-surface-400">Disk</p> <p class="text-xs font-semibold uppercase tracking-wider text-surface-400">Disk</p>
<div class="w-8 h-8 rounded-lg bg-amber-50 flex items-center justify-center"> <div class="w-8 h-8 rounded-lg bg-amber-50 flex items-center justify-center">
@@ -90,14 +90,14 @@
</div> </div>
<!-- Uptime --> <!-- Uptime -->
<div class="bg-white rounded-xl border border-surface-200 p-5 shadow-sm hover:shadow-md transition-shadow duration-200"> <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm hover:shadow-md transition-shadow duration-200">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<p class="text-xs font-semibold uppercase tracking-wider text-surface-400">Uptime</p> <p class="text-xs font-semibold uppercase tracking-wider text-surface-400">Uptime</p>
<div class="w-8 h-8 rounded-lg bg-emerald-50 flex items-center justify-center"> <div class="w-8 h-8 rounded-lg bg-emerald-50 flex items-center justify-center">
<svg class="w-4 h-4 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg> <svg class="w-4 h-4 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
</div> </div>
</div> </div>
<p class="text-2xl font-bold text-surface-800">{stats?.uptime || "—"}</p> <p class="text-2xl font-bold text-surface-800 dark:text-surface-100">{stats?.uptime || "—"}</p>
<p class="text-xs text-surface-400 mt-1 truncate" title={stats?.platform}>{stats?.platform || "—"}</p> <p class="text-xs text-surface-400 mt-1 truncate" title={stats?.platform}>{stats?.platform || "—"}</p>
</div> </div>
</div> </div>
@@ -105,9 +105,9 @@
<!-- Services Grid --> <!-- Services Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Docker Summary --> <!-- Docker Summary -->
<div class="bg-white rounded-xl border border-surface-200 p-5 shadow-sm"> <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-surface-700">Docker Containers</h3> <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Docker Containers</h3>
<span class="text-xs font-medium text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-full">{containers.filter(c => c.status === "running").length}/{containers.length} running</span> <span class="text-xs font-medium text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-full">{containers.filter(c => c.status === "running").length}/{containers.length} running</span>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
@@ -115,7 +115,7 @@
<div class="flex items-center justify-between py-1.5 text-sm"> <div class="flex items-center justify-between py-1.5 text-sm">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full {c.status !== 'running' ? 'bg-surface-300' : c.health === 'unhealthy' ? 'bg-rose-400' : c.health === 'healthy' ? 'bg-emerald-400' : 'bg-sky-400'}"></span> <span class="w-1.5 h-1.5 rounded-full {c.status !== 'running' ? 'bg-surface-300' : c.health === 'unhealthy' ? 'bg-rose-400' : c.health === 'healthy' ? 'bg-emerald-400' : 'bg-sky-400'}"></span>
<span class="font-medium text-surface-700">{c.name}</span> <span class="font-medium text-surface-700 dark:text-surface-200">{c.name}</span>
</div> </div>
<span class="text-xs text-surface-400 truncate max-w-[180px]">{c.image}</span> <span class="text-xs text-surface-400 truncate max-w-[180px]">{c.image}</span>
</div> </div>
@@ -127,9 +127,9 @@
</div> </div>
<!-- Repos Summary --> <!-- Repos Summary -->
<div class="bg-white rounded-xl border border-surface-200 p-5 shadow-sm"> <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-surface-700">Git Repositories</h3> <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Git Repositories</h3>
<span class="text-xs font-medium text-primary-600 bg-primary-50 px-2 py-0.5 rounded-full">{repos.length} repos</span> <span class="text-xs font-medium text-primary-600 bg-primary-50 px-2 py-0.5 rounded-full">{repos.length} repos</span>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
@@ -137,7 +137,7 @@
<div class="flex items-center justify-between py-1.5 text-sm"> <div class="flex items-center justify-between py-1.5 text-sm">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<svg class="w-3.5 h-3.5 text-surface-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /></svg> <svg class="w-3.5 h-3.5 text-surface-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /></svg>
<span class="font-medium text-surface-700">{r.name}</span> <span class="font-medium text-surface-700 dark:text-surface-200">{r.name}</span>
</div> </div>
<span class="text-xs text-surface-400">{r.language || "—"}</span> <span class="text-xs text-surface-400">{r.language || "—"}</span>
</div> </div>
@@ -151,8 +151,8 @@
<!-- Storage Usage --> <!-- Storage Usage -->
{#if stats?.volumes?.length} {#if stats?.volumes?.length}
<div class="bg-white rounded-xl border border-surface-200 p-5 shadow-sm"> <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
<h3 class="text-sm font-semibold text-surface-700 mb-3">Storage Usage</h3> <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-3">Storage Usage</h3>
<div class="space-y-3"> <div class="space-y-3">
{#each stats.volumes as vol} {#each stats.volumes as vol}
<div> <div>
@@ -160,7 +160,7 @@
<span class="font-medium">{vol.mount}</span> <span class="font-medium">{vol.mount}</span>
<span>{vol.percent}%</span> <span>{vol.percent}%</span>
</div> </div>
<div class="w-full h-3 bg-surface-100 rounded-full overflow-hidden"> <div class="w-full h-3 bg-surface-100 dark:bg-surface-700 rounded-full overflow-hidden">
<div <div
class="h-full rounded-full transition-all duration-700 ease-out {vol.percent > 90 ? 'bg-gradient-to-r from-rose-400 to-rose-500' : vol.percent > 70 ? 'bg-gradient-to-r from-amber-400 to-amber-500' : 'bg-gradient-to-r from-emerald-400 to-emerald-500'}" class="h-full rounded-full transition-all duration-700 ease-out {vol.percent > 90 ? 'bg-gradient-to-r from-rose-400 to-rose-500' : vol.percent > 70 ? 'bg-gradient-to-r from-amber-400 to-amber-500' : 'bg-gradient-to-r from-emerald-400 to-emerald-500'}"
style="width: {vol.percent}%" style="width: {vol.percent}%"
+7 -7
View File
@@ -36,7 +36,7 @@
<div class="space-y-6"> <div class="space-y-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h1 class="text-2xl font-bold text-surface-900 tracking-tight">Docker</h1> <h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Docker</h1>
<p class="text-sm text-surface-400 mt-1">{containers.length} containers · {containers.filter(c => c.status === "running").length} running</p> <p class="text-sm text-surface-400 mt-1">{containers.length} containers · {containers.filter(c => c.status === "running").length} running</p>
</div> </div>
<button onclick={load} class="text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 px-3 py-1.5 rounded-lg transition-colors"> <button onclick={load} class="text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 px-3 py-1.5 rounded-lg transition-colors">
@@ -47,13 +47,13 @@
{#if loading} {#if loading}
<div class="space-y-3"> <div class="space-y-3">
{#each Array(5) as _} {#each Array(5) as _}
<div class="h-[72px] rounded-xl bg-surface-100 animate-pulse"></div> <div class="h-[72px] rounded-xl bg-surface-100 dark:bg-surface-800 animate-pulse"></div>
{/each} {/each}
</div> </div>
{:else} {:else}
<div class="space-y-2"> <div class="space-y-2">
{#each containers as c} {#each containers as c}
<div class="bg-white rounded-xl border border-surface-200 p-4 flex items-center justify-between shadow-sm hover:shadow-md transition-all duration-200 group"> <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-4 flex items-center justify-between shadow-sm hover:shadow-md transition-all duration-200 group">
<div class="flex items-center gap-3 min-w-0"> <div class="flex items-center gap-3 min-w-0">
<span class="relative flex h-2.5 w-2.5 shrink-0"> <span class="relative flex h-2.5 w-2.5 shrink-0">
{#if c.status === "running"} {#if c.status === "running"}
@@ -64,7 +64,7 @@
{/if} {/if}
</span> </span>
<div class="min-w-0"> <div class="min-w-0">
<p class="text-sm font-semibold text-surface-800 truncate">{c.name}</p> <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 truncate">{c.name}</p>
<p class="text-xs text-surface-400 truncate mt-0.5">{c.image}</p> <p class="text-xs text-surface-400 truncate mt-0.5">{c.image}</p>
</div> </div>
</div> </div>
@@ -108,10 +108,10 @@
<!-- Log Modal --> <!-- Log Modal -->
{#if logTarget} {#if logTarget}
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50" role="dialog" aria-modal="true" onclick={() => { logTarget = ""; logContent = ""; }} onkeydown={(e) => e.key === "Escape" && (logTarget = "", logContent = "")}> <div class="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50" role="dialog" aria-modal="true" onclick={() => { logTarget = ""; logContent = ""; }} onkeydown={(e) => e.key === "Escape" && (logTarget = "", logContent = "")}>
<div class="bg-white rounded-2xl shadow-2xl w-[90vw] max-w-4xl max-h-[80vh] flex flex-col" role="document" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}> <div class="bg-white dark:bg-surface-800 rounded-2xl shadow-2xl w-[90vw] max-w-4xl max-h-[80vh] flex flex-col" role="document" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
<div class="flex items-center justify-between px-5 py-4 border-b border-surface-200"> <div class="flex items-center justify-between px-5 py-4 border-b border-surface-200 dark:border-surface-700">
<div> <div>
<h3 class="text-sm font-bold text-surface-800">Container Logs</h3> <h3 class="text-sm font-bold text-surface-800 dark:text-surface-100">Container Logs</h3>
<p class="text-xs text-surface-400 mt-0.5">{logTarget}</p> <p class="text-xs text-surface-400 mt-0.5">{logTarget}</p>
</div> </div>
<button aria-label="Close logs" class="text-surface-400 hover:text-surface-600 transition-colors" onclick={() => { logTarget = ""; logContent = ""; }}> <button aria-label="Close logs" class="text-surface-400 hover:text-surface-600 transition-colors" onclick={() => { logTarget = ""; logContent = ""; }}>
+8 -8
View File
@@ -69,7 +69,7 @@
<div class="space-y-5"> <div class="space-y-5">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h1 class="text-2xl font-bold text-surface-900 tracking-tight">Files</h1> <h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Files</h1>
<p class="text-sm text-surface-400 mt-1">/volume1{currentPath ? "/" + currentPath : ""}</p> <p class="text-sm text-surface-400 mt-1">/volume1{currentPath ? "/" + currentPath : ""}</p>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -96,7 +96,7 @@
{/each} {/each}
{#if currentPath} {#if currentPath}
<button <button
class="ml-auto text-xs font-medium text-surface-500 bg-surface-100 hover:bg-surface-200 px-2.5 py-1 rounded-lg transition-colors flex items-center gap-1" class="ml-auto text-xs font-medium text-surface-500 bg-surface-100 dark:bg-surface-700 hover:bg-surface-200 dark:hover:bg-surface-600 px-2.5 py-1 rounded-lg transition-colors flex items-center gap-1"
onclick={goUp} onclick={goUp}
> >
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 10l7-7m0 0l7 7m-7-7v18" /></svg> <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 10l7-7m0 0l7 7m-7-7v18" /></svg>
@@ -109,27 +109,27 @@
{#if loading} {#if loading}
<div class="space-y-1"> <div class="space-y-1">
{#each Array(8) as _} {#each Array(8) as _}
<div class="h-11 rounded-lg bg-surface-100 animate-pulse"></div> <div class="h-11 rounded-lg bg-surface-100 dark:bg-surface-800 animate-pulse"></div>
{/each} {/each}
</div> </div>
{:else} {:else}
<div class="bg-white rounded-xl border border-surface-200 shadow-sm overflow-hidden"> <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
<!-- Header --> <!-- Header -->
<div class="grid grid-cols-[1fr_100px_100px] px-4 py-2 text-[11px] font-semibold uppercase tracking-wider text-surface-400 border-b border-surface-100"> <div class="grid grid-cols-[1fr_100px_100px] px-4 py-2 text-[11px] font-semibold uppercase tracking-wider text-surface-400 border-b border-surface-100 dark:border-surface-700">
<span>Name</span> <span>Name</span>
<span class="text-right">Size</span> <span class="text-right">Size</span>
<span class="text-right">Actions</span> <span class="text-right">Actions</span>
</div> </div>
<!-- Entries --> <!-- Entries -->
{#each entries as e} {#each entries as e}
<div class="grid grid-cols-[1fr_100px_100px] items-center px-4 py-2.5 hover:bg-surface-50 border-b border-surface-100 last:border-0 transition-colors group"> <div class="grid grid-cols-[1fr_100px_100px] items-center px-4 py-2.5 hover:bg-surface-50 dark:hover:bg-surface-700 border-b border-surface-100 dark:border-surface-700 last:border-0 transition-colors group">
{#if e.is_dir} {#if e.is_dir}
<button class="flex items-center gap-2.5 text-sm font-medium text-surface-700 hover:text-primary-600 transition-colors text-left truncate" onclick={() => browse(currentPath ? `${currentPath}/${e.name}` : e.name)}> <button class="flex items-center gap-2.5 text-sm font-medium text-surface-700 dark:text-surface-200 hover:text-primary-600 transition-colors text-left truncate" onclick={() => browse(currentPath ? `${currentPath}/${e.name}` : e.name)}>
<svg class="w-4 h-4 text-amber-400 shrink-0" fill="currentColor" viewBox="0 0 20 20"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" /></svg> <svg class="w-4 h-4 text-amber-400 shrink-0" fill="currentColor" viewBox="0 0 20 20"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" /></svg>
{e.name} {e.name}
</button> </button>
{:else} {:else}
<span class="flex items-center gap-2.5 text-sm text-surface-600 truncate"> <span class="flex items-center gap-2.5 text-sm text-surface-600 dark:text-surface-300 truncate">
<svg class="w-4 h-4 text-surface-300 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg> <svg class="w-4 h-4 text-surface-300 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>
{e.name} {e.name}
</span> </span>
+9 -9
View File
@@ -41,26 +41,26 @@
<div class="space-y-6"> <div class="space-y-6">
<div> <div>
<h1 class="text-2xl font-bold text-surface-900 tracking-tight">Repositories</h1> <h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Repositories</h1>
<p class="text-sm text-surface-400 mt-1">{repos.length} repositories on Gitea</p> <p class="text-sm text-surface-400 mt-1">{repos.length} repositories on Gitea</p>
</div> </div>
{#if loading} {#if loading}
<div class="grid grid-cols-1 md:grid-cols-2 gap-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each Array(4) as _} {#each Array(4) as _}
<div class="h-[100px] rounded-xl bg-surface-100 animate-pulse"></div> <div class="h-[100px] rounded-xl bg-surface-100 dark:bg-surface-800 animate-pulse"></div>
{/each} {/each}
</div> </div>
{:else} {:else}
<div class="grid grid-cols-1 md:grid-cols-2 gap-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each repos as r} {#each repos as r}
<button <button
class="bg-white rounded-xl border border-surface-200 p-4 text-left shadow-sm hover:shadow-md hover:border-primary-200 transition-all duration-200 group {selectedRepo === `${r.owner?.login}/${r.name}` ? 'ring-2 ring-primary-500 border-primary-300' : ''}" class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-4 text-left shadow-sm hover:shadow-md hover:border-primary-200 dark:hover:border-primary-700 transition-all duration-200 group {selectedRepo === `${r.owner?.login}/${r.name}` ? 'ring-2 ring-primary-500 border-primary-300' : ''}"
onclick={() => showCommits(r.owner?.login, r.name)} onclick={() => showCommits(r.owner?.login, r.name)}
> >
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="min-w-0"> <div class="min-w-0">
<p class="text-sm font-semibold text-surface-800 group-hover:text-primary-600 transition-colors truncate">{r.name}</p> <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 group-hover:text-primary-600 transition-colors truncate">{r.name}</p>
<p class="text-xs text-surface-400 mt-1 line-clamp-1">{r.description || "No description"}</p> <p class="text-xs text-surface-400 mt-1 line-clamp-1">{r.description || "No description"}</p>
</div> </div>
{#if r.language} {#if r.language}
@@ -78,10 +78,10 @@
<!-- Commits Panel --> <!-- Commits Panel -->
{#if selectedRepo} {#if selectedRepo}
<div class="bg-white rounded-xl border border-surface-200 p-5 shadow-sm"> <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div> <div>
<h3 class="text-sm font-bold text-surface-800">Recent Commits</h3> <h3 class="text-sm font-bold text-surface-800 dark:text-surface-100">Recent Commits</h3>
<p class="text-xs text-surface-400 mt-0.5">{selectedRepo}</p> <p class="text-xs text-surface-400 mt-0.5">{selectedRepo}</p>
</div> </div>
<button <button
@@ -101,7 +101,7 @@
{:else} {:else}
<div class="space-y-0"> <div class="space-y-0">
{#each commits.slice(0, 15) as c, i} {#each commits.slice(0, 15) as c, i}
<div class="flex gap-3 py-2.5 {i < commits.length - 1 ? 'border-b border-surface-100' : ''}"> <div class="flex gap-3 py-2.5 {i < commits.length - 1 ? 'border-b border-surface-100 dark:border-surface-700' : ''}">
<div class="flex flex-col items-center pt-1"> <div class="flex flex-col items-center pt-1">
<div class="w-2 h-2 rounded-full bg-primary-400 shrink-0"></div> <div class="w-2 h-2 rounded-full bg-primary-400 shrink-0"></div>
{#if i < commits.length - 1} {#if i < commits.length - 1}
@@ -109,12 +109,12 @@
{/if} {/if}
</div> </div>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-sm text-surface-700 truncate">{c.commit?.message?.split("\n")[0]}</p> <p class="text-sm text-surface-700 dark:text-surface-200 truncate">{c.commit?.message?.split("\n")[0]}</p>
<p class="text-[11px] text-surface-400 mt-0.5"> <p class="text-[11px] text-surface-400 mt-0.5">
<span class="font-medium">{c.commit?.author?.name}</span> · {timeAgo(c.commit?.author?.date)} <span class="font-medium">{c.commit?.author?.name}</span> · {timeAgo(c.commit?.author?.date)}
</p> </p>
</div> </div>
<span class="text-[10px] font-mono text-surface-400 bg-surface-50 px-1.5 py-0.5 rounded h-fit shrink-0">{c.sha?.slice(0, 7)}</span> <span class="text-[10px] font-mono text-surface-400 bg-surface-50 dark:bg-surface-700 px-1.5 py-0.5 rounded h-fit shrink-0">{c.sha?.slice(0, 7)}</span>
</div> </div>
{/each} {/each}
</div> </div>
+165 -3
View File
@@ -1,6 +1,7 @@
<script> <script>
import { post, get } from "../lib/api.js"; import { post, get, del, put } from "../lib/api.js";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { currentUser } from "../lib/api.js";
let currentPassword = $state(""); let currentPassword = $state("");
let newPassword = $state(""); let newPassword = $state("");
@@ -14,8 +15,6 @@
let passkeyErr = $state(""); let passkeyErr = $state("");
let passkeyLoading = $state(false); let passkeyLoading = $state(false);
onMount(loadPasskeys);
async function loadPasskeys() { async function loadPasskeys() {
try { try {
const res = await get("/auth/passkey/list"); const res = await get("/auth/passkey/list");
@@ -131,6 +130,79 @@
} catch {} } catch {}
auditLoading = false; auditLoading = false;
} }
// Access Control
const allPages = ["dashboard", "docker", "files", "terminal", "security", "openclaw", "chat-digest", "gitea"];
let rbacConfig = $state(null);
let rbacLoading = $state(false);
let rbacMsg = $state("");
let rbacErr = $state("");
let editingUser = $state("");
let editPages = $state([]);
let newUsername = $state("");
async function loadRbac() {
rbacLoading = true;
try {
rbacConfig = await get("/auth/rbac/config");
} catch {}
rbacLoading = false;
}
function startEdit(username, pages) {
editingUser = username;
editPages = [...pages];
newUsername = "";
}
function startAdd() {
editingUser = "__new__";
editPages = ["dashboard"];
newUsername = "";
}
function cancelEdit() {
editingUser = "";
editPages = [];
newUsername = "";
}
function togglePage(p) {
if (editPages.includes(p)) editPages = editPages.filter(x => x !== p);
else editPages = [...editPages, p];
}
async function saveOverride() {
rbacMsg = ""; rbacErr = "";
const uname = editingUser === "__new__" ? newUsername.trim() : editingUser;
if (!uname) { rbacErr = "Username required"; return; }
if (!editPages.length) { rbacErr = "Select at least one page"; return; }
try {
await put(`/auth/rbac/overrides/${encodeURIComponent(uname)}`, { pages: editPages });
rbacMsg = `Saved override for ${uname}`;
cancelEdit();
await loadRbac();
} catch (e) {
rbacErr = e.body?.detail || "Failed to save";
}
}
async function deleteOverride(username) {
if (!confirm(`Remove override for "${username}"?`)) return;
rbacMsg = ""; rbacErr = "";
try {
await del(`/auth/rbac/overrides/${encodeURIComponent(username)}`);
rbacMsg = `Removed override for ${username}`;
await loadRbac();
} catch (e) {
rbacErr = e.body?.detail || "Failed to delete";
}
}
onMount(() => {
loadPasskeys();
if (currentUser.role === "admin") loadRbac();
});
</script> </script>
<div class="space-y-6"> <div class="space-y-6">
@@ -208,6 +280,96 @@
</div> </div>
</div> </div>
{#if currentUser.role === "admin" && rbacConfig}
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-6 shadow-sm">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Access Control</h3>
<button onclick={loadRbac} disabled={rbacLoading} class="px-3 py-1.5 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors disabled:opacity-50">
{rbacLoading ? "Loading..." : "Refresh"}
</button>
</div>
{#if rbacMsg}
<div class="text-sm text-emerald-600 bg-emerald-50 px-3 py-2 rounded-lg mb-4">{rbacMsg}</div>
{/if}
{#if rbacErr}
<div class="text-sm text-rose-600 bg-rose-50 px-3 py-2 rounded-lg mb-4">{rbacErr}</div>
{/if}
<!-- Role Defaults -->
<div class="mb-4">
<p class="text-xs font-medium text-surface-500 mb-2">Role Defaults</p>
<div class="space-y-1.5">
{#each Object.entries(rbacConfig.role_defaults || {}) as [role, cfg]}
<div class="flex items-center gap-2 text-xs">
<span class="font-medium text-surface-700 dark:text-surface-300 w-16">{role}</span>
<span class="text-surface-400">{cfg.pages === "*" ? "All pages" : (cfg.pages || []).join(", ")}</span>
</div>
{/each}
</div>
</div>
<!-- User Overrides -->
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<p class="text-xs font-medium text-surface-500">User Overrides</p>
{#if editingUser !== "__new__"}
<button onclick={startAdd} class="px-2 py-1 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-md transition-colors">Add Override</button>
{/if}
</div>
{#if Object.keys(rbacConfig.user_overrides || {}).length > 0}
<div class="space-y-2">
{#each Object.entries(rbacConfig.user_overrides) as [username, cfg]}
{#if editingUser === username}
<div class="p-3 bg-surface-50 dark:bg-surface-700 rounded-lg space-y-2">
<p class="text-xs font-medium text-surface-700 dark:text-surface-200">{username}</p>
<div class="flex flex-wrap gap-1.5">
{#each allPages as p}
<button onclick={() => togglePage(p)} class="px-2 py-0.5 text-xs rounded-md transition-colors {editPages.includes(p) ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">{p}</button>
{/each}
</div>
<div class="flex gap-2">
<button onclick={saveOverride} class="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-md">Save</button>
<button onclick={cancelEdit} class="px-3 py-1 text-xs font-medium text-surface-500 bg-surface-200 hover:bg-surface-300 rounded-md">Cancel</button>
</div>
</div>
{:else}
<div class="flex items-center justify-between px-3 py-2 bg-surface-50 dark:bg-surface-700 rounded-lg">
<div>
<span class="text-sm font-medium text-surface-700 dark:text-surface-200">{username}</span>
<span class="text-xs text-surface-400 ml-2">{(cfg.pages || []).join(", ")}</span>
</div>
<div class="flex gap-1.5">
<button onclick={() => startEdit(username, cfg.pages || [])} class="text-xs text-primary-500 hover:text-primary-700">Edit</button>
<button onclick={() => deleteOverride(username)} class="text-xs text-rose-500 hover:text-rose-700">Delete</button>
</div>
</div>
{/if}
{/each}
</div>
{:else if editingUser !== "__new__"}
<p class="text-sm text-surface-400">No user overrides configured</p>
{/if}
{#if editingUser === "__new__"}
<div class="p-3 bg-surface-50 dark:bg-surface-700 rounded-lg space-y-2 mt-2">
<input type="text" bind:value={newUsername} placeholder="Username" class="w-full px-3 py-1.5 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-800 dark:text-white" />
<div class="flex flex-wrap gap-1.5">
{#each allPages as p}
<button onclick={() => togglePage(p)} class="px-2 py-0.5 text-xs rounded-md transition-colors {editPages.includes(p) ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">{p}</button>
{/each}
</div>
<div class="flex gap-2">
<button onclick={saveOverride} class="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-md">Save</button>
<button onclick={cancelEdit} class="px-3 py-1 text-xs font-medium text-surface-500 bg-surface-200 hover:bg-surface-300 rounded-md">Cancel</button>
</div>
</div>
{/if}
</div>
</div>
{/if}
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-6 shadow-sm"> <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-6 shadow-sm">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Audit Log</h3> <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Audit Log</h3>
+1 -1
View File
@@ -18,7 +18,7 @@ services:
DB_PASSWORD: ${DB_PASSWORD} DB_PASSWORD: ${DB_PASSWORD}
DB_DATABASE_NAME: ${DB_DATABASE_NAME} DB_DATABASE_NAME: ${DB_DATABASE_NAME}
REDIS_HOSTNAME: immich-redis REDIS_HOSTNAME: immich-redis
IMMICH_MACHINE_LEARNING_ENABLED: "false" MACHINE_LEARNING_URL: http://100.65.185.12:3003
healthcheck: healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:2283/api/server/ping"] test: ["CMD", "wget", "-q", "--spider", "http://localhost:2283/api/server/ping"]
interval: 30s interval: 30s