feat: build out project dashboard (#2)
Node.js CI / Lint and Test (push) Successful in 57s

### Explanation

This creates an interactive product directory to help potential consumers discover our works.

### Issue

_No response_

### Attestations

- [x] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [x] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [x] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [x] I have pinned the dependencies to a specific patch version.

### Style

- [x] I have run the linter and resolved any errors.
- [x] My pull request uses an appropriate title, matching the conventional commit standards.
- [x] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #2
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #2.
This commit is contained in:
2025-07-04 20:05:20 -07:00
committed by Naomi Carrigan
parent 6b19de55f2
commit 6e8c048e25
17 changed files with 1190 additions and 22 deletions
+85
View File
@@ -0,0 +1,85 @@
a.product {
text-decoration: none;
}
a.product:hover {
background-color: var(--background);
color: var(--foreground);
}
.product:not(a) {
cursor: default;
border: 2px dashed grey;
}
.btn {
display: inline-block;
padding: 10px 20px;
background-color: var(--foreground);
color: var(--background);
text-decoration: none;
border-radius: 50px;
border: 2px solid white;
font-family: 'OpenDyslexic', monospace;
}
.btn:disabled {
background-color: var(--background);
color: var(--foreground);
}
.btn:hover {
background-color: var(--background);
color: var(--foreground);
transition: background-color 0.3s, color 0.3s;
}
.product {
display: grid;
grid-template-areas: "logo title icon" "logo description icon";
grid-template-columns: 100px 1fr auto;
background-color: var(--foreground);
color: var(--background);
border: 2px solid white;
border-radius: 50px;
margin-left: 10px;
margin-right: 10px;
margin-bottom: 10px;
padding-right: 20px;
align-items: center;
}
.icons {
grid-area: icon;
font-size: 2rem;
display: grid;
grid-template-columns: repeat(2, auto);
gap: 10px;
}
.title {
grid-area: title;
font-size: 1.5rem;
font-weight: bold;
}
.description {
grid-area: description;
font-size: 1rem;
margin-top: 10px;
}
.logo {
grid-area: logo;
width: 100px;
height: 100px;
border-radius: 50%;
}
.row {
display: flex;
flex-direction: row;
justify-content: space-evenly;
align-items: center;
flex-wrap: wrap;
}
+150
View File
@@ -0,0 +1,150 @@
<h1>Products</h1>
<img
src="https://cdn.nhcarrigan.com/new-avatars/hikari-thinking-full.png"
alt="Hikari"
height="250"
/>
<p>Excellent! What sort of product are you looking for?</p>
<div class="row">
<button
class="btn"
(click)="selectCategory('community')"
[disabled]="view === 'community' ? true : false"
>
Community Tooling and Integrations
</button>
<button
class="btn"
(click)="selectCategory('websites')"
[disabled]="view === 'websites' ? true : false"
>
Websites and APIs
</button>
<button
class="btn"
(click)="selectCategory('apps')"
[disabled]="view === 'apps' ? true : false"
>
Apps and Games
</button>
<button
class="btn"
(click)="selectCategory('all')"
[disabled]="view === 'all' ? true : false"
>
Show Me Everything!
</button>
</div>
<p>And would you like to apply a filter?</p>
<div class="row">
<button class="btn" (click)="toggleFilter('wip')">
<span *ngIf="filters.wip">Hide</span
><span *ngIf="!filters.wip">Show</span> WIP
</button>
<button class="btn" (click)="toggleFilter('prod')">
<span *ngIf="filters.prod">Hide</span
><span *ngIf="!filters.prod">Show</span> Production
</button>
<button class="btn" (click)="toggleFilter('paid')">
<span *ngIf="filters.paid">Hide</span
><span *ngIf="!filters.paid">Show</span> Paid
</button>
<button class="btn" (click)="toggleFilter('free')">
<span *ngIf="filters.free">Hide</span
><span *ngIf="!filters.free">Show</span> Free
</button>
</div>
<hr />
<p *ngIf="products.length === 0">
Oh dear, it appears there are no products in this category yet! Please check
back later.
</p>
<div id="products">
<div *ngFor="let product of products">
<!-- Render as <a> if product has a URL -->
<a
*ngIf="product.url"
[class]="product.wip ? 'product wip' : 'product'"
[href]="product.url"
target="_blank"
>
<h2 class="title">{{ product.name }}</h2>
<img
class="logo"
[src]="product.avatar ?? 'https://cdn.nhcarrigan.com/logo.png'"
alt="{{ product.name }} Logo"
/>
<p class="description">{{ product.description }}</p>
<div class="icons">
<i
title="Under construction"
*ngIf="product.wip"
class="fa-solid fa-wrench"
style="color: rgb(141, 23, 23)"
></i>
<i
title="Production Ready"
*ngIf="!product.wip"
class="fa-solid fa-check"
style="color: rgb(31, 117, 19)"
></i>
<i
title="Requires Subscription"
*ngIf="product.premium"
class="fa-solid fa-money-bill-1-wave"
style="color: rgb(145, 129, 40)"
></i>
<i
title="Free to Use"
*ngIf="!product.premium"
class="fa-solid fa-piggy-bank"
style="color: rgb(116, 37, 206)"
></i>
</div>
</a>
<!-- Render as <div> if no URL -->
<div *ngIf="!product.url" [class]="product.wip ? 'product wip' : 'product'">
<h2 class="title">{{ product.name }}</h2>
<img
class="logo"
[src]="product.avatar ?? 'https://cdn.nhcarrigan.com/logo.png'"
alt="{{ product.name }} Logo"
/>
<p class="description">{{ product.description }}</p>
<div *ngIf="product.wip || product.premium" class="icons">
<i
title="Under construction"
*ngIf="product.wip"
class="fa-solid fa-wrench"
style="color: rgb(141, 23, 23)"
></i>
<i
title="Production Ready"
*ngIf="!product.wip"
class="fa-solid fa-check"
style="color: rgb(31, 117, 19)"
></i>
<i
title="Requires Subscription"
*ngIf="product.premium"
class="fa-solid fa-money-bill-1-wave"
style="color: rgb(145, 129, 40)"
></i>
<i
title="Free to Use"
*ngIf="!product.premium"
class="fa-solid fa-piggy-bank"
style="color: rgb(116, 37, 206)"
></i>
</div>
</div>
</div>
</div>
<hr />
<a
href="https://forms.nhcarrigan.com/form/XRlQjeu8CbMrTA-v0IPOxlUPEPitLKXTWg70UUCIORA"
target="_blank"
class="btn"
>I want something custom...</a
>
+78
View File
@@ -0,0 +1,78 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { products } from "../config/products.js";
@Component({
imports: [ CommonModule ],
selector: "app-products",
styleUrl: "./products.css",
templateUrl: "./products.html",
})
export class Products {
public view: (typeof products)[number]["category"] | "all"
= "all";
public products: typeof products = [];
public readonly filters: {
wip: boolean;
prod: boolean;
paid: boolean;
free: boolean;
} = {
free: true,
paid: true,
prod: true,
wip: true,
};
public constructor() {
this.selectCategory("all");
}
public selectCategory(
category: (typeof products)[number]["category"] | "all",
): void {
this.view = category;
const sortedProducts = products.sort((a, b) => {
return a.name.localeCompare(b.name);
});
if (this.view === "all") {
this.products = sortedProducts;
return;
}
this.products = sortedProducts.filter((product) => {
return product.category === this.view;
});
}
public toggleFilter(
filter: "wip" | "prod" | "paid" | "free",
): void {
this.filters[filter] = !this.filters[filter];
this.applyFilters();
}
private applyFilters(): void {
this.selectCategory(this.view);
this.products = this.products.filter((product) => {
if (!this.filters.wip && product.wip) {
return false;
}
if (!this.filters.prod && !product.wip) {
return false;
}
if (!this.filters.paid && product.premium) {
return false;
}
if (!this.filters.free && !product.premium) {
return false;
}
return true;
});
}
}