Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public class MarketplacePackageDetail : MarketplacePackage
public string LicenseLink { get; set; } = string.Empty;
public List<MarketplacePackageVersion> Versions { get; set; } = [];
public List<string> Dependencies { get; set; } = [];
public string Readme { get; set; } = string.Empty;
}

[Dto]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ public class MarketplaceModuleOptions : IModuleOptions
public string NuGetSearchBaseAddress { get; set; } = "https://azuresearch-usnc.nuget.org/query";
public string NuGetRegistrationBaseAddress { get; set; } =
"https://api.nuget.org/v3/registration5-gz-semver2";
public string NuGetFlatContainerBaseAddress { get; set; } =
"https://api.nuget.org/v3-flatcontainer";
public string PackageTag { get; set; } = "simplemodule";
public int SearchCacheDurationMinutes { get; set; } = 5;
public int DetailCacheDurationMinutes { get; set; } = 10;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ .. packages.OrderByDescending(p => p.TotalDownloads),
_ => packages,
};

return new MarketplaceSearchResult { TotalHits = packages.Count, Packages = packages };
return new MarketplaceSearchResult { TotalHits = result.TotalHits, Packages = packages };
}

public async Task<MarketplacePackageDetail?> GetPackageDetailsAsync(string packageId)
Expand Down Expand Up @@ -93,11 +93,18 @@ MarketplaceSearchRequest request

var installedIds = await installedPackageDetector.GetInstalledPackageIdsAsync();

var packages = response.Data.Select(d => MapToPackage(d, installedIds)).ToList();
var filtered = response.Data
.Where(d =>
d.Id?.EndsWith(".Contracts", StringComparison.OrdinalIgnoreCase) != true
)
.ToList();

var contractsRemoved = response.Data.Count - filtered.Count;
var packages = filtered.Select(d => MapToPackage(d, installedIds)).ToList();

return new MarketplaceSearchResult
{
TotalHits = response.TotalHits,
TotalHits = response.TotalHits - contractsRemoved,
Packages = packages,
};
}
Expand All @@ -123,8 +130,13 @@ MarketplaceSearchRequest request
return null;
}

var installedIds = await installedPackageDetector.GetInstalledPackageIdsAsync();
var installedIdsTask = installedPackageDetector.GetInstalledPackageIdsAsync();
var readmeTask = FetchReadmeAsync(client, packageData.Id, packageData.Version);
await Task.WhenAll(installedIdsTask, readmeTask);

var installedIds = await installedIdsTask;
var basePackage = MapToPackage(packageData, installedIds);
var readme = await readmeTask;

return new MarketplacePackageDetail
{
Expand All @@ -148,6 +160,7 @@ MarketplaceSearchRequest request
})
.ToList(),
Dependencies = [],
Readme = readme,
};
}
catch (HttpRequestException)
Expand All @@ -156,6 +169,40 @@ MarketplaceSearchRequest request
}
}

[SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "NuGet flat container API requires lowercase package IDs")]
private async Task<string> FetchReadmeAsync(
HttpClient client,
string? packageId,
string? version
)
{
if (string.IsNullOrEmpty(packageId) || string.IsNullOrEmpty(version))
{
return string.Empty;
}

try
{
var id = packageId.ToLowerInvariant();
var ver = version.ToLowerInvariant();
var readmeUri = new Uri(
$"{options.Value.NuGetFlatContainerBaseAddress}/{id}/{ver}/readme"
);
using var response = await client.GetAsync(readmeUri);

if (!response.IsSuccessStatusCode)
{
return string.Empty;
}

return await response.Content.ReadAsStringAsync();
}
catch (HttpRequestException)
{
return string.Empty;
}
}

private static MarketplacePackage MapToPackage(
NuGetPackageData data,
HashSet<string> installedIds
Expand Down
139 changes: 85 additions & 54 deletions modules/Marketplace/src/SimpleModule.Marketplace/Views/Browse.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,11 @@
import { router } from '@inertiajs/react';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
PageShell,
} from '@simplemodule/ui';
import { Badge, Button, Card, CardContent, CardFooter, Input, PageShell } from '@simplemodule/ui';
import { useState } from 'react';
import type { MarketplacePackage } from '../types';
import { formatDownloads } from './utils';
import { categoryLabel, categoryNames, formatDownloads } from './utils';

const PAGE_SIZE = 24;

