From d0535fb471dddb8aa96c49970929949a783fd627 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Fri, 3 Jul 2026 10:41:24 +0000 Subject: [PATCH 01/57] Expand example widget reference --- packages/assets/integrations/example.yaml | 225 +++++++++++++++++++--- 1 file changed, 203 insertions(+), 22 deletions(-) diff --git a/packages/assets/integrations/example.yaml b/packages/assets/integrations/example.yaml index 5c365306..7fa416ae 100644 --- a/packages/assets/integrations/example.yaml +++ b/packages/assets/integrations/example.yaml @@ -49,6 +49,34 @@ configuration: required: false default: 60 # Default value if not provided description: "Interval in seconds" + SHOW_HEADER: + required: false + default: true + description: "Toggle the header on the column widget examples" + SHOW_CPU: + required: false + default: true + description: "Toggle the CPU column in the static array example" + SHOW_MEMORY: + required: false + default: true + description: "Toggle the Memory column in the static array example" + SHOW_STORAGE: + required: false + default: true + description: "Toggle the Storage column in the static array example" + SHOW_LIST_HEADER: + required: false + default: true + description: "Toggle the list widget header" + SHOW_CARD_HEADER: + required: false + default: true + description: "Toggle the details card header" + SHOW_IFRAME_HEADER: + required: false + default: true + description: "Toggle the iframe widget header" # Endpoints # HTTP requests used to fetch or push data. @@ -103,36 +131,194 @@ configuration: # Widgets # Defines how data is presented on the dashboard. + # This section shows every supported widget template and the optional fields + # that the current resolver understands. widgets: - - name: "Status Overview" - key: "status-overview" # Unique identifier for the widget - template: "columns" # UI template (e.g., columns, vertical-list) + - name: "Columns Widget (prototype)" + key: "columns-prototype" + template: "columns" depends_on: - "computed.status_summary" - hide_if_empty: true + refresh_interval: 30 data: source: "computed.status_summary" input: URL: "${URL}" API_KEY: "${API_KEY}" POLLING_INTERVAL: "${POLLING_INTERVAL}" + display_customizations: + order: + - "status-overview" + - "uptime" + hidden: + - "deprecated" properties: header: + show_if: "${SHOW_HEADER} != false" title: "System Status" icon: "integrations.example.details.icon" + titleAction: "url:${URL}/status" columns: + user_customizations: + - allow_reorder + - allow_hide iterate_over: "computed.status_summary" - prototype: # Template for each item in the list - title: "${label}" + prototype: + id: "${id}" + label: "${label}" + primary: "${score}%" + primaryAction: "url:${link}" + secondary: "${description}" + title: "${name}" titleAction: "url:${link}" + thumbnail: "${thumbnail}" + icon: + type: "weather" + file: "${icon_file} ??? fa6-solid:circle-info" + size: 32 + description: "${icon_description}" + useFrostedGradient: true progress: type: "circular" value: "${score}" thresholds: - min: 80 color: "#16a34a" + - min: 60 + color: "#f59e0b" - min: 0 color: "#ef4444" + zero_label: "N/A" + badge: + show: true + icon: "fa6-solid:triangle-exclamation" + tooltip: "${warning}" + + - name: "Columns Widget (static array)" + key: "columns-static" + template: "columns" + properties: + header: + show_if: "${SHOW_HEADER}" + title: "Static Columns" + icon: "integrations.example.details.icon" + titleAction: "url:${URL}/static" + columns: + - show_if: "${SHOW_CPU} != false" + id: "cpu" + label: "CPU" + primary: "${computed.metrics.cpu.avg_load}%" + primaryAction: "url:${URL}/cpu" + secondary: "${computed.metrics.cpu.cores} cores" + - show_if: "${SHOW_MEMORY} != false" + id: "memory" + label: "Memory" + icon: + type: "custom" + file: "fa6-solid:memory" + size: 28 + description: "Memory usage" + useFrostedGradient: false + primary: "${computed.metrics.memory.used_percent}%" + secondary: "${computed.metrics.memory.total} total" + badge: + show: true + icon: "fa6-solid:circle-info" + tooltip: "Usage details" + - show_if: "${SHOW_STORAGE} != false" + id: "storage" + label: "Storage" + title: "Disks" + titleAction: "url:${URL}/storage" + thumbnail: "${computed.metrics.storage.thumbnail}" + primary: "${computed.metrics.storage.used_percent}%" + secondary: "${computed.metrics.storage.total} used" + + - name: "Vertical List Widget" + key: "vertical-list-reference" + template: "vertical-list" + depends_on: + - "computed.items" + hide_if_empty: true + refresh_interval: 60 + data: + source: "computed.items" + input: + URL: "${URL}" + API_KEY: "${API_KEY}" + properties: + header: + show_if: "${SHOW_LIST_HEADER} != false" + title: "Recent Items" + icon: "fa6-solid:list" + titleAction: "url:${URL}/items" + list: + iterate_over: "computed.items" + max_items: 8 + # max_visible is also supported and behaves the same as max_items. + max_visible: 8 + sort_by: "updated_at" + sort_order: "desc" + prototype: + accent: + value: "severity" + map: + critical: "#ef4444" + warning: "#f59e0b" + info: "#3b82f6" + icon: + value: "kind" + map: + doc: "fa6-solid:file-lines" + link: "fa6-solid:link" + image: "fa6-solid:image" + title: "${title}" + titleAction: "url:${link}" + subtitle: + type: "list" + value: "${tags}" + thumbnail: "${thumbnail}" + badge: + show: true + icon: "fa6-solid:bell" + tooltip: "${summary}" + + - name: "Item Details Card" + key: "icon-details-card-reference" + template: "icon-details-card" + depends_on: + - "computed.item" + data: + source: "computed.item" + input: + URL: "${URL}" + properties: + header: + show_if: "${SHOW_CARD_HEADER} != false" + title: "Current Item" + icon: "fa6-solid:circle-info" + titleAction: "url:${URL}/item" + icon: + file: "${computed.item.icon} ??? fa6-solid:square" + primary: "${computed.item.name}" + secondary: "${computed.item.description}" + + - name: "Iframe Widget" + key: "iframe-reference" + template: "iframe" + data: + source: "computed.iframe" + input: + URL: "${URL}" + properties: + header: + show_if: "${SHOW_IFRAME_HEADER} != false" + title: "Embedded App" + icon: "fa6-solid:window-restore" + titleAction: "url:${URL}" + url: "${URL}/embed" + min_height: 300 + max_height: 800 # --------------------------------------------------------- # 3. Shortcuts @@ -152,19 +338,14 @@ shortcuts: - "Example" - "${name}" -# --------------------------------------------------------- -# 4. Glanceables -# Defines compact summary items shown in glanceable slots. -# See weather.yaml for a richer example with computed fields and icon rendering. -# --------------------------------------------------------- - glanceables: - - name: "Example Glanceable" - key: "example-glanceable" - properties: - label: "${name}" - value: "${value}" - icon: - file: "${icon} ??? fa6-solid:sun" - size: 16 - description: "${description}" - text: "${value} ??? No data" +glanceables: + - name: "Example Glanceable" + key: "example-glanceable" + properties: + label: "${name}" + value: "${value}" + icon: + file: "${icon} ??? fa6-solid:sun" + size: 16 + description: "${description}" + text: "${value} ??? No data" From 3b76145f485cb1a69ca41348d05c5db1c6a337f4 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Sun, 23 Aug 2026 14:08:57 +0200 Subject: [PATCH 02/57] feat: randomize text in blurred widgets --- .../dashboard/DashboardLayoutTemplate.tsx | 146 ++++++++++++++++-- 1 file changed, 132 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/dashboard/DashboardLayoutTemplate.tsx b/apps/web/src/components/dashboard/DashboardLayoutTemplate.tsx index fd6cf9fc..3c7366ee 100644 --- a/apps/web/src/components/dashboard/DashboardLayoutTemplate.tsx +++ b/apps/web/src/components/dashboard/DashboardLayoutTemplate.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties, ReactNode } from "react"; import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { Icon } from "@iconify-icon/react"; @@ -46,11 +46,129 @@ const WIDGET_SIZE_CLASSNAME: Record = { tall: "h-[360px] overflow-hidden rounded-xl", }; -const PRIVACY_BLUR_LINES = [ - "kJ8fL2pQwR9z", - "xN4vB7mC3sA1", - "tY6uI0oP5dF2", -]; +const PRIVACY_RANDOM_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789"; +const PRIVACY_TEXT_ATTRIBUTES = ["alt", "aria-label", "placeholder", "title", "value"]; + +function randomPrivacyString(length: number) { + const safeLength = Math.max(1, length); + const values = new Uint32Array(safeLength); + + if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { + crypto.getRandomValues(values); + return Array.from(values, (value) => + PRIVACY_RANDOM_CHARS[value % PRIVACY_RANDOM_CHARS.length], + ).join(""); + } + + return Array.from( + { length: safeLength }, + () => PRIVACY_RANDOM_CHARS[Math.floor(Math.random() * PRIVACY_RANDOM_CHARS.length)], + ).join(""); +} + +function randomizePrivacyText(value: string) { + return value.replace(/\S+/g, (token) => randomPrivacyString(token.length)); +} + +function sanitizePrivacyClone(root: HTMLElement) { + const textWalker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const textNodes: Text[] = []; + let currentNode = textWalker.nextNode(); + + while (currentNode) { + const textNode = currentNode as Text; + const parentTagName = textNode.parentElement?.tagName; + if ( + textNode.nodeValue?.trim() && + parentTagName !== "SCRIPT" && + parentTagName !== "STYLE" && + parentTagName !== "NOSCRIPT" + ) { + textNodes.push(textNode); + } + currentNode = textWalker.nextNode(); + } + + for (const textNode of textNodes) { + textNode.nodeValue = randomizePrivacyText(textNode.nodeValue ?? ""); + } + + const elements = [root, ...Array.from(root.querySelectorAll("*"))]; + for (const element of elements) { + element.removeAttribute("id"); + for (const attribute of PRIVACY_TEXT_ATTRIBUTES) { + const value = element.getAttribute(attribute); + if (value?.trim()) element.setAttribute(attribute, randomizePrivacyText(value)); + } + + if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { + if (element.value.trim()) element.value = randomizePrivacyText(element.value); + } + } + + root.setAttribute("aria-hidden", "true"); +} + +function WidgetPrivacyOverlay({ + sourceId, + refreshVersion, +}: { + sourceId: string; + refreshVersion: number; +}) { + const cloneHostRef = useRef(null); + + useLayoutEffect(() => { + const source = document.getElementById(sourceId); + const cloneHost = cloneHostRef.current; + if (!source || !cloneHost) return; + + let frameId: number | null = null; + + const renderPrivacyClone = () => { + cloneHost.replaceChildren(); + const clone = source.cloneNode(true) as HTMLDivElement; + clone.style.visibility = "visible"; + clone.style.width = "100%"; + clone.style.height = "100%"; + clone.style.overflow = "hidden"; + clone.style.filter = "blur(3px)"; + clone.style.userSelect = "none"; + sanitizePrivacyClone(clone); + cloneHost.appendChild(clone); + }; + + const schedulePrivacyClone = () => { + if (frameId !== null) cancelAnimationFrame(frameId); + frameId = requestAnimationFrame(() => { + frameId = null; + renderPrivacyClone(); + }); + }; + + renderPrivacyClone(); + const observer = new MutationObserver(schedulePrivacyClone); + observer.observe(source, { + attributes: true, + childList: true, + characterData: true, + subtree: true, + }); + + return () => { + observer.disconnect(); + if (frameId !== null) cancelAnimationFrame(frameId); + cloneHost.replaceChildren(); + }; + }, [refreshVersion, sourceId]); + + return ( +
+
+
+
+ ); +} const COLUMN_CLASSNAME: Record = { left: @@ -464,6 +582,7 @@ export default function DashboardLayoutTemplate({ }) => { const state = getWidgetMenuState(baseKey); const sizeClass = WIDGET_SIZE_CLASSNAME[state.size]; + const privacySourceId = `dashwise-widget-content-${baseKey}`; return (
-
+
{children}
{darkenOnMenu && state.menuOpen && ( @@ -516,13 +640,7 @@ export default function DashboardLayoutTemplate({
)} {state.blurred && ( -
- {PRIVACY_BLUR_LINES.map((line) => ( -
- {line} -
- ))} -
+ )}
); From f0847fa88e338db3c4445298cafee6a6d9d109f8 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Sun, 23 Aug 2026 14:34:23 +0200 Subject: [PATCH 03/57] feat: add dashboard privacy mode toggle --- .../(authenticated)/settings/search/page.tsx | 32 +++++++++++++++++++ .../dashboard/DashboardLayoutTemplate.tsx | 19 ++++++++--- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/apps/web/src/app/(authenticated)/settings/search/page.tsx b/apps/web/src/app/(authenticated)/settings/search/page.tsx index 929f90e9..0b55eeb5 100644 --- a/apps/web/src/app/(authenticated)/settings/search/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/search/page.tsx @@ -27,6 +27,7 @@ import useAuth from "@/context/useAuth"; export default function SearchSettingsPage() { const { user, updateUserProperty } = useAuth(); const [engines, setEngines] = useState(user.searchPreferences?.searchEngines || []); + const [privacyMode, setPrivacyMode] = useState(user.searchPreferences?.privacyMode === true); const [activeTab, setActiveTab] = useState("manual"); // sync when config changes @@ -34,6 +35,10 @@ export default function SearchSettingsPage() { setEngines(user.searchPreferences?.searchEngines || []); }, [user.searchPreferences?.searchEngines]); + useEffect(() => { + setPrivacyMode(user.searchPreferences?.privacyMode === true); + }, [user.searchPreferences?.privacyMode]); + const handleOpenChange = (open: boolean) => { setCreateOpen(open); if (!open) { @@ -57,6 +62,21 @@ export default function SearchSettingsPage() { } } + async function togglePrivacyMode(enabled: boolean) { + const previousValue = privacyMode; + setPrivacyMode(enabled); + + try { + await updateUserProperty("searchPreferences", { + ...user.searchPreferences, + privacyMode: enabled, + }); + } catch (err) { + setPrivacyMode(previousValue); + console.error("Failed to persist dashboard privacy mode:", err); + } + } + function toggleEngine(slug: string) { const updated = engines.map((e) => { if (e.slug !== slug) return e; @@ -107,6 +127,18 @@ export default function SearchSettingsPage() { <>

Search

+
+
+

Dashboard privacy mode

+

Blur and anonymize text across every widget on dashboard pages.

+
+ +
+

Search engines

diff --git a/apps/web/src/components/dashboard/DashboardLayoutTemplate.tsx b/apps/web/src/components/dashboard/DashboardLayoutTemplate.tsx index 3c7366ee..1ec13be6 100644 --- a/apps/web/src/components/dashboard/DashboardLayoutTemplate.tsx +++ b/apps/web/src/components/dashboard/DashboardLayoutTemplate.tsx @@ -216,7 +216,7 @@ export default function DashboardLayoutTemplate({ }) { const [searchParams] = useSearchParams(); const navigate = useNavigate(); - const { token, withAuth } = useAuth(); + const { token, user, withAuth } = useAuth(); const openFromURL = searchParams.get("search") === "1"; const hasSearchBarWidget = useMemo(() => { const columns = config?.columns as @@ -237,6 +237,7 @@ export default function DashboardLayoutTemplate({ const containerRef = useRef(null); const [activePanel, setActivePanel] = useState(1); const [widgetMenuState, setWidgetMenuState] = useState>({}); + const dashboardPrivacyMode = user?.searchPreferences?.privacyMode === true; const heightRefs = useRef>({}); const heightRefCallbacks = useRef< Record void> @@ -582,6 +583,7 @@ export default function DashboardLayoutTemplate({ }) => { const state = getWidgetMenuState(baseKey); const sizeClass = WIDGET_SIZE_CLASSNAME[state.size]; + const isBlurred = dashboardPrivacyMode || state.blurred; const privacySourceId = `dashwise-widget-content-${baseKey}`; return (
{children}
@@ -631,15 +633,22 @@ export default function DashboardLayoutTemplate({ Resize widget - toggleWidgetBlur(baseKey)}> + toggleWidgetBlur(baseKey)} + > - {state.blurred ? "Unblur widget" : "Blur widget"} + {dashboardPrivacyMode + ? "Privacy mode enabled" + : state.blurred + ? "Unblur widget" + : "Blur widget"}
)} - {state.blurred && ( + {isBlurred && ( )}
From 84ee6150738bbe31db04421370a4c7737fc36b73 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Sun, 23 Aug 2026 16:55:39 +0200 Subject: [PATCH 04/57] docs: correct configuration settings --- docs/Configuration.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/Configuration.md b/docs/Configuration.md index 77e2852f..d1da3d53 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -7,9 +7,9 @@ You can use the following environment variables for the all-in-one container; th | Name | Required | Default Value | Description | | --- | --- | --- | --- | | INSTANCE_NAME / NEXT_PUBLIC_INSTANCE_NAME | No | Dashwise | The dashboard's display name | -| PB_URL / NEXT_PUBLIC_PB_URL | No (if start pocketbase is true) | `http://127.0.0.1:8090` | PocketBase URL. Backend uses `PB_URL`, frontend uses `NEXT_PUBLIC_PB_URL` | +| PB_URL / NEXT_PUBLIC_PB_URL | No (if start pocketbase is true) | `http://127.0.0.1:8090` | PocketBase URL. The backend accepts either name (`PB_URL` takes precedence); the frontend uses `NEXT_PUBLIC_PB_URL`. | | START_POCKETBASE | No | `true` | Start the bundled PocketBase process; set to `false` to use an external instance | -| PB_BINARY_PATH | No | - | Path to PocketBase binary (default: `pocketbase/pocketbase`) | +| PB_BINARY_PATH | No | - | Path to an existing, executable PocketBase binary. Required when `START_POCKETBASE=true`; the application has no fallback path. The Docker image supplies `/usr/local/bin/pocketbase`. | | PORT | No | `3000` | HTTP port for the backend server | | ENVIRONMENT | No | `production` | Set to `dev` for development-only behavior | @@ -38,7 +38,7 @@ You can use the following environment variables for the all-in-one container; th | Name | Required | Default Value | Description | | --- | --- | --- | --- | -| NEXT_PUBLIC_DEFAULT_BG_URL / DEFAULT_BG_URL | No | `/dashboard-wallpaper.png` | Default background URL for new users | +| NEXT_PUBLIC_DEFAULT_BG_URL | No | `/dashboard-wallpaper.png` | Default background URL for new users | | NEXT_PUBLIC_ENABLE_SSO / ENABLE_SSO | No | `false` | Enable Single Sign-On (SSO) via OIDC | | NEXT_PUBLIC_DISABLE_USER_SIGNUP / DISABLE_USER_SIGNUP | No | `false` | Disable user self-registration | @@ -55,7 +55,7 @@ You can use the following environment variables for the all-in-one container; th | Name | Required | Default Value | Description | | --- | --- | --- | --- | | JOBS_URL / NEXT_PUBLIC_JOBS_URL | No | `http://127.0.0.1:3001` | URL of the jobs service | -| JOBS_WEBHOOK_ENABLE / NEXT_PUBLIC_JOBS_WEBHOOK_ENABLE | No | `false` | Explicitly enable the jobs webhook. Set to `1` or `true` to force-enable | +| JOBS_WEBHOOK_ENABLE / NEXT_PUBLIC_JOBS_WEBHOOK_ENABLE | No | `false` | Enable the jobs webhook when set to `1` or `true`. Setting either jobs URL variable also enables the webhook, so `false` cannot disable it while `JOBS_URL` or `NEXT_PUBLIC_JOBS_URL` is set. | | JOBS_WEBHOOK_URL | No | `http://jobs:3000/api/forward-notifications` | Webhook URL for forwarding notifications to jobs | | JOBS_MONITORING_RETRY_AFTER | No | `5000` | Time in milliseconds to wait before retrying a failed monitoring ping | @@ -64,14 +64,16 @@ You can use the following environment variables for the all-in-one container; th | Name | Required | Default Value | Description | | --- | --- | --- | --- | | SEARCHITEMS_SCHEDULE | No | `*/10 * * * *` | Interval for search item indexing job | -| ENABLE_ICONS_REFRESH | No | `false` | Enable automatic icon refresh job | -| PULL_ICONS_SCHEDULE | No | `0 */6 * * *` | How often the icons refresh job runs | +| ENABLE_ICONS_REFRESH | No | `false` | Enable automatic icon refresh job. Only the literal value `true` enables it; `1` does not. | +| PULL_ICONS_SCHEDULE | No | `0 */18 * * *` | How often the icons refresh job runs | | MONITORING_INDEXER_SCHEDULE | No | `*/10 * * * *` | How often the monitoring indexer runs | | MONITORING_RUNNER_SCHEDULE | No | `*/1 * * * *` | How often the monitoring runner executes | | UPDATE_CHECK_SCHEDULE | No | `0 2 * * *` | Schedule for update check job | | FEED_BUILDING_SCHEDULE | No | `*/30 * * * *` | Schedule for news feed building job | | NOTIFICATION_FORWARDER_SCHEDULE | No | `* * * * *` | Schedule for notification forwarder job | | DEFAULT_INTEGRATIONS_SCHEDULE | No | `0 4 * * *` | Schedule for default integrations sync | -| PAGECONFIG_CLEANUP_SCHEDULE | No | `0 5 * * *` | Schedule for page config cleanup | +| PAGECONFIG_CLEANUP_SCHEDULE | No | - | Deprecated/no-op. Page-config cleanup runs immediately when an integration is deleted; this variable is read for compatibility but does not schedule work. | | MONITORING_OUTLIER_THRESHOLD_TYPE | No | `relative` | Threshold type for monitoring outliers (`absolute` or `relative`) | -| MONITORING_OUTLIER_THRESHOLD_VALUE | No | `50` | Threshold value for monitoring outliers | +| MONITORING_OUTLIER_THRESHOLD_VALUE | No | `50` (`500` when type is `absolute`) | Threshold value for monitoring outliers. Must be a positive number; invalid or missing values use `50` for `relative` and `500` for `absolute`. | + +`DEFAULT_BG_URL` is deprecated/no-op on the backend. Use `NEXT_PUBLIC_DEFAULT_BG_URL` for the frontend background setting. From 9bdd31c905c792466d1e355c237bde27eb70a3bf Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Mon, 24 Aug 2026 10:59:50 +0200 Subject: [PATCH 05/57] Toggle privacy mode from search bar --- apps/backend/src/jobs/search-indexer.ts | 10 ++ .../(authenticated)/settings/search/page.tsx | 32 ----- .../dashboard/DashboardLayoutTemplate.tsx | 111 ++++++++++++------ .../web/src/components/widgets/CommandBar.tsx | 22 +++- apps/web/src/context/useAuth.tsx | 24 +++- 5 files changed, 124 insertions(+), 75 deletions(-) diff --git a/apps/backend/src/jobs/search-indexer.ts b/apps/backend/src/jobs/search-indexer.ts index 70ff60b0..e11feb4b 100644 --- a/apps/backend/src/jobs/search-indexer.ts +++ b/apps/backend/src/jobs/search-indexer.ts @@ -172,6 +172,16 @@ function buildDefaultShortcutSearchRows(): SearchItemRow[] { sourceId: "default-shortcut:logout", }); + rows.push({ + name: "Toggle privacy mode", + icon: "fa6-solid:eye-slash", + secondary: "Dashwise", + action: "privacy:", + app: "", + tags: ["privacy", "privacy mode", "toggle privacy", "dashboard privacy"], + sourceId: "default-shortcut:privacy", + }); + return rows; } diff --git a/apps/web/src/app/(authenticated)/settings/search/page.tsx b/apps/web/src/app/(authenticated)/settings/search/page.tsx index 0b55eeb5..929f90e9 100644 --- a/apps/web/src/app/(authenticated)/settings/search/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/search/page.tsx @@ -27,7 +27,6 @@ import useAuth from "@/context/useAuth"; export default function SearchSettingsPage() { const { user, updateUserProperty } = useAuth(); const [engines, setEngines] = useState(user.searchPreferences?.searchEngines || []); - const [privacyMode, setPrivacyMode] = useState(user.searchPreferences?.privacyMode === true); const [activeTab, setActiveTab] = useState("manual"); // sync when config changes @@ -35,10 +34,6 @@ export default function SearchSettingsPage() { setEngines(user.searchPreferences?.searchEngines || []); }, [user.searchPreferences?.searchEngines]); - useEffect(() => { - setPrivacyMode(user.searchPreferences?.privacyMode === true); - }, [user.searchPreferences?.privacyMode]); - const handleOpenChange = (open: boolean) => { setCreateOpen(open); if (!open) { @@ -62,21 +57,6 @@ export default function SearchSettingsPage() { } } - async function togglePrivacyMode(enabled: boolean) { - const previousValue = privacyMode; - setPrivacyMode(enabled); - - try { - await updateUserProperty("searchPreferences", { - ...user.searchPreferences, - privacyMode: enabled, - }); - } catch (err) { - setPrivacyMode(previousValue); - console.error("Failed to persist dashboard privacy mode:", err); - } - } - function toggleEngine(slug: string) { const updated = engines.map((e) => { if (e.slug !== slug) return e; @@ -127,18 +107,6 @@ export default function SearchSettingsPage() { <>

Search

-
-
-

Dashboard privacy mode

-

Blur and anonymize text across every widget on dashboard pages.

-
- -
-

Search engines

diff --git a/apps/web/src/components/dashboard/DashboardLayoutTemplate.tsx b/apps/web/src/components/dashboard/DashboardLayoutTemplate.tsx index 1ec13be6..b83a7276 100644 --- a/apps/web/src/components/dashboard/DashboardLayoutTemplate.tsx +++ b/apps/web/src/components/dashboard/DashboardLayoutTemplate.tsx @@ -70,51 +70,79 @@ function randomizePrivacyText(value: string) { return value.replace(/\S+/g, (token) => randomPrivacyString(token.length)); } -function sanitizePrivacyClone(root: HTMLElement) { - const textWalker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); - const textNodes: Text[] = []; - let currentNode = textWalker.nextNode(); - - while (currentNode) { - const textNode = currentNode as Text; - const parentTagName = textNode.parentElement?.tagName; - if ( - textNode.nodeValue?.trim() && - parentTagName !== "SCRIPT" && - parentTagName !== "STYLE" && - parentTagName !== "NOSCRIPT" - ) { - textNodes.push(textNode); +function sanitizePrivacyClone(root: HTMLElement, targetSelector?: string) { + const privacyRoots = targetSelector + ? Array.from(root.querySelectorAll(targetSelector)) + : [root]; + + for (const privacyRoot of privacyRoots) { + const textWalker = document.createTreeWalker(privacyRoot, NodeFilter.SHOW_TEXT); + const textNodes: Text[] = []; + let currentNode = textWalker.nextNode(); + + while (currentNode) { + const textNode = currentNode as Text; + const parentTagName = textNode.parentElement?.tagName; + if ( + textNode.nodeValue?.trim() && + parentTagName !== "SCRIPT" && + parentTagName !== "STYLE" && + parentTagName !== "NOSCRIPT" + ) { + textNodes.push(textNode); + } + currentNode = textWalker.nextNode(); } - currentNode = textWalker.nextNode(); - } - for (const textNode of textNodes) { - textNode.nodeValue = randomizePrivacyText(textNode.nodeValue ?? ""); - } + for (const textNode of textNodes) { + const randomizedText = randomizePrivacyText(textNode.nodeValue ?? ""); + textNode.nodeValue = randomizedText; - const elements = [root, ...Array.from(root.querySelectorAll("*"))]; - for (const element of elements) { - element.removeAttribute("id"); - for (const attribute of PRIVACY_TEXT_ATTRIBUTES) { - const value = element.getAttribute(attribute); - if (value?.trim()) element.setAttribute(attribute, randomizePrivacyText(value)); + const parent = textNode.parentElement; + if (!parent) { + continue; + } + + if (parent.namespaceURI === "http://www.w3.org/2000/svg") { + if (["text", "tspan"].includes(parent.tagName.toLowerCase())) { + parent.style.filter = "blur(3px)"; + } + continue; + } + + const textSpan = document.createElement("span"); + textSpan.textContent = randomizedText; + textSpan.style.filter = "blur(3px)"; + textNode.replaceWith(textSpan); } - if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { - if (element.value.trim()) element.value = randomizePrivacyText(element.value); + const elements = [privacyRoot, ...Array.from(privacyRoot.querySelectorAll("*"))]; + for (const element of elements) { + for (const attribute of PRIVACY_TEXT_ATTRIBUTES) { + const value = element.getAttribute(attribute); + if (value?.trim()) element.setAttribute(attribute, randomizePrivacyText(value)); + } + + if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { + if (element.value.trim()) element.value = randomizePrivacyText(element.value); + } } } + for (const element of [root, ...Array.from(root.querySelectorAll("*"))]) { + element.removeAttribute("id"); + } root.setAttribute("aria-hidden", "true"); } function WidgetPrivacyOverlay({ sourceId, refreshVersion, + targetSelector, }: { sourceId: string; refreshVersion: number; + targetSelector?: string; }) { const cloneHostRef = useRef(null); @@ -132,9 +160,8 @@ function WidgetPrivacyOverlay({ clone.style.width = "100%"; clone.style.height = "100%"; clone.style.overflow = "hidden"; - clone.style.filter = "blur(3px)"; clone.style.userSelect = "none"; - sanitizePrivacyClone(clone); + sanitizePrivacyClone(clone, targetSelector); cloneHost.appendChild(clone); }; @@ -160,12 +187,11 @@ function WidgetPrivacyOverlay({ if (frameId !== null) cancelAnimationFrame(frameId); cloneHost.replaceChildren(); }; - }, [refreshVersion, sourceId]); + }, [refreshVersion, sourceId, targetSelector]); return ( -
-
-
+
+
); } @@ -571,6 +597,7 @@ export default function DashboardLayoutTemplate({ showMenu = true, onEditProperties, darkenOnMenu = false, + privacyTargetSelector, }: { baseKey: string; wrapperClass: string; @@ -580,10 +607,12 @@ export default function DashboardLayoutTemplate({ showMenu?: boolean; onEditProperties?: () => void; darkenOnMenu?: boolean; + privacyTargetSelector?: string | false; }) => { const state = getWidgetMenuState(baseKey); const sizeClass = WIDGET_SIZE_CLASSNAME[state.size]; - const isBlurred = dashboardPrivacyMode || state.blurred; + const privacyBlurred = dashboardPrivacyMode && privacyTargetSelector !== false; + const isBlurred = privacyBlurred || state.blurred; const privacySourceId = `dashwise-widget-content-${baseKey}`; return (
)} {isBlurred && ( - + )}
); @@ -699,6 +732,7 @@ export default function DashboardLayoutTemplate({ wrapperClass, ref, showMenu: false, + privacyTargetSelector: ".area-gl1, .area-gl2", children: renderWidget({ type: "main-clock", params: cfg, @@ -713,6 +747,7 @@ export default function DashboardLayoutTemplate({ baseKey, wrapperClass, showMenu: false, + privacyTargetSelector: false, children: renderWidget({ type: "search-bar", defaultOpen: openFromURL ?? false, @@ -725,6 +760,7 @@ export default function DashboardLayoutTemplate({ baseKey, wrapperClass, showMenu: false, + privacyTargetSelector: false, children: renderWidget({ type: "link-view", }), @@ -740,6 +776,9 @@ export default function DashboardLayoutTemplate({ ? () => editWidgetProperties(columnName, entryKey, entryIndex) : undefined, darkenOnMenu: entryKey === "image", + privacyTargetSelector: entryKey === "glanceable-clock" + ? ".area-gl1, .area-gl2" + : undefined, children: renderWidget({ type: entryKey, consumerKey: typeof cfg.configKey === "string" && cfg.configKey.trim() diff --git a/apps/web/src/components/widgets/CommandBar.tsx b/apps/web/src/components/widgets/CommandBar.tsx index 782656a8..ec8d4230 100644 --- a/apps/web/src/components/widgets/CommandBar.tsx +++ b/apps/web/src/components/widgets/CommandBar.tsx @@ -100,6 +100,8 @@ function normalizeConfigLinks(input: IncomingSearchItem[] = []): LinkItem[] { proxyAction = true; } else if (action.toLowerCase().startsWith("logout:")) { url = "__logout_action__"; + } else if (action.toLowerCase().startsWith("privacy:")) { + url = "__privacy_action__"; } else if (action.startsWith("url:")) { url = action.slice(4); } else if (action.startsWith("command:")) { @@ -143,7 +145,7 @@ function normalizeConfigLinks(input: IncomingSearchItem[] = []): LinkItem[] { export default function CommandBar( { open, setOpen, searchItems, config }: CommandBarProps, ) { - const { user, token, logout } = useAuth(); + const { user, token, logout, updateUserProperty } = useAuth(); const searchPreferences = user?.searchPreferences ?? {}; const searchEngines: SearchEngine[] = (searchPreferences.searchEngines || []) as SearchEngine[]; @@ -545,6 +547,9 @@ export default function CommandBar( logSearchItemUsage(a); logout(); setOpen(false); + } else if (a.url === "__privacy_action__") { + logSearchItemUsage(a); + void togglePrivacyMode(); } else if (a.url.startsWith("__engine_search__:")) { const slug = a.url.split(":", 2)[1]; openEngineSearch(slug, query); @@ -557,6 +562,21 @@ export default function CommandBar( } } + async function togglePrivacyMode() { + const privacyMode = user?.searchPreferences?.privacyMode === true; + + try { + await updateUserProperty("searchPreferences", { + ...user?.searchPreferences, + privacyMode: !privacyMode, + }); + } catch (error) { + console.error("Failed to toggle dashboard privacy mode:", error); + } finally { + setOpen(false); + } + } + function logSearchItemUsage(item?: LinkItem) { if (!token || !item?.id) return; void logSearchItemUsageAction({ token }, item.id, new Date().toISOString()).catch(() => {}); diff --git a/apps/web/src/context/useAuth.tsx b/apps/web/src/context/useAuth.tsx index 2f7ef857..ebb064fe 100644 --- a/apps/web/src/context/useAuth.tsx +++ b/apps/web/src/context/useAuth.tsx @@ -10,6 +10,7 @@ type AuthUser = AuthUserRecord; const EMPTY_AUTH_USER: AuthUser = {}; export const COMMAND_BAR_OPEN_EVENT = "dashwise:open-command-bar"; +export const AUTH_USER_UPDATED_EVENT = "dashwise:auth-user-updated"; function createUnauthorizedError() { const error = new Error("Unauthorized") as Error & { status: number; body: { error: string } }; @@ -54,21 +55,30 @@ export function useAuth() { }); useEffect(() => { + const syncStoredUser = () => { + try { + const raw = localStorage.getItem("pb_user"); + setUser(raw ? (JSON.parse(raw) as AuthUser) : EMPTY_AUTH_USER); + } catch { + setUser(EMPTY_AUTH_USER); + } + }; + const onStorage = (e: StorageEvent) => { if (e.key === "pb_user") { - try { - setUser(e.newValue ? (JSON.parse(e.newValue) as AuthUser) : EMPTY_AUTH_USER); - } catch (err) { - setUser(EMPTY_AUTH_USER); - } + syncStoredUser(); } if (e.key === "pb_token") { setToken(e.newValue); } }; + window.addEventListener(AUTH_USER_UPDATED_EVENT, syncStoredUser); window.addEventListener("storage", onStorage); - return () => window.removeEventListener("storage", onStorage); + return () => { + window.removeEventListener(AUTH_USER_UPDATED_EVENT, syncStoredUser); + window.removeEventListener("storage", onStorage); + }; }, []); const setAuth = useCallback((u: AuthUser | null, t?: string | null) => { @@ -88,6 +98,8 @@ export function useAuth() { localStorage.setItem("pb_token", t); setToken(t); } + + window.dispatchEvent(new Event(AUTH_USER_UPDATED_EVENT)); } catch (err) { // ignore storage errors } From 9b9c5e9945e94b9e89e0c9c52c0e2d0eba7bed11 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Mon, 24 Aug 2026 12:14:01 +0200 Subject: [PATCH 06/57] feat(news): add feed hierarchy --- .../src/jobs/news/feed-builder.test.ts | 19 ++++ apps/backend/src/jobs/news/feed-builder.ts | 50 ++++++++-- apps/backend/src/lib/data/news.ts | 46 ++++++++- .../src/components/apps/LayoutTemplate.tsx | 95 +++++++++++++------ .../web/src/components/news/NewsDashboard.tsx | 7 ++ .../src/components/news/NewsFeedEditModal.tsx | 57 ++++++++++- apps/web/src/components/news/NewsLayout.tsx | 82 ++++++++++++---- apps/web/src/components/news/NewsOverview.tsx | 4 +- packages/types/pocketbase/pocketbase-types.ts | 1 + packages/types/sdk-types.ts | 4 + .../1785000002_add_included_feed_refs.js | 22 +++++ 11 files changed, 327 insertions(+), 60 deletions(-) create mode 100644 pocketbase/migrations/1785000002_add_included_feed_refs.js diff --git a/apps/backend/src/jobs/news/feed-builder.test.ts b/apps/backend/src/jobs/news/feed-builder.test.ts index 8756b358..6a46197a 100644 --- a/apps/backend/src/jobs/news/feed-builder.test.ts +++ b/apps/backend/src/jobs/news/feed-builder.test.ts @@ -70,6 +70,25 @@ describe("materialized news selection", () => { expect(selectNewsFeedSubscriptions(subs, custom).map((entry) => entry.id)).toEqual(["two"]); }); + test("includes nested feed subscriptions and keeps child exclusions", () => { + const subs = [subscription("one"), subscription("two"), subscription("three")]; + const child: NewsFeedRecord = { + id: "child", + title: "Child", + subscriptionRefs: ["one", "two"], + excludedSubscriptionRefs: ["two"], + }; + const parent: NewsFeedRecord = { + id: "parent", + title: "Parent", + subscriptionRefs: ["three"], + includedFeedRefs: ["child"], + excludedSubscriptionRefs: [], + }; + + expect(selectNewsFeedSubscriptions(subs, parent, [parent, child]).map((entry) => entry.id)).toEqual(["one", "three"]); + }); + test("groups topics only after exact article deduplication", () => { const subs = [subscription("one"), subscription("two")]; const items = deduplicateUserArticles([ diff --git a/apps/backend/src/jobs/news/feed-builder.ts b/apps/backend/src/jobs/news/feed-builder.ts index 0bee88ba..8801b90c 100644 --- a/apps/backend/src/jobs/news/feed-builder.ts +++ b/apps/backend/src/jobs/news/feed-builder.ts @@ -31,6 +31,7 @@ export type NewsFeedRecord = { feedType?: "all" | "custom" | string; systemKey?: string; subscriptionRefs?: string[]; + includedFeedRefs?: string[]; excludedSubscriptionRefs?: string[]; maxFeedItems?: number; [key: string]: unknown; @@ -127,6 +128,7 @@ function normalizeFeed(record: Record): NewsFeedRecord | null { feedType: record.feedType ? String(record.feedType) : undefined, systemKey: record.systemKey ? String(record.systemKey) : undefined, subscriptionRefs: Array.isArray(record.subscriptionRefs) ? record.subscriptionRefs.map(String) : [], + includedFeedRefs: Array.isArray(record.includedFeedRefs) ? record.includedFeedRefs.map(String) : [], excludedSubscriptionRefs: Array.isArray(record.excludedSubscriptionRefs) ? record.excludedSubscriptionRefs.map(String) : [], }; } @@ -168,12 +170,29 @@ export function deduplicateUserArticles( export function selectNewsFeedSubscriptions( userSubscriptions: NewsSubscription[], record: NewsFeedRecord | null, + feeds: NewsFeedRecord[] = [], ) { const exclusions = new Set((record?.excludedSubscriptionRefs || []).map(String)); if (isAllNewsFeed(record)) { return userSubscriptions.filter((subscription) => subscription.id && !exclusions.has(String(subscription.id))); } - const refs = new Set((record?.subscriptionRefs || []).map(String)); + const feedsById = new Map(feeds.map((feed) => [String(feed.id), feed])); + const refs = new Set(); + const visited = new Set(); + const collectRefs = (current: NewsFeedRecord | null) => { + if (!current) return; + const currentId = String(current.id || ""); + if (currentId && visited.has(currentId)) return; + if (currentId) visited.add(currentId); + const currentExclusions = new Set((current.excludedSubscriptionRefs || []).map(String)); + for (const subscriptionId of current.subscriptionRefs || []) { + if (!currentExclusions.has(String(subscriptionId))) refs.add(String(subscriptionId)); + } + for (const childId of current.includedFeedRefs || []) { + collectRefs(feedsById.get(String(childId)) || null); + } + }; + collectRefs(record); return userSubscriptions.filter((subscription) => subscription.id && refs.has(String(subscription.id)) && !exclusions.has(String(subscription.id))); } @@ -205,6 +224,7 @@ async function buildUserFeed( feedId: string, record: NewsFeedRecord | null, allSubscriptions: NewsSubscription[], + userFeeds: NewsFeedRecord[], sourceRevision: string, result: { errors: number; updated: number; details: any[] }, ) { @@ -222,7 +242,7 @@ async function buildUserFeed( excludedSubscriptionRefs: [], } : record; - const selectedSubscriptions = selectNewsFeedSubscriptions(userSubscriptions, effectiveRecord); + const selectedSubscriptions = selectNewsFeedSubscriptions(userSubscriptions, effectiveRecord, userFeeds); const maxFeedItems = normalizeMaxFeedItems(effectiveRecord?.maxFeedItems); try { @@ -294,7 +314,14 @@ export async function newsFeedBuilder(feedId?: string, options: BuilderOptions = normalizeFeed(await getNewsFeedById(requestedId).catch(() => null) as Record || {}); if (targetFeed?.userId) targetUserIds.add(targetFeed.userId); if (targetFeed && !isAllNewsFeed(targetFeed)) { - for (const id of targetFeed.subscriptionRefs || []) targetSubscriptionIds.add(String(id)); + const targetSubscriptions = selectNewsFeedSubscriptions( + allSubscriptions.filter((entry) => !entry.userId || entry.userId === targetFeed.userId), + targetFeed, + feeds.filter((entry) => entry.userId === targetFeed.userId), + ); + for (const subscription of targetSubscriptions) { + if (subscription.id) targetSubscriptionIds.add(String(subscription.id)); + } } if (targetFeed && isAllNewsFeed(targetFeed) && targetFeed.userId) { for (const subscription of allSubscriptions.filter((entry) => !entry.userId || entry.userId === targetFeed.userId)) { @@ -315,8 +342,14 @@ export async function newsFeedBuilder(feedId?: string, options: BuilderOptions = } for (const feed of feeds) { if (!feed.userId) continue; - const refs = new Set(feed.subscriptionRefs || []); - if (isAllNewsFeed(feed) || Array.from(targetSubscriptionIds).some((id) => refs.has(id))) affectedUsers.add(feed.userId); + const selectedSubscriptions = selectNewsFeedSubscriptions( + allSubscriptions.filter((entry) => !entry.userId || entry.userId === feed.userId), + feed, + feeds.filter((entry) => entry.userId === feed.userId), + ); + if (isAllNewsFeed(feed) || selectedSubscriptions.some((subscription) => targetSubscriptionIds.has(String(subscription.id)))) { + affectedUsers.add(feed.userId); + } } if (!requestedIds.length && !options.userId) { for (const feed of feeds) if (feed.userId) affectedUsers.add(feed.userId); @@ -325,9 +358,9 @@ export async function newsFeedBuilder(feedId?: string, options: BuilderOptions = for (const userId of affectedUsers) { const userFeeds = feeds.filter((feed) => feed.userId === userId); const allFeed = userFeeds.find((feed) => isAllNewsFeed(feed)) || null; - await buildUserFeed(userId, "all", allFeed, allSubscriptions, sourceRevision, result); + await buildUserFeed(userId, "all", allFeed, allSubscriptions, userFeeds, sourceRevision, result); for (const feed of userFeeds.filter((entry) => !isAllNewsFeed(entry))) { - await buildUserFeed(userId, String(feed.id), feed, allSubscriptions, sourceRevision, result); + await buildUserFeed(userId, String(feed.id), feed, allSubscriptions, userFeeds, sourceRevision, result); } for (const subscription of allSubscriptions.filter((entry) => !entry.userId || entry.userId === userId)) { if (!subscription.id) continue; @@ -336,8 +369,9 @@ export async function newsFeedBuilder(feedId?: string, options: BuilderOptions = title: String(subscription.title || subscription.name || subscription.url || "Subscription"), feedType: "custom", subscriptionRefs: [String(subscription.id)], + includedFeedRefs: [], excludedSubscriptionRefs: [], - }, allSubscriptions, sourceRevision, result); + }, allSubscriptions, userFeeds, sourceRevision, result); } } diff --git a/apps/backend/src/lib/data/news.ts b/apps/backend/src/lib/data/news.ts index 7563e1c7..556c8187 100644 --- a/apps/backend/src/lib/data/news.ts +++ b/apps/backend/src/lib/data/news.ts @@ -461,7 +461,7 @@ export function normalizeSubscription(entry: Record | null): Ne async function getUserFeeds(userId: string): Promise { const feeds = await getNewsFeedsByUserId(userId, 2000, { - fields: "id,userId,subscriptionRefs,title,icon,excludedSubscriptionRefs,maxFeedItems,feedType,systemKey", + fields: "id,userId,subscriptionRefs,includedFeedRefs,title,icon,excludedSubscriptionRefs,maxFeedItems,feedType,systemKey", }); return Array.isArray(feeds) ? feeds as NewsFeedRecord[] : []; } @@ -475,6 +475,9 @@ function buildFeedList(feeds: NewsFeedRecord[]) { id: String(feed.id), title: String(feed.title || "Untitled feed"), icon: String(feed.icon || "").trim(), + includedFeedRefs: Array.isArray(feed.includedFeedRefs) + ? feed.includedFeedRefs.map(String).filter(Boolean) + : [], })), ]; } @@ -570,6 +573,9 @@ function normalizeFeedRecord(entry: Record | null): NewsFeedRec excludedSubscriptionRefs: Array.isArray(entry.excludedSubscriptionRefs) ? entry.excludedSubscriptionRefs.map((value) => String(value).trim()).filter(Boolean) : [], + includedFeedRefs: Array.isArray(entry.includedFeedRefs) + ? entry.includedFeedRefs.map((value) => String(value).trim()).filter(Boolean) + : [], maxFeedItems: normalizeMaxFeedItems(entry.maxFeedItems), feedType: entry.feedType ? String(entry.feedType) : undefined, systemKey: entry.systemKey ? String(entry.systemKey) : undefined, @@ -604,6 +610,7 @@ export async function getNewsFeedRecord(userId: string, feedId: string): Promise feedType: "all", systemKey: "all", subscriptionRefs: [], + includedFeedRefs: [], excludedSubscriptionRefs: [], maxFeedItems: 200, }; @@ -649,6 +656,7 @@ export async function updateNewsFeedRecordForUser( systemKey: "all", ...(icon === undefined ? {} : { icon }), subscriptionRefs: [], + includedFeedRefs: [], excludedSubscriptionRefs, maxFeedItems, }); @@ -663,6 +671,7 @@ export async function updateNewsFeedRecordForUser( systemKey: "all", ...(icon === undefined ? {} : { icon }), subscriptionRefs: [], + includedFeedRefs: [], excludedSubscriptionRefs, maxFeedItems, }); @@ -676,11 +685,43 @@ export async function updateNewsFeedRecordForUser( const ownerId = String((feedRecord as Record).userId ?? "").trim(); if (ownerId && ownerId !== userId) return null; + const requestedIncludedFeedRefs = Array.from( + new Set((payload.includedFeedRefs ?? (feedRecord.includedFeedRefs ?? [])) + .map((value: string) => String(value).trim()).filter(Boolean)), + ); + + const userFeeds = await getUserFeeds(userId); + const availableFeedIds = new Set(userFeeds + .filter((feed) => !isAllNewsFeed(feed) && String(feed.id) !== normalizedFeedId) + .map((feed) => String(feed.id))); + const includedFeedRefs = requestedIncludedFeedRefs.filter((feedId) => availableFeedIds.has(feedId)); + const feedById = new Map(userFeeds.map((feed) => [String(feed.id), feed])); + const visiting = new Set(); + const visited = new Set(); + const hasCycle = (currentId: string): boolean => { + if (visiting.has(currentId)) return true; + if (visited.has(currentId)) return false; + visiting.add(currentId); + const current = currentId === normalizedFeedId + ? { includedFeedRefs } + : feedById.get(currentId); + for (const childId of current?.includedFeedRefs ?? []) { + if (childId === "all" || hasCycle(String(childId))) return true; + } + visiting.delete(currentId); + visited.add(currentId); + return false; + }; + if (hasCycle(normalizedFeedId)) { + throw new Error("Feed hierarchy cannot contain cycles"); + } + const updated = await updateNewsFeedRecord(normalizedFeedId, { title: title || String((feedRecord as Record).title ?? "").trim(), feedType: "custom", ...(icon === undefined ? {} : { icon }), subscriptionRefs, + includedFeedRefs, excludedSubscriptionRefs, maxFeedItems, }); @@ -703,6 +744,7 @@ export async function createNewsFeedRecordForUser( feedType: "all", systemKey: "all", subscriptionRefs: [], + includedFeedRefs: [], excludedSubscriptionRefs: [], maxFeedItems: 200, }) as Promise; @@ -719,6 +761,7 @@ export async function createNewsFeedRecordForUser( feedType: "custom", icon: String(payload.icon ?? "").trim(), subscriptionRefs: [], + includedFeedRefs: [], excludedSubscriptionRefs: [], maxFeedItems: 200, })) as NewsFeedRecord; @@ -752,6 +795,7 @@ async function syncSubscriptionFeedRefs( title, feedType: "custom", subscriptionRefs: [subscriptionId], + includedFeedRefs: [], excludedSubscriptionRefs: [], })) as NewsFeedRecord; selectedIds.add(String(createdFeed.id)); diff --git a/apps/web/src/components/apps/LayoutTemplate.tsx b/apps/web/src/components/apps/LayoutTemplate.tsx index fb9788a7..d75f8ea6 100644 --- a/apps/web/src/components/apps/LayoutTemplate.tsx +++ b/apps/web/src/components/apps/LayoutTemplate.tsx @@ -52,6 +52,11 @@ interface TabProps { badge?: number | string; hasError?: boolean; dropdownActions?: DropdownAction[]; + depth?: number; + expandable?: boolean; + expanded?: boolean; + onToggleExpand?: () => void; + onOpen?: () => void; } interface BottomTabProps { @@ -198,7 +203,7 @@ export default function AppTemplate({ // ─── Tab ───────────────────────────────────────────────────────────────────── -export function Tab({ dst, icon, title, group, isRoot, fallbackIcon, badge, hasError, dropdownActions }: TabProps) { +export function Tab({ dst, icon, title, group, isRoot, fallbackIcon, badge, hasError, dropdownActions, depth = 0, expandable, expanded, onToggleExpand, onOpen }: TabProps) { const { pathname, search, closeMobileSidebar } = useContext(SidebarContext); const destination = new URL(dst, "http://dashwise.local"); const isActive = destination.search @@ -209,37 +214,65 @@ export function Tab({ dst, icon, title, group, isRoot, fallbackIcon, badge, hasE return (
-
- closeMobileSidebar?.()} - > - - +
+ closeMobileSidebar?.()} > - {title} - - + {depth > 0 && ( +
{badge !== undefined && ( diff --git a/apps/web/src/components/news/NewsDashboard.tsx b/apps/web/src/components/news/NewsDashboard.tsx index 58d647f0..cdb22516 100644 --- a/apps/web/src/components/news/NewsDashboard.tsx +++ b/apps/web/src/components/news/NewsDashboard.tsx @@ -385,6 +385,7 @@ export default function NewsDashboardComponent() { id: editFeedRef, title: feedSummary?.title || (editFeedRef === "all" ? "All feed" : "Untitled feed"), subscriptionRefs: derivedSubscriptionRefs, + includedFeedRefs: feedSummary?.includedFeedRefs ?? [], excludedSubscriptionRefs: [], }; @@ -967,6 +968,12 @@ export default function NewsDashboardComponent() { id: String(subscription.id), title: String(subscription.title || subscription.name || subscription.url || "Untitled subscription"), }))} + feeds={(feeds ?? []) + .filter((feed) => feed.id !== "all" && feed.id !== editFeedRef) + .map((feed) => ({ + id: feed.id, + title: String(feed.title || "Untitled feed"), + }))} onClose={closeFeedEditor} onSave={async (payload) => { await updateFeedRecordMutation.mutateAsync(payload); diff --git a/apps/web/src/components/news/NewsFeedEditModal.tsx b/apps/web/src/components/news/NewsFeedEditModal.tsx index 77f46225..be7e5932 100644 --- a/apps/web/src/components/news/NewsFeedEditModal.tsx +++ b/apps/web/src/components/news/NewsFeedEditModal.tsx @@ -14,10 +14,16 @@ interface SubscriptionOption { title: string; } +interface FeedOption { + id: string; + title: string; +} + interface NewsFeedEditModalProps { open: boolean; feed: NewsFeedRecord | null; subscriptions: SubscriptionOption[]; + feeds: FeedOption[]; loading?: boolean; onClose: () => void | Promise; onSave: (payload: NewsFeedRecordUpdateInput) => Promise | void; @@ -27,6 +33,7 @@ export default function NewsFeedEditModal({ open, feed, subscriptions, + feeds, loading = false, onClose, onSave, @@ -35,6 +42,7 @@ export default function NewsFeedEditModal({ const [icon, setIcon] = useState(""); const [iconPickerOpen, setIconPickerOpen] = useState(false); const [selectedSubscriptionIds, setSelectedSubscriptionIds] = useState([]); + const [selectedFeedIds, setSelectedFeedIds] = useState([]); const [query, setQuery] = useState(""); const [maxFeedItems, setMaxFeedItems] = useState("200"); const [saving, setSaving] = useState(false); @@ -52,6 +60,7 @@ export default function NewsFeedEditModal({ setSelectedSubscriptionIds(isAllFeed ? [...(feed?.excludedSubscriptionRefs ?? [])] : [...(feed?.subscriptionRefs ?? [])]); + setSelectedFeedIds(isAllFeed ? [] : [...(feed?.includedFeedRefs ?? [])]); setMaxFeedItems(String(feed?.maxFeedItems || 200)); setQuery(""); setError(null); @@ -68,6 +77,14 @@ export default function NewsFeedEditModal({ return new Map(sortedSubscriptions.map((entry) => [entry.id, entry] as const)); }, [sortedSubscriptions]); + const sortedFeeds = useMemo( + () => feeds + .filter((entry) => entry.id !== "all") + .slice() + .sort((left, right) => left.title.localeCompare(right.title)), + [feeds], + ); + const filteredSubscriptions = useMemo(() => { const normalizedQuery = query.trim().toLowerCase(); if (!normalizedQuery) { @@ -96,6 +113,12 @@ export default function NewsFeedEditModal({ }); }; + const toggleFeed = (feedId: string) => { + setSelectedFeedIds((current) => current.includes(feedId) + ? current.filter((entryId) => entryId !== feedId) + : [...current, feedId]); + }; + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); setSaving(true); @@ -116,6 +139,7 @@ export default function NewsFeedEditModal({ ? sortedSubscriptions.map((entry) => entry.id) : selectedSubscriptionIds, excludedSubscriptionRefs: isAllFeed ? selectedSubscriptionIds : [], + includedFeedRefs: isAllFeed ? [] : selectedFeedIds, maxFeedItems: normalizedMaxFeedItems, }; @@ -195,6 +219,36 @@ export default function NewsFeedEditModal({ />
+ {!isAllFeed && ( +
+
+ + {selectedFeedIds.length} selected +
+
+ {sortedFeeds.length === 0 && ( +
No other feeds available.
+ )} + {sortedFeeds.map((includedFeed) => { + const isSelected = selectedFeedIds.includes(includedFeed.id); + return ( + + ); + })} +
+
+ )} +
+ {metadataLoading && serverGroupingWords === null && feedUrl.trim() && ( +

Analyzing recent articles for feed-specific words...

+ )} + {commonGroupingWords.length > 0 && (
- +
{commonGroupingWords.map((word) => ( diff --git a/apps/web/src/components/settings/SettingsLayout.tsx b/apps/web/src/components/settings/SettingsLayout.tsx index f4aa89ea..9286d061 100644 --- a/apps/web/src/components/settings/SettingsLayout.tsx +++ b/apps/web/src/components/settings/SettingsLayout.tsx @@ -38,7 +38,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
From a713aa41e55d56342f10a8dbd8390674eb2d1862 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Mon, 24 Aug 2026 14:32:43 +0200 Subject: [PATCH 10/57] feat(notifications/topics): replace dropdown with revamped topic details modal --- apps/backend/openapi.yaml | 19 ++ .../src/lib/data/notifications/forwarders.ts | 19 ++ .../src/lib/data/notifications/topicTokens.ts | 36 ++ .../backend/src/routes/notifications.route.ts | 18 +- apps/web/public/openapi.json | 30 ++ .../notifications/CreateForwarderDialog.tsx | 18 +- .../notifications/CreateTopicTokenDialog.tsx | 55 ++- .../notifications/NotificationsPage.tsx | 315 +++++++++++------- apps/web/src/lib/api/index.ts | 4 +- apps/web/src/lib/api/sdk.gen.ts | 26 +- apps/web/src/lib/api/types.gen.ts | 32 ++ apps/web/src/lib/apiClient.ts | 14 +- packages/api-types/src/openapi.ts | 42 ++- 13 files changed, 479 insertions(+), 149 deletions(-) diff --git a/apps/backend/openapi.yaml b/apps/backend/openapi.yaml index e017bc7b..5f6cd73e 100644 --- a/apps/backend/openapi.yaml +++ b/apps/backend/openapi.yaml @@ -695,6 +695,16 @@ paths: responses: "200": $ref: "#/components/responses/JsonOk" + /notifications/forwarders/test: + post: + tags: + - notifications + summary: Test forwarder + requestBody: + $ref: "#/components/requestBodies/JsonBody" + responses: + "200": + $ref: "#/components/responses/JsonOk" /notifications/markAsRead: post: tags: @@ -757,6 +767,15 @@ paths: responses: "200": $ref: "#/components/responses/JsonOk" + put: + tags: + - notifications + summary: Update topic token + requestBody: + $ref: "#/components/requestBodies/JsonBody" + responses: + "200": + $ref: "#/components/responses/JsonOk" get: tags: - notifications diff --git a/apps/backend/src/lib/data/notifications/forwarders.ts b/apps/backend/src/lib/data/notifications/forwarders.ts index 9b39c883..8e538e88 100644 --- a/apps/backend/src/lib/data/notifications/forwarders.ts +++ b/apps/backend/src/lib/data/notifications/forwarders.ts @@ -1,5 +1,6 @@ import { getSuperuserPB } from "../../pb/pocketbase"; import type { NotificationForwardersResponse, NotificationTopicsResponse } from "@dashwise/types"; +import { sendViaShoutrrr } from "./forwarding"; export async function getForwarders(userId: string) { const pb = await getSuperuserPB(); @@ -72,3 +73,21 @@ export async function deleteForwarder(userId: string, forwarderId: string) { await pb.collection("notificationForwarders").delete(forwarderId); return { success: true }; } + +export async function testForwarder(userId: string, forwarderId: string) { + const pb = await getSuperuserPB(); + + const forwarderRecord = (await pb.collection("notificationForwarders").getOne(forwarderId)) as NotificationForwardersResponse; + const topicRecord = (await pb.collection("notificationTopics").getOne(forwarderRecord.topic)) as NotificationTopicsResponse; + if (!topicRecord || topicRecord.userId !== userId) { + throw new Error("Forwarder not found or not owned by user"); + } + + await sendViaShoutrrr(forwarderRecord.target, "This is a test notification from Dashwise."); + return { ok: true }; +} + +export async function testForwarderTarget(target: string) { + await sendViaShoutrrr(target, "This is a test notification from Dashwise."); + return { ok: true }; +} diff --git a/apps/backend/src/lib/data/notifications/topicTokens.ts b/apps/backend/src/lib/data/notifications/topicTokens.ts index 6a17b2f5..b5c4ba36 100644 --- a/apps/backend/src/lib/data/notifications/topicTokens.ts +++ b/apps/backend/src/lib/data/notifications/topicTokens.ts @@ -103,6 +103,42 @@ export async function deleteTopicToken(userId: string, tokenId: string) { return { success: true }; } +export async function updateTopicToken(userId: string, body: any) { + const pb = await getSuperuserPB(); + const { tokenId, topicId, expires } = body; + + const tokenRecord = (await pb.collection("notificationTopicTokens").getOne(tokenId)) as NotificationTopicTokensResponse; + const currentTopic = (await pb.collection("notificationTopics").getOne(tokenRecord.topic)) as NotificationTopicsResponse; + if (!currentTopic || currentTopic.userId !== userId) { + throw new Error("Token not found or not owned by user"); + } + + const updatePayload: Record = {}; + let topicRecord = currentTopic; + if (topicId !== undefined && topicId !== tokenRecord.topic) { + topicRecord = (await pb.collection("notificationTopics").getOne(topicId)) as NotificationTopicsResponse; + if (!topicRecord || topicRecord.userId !== userId) { + throw new ApiActionError("Topic not found", 404, { error: "Topic not found" }); + } + updatePayload.topic = topicRecord.id; + } + + if (Object.prototype.hasOwnProperty.call(body, "expires")) { + if (!expires) { + updatePayload.expires = null; + } else { + const expiresDate = new Date(expires); + if (Number.isNaN(expiresDate.getTime())) { + throw new ApiActionError("Invalid expiry date", 400, { error: "Invalid expiry date" }); + } + updatePayload.expires = expiresDate.toISOString(); + } + } + + const updated = (await pb.collection("notificationTopicTokens").update(tokenId, updatePayload)) as NotificationTopicTokensResponse; + return { item: { ...updated, topic: topicRecord } }; +} + export async function resolveTopicToken(token: string) { const pb = await getSuperuserPB(); const records = (await pb.collection("notificationTopicTokens").getFullList({ diff --git a/apps/backend/src/routes/notifications.route.ts b/apps/backend/src/routes/notifications.route.ts index dee00226..db839e73 100644 --- a/apps/backend/src/routes/notifications.route.ts +++ b/apps/backend/src/routes/notifications.route.ts @@ -2,10 +2,10 @@ import { Hono } from "hono"; import type { Context } from "hono"; import { ApiActionError } from "../lib/data/auth"; -import { createForwarder, deleteForwarder, getForwarders, updateForwarder } from "../lib/data/notifications/forwarders"; +import { createForwarder, deleteForwarder, getForwarders, testForwarder, testForwarderTarget, updateForwarder } from "../lib/data/notifications/forwarders"; import { createNotificationTopic, deleteNotificationTopic, getNotificationTopics, getNotifications, markNotificationsAsRead, sendTestNotification } from "../lib/data/notifications/items"; import { createNotificationByTopicId, createNotificationWithTopicToken } from "../lib/data/notifications/publish"; -import { createTopicToken, deleteTopicToken, listTopicTokens } from "../lib/data/notifications/topicTokens"; +import { createTopicToken, deleteTopicToken, listTopicTokens, updateTopicToken } from "../lib/data/notifications/topicTokens"; import { getServerPB, getSuperuserPB } from "../lib/pb/pocketbase"; import { readAuthToken, readJsonBody, readBool, requireAuth, withJson } from "./shared"; @@ -122,6 +122,11 @@ notificationsRoute const { userId } = await requireAuth({ token: readAuthToken(c) }); return deleteTopicToken(userId, String(body?.tokenId ?? "")); })) + .put("/api/v1/notifications/topicTokens", withJson(async (c) => { + const body = await readJsonBody(c); + const { userId } = await requireAuth({ token: readAuthToken(c) }); + return updateTopicToken(userId, body ?? {}); + })) .get("/api/v1/notifications/forwarders", withJson(async (c) => { const { userId } = await requireAuth({ token: readAuthToken(c) }); return getForwarders(userId); @@ -141,6 +146,13 @@ notificationsRoute const { userId } = await requireAuth({ token: readAuthToken(c) }); return deleteForwarder(userId, String(body?.forwarderId ?? "")); })) + .post("/api/v1/notifications/forwarders/test", withJson(async (c) => { + const body = await readJsonBody(c); + const { userId } = await requireAuth({ token: readAuthToken(c) }); + return body?.forwarderId + ? testForwarder(userId, String(body.forwarderId)) + : testForwarderTarget(String(body?.target ?? "")); + })) .post("/api/v1/notifications/:topic", withJson(async (c) => { const topic = String(c.req.param("topic") ?? "").trim(); const body = await readJsonBody(c); @@ -161,4 +173,4 @@ notificationsRoute return { ok: true, ...(await createNotificationByTopicId(topic, content, source)) }; })); -export default notificationsRoute; \ No newline at end of file +export default notificationsRoute; diff --git a/apps/web/public/openapi.json b/apps/web/public/openapi.json index b2e42562..1ac23d23 100644 --- a/apps/web/public/openapi.json +++ b/apps/web/public/openapi.json @@ -1145,6 +1145,22 @@ } } }, + "/notifications/forwarders/test": { + "post": { + "tags": [ + "notifications" + ], + "summary": "Test forwarder", + "requestBody": { + "$ref": "#/components/requestBodies/JsonBody" + }, + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" + } + } + } + }, "/notifications/markAsRead": { "post": { "tags": [ @@ -1243,6 +1259,20 @@ } } }, + "put": { + "tags": [ + "notifications" + ], + "summary": "Update topic token", + "requestBody": { + "$ref": "#/components/requestBodies/JsonBody" + }, + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" + } + } + }, "get": { "tags": [ "notifications" diff --git a/apps/web/src/components/notifications/CreateForwarderDialog.tsx b/apps/web/src/components/notifications/CreateForwarderDialog.tsx index 998ca281..c0668511 100644 --- a/apps/web/src/components/notifications/CreateForwarderDialog.tsx +++ b/apps/web/src/components/notifications/CreateForwarderDialog.tsx @@ -9,12 +9,11 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { cn } from "@/lib/utils"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Checkbox } from "../ui/checkbox"; import TopicCombobox, { type Topic } from "./TopicCombobox"; -import { createForwarderAction } from '@/lib/apiClient'; +import { createForwarderAction, testForwarderTargetAction } from '@/lib/apiClient'; export type ForwarderItem = { id: string; topic: { id: string }; @@ -30,6 +29,7 @@ type CreateForwarderDialogProps = { topics: Topic[]; onForwarderCreated?: (newItem: ForwarderItem) => void; initialTopic?: Topic | null; + onBack?: () => void; }; export default function CreateForwarderDialogComponent({ @@ -38,13 +38,14 @@ export default function CreateForwarderDialogComponent({ topics, onForwarderCreated, initialTopic = null, + onBack, }: CreateForwarderDialogProps) { const [selectedTopic, setSelectedTopic] = useState(null); const [target, setTarget] = useState(""); const [isActive, setIsActive] = useState(true); const [creating, setCreating] = useState(false); - const { token, withAuth } = useAuth(); + const { withAuth } = useAuth(); useEffect(() => { if (!open) return; @@ -66,6 +67,8 @@ export default function CreateForwarderDialogComponent({ setCreating(true); try { + await withAuth((auth) => testForwarderTargetAction(auth, target)); + const json = await withAuth((auth) => createForwarderAction(auth, { topic: selectedTopic.id, target, isActive }) ); @@ -132,11 +135,14 @@ export default function CreateForwarderDialogComponent({
- diff --git a/apps/web/src/components/notifications/CreateTopicTokenDialog.tsx b/apps/web/src/components/notifications/CreateTopicTokenDialog.tsx index a1b97e40..801a2510 100644 --- a/apps/web/src/components/notifications/CreateTopicTokenDialog.tsx +++ b/apps/web/src/components/notifications/CreateTopicTokenDialog.tsx @@ -13,15 +13,25 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { Calendar } from "@/components/ui/calendar"; import { format } from "date-fns"; import { Button } from "../ui/button"; -import { createTopicTokenAction } from '@/lib/apiClient'; +import { createTopicTokenAction, updateTopicTokenAction } from '@/lib/apiClient'; import TopicCombobox, { type Topic } from "./TopicCombobox"; +export type TopicTokenItem = { + id: string; + token?: string; + topic?: { id: string; title?: string } | string; + expires?: string | null; + created?: string | null; +}; + type NewTokenDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; topics: Topic[]; onTokenCreated?: (newItem: any) => void; initialTopic?: Topic | null; + initialToken?: TopicTokenItem | null; + onBack?: () => void; }; export default function nn({ @@ -30,6 +40,8 @@ export default function nn({ topics, onTokenCreated, initialTopic = null, + initialToken = null, + onBack, }: NewTokenDialogProps) { const [selectedTopic, setSelectedTopic] = useState(null); const [creating, setCreating] = useState(false); @@ -45,12 +57,12 @@ export default function nn({ if (!open) return; setSelectedTopic(initialTopic); - setExpiryMode("never"); + setExpiryMode(initialToken?.expires ? "onDate" : "never"); setInDays(30); - const d = new Date(); - d.setMonth(d.getMonth() + 1); + const d = initialToken?.expires ? new Date(initialToken.expires) : new Date(); + if (!initialToken?.expires) d.setMonth(d.getMonth() + 1); setOnDate(d.toISOString().split("T")[0]); - }, [open, initialTopic]); + }, [open, initialTopic, initialToken]); const expiryLabel = () => { if (expiryMode === "never") return "Never"; @@ -63,7 +75,7 @@ export default function nn({ return "—"; }; - const { token, withAuth } = useAuth(); + const { withAuth } = useAuth(); const handleCreate = async () => { if (!selectedTopic) return; @@ -79,12 +91,16 @@ export default function nn({ expiresVal = new Date(onDate).toISOString(); } - const json = await withAuth((auth) => - createTopicTokenAction(auth, { - topicId: selectedTopic.id, - ...(expiresVal ? { expires: expiresVal } : {}), - }) - ); + const json = await withAuth((auth) => initialToken + ? updateTopicTokenAction(auth, { + tokenId: initialToken.id, + topicId: selectedTopic.id, + expires: expiresVal ?? null, + }) + : createTopicTokenAction(auth, { + topicId: selectedTopic.id, + ...(expiresVal ? { expires: expiresVal } : {}), + })); // Reset setSelectedTopic(null); @@ -99,7 +115,7 @@ export default function nn({ } catch (err) { console.error(err); - alert("Failed to create token"); + alert(`Failed to ${initialToken ? "update" : "create"} token`); } finally { setCreating(false); } @@ -109,7 +125,7 @@ export default function nn({ - Create Topic Token + {initialToken ? "Edit Topic Token" : "Create Topic Token"}
@@ -193,8 +209,15 @@ export default function nn({
- - + +
diff --git a/apps/web/src/components/notifications/NotificationsPage.tsx b/apps/web/src/components/notifications/NotificationsPage.tsx index 80545419..d7b8d5a9 100644 --- a/apps/web/src/components/notifications/NotificationsPage.tsx +++ b/apps/web/src/components/notifications/NotificationsPage.tsx @@ -6,7 +6,6 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Button } from "@/components/ui/button"; @@ -19,13 +18,16 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; -import { Check, MoreHorizontal, Plus } from "lucide-react"; +import { Check, MoreHorizontal, Pencil, Plus } from "lucide-react"; +import { Icon } from "@iconify-icon/react"; import { cn } from "@/lib/utils"; +import config from "@/lib/config"; import { createNotificationTopicAction, deleteNotificationTopicAction, getNotificationTopicsAction, markNotificationsAsReadAction, + testForwarderAction, } from '@/lib/apiClient'; import { getForwardersAction } from '@/lib/apiClient'; import { listTopicTokensAction } from '@/lib/apiClient'; @@ -135,10 +137,10 @@ export default function NotificationsPage() { const [createTopicTitle, setCreateTopicTitle] = useState(""); const [creatingTopic, setCreatingTopic] = useState(false); const [topicForToken, setTopicForToken] = useState(null); + const [topicTokenToEdit, setTopicTokenToEdit] = useState(null); const [topicForForwarder, setTopicForForwarder] = useState< TopicItem | null >(null); - const [topicToDelete, setTopicToDelete] = useState(null); const [helpOpen, setHelpOpen] = useState(false); const [notificationDetailsOpen, setNotificationDetailsOpen] = useState(false); @@ -155,6 +157,7 @@ export default function NotificationsPage() { TopicForwarderItem[] >([]); const [topicDetailsLoading, setTopicDetailsLoading] = useState(false); + const [testingForwarderId, setTestingForwarderId] = useState(null); const [topicDetailsSection, setTopicDetailsSection] = useState< "tokens" | "forwarders" | null >(null); @@ -189,6 +192,25 @@ export default function NotificationsPage() { ); const createTopicMutation = useApiMutation((auth, title: string) => createNotificationTopicAction(auth, title), { onSuccess: invalidateNotifications }); const deleteTopicMutation = useApiMutation((auth, topicId: string) => deleteNotificationTopicAction(auth, topicId), { onSuccess: invalidateNotifications }); + const testForwarderMutation = useApiMutation(testForwarderAction); + const invalidateTopicTokens = useCallback(() => { + void queryClient.invalidateQueries({ + queryKey: ["api", token, ...queryKeys.notifications.tokens(token)], + }); + }, [queryClient, token]); + + const testForwarder = async (forwarderId: string) => { + setTestingForwarderId(forwarderId); + try { + await testForwarderMutation.mutateAsync(forwarderId); + alert("Test notification sent"); + } catch (err) { + console.error("Failed to test forwarder:", err); + alert("Failed to send test notification"); + } finally { + setTestingForwarderId(null); + } + }; useEffect(() => { if (!topicDetailsOpen || !topicDetailsSection) return; @@ -286,10 +308,14 @@ export default function NotificationsPage() { try { await deleteTopicMutation.mutateAsync(topic.id); - setTopicToDelete(null); if (activeTopic === topic.id) { setActiveTopic(null); } + if (topicDetailsTopic?.id === topic.id) { + setTopicDetailsOpen(false); + setTopicDetailsTopic(null); + setTopicDetailsSection(null); + } } catch (err) { console.error("Failed to delete topic:", err); alert("Failed to delete topic"); @@ -312,6 +338,33 @@ export default function NotificationsPage() { setTopicDetailsOpen(true); }; + const returnToTopicDetails = () => { + setTopicForToken(null); + setTopicTokenToEdit(null); + setTopicForForwarder(null); + setTopicDetailsOpen(true); + }; + + const usageToken = topicDetailsTokens.find((item) => item.token)?.token ?? null; + const usageBaseUrl = config.backend_url.replace(/\/+$/, ""); + const usageShoutrrr = usageToken + ? `generic://${usageBaseUrl.replace(/^https?:\/\//, "")}/api/v1/notifications/${usageToken}?template=json` + : null; + const usageCurl = usageToken + ? `curl -X POST "${usageBaseUrl}/api/v1/notifications/${usageToken}" \\\n+ -H "Content-Type: application/json" \\ + -d '{"message":"Hello from your automation"}'` + .replace(/\n\+/g, "\n") + : null; + const copyUsage = async (value: string | null) => { + if (!value) return; + try { + await navigator.clipboard.writeText(value); + } catch (err) { + console.error("Failed to copy usage example:", err); + alert("Failed to copy usage example"); + } + }; + return ( <>
@@ -374,7 +427,13 @@ export default function NotificationsPage() { className="flex items-center gap-1 shrink-0" > - - - - - - - Topic actions - - - openTopicDetails(topic, "tokens")} - > - View topic tokens - - - openTopicDetails( - topic, - "forwarders", - )} - > - View topic forwarders - - - setTopicForToken(topic)} - > - New topic token - - - setTopicForForwarder(topic)} - > - New topic forwarder - - - setTopicToDelete(topic)} - > - Delete topic and messages - - - + +
))} @@ -736,7 +753,9 @@ export default function NotificationsPage() { > - Topic details + + Topic: {topicDetailsTopic?.title ?? ""} + {topicDetailsLoading ? ( @@ -750,6 +769,21 @@ export default function NotificationsPage() {

Topic tokens

+
{topicDetailsTokens.length === 0 ? ( @@ -773,19 +807,33 @@ export default function NotificationsPage() { "(hidden)"}
- + > + Copy + +
{item.expires @@ -797,10 +845,34 @@ export default function NotificationsPage() {
))}
- )} -
- -
+

Usage

+ + +
+
+ +
@@ -808,6 +880,20 @@ export default function NotificationsPage() {

Forwarders

+
{topicDetailsForwarders.length === 0 ? ( @@ -827,18 +913,28 @@ export default function NotificationsPage() { {item.target || "(missing target)"}
- - {item.isActive - ? "Active" - : "Paused"} - +
+ + + {item.isActive + ? "Active" + : "Paused"} + +
{item.updated && (
@@ -856,7 +952,15 @@ export default function NotificationsPage() {
)} - + + - - - - ); } diff --git a/apps/web/src/lib/api/index.ts b/apps/web/src/lib/api/index.ts index 34e0115c..0451739c 100644 --- a/apps/web/src/lib/api/index.ts +++ b/apps/web/src/lib/api/index.ts @@ -1,5 +1,5 @@ // DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts -export { deleteAuthDeleteAccount, deleteIntegrationsById, deleteLinksItemsByLinkId, deleteMonitoringHostsById, deleteMonitoringSshHostsById, deleteMonitorsById, deleteNotificationsForwarders, deleteNotificationsTopics, deleteNotificationsTopicTokens, getAppConfig, getAppInfo, getAuthCallback, getAuthSso, getGlanceables, getGlanceablesByIntegration, getIntegrations, getIntegrationsCaldavEvents, getIntegrationsConsumerData, getIntegrationsWidgetProperties, getJobsPullIcons, getJobsSearchItems, getLinksCollections, getLinksFolders, getLinksHome, getLinksHomeGroups, getLinksItems, getLinksTags, getLocations, getMonitoringHosts, getMonitoringHostsById, getMonitoringHostsByIdHistory, getMonitoringHostsByIdStats, getMonitoringSshHosts, getMonitoringStatus, getMonitors, getMonitorsById, getNews, getNewsFeed, getNewsFeedMetadata, getNewsFeedRecordsById, getNewsFeedRefresh, getNewsFeeds, getNewsFeedsById, getNewsSubscriptions, getNewsSubscriptionsByIdJson, getNotifications, getNotificationsForwarders, getNotificationsTopics, getNotificationsTopicTokens, getPageConfig, getPageConfigUserPages, getSearchItems, getSearchItemsFrequentlyUsed, getTestBookmarks, getWallpapers, getWeather, getWidgets, getWidgetsByIntegration, getWidgetsGlanceable, getWidgetsGlanceables, type Options, patchAuthUpdateUserProperty, postAuthChangePassword, postAuthLogin, postAuthMfa, postAuthSignup, postAuthValidateAuth, postIntegrations, postIntegrationsConsumerData, postIntegrationsProxyAction, postIntegrationsTestEndpoint, postLinksCollections, postLinksFolders, postLinksHomeGroups, postLinksItems, postLinksReorder, postLinksTags, postMonitoringHosts, postMonitoringHostsByIdRefresh, postMonitoringSshHosts, postMonitoringStatus, postMonitors, postNewsFeedRecords, postNewsFeedRecordsById, postNewsFeedRefresh, postNewsFeedSubscribe, postNewsFeedUnsubscribe, postNewsFeedUpdate, postNewsFixMissingTitles, postNotifications, postNotificationsByTopic, postNotificationsForwarders, postNotificationsMarkAsRead, postNotificationsTest, postNotificationsTopics, postNotificationsTopicTokens, postPageConfigHome, postPageConfigIntegrationData, postPageConfigMigrateLegacy, postSearchItemsUsageStats, postWallpapers, putIntegrationsById, putLinksCollectionsByCollectionId, putLinksFoldersByFolderIdIcon, putLinksItemsByLinkId, putLinksTagsByTagId, putMonitoringHostsById, putMonitoringSshHostsById, putMonitorsById, putNotificationsForwarders, putPageConfig } from './sdk.gen'; -export type { ClientOptions, DeleteAuthDeleteAccountData, DeleteAuthDeleteAccountError, DeleteAuthDeleteAccountErrors, DeleteAuthDeleteAccountResponse, DeleteAuthDeleteAccountResponses, DeleteIntegrationsByIdData, DeleteIntegrationsByIdResponse, DeleteIntegrationsByIdResponses, DeleteLinksItemsByLinkIdData, DeleteLinksItemsByLinkIdResponse, DeleteLinksItemsByLinkIdResponses, DeleteMonitoringHostsByIdData, DeleteMonitoringHostsByIdResponse, DeleteMonitoringHostsByIdResponses, DeleteMonitoringSshHostsByIdData, DeleteMonitoringSshHostsByIdResponse, DeleteMonitoringSshHostsByIdResponses, DeleteMonitorsByIdData, DeleteMonitorsByIdResponse, DeleteMonitorsByIdResponses, DeleteNotificationsForwardersData, DeleteNotificationsForwardersResponse, DeleteNotificationsForwardersResponses, DeleteNotificationsTopicsData, DeleteNotificationsTopicsResponse, DeleteNotificationsTopicsResponses, DeleteNotificationsTopicTokensData, DeleteNotificationsTopicTokensResponse, DeleteNotificationsTopicTokensResponses, Error, GenericObject, GetAppConfigData, GetAppConfigResponse, GetAppConfigResponses, GetAppInfoData, GetAppInfoResponse, GetAppInfoResponses, GetAuthCallbackData, GetAuthCallbackResponse, GetAuthCallbackResponses, GetAuthSsoData, GetAuthSsoResponse, GetAuthSsoResponses, GetGlanceablesByIntegrationData, GetGlanceablesByIntegrationResponse, GetGlanceablesByIntegrationResponses, GetGlanceablesData, GetGlanceablesResponse, GetGlanceablesResponses, GetIntegrationsCaldavEventsData, GetIntegrationsCaldavEventsResponse, GetIntegrationsCaldavEventsResponses, GetIntegrationsConsumerDataData, GetIntegrationsConsumerDataResponse, GetIntegrationsConsumerDataResponses, GetIntegrationsData, GetIntegrationsResponse, GetIntegrationsResponses, GetIntegrationsWidgetPropertiesData, GetIntegrationsWidgetPropertiesResponse, GetIntegrationsWidgetPropertiesResponses, GetJobsPullIconsData, GetJobsPullIconsError, GetJobsPullIconsErrors, GetJobsPullIconsResponse, GetJobsPullIconsResponses, GetJobsSearchItemsData, GetJobsSearchItemsResponse, GetJobsSearchItemsResponses, GetLinksCollectionsData, GetLinksCollectionsResponse, GetLinksCollectionsResponses, GetLinksFoldersData, GetLinksFoldersResponse, GetLinksFoldersResponses, GetLinksHomeData, GetLinksHomeGroupsData, GetLinksHomeGroupsResponse, GetLinksHomeGroupsResponses, GetLinksHomeResponse, GetLinksHomeResponses, GetLinksItemsData, GetLinksItemsResponse, GetLinksItemsResponses, GetLinksTagsData, GetLinksTagsResponse, GetLinksTagsResponses, GetLocationsData, GetLocationsResponse, GetLocationsResponses, GetMonitoringHostsByIdData, GetMonitoringHostsByIdHistoryData, GetMonitoringHostsByIdHistoryResponse, GetMonitoringHostsByIdHistoryResponses, GetMonitoringHostsByIdResponse, GetMonitoringHostsByIdResponses, GetMonitoringHostsByIdStatsData, GetMonitoringHostsByIdStatsResponse, GetMonitoringHostsByIdStatsResponses, GetMonitoringHostsData, GetMonitoringHostsResponse, GetMonitoringHostsResponses, GetMonitoringSshHostsData, GetMonitoringSshHostsResponse, GetMonitoringSshHostsResponses, GetMonitoringStatusData, GetMonitoringStatusResponse, GetMonitoringStatusResponses, GetMonitorsByIdData, GetMonitorsByIdResponse, GetMonitorsByIdResponses, GetMonitorsData, GetMonitorsResponse, GetMonitorsResponses, GetNewsData, GetNewsFeedData, GetNewsFeedMetadataData, GetNewsFeedMetadataResponse, GetNewsFeedMetadataResponses, GetNewsFeedRecordsByIdData, GetNewsFeedRecordsByIdResponse, GetNewsFeedRecordsByIdResponses, GetNewsFeedRefreshData, GetNewsFeedRefreshResponse, GetNewsFeedRefreshResponses, GetNewsFeedResponse, GetNewsFeedResponses, GetNewsFeedsByIdData, GetNewsFeedsByIdResponse, GetNewsFeedsByIdResponses, GetNewsFeedsData, GetNewsFeedsResponse, GetNewsFeedsResponses, GetNewsResponse, GetNewsResponses, GetNewsSubscriptionsByIdJsonData, GetNewsSubscriptionsByIdJsonResponse, GetNewsSubscriptionsByIdJsonResponses, GetNewsSubscriptionsData, GetNewsSubscriptionsResponse, GetNewsSubscriptionsResponses, GetNotificationsData, GetNotificationsForwardersData, GetNotificationsForwardersResponse, GetNotificationsForwardersResponses, GetNotificationsResponse, GetNotificationsResponses, GetNotificationsTopicsData, GetNotificationsTopicsResponse, GetNotificationsTopicsResponses, GetNotificationsTopicTokensData, GetNotificationsTopicTokensResponse, GetNotificationsTopicTokensResponses, GetPageConfigData, GetPageConfigResponse, GetPageConfigResponses, GetPageConfigUserPagesData, GetPageConfigUserPagesResponse, GetPageConfigUserPagesResponses, GetSearchItemsData, GetSearchItemsFrequentlyUsedData, GetSearchItemsFrequentlyUsedResponse, GetSearchItemsFrequentlyUsedResponses, GetSearchItemsResponse, GetSearchItemsResponses, GetTestBookmarksData, GetTestBookmarksResponse, GetTestBookmarksResponses, GetWallpapersData, GetWallpapersResponse, GetWallpapersResponses, GetWeatherData, GetWeatherResponse, GetWeatherResponses, GetWidgetsByIntegrationData, GetWidgetsByIntegrationResponse, GetWidgetsByIntegrationResponses, GetWidgetsData, GetWidgetsGlanceableData, GetWidgetsGlanceableResponse, GetWidgetsGlanceableResponses, GetWidgetsGlanceablesData, GetWidgetsGlanceablesResponse, GetWidgetsGlanceablesResponses, GetWidgetsResponse, GetWidgetsResponses, Id, JsonBody, PatchAuthUpdateUserPropertyData, PatchAuthUpdateUserPropertyError, PatchAuthUpdateUserPropertyErrors, PatchAuthUpdateUserPropertyResponse, PatchAuthUpdateUserPropertyResponses, PostAuthChangePasswordData, PostAuthChangePasswordError, PostAuthChangePasswordErrors, PostAuthChangePasswordResponse, PostAuthChangePasswordResponses, PostAuthLoginData, PostAuthLoginError, PostAuthLoginErrors, PostAuthLoginResponse, PostAuthLoginResponses, PostAuthMfaData, PostAuthMfaError, PostAuthMfaErrors, PostAuthMfaResponse, PostAuthMfaResponses, PostAuthSignupData, PostAuthSignupError, PostAuthSignupErrors, PostAuthSignupResponse, PostAuthSignupResponses, PostAuthValidateAuthData, PostAuthValidateAuthError, PostAuthValidateAuthErrors, PostAuthValidateAuthResponse, PostAuthValidateAuthResponses, PostIntegrationsConsumerDataData, PostIntegrationsConsumerDataResponse, PostIntegrationsConsumerDataResponses, PostIntegrationsData, PostIntegrationsProxyActionData, PostIntegrationsProxyActionResponse, PostIntegrationsProxyActionResponses, PostIntegrationsResponse, PostIntegrationsResponses, PostIntegrationsTestEndpointData, PostIntegrationsTestEndpointResponse, PostIntegrationsTestEndpointResponses, PostLinksCollectionsData, PostLinksCollectionsResponse, PostLinksCollectionsResponses, PostLinksFoldersData, PostLinksFoldersResponse, PostLinksFoldersResponses, PostLinksHomeGroupsData, PostLinksHomeGroupsResponse, PostLinksHomeGroupsResponses, PostLinksItemsData, PostLinksItemsResponse, PostLinksItemsResponses, PostLinksReorderData, PostLinksReorderResponse, PostLinksReorderResponses, PostLinksTagsData, PostLinksTagsResponse, PostLinksTagsResponses, PostMonitoringHostsByIdRefreshData, PostMonitoringHostsByIdRefreshError, PostMonitoringHostsByIdRefreshErrors, PostMonitoringHostsByIdRefreshResponse, PostMonitoringHostsByIdRefreshResponses, PostMonitoringHostsData, PostMonitoringHostsResponse, PostMonitoringHostsResponses, PostMonitoringSshHostsData, PostMonitoringSshHostsResponse, PostMonitoringSshHostsResponses, PostMonitoringStatusData, PostMonitoringStatusError, PostMonitoringStatusErrors, PostMonitoringStatusResponse, PostMonitoringStatusResponses, PostMonitorsData, PostMonitorsResponse, PostMonitorsResponses, PostNewsFeedRecordsByIdData, PostNewsFeedRecordsByIdResponse, PostNewsFeedRecordsByIdResponses, PostNewsFeedRecordsData, PostNewsFeedRecordsResponse, PostNewsFeedRecordsResponses, PostNewsFeedRefreshData, PostNewsFeedRefreshResponse, PostNewsFeedRefreshResponses, PostNewsFeedSubscribeData, PostNewsFeedSubscribeResponse, PostNewsFeedSubscribeResponses, PostNewsFeedUnsubscribeData, PostNewsFeedUnsubscribeResponse, PostNewsFeedUnsubscribeResponses, PostNewsFeedUpdateData, PostNewsFeedUpdateResponse, PostNewsFeedUpdateResponses, PostNewsFixMissingTitlesData, PostNewsFixMissingTitlesResponse, PostNewsFixMissingTitlesResponses, PostNotificationsByTopicData, PostNotificationsByTopicResponse, PostNotificationsByTopicResponses, PostNotificationsData, PostNotificationsForwardersData, PostNotificationsForwardersResponse, PostNotificationsForwardersResponses, PostNotificationsMarkAsReadData, PostNotificationsMarkAsReadResponse, PostNotificationsMarkAsReadResponses, PostNotificationsResponse, PostNotificationsResponses, PostNotificationsTestData, PostNotificationsTestResponse, PostNotificationsTestResponses, PostNotificationsTopicsData, PostNotificationsTopicsResponse, PostNotificationsTopicsResponses, PostNotificationsTopicTokensData, PostNotificationsTopicTokensResponse, PostNotificationsTopicTokensResponses, PostPageConfigHomeData, PostPageConfigHomeResponse, PostPageConfigHomeResponses, PostPageConfigIntegrationDataData, PostPageConfigIntegrationDataResponse, PostPageConfigIntegrationDataResponses, PostPageConfigMigrateLegacyData, PostPageConfigMigrateLegacyResponse, PostPageConfigMigrateLegacyResponses, PostSearchItemsUsageStatsData, PostSearchItemsUsageStatsResponse, PostSearchItemsUsageStatsResponses, PostWallpapersData, PostWallpapersResponse, PostWallpapersResponses, PutIntegrationsByIdData, PutIntegrationsByIdResponse, PutIntegrationsByIdResponses, PutLinksCollectionsByCollectionIdData, PutLinksCollectionsByCollectionIdResponse, PutLinksCollectionsByCollectionIdResponses, PutLinksFoldersByFolderIdIconData, PutLinksFoldersByFolderIdIconResponse, PutLinksFoldersByFolderIdIconResponses, PutLinksItemsByLinkIdData, PutLinksItemsByLinkIdResponse, PutLinksItemsByLinkIdResponses, PutLinksTagsByTagIdData, PutLinksTagsByTagIdResponse, PutLinksTagsByTagIdResponses, PutMonitoringHostsByIdData, PutMonitoringHostsByIdResponse, PutMonitoringHostsByIdResponses, PutMonitoringSshHostsByIdData, PutMonitoringSshHostsByIdResponse, PutMonitoringSshHostsByIdResponses, PutMonitorsByIdData, PutMonitorsByIdResponse, PutMonitorsByIdResponses, PutNotificationsForwardersData, PutNotificationsForwardersResponse, PutNotificationsForwardersResponses, PutPageConfigData, PutPageConfigResponse, PutPageConfigResponses } from './types.gen'; +export { deleteAuthDeleteAccount, deleteIntegrationsById, deleteLinksItemsByLinkId, deleteMonitoringHostsById, deleteMonitoringSshHostsById, deleteMonitorsById, deleteNotificationsForwarders, deleteNotificationsTopics, deleteNotificationsTopicTokens, getAppConfig, getAppInfo, getAuthCallback, getAuthSso, getGlanceables, getGlanceablesByIntegration, getIntegrations, getIntegrationsCaldavEvents, getIntegrationsConsumerData, getIntegrationsWidgetProperties, getJobsPullIcons, getJobsSearchItems, getLinksCollections, getLinksFolders, getLinksHome, getLinksHomeGroups, getLinksItems, getLinksTags, getLocations, getMonitoringHosts, getMonitoringHostsById, getMonitoringHostsByIdHistory, getMonitoringHostsByIdStats, getMonitoringSshHosts, getMonitoringStatus, getMonitors, getMonitorsById, getNews, getNewsFeed, getNewsFeedMetadata, getNewsFeedRecordsById, getNewsFeedRefresh, getNewsFeeds, getNewsFeedsById, getNewsSubscriptions, getNewsSubscriptionsByIdJson, getNotifications, getNotificationsForwarders, getNotificationsTopics, getNotificationsTopicTokens, getPageConfig, getPageConfigUserPages, getSearchItems, getSearchItemsFrequentlyUsed, getTestBookmarks, getWallpapers, getWeather, getWidgets, getWidgetsByIntegration, getWidgetsGlanceable, getWidgetsGlanceables, type Options, patchAuthUpdateUserProperty, postAuthChangePassword, postAuthLogin, postAuthMfa, postAuthSignup, postAuthValidateAuth, postIntegrations, postIntegrationsConsumerData, postIntegrationsProxyAction, postIntegrationsTestEndpoint, postLinksCollections, postLinksFolders, postLinksHomeGroups, postLinksItems, postLinksReorder, postLinksTags, postMonitoringHosts, postMonitoringHostsByIdRefresh, postMonitoringSshHosts, postMonitoringStatus, postMonitors, postNewsFeedRecords, postNewsFeedRecordsById, postNewsFeedRefresh, postNewsFeedSubscribe, postNewsFeedUnsubscribe, postNewsFeedUpdate, postNewsFixMissingTitles, postNotifications, postNotificationsByTopic, postNotificationsForwarders, postNotificationsForwardersTest, postNotificationsMarkAsRead, postNotificationsTest, postNotificationsTopics, postNotificationsTopicTokens, postPageConfigHome, postPageConfigIntegrationData, postPageConfigMigrateLegacy, postSearchItemsUsageStats, postWallpapers, putIntegrationsById, putLinksCollectionsByCollectionId, putLinksFoldersByFolderIdIcon, putLinksItemsByLinkId, putLinksTagsByTagId, putMonitoringHostsById, putMonitoringSshHostsById, putMonitorsById, putNotificationsForwarders, putNotificationsTopicTokens, putPageConfig } from './sdk.gen'; +export type { ClientOptions, DeleteAuthDeleteAccountData, DeleteAuthDeleteAccountError, DeleteAuthDeleteAccountErrors, DeleteAuthDeleteAccountResponse, DeleteAuthDeleteAccountResponses, DeleteIntegrationsByIdData, DeleteIntegrationsByIdResponse, DeleteIntegrationsByIdResponses, DeleteLinksItemsByLinkIdData, DeleteLinksItemsByLinkIdResponse, DeleteLinksItemsByLinkIdResponses, DeleteMonitoringHostsByIdData, DeleteMonitoringHostsByIdResponse, DeleteMonitoringHostsByIdResponses, DeleteMonitoringSshHostsByIdData, DeleteMonitoringSshHostsByIdResponse, DeleteMonitoringSshHostsByIdResponses, DeleteMonitorsByIdData, DeleteMonitorsByIdResponse, DeleteMonitorsByIdResponses, DeleteNotificationsForwardersData, DeleteNotificationsForwardersResponse, DeleteNotificationsForwardersResponses, DeleteNotificationsTopicsData, DeleteNotificationsTopicsResponse, DeleteNotificationsTopicsResponses, DeleteNotificationsTopicTokensData, DeleteNotificationsTopicTokensResponse, DeleteNotificationsTopicTokensResponses, Error, GenericObject, GetAppConfigData, GetAppConfigResponse, GetAppConfigResponses, GetAppInfoData, GetAppInfoResponse, GetAppInfoResponses, GetAuthCallbackData, GetAuthCallbackResponse, GetAuthCallbackResponses, GetAuthSsoData, GetAuthSsoResponse, GetAuthSsoResponses, GetGlanceablesByIntegrationData, GetGlanceablesByIntegrationResponse, GetGlanceablesByIntegrationResponses, GetGlanceablesData, GetGlanceablesResponse, GetGlanceablesResponses, GetIntegrationsCaldavEventsData, GetIntegrationsCaldavEventsResponse, GetIntegrationsCaldavEventsResponses, GetIntegrationsConsumerDataData, GetIntegrationsConsumerDataResponse, GetIntegrationsConsumerDataResponses, GetIntegrationsData, GetIntegrationsResponse, GetIntegrationsResponses, GetIntegrationsWidgetPropertiesData, GetIntegrationsWidgetPropertiesResponse, GetIntegrationsWidgetPropertiesResponses, GetJobsPullIconsData, GetJobsPullIconsError, GetJobsPullIconsErrors, GetJobsPullIconsResponse, GetJobsPullIconsResponses, GetJobsSearchItemsData, GetJobsSearchItemsResponse, GetJobsSearchItemsResponses, GetLinksCollectionsData, GetLinksCollectionsResponse, GetLinksCollectionsResponses, GetLinksFoldersData, GetLinksFoldersResponse, GetLinksFoldersResponses, GetLinksHomeData, GetLinksHomeGroupsData, GetLinksHomeGroupsResponse, GetLinksHomeGroupsResponses, GetLinksHomeResponse, GetLinksHomeResponses, GetLinksItemsData, GetLinksItemsResponse, GetLinksItemsResponses, GetLinksTagsData, GetLinksTagsResponse, GetLinksTagsResponses, GetLocationsData, GetLocationsResponse, GetLocationsResponses, GetMonitoringHostsByIdData, GetMonitoringHostsByIdHistoryData, GetMonitoringHostsByIdHistoryResponse, GetMonitoringHostsByIdHistoryResponses, GetMonitoringHostsByIdResponse, GetMonitoringHostsByIdResponses, GetMonitoringHostsByIdStatsData, GetMonitoringHostsByIdStatsResponse, GetMonitoringHostsByIdStatsResponses, GetMonitoringHostsData, GetMonitoringHostsResponse, GetMonitoringHostsResponses, GetMonitoringSshHostsData, GetMonitoringSshHostsResponse, GetMonitoringSshHostsResponses, GetMonitoringStatusData, GetMonitoringStatusResponse, GetMonitoringStatusResponses, GetMonitorsByIdData, GetMonitorsByIdResponse, GetMonitorsByIdResponses, GetMonitorsData, GetMonitorsResponse, GetMonitorsResponses, GetNewsData, GetNewsFeedData, GetNewsFeedMetadataData, GetNewsFeedMetadataResponse, GetNewsFeedMetadataResponses, GetNewsFeedRecordsByIdData, GetNewsFeedRecordsByIdResponse, GetNewsFeedRecordsByIdResponses, GetNewsFeedRefreshData, GetNewsFeedRefreshResponse, GetNewsFeedRefreshResponses, GetNewsFeedResponse, GetNewsFeedResponses, GetNewsFeedsByIdData, GetNewsFeedsByIdResponse, GetNewsFeedsByIdResponses, GetNewsFeedsData, GetNewsFeedsResponse, GetNewsFeedsResponses, GetNewsResponse, GetNewsResponses, GetNewsSubscriptionsByIdJsonData, GetNewsSubscriptionsByIdJsonResponse, GetNewsSubscriptionsByIdJsonResponses, GetNewsSubscriptionsData, GetNewsSubscriptionsResponse, GetNewsSubscriptionsResponses, GetNotificationsData, GetNotificationsForwardersData, GetNotificationsForwardersResponse, GetNotificationsForwardersResponses, GetNotificationsResponse, GetNotificationsResponses, GetNotificationsTopicsData, GetNotificationsTopicsResponse, GetNotificationsTopicsResponses, GetNotificationsTopicTokensData, GetNotificationsTopicTokensResponse, GetNotificationsTopicTokensResponses, GetPageConfigData, GetPageConfigResponse, GetPageConfigResponses, GetPageConfigUserPagesData, GetPageConfigUserPagesResponse, GetPageConfigUserPagesResponses, GetSearchItemsData, GetSearchItemsFrequentlyUsedData, GetSearchItemsFrequentlyUsedResponse, GetSearchItemsFrequentlyUsedResponses, GetSearchItemsResponse, GetSearchItemsResponses, GetTestBookmarksData, GetTestBookmarksResponse, GetTestBookmarksResponses, GetWallpapersData, GetWallpapersResponse, GetWallpapersResponses, GetWeatherData, GetWeatherResponse, GetWeatherResponses, GetWidgetsByIntegrationData, GetWidgetsByIntegrationResponse, GetWidgetsByIntegrationResponses, GetWidgetsData, GetWidgetsGlanceableData, GetWidgetsGlanceableResponse, GetWidgetsGlanceableResponses, GetWidgetsGlanceablesData, GetWidgetsGlanceablesResponse, GetWidgetsGlanceablesResponses, GetWidgetsResponse, GetWidgetsResponses, Id, JsonBody, PatchAuthUpdateUserPropertyData, PatchAuthUpdateUserPropertyError, PatchAuthUpdateUserPropertyErrors, PatchAuthUpdateUserPropertyResponse, PatchAuthUpdateUserPropertyResponses, PostAuthChangePasswordData, PostAuthChangePasswordError, PostAuthChangePasswordErrors, PostAuthChangePasswordResponse, PostAuthChangePasswordResponses, PostAuthLoginData, PostAuthLoginError, PostAuthLoginErrors, PostAuthLoginResponse, PostAuthLoginResponses, PostAuthMfaData, PostAuthMfaError, PostAuthMfaErrors, PostAuthMfaResponse, PostAuthMfaResponses, PostAuthSignupData, PostAuthSignupError, PostAuthSignupErrors, PostAuthSignupResponse, PostAuthSignupResponses, PostAuthValidateAuthData, PostAuthValidateAuthError, PostAuthValidateAuthErrors, PostAuthValidateAuthResponse, PostAuthValidateAuthResponses, PostIntegrationsConsumerDataData, PostIntegrationsConsumerDataResponse, PostIntegrationsConsumerDataResponses, PostIntegrationsData, PostIntegrationsProxyActionData, PostIntegrationsProxyActionResponse, PostIntegrationsProxyActionResponses, PostIntegrationsResponse, PostIntegrationsResponses, PostIntegrationsTestEndpointData, PostIntegrationsTestEndpointResponse, PostIntegrationsTestEndpointResponses, PostLinksCollectionsData, PostLinksCollectionsResponse, PostLinksCollectionsResponses, PostLinksFoldersData, PostLinksFoldersResponse, PostLinksFoldersResponses, PostLinksHomeGroupsData, PostLinksHomeGroupsResponse, PostLinksHomeGroupsResponses, PostLinksItemsData, PostLinksItemsResponse, PostLinksItemsResponses, PostLinksReorderData, PostLinksReorderResponse, PostLinksReorderResponses, PostLinksTagsData, PostLinksTagsResponse, PostLinksTagsResponses, PostMonitoringHostsByIdRefreshData, PostMonitoringHostsByIdRefreshError, PostMonitoringHostsByIdRefreshErrors, PostMonitoringHostsByIdRefreshResponse, PostMonitoringHostsByIdRefreshResponses, PostMonitoringHostsData, PostMonitoringHostsResponse, PostMonitoringHostsResponses, PostMonitoringSshHostsData, PostMonitoringSshHostsResponse, PostMonitoringSshHostsResponses, PostMonitoringStatusData, PostMonitoringStatusError, PostMonitoringStatusErrors, PostMonitoringStatusResponse, PostMonitoringStatusResponses, PostMonitorsData, PostMonitorsResponse, PostMonitorsResponses, PostNewsFeedRecordsByIdData, PostNewsFeedRecordsByIdResponse, PostNewsFeedRecordsByIdResponses, PostNewsFeedRecordsData, PostNewsFeedRecordsResponse, PostNewsFeedRecordsResponses, PostNewsFeedRefreshData, PostNewsFeedRefreshResponse, PostNewsFeedRefreshResponses, PostNewsFeedSubscribeData, PostNewsFeedSubscribeResponse, PostNewsFeedSubscribeResponses, PostNewsFeedUnsubscribeData, PostNewsFeedUnsubscribeResponse, PostNewsFeedUnsubscribeResponses, PostNewsFeedUpdateData, PostNewsFeedUpdateResponse, PostNewsFeedUpdateResponses, PostNewsFixMissingTitlesData, PostNewsFixMissingTitlesResponse, PostNewsFixMissingTitlesResponses, PostNotificationsByTopicData, PostNotificationsByTopicResponse, PostNotificationsByTopicResponses, PostNotificationsData, PostNotificationsForwardersData, PostNotificationsForwardersResponse, PostNotificationsForwardersResponses, PostNotificationsForwardersTestData, PostNotificationsForwardersTestResponse, PostNotificationsForwardersTestResponses, PostNotificationsMarkAsReadData, PostNotificationsMarkAsReadResponse, PostNotificationsMarkAsReadResponses, PostNotificationsResponse, PostNotificationsResponses, PostNotificationsTestData, PostNotificationsTestResponse, PostNotificationsTestResponses, PostNotificationsTopicsData, PostNotificationsTopicsResponse, PostNotificationsTopicsResponses, PostNotificationsTopicTokensData, PostNotificationsTopicTokensResponse, PostNotificationsTopicTokensResponses, PostPageConfigHomeData, PostPageConfigHomeResponse, PostPageConfigHomeResponses, PostPageConfigIntegrationDataData, PostPageConfigIntegrationDataResponse, PostPageConfigIntegrationDataResponses, PostPageConfigMigrateLegacyData, PostPageConfigMigrateLegacyResponse, PostPageConfigMigrateLegacyResponses, PostSearchItemsUsageStatsData, PostSearchItemsUsageStatsResponse, PostSearchItemsUsageStatsResponses, PostWallpapersData, PostWallpapersResponse, PostWallpapersResponses, PutIntegrationsByIdData, PutIntegrationsByIdResponse, PutIntegrationsByIdResponses, PutLinksCollectionsByCollectionIdData, PutLinksCollectionsByCollectionIdResponse, PutLinksCollectionsByCollectionIdResponses, PutLinksFoldersByFolderIdIconData, PutLinksFoldersByFolderIdIconResponse, PutLinksFoldersByFolderIdIconResponses, PutLinksItemsByLinkIdData, PutLinksItemsByLinkIdResponse, PutLinksItemsByLinkIdResponses, PutLinksTagsByTagIdData, PutLinksTagsByTagIdResponse, PutLinksTagsByTagIdResponses, PutMonitoringHostsByIdData, PutMonitoringHostsByIdResponse, PutMonitoringHostsByIdResponses, PutMonitoringSshHostsByIdData, PutMonitoringSshHostsByIdResponse, PutMonitoringSshHostsByIdResponses, PutMonitorsByIdData, PutMonitorsByIdResponse, PutMonitorsByIdResponses, PutNotificationsForwardersData, PutNotificationsForwardersResponse, PutNotificationsForwardersResponses, PutNotificationsTopicTokensData, PutNotificationsTopicTokensResponse, PutNotificationsTopicTokensResponses, PutPageConfigData, PutPageConfigResponse, PutPageConfigResponses } from './types.gen'; diff --git a/apps/web/src/lib/api/sdk.gen.ts b/apps/web/src/lib/api/sdk.gen.ts index 3a3fd74d..f5bcb53a 100644 --- a/apps/web/src/lib/api/sdk.gen.ts +++ b/apps/web/src/lib/api/sdk.gen.ts @@ -3,7 +3,7 @@ import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client'; import { client } from './client.gen'; -import type { DeleteAuthDeleteAccountData, DeleteAuthDeleteAccountErrors, DeleteAuthDeleteAccountResponses, DeleteIntegrationsByIdData, DeleteIntegrationsByIdResponses, DeleteLinksItemsByLinkIdData, DeleteLinksItemsByLinkIdResponses, DeleteMonitoringHostsByIdData, DeleteMonitoringHostsByIdResponses, DeleteMonitoringSshHostsByIdData, DeleteMonitoringSshHostsByIdResponses, DeleteMonitorsByIdData, DeleteMonitorsByIdResponses, DeleteNotificationsForwardersData, DeleteNotificationsForwardersResponses, DeleteNotificationsTopicsData, DeleteNotificationsTopicsResponses, DeleteNotificationsTopicTokensData, DeleteNotificationsTopicTokensResponses, GetAppConfigData, GetAppConfigResponses, GetAppInfoData, GetAppInfoResponses, GetAuthCallbackData, GetAuthCallbackResponses, GetAuthSsoData, GetAuthSsoResponses, GetGlanceablesByIntegrationData, GetGlanceablesByIntegrationResponses, GetGlanceablesData, GetGlanceablesResponses, GetIntegrationsCaldavEventsData, GetIntegrationsCaldavEventsResponses, GetIntegrationsConsumerDataData, GetIntegrationsConsumerDataResponses, GetIntegrationsData, GetIntegrationsResponses, GetIntegrationsWidgetPropertiesData, GetIntegrationsWidgetPropertiesResponses, GetJobsPullIconsData, GetJobsPullIconsErrors, GetJobsPullIconsResponses, GetJobsSearchItemsData, GetJobsSearchItemsResponses, GetLinksCollectionsData, GetLinksCollectionsResponses, GetLinksFoldersData, GetLinksFoldersResponses, GetLinksHomeData, GetLinksHomeGroupsData, GetLinksHomeGroupsResponses, GetLinksHomeResponses, GetLinksItemsData, GetLinksItemsResponses, GetLinksTagsData, GetLinksTagsResponses, GetLocationsData, GetLocationsResponses, GetMonitoringHostsByIdData, GetMonitoringHostsByIdHistoryData, GetMonitoringHostsByIdHistoryResponses, GetMonitoringHostsByIdResponses, GetMonitoringHostsByIdStatsData, GetMonitoringHostsByIdStatsResponses, GetMonitoringHostsData, GetMonitoringHostsResponses, GetMonitoringSshHostsData, GetMonitoringSshHostsResponses, GetMonitoringStatusData, GetMonitoringStatusResponses, GetMonitorsByIdData, GetMonitorsByIdResponses, GetMonitorsData, GetMonitorsResponses, GetNewsData, GetNewsFeedData, GetNewsFeedMetadataData, GetNewsFeedMetadataResponses, GetNewsFeedRecordsByIdData, GetNewsFeedRecordsByIdResponses, GetNewsFeedRefreshData, GetNewsFeedRefreshResponses, GetNewsFeedResponses, GetNewsFeedsByIdData, GetNewsFeedsByIdResponses, GetNewsFeedsData, GetNewsFeedsResponses, GetNewsResponses, GetNewsSubscriptionsByIdJsonData, GetNewsSubscriptionsByIdJsonResponses, GetNewsSubscriptionsData, GetNewsSubscriptionsResponses, GetNotificationsData, GetNotificationsForwardersData, GetNotificationsForwardersResponses, GetNotificationsResponses, GetNotificationsTopicsData, GetNotificationsTopicsResponses, GetNotificationsTopicTokensData, GetNotificationsTopicTokensResponses, GetPageConfigData, GetPageConfigResponses, GetPageConfigUserPagesData, GetPageConfigUserPagesResponses, GetSearchItemsData, GetSearchItemsFrequentlyUsedData, GetSearchItemsFrequentlyUsedResponses, GetSearchItemsResponses, GetTestBookmarksData, GetTestBookmarksResponses, GetWallpapersData, GetWallpapersResponses, GetWeatherData, GetWeatherResponses, GetWidgetsByIntegrationData, GetWidgetsByIntegrationResponses, GetWidgetsData, GetWidgetsGlanceableData, GetWidgetsGlanceableResponses, GetWidgetsGlanceablesData, GetWidgetsGlanceablesResponses, GetWidgetsResponses, PatchAuthUpdateUserPropertyData, PatchAuthUpdateUserPropertyErrors, PatchAuthUpdateUserPropertyResponses, PostAuthChangePasswordData, PostAuthChangePasswordErrors, PostAuthChangePasswordResponses, PostAuthLoginData, PostAuthLoginErrors, PostAuthLoginResponses, PostAuthMfaData, PostAuthMfaErrors, PostAuthMfaResponses, PostAuthSignupData, PostAuthSignupErrors, PostAuthSignupResponses, PostAuthValidateAuthData, PostAuthValidateAuthErrors, PostAuthValidateAuthResponses, PostIntegrationsConsumerDataData, PostIntegrationsConsumerDataResponses, PostIntegrationsData, PostIntegrationsProxyActionData, PostIntegrationsProxyActionResponses, PostIntegrationsResponses, PostIntegrationsTestEndpointData, PostIntegrationsTestEndpointResponses, PostLinksCollectionsData, PostLinksCollectionsResponses, PostLinksFoldersData, PostLinksFoldersResponses, PostLinksHomeGroupsData, PostLinksHomeGroupsResponses, PostLinksItemsData, PostLinksItemsResponses, PostLinksReorderData, PostLinksReorderResponses, PostLinksTagsData, PostLinksTagsResponses, PostMonitoringHostsByIdRefreshData, PostMonitoringHostsByIdRefreshErrors, PostMonitoringHostsByIdRefreshResponses, PostMonitoringHostsData, PostMonitoringHostsResponses, PostMonitoringSshHostsData, PostMonitoringSshHostsResponses, PostMonitoringStatusData, PostMonitoringStatusErrors, PostMonitoringStatusResponses, PostMonitorsData, PostMonitorsResponses, PostNewsFeedRecordsByIdData, PostNewsFeedRecordsByIdResponses, PostNewsFeedRecordsData, PostNewsFeedRecordsResponses, PostNewsFeedRefreshData, PostNewsFeedRefreshResponses, PostNewsFeedSubscribeData, PostNewsFeedSubscribeResponses, PostNewsFeedUnsubscribeData, PostNewsFeedUnsubscribeResponses, PostNewsFeedUpdateData, PostNewsFeedUpdateResponses, PostNewsFixMissingTitlesData, PostNewsFixMissingTitlesResponses, PostNotificationsByTopicData, PostNotificationsByTopicResponses, PostNotificationsData, PostNotificationsForwardersData, PostNotificationsForwardersResponses, PostNotificationsMarkAsReadData, PostNotificationsMarkAsReadResponses, PostNotificationsResponses, PostNotificationsTestData, PostNotificationsTestResponses, PostNotificationsTopicsData, PostNotificationsTopicsResponses, PostNotificationsTopicTokensData, PostNotificationsTopicTokensResponses, PostPageConfigHomeData, PostPageConfigHomeResponses, PostPageConfigIntegrationDataData, PostPageConfigIntegrationDataResponses, PostPageConfigMigrateLegacyData, PostPageConfigMigrateLegacyResponses, PostSearchItemsUsageStatsData, PostSearchItemsUsageStatsResponses, PostWallpapersData, PostWallpapersResponses, PutIntegrationsByIdData, PutIntegrationsByIdResponses, PutLinksCollectionsByCollectionIdData, PutLinksCollectionsByCollectionIdResponses, PutLinksFoldersByFolderIdIconData, PutLinksFoldersByFolderIdIconResponses, PutLinksItemsByLinkIdData, PutLinksItemsByLinkIdResponses, PutLinksTagsByTagIdData, PutLinksTagsByTagIdResponses, PutMonitoringHostsByIdData, PutMonitoringHostsByIdResponses, PutMonitoringSshHostsByIdData, PutMonitoringSshHostsByIdResponses, PutMonitorsByIdData, PutMonitorsByIdResponses, PutNotificationsForwardersData, PutNotificationsForwardersResponses, PutPageConfigData, PutPageConfigResponses } from './types.gen'; +import type { DeleteAuthDeleteAccountData, DeleteAuthDeleteAccountErrors, DeleteAuthDeleteAccountResponses, DeleteIntegrationsByIdData, DeleteIntegrationsByIdResponses, DeleteLinksItemsByLinkIdData, DeleteLinksItemsByLinkIdResponses, DeleteMonitoringHostsByIdData, DeleteMonitoringHostsByIdResponses, DeleteMonitoringSshHostsByIdData, DeleteMonitoringSshHostsByIdResponses, DeleteMonitorsByIdData, DeleteMonitorsByIdResponses, DeleteNotificationsForwardersData, DeleteNotificationsForwardersResponses, DeleteNotificationsTopicsData, DeleteNotificationsTopicsResponses, DeleteNotificationsTopicTokensData, DeleteNotificationsTopicTokensResponses, GetAppConfigData, GetAppConfigResponses, GetAppInfoData, GetAppInfoResponses, GetAuthCallbackData, GetAuthCallbackResponses, GetAuthSsoData, GetAuthSsoResponses, GetGlanceablesByIntegrationData, GetGlanceablesByIntegrationResponses, GetGlanceablesData, GetGlanceablesResponses, GetIntegrationsCaldavEventsData, GetIntegrationsCaldavEventsResponses, GetIntegrationsConsumerDataData, GetIntegrationsConsumerDataResponses, GetIntegrationsData, GetIntegrationsResponses, GetIntegrationsWidgetPropertiesData, GetIntegrationsWidgetPropertiesResponses, GetJobsPullIconsData, GetJobsPullIconsErrors, GetJobsPullIconsResponses, GetJobsSearchItemsData, GetJobsSearchItemsResponses, GetLinksCollectionsData, GetLinksCollectionsResponses, GetLinksFoldersData, GetLinksFoldersResponses, GetLinksHomeData, GetLinksHomeGroupsData, GetLinksHomeGroupsResponses, GetLinksHomeResponses, GetLinksItemsData, GetLinksItemsResponses, GetLinksTagsData, GetLinksTagsResponses, GetLocationsData, GetLocationsResponses, GetMonitoringHostsByIdData, GetMonitoringHostsByIdHistoryData, GetMonitoringHostsByIdHistoryResponses, GetMonitoringHostsByIdResponses, GetMonitoringHostsByIdStatsData, GetMonitoringHostsByIdStatsResponses, GetMonitoringHostsData, GetMonitoringHostsResponses, GetMonitoringSshHostsData, GetMonitoringSshHostsResponses, GetMonitoringStatusData, GetMonitoringStatusResponses, GetMonitorsByIdData, GetMonitorsByIdResponses, GetMonitorsData, GetMonitorsResponses, GetNewsData, GetNewsFeedData, GetNewsFeedMetadataData, GetNewsFeedMetadataResponses, GetNewsFeedRecordsByIdData, GetNewsFeedRecordsByIdResponses, GetNewsFeedRefreshData, GetNewsFeedRefreshResponses, GetNewsFeedResponses, GetNewsFeedsByIdData, GetNewsFeedsByIdResponses, GetNewsFeedsData, GetNewsFeedsResponses, GetNewsResponses, GetNewsSubscriptionsByIdJsonData, GetNewsSubscriptionsByIdJsonResponses, GetNewsSubscriptionsData, GetNewsSubscriptionsResponses, GetNotificationsData, GetNotificationsForwardersData, GetNotificationsForwardersResponses, GetNotificationsResponses, GetNotificationsTopicsData, GetNotificationsTopicsResponses, GetNotificationsTopicTokensData, GetNotificationsTopicTokensResponses, GetPageConfigData, GetPageConfigResponses, GetPageConfigUserPagesData, GetPageConfigUserPagesResponses, GetSearchItemsData, GetSearchItemsFrequentlyUsedData, GetSearchItemsFrequentlyUsedResponses, GetSearchItemsResponses, GetTestBookmarksData, GetTestBookmarksResponses, GetWallpapersData, GetWallpapersResponses, GetWeatherData, GetWeatherResponses, GetWidgetsByIntegrationData, GetWidgetsByIntegrationResponses, GetWidgetsData, GetWidgetsGlanceableData, GetWidgetsGlanceableResponses, GetWidgetsGlanceablesData, GetWidgetsGlanceablesResponses, GetWidgetsResponses, PatchAuthUpdateUserPropertyData, PatchAuthUpdateUserPropertyErrors, PatchAuthUpdateUserPropertyResponses, PostAuthChangePasswordData, PostAuthChangePasswordErrors, PostAuthChangePasswordResponses, PostAuthLoginData, PostAuthLoginErrors, PostAuthLoginResponses, PostAuthMfaData, PostAuthMfaErrors, PostAuthMfaResponses, PostAuthSignupData, PostAuthSignupErrors, PostAuthSignupResponses, PostAuthValidateAuthData, PostAuthValidateAuthErrors, PostAuthValidateAuthResponses, PostIntegrationsConsumerDataData, PostIntegrationsConsumerDataResponses, PostIntegrationsData, PostIntegrationsProxyActionData, PostIntegrationsProxyActionResponses, PostIntegrationsResponses, PostIntegrationsTestEndpointData, PostIntegrationsTestEndpointResponses, PostLinksCollectionsData, PostLinksCollectionsResponses, PostLinksFoldersData, PostLinksFoldersResponses, PostLinksHomeGroupsData, PostLinksHomeGroupsResponses, PostLinksItemsData, PostLinksItemsResponses, PostLinksReorderData, PostLinksReorderResponses, PostLinksTagsData, PostLinksTagsResponses, PostMonitoringHostsByIdRefreshData, PostMonitoringHostsByIdRefreshErrors, PostMonitoringHostsByIdRefreshResponses, PostMonitoringHostsData, PostMonitoringHostsResponses, PostMonitoringSshHostsData, PostMonitoringSshHostsResponses, PostMonitoringStatusData, PostMonitoringStatusErrors, PostMonitoringStatusResponses, PostMonitorsData, PostMonitorsResponses, PostNewsFeedRecordsByIdData, PostNewsFeedRecordsByIdResponses, PostNewsFeedRecordsData, PostNewsFeedRecordsResponses, PostNewsFeedRefreshData, PostNewsFeedRefreshResponses, PostNewsFeedSubscribeData, PostNewsFeedSubscribeResponses, PostNewsFeedUnsubscribeData, PostNewsFeedUnsubscribeResponses, PostNewsFeedUpdateData, PostNewsFeedUpdateResponses, PostNewsFixMissingTitlesData, PostNewsFixMissingTitlesResponses, PostNotificationsByTopicData, PostNotificationsByTopicResponses, PostNotificationsData, PostNotificationsForwardersData, PostNotificationsForwardersResponses, PostNotificationsForwardersTestData, PostNotificationsForwardersTestResponses, PostNotificationsMarkAsReadData, PostNotificationsMarkAsReadResponses, PostNotificationsResponses, PostNotificationsTestData, PostNotificationsTestResponses, PostNotificationsTopicsData, PostNotificationsTopicsResponses, PostNotificationsTopicTokensData, PostNotificationsTopicTokensResponses, PostPageConfigHomeData, PostPageConfigHomeResponses, PostPageConfigIntegrationDataData, PostPageConfigIntegrationDataResponses, PostPageConfigMigrateLegacyData, PostPageConfigMigrateLegacyResponses, PostSearchItemsUsageStatsData, PostSearchItemsUsageStatsResponses, PostWallpapersData, PostWallpapersResponses, PutIntegrationsByIdData, PutIntegrationsByIdResponses, PutLinksCollectionsByCollectionIdData, PutLinksCollectionsByCollectionIdResponses, PutLinksFoldersByFolderIdIconData, PutLinksFoldersByFolderIdIconResponses, PutLinksItemsByLinkIdData, PutLinksItemsByLinkIdResponses, PutLinksTagsByTagIdData, PutLinksTagsByTagIdResponses, PutMonitoringHostsByIdData, PutMonitoringHostsByIdResponses, PutMonitoringSshHostsByIdData, PutMonitoringSshHostsByIdResponses, PutMonitorsByIdData, PutMonitorsByIdResponses, PutNotificationsForwardersData, PutNotificationsForwardersResponses, PutNotificationsTopicTokensData, PutNotificationsTopicTokensResponses, PutPageConfigData, PutPageConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -513,6 +513,18 @@ export const putNotificationsForwarders = } }); +/** + * Test forwarder + */ +export const postNotificationsForwardersTest = (options?: Options): RequestResult => (options?.client ?? client).post({ + url: '/notifications/forwarders/test', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + /** * Mark notifications as read */ @@ -595,6 +607,18 @@ export const postNotificationsTopicTokens = (options?: Options): RequestResult => (options?.client ?? client).put({ + url: '/notifications/topicTokens', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + /** * Search items */ diff --git a/apps/web/src/lib/api/types.gen.ts b/apps/web/src/lib/api/types.gen.ts index c84a1f3f..3fdd1619 100644 --- a/apps/web/src/lib/api/types.gen.ts +++ b/apps/web/src/lib/api/types.gen.ts @@ -1087,6 +1087,22 @@ export type PutNotificationsForwardersResponses = { export type PutNotificationsForwardersResponse = PutNotificationsForwardersResponses[keyof PutNotificationsForwardersResponses]; +export type PostNotificationsForwardersTestData = { + body?: JsonBody; + path?: never; + query?: never; + url: '/notifications/forwarders/test'; +}; + +export type PostNotificationsForwardersTestResponses = { + /** + * OK + */ + 200: GenericObject; +}; + +export type PostNotificationsForwardersTestResponse = PostNotificationsForwardersTestResponses[keyof PostNotificationsForwardersTestResponses]; + export type PostNotificationsMarkAsReadData = { body?: JsonBody; path?: never; @@ -1217,6 +1233,22 @@ export type PostNotificationsTopicTokensResponses = { export type PostNotificationsTopicTokensResponse = PostNotificationsTopicTokensResponses[keyof PostNotificationsTopicTokensResponses]; +export type PutNotificationsTopicTokensData = { + body?: JsonBody; + path?: never; + query?: never; + url: '/notifications/topicTokens'; +}; + +export type PutNotificationsTopicTokensResponses = { + /** + * OK + */ + 200: GenericObject; +}; + +export type PutNotificationsTopicTokensResponse = PutNotificationsTopicTokensResponses[keyof PutNotificationsTopicTokensResponses]; + export type GetSearchItemsData = { body?: never; path?: never; diff --git a/apps/web/src/lib/apiClient.ts b/apps/web/src/lib/apiClient.ts index 7469a910..5d59fcc5 100644 --- a/apps/web/src/lib/apiClient.ts +++ b/apps/web/src/lib/apiClient.ts @@ -19,7 +19,7 @@ import config from "@/lib/config"; import { client } from "./api/client.gen"; import * as sdk from "./api/sdk.gen"; -const { getAppConfig, getAppInfo, postAuthLogin, postAuthChangePassword, postAuthSignup, postAuthValidateAuth, deleteAuthDeleteAccount, patchAuthUpdateUserProperty, getLinksCollections, postLinksCollections, putLinksCollectionsByCollectionId, postLinksTags, putLinksTagsByTagId, getLinksHomeGroups, postLinksHomeGroups, putLinksFoldersByFolderIdIcon, getLinksHome, getLinksFolders, postLinksFolders, getLinksItems, getLinksTags, postLinksItems, putLinksItemsByLinkId, deleteLinksItemsByLinkId, postLinksReorder, getIntegrations, postIntegrations, putIntegrationsById, deleteIntegrationsById, postIntegrationsTestEndpoint, getIntegrationsWidgetProperties, getWidgetsByIntegration, postIntegrationsConsumerData, getIntegrationsCaldavEvents, postIntegrationsProxyAction, getWidgets, getGlanceables, getGlanceablesByIntegration, getMonitoringStatus, postMonitoringStatus, getMonitoringSshHosts, postMonitoringSshHosts, putMonitoringSshHostsById, getMonitoringHosts, postMonitoringHosts, getMonitoringHostsByIdHistory, getMonitors, getMonitorsById, putMonitorsById, postMonitors, deleteMonitorsById, getNewsFeedRecordsById, postNewsFeedRecords, getNewsSubscriptions, getNewsFeeds, getNewsFeedMetadata, postNewsFeedRefresh, postNewsFeedSubscribe, postNewsFeedUnsubscribe, postNewsFeedUpdate, postNewsFeedRecordsById, postNewsFixMissingTitles, getPageConfig, getPageConfigUserPages, putPageConfig, postPageConfigHome, postPageConfigMigrateLegacy, postPageConfigIntegrationData, getSearchItems, getSearchItemsFrequentlyUsed, postSearchItemsUsageStats, getLocations, getJobsPullIcons, postWallpapers, getNotifications, getNotificationsTopics, postNotificationsTopics, deleteNotificationsTopics, postNotificationsMarkAsRead, postNotificationsTest, getNotificationsTopicTokens, postNotificationsTopicTokens, deleteNotificationsTopicTokens, getNotificationsForwarders, postNotificationsForwarders, putNotificationsForwarders, deleteNotificationsForwarders } = sdk; +const { getAppConfig, getAppInfo, postAuthLogin, postAuthChangePassword, postAuthSignup, postAuthValidateAuth, deleteAuthDeleteAccount, patchAuthUpdateUserProperty, getLinksCollections, postLinksCollections, putLinksCollectionsByCollectionId, postLinksTags, putLinksTagsByTagId, getLinksHomeGroups, postLinksHomeGroups, putLinksFoldersByFolderIdIcon, getLinksHome, getLinksFolders, postLinksFolders, getLinksItems, getLinksTags, postLinksItems, putLinksItemsByLinkId, deleteLinksItemsByLinkId, postLinksReorder, getIntegrations, postIntegrations, putIntegrationsById, deleteIntegrationsById, postIntegrationsTestEndpoint, getIntegrationsWidgetProperties, getWidgetsByIntegration, postIntegrationsConsumerData, getIntegrationsCaldavEvents, postIntegrationsProxyAction, getWidgets, getGlanceables, getGlanceablesByIntegration, getMonitoringStatus, postMonitoringStatus, getMonitoringSshHosts, postMonitoringSshHosts, putMonitoringSshHostsById, getMonitoringHosts, postMonitoringHosts, getMonitoringHostsByIdHistory, getMonitors, getMonitorsById, putMonitorsById, postMonitors, deleteMonitorsById, getNewsFeedRecordsById, postNewsFeedRecords, getNewsSubscriptions, getNewsFeeds, getNewsFeedMetadata, postNewsFeedRefresh, postNewsFeedSubscribe, postNewsFeedUnsubscribe, postNewsFeedUpdate, postNewsFeedRecordsById, postNewsFixMissingTitles, getPageConfig, getPageConfigUserPages, putPageConfig, postPageConfigHome, postPageConfigMigrateLegacy, postPageConfigIntegrationData, getSearchItems, getSearchItemsFrequentlyUsed, postSearchItemsUsageStats, getLocations, getJobsPullIcons, postWallpapers, getNotifications, getNotificationsTopics, postNotificationsTopics, deleteNotificationsTopics, postNotificationsMarkAsRead, postNotificationsTest, getNotificationsTopicTokens, postNotificationsTopicTokens, deleteNotificationsTopicTokens, putNotificationsTopicTokens, getNotificationsForwarders, postNotificationsForwarders, putNotificationsForwarders, deleteNotificationsForwarders, postNotificationsForwardersTest } = sdk; export * from "./api/sdk.gen"; export type { GenericObject, Error } from "./api/types.gen"; @@ -694,6 +694,14 @@ export async function sendTestNotificationAction(auth: ActionAuth, topicId: stri return extractData(await postNotificationsTest({ body: { auth, topicId }, headers: authHeaders(auth) })); } +export async function testForwarderAction(auth: ActionAuth, forwarderId: string) { + return extractData(await postNotificationsForwardersTest({ body: { auth, forwarderId }, headers: authHeaders(auth) })); +} + +export async function testForwarderTargetAction(auth: ActionAuth, target: string) { + return extractData(await postNotificationsForwardersTest({ body: { auth, target }, headers: authHeaders(auth) })); +} + export async function listTopicTokensAction(auth: ActionAuth) { return extractData(await getNotificationsTopicTokens({ headers: authHeaders(auth) })); } @@ -706,6 +714,10 @@ export async function deleteTopicTokenAction(auth: ActionAuth, tokenId: string) return extractData(await deleteNotificationsTopicTokens({ body: { auth, tokenId }, headers: authHeaders(auth) })); } +export async function updateTopicTokenAction(auth: ActionAuth, body: any) { + return extractData(await putNotificationsTopicTokens({ body, headers: authHeaders(auth) })); +} + export async function getForwardersAction(auth: ActionAuth) { return extractData(await getNotificationsForwarders({ headers: authHeaders(auth) })); } diff --git a/packages/api-types/src/openapi.ts b/packages/api-types/src/openapi.ts index 508c052f..d0418d54 100644 --- a/packages/api-types/src/openapi.ts +++ b/packages/api-types/src/openapi.ts @@ -1437,6 +1437,34 @@ export interface paths { patch?: never; trace?: never; }; + "/notifications/forwarders/test": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Test forwarder */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: components["requestBodies"]["JsonBody"]; + responses: { + 200: components["responses"]["JsonOk"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/notifications/markAsRead": { parameters: { query?: never; @@ -1567,7 +1595,19 @@ export interface paths { 200: components["responses"]["JsonOk"]; }; }; - put?: never; + /** Update topic token */ + put: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: components["requestBodies"]["JsonBody"]; + responses: { + 200: components["responses"]["JsonOk"]; + }; + }; /** Create topic token */ post: { parameters: { From 891347b244efd87cc46a7dcccaaa7e052492751f Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Mon, 24 Aug 2026 14:37:28 +0200 Subject: [PATCH 11/57] feat(config): add shoutrrr binary path --- apps/backend/src/lib/config.ts | 4 +--- .../src/lib/data/notifications/forwarding.ts | 18 +++++++----------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/lib/config.ts b/apps/backend/src/lib/config.ts index fc0fa4b6..5043a05e 100644 --- a/apps/backend/src/lib/config.ts +++ b/apps/backend/src/lib/config.ts @@ -48,6 +48,7 @@ export const config = { PORT: Number(env.PORT) || 3000, PB_URL: getEnv("PB_URL", "NEXT_PUBLIC_PB_URL") || "http://127.0.0.1:8090", PB_BINARY_PATH: env.PB_BINARY_PATH, + SHOUTRRR_BINARY_PATH: env.SHOUTRRR_BINARY_PATH, LOG_LEVEL: getLogLevel(), START_POCKETBASE: processStartPocketBase == null @@ -76,9 +77,6 @@ export const config = { INSTANCE_NAME: getEnv("INSTANCE_NAME", "NEXT_PUBLIC_INSTANCE_NAME") || "Dashwise", DISABLE_USER_SIGNUP: truthyEnv(getEnv("DISABLE_USER_SIGNUP", "NEXT_PUBLIC_DISABLE_USER_SIGNUP")), ENABLE_SSO: truthyEnv(getEnv("ENABLE_SSO", "NEXT_PUBLIC_ENABLE_SSO")), - JOBS_URL: getEnv("JOBS_URL", "NEXT_PUBLIC_JOBS_URL") || "http://127.0.0.1:3001", - JOBS_WEBHOOK_URL: env.JOBS_WEBHOOK_URL || "http://jobs:3000/api/forward-notifications", - JOBS_WEBHOOK_ENABLED: truthyEnv(getEnv("JOBS_WEBHOOK_ENABLE", "NEXT_PUBLIC_JOBS_WEBHOOK_ENABLE")) || !!getEnv("JOBS_URL", "NEXT_PUBLIC_JOBS_URL"), DEFAULT_BG_URL: getEnv("DEFAULT_BG_URL", "NEXT_PUBLIC_DEFAULT_BG_URL") || "/dashboard-wallpaper.png", allowInsecureCertsForIntegrationUrls: truthyEnv(getEnv("ALLOW_INSECURE_CERTS_FOR_INTEGRATION_URLS", "NEXT_PUBLIC_INTEGRATIONS_ENABLE_SSL")) || truthyEnv(env.ALLOW_SSL), } as const; diff --git a/apps/backend/src/lib/data/notifications/forwarding.ts b/apps/backend/src/lib/data/notifications/forwarding.ts index b8b3cef9..61380228 100644 --- a/apps/backend/src/lib/data/notifications/forwarding.ts +++ b/apps/backend/src/lib/data/notifications/forwarding.ts @@ -1,7 +1,8 @@ -import { exec } from "child_process"; +import { execFile } from "child_process"; import { promisify } from "util"; +import { config } from "../../config"; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); export function formatNotificationMessage(content: unknown): string { if (typeof content === "string") { @@ -38,16 +39,11 @@ export async function sendViaShoutrrr(target: string, message: string): Promise< throw new Error("Invalid target"); } - const safeTarget = target.replace(/'/g, "'\\''"); - const safeMessage = message.replace(/'/g, "'\\''"); - try { - const { stderr } = await execAsync( - `shoutrrr send --url '${safeTarget}' --message '${safeMessage}'`, - { - env: { ...process.env }, - timeout: 30000, - } + const { stderr } = await execFileAsync( + config.SHOUTRRR_BINARY_PATH || "shoutrrr", + ["send", "--url", target, "--message", message], + { env: { ...process.env }, timeout: 30000 }, ); if (stderr) { From eea1aada4b2597d7b391107790c6ef6cc1fb7c54 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Mon, 24 Aug 2026 14:37:44 +0200 Subject: [PATCH 12/57] fix(docker): configure shoutrrr binary --- Dockerfile | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Dockerfile b/Dockerfile index bb226484..07214eab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,21 @@ RUN case "${TARGETARCH}" in \ rm /tmp/pocketbase.zip && \ chmod +x /usr/local/bin/pocketbase +FROM alpine:latest AS shoutrrr +ARG SHOUTRRR_VERSION=0.8.0 +ARG TARGETARCH + +RUN case "${TARGETARCH}" in \ + "arm64") ARCH="arm64" ;; \ + "amd64") ARCH="amd64" ;; \ + *) echo "Unsupported architecture: ${TARGETARCH}" && exit 1 ;; \ + esac && \ + apk add --no-cache wget && \ + wget -O /tmp/shoutrrr.tar.gz "https://github.com/containrrr/shoutrrr/releases/download/v${SHOUTRRR_VERSION}/shoutrrr_linux_${ARCH}.tar.gz" && \ + tar -xzf /tmp/shoutrrr.tar.gz -C /usr/local/bin shoutrrr && \ + rm /tmp/shoutrrr.tar.gz && \ + chmod +x /usr/local/bin/shoutrrr + FROM oven/bun:1-alpine AS deps WORKDIR /app @@ -52,8 +67,10 @@ WORKDIR /app RUN apk add --no-cache valkey COPY --from=pocketbase /usr/local/bin/pocketbase /usr/local/bin/pocketbase +COPY --from=shoutrrr /usr/local/bin/shoutrrr /usr/local/bin/shoutrrr COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh ENV PB_BINARY_PATH=/usr/local/bin/pocketbase +ENV SHOUTRRR_BINARY_PATH=/usr/local/bin/shoutrrr ENV NODE_PATH=/app/apps/backend:/app/packages RUN mkdir -p /app && touch /app/.root.ind From f1159ff03bbc18afee313a2796c5f712795f6db3 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Mon, 24 Aug 2026 14:38:02 +0200 Subject: [PATCH 13/57] fix(notification): run queued forwarders --- apps/backend/src/jobs/monitoring/runner.ts | 11 ++------ apps/backend/src/lib/activity.ts | 20 +++++++++++++ apps/backend/src/lib/data/monitoring.ts | 28 +++++-------------- .../src/lib/data/notifications/items.ts | 12 ++------ .../src/lib/data/notifications/publish.ts | 21 +++++++------- .../notifications/CreateForwarderDialog.tsx | 21 ++++++++++++-- .../notifications/NotificationsPage.tsx | 22 ++++++++------- apps/web/src/lib/config.ts | 11 -------- apps/web/src/vite-env.d.ts | 2 -- docs/Configuration.md | 7 ++--- 10 files changed, 74 insertions(+), 81 deletions(-) create mode 100644 apps/backend/src/lib/activity.ts diff --git a/apps/backend/src/jobs/monitoring/runner.ts b/apps/backend/src/jobs/monitoring/runner.ts index 33118cbb..ba981e2e 100644 --- a/apps/backend/src/jobs/monitoring/runner.ts +++ b/apps/backend/src/jobs/monitoring/runner.ts @@ -6,10 +6,7 @@ import { getUserConfigsByAssociatedUserId, updateMonitoringJob, } from "../../lib/data/superuser"; -import { - createNotificationByTopicId, - queueNotificationForForwarding, -} from "../../lib/data/notifications/publish"; +import { createNotificationByTopicId } from "../../lib/data/notifications/publish"; import { createLogger } from "../../lib/logger"; import { getLinkIdFromSource, getLinkSource, parseConfigObject, type StatusCheckMethod } from "./shared"; @@ -185,8 +182,7 @@ export async function runStatusMonitoringJobsWithOptions(options?: { if (job.notifyOnStatusChange && job.notifyTopicId) { try { const content = `Monitor "${job.title || job.endpoint || job.source || 'Unnamed'}" changed from ${currentStatus} to ${newStatus}`; - const { itemId } = await createNotificationByTopicId(job.notifyTopicId, content, 'monitoring'); - await queueNotificationForForwarding(itemId); + await createNotificationByTopicId(job.notifyTopicId, content, 'monitoring'); } catch (err: any) { logger.error("Failed to send notification for monitor status change", { jobId: job.id, error: err.message }); } @@ -241,8 +237,7 @@ export async function runStatusMonitoringJobsWithOptions(options?: { if (job.notifyOnStatusChange && job.notifyTopicId) { try { const content = `Monitor "${job.title || job.endpoint || job.source || 'Unnamed'}" changed from ${currentStatus} to unhealthy (fetch error)`; - const { itemId } = await createNotificationByTopicId(job.notifyTopicId, content, 'monitoring'); - await queueNotificationForForwarding(itemId); + await createNotificationByTopicId(job.notifyTopicId, content, 'monitoring'); } catch (nerr: any) { logger.error("Failed to send notification for monitor status change (error)", { jobId: job.id, error: nerr.message }); } diff --git a/apps/backend/src/lib/activity.ts b/apps/backend/src/lib/activity.ts new file mode 100644 index 00000000..7905b5a2 --- /dev/null +++ b/apps/backend/src/lib/activity.ts @@ -0,0 +1,20 @@ +type ActivitySubscriber = () => Promise; + +const subscribers = new Map>(); + +export function subscribeActivity(userId: string, subscriber: ActivitySubscriber) { + const userSubscribers = subscribers.get(userId) ?? new Set(); + userSubscribers.add(subscriber); + subscribers.set(userId, userSubscribers); + + return () => { + userSubscribers.delete(subscriber); + if (userSubscribers.size === 0) subscribers.delete(userId); + }; +} + +export function broadcastActivity(userId: string) { + for (const subscriber of subscribers.get(userId) ?? []) { + void subscriber().catch(() => undefined); + } +} diff --git a/apps/backend/src/lib/data/monitoring.ts b/apps/backend/src/lib/data/monitoring.ts index 9b20e162..19962f7c 100644 --- a/apps/backend/src/lib/data/monitoring.ts +++ b/apps/backend/src/lib/data/monitoring.ts @@ -1,4 +1,5 @@ import { config } from "../config"; +import { runStatusMonitoringJobsWithOptions } from "../../jobs/monitoring/runner"; import { decryptSecret, encryptSecret } from "../crypto"; import { getSuperuserPB } from "../pb/pocketbase"; import type { MonitorsResponse } from "@dashwise/types"; @@ -603,25 +604,12 @@ export async function runMonitoringStatus(userId: string, body: any) { return { _status: 404, error: "Monitoring job not found for this user" }; } - if (!config.JOBS_WEBHOOK_ENABLED) { - return { _status: 400, error: "Jobs webhook is disabled" }; - } - const targetMonitor = existingMonitors[0]; const sourceLinkId = targetMonitor.sourcelinkId || targetMonitor.linkId; - - const webhookUrl = `${config.JOBS_URL}/webhook/statusMonitoringRunner${ - sourceLinkId ? `?linkId=${encodeURIComponent(sourceLinkId)}` : "" - }`; - const webhookResponse = await fetch(webhookUrl, { - ...(webhookUrl.startsWith("https://") - ? { tls: { rejectUnauthorized: false } } - : {}), - } as any); - const webhookContentType = webhookResponse.headers.get("content-type") || ""; - const webhookData = webhookContentType.includes("application/json") - ? await webhookResponse.json() - : await webhookResponse.text(); + const runnerData = await runStatusMonitoringJobsWithOptions({ + source: targetMonitor.source, + linkId: sourceLinkId, + }); const refreshedMonitors = await pb .collection("monitors") @@ -629,9 +617,7 @@ export async function runMonitoringStatus(userId: string, body: any) { const refreshedMonitor = refreshedMonitors[0] || targetMonitor; const statusSummary = getLatestMonitorStatus(refreshedMonitor); - const runnerDetails = typeof webhookData === "object" && webhookData - ? (webhookData as any)?.result?.details - : undefined; + const runnerDetails = runnerData.details; const matchingRunnerDetail = Array.isArray(runnerDetails) ? runnerDetails.find((entry: any) => entry?.jobId === refreshedMonitor.id) || runnerDetails[0] : undefined; @@ -656,6 +642,6 @@ export async function runMonitoringStatus(userId: string, body: any) { httpStatus: matchingRunnerDetail?.httpStatus, method: matchingRunnerDetail?.method, result: matchingRunnerDetail, - webhookResult: webhookData, + runnerResult: runnerData, }; } diff --git a/apps/backend/src/lib/data/notifications/items.ts b/apps/backend/src/lib/data/notifications/items.ts index a04a80d7..3c51bad0 100644 --- a/apps/backend/src/lib/data/notifications/items.ts +++ b/apps/backend/src/lib/data/notifications/items.ts @@ -1,5 +1,5 @@ import { getSuperuserPB } from "../../pb/pocketbase"; -import { config } from "../../config"; +import { queueNotificationForForwarding } from "./publish"; import type { NotificationForwardersResponse, NotificationItemsResponse, @@ -151,15 +151,7 @@ export async function sendTestNotification(userId: string, topicId: string) { forwardStatus: "queued", }); - try { - const jobsUrl = config.JOBS_WEBHOOK_URL; - await fetch(jobsUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ trigger: "notification-queued", itemId: item.id }), - }); - } catch { - } + await queueNotificationForForwarding(item.id); return { ok: true, itemId: item.id }; } diff --git a/apps/backend/src/lib/data/notifications/publish.ts b/apps/backend/src/lib/data/notifications/publish.ts index 547c240d..26bbab78 100644 --- a/apps/backend/src/lib/data/notifications/publish.ts +++ b/apps/backend/src/lib/data/notifications/publish.ts @@ -1,5 +1,6 @@ import { getServerPB, getSuperuserPB } from "../../pb/pocketbase"; -import { config } from "../../config"; +import { broadcastActivity } from "../../activity"; +import { processQueuedNotifications } from "../../../jobs/notifications/forwarder"; import { resolveTopicToken } from "./topicTokens"; import type { NotificationItemsResponse, NotificationTopicsResponse } from "@dashwise/types"; @@ -24,6 +25,8 @@ export async function createNotificationWithTopicToken(topicToken: string, conte source: "token", forwardStatus: "none", })) as NotificationItemsResponse; + broadcastActivity(resolved.userId); + await queueNotificationForForwarding(createdItem.id); return { topicId: resolved.topicId, @@ -62,6 +65,8 @@ export async function createNotificationForUserTopic(input: PublishToUserTopicIn source, forwardStatus: "none", })) as NotificationItemsResponse; + broadcastActivity(userId); + await queueNotificationForForwarding(notificationItem.id); return { topicId, @@ -75,6 +80,7 @@ export async function createNotificationByTopicId( source = "system", ) { const pb = await getSuperuserPB(); + const topicRecord = (await pb.collection("notificationTopics").getOne(topicId)) as NotificationTopicsResponse; const notificationItem = (await pb.collection("notificationItems").create({ topicId, content, @@ -82,6 +88,8 @@ export async function createNotificationByTopicId( source, forwardStatus: "none", })) as NotificationItemsResponse; + broadcastActivity(topicRecord.userId); + await queueNotificationForForwarding(notificationItem.id); return { topicId, @@ -94,14 +102,5 @@ export async function queueNotificationForForwarding(itemId: string) { await pb.collection("notificationItems").update(itemId, { forwardStatus: "queued", }); - - try { - const jobsUrl = config.JOBS_WEBHOOK_URL; - await fetch(jobsUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ trigger: "notification-queued", itemId }), - }); - } catch { - } + await processQueuedNotifications(); } diff --git a/apps/web/src/components/notifications/CreateForwarderDialog.tsx b/apps/web/src/components/notifications/CreateForwarderDialog.tsx index c0668511..e63eb613 100644 --- a/apps/web/src/components/notifications/CreateForwarderDialog.tsx +++ b/apps/web/src/components/notifications/CreateForwarderDialog.tsx @@ -12,6 +12,8 @@ import { import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Checkbox } from "../ui/checkbox"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { AlertTriangle } from "lucide-react"; import TopicCombobox, { type Topic } from "./TopicCombobox"; import { createForwarderAction, testForwarderTargetAction } from '@/lib/apiClient'; export type ForwarderItem = { @@ -44,6 +46,7 @@ export default function CreateForwarderDialogComponent({ const [target, setTarget] = useState(""); const [isActive, setIsActive] = useState(true); const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); const { withAuth } = useAuth(); @@ -53,6 +56,7 @@ export default function CreateForwarderDialogComponent({ setSelectedTopic(initialTopic); setTarget(""); setIsActive(true); + setError(null); }, [open, initialTopic]); const handleTopicChange = (topic: Topic) => { @@ -65,6 +69,7 @@ export default function CreateForwarderDialogComponent({ return; } setCreating(true); + setError(null); try { await withAuth((auth) => testForwarderTargetAction(auth, target)); @@ -86,8 +91,7 @@ export default function CreateForwarderDialogComponent({ isActive, }); } catch (err) { - console.error(err); - alert("Failed to create forwarder"); + setError(err instanceof Error ? err.message : "Failed to create forwarder or send test notification"); } finally { setCreating(false); } @@ -100,6 +104,14 @@ export default function CreateForwarderDialogComponent({ New Forwarder + {error && ( + + + Forwarder test failed + {error} + + )} +
@@ -115,7 +127,10 @@ export default function CreateForwarderDialogComponent({ setTarget(e.target.value)} + onChange={(e) => { + setTarget(e.target.value); + setError(null); + }} /> For more info, visit Shoutrrr's docs diff --git a/apps/web/src/components/notifications/NotificationsPage.tsx b/apps/web/src/components/notifications/NotificationsPage.tsx index d7b8d5a9..522cde1f 100644 --- a/apps/web/src/components/notifications/NotificationsPage.tsx +++ b/apps/web/src/components/notifications/NotificationsPage.tsx @@ -25,6 +25,7 @@ import config from "@/lib/config"; import { createNotificationTopicAction, deleteNotificationTopicAction, + getNotificationsAction, getNotificationTopicsAction, markNotificationsAsReadAction, testForwarderAction, @@ -38,7 +39,7 @@ import { useActivity } from "@/context/ActivityContext"; import { useApiMutation } from "@/hooks/useApiMutation"; import { useApiQuery } from "@/hooks/useApiQuery"; import { queryKeys } from "@/lib/queryClient"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; export type NotificationItem = { id: string; @@ -122,14 +123,14 @@ function getNotificationDescription(notif: NotificationItem): React.ReactNode { } export default function NotificationsPage() { - const { token, withAuth } = useAuth(); + const { token } = useAuth(); const queryClient = useQueryClient(); const { refresh, markNotificationsAsRead: updateNotifications } = useActivity(); - const notificationsQuery = useQuery({ - queryKey: ["api", token, ...queryKeys.notifications.items(token)], - enabled: false, - }); - const notifications = (notificationsQuery.data ?? []) as NotificationItem[]; + const notificationsQuery = useApiQuery( + queryKeys.notifications.items(token), + getNotificationsAction, + ); + const notifications = (notificationsQuery.data?.items ?? []) as NotificationItem[]; const topicsQuery = useApiQuery(queryKeys.notifications.topics(token), getNotificationTopicsAction); const topics = (topicsQuery.data?.items ?? []) as TopicItem[]; const [activeTopic, setActiveTopic] = useState(null); @@ -392,9 +393,10 @@ export default function NotificationsPage() {
- )} -
-
+ )} +
+ +
); } diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index f22c2c73..b655af81 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -21,6 +21,8 @@ const buttonVariants = cva( "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + "ghost-nohover": + "text-current hover:bg-transparent hover:text-current dark:hover:bg-transparent", link: "text-primary underline-offset-4 hover:underline", }, size: { diff --git a/apps/web/src/components/widgets/LinkView.tsx b/apps/web/src/components/widgets/LinkView.tsx index c0cba47c..bd7c116a 100644 --- a/apps/web/src/components/widgets/LinkView.tsx +++ b/apps/web/src/components/widgets/LinkView.tsx @@ -196,6 +196,9 @@ export default function LinkView({ links = [] }: { links?: LinkType[] }) { const { token, user, withAuth } = useAuth(); const location = useLocation(); const navigate = useNavigate(); + const linkTileStyle = user?.appearancePreferences?.linkTileStyle === "compact" + ? "compact" + : "default"; const [localLinks, setLocalLinks] = useState(() => { if (links.length > 0) return links; return readCachedHomeLinks(user?.id) ?? links; @@ -565,6 +568,7 @@ export default function LinkView({ links = [] }: { links?: LinkType[] }) { monitoringDetails={monitoringDetails} setEditingLink={setEditingLink} navigate={navigate} + style={linkTileStyle} itemIdx={itemIdx} isDragging={dragging} /> @@ -572,8 +576,6 @@ export default function LinkView({ links = [] }: { links?: LinkType[] }) { } const folder = item; - const folderId = folder.recordId || folder.key; - const isPreview = previewFirstFolderIcons(folderId); return ( ); }, - [monitoringDetails, navigate, setEditingFolder, setEditingLink, toggleFolderPreview], + [linkTileStyle, monitoringDetails, navigate, setEditingFolder, setEditingLink, toggleFolderPreview], ); return ( @@ -634,7 +637,12 @@ export default function LinkView({ links = [] }: { links?: LinkType[] }) { items={items.map((item) => getItemId(item))} strategy={rectSortingStrategy} > - + {items.map((item, itemIdx) => ( {renderItem(item, itemIdx)} @@ -791,6 +799,7 @@ interface LinkTileProps { monitoringDetails: any; setEditingLink: (link: LinkType) => void; navigate: (to: string) => void; + style?: "default" | "compact"; itemIdx?: number; isDragging?: boolean; } @@ -800,10 +809,24 @@ function LinkTile({ monitoringDetails, setEditingLink, navigate, + style = "default", itemIdx, isDragging, }: LinkTileProps) { const { user } = useAuth(); + if (style === "compact") { + return ( + + ); + } + const serverEntry = link.id && monitoringDetails ? monitoringDetails[link.id] : undefined; @@ -891,6 +914,104 @@ function LinkTile({ ); } +function CompactLinkTile({ + link, + monitoringDetails, + setEditingLink, + navigate, + itemIdx, + isDragging, +}: LinkTileProps) { + const { user } = useAuth(); + const serverEntry = link.id && monitoringDetails + ? monitoringDetails[link.id] + : undefined; + const serverStatus = serverEntry?.status; + const isHealthy = serverStatus === "healthy"; + const isDisabled = serverStatus === "disabled"; + const showDot = Boolean(link.statusCheck); + + return ( +
+
+ {link.iconUrl + ? ( + + ) + : ( + + )} +
+ +
+
+ {link.title} + + {showDot && ( + + )} +
+ +
+
+ ); +} + function SortableTileWrapper( { id, children }: { id: string; children: React.ReactNode }, ) { @@ -929,6 +1050,7 @@ function LinkFolderPopover({ setEditingFolder, setEditingLink, navigate, + style = "default", toggleFolderPreview, }: { folder: { @@ -943,21 +1065,46 @@ function LinkFolderPopover({ setEditingFolder: (f: { id: string; name: string; icon?: string } | null) => void; setEditingLink: (link: LinkType) => void; navigate: (to: string) => void; + style?: "default" | "compact"; toggleFolderPreview: (folderId: string) => void; }) { const folderId = folder.recordId || folder.key; const isPreview = previewFirstFolderIcons(folderId); + const isCompact = style === "compact"; return (
@@ -1104,22 +1119,40 @@ function BottomNavbar({ } function DotIndicator( - { showThreeDots, active }: { showThreeDots: boolean; active: number }, + { + showThreeDots, + active, + onSelectPanel, + }: { + showThreeDots: boolean; + active: number; + onSelectPanel?: (panel: number) => void; + }, ) { const dotBase = "inline-block w-2.5 h-2.5 rounded-full transition-transform transition-opacity"; const activeClasses = "scale-110 opacity-100"; const inactiveClasses = "scale-100 opacity-60"; + const buttonBase = + "flex h-8 w-8 items-center justify-center rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-white/60"; if (!showThreeDots) { return (
- +
); } @@ -1127,13 +1160,21 @@ function DotIndicator( return (
{[0, 1, 2].map((i) => ( - + type="button" + aria-label={`Show ${i === 0 ? "left" : i === 1 ? "main" : "right"} panel`} + aria-current={active === i ? "true" : undefined} + className={buttonBase} + onClick={() => onSelectPanel?.(i)} + > + + ))}
); From 067fd9aaa4b6586e64f152c9d00175186780f628 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Tue, 25 Aug 2026 13:09:24 +0200 Subject: [PATCH 18/57] fix(news): limit feed modal lists --- .../src/components/news/NewsFeedEditModal.tsx | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/news/NewsFeedEditModal.tsx b/apps/web/src/components/news/NewsFeedEditModal.tsx index be7e5932..7258d36b 100644 --- a/apps/web/src/components/news/NewsFeedEditModal.tsx +++ b/apps/web/src/components/news/NewsFeedEditModal.tsx @@ -229,22 +229,24 @@ export default function NewsFeedEditModal({ {sortedFeeds.length === 0 && (
No other feeds available.
)} - {sortedFeeds.map((includedFeed) => { - const isSelected = selectedFeedIds.includes(includedFeed.id); - return ( - - ); - })} +
+ {sortedFeeds.map((includedFeed) => { + const isSelected = selectedFeedIds.includes(includedFeed.id); + return ( + + ); + })} +
)} @@ -282,7 +284,7 @@ export default function NewsFeedEditModal({ )} {!loading && filteredSubscriptions.length > 0 && ( -
+
{filteredSubscriptions.map((subscription) => { const isSelected = selectedSubscriptionIds.includes(subscription.id); From c9f1ca36ef6f8cc109e44a82bc623008f9686581 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Tue, 25 Aug 2026 13:09:35 +0200 Subject: [PATCH 19/57] fix(layout): hide empty scrollbars --- apps/web/src/components/apps/LayoutTemplate.tsx | 8 +++++--- apps/web/src/components/news/NewsLayout.tsx | 5 +++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/apps/LayoutTemplate.tsx b/apps/web/src/components/apps/LayoutTemplate.tsx index d75f8ea6..48dac13e 100644 --- a/apps/web/src/components/apps/LayoutTemplate.tsx +++ b/apps/web/src/components/apps/LayoutTemplate.tsx @@ -91,9 +91,11 @@ interface GroupLabelProps { export default function AppTemplate({ title, + disableScroll = false, children, }: { title: string; + disableScroll?: boolean; children: ReactNode; }) { const location = useLocation(); @@ -133,7 +135,7 @@ export default function AppTemplate({
{sidebar}
-
+
; +export function Content({ children, className }: { children: ReactNode; className?: string }) { + return
{children}
; } diff --git a/apps/web/src/components/news/NewsLayout.tsx b/apps/web/src/components/news/NewsLayout.tsx index 5d8ec3f3..328a2647 100644 --- a/apps/web/src/components/news/NewsLayout.tsx +++ b/apps/web/src/components/news/NewsLayout.tsx @@ -64,6 +64,7 @@ export default function NewsLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); const location = useLocation(); const { feedId } = useParams(); + const isOverview = feedId === "overview"; const queryClient = useQueryClient(); const [expandedFeedIds, setExpandedFeedIds] = useState>(new Set()); const activeSavedList = feedId?.startsWith("saved-") ? decodeURIComponent(feedId.slice("saved-".length)) : null; @@ -321,7 +322,7 @@ export default function NewsLayout({ children }: { children: ReactNode }) { return ( - + - + <> {/** here here*/} {isNewsRootRedirect ? ( From 4360500a963e153bb1bb8fdc69b558b868d9d395 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Tue, 25 Aug 2026 14:03:15 +0200 Subject: [PATCH 20/57] feat(news): add feed deletion action --- apps/backend/openapi.yaml | 13 ++++++++ apps/backend/src/lib/cache/feed-items.ts | 14 +++++++++ apps/backend/src/lib/data/news.ts | 34 +++++++++++++++++++++ apps/backend/src/lib/data/superuser.ts | 7 +++++ apps/backend/src/routes/news.route.ts | 6 +++- apps/web/public/openapi.json | 21 +++++++++++++ apps/web/src/components/news/NewsLayout.tsx | 20 ++++++++++++ apps/web/src/lib/api/index.ts | 4 +-- apps/web/src/lib/api/sdk.gen.ts | 7 ++++- apps/web/src/lib/api/types.gen.ts | 18 +++++++++++ apps/web/src/lib/apiClient.ts | 4 +++ packages/api-types/src/openapi.ts | 16 +++++++++- 12 files changed, 159 insertions(+), 5 deletions(-) diff --git a/apps/backend/openapi.yaml b/apps/backend/openapi.yaml index 5f6cd73e..58c74c7e 100644 --- a/apps/backend/openapi.yaml +++ b/apps/backend/openapi.yaml @@ -1253,6 +1253,19 @@ paths: responses: "200": $ref: "#/components/responses/JsonOk" + delete: + tags: + - news + summary: Delete news feed record + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + $ref: "#/components/responses/JsonOk" /news/feed-records: post: tags: diff --git a/apps/backend/src/lib/cache/feed-items.ts b/apps/backend/src/lib/cache/feed-items.ts index 5782bcaf..dee6be0d 100644 --- a/apps/backend/src/lib/cache/feed-items.ts +++ b/apps/backend/src/lib/cache/feed-items.ts @@ -332,6 +332,20 @@ export async function writeMaterializedFeed( ]); } +export async function deleteMaterializedFeed(userId: string, feedId: string) { + const orderKey = materializedFeedOrderKey(userId, feedId); + const itemsKey = materializedFeedItemsKey(userId, feedId); + const metaKey = materializedFeedMetaKey(userId, feedId); + + if (config.USE_LOCAL_FEED_CACHE) { + localViews.delete(orderKey); + localMetadata.delete(metaKey); + return; + } + + await command("DEL", [orderKey, itemsKey, metaKey]); +} + // Compatibility for the subscription JSON endpoint and older callers. New feed // construction must use the article/index functions above. export async function readFeedItemsCache(feedId: string): Promise { diff --git a/apps/backend/src/lib/data/news.ts b/apps/backend/src/lib/data/news.ts index 556c8187..4a1c1fc5 100644 --- a/apps/backend/src/lib/data/news.ts +++ b/apps/backend/src/lib/data/news.ts @@ -28,6 +28,7 @@ import { getNewsSubscriptionById, getNewsSubscriptionByUrl, createNewsFeedRecord, + deleteNewsFeedRecord, updateNewsFeedRecord, updateNewsSubscription, createNewsSubscription, @@ -35,6 +36,7 @@ import { import { getSuperuserPB } from "../pb/pocketbase"; import { deleteSubscriptionArticleIndex, + deleteMaterializedFeed, readFeedItemsCache, readMaterializedFeedItems, readMaterializedFeedPage, @@ -729,6 +731,38 @@ export async function updateNewsFeedRecordForUser( return updated; } +export async function deleteNewsFeedRecordForUser(userId: string, feedId: string) { + const normalizedFeedId = String(feedId || "").trim(); + if (!normalizedFeedId || normalizedFeedId === "all") { + throw new Error("All feed cannot be deleted"); + } + + const feedRecord = (await getNewsFeedById(normalizedFeedId).catch(() => null)) as NewsFeedRecord | null; + if (!feedRecord) return null; + + const ownerId = String((feedRecord as Record).userId ?? "").trim(); + if (ownerId && ownerId !== userId) return null; + if (isAllNewsFeed(feedRecord)) { + throw new Error("All feed cannot be deleted"); + } + + const deleted = await deleteNewsFeedRecord(normalizedFeedId); + if (!deleted) { + throw new Error("Unable to delete feed"); + } + + const userFeeds = await getUserFeeds(userId); + await Promise.all(userFeeds + .filter((feed) => Array.isArray(feed.includedFeedRefs) && feed.includedFeedRefs.includes(normalizedFeedId)) + .map((feed) => updateNewsFeedRecord(String(feed.id), { + includedFeedRefs: feed.includedFeedRefs?.filter((childId) => String(childId) !== normalizedFeedId) ?? [], + }))); + + await deleteMaterializedFeed(userId, normalizedFeedId); + void rebuildNewsViews(userId).catch(() => undefined); + return { id: normalizedFeedId }; +} + export async function createNewsFeedRecordForUser( userId: string, payload: NewsFeedRecordCreateInput, diff --git a/apps/backend/src/lib/data/superuser.ts b/apps/backend/src/lib/data/superuser.ts index 839f16ad..333d8b5d 100644 --- a/apps/backend/src/lib/data/superuser.ts +++ b/apps/backend/src/lib/data/superuser.ts @@ -143,6 +143,13 @@ export async function updateNewsFeedRecord( return safeNull((pb) => pb.collection("newsFeeds").update(feedId, payload)); } +export async function deleteNewsFeedRecord(feedId: string) { + return safeNull(async (pb) => { + await pb.collection("newsFeeds").delete(feedId); + return true; + }); +} + export async function getAllNewsSubscriptions(batchSize = 2000, options?: Record) { return safeNull((pb) => pb.collection("newsSubscriptions").getFullList(batchSize, options)); } diff --git a/apps/backend/src/routes/news.route.ts b/apps/backend/src/routes/news.route.ts index bca54635..36705e57 100644 --- a/apps/backend/src/routes/news.route.ts +++ b/apps/backend/src/routes/news.route.ts @@ -2,7 +2,7 @@ import Parser from "rss-parser"; import { Hono } from "hono"; import type { Context } from "hono"; -import { createNewsFeedRecordForUser, deleteNewsSavedArticle, deleteNewsSavedArticleList, getNewsFeed, getNewsFeedRecord, getNewsFeeds, getNewsSavedArticles, getNewsSubscriptions, getNewsSubscriptionJson, renameNewsSavedArticleList, saveNewsArticle, subscribeNewsFeed, unsubscribeNewsFeed, updateNewsFeed, updateNewsFeedRecordForUser, getNewsFeedMetadata, updateNewsSubscription, updateNewsSavedArticleReadState } from "../lib/data/news"; +import { createNewsFeedRecordForUser, deleteNewsFeedRecordForUser, deleteNewsSavedArticle, deleteNewsSavedArticleList, getNewsFeed, getNewsFeedRecord, getNewsFeeds, getNewsSavedArticles, getNewsSubscriptions, getNewsSubscriptionJson, renameNewsSavedArticleList, saveNewsArticle, subscribeNewsFeed, unsubscribeNewsFeed, updateNewsFeed, updateNewsFeedRecordForUser, getNewsFeedMetadata, updateNewsSubscription, updateNewsSavedArticleReadState } from "../lib/data/news"; import type { NewsFeedItem, NewsFeedMetadata, NewsFeedRecordCreateInput, NewsFeedRecordUpdateInput, NewsSubscribeInput, NewsUpdateInput } from "@dashwise/types/sdk"; import { readAuthToken, readJsonBody, requireAuth, withJson } from "./shared"; @@ -240,6 +240,10 @@ newsRoute const { userId } = await requireAuth({ token: readAuthToken(c) }); return getNewsFeedRecord(userId, String(c.req.param("id") ?? "")); })) + .delete("/api/v1/news/feed-records/:id", withJson(async (c) => { + const { userId } = await requireAuth({ token: readAuthToken(c) }); + return deleteNewsFeedRecordForUser(userId, String(c.req.param("id") ?? "")); + })) .post("/api/v1/news/feed-records", withJson(async (c) => { const body = await readJsonBody(c); const { userId } = await requireAuth({ token: readAuthToken(c) }); diff --git a/apps/web/public/openapi.json b/apps/web/public/openapi.json index 1ac23d23..cbe438b4 100644 --- a/apps/web/public/openapi.json +++ b/apps/web/public/openapi.json @@ -2039,6 +2039,27 @@ "$ref": "#/components/responses/JsonOk" } } + }, + "delete": { + "tags": [ + "news" + ], + "summary": "Delete news feed record", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" + } + } } }, "/news/feed-records": { diff --git a/apps/web/src/components/news/NewsLayout.tsx b/apps/web/src/components/news/NewsLayout.tsx index 328a2647..40a55e91 100644 --- a/apps/web/src/components/news/NewsLayout.tsx +++ b/apps/web/src/components/news/NewsLayout.tsx @@ -4,6 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState, t import { useLocation, useNavigate, useParams } from "react-router-dom"; import useAuth from "@/context/useAuth"; import { + deleteNewsFeedRecordAction, deleteNewsSavedArticleListAction, getNewsFeedsAction, getNewsSavedArticlesAction, @@ -244,6 +245,20 @@ export default function NewsLayout({ children }: { children: ReactNode }) { navigate(`${activeFeedRoute}?${params.toString()}`); }; + const deleteFeed = async (feed: FeedRecord) => { + if (!token || feed.id === "all") return; + if (!window.confirm(`Delete ${feed.title || "this feed"}?`)) return; + + try { + await deleteNewsFeedRecordAction({ token }, feed.id); + reloadSidebar(); + if (feedId === feed.id) navigate("/apps/news/all"); + } catch (error) { + console.error("Failed to delete news feed:", error); + window.alert(error instanceof Error ? error.message : "Failed to delete feed"); + } + }; + const renderFeedTabs = (parentId?: string, depth = 0, ancestors: Set = new Set()): ReactNode[] => { const parentFeed = parentId ? userFeeds.find((feed) => feed.id === parentId) : null; const parentFeeds = parentFeed @@ -288,6 +303,11 @@ export default function NewsLayout({ children }: { children: ReactNode }) { icon: "fa6-solid:pen-to-square", action: () => openEditFeedModal(feed), }, + { + label: "Delete feed", + icon: "fa6-solid:trash", + action: () => void deleteFeed(feed), + }, ]} /> ); diff --git a/apps/web/src/lib/api/index.ts b/apps/web/src/lib/api/index.ts index 0451739c..855dd790 100644 --- a/apps/web/src/lib/api/index.ts +++ b/apps/web/src/lib/api/index.ts @@ -1,5 +1,5 @@ // DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts -export { deleteAuthDeleteAccount, deleteIntegrationsById, deleteLinksItemsByLinkId, deleteMonitoringHostsById, deleteMonitoringSshHostsById, deleteMonitorsById, deleteNotificationsForwarders, deleteNotificationsTopics, deleteNotificationsTopicTokens, getAppConfig, getAppInfo, getAuthCallback, getAuthSso, getGlanceables, getGlanceablesByIntegration, getIntegrations, getIntegrationsCaldavEvents, getIntegrationsConsumerData, getIntegrationsWidgetProperties, getJobsPullIcons, getJobsSearchItems, getLinksCollections, getLinksFolders, getLinksHome, getLinksHomeGroups, getLinksItems, getLinksTags, getLocations, getMonitoringHosts, getMonitoringHostsById, getMonitoringHostsByIdHistory, getMonitoringHostsByIdStats, getMonitoringSshHosts, getMonitoringStatus, getMonitors, getMonitorsById, getNews, getNewsFeed, getNewsFeedMetadata, getNewsFeedRecordsById, getNewsFeedRefresh, getNewsFeeds, getNewsFeedsById, getNewsSubscriptions, getNewsSubscriptionsByIdJson, getNotifications, getNotificationsForwarders, getNotificationsTopics, getNotificationsTopicTokens, getPageConfig, getPageConfigUserPages, getSearchItems, getSearchItemsFrequentlyUsed, getTestBookmarks, getWallpapers, getWeather, getWidgets, getWidgetsByIntegration, getWidgetsGlanceable, getWidgetsGlanceables, type Options, patchAuthUpdateUserProperty, postAuthChangePassword, postAuthLogin, postAuthMfa, postAuthSignup, postAuthValidateAuth, postIntegrations, postIntegrationsConsumerData, postIntegrationsProxyAction, postIntegrationsTestEndpoint, postLinksCollections, postLinksFolders, postLinksHomeGroups, postLinksItems, postLinksReorder, postLinksTags, postMonitoringHosts, postMonitoringHostsByIdRefresh, postMonitoringSshHosts, postMonitoringStatus, postMonitors, postNewsFeedRecords, postNewsFeedRecordsById, postNewsFeedRefresh, postNewsFeedSubscribe, postNewsFeedUnsubscribe, postNewsFeedUpdate, postNewsFixMissingTitles, postNotifications, postNotificationsByTopic, postNotificationsForwarders, postNotificationsForwardersTest, postNotificationsMarkAsRead, postNotificationsTest, postNotificationsTopics, postNotificationsTopicTokens, postPageConfigHome, postPageConfigIntegrationData, postPageConfigMigrateLegacy, postSearchItemsUsageStats, postWallpapers, putIntegrationsById, putLinksCollectionsByCollectionId, putLinksFoldersByFolderIdIcon, putLinksItemsByLinkId, putLinksTagsByTagId, putMonitoringHostsById, putMonitoringSshHostsById, putMonitorsById, putNotificationsForwarders, putNotificationsTopicTokens, putPageConfig } from './sdk.gen'; -export type { ClientOptions, DeleteAuthDeleteAccountData, DeleteAuthDeleteAccountError, DeleteAuthDeleteAccountErrors, DeleteAuthDeleteAccountResponse, DeleteAuthDeleteAccountResponses, DeleteIntegrationsByIdData, DeleteIntegrationsByIdResponse, DeleteIntegrationsByIdResponses, DeleteLinksItemsByLinkIdData, DeleteLinksItemsByLinkIdResponse, DeleteLinksItemsByLinkIdResponses, DeleteMonitoringHostsByIdData, DeleteMonitoringHostsByIdResponse, DeleteMonitoringHostsByIdResponses, DeleteMonitoringSshHostsByIdData, DeleteMonitoringSshHostsByIdResponse, DeleteMonitoringSshHostsByIdResponses, DeleteMonitorsByIdData, DeleteMonitorsByIdResponse, DeleteMonitorsByIdResponses, DeleteNotificationsForwardersData, DeleteNotificationsForwardersResponse, DeleteNotificationsForwardersResponses, DeleteNotificationsTopicsData, DeleteNotificationsTopicsResponse, DeleteNotificationsTopicsResponses, DeleteNotificationsTopicTokensData, DeleteNotificationsTopicTokensResponse, DeleteNotificationsTopicTokensResponses, Error, GenericObject, GetAppConfigData, GetAppConfigResponse, GetAppConfigResponses, GetAppInfoData, GetAppInfoResponse, GetAppInfoResponses, GetAuthCallbackData, GetAuthCallbackResponse, GetAuthCallbackResponses, GetAuthSsoData, GetAuthSsoResponse, GetAuthSsoResponses, GetGlanceablesByIntegrationData, GetGlanceablesByIntegrationResponse, GetGlanceablesByIntegrationResponses, GetGlanceablesData, GetGlanceablesResponse, GetGlanceablesResponses, GetIntegrationsCaldavEventsData, GetIntegrationsCaldavEventsResponse, GetIntegrationsCaldavEventsResponses, GetIntegrationsConsumerDataData, GetIntegrationsConsumerDataResponse, GetIntegrationsConsumerDataResponses, GetIntegrationsData, GetIntegrationsResponse, GetIntegrationsResponses, GetIntegrationsWidgetPropertiesData, GetIntegrationsWidgetPropertiesResponse, GetIntegrationsWidgetPropertiesResponses, GetJobsPullIconsData, GetJobsPullIconsError, GetJobsPullIconsErrors, GetJobsPullIconsResponse, GetJobsPullIconsResponses, GetJobsSearchItemsData, GetJobsSearchItemsResponse, GetJobsSearchItemsResponses, GetLinksCollectionsData, GetLinksCollectionsResponse, GetLinksCollectionsResponses, GetLinksFoldersData, GetLinksFoldersResponse, GetLinksFoldersResponses, GetLinksHomeData, GetLinksHomeGroupsData, GetLinksHomeGroupsResponse, GetLinksHomeGroupsResponses, GetLinksHomeResponse, GetLinksHomeResponses, GetLinksItemsData, GetLinksItemsResponse, GetLinksItemsResponses, GetLinksTagsData, GetLinksTagsResponse, GetLinksTagsResponses, GetLocationsData, GetLocationsResponse, GetLocationsResponses, GetMonitoringHostsByIdData, GetMonitoringHostsByIdHistoryData, GetMonitoringHostsByIdHistoryResponse, GetMonitoringHostsByIdHistoryResponses, GetMonitoringHostsByIdResponse, GetMonitoringHostsByIdResponses, GetMonitoringHostsByIdStatsData, GetMonitoringHostsByIdStatsResponse, GetMonitoringHostsByIdStatsResponses, GetMonitoringHostsData, GetMonitoringHostsResponse, GetMonitoringHostsResponses, GetMonitoringSshHostsData, GetMonitoringSshHostsResponse, GetMonitoringSshHostsResponses, GetMonitoringStatusData, GetMonitoringStatusResponse, GetMonitoringStatusResponses, GetMonitorsByIdData, GetMonitorsByIdResponse, GetMonitorsByIdResponses, GetMonitorsData, GetMonitorsResponse, GetMonitorsResponses, GetNewsData, GetNewsFeedData, GetNewsFeedMetadataData, GetNewsFeedMetadataResponse, GetNewsFeedMetadataResponses, GetNewsFeedRecordsByIdData, GetNewsFeedRecordsByIdResponse, GetNewsFeedRecordsByIdResponses, GetNewsFeedRefreshData, GetNewsFeedRefreshResponse, GetNewsFeedRefreshResponses, GetNewsFeedResponse, GetNewsFeedResponses, GetNewsFeedsByIdData, GetNewsFeedsByIdResponse, GetNewsFeedsByIdResponses, GetNewsFeedsData, GetNewsFeedsResponse, GetNewsFeedsResponses, GetNewsResponse, GetNewsResponses, GetNewsSubscriptionsByIdJsonData, GetNewsSubscriptionsByIdJsonResponse, GetNewsSubscriptionsByIdJsonResponses, GetNewsSubscriptionsData, GetNewsSubscriptionsResponse, GetNewsSubscriptionsResponses, GetNotificationsData, GetNotificationsForwardersData, GetNotificationsForwardersResponse, GetNotificationsForwardersResponses, GetNotificationsResponse, GetNotificationsResponses, GetNotificationsTopicsData, GetNotificationsTopicsResponse, GetNotificationsTopicsResponses, GetNotificationsTopicTokensData, GetNotificationsTopicTokensResponse, GetNotificationsTopicTokensResponses, GetPageConfigData, GetPageConfigResponse, GetPageConfigResponses, GetPageConfigUserPagesData, GetPageConfigUserPagesResponse, GetPageConfigUserPagesResponses, GetSearchItemsData, GetSearchItemsFrequentlyUsedData, GetSearchItemsFrequentlyUsedResponse, GetSearchItemsFrequentlyUsedResponses, GetSearchItemsResponse, GetSearchItemsResponses, GetTestBookmarksData, GetTestBookmarksResponse, GetTestBookmarksResponses, GetWallpapersData, GetWallpapersResponse, GetWallpapersResponses, GetWeatherData, GetWeatherResponse, GetWeatherResponses, GetWidgetsByIntegrationData, GetWidgetsByIntegrationResponse, GetWidgetsByIntegrationResponses, GetWidgetsData, GetWidgetsGlanceableData, GetWidgetsGlanceableResponse, GetWidgetsGlanceableResponses, GetWidgetsGlanceablesData, GetWidgetsGlanceablesResponse, GetWidgetsGlanceablesResponses, GetWidgetsResponse, GetWidgetsResponses, Id, JsonBody, PatchAuthUpdateUserPropertyData, PatchAuthUpdateUserPropertyError, PatchAuthUpdateUserPropertyErrors, PatchAuthUpdateUserPropertyResponse, PatchAuthUpdateUserPropertyResponses, PostAuthChangePasswordData, PostAuthChangePasswordError, PostAuthChangePasswordErrors, PostAuthChangePasswordResponse, PostAuthChangePasswordResponses, PostAuthLoginData, PostAuthLoginError, PostAuthLoginErrors, PostAuthLoginResponse, PostAuthLoginResponses, PostAuthMfaData, PostAuthMfaError, PostAuthMfaErrors, PostAuthMfaResponse, PostAuthMfaResponses, PostAuthSignupData, PostAuthSignupError, PostAuthSignupErrors, PostAuthSignupResponse, PostAuthSignupResponses, PostAuthValidateAuthData, PostAuthValidateAuthError, PostAuthValidateAuthErrors, PostAuthValidateAuthResponse, PostAuthValidateAuthResponses, PostIntegrationsConsumerDataData, PostIntegrationsConsumerDataResponse, PostIntegrationsConsumerDataResponses, PostIntegrationsData, PostIntegrationsProxyActionData, PostIntegrationsProxyActionResponse, PostIntegrationsProxyActionResponses, PostIntegrationsResponse, PostIntegrationsResponses, PostIntegrationsTestEndpointData, PostIntegrationsTestEndpointResponse, PostIntegrationsTestEndpointResponses, PostLinksCollectionsData, PostLinksCollectionsResponse, PostLinksCollectionsResponses, PostLinksFoldersData, PostLinksFoldersResponse, PostLinksFoldersResponses, PostLinksHomeGroupsData, PostLinksHomeGroupsResponse, PostLinksHomeGroupsResponses, PostLinksItemsData, PostLinksItemsResponse, PostLinksItemsResponses, PostLinksReorderData, PostLinksReorderResponse, PostLinksReorderResponses, PostLinksTagsData, PostLinksTagsResponse, PostLinksTagsResponses, PostMonitoringHostsByIdRefreshData, PostMonitoringHostsByIdRefreshError, PostMonitoringHostsByIdRefreshErrors, PostMonitoringHostsByIdRefreshResponse, PostMonitoringHostsByIdRefreshResponses, PostMonitoringHostsData, PostMonitoringHostsResponse, PostMonitoringHostsResponses, PostMonitoringSshHostsData, PostMonitoringSshHostsResponse, PostMonitoringSshHostsResponses, PostMonitoringStatusData, PostMonitoringStatusError, PostMonitoringStatusErrors, PostMonitoringStatusResponse, PostMonitoringStatusResponses, PostMonitorsData, PostMonitorsResponse, PostMonitorsResponses, PostNewsFeedRecordsByIdData, PostNewsFeedRecordsByIdResponse, PostNewsFeedRecordsByIdResponses, PostNewsFeedRecordsData, PostNewsFeedRecordsResponse, PostNewsFeedRecordsResponses, PostNewsFeedRefreshData, PostNewsFeedRefreshResponse, PostNewsFeedRefreshResponses, PostNewsFeedSubscribeData, PostNewsFeedSubscribeResponse, PostNewsFeedSubscribeResponses, PostNewsFeedUnsubscribeData, PostNewsFeedUnsubscribeResponse, PostNewsFeedUnsubscribeResponses, PostNewsFeedUpdateData, PostNewsFeedUpdateResponse, PostNewsFeedUpdateResponses, PostNewsFixMissingTitlesData, PostNewsFixMissingTitlesResponse, PostNewsFixMissingTitlesResponses, PostNotificationsByTopicData, PostNotificationsByTopicResponse, PostNotificationsByTopicResponses, PostNotificationsData, PostNotificationsForwardersData, PostNotificationsForwardersResponse, PostNotificationsForwardersResponses, PostNotificationsForwardersTestData, PostNotificationsForwardersTestResponse, PostNotificationsForwardersTestResponses, PostNotificationsMarkAsReadData, PostNotificationsMarkAsReadResponse, PostNotificationsMarkAsReadResponses, PostNotificationsResponse, PostNotificationsResponses, PostNotificationsTestData, PostNotificationsTestResponse, PostNotificationsTestResponses, PostNotificationsTopicsData, PostNotificationsTopicsResponse, PostNotificationsTopicsResponses, PostNotificationsTopicTokensData, PostNotificationsTopicTokensResponse, PostNotificationsTopicTokensResponses, PostPageConfigHomeData, PostPageConfigHomeResponse, PostPageConfigHomeResponses, PostPageConfigIntegrationDataData, PostPageConfigIntegrationDataResponse, PostPageConfigIntegrationDataResponses, PostPageConfigMigrateLegacyData, PostPageConfigMigrateLegacyResponse, PostPageConfigMigrateLegacyResponses, PostSearchItemsUsageStatsData, PostSearchItemsUsageStatsResponse, PostSearchItemsUsageStatsResponses, PostWallpapersData, PostWallpapersResponse, PostWallpapersResponses, PutIntegrationsByIdData, PutIntegrationsByIdResponse, PutIntegrationsByIdResponses, PutLinksCollectionsByCollectionIdData, PutLinksCollectionsByCollectionIdResponse, PutLinksCollectionsByCollectionIdResponses, PutLinksFoldersByFolderIdIconData, PutLinksFoldersByFolderIdIconResponse, PutLinksFoldersByFolderIdIconResponses, PutLinksItemsByLinkIdData, PutLinksItemsByLinkIdResponse, PutLinksItemsByLinkIdResponses, PutLinksTagsByTagIdData, PutLinksTagsByTagIdResponse, PutLinksTagsByTagIdResponses, PutMonitoringHostsByIdData, PutMonitoringHostsByIdResponse, PutMonitoringHostsByIdResponses, PutMonitoringSshHostsByIdData, PutMonitoringSshHostsByIdResponse, PutMonitoringSshHostsByIdResponses, PutMonitorsByIdData, PutMonitorsByIdResponse, PutMonitorsByIdResponses, PutNotificationsForwardersData, PutNotificationsForwardersResponse, PutNotificationsForwardersResponses, PutNotificationsTopicTokensData, PutNotificationsTopicTokensResponse, PutNotificationsTopicTokensResponses, PutPageConfigData, PutPageConfigResponse, PutPageConfigResponses } from './types.gen'; +export { deleteAuthDeleteAccount, deleteIntegrationsById, deleteLinksItemsByLinkId, deleteMonitoringHostsById, deleteMonitoringSshHostsById, deleteMonitorsById, deleteNewsFeedRecordsById, deleteNotificationsForwarders, deleteNotificationsTopics, deleteNotificationsTopicTokens, getAppConfig, getAppInfo, getAuthCallback, getAuthSso, getGlanceables, getGlanceablesByIntegration, getIntegrations, getIntegrationsCaldavEvents, getIntegrationsConsumerData, getIntegrationsWidgetProperties, getJobsPullIcons, getJobsSearchItems, getLinksCollections, getLinksFolders, getLinksHome, getLinksHomeGroups, getLinksItems, getLinksTags, getLocations, getMonitoringHosts, getMonitoringHostsById, getMonitoringHostsByIdHistory, getMonitoringHostsByIdStats, getMonitoringSshHosts, getMonitoringStatus, getMonitors, getMonitorsById, getNews, getNewsFeed, getNewsFeedMetadata, getNewsFeedRecordsById, getNewsFeedRefresh, getNewsFeeds, getNewsFeedsById, getNewsSubscriptions, getNewsSubscriptionsByIdJson, getNotifications, getNotificationsForwarders, getNotificationsTopics, getNotificationsTopicTokens, getPageConfig, getPageConfigUserPages, getSearchItems, getSearchItemsFrequentlyUsed, getTestBookmarks, getWallpapers, getWeather, getWidgets, getWidgetsByIntegration, getWidgetsGlanceable, getWidgetsGlanceables, type Options, patchAuthUpdateUserProperty, postAuthChangePassword, postAuthLogin, postAuthMfa, postAuthSignup, postAuthValidateAuth, postIntegrations, postIntegrationsConsumerData, postIntegrationsProxyAction, postIntegrationsTestEndpoint, postLinksCollections, postLinksFolders, postLinksHomeGroups, postLinksItems, postLinksReorder, postLinksTags, postMonitoringHosts, postMonitoringHostsByIdRefresh, postMonitoringSshHosts, postMonitoringStatus, postMonitors, postNewsFeedRecords, postNewsFeedRecordsById, postNewsFeedRefresh, postNewsFeedSubscribe, postNewsFeedUnsubscribe, postNewsFeedUpdate, postNewsFixMissingTitles, postNotifications, postNotificationsByTopic, postNotificationsForwarders, postNotificationsForwardersTest, postNotificationsMarkAsRead, postNotificationsTest, postNotificationsTopics, postNotificationsTopicTokens, postPageConfigHome, postPageConfigIntegrationData, postPageConfigMigrateLegacy, postSearchItemsUsageStats, postWallpapers, putIntegrationsById, putLinksCollectionsByCollectionId, putLinksFoldersByFolderIdIcon, putLinksItemsByLinkId, putLinksTagsByTagId, putMonitoringHostsById, putMonitoringSshHostsById, putMonitorsById, putNotificationsForwarders, putNotificationsTopicTokens, putPageConfig } from './sdk.gen'; +export type { ClientOptions, DeleteAuthDeleteAccountData, DeleteAuthDeleteAccountError, DeleteAuthDeleteAccountErrors, DeleteAuthDeleteAccountResponse, DeleteAuthDeleteAccountResponses, DeleteIntegrationsByIdData, DeleteIntegrationsByIdResponse, DeleteIntegrationsByIdResponses, DeleteLinksItemsByLinkIdData, DeleteLinksItemsByLinkIdResponse, DeleteLinksItemsByLinkIdResponses, DeleteMonitoringHostsByIdData, DeleteMonitoringHostsByIdResponse, DeleteMonitoringHostsByIdResponses, DeleteMonitoringSshHostsByIdData, DeleteMonitoringSshHostsByIdResponse, DeleteMonitoringSshHostsByIdResponses, DeleteMonitorsByIdData, DeleteMonitorsByIdResponse, DeleteMonitorsByIdResponses, DeleteNewsFeedRecordsByIdData, DeleteNewsFeedRecordsByIdResponse, DeleteNewsFeedRecordsByIdResponses, DeleteNotificationsForwardersData, DeleteNotificationsForwardersResponse, DeleteNotificationsForwardersResponses, DeleteNotificationsTopicsData, DeleteNotificationsTopicsResponse, DeleteNotificationsTopicsResponses, DeleteNotificationsTopicTokensData, DeleteNotificationsTopicTokensResponse, DeleteNotificationsTopicTokensResponses, Error, GenericObject, GetAppConfigData, GetAppConfigResponse, GetAppConfigResponses, GetAppInfoData, GetAppInfoResponse, GetAppInfoResponses, GetAuthCallbackData, GetAuthCallbackResponse, GetAuthCallbackResponses, GetAuthSsoData, GetAuthSsoResponse, GetAuthSsoResponses, GetGlanceablesByIntegrationData, GetGlanceablesByIntegrationResponse, GetGlanceablesByIntegrationResponses, GetGlanceablesData, GetGlanceablesResponse, GetGlanceablesResponses, GetIntegrationsCaldavEventsData, GetIntegrationsCaldavEventsResponse, GetIntegrationsCaldavEventsResponses, GetIntegrationsConsumerDataData, GetIntegrationsConsumerDataResponse, GetIntegrationsConsumerDataResponses, GetIntegrationsData, GetIntegrationsResponse, GetIntegrationsResponses, GetIntegrationsWidgetPropertiesData, GetIntegrationsWidgetPropertiesResponse, GetIntegrationsWidgetPropertiesResponses, GetJobsPullIconsData, GetJobsPullIconsError, GetJobsPullIconsErrors, GetJobsPullIconsResponse, GetJobsPullIconsResponses, GetJobsSearchItemsData, GetJobsSearchItemsResponse, GetJobsSearchItemsResponses, GetLinksCollectionsData, GetLinksCollectionsResponse, GetLinksCollectionsResponses, GetLinksFoldersData, GetLinksFoldersResponse, GetLinksFoldersResponses, GetLinksHomeData, GetLinksHomeGroupsData, GetLinksHomeGroupsResponse, GetLinksHomeGroupsResponses, GetLinksHomeResponse, GetLinksHomeResponses, GetLinksItemsData, GetLinksItemsResponse, GetLinksItemsResponses, GetLinksTagsData, GetLinksTagsResponse, GetLinksTagsResponses, GetLocationsData, GetLocationsResponse, GetLocationsResponses, GetMonitoringHostsByIdData, GetMonitoringHostsByIdHistoryData, GetMonitoringHostsByIdHistoryResponse, GetMonitoringHostsByIdHistoryResponses, GetMonitoringHostsByIdResponse, GetMonitoringHostsByIdResponses, GetMonitoringHostsByIdStatsData, GetMonitoringHostsByIdStatsResponse, GetMonitoringHostsByIdStatsResponses, GetMonitoringHostsData, GetMonitoringHostsResponse, GetMonitoringHostsResponses, GetMonitoringSshHostsData, GetMonitoringSshHostsResponse, GetMonitoringSshHostsResponses, GetMonitoringStatusData, GetMonitoringStatusResponse, GetMonitoringStatusResponses, GetMonitorsByIdData, GetMonitorsByIdResponse, GetMonitorsByIdResponses, GetMonitorsData, GetMonitorsResponse, GetMonitorsResponses, GetNewsData, GetNewsFeedData, GetNewsFeedMetadataData, GetNewsFeedMetadataResponse, GetNewsFeedMetadataResponses, GetNewsFeedRecordsByIdData, GetNewsFeedRecordsByIdResponse, GetNewsFeedRecordsByIdResponses, GetNewsFeedRefreshData, GetNewsFeedRefreshResponse, GetNewsFeedRefreshResponses, GetNewsFeedResponse, GetNewsFeedResponses, GetNewsFeedsByIdData, GetNewsFeedsByIdResponse, GetNewsFeedsByIdResponses, GetNewsFeedsData, GetNewsFeedsResponse, GetNewsFeedsResponses, GetNewsResponse, GetNewsResponses, GetNewsSubscriptionsByIdJsonData, GetNewsSubscriptionsByIdJsonResponse, GetNewsSubscriptionsByIdJsonResponses, GetNewsSubscriptionsData, GetNewsSubscriptionsResponse, GetNewsSubscriptionsResponses, GetNotificationsData, GetNotificationsForwardersData, GetNotificationsForwardersResponse, GetNotificationsForwardersResponses, GetNotificationsResponse, GetNotificationsResponses, GetNotificationsTopicsData, GetNotificationsTopicsResponse, GetNotificationsTopicsResponses, GetNotificationsTopicTokensData, GetNotificationsTopicTokensResponse, GetNotificationsTopicTokensResponses, GetPageConfigData, GetPageConfigResponse, GetPageConfigResponses, GetPageConfigUserPagesData, GetPageConfigUserPagesResponse, GetPageConfigUserPagesResponses, GetSearchItemsData, GetSearchItemsFrequentlyUsedData, GetSearchItemsFrequentlyUsedResponse, GetSearchItemsFrequentlyUsedResponses, GetSearchItemsResponse, GetSearchItemsResponses, GetTestBookmarksData, GetTestBookmarksResponse, GetTestBookmarksResponses, GetWallpapersData, GetWallpapersResponse, GetWallpapersResponses, GetWeatherData, GetWeatherResponse, GetWeatherResponses, GetWidgetsByIntegrationData, GetWidgetsByIntegrationResponse, GetWidgetsByIntegrationResponses, GetWidgetsData, GetWidgetsGlanceableData, GetWidgetsGlanceableResponse, GetWidgetsGlanceableResponses, GetWidgetsGlanceablesData, GetWidgetsGlanceablesResponse, GetWidgetsGlanceablesResponses, GetWidgetsResponse, GetWidgetsResponses, Id, JsonBody, PatchAuthUpdateUserPropertyData, PatchAuthUpdateUserPropertyError, PatchAuthUpdateUserPropertyErrors, PatchAuthUpdateUserPropertyResponse, PatchAuthUpdateUserPropertyResponses, PostAuthChangePasswordData, PostAuthChangePasswordError, PostAuthChangePasswordErrors, PostAuthChangePasswordResponse, PostAuthChangePasswordResponses, PostAuthLoginData, PostAuthLoginError, PostAuthLoginErrors, PostAuthLoginResponse, PostAuthLoginResponses, PostAuthMfaData, PostAuthMfaError, PostAuthMfaErrors, PostAuthMfaResponse, PostAuthMfaResponses, PostAuthSignupData, PostAuthSignupError, PostAuthSignupErrors, PostAuthSignupResponse, PostAuthSignupResponses, PostAuthValidateAuthData, PostAuthValidateAuthError, PostAuthValidateAuthErrors, PostAuthValidateAuthResponse, PostAuthValidateAuthResponses, PostIntegrationsConsumerDataData, PostIntegrationsConsumerDataResponse, PostIntegrationsConsumerDataResponses, PostIntegrationsData, PostIntegrationsProxyActionData, PostIntegrationsProxyActionResponse, PostIntegrationsProxyActionResponses, PostIntegrationsResponse, PostIntegrationsResponses, PostIntegrationsTestEndpointData, PostIntegrationsTestEndpointResponse, PostIntegrationsTestEndpointResponses, PostLinksCollectionsData, PostLinksCollectionsResponse, PostLinksCollectionsResponses, PostLinksFoldersData, PostLinksFoldersResponse, PostLinksFoldersResponses, PostLinksHomeGroupsData, PostLinksHomeGroupsResponse, PostLinksHomeGroupsResponses, PostLinksItemsData, PostLinksItemsResponse, PostLinksItemsResponses, PostLinksReorderData, PostLinksReorderResponse, PostLinksReorderResponses, PostLinksTagsData, PostLinksTagsResponse, PostLinksTagsResponses, PostMonitoringHostsByIdRefreshData, PostMonitoringHostsByIdRefreshError, PostMonitoringHostsByIdRefreshErrors, PostMonitoringHostsByIdRefreshResponse, PostMonitoringHostsByIdRefreshResponses, PostMonitoringHostsData, PostMonitoringHostsResponse, PostMonitoringHostsResponses, PostMonitoringSshHostsData, PostMonitoringSshHostsResponse, PostMonitoringSshHostsResponses, PostMonitoringStatusData, PostMonitoringStatusError, PostMonitoringStatusErrors, PostMonitoringStatusResponse, PostMonitoringStatusResponses, PostMonitorsData, PostMonitorsResponse, PostMonitorsResponses, PostNewsFeedRecordsByIdData, PostNewsFeedRecordsByIdResponse, PostNewsFeedRecordsByIdResponses, PostNewsFeedRecordsData, PostNewsFeedRecordsResponse, PostNewsFeedRecordsResponses, PostNewsFeedRefreshData, PostNewsFeedRefreshResponse, PostNewsFeedRefreshResponses, PostNewsFeedSubscribeData, PostNewsFeedSubscribeResponse, PostNewsFeedSubscribeResponses, PostNewsFeedUnsubscribeData, PostNewsFeedUnsubscribeResponse, PostNewsFeedUnsubscribeResponses, PostNewsFeedUpdateData, PostNewsFeedUpdateResponse, PostNewsFeedUpdateResponses, PostNewsFixMissingTitlesData, PostNewsFixMissingTitlesResponse, PostNewsFixMissingTitlesResponses, PostNotificationsByTopicData, PostNotificationsByTopicResponse, PostNotificationsByTopicResponses, PostNotificationsData, PostNotificationsForwardersData, PostNotificationsForwardersResponse, PostNotificationsForwardersResponses, PostNotificationsForwardersTestData, PostNotificationsForwardersTestResponse, PostNotificationsForwardersTestResponses, PostNotificationsMarkAsReadData, PostNotificationsMarkAsReadResponse, PostNotificationsMarkAsReadResponses, PostNotificationsResponse, PostNotificationsResponses, PostNotificationsTestData, PostNotificationsTestResponse, PostNotificationsTestResponses, PostNotificationsTopicsData, PostNotificationsTopicsResponse, PostNotificationsTopicsResponses, PostNotificationsTopicTokensData, PostNotificationsTopicTokensResponse, PostNotificationsTopicTokensResponses, PostPageConfigHomeData, PostPageConfigHomeResponse, PostPageConfigHomeResponses, PostPageConfigIntegrationDataData, PostPageConfigIntegrationDataResponse, PostPageConfigIntegrationDataResponses, PostPageConfigMigrateLegacyData, PostPageConfigMigrateLegacyResponse, PostPageConfigMigrateLegacyResponses, PostSearchItemsUsageStatsData, PostSearchItemsUsageStatsResponse, PostSearchItemsUsageStatsResponses, PostWallpapersData, PostWallpapersResponse, PostWallpapersResponses, PutIntegrationsByIdData, PutIntegrationsByIdResponse, PutIntegrationsByIdResponses, PutLinksCollectionsByCollectionIdData, PutLinksCollectionsByCollectionIdResponse, PutLinksCollectionsByCollectionIdResponses, PutLinksFoldersByFolderIdIconData, PutLinksFoldersByFolderIdIconResponse, PutLinksFoldersByFolderIdIconResponses, PutLinksItemsByLinkIdData, PutLinksItemsByLinkIdResponse, PutLinksItemsByLinkIdResponses, PutLinksTagsByTagIdData, PutLinksTagsByTagIdResponse, PutLinksTagsByTagIdResponses, PutMonitoringHostsByIdData, PutMonitoringHostsByIdResponse, PutMonitoringHostsByIdResponses, PutMonitoringSshHostsByIdData, PutMonitoringSshHostsByIdResponse, PutMonitoringSshHostsByIdResponses, PutMonitorsByIdData, PutMonitorsByIdResponse, PutMonitorsByIdResponses, PutNotificationsForwardersData, PutNotificationsForwardersResponse, PutNotificationsForwardersResponses, PutNotificationsTopicTokensData, PutNotificationsTopicTokensResponse, PutNotificationsTopicTokensResponses, PutPageConfigData, PutPageConfigResponse, PutPageConfigResponses } from './types.gen'; diff --git a/apps/web/src/lib/api/sdk.gen.ts b/apps/web/src/lib/api/sdk.gen.ts index f5bcb53a..38c68db3 100644 --- a/apps/web/src/lib/api/sdk.gen.ts +++ b/apps/web/src/lib/api/sdk.gen.ts @@ -3,7 +3,7 @@ import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client'; import { client } from './client.gen'; -import type { DeleteAuthDeleteAccountData, DeleteAuthDeleteAccountErrors, DeleteAuthDeleteAccountResponses, DeleteIntegrationsByIdData, DeleteIntegrationsByIdResponses, DeleteLinksItemsByLinkIdData, DeleteLinksItemsByLinkIdResponses, DeleteMonitoringHostsByIdData, DeleteMonitoringHostsByIdResponses, DeleteMonitoringSshHostsByIdData, DeleteMonitoringSshHostsByIdResponses, DeleteMonitorsByIdData, DeleteMonitorsByIdResponses, DeleteNotificationsForwardersData, DeleteNotificationsForwardersResponses, DeleteNotificationsTopicsData, DeleteNotificationsTopicsResponses, DeleteNotificationsTopicTokensData, DeleteNotificationsTopicTokensResponses, GetAppConfigData, GetAppConfigResponses, GetAppInfoData, GetAppInfoResponses, GetAuthCallbackData, GetAuthCallbackResponses, GetAuthSsoData, GetAuthSsoResponses, GetGlanceablesByIntegrationData, GetGlanceablesByIntegrationResponses, GetGlanceablesData, GetGlanceablesResponses, GetIntegrationsCaldavEventsData, GetIntegrationsCaldavEventsResponses, GetIntegrationsConsumerDataData, GetIntegrationsConsumerDataResponses, GetIntegrationsData, GetIntegrationsResponses, GetIntegrationsWidgetPropertiesData, GetIntegrationsWidgetPropertiesResponses, GetJobsPullIconsData, GetJobsPullIconsErrors, GetJobsPullIconsResponses, GetJobsSearchItemsData, GetJobsSearchItemsResponses, GetLinksCollectionsData, GetLinksCollectionsResponses, GetLinksFoldersData, GetLinksFoldersResponses, GetLinksHomeData, GetLinksHomeGroupsData, GetLinksHomeGroupsResponses, GetLinksHomeResponses, GetLinksItemsData, GetLinksItemsResponses, GetLinksTagsData, GetLinksTagsResponses, GetLocationsData, GetLocationsResponses, GetMonitoringHostsByIdData, GetMonitoringHostsByIdHistoryData, GetMonitoringHostsByIdHistoryResponses, GetMonitoringHostsByIdResponses, GetMonitoringHostsByIdStatsData, GetMonitoringHostsByIdStatsResponses, GetMonitoringHostsData, GetMonitoringHostsResponses, GetMonitoringSshHostsData, GetMonitoringSshHostsResponses, GetMonitoringStatusData, GetMonitoringStatusResponses, GetMonitorsByIdData, GetMonitorsByIdResponses, GetMonitorsData, GetMonitorsResponses, GetNewsData, GetNewsFeedData, GetNewsFeedMetadataData, GetNewsFeedMetadataResponses, GetNewsFeedRecordsByIdData, GetNewsFeedRecordsByIdResponses, GetNewsFeedRefreshData, GetNewsFeedRefreshResponses, GetNewsFeedResponses, GetNewsFeedsByIdData, GetNewsFeedsByIdResponses, GetNewsFeedsData, GetNewsFeedsResponses, GetNewsResponses, GetNewsSubscriptionsByIdJsonData, GetNewsSubscriptionsByIdJsonResponses, GetNewsSubscriptionsData, GetNewsSubscriptionsResponses, GetNotificationsData, GetNotificationsForwardersData, GetNotificationsForwardersResponses, GetNotificationsResponses, GetNotificationsTopicsData, GetNotificationsTopicsResponses, GetNotificationsTopicTokensData, GetNotificationsTopicTokensResponses, GetPageConfigData, GetPageConfigResponses, GetPageConfigUserPagesData, GetPageConfigUserPagesResponses, GetSearchItemsData, GetSearchItemsFrequentlyUsedData, GetSearchItemsFrequentlyUsedResponses, GetSearchItemsResponses, GetTestBookmarksData, GetTestBookmarksResponses, GetWallpapersData, GetWallpapersResponses, GetWeatherData, GetWeatherResponses, GetWidgetsByIntegrationData, GetWidgetsByIntegrationResponses, GetWidgetsData, GetWidgetsGlanceableData, GetWidgetsGlanceableResponses, GetWidgetsGlanceablesData, GetWidgetsGlanceablesResponses, GetWidgetsResponses, PatchAuthUpdateUserPropertyData, PatchAuthUpdateUserPropertyErrors, PatchAuthUpdateUserPropertyResponses, PostAuthChangePasswordData, PostAuthChangePasswordErrors, PostAuthChangePasswordResponses, PostAuthLoginData, PostAuthLoginErrors, PostAuthLoginResponses, PostAuthMfaData, PostAuthMfaErrors, PostAuthMfaResponses, PostAuthSignupData, PostAuthSignupErrors, PostAuthSignupResponses, PostAuthValidateAuthData, PostAuthValidateAuthErrors, PostAuthValidateAuthResponses, PostIntegrationsConsumerDataData, PostIntegrationsConsumerDataResponses, PostIntegrationsData, PostIntegrationsProxyActionData, PostIntegrationsProxyActionResponses, PostIntegrationsResponses, PostIntegrationsTestEndpointData, PostIntegrationsTestEndpointResponses, PostLinksCollectionsData, PostLinksCollectionsResponses, PostLinksFoldersData, PostLinksFoldersResponses, PostLinksHomeGroupsData, PostLinksHomeGroupsResponses, PostLinksItemsData, PostLinksItemsResponses, PostLinksReorderData, PostLinksReorderResponses, PostLinksTagsData, PostLinksTagsResponses, PostMonitoringHostsByIdRefreshData, PostMonitoringHostsByIdRefreshErrors, PostMonitoringHostsByIdRefreshResponses, PostMonitoringHostsData, PostMonitoringHostsResponses, PostMonitoringSshHostsData, PostMonitoringSshHostsResponses, PostMonitoringStatusData, PostMonitoringStatusErrors, PostMonitoringStatusResponses, PostMonitorsData, PostMonitorsResponses, PostNewsFeedRecordsByIdData, PostNewsFeedRecordsByIdResponses, PostNewsFeedRecordsData, PostNewsFeedRecordsResponses, PostNewsFeedRefreshData, PostNewsFeedRefreshResponses, PostNewsFeedSubscribeData, PostNewsFeedSubscribeResponses, PostNewsFeedUnsubscribeData, PostNewsFeedUnsubscribeResponses, PostNewsFeedUpdateData, PostNewsFeedUpdateResponses, PostNewsFixMissingTitlesData, PostNewsFixMissingTitlesResponses, PostNotificationsByTopicData, PostNotificationsByTopicResponses, PostNotificationsData, PostNotificationsForwardersData, PostNotificationsForwardersResponses, PostNotificationsForwardersTestData, PostNotificationsForwardersTestResponses, PostNotificationsMarkAsReadData, PostNotificationsMarkAsReadResponses, PostNotificationsResponses, PostNotificationsTestData, PostNotificationsTestResponses, PostNotificationsTopicsData, PostNotificationsTopicsResponses, PostNotificationsTopicTokensData, PostNotificationsTopicTokensResponses, PostPageConfigHomeData, PostPageConfigHomeResponses, PostPageConfigIntegrationDataData, PostPageConfigIntegrationDataResponses, PostPageConfigMigrateLegacyData, PostPageConfigMigrateLegacyResponses, PostSearchItemsUsageStatsData, PostSearchItemsUsageStatsResponses, PostWallpapersData, PostWallpapersResponses, PutIntegrationsByIdData, PutIntegrationsByIdResponses, PutLinksCollectionsByCollectionIdData, PutLinksCollectionsByCollectionIdResponses, PutLinksFoldersByFolderIdIconData, PutLinksFoldersByFolderIdIconResponses, PutLinksItemsByLinkIdData, PutLinksItemsByLinkIdResponses, PutLinksTagsByTagIdData, PutLinksTagsByTagIdResponses, PutMonitoringHostsByIdData, PutMonitoringHostsByIdResponses, PutMonitoringSshHostsByIdData, PutMonitoringSshHostsByIdResponses, PutMonitorsByIdData, PutMonitorsByIdResponses, PutNotificationsForwardersData, PutNotificationsForwardersResponses, PutNotificationsTopicTokensData, PutNotificationsTopicTokensResponses, PutPageConfigData, PutPageConfigResponses } from './types.gen'; +import type { DeleteAuthDeleteAccountData, DeleteAuthDeleteAccountErrors, DeleteAuthDeleteAccountResponses, DeleteIntegrationsByIdData, DeleteIntegrationsByIdResponses, DeleteLinksItemsByLinkIdData, DeleteLinksItemsByLinkIdResponses, DeleteMonitoringHostsByIdData, DeleteMonitoringHostsByIdResponses, DeleteMonitoringSshHostsByIdData, DeleteMonitoringSshHostsByIdResponses, DeleteMonitorsByIdData, DeleteMonitorsByIdResponses, DeleteNewsFeedRecordsByIdData, DeleteNewsFeedRecordsByIdResponses, DeleteNotificationsForwardersData, DeleteNotificationsForwardersResponses, DeleteNotificationsTopicsData, DeleteNotificationsTopicsResponses, DeleteNotificationsTopicTokensData, DeleteNotificationsTopicTokensResponses, GetAppConfigData, GetAppConfigResponses, GetAppInfoData, GetAppInfoResponses, GetAuthCallbackData, GetAuthCallbackResponses, GetAuthSsoData, GetAuthSsoResponses, GetGlanceablesByIntegrationData, GetGlanceablesByIntegrationResponses, GetGlanceablesData, GetGlanceablesResponses, GetIntegrationsCaldavEventsData, GetIntegrationsCaldavEventsResponses, GetIntegrationsConsumerDataData, GetIntegrationsConsumerDataResponses, GetIntegrationsData, GetIntegrationsResponses, GetIntegrationsWidgetPropertiesData, GetIntegrationsWidgetPropertiesResponses, GetJobsPullIconsData, GetJobsPullIconsErrors, GetJobsPullIconsResponses, GetJobsSearchItemsData, GetJobsSearchItemsResponses, GetLinksCollectionsData, GetLinksCollectionsResponses, GetLinksFoldersData, GetLinksFoldersResponses, GetLinksHomeData, GetLinksHomeGroupsData, GetLinksHomeGroupsResponses, GetLinksHomeResponses, GetLinksItemsData, GetLinksItemsResponses, GetLinksTagsData, GetLinksTagsResponses, GetLocationsData, GetLocationsResponses, GetMonitoringHostsByIdData, GetMonitoringHostsByIdHistoryData, GetMonitoringHostsByIdHistoryResponses, GetMonitoringHostsByIdResponses, GetMonitoringHostsByIdStatsData, GetMonitoringHostsByIdStatsResponses, GetMonitoringHostsData, GetMonitoringHostsResponses, GetMonitoringSshHostsData, GetMonitoringSshHostsResponses, GetMonitoringStatusData, GetMonitoringStatusResponses, GetMonitorsByIdData, GetMonitorsByIdResponses, GetMonitorsData, GetMonitorsResponses, GetNewsData, GetNewsFeedData, GetNewsFeedMetadataData, GetNewsFeedMetadataResponses, GetNewsFeedRecordsByIdData, GetNewsFeedRecordsByIdResponses, GetNewsFeedRefreshData, GetNewsFeedRefreshResponses, GetNewsFeedResponses, GetNewsFeedsByIdData, GetNewsFeedsByIdResponses, GetNewsFeedsData, GetNewsFeedsResponses, GetNewsResponses, GetNewsSubscriptionsByIdJsonData, GetNewsSubscriptionsByIdJsonResponses, GetNewsSubscriptionsData, GetNewsSubscriptionsResponses, GetNotificationsData, GetNotificationsForwardersData, GetNotificationsForwardersResponses, GetNotificationsResponses, GetNotificationsTopicsData, GetNotificationsTopicsResponses, GetNotificationsTopicTokensData, GetNotificationsTopicTokensResponses, GetPageConfigData, GetPageConfigResponses, GetPageConfigUserPagesData, GetPageConfigUserPagesResponses, GetSearchItemsData, GetSearchItemsFrequentlyUsedData, GetSearchItemsFrequentlyUsedResponses, GetSearchItemsResponses, GetTestBookmarksData, GetTestBookmarksResponses, GetWallpapersData, GetWallpapersResponses, GetWeatherData, GetWeatherResponses, GetWidgetsByIntegrationData, GetWidgetsByIntegrationResponses, GetWidgetsData, GetWidgetsGlanceableData, GetWidgetsGlanceableResponses, GetWidgetsGlanceablesData, GetWidgetsGlanceablesResponses, GetWidgetsResponses, PatchAuthUpdateUserPropertyData, PatchAuthUpdateUserPropertyErrors, PatchAuthUpdateUserPropertyResponses, PostAuthChangePasswordData, PostAuthChangePasswordErrors, PostAuthChangePasswordResponses, PostAuthLoginData, PostAuthLoginErrors, PostAuthLoginResponses, PostAuthMfaData, PostAuthMfaErrors, PostAuthMfaResponses, PostAuthSignupData, PostAuthSignupErrors, PostAuthSignupResponses, PostAuthValidateAuthData, PostAuthValidateAuthErrors, PostAuthValidateAuthResponses, PostIntegrationsConsumerDataData, PostIntegrationsConsumerDataResponses, PostIntegrationsData, PostIntegrationsProxyActionData, PostIntegrationsProxyActionResponses, PostIntegrationsResponses, PostIntegrationsTestEndpointData, PostIntegrationsTestEndpointResponses, PostLinksCollectionsData, PostLinksCollectionsResponses, PostLinksFoldersData, PostLinksFoldersResponses, PostLinksHomeGroupsData, PostLinksHomeGroupsResponses, PostLinksItemsData, PostLinksItemsResponses, PostLinksReorderData, PostLinksReorderResponses, PostLinksTagsData, PostLinksTagsResponses, PostMonitoringHostsByIdRefreshData, PostMonitoringHostsByIdRefreshErrors, PostMonitoringHostsByIdRefreshResponses, PostMonitoringHostsData, PostMonitoringHostsResponses, PostMonitoringSshHostsData, PostMonitoringSshHostsResponses, PostMonitoringStatusData, PostMonitoringStatusErrors, PostMonitoringStatusResponses, PostMonitorsData, PostMonitorsResponses, PostNewsFeedRecordsByIdData, PostNewsFeedRecordsByIdResponses, PostNewsFeedRecordsData, PostNewsFeedRecordsResponses, PostNewsFeedRefreshData, PostNewsFeedRefreshResponses, PostNewsFeedSubscribeData, PostNewsFeedSubscribeResponses, PostNewsFeedUnsubscribeData, PostNewsFeedUnsubscribeResponses, PostNewsFeedUpdateData, PostNewsFeedUpdateResponses, PostNewsFixMissingTitlesData, PostNewsFixMissingTitlesResponses, PostNotificationsByTopicData, PostNotificationsByTopicResponses, PostNotificationsData, PostNotificationsForwardersData, PostNotificationsForwardersResponses, PostNotificationsForwardersTestData, PostNotificationsForwardersTestResponses, PostNotificationsMarkAsReadData, PostNotificationsMarkAsReadResponses, PostNotificationsResponses, PostNotificationsTestData, PostNotificationsTestResponses, PostNotificationsTopicsData, PostNotificationsTopicsResponses, PostNotificationsTopicTokensData, PostNotificationsTopicTokensResponses, PostPageConfigHomeData, PostPageConfigHomeResponses, PostPageConfigIntegrationDataData, PostPageConfigIntegrationDataResponses, PostPageConfigMigrateLegacyData, PostPageConfigMigrateLegacyResponses, PostSearchItemsUsageStatsData, PostSearchItemsUsageStatsResponses, PostWallpapersData, PostWallpapersResponses, PutIntegrationsByIdData, PutIntegrationsByIdResponses, PutLinksCollectionsByCollectionIdData, PutLinksCollectionsByCollectionIdResponses, PutLinksFoldersByFolderIdIconData, PutLinksFoldersByFolderIdIconResponses, PutLinksItemsByLinkIdData, PutLinksItemsByLinkIdResponses, PutLinksTagsByTagIdData, PutLinksTagsByTagIdResponses, PutMonitoringHostsByIdData, PutMonitoringHostsByIdResponses, PutMonitoringSshHostsByIdData, PutMonitoringSshHostsByIdResponses, PutMonitorsByIdData, PutMonitorsByIdResponses, PutNotificationsForwardersData, PutNotificationsForwardersResponses, PutNotificationsTopicTokensData, PutNotificationsTopicTokensResponses, PutPageConfigData, PutPageConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -882,6 +882,11 @@ export const putMonitorsById = (options: O */ export const getNewsFeedsById = (options: Options): RequestResult => (options.client ?? client).get({ url: '/news/feeds/{id}', ...options }); +/** + * Delete news feed record + */ +export const deleteNewsFeedRecordsById = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/news/feed-records/{id}', ...options }); + /** * Get news feed record */ diff --git a/apps/web/src/lib/api/types.gen.ts b/apps/web/src/lib/api/types.gen.ts index 3fdd1619..6bb288a8 100644 --- a/apps/web/src/lib/api/types.gen.ts +++ b/apps/web/src/lib/api/types.gen.ts @@ -1820,6 +1820,24 @@ export type GetNewsFeedsByIdResponses = { export type GetNewsFeedsByIdResponse = GetNewsFeedsByIdResponses[keyof GetNewsFeedsByIdResponses]; +export type DeleteNewsFeedRecordsByIdData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: '/news/feed-records/{id}'; +}; + +export type DeleteNewsFeedRecordsByIdResponses = { + /** + * OK + */ + 200: GenericObject; +}; + +export type DeleteNewsFeedRecordsByIdResponse = DeleteNewsFeedRecordsByIdResponses[keyof DeleteNewsFeedRecordsByIdResponses]; + export type GetNewsFeedRecordsByIdData = { body?: never; path: { diff --git a/apps/web/src/lib/apiClient.ts b/apps/web/src/lib/apiClient.ts index 5d59fcc5..9d548f7e 100644 --- a/apps/web/src/lib/apiClient.ts +++ b/apps/web/src/lib/apiClient.ts @@ -571,6 +571,10 @@ export async function updateNewsFeedRecordAction(auth: ActionAuth, payload: News return extractData(await postNewsFeedRecordsById({ path: { id: payload.feedId ?? "all" }, body: payload, headers: authHeaders(auth) })); } +export async function deleteNewsFeedRecordAction(auth: ActionAuth, feedId: string) { + return extractData(await sdk.deleteNewsFeedRecordsById({ path: { id: feedId }, headers: authHeaders(auth) })); +} + export async function fixMissingTitlesAction(auth: ActionAuth): Promise { return extractData(await postNewsFixMissingTitles({ headers: authHeaders(auth) })) as Promise; } diff --git a/packages/api-types/src/openapi.ts b/packages/api-types/src/openapi.ts index d0418d54..dd882855 100644 --- a/packages/api-types/src/openapi.ts +++ b/packages/api-types/src/openapi.ts @@ -2544,7 +2544,21 @@ export interface paths { 200: components["responses"]["JsonOk"]; }; }; - delete?: never; + /** Delete news feed record */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["JsonOk"]; + }; + }; options?: never; head?: never; patch?: never; From 0f67d426db0f4fb6258562ac4a7d14e2e03f0023 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Tue, 25 Aug 2026 17:00:34 +0200 Subject: [PATCH 21/57] feat: add news feed widget selector --- .../settings/pages/DashboardWidgetPreview.tsx | 121 +++++++++++++++++- 1 file changed, 120 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/pages/DashboardWidgetPreview.tsx b/apps/web/src/components/settings/pages/DashboardWidgetPreview.tsx index 7903b920..1cf95b8b 100644 --- a/apps/web/src/components/settings/pages/DashboardWidgetPreview.tsx +++ b/apps/web/src/components/settings/pages/DashboardWidgetPreview.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState, useCallback, type Dispatch, type ReactNode, type SetStateAction } from "react"; import useAuth from "@/context/useAuth"; -import { getConsumerDataAction, previewImageSourceAction } from '@/lib/apiClient'; +import { getConsumerDataAction, getNewsFeedsAction, previewImageSourceAction } from '@/lib/apiClient'; import { closestCenter, DndContext, @@ -585,6 +585,18 @@ function WidgetInputEditor({ dataError: string | null; setDataError: (value: string | null) => void; }) { + if (widgetType === "rss-feed" || widgetType === "latest-rss-feed") { + return ( + + ); + } + if (widgetType === "shortcuts") { const shortcutIds = Array.isArray(inputDraft.shortcutIds) ? inputDraft.shortcutIds.filter((id): id is string => typeof id === "string") @@ -618,6 +630,113 @@ function WidgetInputEditor({ ); } +function NewsFeedWidgetInputEditor({ + schema, + inputDraft, + onChange, + dataError, + setDataError, +}: { + schema?: Record; + inputDraft: Record; + onChange: (next: Record) => void; + dataError: string | null; + setDataError: (value: string | null) => void; +}) { + const { withAuth } = useAuth(); + const [feeds, setFeeds] = useState>([]); + const [isLoadingFeeds, setIsLoadingFeeds] = useState(true); + const [feedError, setFeedError] = useState(null); + const selectedFeedId = String(inputDraft.feedId ?? schema?.feedId ?? "all").trim() || "all"; + + useEffect(() => { + let cancelled = false; + + setIsLoadingFeeds(true); + setFeedError(null); + + void withAuth((auth) => getNewsFeedsAction(auth)) + .then((response) => { + if (cancelled) return; + setFeeds(Array.isArray(response?.feeds) + ? response.feeds + .map((feed) => ({ id: String(feed.id || "").trim(), title: String(feed.title || "Untitled feed") })) + .filter((feed) => feed.id) + : []); + }) + .catch((error) => { + if (!cancelled) { + setFeedError(error instanceof Error ? error.message : "Unable to load news feeds."); + } + }) + .finally(() => { + if (!cancelled) setIsLoadingFeeds(false); + }); + + return () => { + cancelled = true; + }; + }, [withAuth]); + + const feedOptions = useMemo(() => { + const options = feeds.some((feed) => feed.id === "all") + ? feeds + : [{ id: "all", title: "All feed" }, ...feeds]; + + if (options.some((feed) => feed.id === selectedFeedId)) return options; + return [{ id: selectedFeedId, title: "Selected feed" }, ...options]; + }, [feeds, selectedFeedId]); + + const propertySchema = { ...(schema ?? {}) }; + delete propertySchema.feedId; + const formValue = { ...inputDraft }; + delete formValue.feedId; + + return ( +
+
+
+ + + select + +
+

Choose which news feed this widget displays.

+ + {isLoadingFeeds &&

Loading feeds…

} + {feedError &&

{feedError}

} +
+ + onChange({ ...next, feedId: selectedFeedId })} + onError={setDataError} + error={dataError} + emptyMessage="No additional input properties for this widget." + /> +
+ ); +} + function ImageWidgetInputEditor({ schema, inputDraft, From 0499782b2eb83b37064d6c208dc2e8c248acac27 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Thu, 27 Aug 2026 15:16:41 +0200 Subject: [PATCH 22/57] feat: add curated integrations settings --- .../settings/integrations/page.tsx | 244 +++++++++++++++++- .../AddIntegrationConfigDialog.tsx | 53 ++-- 2 files changed, 271 insertions(+), 26 deletions(-) diff --git a/apps/web/src/app/(authenticated)/settings/integrations/page.tsx b/apps/web/src/app/(authenticated)/settings/integrations/page.tsx index dd2cabee..de801f69 100644 --- a/apps/web/src/app/(authenticated)/settings/integrations/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/integrations/page.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import Editor from "@monaco-editor/react"; -import { Loader2 } from "lucide-react"; +import { Loader2, Puzzle } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -79,6 +79,15 @@ type ResolvedEndpoint = { resolvedBody: unknown; }; +type CuratedIntegration = { + name: string; + category: string; + url: string; +}; + +const CURATED_CATALOGUE_URL = + "https://raw.githubusercontent.com/dashwise-homelab/integrations/refs/heads/main/catalogue.json"; + export default function IntegrationsModularSettingsPage() { const { token, withAuth } = useAuth(); @@ -87,10 +96,18 @@ export default function IntegrationsModularSettingsPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [settingsTab, setSettingsTab] = useState<"local" | "curated">("local"); + const [curatedIntegrations, setCuratedIntegrations] = useState([]); + const [curatedLoading, setCuratedLoading] = useState(false); + const [curatedError, setCuratedError] = useState(null); + const [selectedCuratedCategory, setSelectedCuratedCategory] = useState("all"); + const [loadingCuratedUrl, setLoadingCuratedUrl] = useState(null); + const [addDialogOpen, setAddDialogOpen] = useState(false); const [newName, setNewName] = useState(""); const [newType, setNewType] = useState<"plugin" | "caldav">("plugin"); const [newConfig, setNewConfig] = useState(""); + const [curatedSource, setCuratedSource] = useState(null); const [envDefinitions, setEnvDefinitions] = useState([]); const [environmentOverrides, setEnvironmentOverrides] = useState>({}); const [formError, setFormError] = useState(null); @@ -141,6 +158,21 @@ export default function IntegrationsModularSettingsPage() { [envDefinitions] ); + const curatedCategories = useMemo( + () => ["all", ...new Set(curatedIntegrations.map((integration) => integration.category))], + [curatedIntegrations] + ); + + const visibleCuratedIntegrations = useMemo( + () => + selectedCuratedCategory === "all" + ? curatedIntegrations + : curatedIntegrations.filter( + (integration) => integration.category === selectedCuratedCategory + ), + [curatedIntegrations, selectedCuratedCategory] + ); + const openIntegrationDetails = (integrationId: string) => { setSelectedId(integrationId); setDetailsDialogOpen(true); @@ -150,6 +182,84 @@ export default function IntegrationsModularSettingsPage() { setDetailsDialogOpen(open); }; + const fetchCuratedIntegrations = useCallback(async () => { + setCuratedLoading(true); + setCuratedError(null); + + try { + const response = await fetch(CURATED_CATALOGUE_URL); + if (!response.ok) { + throw new Error(`Catalogue request failed with ${response.status}`); + } + + const data: unknown = await response.json(); + const entries = Array.isArray(data) + ? data + .filter(isRecord) + .map((entry) => ({ + name: typeof entry.name === "string" ? entry.name.trim() : "", + category: typeof entry.category === "string" ? entry.category.trim() : "other", + url: typeof entry.url === "string" ? entry.url.trim() : "", + })) + .filter((entry) => entry.name && entry.url) + : []; + + setCuratedIntegrations(entries); + } catch (err) { + console.error("Unable to load curated integrations", err); + setCuratedError("Unable to load curated integrations right now."); + } finally { + setCuratedLoading(false); + } + }, []); + + useEffect(() => { + void fetchCuratedIntegrations(); + }, [fetchCuratedIntegrations]); + + const openManualAddDialog = () => { + setCuratedSource(null); + setNewName(""); + setNewType("plugin"); + setNewConfig(""); + setEnvironmentOverrides({}); + setFormError(null); + setAddDialogOpen(true); + }; + + const openCuratedIntegration = async (entry: CuratedIntegration) => { + setLoadingCuratedUrl(entry.url); + setFormError(null); + + try { + const response = await fetch(entry.url); + if (!response.ok) { + throw new Error(`Integration request failed with ${response.status}`); + } + + const configText = await response.text(); + const parsedConfig = parseConfigValue(configText); + if (!isRecord(parsedConfig)) { + throw new Error("The curated YAML is invalid."); + } + + const details = isRecord(parsedConfig.details) ? parsedConfig.details : {}; + const configName = typeof details.name === "string" ? details.name.trim() : ""; + + setCuratedSource(entry.url); + setNewName(configName || entry.name); + setNewType("plugin"); + setNewConfig(configText); + setEnvironmentOverrides({}); + setAddDialogOpen(true); + } catch (err) { + console.error("Unable to load curated integration", err); + setCuratedError(`Unable to load ${entry.name} right now.`); + } finally { + setLoadingCuratedUrl(null); + } + }; + const triggerEndpointTest = useCallback( async (endpoint: ResolvedEndpoint) => { if (!selectedIntegration) { @@ -360,7 +470,7 @@ export default function IntegrationsModularSettingsPage() { createIntegrationAction(auth, { name: newName.trim() || undefined, type: newType, - source: "manual", + source: curatedSource ?? "manual", config: parsedConfig, environment: environmentOverrides, }) @@ -370,6 +480,9 @@ export default function IntegrationsModularSettingsPage() { setNewName(""); setNewType("plugin"); setNewConfig(""); + setCuratedSource(null); + setEnvironmentOverrides({}); + setFormError(null); await fetchIntegrations(); } catch (err) { console.error("Unable to create integration", err); @@ -382,6 +495,7 @@ export default function IntegrationsModularSettingsPage() { fetchIntegrations, newConfig, newName, + curatedSource, token, visibleEnvFields, withAuth, @@ -530,11 +644,117 @@ export default function IntegrationsModularSettingsPage() { {loading ? : null} Refresh - +
- {error && ( +
+
+ {(["local", "curated"] as const).map((tab) => ( + + ))} +
+
+ + {settingsTab === "curated" && ( +
+
+
+
+

Curated integrations

+

+ Pick an integration to add it from the Dashwise catalogue. +

+
+

+ {curatedIntegrations.length} available +

+
+ +
+ {curatedCategories.map((category) => ( + + ))} +
+
+ + {curatedLoading ? ( +
+ +
+ ) : curatedError ? ( + + +

{curatedError}

+ +
+
+ ) : visibleCuratedIntegrations.length === 0 ? ( +

No integrations match this tag.

+ ) : ( +
+ {visibleCuratedIntegrations.map((integration) => { + const isLoading = loadingCuratedUrl === integration.url; + return ( + + ); + })} +
+ )} +
+ )} + + {settingsTab === "local" && error && (

{error}

@@ -542,7 +762,7 @@ export default function IntegrationsModularSettingsPage() {
)} - {checkingUpdates && !loading && ( + {settingsTab === "local" && checkingUpdates && !loading && (

Checking integration sources for updates...

@@ -550,7 +770,7 @@ export default function IntegrationsModularSettingsPage() {
)} - {integrations.some((integration) => integration.localData?.updateAvailable) && ( + {settingsTab === "local" && integrations.some((integration) => integration.localData?.updateAvailable) && (
@@ -576,7 +796,7 @@ export default function IntegrationsModularSettingsPage() { )} -
+ {settingsTab === "local" &&

Your integrations

{integrations.length} total

@@ -665,7 +885,7 @@ export default function IntegrationsModularSettingsPage() {
)}
-
+
} void; + mode?: "manual" | "curated"; newName: string; onNewNameChange: (value: string) => void; newType: "plugin" | "caldav"; @@ -42,6 +43,7 @@ type AddIntegrationConfigDialogProps = { export function AddIntegrationConfigDialog({ open, onOpenChange, + mode = "manual", newName, onNewNameChange, newType, @@ -61,6 +63,12 @@ export function AddIntegrationConfigDialog({ const [caldavUsername, setCaldavUsername] = useState(""); const [caldavPassword, setCaldavPassword] = useState(""); + useEffect(() => { + if (open) { + setStage(mode === "curated" ? 2 : 1); + } + }, [mode, open]); + const updateCaldavConfig = (url: string, user: string, pass: string) => { onNewConfigChange(""); onEnvironmentOverrideChange("CALDAV_URL", url); @@ -79,16 +87,20 @@ export function AddIntegrationConfigDialog({ > - Add a manual integration + + {mode === "curated" ? `Configure ${newName}` : "Add a manual integration"} + - {stage === 1 + {mode === "curated" + ? "The curated YAML is ready. Enter the environment values for this integration." + : stage === 1 ? "Provide a name and configuration." : "Override environment variables before saving."}
- {stage === 1 && ( + {mode === "manual" && stage === 1 && ( <>
@@ -191,13 +203,15 @@ export function AddIntegrationConfigDialog({ )} - {stage === 2 && ( + {(mode === "curated" || stage === 2) && (
{newType === "caldav" ? (

Click Continue to finish creating your CalDAV integration.

) : ( <> - + {visibleEnvFields.length ? (
@@ -244,8 +258,9 @@ export function AddIntegrationConfigDialog({
) : (

- Paste a config JSON or YAML that declares - environment_variables to show override inputs here. + {mode === "curated" + ? "This integration does not require any environment values." + : "Paste a config JSON or YAML that declares environment_variables to show override inputs here."}

)} @@ -265,7 +280,7 @@ export function AddIntegrationConfigDialog({ Cancel - {stage === 1 ? ( + {mode === "manual" && stage === 1 ? ( ) : ( <> - + {mode === "manual" && ( + + )} )} @@ -308,4 +325,4 @@ export function AddIntegrationConfigDialog({ ); -} \ No newline at end of file +} From 27ad202691a2b6fafeffd84a66f8bef363581033 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Thu, 27 Aug 2026 15:37:13 +0200 Subject: [PATCH 23/57] feat: add glanceables to screensaver clock --- .../settings/SmartFramesManager.tsx | 57 +---- apps/web/src/components/widgets/Widget.tsx | 9 +- .../widgets/dashboard/GlanceableClock.tsx | 84 ++++++- .../forms/WidgetPropertiesForm.tsx | 208 +++++++++++++++++- 4 files changed, 302 insertions(+), 56 deletions(-) diff --git a/apps/web/src/components/settings/SmartFramesManager.tsx b/apps/web/src/components/settings/SmartFramesManager.tsx index 758b0dd9..268e579f 100644 --- a/apps/web/src/components/settings/SmartFramesManager.tsx +++ b/apps/web/src/components/settings/SmartFramesManager.tsx @@ -14,13 +14,6 @@ import { } from "@/components/ui/dropdown-menu"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { PopoverClose } from "@radix-ui/react-popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Switch } from "@/components/ui/switch"; import useAuth from "@/context/useAuth"; @@ -31,10 +24,11 @@ import { } from "@/lib/apiClient"; import { normalizeWallpaperFilters } from "./wallpaperFilterDefaults"; import { renderWidget } from "../widgets/Widget"; -import WidgetPropertiesForm from "@dashwise/integrationskit/forms/WidgetPropertiesForm"; +import WidgetPropertiesForm, { type GlanceableOption } from "@dashwise/integrationskit/forms/WidgetPropertiesForm"; import LocationSelectFormComponent from "./LocationSelectForm"; const LOCAL_WIDGET_OPTIONS = [ + { value: "glanceable-clock", label: "Glanceable Clock" }, { value: "calendar-today", label: "Calendar Overview: Today" }, { value: "calendar-week", label: "Calendar Overview: Week" }, { value: "calendar-upcoming", label: "Calendar Overview: Upcoming" }, @@ -275,9 +269,7 @@ export default function SmartFramesManager({ const [widgetCategories, setWidgetCategories] = useState([]); const [selectedWidgetCategory, setSelectedWidgetCategory] = useState("frame"); const [isWidgetCarouselOpen, setIsWidgetCarouselOpen] = useState(false); - const [glanceableOptions, setGlanceableOptions] = useState< - Array<{ value: string; label: string }> - >([]); + const [glanceableOptions, setGlanceableOptions] = useState([]); const [widgetSchemas, setWidgetSchemas] = useState< Record> >({}); @@ -429,6 +421,9 @@ export default function SmartFramesManager({ glanceable.name ?? glanceable.displayName ?? glanceable.type ?? glanceable.key ?? "Glanceable", ), + exampleProps: glanceable.exampleProps && typeof glanceable.exampleProps === "object" + ? glanceable.exampleProps + : {}, })).filter((glanceable) => glanceable.value); setWidgetOptionsByCategory(nextWidgetOptionsByCategory); @@ -942,40 +937,6 @@ export default function SmartFramesManager({
- {selectedSection?.widgetType === "glanceable-clock" && glanceableOptions.length > 0 && ( -
- -
- {(["left", "right"] as const).map((side) => ( - - ))} -
-
- )} {selectedSection?.widgetType === "weather" && ( updateSelectedSection({ params })} onError={setWidgetParamsError} error={widgetParamsError} + glanceableOptions={selectedSection?.widgetType === "glanceable-clock" ? glanceableOptions : undefined} + glanceableSlotPositions={["top", "down"]} />
diff --git a/apps/web/src/components/widgets/Widget.tsx b/apps/web/src/components/widgets/Widget.tsx index 23aa1135..e3f555d1 100644 --- a/apps/web/src/components/widgets/Widget.tsx +++ b/apps/web/src/components/widgets/Widget.tsx @@ -74,7 +74,14 @@ export function renderWidget({ switch (resolvedType) { case "main-clock": case "glanceable-clock": - return ; + return ( + + ); case "search-bar": return ; diff --git a/apps/web/src/components/widgets/dashboard/GlanceableClock.tsx b/apps/web/src/components/widgets/dashboard/GlanceableClock.tsx index 615bede6..f15b772e 100644 --- a/apps/web/src/components/widgets/dashboard/GlanceableClock.tsx +++ b/apps/web/src/components/widgets/dashboard/GlanceableClock.tsx @@ -44,9 +44,15 @@ const LOCAL_ONLY_GLANCEABLES = new Set([ type GlanceableClockWidgetProps = WidgetItemProps & { isPreview?: boolean; + layout?: "dashboard" | "frame"; }; -export default function GlanceableClockWidget({ className, params, isPreview }: GlanceableClockWidgetProps) { +type GlanceableSlot = { + type: string; + params?: Record; +}; + +export default function GlanceableClockWidget({ className, params, isPreview, layout = "dashboard" }: GlanceableClockWidgetProps) { const { pageConfig } = usePageConfig(); const { user } = useAuth(); const localization = useLocalization(); @@ -58,7 +64,7 @@ export default function GlanceableClockWidget({ className, params, isPreview }: const defaultGlanceables: any[] = pageConfig?.glanceables ?? []; const glanceableKeys = glanceableOverrides - ? Object.keys(glanceableOverrides) + ? Object.keys(glanceableOverrides).filter((key) => key !== "slots" && key !== "intervals") : defaultGlanceables.map((g) => g?.type).filter(Boolean); const getParams = (type: string) => { @@ -79,9 +85,40 @@ export default function GlanceableClockWidget({ className, params, isPreview }: } return Object.keys(rest).length > 0 ? rest : undefined; }; - const slots = glanceableOverrides?.slots as Partial }>>> | undefined; + const slots = glanceableOverrides?.slots as Partial> | undefined; const carouselIntervals = glanceableOverrides?.intervals as Partial> | undefined; + const horizontalItems = { + left: getSlotItems(slots?.left ?? (glanceableKeys[0] ? [{ type: glanceableKeys[0], params: getParams(glanceableKeys[0]) }] : [])), + right: getSlotItems(slots?.right ?? (glanceableKeys[1] ? [{ type: glanceableKeys[1], params: getParams(glanceableKeys[1]) }] : [])), + }; + const verticalItems = { + top: getSlotItems(slots?.top ?? slots?.left ?? []), + down: getSlotItems(slots?.down ?? slots?.right ?? []), + }; + + if (layout === "frame") { + return ( +
+ +
+ +
+ +
+ ); + } + const previewGridStyle = isPreview ? { gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)", gridTemplateRows: "minmax(0, 1fr)", gridTemplateAreas: '"gl1 clock gl2"' } : undefined; @@ -109,14 +146,14 @@ export default function GlanceableClockWidget({ className, params, isPreview }:
@@ -125,6 +162,43 @@ export default function GlanceableClockWidget({ className, params, isPreview }: ); } +function getSlotItems(items: unknown): GlanceableSlot[] { + const entries = Array.isArray(items) ? items : items === undefined ? [] : [items]; + return entries.flatMap((item) => { + if (typeof item === "string" && item.trim()) { + return [{ type: item.trim() }]; + } + + return item && typeof item === "object" && typeof item.type === "string" && item.type.trim() + ? [item as GlanceableSlot] + : []; + }); +} + +function GlanceableRow({ + items, + formatters, + isPreview, +}: { + items: GlanceableSlot[]; + formatters: LocalizationFormatters; + isPreview?: boolean; +}) { + return items.length > 0 ? ( +
+ {items.map((item, index) => ( + + ))} +
+ ) : null; +} + function GlanceableCarousel({ items, intervalSeconds, diff --git a/packages/integrationskit/forms/WidgetPropertiesForm.tsx b/packages/integrationskit/forms/WidgetPropertiesForm.tsx index c141053a..73c37394 100644 --- a/packages/integrationskit/forms/WidgetPropertiesForm.tsx +++ b/packages/integrationskit/forms/WidgetPropertiesForm.tsx @@ -1,4 +1,12 @@ -import type { ChangeEvent } from "react"; +import { useEffect, useState, type ChangeEvent } from "react"; + +export type GlanceableSlotPosition = "left" | "right" | "top" | "down"; + +export type GlanceableOption = { + value: string; + label: string; + exampleProps?: Record; +}; export type WidgetPropertiesFormProps = { idPrefix?: string; @@ -8,6 +16,8 @@ export type WidgetPropertiesFormProps = { onError?: (message: string | null) => void; error?: string | null; emptyMessage?: string; + glanceableOptions?: GlanceableOption[]; + glanceableSlotPositions?: readonly GlanceableSlotPosition[]; }; type SelectSchema = { @@ -48,6 +58,185 @@ function getSelectOptions(schemaValue: SelectSchema) { .filter((option) => Boolean(option.value)); } +type GlanceableSlot = { + type: string; + params: Record; +}; + +type GlanceableSlots = Record; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function normalizeGlanceableSlot(value: unknown): GlanceableSlot | null { + if (typeof value === "string" && value.trim()) { + return { type: value.trim(), params: {} }; + } + + if (!isRecord(value) || typeof value.type !== "string" || !value.type.trim()) { + return null; + } + + return { + type: value.type.trim(), + params: isRecord(value.params) ? value.params : {}, + }; +} + +function normalizeGlanceableSlots( + value: unknown, +): GlanceableSlots { + const record = isRecord(value) ? value : {}; + const rawSlots = isRecord(record.slots) ? record.slots : record; + + return { + left: normalizePositionSlots(rawSlots.left), + right: normalizePositionSlots(rawSlots.right), + top: normalizePositionSlots(rawSlots.top), + down: normalizePositionSlots(rawSlots.down), + }; + + function normalizePositionSlots(rawValue: unknown) { + const values = Array.isArray(rawValue) + ? rawValue + : rawValue === undefined + ? [] + : [rawValue]; + return values + .map(normalizeGlanceableSlot) + .filter((item): item is GlanceableSlot => Boolean(item)); + } +} + +function GlanceablesEditor({ + value, + options, + positions, + onChange, + onError, +}: { + value: unknown; + options: GlanceableOption[]; + positions: readonly GlanceableSlotPosition[]; + onChange: (next: Record) => void; + onError?: (message: string | null) => void; +}) { + const currentValue = isRecord(value) && value.type !== "glanceables" ? value : {}; + const slots = normalizeGlanceableSlots(value); + const [paramDrafts, setParamDrafts] = useState>({}); + const positionKey = positions.join("|"); + + useEffect(() => { + const nextDrafts: Record = {}; + positions.forEach((position) => { + slots[position].forEach((slot, index) => { + nextDrafts[`${position}-${index}`] = JSON.stringify(slot.params, null, 2); + }); + }); + setParamDrafts(nextDrafts); + // The value and selected positions are the source of truth for the text drafts. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [positionKey, value]); + + const updateSlots = (nextSlots: GlanceableSlots) => { + onError?.(null); + onChange({ + ...currentValue, + slots: Object.fromEntries( + positions.map((position) => [position, nextSlots[position]]), + ), + }); + }; + + const toggleOption = (position: GlanceableSlotPosition, option: GlanceableOption) => { + const current = slots[position]; + const existingIndex = current.findIndex((slot) => slot.type === option.value); + const next = existingIndex === -1 + ? [...current, { type: option.value, params: option.exampleProps ?? {} }] + : current.filter((_, index) => index !== existingIndex); + + updateSlots({ ...slots, [position]: next }); + }; + + const updateParams = ( + position: GlanceableSlotPosition, + index: number, + params: Record, + ) => { + updateSlots({ + ...slots, + [position]: slots[position].map((slot, slotIndex) => + slotIndex === index ? { ...slot, params } : slot, + ), + }); + }; + + return ( +
+

+ Choose one or more glanceables for each row. Selected glanceables are shown together. +

+ {positions.map((position) => ( +
+

{position} row

+
+ {options.map((option) => { + const selected = slots[position].some((slot) => slot.type === option.value); + return ( + + ); + })} +
+ {slots[position].map((slot, index) => { + const option = options.find((item) => item.value === slot.type); + const draftKey = `${position}-${index}`; + return ( +
+

+ {option?.label ?? slot.type} properties +

+