diff --git a/README.md b/README.md index b9263650..650a4128 100755 --- a/README.md +++ b/README.md @@ -12,17 +12,14 @@ This is my attempt to solving that. ## Features -- GUI Editing: Edit and manage links, search engines, your wallpapers in settings via the GUI instead of relying purely on a config. -- Built-in Authentication: dashwise has Auth built right into it - and even features -- SSO via OIDC: While tested with PocketId, it should work via OIDC - which can be configured in Pocketbase directly. -- Links: store your most important links for quick access and group them into Link Groups and Folders coming. Links can also be monitored with downtime being logged. -- Glanceables: Customizable bits of one-line information next to the clock. -- Widgets: Modular blocks on the dashboard that show key info or actions at a glance. They can be moved and customized individually. -- News: Subscribe to RSS feeds to stay on top of everything. -- Notfications: Dashwise can receive notifications via PUSH requests to /api/v1/notifications/TOPIC-NAME -- Wallpapers: Upload them to dashwise directly, or even change the default one for new users by mounting one into the container -- Spotlight-like Search: Hit Ctrl+K from your dashboard, and you'll be able to search your links and integrations or use bangs for search engines specified in settings. -- Integrations: directly integrates with your favourite self hosted apps. Supported services are Karakeep, Dashdot, Beszel and Jellyfin. More integrations are planned +- **Principles**: Configure dashwise using its UI. No need to touch config files. Dashwise also has Authentication (including SSO) built in. +- **Dashboards** show Glanceables and Widgets, for example Links (which can be grouped into Link groups and folders) +- **Glanceables**: Customizable bits of one-line information next to the clock. +- **Widgets**: Modular blocks on the dashboard that show key info or actions at a glance. They can be moved and customized individually. +- **News**: Subscribe to RSS feeds to stay on top of everything. +- **Notfications** : Dashwise can receive notifications via PUSH requests to /api/v1/notifications/TOPIC-NAME +- **Spotlight-like Search**: Hit Ctrl+K from your dashboard, and you'll be able to search your links and integrations or use bangs for search engines specified in settings. +- **Integrations**: directly integrates with your favourite self hosted apps. Supported services are Karakeep, Dashdot, Beszel and Jellyfin. More integrations are planned ## Installation For production depolyments, use the docker-compose.yaml (image is currently only built for arm, will change later). diff --git a/apps/web/src/components/settings/SmartFramesManager.tsx b/apps/web/src/components/settings/SmartFramesManager.tsx index 721e1dce..758b0dd9 100644 --- a/apps/web/src/components/settings/SmartFramesManager.tsx +++ b/apps/web/src/components/settings/SmartFramesManager.tsx @@ -41,6 +41,7 @@ const LOCAL_WIDGET_OPTIONS = [ { value: "progress", label: "Calendar progress" }, "countdown", "rss-feed", + "latest-links", "monitoring", ] as const; @@ -68,6 +69,7 @@ const LOCAL_WIDGET_SCHEMAS: Record> = { "calendar-upcoming": { maxEvents: 5 }, countdown: { date: "", date_format: "yyyy-MM-dd", label: "Countdown" }, "rss-feed": { feedId: "all", maxItems: 8, title: "Latest Articles" }, + "latest-links": { listId: "", maxItems: 8, title: "Latest Links" }, progress: { period: { type: "select", diff --git a/apps/web/src/components/settings/pages/EditGlanceablesView.tsx b/apps/web/src/components/settings/pages/EditGlanceablesView.tsx index 058552f3..1c6f32da 100644 --- a/apps/web/src/components/settings/pages/EditGlanceablesView.tsx +++ b/apps/web/src/components/settings/pages/EditGlanceablesView.tsx @@ -12,6 +12,7 @@ import { } from "@/components/ui/select"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; import { GlanceableSide } from "./utils"; import { useLocalization } from "@/context/LocalizationContext"; import useAuth from "@/context/useAuth"; diff --git a/apps/web/src/components/widgets/Widget.tsx b/apps/web/src/components/widgets/Widget.tsx index eaf37522..a7f9253e 100644 --- a/apps/web/src/components/widgets/Widget.tsx +++ b/apps/web/src/components/widgets/Widget.tsx @@ -17,6 +17,7 @@ import { } from "@dashwise/integrationskit/static-widgets/CalendarWidgets"; import CountdownWidget from "@dashwise/integrationskit/static-widgets/CountdownWidget"; import RssFeedWidget from "@dashwise/integrationskit/static-widgets/RssFeedWidget"; +import VerticalList from "@dashwise/integrationskit/templates/VerticalList"; import LinkView from "./LinkView"; import SearchBar from "./SearchBar"; import IframeTemplate from "@dashwise/integrationskit/templates/IFrame"; @@ -27,6 +28,8 @@ import useAuth from "@/context/useAuth"; import { getConsumerDataAction, getIntegrationCalendarEventsAction, + getLinksCollectionsAction, + getLinksItemsAction, getNewsFeedAction, } from '@/lib/apiClient'; @@ -91,6 +94,9 @@ export function renderWidget({ case "latest-rss-feed": return ; + case "latest-links": + return ; + case "countdown": return ; @@ -123,6 +129,136 @@ export function renderWidget({ } } +type LatestLinkItem = { + id: string; + url?: string; + title?: string; + iconUrl?: string; + description?: string; + collection?: string; + created?: string; + updated?: string; +}; + +function LatestLinksWidgetWrapper({ + className, + listId, + collectionId, + maxItems = 8, + title = "Latest Links", +}: { + className?: string; + listId?: string; + collectionId?: string; + maxItems?: number; + title?: string; +}) { + const { withAuth } = useAuth(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + const selectedListId = String(listId || collectionId || "").trim(); + + setLoading(true); + + const fetchItems = async () => { + const links = await withAuth(async (auth) => { + if (selectedListId) { + const listItems = await getLinksItemsAction(auth, selectedListId); + return Array.isArray(listItems) ? listItems : []; + } + + const collections = await getLinksCollectionsAction(auth); + const listIds = Array.isArray(collections) + ? collections.map((collection: any) => String(collection?.id ?? "").trim()).filter(Boolean) + : []; + const groupedItems = await Promise.all( + listIds.map(async (id) => { + try { + const listItems = await getLinksItemsAction(auth, id); + return Array.isArray(listItems) ? listItems : []; + } catch { + return []; + } + }), + ); + + return groupedItems.flat(); + }); + + return Array.isArray(links) ? links : []; + }; + + void fetchItems() + .then((links) => { + if (!cancelled) { + setItems(sortLatestLinks(links).slice(0, maxItems)); + } + }) + .catch((err) => { + console.error("Failed to fetch latest links", err); + if (!cancelled) setItems([]); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [collectionId, listId, maxItems, withAuth]); + + if (loading) { + return ( +
+
+ Loading... +
+
+ ); + } + + return ( + ({ + title: item.title || "Untitled", + titleAction: item.url || undefined, + subtitle: [item.description || item.url, formatLatestLinkDate(item.created)].filter(Boolean) as string[], + thumbnail: item.iconUrl || undefined, + icon: "fa6-solid:link", + })), + raw: {}, + }} + /> + ); +} + +function sortLatestLinks(items: LatestLinkItem[]) { + return [...items].sort((left, right) => { + const leftTime = new Date(left.created || left.updated || 0).getTime(); + const rightTime = new Date(right.created || right.updated || 0).getTime(); + return (Number.isNaN(rightTime) ? 0 : rightTime) - (Number.isNaN(leftTime) ? 0 : leftTime); + }); +} + +function formatLatestLinkDate(value?: string) { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return date.toLocaleDateString([], { month: "short", day: "numeric" }); +} + function resolveProgressPeriod(type: string, params?: Record) { const candidate = String(params?.period ?? type ?? "day").trim(); if (candidate === "year" || candidate === "month" || candidate === "day" || candidate === "week") { diff --git a/apps/web/src/components/widgets/dashboard/GlanceableClock.tsx b/apps/web/src/components/widgets/dashboard/GlanceableClock.tsx index 3f007c97..e5d28dbc 100644 --- a/apps/web/src/components/widgets/dashboard/GlanceableClock.tsx +++ b/apps/web/src/components/widgets/dashboard/GlanceableClock.tsx @@ -228,6 +228,7 @@ function ResolvedGlanceable({ return ( - {resolved.icon && ( - - )} - {renderLocalizedText(resolved.text, formatters)} - - ); - } - - if (!glanceableJSON && type) { - return ; - } - const safeGlanceableJSON = useMemo( () => mergeGlanceableJSON(glanceableJSON ?? {}, params), [glanceableJSON, params], @@ -136,6 +126,56 @@ export default function Glanceable({ }; }, [integrationJSON, isPreview, resolvedRuntimeData, safeGlanceableJSON]); + + const multiColumnItems = Array.isArray(safeGlanceableJSON.columns) + ? safeGlanceableJSON.columns + .filter((entry): entry is MultiColumnGlanceableItem => !!entry && typeof entry === "object") + .map((entry) => { + const rawValue = typeof entry.value === "string" + ? resolveGlanceableText(entry.value, env, formatters) + : typeof entry.text === "string" + ? resolveGlanceableText(entry.text, env, formatters) + : ""; + + return { + icon: getGlanceableIconSource(entry.icon, env), + text: entry.format === "time" ? formatGlanceableTime(rawValue, formatters) : rawValue, + }; + }) + : null; + + if (multiColumnItems && multiColumnItems.length > 0) { + return ( + + {multiColumnItems.map((item, index) => ( + + {item.icon && ( + + )} + {item.text} + + ))} + + ); + } + + if (resolved) { + return ( + + {resolved.icon && ( + + )} + {renderLocalizedText(resolved.text, formatters)} + + ); + } + + if (!glanceableJSON && type) { + return ; + } + let text = ""; // support structured text operations (eg. stringadd) as well as plain strings @@ -394,6 +434,15 @@ function formatWeather(params?: Record, formatters?: TextFormatters return `${emoji} ${temperatureText}${location}`.trim(); } +function formatGlanceableTime(value: string, formatters?: TextFormatters) { + if (!value.trim()) return ""; + + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + + return formatTime(date, undefined, formatters); +} + function getWeatherEmoji(weatherCode: unknown, description: unknown) { const code = typeof weatherCode === "number" ? weatherCode : Number(weatherCode); if (Number.isFinite(code)) {