mirror of
https://github.com/prowler-cloud/prowler.git
synced 2025-12-19 05:17:47 +00:00
feat: RSS system (#9109)
This commit is contained in:
11
.env
11
.env
@@ -10,8 +10,6 @@ NEXT_PUBLIC_API_BASE_URL=${API_BASE_URL}
|
||||
NEXT_PUBLIC_API_DOCS_URL=http://prowler-api:8080/api/v1/docs
|
||||
AUTH_TRUST_HOST=true
|
||||
UI_PORT=3000
|
||||
# Temp URL for feeds need to use actual
|
||||
RSS_FEED_URL=https://prowler.com/blog/rss
|
||||
# openssl rand -base64 32
|
||||
AUTH_SECRET="N/c6mnaS5+SWq81+819OrzQZlmx1Vxtp/orjttJSmw8="
|
||||
# Google Tag Manager ID
|
||||
@@ -126,3 +124,12 @@ LANGSMITH_TRACING=false
|
||||
LANGSMITH_ENDPOINT="https://api.smith.langchain.com"
|
||||
LANGSMITH_API_KEY=""
|
||||
LANGCHAIN_PROJECT=""
|
||||
|
||||
# RSS Feed Configuration
|
||||
# Multiple feed sources can be configured as a JSON array (must be valid JSON, no trailing commas)
|
||||
# Each source requires: id, name, type (github_releases|blog|custom), url, and enabled flag
|
||||
# IMPORTANT: Must be a single line with valid JSON (no newlines, no trailing commas)
|
||||
# Example with one source:
|
||||
RSS_FEED_SOURCES='[{"id":"prowler-releases","name":"Prowler Releases","type":"github_releases","url":"https://github.com/prowler-cloud/prowler/releases.atom","enabled":true}]'
|
||||
# Example with multiple sources (no trailing comma after last item):
|
||||
# RSS_FEED_SOURCES='[{"id":"prowler-releases","name":"Prowler Releases","type":"github_releases","url":"https://github.com/prowler-cloud/prowler/releases.atom","enabled":true},{"id":"prowler-blog","name":"Prowler Blog","type":"blog","url":"https://prowler.com/blog/rss","enabled":false}]'
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -70,7 +70,6 @@ htmlcov/
|
||||
ui/.env*
|
||||
api/.env*
|
||||
mcp_server/.env*
|
||||
.env.local
|
||||
|
||||
# Coverage
|
||||
.coverage*
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
|
||||
All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
## [1.13.1] (Unreleased)
|
||||
## [1.14.0] (Unreleased)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- RSS feeds support [(#9109)](https://github.com/prowler-cloud/prowler/pull/9109)
|
||||
|
||||
---
|
||||
|
||||
## [1.13.1]
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
|
||||
@@ -1,31 +1,151 @@
|
||||
"use server";
|
||||
|
||||
import { unstable_cache } from "next/cache";
|
||||
import Parser from "rss-parser";
|
||||
import { z } from "zod";
|
||||
|
||||
const RSS_FEED_URL = process.env.RSS_FEED_URL || "";
|
||||
import type { FeedError, FeedItem, FeedSource, ParsedFeed } from "./types";
|
||||
import { FEED_SOURCE_TYPES } from "./types";
|
||||
|
||||
export const fetchFeeds = async (): Promise<any | any[]> => {
|
||||
if (RSS_FEED_URL?.trim()?.length > 0) {
|
||||
const parser = new Parser();
|
||||
try {
|
||||
// TODO: Need to update return logic when actual URL is updated for RSS FEED
|
||||
const feed = await parser.parseURL(RSS_FEED_URL);
|
||||
return [
|
||||
{
|
||||
title: feed.title,
|
||||
description: feed.description,
|
||||
link: feed.link,
|
||||
lastBuildDate: new Date(feed.lastBuildDate).toLocaleString(),
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error fetching RSS feed:", error);
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("RSS url not set");
|
||||
const feedSourceSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
type: z.enum([
|
||||
FEED_SOURCE_TYPES.GITHUB_RELEASES,
|
||||
FEED_SOURCE_TYPES.BLOG,
|
||||
FEED_SOURCE_TYPES.CUSTOM,
|
||||
]),
|
||||
url: z.url(),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
const feedSourcesSchema = z.array(feedSourceSchema);
|
||||
|
||||
// Parse feed sources from environment variable
|
||||
function getFeedSources(): FeedSource[] {
|
||||
const feedSourcesEnv = process.env.RSS_FEED_SOURCES;
|
||||
|
||||
if (!feedSourcesEnv || feedSourcesEnv.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(feedSourcesEnv);
|
||||
const validated = feedSourcesSchema.parse(parsed);
|
||||
return validated.filter((source) => source.enabled);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Parse a single RSS/Atom feed
|
||||
async function parseSingleFeed(
|
||||
source: FeedSource,
|
||||
): Promise<{ items: FeedItem[]; error?: FeedError }> {
|
||||
const parser = new Parser({
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
"User-Agent": "Prowler-UI/1.0",
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const feed = await parser.parseURL(source.url);
|
||||
|
||||
// Map RSS items to our FeedItem type
|
||||
const items: FeedItem[] = (feed.items || []).map((item) => {
|
||||
// Validate and parse date with fallback to current date
|
||||
const parsePubDate = (): string => {
|
||||
const dateString = item.isoDate || item.pubDate;
|
||||
if (!dateString) return new Date().toISOString();
|
||||
|
||||
const parsed = new Date(dateString);
|
||||
return isNaN(parsed.getTime())
|
||||
? new Date().toISOString()
|
||||
: parsed.toISOString();
|
||||
};
|
||||
|
||||
return {
|
||||
id: item.guid || item.link || `${source.id}-${item.title}`,
|
||||
title: item.title || "Untitled",
|
||||
description:
|
||||
item.contentSnippet || item.content || item.description || "",
|
||||
link: item.link || "",
|
||||
pubDate: parsePubDate(),
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
sourceType: source.type,
|
||||
author: item.creator || item.author,
|
||||
categories: item.categories || [],
|
||||
contentSnippet: item.contentSnippet || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return { items };
|
||||
} catch (error) {
|
||||
return {
|
||||
items: [],
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch and parse all enabled feeds
|
||||
async function fetchAllFeeds(): Promise<ParsedFeed> {
|
||||
const sources = getFeedSources();
|
||||
|
||||
if (sources.length === 0) {
|
||||
return {
|
||||
items: [],
|
||||
totalCount: 0,
|
||||
sources: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch all feeds in parallel
|
||||
const results = await Promise.all(
|
||||
sources.map((source) => parseSingleFeed(source)),
|
||||
);
|
||||
|
||||
// Combine all items from all sources (errors are handled gracefully by returning empty items)
|
||||
const allItems: FeedItem[] = results.flatMap((result) => result.items);
|
||||
|
||||
// Sort by publication date (newest first)
|
||||
allItems.sort(
|
||||
(a, b) => new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime(),
|
||||
);
|
||||
|
||||
return {
|
||||
items: allItems,
|
||||
totalCount: allItems.length,
|
||||
sources,
|
||||
};
|
||||
}
|
||||
|
||||
// Cached version of fetchAllFeeds with 5-minute revalidation
|
||||
const getCachedFeeds = unstable_cache(
|
||||
async () => fetchAllFeeds(),
|
||||
["rss-feeds"],
|
||||
{
|
||||
revalidate: 300, // 5 minutes
|
||||
tags: ["feeds"],
|
||||
},
|
||||
);
|
||||
|
||||
// Public API: Fetch feeds with optional limit
|
||||
export async function fetchFeeds(limit?: number): Promise<ParsedFeed> {
|
||||
const allFeeds = await getCachedFeeds();
|
||||
|
||||
if (limit && limit > 0) {
|
||||
return {
|
||||
...allFeeds,
|
||||
items: allFeeds.items.slice(0, limit),
|
||||
};
|
||||
}
|
||||
|
||||
return allFeeds;
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./feeds";
|
||||
export * from "./types";
|
||||
|
||||
44
ui/actions/feeds/types.ts
Normal file
44
ui/actions/feeds/types.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Feed type definitions using const-based pattern
|
||||
|
||||
export const FEED_SOURCE_TYPES = {
|
||||
GITHUB_RELEASES: "github_releases",
|
||||
BLOG: "blog",
|
||||
CUSTOM: "custom",
|
||||
} as const;
|
||||
|
||||
export type FeedSourceType =
|
||||
(typeof FEED_SOURCE_TYPES)[keyof typeof FEED_SOURCE_TYPES];
|
||||
|
||||
export interface FeedSource {
|
||||
id: string;
|
||||
name: string;
|
||||
type: FeedSourceType;
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface FeedItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
link: string;
|
||||
pubDate: string; // ISO 8601 format
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
sourceType: FeedSourceType;
|
||||
author?: string;
|
||||
categories?: string[];
|
||||
contentSnippet?: string;
|
||||
}
|
||||
|
||||
export interface ParsedFeed {
|
||||
items: FeedItem[];
|
||||
totalCount: number;
|
||||
sources: FeedSource[];
|
||||
}
|
||||
|
||||
export interface FeedError {
|
||||
message: string;
|
||||
sourceId?: string;
|
||||
sourceName?: string;
|
||||
}
|
||||
194
ui/components/feeds/feeds-client.tsx
Normal file
194
ui/components/feeds/feeds-client.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import { formatDistanceToNow, parseISO } from "date-fns";
|
||||
import { BellRing, ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { FeedItem, ParsedFeed } from "@/actions/feeds";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
Separator,
|
||||
} from "@/components/shadcn";
|
||||
import { hasNewFeeds, markFeedsAsSeen } from "@/lib/feeds-storage";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FeedsClientProps {
|
||||
feedData: ParsedFeed;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function FeedsClient({ feedData, error }: FeedsClientProps) {
|
||||
const { items, totalCount } = feedData;
|
||||
const hasFeeds = totalCount > 0 && !error;
|
||||
|
||||
// State to track if there are new unseen feeds
|
||||
const [hasUnseenFeeds, setHasUnseenFeeds] = useState(false);
|
||||
|
||||
// Check for new feeds on mount
|
||||
useEffect(() => {
|
||||
if (hasFeeds) {
|
||||
const currentFeedIds = items.map((item) => item.id);
|
||||
const isNew = hasNewFeeds(currentFeedIds);
|
||||
setHasUnseenFeeds(isNew);
|
||||
}
|
||||
}, [hasFeeds, items]);
|
||||
|
||||
// Mark feeds as seen when dropdown opens
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (open && hasFeeds) {
|
||||
const currentFeedIds = items.map((item) => item.id);
|
||||
markFeedsAsSeen(currentFeedIds);
|
||||
setHasUnseenFeeds(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="relative h-8 w-8 rounded-full bg-transparent p-2"
|
||||
aria-label={
|
||||
hasUnseenFeeds
|
||||
? "New updates available - Click to view"
|
||||
: "Check for updates"
|
||||
}
|
||||
>
|
||||
<BellRing
|
||||
size={18}
|
||||
className={cn(
|
||||
hasFeeds && hasUnseenFeeds && "text-prowler-green animate-pulse",
|
||||
)}
|
||||
/>
|
||||
{hasFeeds && hasUnseenFeeds && (
|
||||
<span className="absolute top-0 right-0 flex h-2 w-2">
|
||||
<span className="bg-prowler-green absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"></span>
|
||||
<span className="bg-prowler-green relative inline-flex h-2 w-2 rounded-full"></span>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-96 gap-2 overflow-x-hidden border-slate-200 bg-white px-[18px] pt-3 pb-4 dark:border-zinc-900 dark:bg-stone-950"
|
||||
>
|
||||
<div className="pb-2">
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">
|
||||
Latest Updates
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Recent releases and announcements
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="minimal-scrollbar max-h-[500px] overflow-x-hidden overflow-y-auto">
|
||||
{error && (
|
||||
<div className="px-3 py-8 text-center">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && items.length === 0 && (
|
||||
<div className="px-3 py-8 text-center">
|
||||
<BellRing className="mx-auto mb-2 h-8 w-8 text-slate-400" />
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-300">
|
||||
No updates available
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
Check back later for new releases
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasFeeds && (
|
||||
<div className="relative py-2">
|
||||
{items.map((item, index) => (
|
||||
<FeedTimelineItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
isLast={index === items.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
interface FeedTimelineItemProps {
|
||||
item: FeedItem;
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
function FeedTimelineItem({ item, isLast }: FeedTimelineItemProps) {
|
||||
const relativeTime = formatDistanceToNow(parseISO(item.pubDate), {
|
||||
addSuffix: true,
|
||||
});
|
||||
|
||||
// Extract version from title if it's a GitHub release
|
||||
const versionMatch = item.title.match(/v?(\d+\.\d+\.\d+)/);
|
||||
const version = versionMatch ? versionMatch[1] : null;
|
||||
|
||||
return (
|
||||
<div className="group relative flex gap-3 px-3 py-2">
|
||||
{/* Timeline dot */}
|
||||
<div className="relative flex flex-col items-center">
|
||||
<div className="border-prowler-green bg-prowler-green z-10 h-2 w-2 rounded-full border-2" />
|
||||
{!isLast && (
|
||||
<div className="h-full w-px bg-slate-200 dark:bg-slate-700" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1 pb-4">
|
||||
<Link
|
||||
href={item.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="backdrop-blur-0 block space-y-1 rounded-[12px] border border-transparent p-2 transition-all hover:border-slate-300 hover:bg-[#F8FAFC80] hover:backdrop-blur-[46px] dark:hover:border-[rgba(38,38,38,0.70)] dark:hover:bg-[rgba(23,23,23,0.50)]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4 className="group-hover:text-prowler-green dark:group-hover:text-prowler-green min-w-0 flex-1 text-sm leading-tight font-semibold break-words text-slate-900 dark:text-white">
|
||||
{item.title}
|
||||
</h4>
|
||||
{version && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="border-prowler-green bg-prowler-green/10 text-prowler-green dark:bg-prowler-green/20 shrink-0 text-[10px] font-semibold"
|
||||
>
|
||||
v{version}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.description && (
|
||||
<p className="line-clamp-2 text-xs leading-relaxed break-words text-slate-600 dark:text-slate-400">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<time className="text-[11px] text-slate-500 dark:text-slate-500">
|
||||
{relativeTime}
|
||||
</time>
|
||||
|
||||
<div className="text-prowler-green flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<span className="text-[11px] font-medium">Read more</span>
|
||||
<ExternalLink size={10} />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { fetchFeeds } from "@/actions/feeds";
|
||||
import { BellIcon as Icon } from "@/components/icons";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Button } from "../ui/button/button";
|
||||
|
||||
interface Feed {
|
||||
title: string;
|
||||
link: string;
|
||||
description: string;
|
||||
lastBuildDate: string;
|
||||
}
|
||||
|
||||
export const FeedsDetail = () => {
|
||||
// TODO: Need to update with actual interface when actual RSS data finialized
|
||||
const [feed, setFeed] = useState<Feed[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFeedDetails = async () => {
|
||||
const feeds = await fetchFeeds();
|
||||
setFeed(feeds);
|
||||
};
|
||||
|
||||
fetchFeedDetails();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"relative",
|
||||
"rounded-full",
|
||||
"bg-transparent",
|
||||
"p-2",
|
||||
"h-8",
|
||||
"w-8",
|
||||
)}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{/* TODO: Update this condition once the RSS data response structure is finalized */}
|
||||
{feed.length > 0 && (
|
||||
<span className="absolute top-0 right-0 h-2 w-2 rounded-full bg-red-500 dark:bg-gray-400"></span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" forceMount>
|
||||
<h3 className="px-2 text-base font-medium">Feeds</h3>
|
||||
<div className="max-h-48 w-80 overflow-y-auto">
|
||||
{feed.length === 0 ? (
|
||||
<p className="py-4 text-center text-gray-500">
|
||||
No feeds available
|
||||
</p>
|
||||
) : (
|
||||
feed.map((item, index) => (
|
||||
<DropdownMenuItem key={index} className="hover:cursor-pointer">
|
||||
<Link
|
||||
href={item.link}
|
||||
target="_blank"
|
||||
className="flex flex-col"
|
||||
>
|
||||
<h3 className="text-small leading-none font-medium">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">{item.description}</p>
|
||||
<span className="text-muted-foreground mt-1 text-xs text-gray-400">
|
||||
{item.lastBuildDate}
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
12
ui/components/feeds/feeds-server.tsx
Normal file
12
ui/components/feeds/feeds-server.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { fetchFeeds } from "@/actions/feeds";
|
||||
|
||||
import { FeedsClient } from "./feeds-client";
|
||||
|
||||
interface FeedsServerProps {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export async function FeedsServer({ limit = 15 }: FeedsServerProps) {
|
||||
const feedData = await fetchFeeds(limit);
|
||||
return <FeedsClient feedData={feedData} />;
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./feeds-detail";
|
||||
export * from "./feeds-client";
|
||||
export * from "./feeds-server";
|
||||
|
||||
46
ui/components/shadcn/badge/badge.tsx
Normal file
46
ui/components/shadcn/badge/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { ComponentProps } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
60
ui/components/shadcn/button/button.tsx
Normal file
60
ui/components/shadcn/button/button.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { ComponentProps } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
254
ui/components/shadcn/dropdown/dropdown.tsx
Normal file
254
ui/components/shadcn/dropdown/dropdown.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
import { ComponentProps } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({ className, ...props }: ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from "./badge/badge";
|
||||
export * from "./button/button";
|
||||
export * from "./card/base-card/base-card";
|
||||
export * from "./card/card";
|
||||
export * from "./card/resource-stats-card/resource-stats-card";
|
||||
@@ -5,6 +7,8 @@ export * from "./card/resource-stats-card/resource-stats-card-container";
|
||||
export * from "./card/resource-stats-card/resource-stats-card-content";
|
||||
export * from "./card/resource-stats-card/resource-stats-card-divider";
|
||||
export * from "./card/resource-stats-card/resource-stats-card-header";
|
||||
export * from "./dropdown/dropdown";
|
||||
export * from "./select/select";
|
||||
export * from "./separator/separator";
|
||||
export * from "./tabs/generic-tabs";
|
||||
export * from "./tabs/tabs";
|
||||
|
||||
28
ui/components/shadcn/separator/separator.tsx
Normal file
28
ui/components/shadcn/separator/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import { ComponentProps } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { Navbar } from "../nav-bar/navbar";
|
||||
|
||||
52
ui/components/ui/nav-bar/navbar-client.tsx
Normal file
52
ui/components/ui/nav-bar/navbar-client.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { BellRing } from "lucide-react";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { ThemeSwitch } from "@/components/ThemeSwitch";
|
||||
import { BreadcrumbNavigation } from "@/components/ui";
|
||||
|
||||
import { SheetMenu } from "../sidebar/sheet-menu";
|
||||
import { UserNav } from "../user-nav/user-nav";
|
||||
|
||||
interface NavbarClientProps {
|
||||
title: string;
|
||||
icon: string | ReactNode;
|
||||
feedsSlot?: ReactNode;
|
||||
}
|
||||
|
||||
export function NavbarClient({ title, icon, feedsSlot }: NavbarClientProps) {
|
||||
return (
|
||||
<header className="bg-background/95 supports-[backdrop-filter]:bg-background/60 dark:shadow-primary sticky top-0 z-10 w-full shadow backdrop-blur">
|
||||
<div className="mx-4 flex h-14 items-center sm:mx-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<SheetMenu />
|
||||
<BreadcrumbNavigation
|
||||
mode="auto"
|
||||
title={title}
|
||||
icon={icon}
|
||||
paramToPreserve="scanId"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-end gap-3">
|
||||
<ThemeSwitch />
|
||||
{feedsSlot}
|
||||
<UserNav />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeedsLoadingFallback() {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="relative h-8 w-8 rounded-full bg-transparent p-2"
|
||||
disabled
|
||||
>
|
||||
<BellRing size={18} className="animate-pulse text-slate-400" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
"use client";
|
||||
import { ReactNode, Suspense } from "react";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { FeedsServer } from "@/components/feeds";
|
||||
|
||||
import { ThemeSwitch } from "@/components/ThemeSwitch";
|
||||
import { BreadcrumbNavigation } from "@/components/ui";
|
||||
|
||||
import { SheetMenu } from "../sidebar/sheet-menu";
|
||||
import { UserNav } from "../user-nav/user-nav";
|
||||
import { FeedsLoadingFallback, NavbarClient } from "./navbar-client";
|
||||
|
||||
interface NavbarProps {
|
||||
title: string;
|
||||
@@ -15,24 +11,14 @@ interface NavbarProps {
|
||||
|
||||
export function Navbar({ title, icon }: NavbarProps) {
|
||||
return (
|
||||
<header className="bg-background/95 supports-[backdrop-filter]:bg-background/60 dark:shadow-primary sticky top-0 z-10 w-full shadow backdrop-blur">
|
||||
<div className="mx-4 flex h-14 items-center sm:mx-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<SheetMenu />
|
||||
<BreadcrumbNavigation
|
||||
mode="auto"
|
||||
title={title}
|
||||
icon={icon}
|
||||
paramToPreserve="scanId"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-end gap-3">
|
||||
{/* TODO: Uncomment when this feature is enabled and ready for release */}
|
||||
{/* {process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true" && <FeedsDetail />} */}
|
||||
<ThemeSwitch />
|
||||
<UserNav />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<NavbarClient
|
||||
title={title}
|
||||
icon={icon}
|
||||
feedsSlot={
|
||||
<Suspense fallback={<FeedsLoadingFallback />}>
|
||||
<FeedsServer limit={15} />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,6 +119,14 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "@radix-ui/react-separator",
|
||||
"from": "1.1.7",
|
||||
"to": "1.1.7",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-30T10:22:21.335Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "@radix-ui/react-slot",
|
||||
@@ -471,6 +479,14 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "@types/geojson",
|
||||
"from": "7946.0.16",
|
||||
"to": "7946.0.16",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-30T10:22:21.335Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "@types/node",
|
||||
@@ -503,6 +519,14 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "@types/topojson-specification",
|
||||
"from": "1.0.5",
|
||||
"to": "1.0.5",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-30T10:22:21.335Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "@types/uuid",
|
||||
|
||||
57
ui/lib/feeds-storage.ts
Normal file
57
ui/lib/feeds-storage.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// Utility functions for managing feed state in localStorage
|
||||
|
||||
const STORAGE_KEY = "prowler-feeds-last-seen";
|
||||
|
||||
interface FeedStorage {
|
||||
lastSeenIds: string[];
|
||||
lastCheckTimestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last seen feed IDs from localStorage
|
||||
*/
|
||||
export function getLastSeenFeedIds(): string[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return [];
|
||||
|
||||
const data: FeedStorage = JSON.parse(stored);
|
||||
return data.lastSeenIds || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current feed IDs as seen
|
||||
*/
|
||||
export function markFeedsAsSeen(feedIds: string[]): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
const data: FeedStorage = {
|
||||
lastSeenIds: feedIds,
|
||||
lastCheckTimestamp: Date.now(),
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
} catch {
|
||||
// Silently fail if localStorage is unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are new feeds (feeds that haven't been seen before)
|
||||
*/
|
||||
export function hasNewFeeds(currentFeedIds: string[]): boolean {
|
||||
const lastSeenIds = getLastSeenFeedIds();
|
||||
|
||||
// If no feeds stored, everything is new
|
||||
if (lastSeenIds.length === 0 && currentFeedIds.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if there are any current feeds not in the last seen list
|
||||
return currentFeedIds.some((id) => !lastSeenIds.includes(id));
|
||||
}
|
||||
1
ui/package-lock.json
generated
1
ui/package-lock.json
generated
@@ -24,6 +24,7 @@
|
||||
"@radix-ui/react-icons": "1.3.2",
|
||||
"@radix-ui/react-label": "2.1.7",
|
||||
"@radix-ui/react-select": "2.2.5",
|
||||
"@radix-ui/react-separator": "1.1.7",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-toast": "1.2.14",
|
||||
"@react-aria/ssr": "3.9.4",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"@radix-ui/react-icons": "1.3.2",
|
||||
"@radix-ui/react-label": "2.1.7",
|
||||
"@radix-ui/react-select": "2.2.5",
|
||||
"@radix-ui/react-separator": "1.1.7",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-toast": "1.2.14",
|
||||
"@react-aria/ssr": "3.9.4",
|
||||
|
||||
@@ -291,6 +291,52 @@
|
||||
display: none; /* Chrome, Safari, Opera */
|
||||
}
|
||||
|
||||
/* Minimal scrollbar styles */
|
||||
.minimal-scrollbar {
|
||||
scrollbar-width: thin; /* Firefox */
|
||||
scrollbar-color: rgb(203 213 225 / 0.5) transparent; /* thumb and track for Firefox */
|
||||
}
|
||||
|
||||
.minimal-scrollbar:hover {
|
||||
scrollbar-color: rgb(148 163 184 / 0.7) transparent; /* darker thumb on hover */
|
||||
}
|
||||
|
||||
/* Webkit browsers (Chrome, Safari, Edge) */
|
||||
.minimal-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.minimal-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.minimal-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(203 213 225 / 0.5);
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.minimal-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgb(148 163 184 / 0.7);
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
.dark .minimal-scrollbar {
|
||||
scrollbar-color: rgb(71 85 105 / 0.5) transparent;
|
||||
}
|
||||
|
||||
.dark .minimal-scrollbar:hover {
|
||||
scrollbar-color: rgb(100 116 139 / 0.7) transparent;
|
||||
}
|
||||
|
||||
.dark .minimal-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(71 85 105 / 0.5);
|
||||
}
|
||||
|
||||
.dark .minimal-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgb(100 116 139 / 0.7);
|
||||
}
|
||||
|
||||
.checkbox-update {
|
||||
margin-right: 0.5rem;
|
||||
background-color: var(--background);
|
||||
|
||||
Reference in New Issue
Block a user