minor visual tweaks to member landing

This commit is contained in:
miloschwartz 2025-07-28 12:21:15 -07:00
parent bda2aa46b6
commit 67bae76048
No known key found for this signature in database
3 changed files with 275 additions and 281 deletions

View file

@ -4,21 +4,42 @@ import { useState, useEffect } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ExternalLink, Globe, ShieldCheck, Search, RefreshCw, AlertCircle, Plus, Shield, ShieldOff, ChevronLeft, ChevronRight, Building2, Key, KeyRound, Fingerprint, AtSign, Copy, InfoIcon } from "lucide-react";
import { import {
Tooltip, Select,
TooltipContent, SelectContent,
TooltipProvider, SelectItem,
TooltipTrigger, SelectTrigger,
} from "@/components/ui/tooltip"; SelectValue
} from "@/components/ui/select";
import {
ExternalLink,
Globe,
Search,
RefreshCw,
AlertCircle,
ChevronLeft,
ChevronRight,
Key,
KeyRound,
Fingerprint,
AtSign,
Copy,
InfoIcon,
Combine
} from "lucide-react";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { GetUserResourcesResponse } from "@server/routers/resource/getUserResources"; import { GetUserResourcesResponse } from "@server/routers/resource/getUserResources";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { useToast } from "@app/hooks/useToast"; import { useToast } from "@app/hooks/useToast";
import { InfoPopup } from "@/components/ui/info-popup";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@/components/ui/tooltip";
// Update Resource type to include site information // Update Resource type to include site information
type Resource = { type Resource = {
@ -42,26 +63,34 @@ type MemberResourcesPortalProps = {
}; };
// Favicon component with fallback // Favicon component with fallback
const ResourceFavicon = ({ domain, enabled }: { domain: string; enabled: boolean }) => { const ResourceFavicon = ({
domain,
enabled
}: {
domain: string;
enabled: boolean;
}) => {
const [faviconError, setFaviconError] = useState(false); const [faviconError, setFaviconError] = useState(false);
const [faviconLoaded, setFaviconLoaded] = useState(false); const [faviconLoaded, setFaviconLoaded] = useState(false);
// Extract domain for favicon URL // Extract domain for favicon URL
const cleanDomain = domain.replace(/^https?:\/\//, '').split('/')[0]; const cleanDomain = domain.replace(/^https?:\/\//, "").split("/")[0];
const faviconUrl = `https://www.google.com/s2/favicons?domain=${cleanDomain}&sz=32`; const faviconUrl = `https://www.google.com/s2/favicons?domain=${cleanDomain}&sz=32`;
const handleFaviconLoad = () => { const handleFaviconLoad = () => {
setFaviconLoaded(true); setFaviconLoaded(true);
setFaviconError(false); setFaviconError(false);
}; };
const handleFaviconError = () => { const handleFaviconError = () => {
setFaviconError(true); setFaviconError(true);
setFaviconLoaded(false); setFaviconLoaded(false);
}; };
if (faviconError || !enabled) { if (faviconError || !enabled) {
return <Globe className="h-4 w-4 text-muted-foreground flex-shrink-0" />; return (
<Globe className="h-4 w-4 text-muted-foreground flex-shrink-0" />
);
} }
return ( return (
@ -72,7 +101,7 @@ const ResourceFavicon = ({ domain, enabled }: { domain: string; enabled: boolean
<img <img
src={faviconUrl} src={faviconUrl}
alt={`${cleanDomain} favicon`} alt={`${cleanDomain} favicon`}
className={`h-4 w-4 rounded-sm transition-opacity ${faviconLoaded ? 'opacity-100' : 'opacity-0'}`} className={`h-4 w-4 rounded-sm transition-opacity ${faviconLoaded ? "opacity-100" : "opacity-0"}`}
onLoad={handleFaviconLoad} onLoad={handleFaviconLoad}
onError={handleFaviconError} onError={handleFaviconError}
/> />
@ -80,198 +109,107 @@ const ResourceFavicon = ({ domain, enabled }: { domain: string; enabled: boolean
); );
}; };
// Enhanced status badge component
const StatusBadge = ({ enabled, protected: isProtected, resource }: { enabled: boolean; protected: boolean; resource: Resource }) => {
if (!enabled) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="h-7 w-7 rounded-full flex items-center justify-center bg-gray-100 dark:bg-gray-800">
<div className="h-2 w-2 bg-gray-400 dark:bg-gray-500 rounded-full"></div>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Resource Disabled</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
if (isProtected) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="h-7 w-7 rounded-full flex items-center justify-center bg-green-50 dark:bg-green-950">
<ShieldCheck className="h-3.5 w-3.5 text-green-700 dark:text-green-300" />
</div>
</TooltipTrigger>
<TooltipContent className="flex flex-col gap-2">
<p className="font-medium">Protected Resource</p>
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Authentication Methods:</p>
<div className="flex flex-col gap-1.5">
{resource.sso && (
<div className="flex items-center gap-1.5 text-sm">
<div className="h-6 w-6 rounded-full flex items-center justify-center bg-blue-50 dark:bg-blue-950">
<Key className="h-3 w-3 text-blue-700 dark:text-blue-300" />
</div>
<span>Single Sign-On (SSO)</span>
</div>
)}
{resource.password && (
<div className="flex items-center gap-1.5 text-sm">
<div className="h-6 w-6 rounded-full flex items-center justify-center bg-purple-50 dark:bg-purple-950">
<KeyRound className="h-3 w-3 text-purple-700 dark:text-purple-300" />
</div>
<span>Password Protected</span>
</div>
)}
{resource.pincode && (
<div className="flex items-center gap-1.5 text-sm">
<div className="h-6 w-6 rounded-full flex items-center justify-center bg-emerald-50 dark:bg-emerald-950">
<Fingerprint className="h-3 w-3 text-emerald-700 dark:text-emerald-300" />
</div>
<span>PIN Code</span>
</div>
)}
{resource.whitelist && (
<div className="flex items-center gap-1.5 text-sm">
<div className="h-6 w-6 rounded-full flex items-center justify-center bg-amber-50 dark:bg-amber-950">
<AtSign className="h-3 w-3 text-amber-700 dark:text-amber-300" />
</div>
<span>Email Whitelist</span>
</div>
)}
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return (
<div className="h-7 w-7 rounded-full flex items-center justify-center bg-orange-50 dark:bg-orange-950">
<ShieldOff className="h-3.5 w-3.5 text-orange-700 dark:text-orange-300" />
</div>
);
};
// Resource Info component // Resource Info component
const ResourceInfo = ({ resource }: { resource: Resource }) => { const ResourceInfo = ({ resource }: { resource: Resource }) => {
const hasAuthMethods = resource.sso || resource.password || resource.pincode || resource.whitelist; const hasAuthMethods =
resource.sso ||
return ( resource.password ||
<TooltipProvider> resource.pincode ||
<Tooltip> resource.whitelist;
<TooltipTrigger>
<div className="h-7 w-7 rounded-full flex items-center justify-center bg-muted hover:bg-muted/80 transition-colors"> const infoContent = (
<InfoIcon className="h-4 w-4 text-muted-foreground" /> <div className="flex flex-col gap-3">
{/* Site Information */}
{resource.siteName && (
<div>
<div className="text-xs font-medium mb-1.5">Site</div>
<div className="flex items-center gap-2">
<Combine className="h-4 w-4 text-foreground shrink-0" />
<span className="text-sm">{resource.siteName}</span>
</div> </div>
</TooltipTrigger> </div>
<TooltipContent className="flex flex-col gap-3 w-[280px] p-3 bg-card border-2"> )}
{/* Site Information */}
{resource.siteName && (
<div>
<div className="text-xs font-medium mb-1.5">Site</div>
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-foreground shrink-0" />
<span className="text-sm">{resource.siteName}</span>
</div>
</div>
)}
{/* Authentication Methods */} {/* Authentication Methods */}
{hasAuthMethods && ( {hasAuthMethods && (
<div className={resource.siteName ? "border-t border-border pt-2" : ""}> <div
<div className="text-xs font-medium mb-1.5">Authentication Methods</div> className={
<div className="flex flex-col gap-1.5"> resource.siteName ? "border-t border-border pt-2" : ""
{resource.sso && ( }
<div className="flex items-center gap-2"> >
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-blue-50/50 dark:bg-blue-950/50"> <div className="text-xs font-medium mb-1.5">
<Key className="h-3 w-3 text-blue-700 dark:text-blue-300" /> Authentication Methods
</div>
<span className="text-sm">Single Sign-On (SSO)</span>
</div>
)}
{resource.password && (
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-purple-50/50 dark:bg-purple-950/50">
<KeyRound className="h-3 w-3 text-purple-700 dark:text-purple-300" />
</div>
<span className="text-sm">Password Protected</span>
</div>
)}
{resource.pincode && (
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-emerald-50/50 dark:bg-emerald-950/50">
<Fingerprint className="h-3 w-3 text-emerald-700 dark:text-emerald-300" />
</div>
<span className="text-sm">PIN Code</span>
</div>
)}
{resource.whitelist && (
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-amber-50/50 dark:bg-amber-950/50">
<AtSign className="h-3 w-3 text-amber-700 dark:text-amber-300" />
</div>
<span className="text-sm">Email Whitelist</span>
</div>
)}
</div>
</div>
)}
{/* Resource Status - if disabled */}
{!resource.enabled && (
<div className={`${(resource.siteName || hasAuthMethods) ? "border-t border-border pt-2" : ""}`}>
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
<span className="text-sm text-destructive">Resource Disabled</span>
</div>
</div>
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
// Site badge component
const SiteBadge = ({ resource }: { resource: Resource }) => {
if (!resource.siteName) {
return null;
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="h-7 w-7 rounded-full flex items-center justify-center bg-muted/60 dark:bg-muted/80">
<Building2 className="h-3.5 w-3.5 text-muted-foreground" />
</div> </div>
</TooltipTrigger> <div className="flex flex-col gap-1.5">
<TooltipContent> {resource.sso && (
<p>{resource.siteName}</p> <div className="flex items-center gap-2">
</TooltipContent> <div className="h-5 w-5 rounded-full flex items-center justify-center bg-blue-50/50 dark:bg-blue-950/50">
</Tooltip> <Key className="h-3 w-3 text-blue-700 dark:text-blue-300" />
</TooltipProvider> </div>
<span className="text-sm">
Single Sign-On (SSO)
</span>
</div>
)}
{resource.password && (
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-purple-50/50 dark:bg-purple-950/50">
<KeyRound className="h-3 w-3 text-purple-700 dark:text-purple-300" />
</div>
<span className="text-sm">
Password Protected
</span>
</div>
)}
{resource.pincode && (
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-emerald-50/50 dark:bg-emerald-950/50">
<Fingerprint className="h-3 w-3 text-emerald-700 dark:text-emerald-300" />
</div>
<span className="text-sm">PIN Code</span>
</div>
)}
{resource.whitelist && (
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-amber-50/50 dark:bg-amber-950/50">
<AtSign className="h-3 w-3 text-amber-700 dark:text-amber-300" />
</div>
<span className="text-sm">Email Whitelist</span>
</div>
)}
</div>
</div>
)}
{/* Resource Status - if disabled */}
{!resource.enabled && (
<div
className={`${resource.siteName || hasAuthMethods ? "border-t border-border pt-2" : ""}`}
>
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
<span className="text-sm text-destructive">
Resource Disabled
</span>
</div>
</div>
)}
</div>
); );
return <InfoPopup>{infoContent}</InfoPopup>;
}; };
// Pagination component // Pagination component
const PaginationControls = ({ const PaginationControls = ({
currentPage, currentPage,
totalPages, totalPages,
onPageChange, onPageChange,
totalItems, totalItems,
itemsPerPage itemsPerPage
}: { }: {
currentPage: number; currentPage: number;
totalPages: number; totalPages: number;
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
totalItems: number; totalItems: number;
itemsPerPage: number; itemsPerPage: number;
@ -286,7 +224,7 @@ const PaginationControls = ({
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Showing {startItem}-{endItem} of {totalItems} resources Showing {startItem}-{endItem} of {totalItems} resources
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="outline" variant="outline"
@ -298,43 +236,53 @@ const PaginationControls = ({
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
Previous Previous
</Button> </Button>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => { {Array.from({ length: totalPages }, (_, i) => i + 1).map(
// Show first page, last page, current page, and 2 pages around current (page) => {
const showPage = // Show first page, last page, current page, and 2 pages around current
page === 1 || const showPage =
page === totalPages || page === 1 ||
Math.abs(page - currentPage) <= 1; page === totalPages ||
Math.abs(page - currentPage) <= 1;
const showEllipsis =
(page === 2 && currentPage > 4) ||
(page === totalPages - 1 && currentPage < totalPages - 3);
if (!showPage && !showEllipsis) return null; const showEllipsis =
(page === 2 && currentPage > 4) ||
(page === totalPages - 1 &&
currentPage < totalPages - 3);
if (!showPage && !showEllipsis) return null;
if (showEllipsis) {
return (
<span
key={page}
className="px-2 text-muted-foreground"
>
...
</span>
);
}
if (showEllipsis) {
return ( return (
<span key={page} className="px-2 text-muted-foreground"> <Button
... key={page}
</span> variant={
currentPage === page
? "default"
: "outline"
}
size="sm"
onClick={() => onPageChange(page)}
className="w-8 h-8 p-0"
>
{page}
</Button>
); );
} }
)}
return (
<Button
key={page}
variant={currentPage === page ? "default" : "outline"}
size="sm"
onClick={() => onPageChange(page)}
className="w-8 h-8 p-0"
>
{page}
</Button>
);
})}
</div> </div>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -352,7 +300,7 @@ const PaginationControls = ({
// Loading skeleton component // Loading skeleton component
const ResourceCardSkeleton = () => ( const ResourceCardSkeleton = () => (
<Card className="rounded-lg bg-card text-card-foreground border-2 flex flex-col w-full animate-pulse"> <Card className="rounded-lg bg-card text-card-foreground flex flex-col w-full animate-pulse">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="h-6 bg-muted rounded w-3/4"></div> <div className="h-6 bg-muted rounded w-3/4"></div>
@ -377,12 +325,14 @@ const ResourceCardSkeleton = () => (
</Card> </Card>
); );
export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalProps) { export default function MemberResourcesPortal({
orgId
}: MemberResourcesPortalProps) {
const t = useTranslations(); const t = useTranslations();
const { env } = useEnvContext(); const { env } = useEnvContext();
const api = createApiClient({ env }); const api = createApiClient({ env });
const { toast } = useToast(); const { toast } = useToast();
const [resources, setResources] = useState<Resource[]>([]); const [resources, setResources] = useState<Resource[]>([]);
const [filteredResources, setFilteredResources] = useState<Resource[]>([]); const [filteredResources, setFilteredResources] = useState<Resource[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -390,7 +340,7 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [sortBy, setSortBy] = useState("name-asc"); const [sortBy, setSortBy] = useState("name-asc");
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
// Pagination state // Pagination state
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 12; // 3x4 grid on desktop const itemsPerPage = 12; // 3x4 grid on desktop
@ -403,11 +353,11 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr
setLoading(true); setLoading(true);
} }
setError(null); setError(null);
const response = await api.get<GetUserResourcesResponse>( const response = await api.get<GetUserResourcesResponse>(
`/org/${orgId}/user-resources` `/org/${orgId}/user-resources`
); );
if (response.data.success) { if (response.data.success) {
setResources(response.data.data.resources); setResources(response.data.data.resources);
setFilteredResources(response.data.data.resources); setFilteredResources(response.data.data.resources);
@ -416,7 +366,9 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr
} }
} catch (err) { } catch (err) {
console.error("Error fetching user resources:", err); console.error("Error fetching user resources:", err);
setError("Failed to load resources. Please check your connection and try again."); setError(
"Failed to load resources. Please check your connection and try again."
);
} finally { } finally {
setLoading(false); setLoading(false);
setRefreshing(false); setRefreshing(false);
@ -429,9 +381,14 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr
// Filter and sort resources // Filter and sort resources
useEffect(() => { useEffect(() => {
let filtered = resources.filter(resource => const filtered = resources.filter(
resource.name.toLowerCase().includes(searchQuery.toLowerCase()) || (resource) =>
resource.domain.toLowerCase().includes(searchQuery.toLowerCase()) resource.name
.toLowerCase()
.includes(searchQuery.toLowerCase()) ||
resource.domain
.toLowerCase()
.includes(searchQuery.toLowerCase())
); );
// Sort resources // Sort resources
@ -459,7 +416,7 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr
}); });
setFilteredResources(filtered); setFilteredResources(filtered);
// Reset to first page when search/sort changes // Reset to first page when search/sort changes
setCurrentPage(1); setCurrentPage(1);
}, [resources, searchQuery, sortBy]); }, [resources, searchQuery, sortBy]);
@ -467,11 +424,14 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr
// Calculate pagination // Calculate pagination
const totalPages = Math.ceil(filteredResources.length / itemsPerPage); const totalPages = Math.ceil(filteredResources.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage; const startIndex = (currentPage - 1) * itemsPerPage;
const paginatedResources = filteredResources.slice(startIndex, startIndex + itemsPerPage); const paginatedResources = filteredResources.slice(
startIndex,
startIndex + itemsPerPage
);
const handleOpenResource = (resource: Resource) => { const handleOpenResource = (resource: Resource) => {
// Open the resource in a new tab // Open the resource in a new tab
window.open(resource.domain, '_blank'); window.open(resource.domain, "_blank");
}; };
const handleRefresh = () => { const handleRefresh = () => {
@ -485,7 +445,7 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr
const handlePageChange = (page: number) => { const handlePageChange = (page: number) => {
setCurrentPage(page); setCurrentPage(page);
// Scroll to top when page changes // Scroll to top when page changes
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: "smooth" });
}; };
if (loading) { if (loading) {
@ -523,7 +483,7 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr
title="Resources" title="Resources"
description="Resources you have access to in this organization" description="Resources you have access to in this organization"
/> />
<Card className="border-destructive/50 bg-destructive/5 dark:bg-destructive/10"> <Card>
<CardContent className="flex flex-col items-center justify-center py-20 text-center"> <CardContent className="flex flex-col items-center justify-center py-20 text-center">
<div className="mb-6"> <div className="mb-6">
<AlertCircle className="h-16 w-16 text-destructive/60" /> <AlertCircle className="h-16 w-16 text-destructive/60" />
@ -534,7 +494,7 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr
<p className="text-muted-foreground max-w-lg text-base mb-6"> <p className="text-muted-foreground max-w-lg text-base mb-6">
{error} {error}
</p> </p>
<Button <Button
onClick={handleRetry} onClick={handleRetry}
variant="outline" variant="outline"
className="gap-2" className="gap-2"
@ -564,24 +524,36 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr
placeholder="Search resources..." placeholder="Search resources..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8" className="w-full pl-8 bg-card"
/> />
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" /> <Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
</div> </div>
{/* Sort */} {/* Sort */}
<div className="w-full sm:w-36"> <div className="w-full sm:w-36">
<Select value={sortBy} onValueChange={setSortBy}> <Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger> <SelectTrigger className="bg-card">
<SelectValue placeholder="Sort by..." /> <SelectValue placeholder="Sort by..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="name-asc">Name A-Z</SelectItem> <SelectItem value="name-asc">
<SelectItem value="name-desc">Name Z-A</SelectItem> Name A-Z
<SelectItem value="domain-asc">Domain A-Z</SelectItem> </SelectItem>
<SelectItem value="domain-desc">Domain Z-A</SelectItem> <SelectItem value="name-desc">
<SelectItem value="status-enabled">Enabled First</SelectItem> Name Z-A
<SelectItem value="status-disabled">Disabled First</SelectItem> </SelectItem>
<SelectItem value="domain-asc">
Domain A-Z
</SelectItem>
<SelectItem value="domain-desc">
Domain Z-A
</SelectItem>
<SelectItem value="status-enabled">
Enabled First
</SelectItem>
<SelectItem value="status-disabled">
Disabled First
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -595,7 +567,9 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr
disabled={refreshing} disabled={refreshing}
className="gap-2 shrink-0" className="gap-2 shrink-0"
> >
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} /> <RefreshCw
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
/>
Refresh Refresh
</Button> </Button>
</div> </div>
@ -603,7 +577,7 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr
{/* Resources Content */} {/* Resources Content */}
{filteredResources.length === 0 ? ( {filteredResources.length === 0 ? (
/* Enhanced Empty State */ /* Enhanced Empty State */
<Card className="border-muted/50 bg-muted/5 dark:bg-muted/10"> <Card>
<CardContent className="flex flex-col items-center justify-center py-20 text-center"> <CardContent className="flex flex-col items-center justify-center py-20 text-center">
<div className="mb-8 p-4 rounded-full bg-muted/20 dark:bg-muted/30"> <div className="mb-8 p-4 rounded-full bg-muted/20 dark:bg-muted/30">
{searchQuery ? ( {searchQuery ? (
@ -613,17 +587,18 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr
)} )}
</div> </div>
<h3 className="text-2xl font-semibold text-foreground mb-3"> <h3 className="text-2xl font-semibold text-foreground mb-3">
{searchQuery ? "No Resources Found" : "No Resources Available"} {searchQuery
? "No Resources Found"
: "No Resources Available"}
</h3> </h3>
<p className="text-muted-foreground max-w-lg text-base mb-6"> <p className="text-muted-foreground max-w-lg text-base mb-6">
{searchQuery {searchQuery
? `No resources match "${searchQuery}". Try adjusting your search terms or clearing the search to see all resources.` ? `No resources match "${searchQuery}". Try adjusting your search terms or clearing the search to see all resources.`
: "You don't have access to any resources yet. Contact your administrator to get access to resources you need." : "You don't have access to any resources yet. Contact your administrator to get access to resources you need."}
}
</p> </p>
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex flex-col sm:flex-row gap-3">
{searchQuery ? ( {searchQuery ? (
<Button <Button
onClick={() => setSearchQuery("")} onClick={() => setSearchQuery("")}
variant="outline" variant="outline"
className="gap-2" className="gap-2"
@ -631,13 +606,15 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr
Clear Search Clear Search
</Button> </Button>
) : ( ) : (
<Button <Button
onClick={handleRefresh} onClick={handleRefresh}
variant="outline" variant="outline"
disabled={refreshing} disabled={refreshing}
className="gap-2" className="gap-2"
> >
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} /> <RefreshCw
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
/>
Refresh Resources Refresh Resources
</Button> </Button>
)} )}
@ -649,12 +626,15 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr
{/* Resources Grid */} {/* Resources Grid */}
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr"> <div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr">
{paginatedResources.map((resource) => ( {paginatedResources.map((resource) => (
<Card key={resource.resourceId} className="rounded-lg bg-card text-card-foreground hover:shadow-lg transition-all duration-200 border-2 hover:border-primary/20 dark:hover:border-primary/30 flex flex-col w-full group min-h-[200px]"> <Card key={resource.resourceId}>
<div className="p-6"> <div className="p-6">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center min-w-0 flex-1 gap-3 overflow-hidden"> <div className="flex items-center min-w-0 flex-1 gap-3 overflow-hidden">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<ResourceFavicon domain={resource.domain} enabled={resource.enabled} /> <ResourceFavicon
domain={resource.domain}
enabled={resource.enabled}
/>
</div> </div>
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
@ -664,12 +644,14 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr
</CardTitle> </CardTitle>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p className="max-w-xs break-words">{resource.name}</p> <p className="max-w-xs break-words">
{resource.name}
</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
</div> </div>
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<ResourceInfo resource={resource} /> <ResourceInfo resource={resource} />
</div> </div>
@ -677,21 +659,29 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr
<div className="flex items-center gap-2 mt-3"> <div className="flex items-center gap-2 mt-3">
<button <button
onClick={() => handleOpenResource(resource)} onClick={() =>
handleOpenResource(resource)
}
className="text-sm text-muted-foreground font-medium text-left truncate flex-1" className="text-sm text-muted-foreground font-medium text-left truncate flex-1"
disabled={!resource.enabled} disabled={!resource.enabled}
> >
{resource.domain.replace(/^https?:\/\//, '')} {resource.domain.replace(
/^https?:\/\//,
""
)}
</button> </button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-8 w-8 text-muted-foreground"
onClick={() => { onClick={() => {
navigator.clipboard.writeText(resource.domain); navigator.clipboard.writeText(
resource.domain
);
toast({ toast({
title: "Copied to clipboard", title: "Copied to clipboard",
description: "Resource URL has been copied to your clipboard.", description:
"Resource URL has been copied to your clipboard.",
duration: 2000 duration: 2000
}); });
}} }}
@ -702,8 +692,10 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr
</div> </div>
<div className="p-6 pt-0 mt-auto"> <div className="p-6 pt-0 mt-auto">
<Button <Button
onClick={() => handleOpenResource(resource)} onClick={() =>
handleOpenResource(resource)
}
className="w-full h-9 transition-all group-hover:shadow-sm" className="w-full h-9 transition-all group-hover:shadow-sm"
variant="outline" variant="outline"
size="sm" size="sm"
@ -729,4 +721,4 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr
)} )}
</div> </div>
); );
} }

View file

@ -62,11 +62,7 @@ export default async function OrgPage(props: OrgPageProps) {
return ( return (
<UserProvider user={user}> <UserProvider user={user}>
<Layout orgId={orgId} navItems={[]} orgs={orgs}> <Layout orgId={orgId} navItems={[]} orgs={orgs}>
{overview && ( {overview && <MemberResourcesPortal orgId={orgId} />}
<div className="w-full px-4 py-6">
<MemberResourcesPortal orgId={orgId} />
</div>
)}
</Layout> </Layout>
</UserProvider> </UserProvider>
); );

View file

@ -11,11 +11,12 @@ import { Button } from "@/components/ui/button";
interface InfoPopupProps { interface InfoPopupProps {
text?: string; text?: string;
info: string; info?: string;
trigger?: React.ReactNode; trigger?: React.ReactNode;
children?: React.ReactNode;
} }
export function InfoPopup({ text, info, trigger }: InfoPopupProps) { export function InfoPopup({ text, info, trigger, children }: InfoPopupProps) {
const defaultTrigger = ( const defaultTrigger = (
<Button <Button
variant="ghost" variant="ghost"
@ -35,7 +36,12 @@ export function InfoPopup({ text, info, trigger }: InfoPopupProps) {
{trigger ?? defaultTrigger} {trigger ?? defaultTrigger}
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-80"> <PopoverContent className="w-80">
<p className="text-sm text-muted-foreground">{info}</p> {children ||
(info && (
<p className="text-sm text-muted-foreground">
{info}
</p>
))}
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</div> </div>