feat: enable Immich ML, dark mode fixes, sidebar improvements #6
@@ -61,6 +61,18 @@ def get_pages(username: str, role: str) -> list | str:
|
||||
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:
|
||||
if user.pages == "*":
|
||||
return True
|
||||
|
||||
@@ -364,10 +364,28 @@ async def rbac_set_override(username: str, request: Request, current_user = Depe
|
||||
if pages is None:
|
||||
raise HTTPException(status_code=400, detail="Missing pages field")
|
||||
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)
|
||||
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}")
|
||||
async def rbac_delete_override(username: str, current_user = Depends(auth.get_current_user)):
|
||||
if current_user.role != "admin":
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import Security from "./routes/Security.svelte";
|
||||
import Login from "./routes/Login.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 dark = $state(false);
|
||||
@@ -20,6 +20,8 @@
|
||||
let loading = $state(true);
|
||||
let userRole = $state("");
|
||||
let allowedPages = $state([]);
|
||||
let sidebarOrder = $state(null);
|
||||
let orderSaveTimer = null;
|
||||
|
||||
async function fetchUserInfo() {
|
||||
try {
|
||||
@@ -27,11 +29,24 @@
|
||||
setCurrentUser(data);
|
||||
userRole = data.role || "admin";
|
||||
allowedPages = data.pages || "*";
|
||||
// Fetch sidebar preferences
|
||||
try {
|
||||
const prefs = await getPreferences();
|
||||
sidebarOrder = prefs.sidebar_order || null;
|
||||
} catch {}
|
||||
} catch (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) {
|
||||
if (allowedPages === "*") return true;
|
||||
return Array.isArray(allowedPages) && allowedPages.includes(pageId);
|
||||
@@ -108,12 +123,12 @@
|
||||
{:else if !authorized}
|
||||
<Login />
|
||||
{:else}
|
||||
<div class="flex h-screen overflow-hidden bg-surface-50 {dark ? 'dark bg-surface-900' : ''}">
|
||||
<Sidebar bind:page {dark} {toggleTheme} {logout} bind:sidebarOpen {allowedPages} {userRole} />
|
||||
<div class="flex h-screen overflow-hidden bg-surface-50 dark:bg-surface-900">
|
||||
<Sidebar bind:page {dark} {toggleTheme} {logout} bind:sidebarOpen {allowedPages} {userRole} {sidebarOrder} onOrderChange={handleOrderChange} />
|
||||
<main class="flex-1 overflow-auto">
|
||||
<div class="max-w-[1400px] mx-auto p-6 lg:p-8">
|
||||
<!-- 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>
|
||||
</button>
|
||||
{#if page === "dashboard" && hasPageAccess("dashboard")}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", system-ui, -apple-system, sans-serif;
|
||||
|
||||
@@ -22,6 +24,7 @@
|
||||
--color-surface-700: #334155;
|
||||
--color-surface-800: #1e293b;
|
||||
--color-surface-900: #0f172a;
|
||||
--color-surface-950: #020617;
|
||||
|
||||
--color-emerald-400: #34d399;
|
||||
--color-emerald-500: #10b981;
|
||||
@@ -57,8 +60,10 @@ body {
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--color-surface-400); }
|
||||
|
||||
/* Dark mode overrides */
|
||||
.dark body,
|
||||
.dark {
|
||||
background: var(--color-surface-900);
|
||||
html.dark body {
|
||||
background: var(--color-surface-950);
|
||||
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); }
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<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) {
|
||||
if (!id) return true; // external links always shown
|
||||
if (!id) return true;
|
||||
if (allowedPages === "*") return true;
|
||||
return Array.isArray(allowedPages) && allowedPages.includes(id);
|
||||
}
|
||||
|
||||
const links = [
|
||||
const defaultLinks = [
|
||||
{ id: "dashboard", label: "Overview", icon: "grid" },
|
||||
{ id: "docker", label: "Docker", icon: "box" },
|
||||
{ id: "files", label: "Files", icon: "folder" },
|
||||
@@ -25,14 +25,14 @@
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const media = [
|
||||
const defaultMedia = [
|
||||
{ label: "Navidrome", remoteHref: "https://music.jimmygan.com:8443", icon: "music" },
|
||||
{ label: "Jellyfin", remoteHref: "https://media.jimmygan.com:8443", icon: "play" },
|
||||
{ label: "Audiobookshelf", remoteHref: "https://books.jimmygan.com:8443", icon: "headphones" },
|
||||
{ label: "Immich", remoteHref: "https://photos.jimmygan.com:8443", icon: "image" },
|
||||
];
|
||||
|
||||
const tools = [
|
||||
const defaultTools = [
|
||||
{ id: "openclaw", label: "OpenClaw", icon: "openclaw" },
|
||||
{ id: "chat-digest", label: "Chat Digest", icon: "chat" },
|
||||
{ id: "gitea", label: "Repos", icon: "git" },
|
||||
@@ -44,6 +44,125 @@
|
||||
{ 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) {
|
||||
if (lanReachable && svc.port) return `http://${LAN_IP}:${svc.port}`;
|
||||
if (svc.remoteHref) return svc.remoteHref;
|
||||
@@ -58,144 +177,151 @@
|
||||
page = id;
|
||||
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>
|
||||
|
||||
{#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 -->
|
||||
{#if sidebarOpen}
|
||||
<button class="fixed inset-0 bg-black/40 z-40 md:hidden" onclick={() => sidebarOpen = false} aria-label="Close sidebar"></button>
|
||||
{/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 -->
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nav Links -->
|
||||
<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 links as link}
|
||||
{#if canAccess(link.id)}
|
||||
{#each [["main", "Main", links], ["media", "Media", media], ["tools", "Tools", tools], ["tools2", "Tools 2", tools2]] as [cat, label, items], ci}
|
||||
{#if ci > 0}
|
||||
<div class="my-4 border-t border-surface-200 dark:border-surface-700"></div>
|
||||
{/if}
|
||||
<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 === link.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(link.id)}
|
||||
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' : ''}"
|
||||
ondragover={(e) => handleCategoryDragOver(cat, e)}
|
||||
ondrop={(e) => handleCategoryDrop(cat, e)}
|
||||
onclick={() => cat !== 'main' && toggleCollapse(cat)}
|
||||
disabled={cat === 'main'}
|
||||
>
|
||||
<span class="w-5 h-5 flex items-center justify-center">
|
||||
{#if link.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 link.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 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}
|
||||
{label}
|
||||
{#if cat !== "main"}
|
||||
<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>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/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">Media</p>
|
||||
{#each media as svc}
|
||||
<a
|
||||
href={extHref(svc)}
|
||||
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 svc.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 svc.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 svc.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 svc.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>
|
||||
{#if cat === "main" || !collapsed[cat]}
|
||||
{#each items as item, i}
|
||||
{#if canAccess(item.id)}
|
||||
{#if item.external || item.remoteHref || item.port}
|
||||
<a
|
||||
href={extHref(item)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
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 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-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}
|
||||
<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
|
||||
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}
|
||||
</span>
|
||||
{svc.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>
|
||||
{/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}
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- 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"}
|
||||
<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')}
|
||||
>
|
||||
<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}
|
||||
<button
|
||||
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}
|
||||
<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
|
||||
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>
|
||||
Sign out
|
||||
|
||||
@@ -130,3 +130,15 @@ export function login(creds) {
|
||||
export function checkAuth() {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -40,21 +40,21 @@
|
||||
|
||||
<div class="space-y-8">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{#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}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- 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">
|
||||
<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">
|
||||
@@ -66,7 +66,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<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">
|
||||
@@ -78,7 +78,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<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">
|
||||
@@ -90,14 +90,14 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,9 +105,9 @@
|
||||
<!-- Services Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<!-- 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">
|
||||
<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>
|
||||
</div>
|
||||
<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 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="font-medium text-surface-700">{c.name}</span>
|
||||
<span class="font-medium text-surface-700 dark:text-surface-200">{c.name}</span>
|
||||
</div>
|
||||
<span class="text-xs text-surface-400 truncate max-w-[180px]">{c.image}</span>
|
||||
</div>
|
||||
@@ -127,9 +127,9 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<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>
|
||||
</div>
|
||||
<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 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>
|
||||
<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>
|
||||
<span class="text-xs text-surface-400">{r.language || "—"}</span>
|
||||
</div>
|
||||
@@ -151,8 +151,8 @@
|
||||
|
||||
<!-- Storage Usage -->
|
||||
{#if stats?.volumes?.length}
|
||||
<div class="bg-white rounded-xl border border-surface-200 p-5 shadow-sm">
|
||||
<h3 class="text-sm font-semibold text-surface-700 mb-3">Storage Usage</h3>
|
||||
<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 dark:text-surface-200 mb-3">Storage Usage</h3>
|
||||
<div class="space-y-3">
|
||||
{#each stats.volumes as vol}
|
||||
<div>
|
||||
@@ -160,7 +160,7 @@
|
||||
<span class="font-medium">{vol.mount}</span>
|
||||
<span>{vol.percent}%</span>
|
||||
</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
|
||||
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}%"
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<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>
|
||||
</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">
|
||||
@@ -47,13 +47,13 @@
|
||||
{#if loading}
|
||||
<div class="space-y-3">
|
||||
{#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}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#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">
|
||||
<span class="relative flex h-2.5 w-2.5 shrink-0">
|
||||
{#if c.status === "running"}
|
||||
@@ -64,7 +64,7 @@
|
||||
{/if}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,10 +108,10 @@
|
||||
<!-- Log Modal -->
|
||||
{#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="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="flex items-center justify-between px-5 py-4 border-b border-surface-200">
|
||||
<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 dark:border-surface-700">
|
||||
<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>
|
||||
</div>
|
||||
<button aria-label="Close logs" class="text-surface-400 hover:text-surface-600 transition-colors" onclick={() => { logTarget = ""; logContent = ""; }}>
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
<div class="space-y-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -96,7 +96,7 @@
|
||||
{/each}
|
||||
{#if currentPath}
|
||||
<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}
|
||||
>
|
||||
<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}
|
||||
<div class="space-y-1">
|
||||
{#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}
|
||||
</div>
|
||||
{: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 -->
|
||||
<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 class="text-right">Size</span>
|
||||
<span class="text-right">Actions</span>
|
||||
</div>
|
||||
<!-- Entries -->
|
||||
{#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}
|
||||
<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>
|
||||
{e.name}
|
||||
</button>
|
||||
{: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>
|
||||
{e.name}
|
||||
</span>
|
||||
|
||||
@@ -41,26 +41,26 @@
|
||||
|
||||
<div class="space-y-6">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{#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}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{#each repos as r}
|
||||
<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)}
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<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>
|
||||
</div>
|
||||
{#if r.language}
|
||||
@@ -78,10 +78,10 @@
|
||||
|
||||
<!-- Commits Panel -->
|
||||
{#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>
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
@@ -101,7 +101,7 @@
|
||||
{:else}
|
||||
<div class="space-y-0">
|
||||
{#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="w-2 h-2 rounded-full bg-primary-400 shrink-0"></div>
|
||||
{#if i < commits.length - 1}
|
||||
@@ -109,12 +109,12 @@
|
||||
{/if}
|
||||
</div>
|
||||
<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">
|
||||
<span class="font-medium">{c.commit?.author?.name}</span> · {timeAgo(c.commit?.author?.date)}
|
||||
</p>
|
||||
</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>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { post, get } from "../lib/api.js";
|
||||
import { post, get, del, put } from "../lib/api.js";
|
||||
import { onMount } from "svelte";
|
||||
import { currentUser } from "../lib/api.js";
|
||||
|
||||
let currentPassword = $state("");
|
||||
let newPassword = $state("");
|
||||
@@ -14,8 +15,6 @@
|
||||
let passkeyErr = $state("");
|
||||
let passkeyLoading = $state(false);
|
||||
|
||||
onMount(loadPasskeys);
|
||||
|
||||
async function loadPasskeys() {
|
||||
try {
|
||||
const res = await get("/auth/passkey/list");
|
||||
@@ -131,6 +130,79 @@
|
||||
} catch {}
|
||||
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>
|
||||
|
||||
<div class="space-y-6">
|
||||
@@ -208,6 +280,96 @@
|
||||
</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="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Audit Log</h3>
|
||||
|
||||
@@ -18,7 +18,7 @@ services:
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
DB_DATABASE_NAME: ${DB_DATABASE_NAME}
|
||||
REDIS_HOSTNAME: immich-redis
|
||||
IMMICH_MACHINE_LEARNING_ENABLED: "false"
|
||||
MACHINE_LEARNING_URL: http://100.65.185.12:3003
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:2283/api/server/ping"]
|
||||
interval: 30s
|
||||
|
||||
Reference in New Issue
Block a user