b2c4e73e4a
Deploy Dashboard (Dev) / Backend Tests (push) Successful in 1m21s
Deploy Dashboard (Dev) / Frontend Tests (push) Successful in 59s
Deploy Dashboard (Main) / Backend Tests (push) Successful in 2m56s
Deploy Dashboard (Main) / Frontend Tests (push) Successful in 5s
Deploy Dashboard (Main) / Build Main Image (push) Successful in 4m23s
Deploy Dashboard (Main) / Deploy to Production (push) Successful in 1m3s
Deploy Dashboard (Dev) / Build Dev Image (push) Successful in 6m39s
Deploy Dashboard (Dev) / Deploy to Dev (push) Successful in 1m1s
405 lines
25 KiB
Svelte
405 lines
25 KiB
Svelte
<script>
|
|
let { page = $bindable("dashboard"), dark = false, toggleTheme = () => {}, logout = () => {}, sidebarOpen = $bindable(false), allowedPages = "*", allowedSidebarLinks = "*", userRole = "admin", sidebarOrder = null, onOrderChange = () => {} } = $props();
|
|
|
|
function canAccess(id) {
|
|
if (!id) return true;
|
|
if (allowedPages === "*") return true;
|
|
if (!Array.isArray(allowedPages)) return false;
|
|
if (id === "info-engine") return allowedPages.includes("dashboard");
|
|
return allowedPages.includes(id);
|
|
}
|
|
|
|
function canSeeSidebarLink(item) {
|
|
if (allowedSidebarLinks === "*") return true;
|
|
// Check by label or id
|
|
const key = item.label || item.id;
|
|
return Array.isArray(allowedSidebarLinks) && allowedSidebarLinks.includes(key);
|
|
}
|
|
|
|
const defaultLinks = [
|
|
{ id: "dashboard", label: "Overview", icon: "grid" },
|
|
{ id: "litellm", label: "LiteLLM", icon: "bolt" },
|
|
{ id: "opc", label: "OPC", icon: "kanban" },
|
|
{ id: "files", label: "Files", icon: "folder" },
|
|
{ id: "terminal", label: "Terminal", icon: "terminal" },
|
|
{ id: "security", label: "Security", icon: "shield" },
|
|
];
|
|
|
|
let lanIp = $state("192.168.31.222");
|
|
let tsIp = $state("100.78.131.124");
|
|
let lanReachable = $state(false);
|
|
let serviceUrls = $state({});
|
|
|
|
if (typeof window !== "undefined") {
|
|
fetch("/api/client-ip").then(r => r.json()).then(d => {
|
|
if (d.lan) lanReachable = true;
|
|
}).catch(() => {});
|
|
|
|
// Fetch external service URLs from backend config
|
|
fetch("/api/system/external-services").then(r => r.json()).then(d => {
|
|
if (d.lan_ip) lanIp = d.lan_ip;
|
|
if (d.ts_ip) tsIp = d.ts_ip;
|
|
if (d.services) serviceUrls = d.services;
|
|
}).catch(() => {});
|
|
}
|
|
|
|
const defaultMedia = [
|
|
{ label: "Navidrome", remoteHref: "https://music.jimmygan.com", icon: "music" },
|
|
{ label: "Jellyfin", remoteHref: "https://media.jimmygan.com", icon: "play" },
|
|
{ label: "Audiobookshelf", remoteHref: "https://books.jimmygan.com", icon: "headphones" },
|
|
{ label: "Immich", remoteHref: "https://photos.jimmygan.com", icon: "image" },
|
|
{ label: "t-youtube", remoteHref: "https://yt.jimmygan.com", icon: "youtube" },
|
|
{ label: "Media Management", remoteHref: "https://media.jimmygan.com", icon: "wrench" },
|
|
{ id: "transmission", label: "Transmission", icon: "download" },
|
|
];
|
|
|
|
const defaultTools = [
|
|
{ label: "Hermes", remoteHref: "https://hermes-agent.nousresearch.com/docs", icon: "robot" },
|
|
{ id: "chat-digest", label: "Chat Digest", icon: "chat" },
|
|
{ id: "conversations", label: "Code Sessions", icon: "code" },
|
|
{ id: "gitea", label: "Repos", icon: "git" },
|
|
{ label: "Gitea Web", remoteHref: "https://git.jimmygan.com", icon: "git", external: true },
|
|
{ label: "Stirling PDF", remoteHref: "https://pdf.jimmygan.com", icon: "pdf", external: true },
|
|
{ label: "Vaultwarden", remoteHref: "https://vault.jimmygan.com", icon: "lock", external: true },
|
|
{ label: "n8n", remoteHref: "https://n8n.jimmygan.com", icon: "n8n", external: true },
|
|
{ label: "Speedtest", remoteHref: "https://speed.jimmygan.com", 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) {
|
|
// Look up service URL from fetched config by label (lowercased, underscored)
|
|
const key = svc.label ? svc.label.toLowerCase().replace(/\s+/g, "_") : "";
|
|
const configUrl = serviceUrls[key];
|
|
const remoteHref = configUrl || svc.remoteHref;
|
|
|
|
if (lanReachable && svc.port) return `http://${lanIp}:${svc.port}`;
|
|
if (remoteHref) return remoteHref;
|
|
if (svc.port) {
|
|
const host = /^\d+\.\d+\.\d+\.\d+$/.test(location.hostname) ? location.hostname : tsIp;
|
|
return `http://${host}:${svc.port}`;
|
|
}
|
|
return svc.href;
|
|
}
|
|
|
|
function navigate(id) {
|
|
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 === "sparkles"}
|
|
<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="M12 3l1.8 4.2L18 9l-4.2 1.8L12 15l-1.8-4.2L6 9l4.2-1.8L12 3zm6 11l.9 2.1L21 17l-2.1.9L18 20l-.9-2.1L15 17l2.1-.9L18 14zM5 14l.9 2.1L8 17l-2.1.9L5 20l-.9-2.1L2 17l2.1-.9L5 14z" /></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 === "bolt"}
|
|
<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="M13 3L4 14h6l-1 7 9-11h-6l1-7z" /></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 === "kanban"}
|
|
<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 5H7a2 2 0 00-2 2v12a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2zm8 0h-2a2 2 0 00-2 2v8a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2z" /></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 === "download"}
|
|
<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 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" /></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 === "code"}
|
|
<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 === "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>
|
|
{:else if icon === "youtube"}
|
|
<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" /><path stroke-linecap="round" stroke-linejoin="round" d="M8 3l8 3-8 3" /></svg>
|
|
{:else if icon === "wrench"}
|
|
<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="M11.42 15.17l-7.05 7.05a2 2 0 01-2.83 0l-.09-.09a2 2 0 010-2.83l7.05-7.05m4.24-4.24l1.41-1.41a4 4 0 015.66 0l.09.09a4 4 0 010 5.66l-1.41 1.41M14 14l-3-3m2 8l5-5" /></svg>
|
|
{:else if icon === "robot"}
|
|
<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 2v4M8 12h8M8 16h4m-2-8h0m0 0c-4 0-7 2-7 6s3 6 7 6 7-2 7-6-3-6-7-6zM6 8V6m12 2V6" /></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 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 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">
|
|
{#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 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'}
|
|
>
|
|
{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 cat === "main" || !collapsed[cat]}
|
|
{#if items.length === 0}
|
|
<div
|
|
class="px-3 py-3 mx-1 rounded-lg border border-dashed text-[11px] text-center transition-colors duration-150
|
|
{dropCategory === cat ? 'border-primary-400 bg-primary-50 dark:bg-primary-900/20 text-primary-500' : 'border-surface-300 dark:border-surface-600 text-surface-400'}"
|
|
ondragover={(e) => handleCategoryDragOver(cat, e)}
|
|
ondrop={(e) => handleCategoryDrop(cat, e)}
|
|
>
|
|
Drop items here
|
|
</div>
|
|
{/if}
|
|
{#each items as item, i}
|
|
{#if canAccess(item.id) && canSeeSidebarLink(item)}
|
|
{#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}
|
|
{/if}
|
|
{/each}
|
|
{/if}
|
|
{/each}
|
|
</nav>
|
|
|
|
<!-- Bottom actions -->
|
|
<div class="px-3 pb-3 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 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>
|
|
Settings
|
|
</button>
|
|
{/if}
|
|
<div class="flex items-center gap-1">
|
|
<button
|
|
onclick={logout}
|
|
class="flex-1 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
|
|
</button>
|
|
<button
|
|
onclick={toggleTheme}
|
|
class="p-2 rounded-lg text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-700 hover:text-surface-600 dark:hover:text-surface-300 transition-all duration-150"
|
|
title={dark ? 'Light Mode' : 'Dark Mode'}
|
|
>
|
|
{#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>
|
|
{:else}
|
|
<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="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</aside>
|