const categories = [
'All',
'Auth',
'Storage',
'UI',
'Analytics',
'Integration',
'Communication',
'Monitoring',
'Other',
];
const sortOptions = [
{ value: 'Relevance', label: 'Relevance' },
{ value: 'Downloads', label: 'Most Downloads' },
Expand All @@ -36,6 +18,8 @@ interface Props {
query: string;
selectedCategory: string;
selectedSort: string;
skip: number;
hasMore: boolean;
}

export default function Browse({
Expand All @@ -44,31 +28,42 @@ export default function Browse({
query,
selectedCategory,
selectedSort,
skip,
hasMore,
}: Props) {
const [search, setSearch] = useState(query);

function navigate(params: Record<string, string>) {
const current = new URLSearchParams();
if (search) current.set('q', search);
if (selectedCategory !== 'All') current.set('category', selectedCategory);
if (selectedSort !== 'Relevance') current.set('sort', selectedSort);

for (const [key, value] of Object.entries(params)) {
function buildParams(overrides: Record<string, string> = {}) {
const params = new URLSearchParams();
const merged = {
q: search,
category: selectedCategory,
sort: selectedSort,
...overrides,
};
for (const [key, value] of Object.entries(merged)) {
if (value && value !== 'All' && value !== 'Relevance') {
current.set(key, value);
} else {
current.delete(key);
params.set(key, value);
}
}
return params;
}

router.get(`/marketplace/browse?${current.toString()}`);
function navigate(overrides: Record<string, string>) {
router.get(`/marketplace/browse?${buildParams(overrides).toString()}`);
}

function handleSearch(e: React.FormEvent) {
e.preventDefault();
navigate({ q: search });
}

function handleLoadMore() {
const params = buildParams({ q: query });
params.set('skip', String(skip + PAGE_SIZE));
router.get(`/marketplace/browse?${params.toString()}`);
}

return (
<PageShell title="Module Marketplace" description={`${totalHits} modules available`}>
<div className="space-y-6">
Expand All @@ -84,7 +79,7 @@ export default function Browse({

<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-wrap gap-2">
{categories.map((cat) => (
{categoryNames.map((cat) => (
<Button
key={cat}
variant={selectedCategory === cat ? 'primary' : 'secondary'}
Expand Down Expand Up @@ -113,50 +108,86 @@ export default function Browse({
{packages.map((pkg) => (
<Card
key={pkg.id}
className="cursor-pointer transition-shadow hover:shadow-md"
className={`cursor-pointer transition-all duration-200 hover:shadow-md hover:-translate-y-0.5 ${
pkg.isInstalled ? 'border-l-2 border-l-primary' : ''
}`}
onClick={() => router.get(`/marketplace/${pkg.id}`)}
>
<CardHeader className="pb-3">
<div className="flex items-start gap-3">
<CardContent className="pt-5">
<div className="flex items-start gap-4">
{pkg.icon ? (
<img src={pkg.icon} alt="" className="h-10 w-10 rounded" />
<img src={pkg.icon} alt="" className="h-12 w-12 shrink-0 rounded-xl" />
) : (
<div className="flex h-10 w-10 items-center justify-center rounded bg-muted text-text-muted">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-surface-sunken text-text-muted">
<svg
className="h-5 w-5"
className="h-6 w-6"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeWidth="1.5"
viewBox="0 0 24 24"
>
<path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
)}
<div className="min-w-0 flex-1">
<CardTitle className="truncate text-base">{pkg.title}</CardTitle>
<p className="text-xs text-text-muted">v{pkg.latestVersion}</p>
<h3 className="truncate text-base font-semibold text-text">{pkg.title}</h3>
<p className="text-xs text-text-muted">{pkg.authors}</p>
</div>
</div>
</CardHeader>
<CardContent>
<p className="mb-3 line-clamp-2 text-sm text-text-muted">{pkg.description}</p>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="default">{pkg.category}</Badge>
{pkg.isInstalled && <Badge variant="default">Installed</Badge>}
<span className="ml-auto text-xs text-text-muted">
{formatDownloads(pkg.totalDownloads)} downloads
</span>
</div>
<p className="mt-3 line-clamp-2 text-sm text-text-secondary">{pkg.description}</p>
</CardContent>
<CardFooter className="flex items-center gap-2 text-xs">
<Badge variant="default">{categoryLabel(pkg.category)}</Badge>
{pkg.isInstalled && <Badge variant="success">Installed</Badge>}
<span className="ml-auto flex items-center gap-1 text-text-muted">
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{formatDownloads(pkg.totalDownloads)}
</span>
<span className="text-text-muted">v{pkg.latestVersion}</span>
</CardFooter>
</Card>
))}
</div>

{hasMore && (
<div className="flex justify-center pt-4">
<Button variant="secondary" onClick={handleLoadMore}>
Load more modules
</Button>
</div>
)}

{packages.length === 0 && (
<div className="py-12 text-center text-text-muted">
<div className="py-16 text-center text-text-muted">
<svg
className="mx-auto mb-4 h-12 w-12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
viewBox="0 0 24 24"
>
<path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<p className="text-lg font-medium">No modules found</p>
<p className="mt-1 text-sm">Try adjusting your search or filters.</p>
{(query || selectedCategory !== 'All') && (
<Button
variant="secondary"
className="mt-4"
onClick={() => router.get('/marketplace/browse')}
>
Clear filters
</Button>
)}
</div>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,28 +18,38 @@ public void Map(IEndpointRouteBuilder app)
IMarketplaceContracts marketplace,
string? q,
MarketplaceCategory? category,
MarketplaceSortOption? sort
MarketplaceSortOption? sort,
int? skip
) =>
{
var pageSize = 24;
var skipCount = skip ?? 0;

var result = await marketplace.SearchPackagesAsync(
new MarketplaceSearchRequest
{
Query = q,
Category = category,
SortBy = sort ?? MarketplaceSortOption.Relevance,
Take = 50,
Skip = skipCount,
Take = pageSize,
}
);

var packages = result.Packages;
var hasMore = skipCount + packages.Count < result.TotalHits;

return Inertia.Render(
"Marketplace/Browse",
new
{
packages = result.Packages,
packages,
totalHits = result.TotalHits,
query = q ?? string.Empty,
selectedCategory = category?.ToString() ?? "All",
selectedSort = sort?.ToString() ?? "Relevance",
skip = skipCount,
hasMore,
}
);
}
Expand Down
Loading
Loading