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
1 change: 1 addition & 0 deletions apps/web/prerender-routes.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
/docs/version-support
/docs/about
/docs/featured/youtube
/docs/featured/articles
/docs/components/accordion
/docs/components/alert
/docs/components/alert-dialog
Expand Down
23 changes: 23 additions & 0 deletions apps/web/public/docs/featured/articles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
title: Articles
description: Blog posts, tutorials and write-ups the community published about Zard UI, gathered in one place.
---

# Articles

Blog posts, tutorials and write-ups the community published about Zard UI, gathered in one place.

Made by the community

Every article below was written by someone outside the core team. Each card opens the original post on the platform where it was published, so the author keeps the traffic and the credit.

## English

- [Building fast in Angular with Zard UI, Tailwind CSS and Signals](https://dev.to/hassantayyab/building-fast-in-angular-with-zard-ui-tailwind-css-and-signals-cj5)
- [ZardUI Beta: Bringing shadcn/ui's Philosophy to Angular - Where You Own Every Line of Code](https://dev.to/samuelrizzondev/zardui-beta-bringing-shadcnuis-philosophy-to-angular-where-you-own-every-line-of-code-2a79)

## Contribute

Wrote an article about Zard UI?

Send the link to [hello@luizgomes.dev](mailto:hello@luizgomes.dev?subject=Zard%20UI%20article%20submission) and we will add it to this page. Submissions go through a review before being published, so it may take a while until your article shows up here. Content in any language is welcome.
90 changes: 90 additions & 0 deletions apps/web/src/app/domain/pages/featured/articles/articles.page.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<z-content
[navigationConfig]="navigationConfig()"
[activeAnchor]="activeAnchor"
scrollSpy
(scrollSpyChange)="activeAnchor = $event"
>
<z-doc-heading
title="Articles"
description="Blog posts, tutorials and write-ups the community published about Zard UI, gathered in one place."
scrollSpyItem="overview"
id="overview"
></z-doc-heading>

<z-alert
zTitle="Made by the community"
zDescription="Every article below was written by someone outside the core team. Each card opens the original post on the platform where it was published, so the author keeps the traffic and the credit."
/>

@for (group of groups(); track group.id) {
<section class="flex flex-col gap-6 sm:gap-8" [scrollSpyItem]="group.id" [id]="group.id">
<h2 class="font-heading mt-12 scroll-m-28 text-2xl font-medium tracking-tight first:mt-0 lg:mt-20">
{{ group.label }}
</h2>

<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
@for (article of group.articles; track article.id) {
<a
[href]="article.url"
target="_blank"
rel="noopener noreferrer"
class="group focus-visible:ring-ring focus-visible:ring-offset-background flex flex-col gap-3 rounded-lg outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2"
>
<div class="bg-muted relative aspect-video overflow-hidden rounded-lg border">
@if (article.coverFailed) {
<div class="flex h-full w-full flex-col items-center justify-center gap-3 px-6">
<img src="/images/zard.svg" alt="" aria-hidden="true" class="h-10 w-auto opacity-40 dark:invert" />
<p class="text-muted-foreground line-clamp-2 text-center text-sm font-medium">{{ article.title }}</p>
</div>
} @else {
<img
[src]="article.cover"
[alt]="'Cover image: ' + article.title"
loading="lazy"
decoding="async"
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
(error)="onCoverError(article.id)"
Comment thread
Luizgomess marked this conversation as resolved.
/>
}

<div
class="absolute inset-0 flex items-center justify-center bg-black/20 opacity-0 transition-opacity duration-300 group-hover:opacity-100 group-focus-visible:opacity-100"
>
<span
class="bg-foreground text-background flex h-14 w-14 items-center justify-center rounded-full shadow-lg transition-transform duration-300 group-hover:scale-110"
>
<ng-icon name="lucideArrowUpRight" class="size-6!" aria-hidden="true" />
</span>
</div>

<z-badge zType="secondary" class="absolute top-3 right-3">{{ article.source }}</z-badge>
</div>

<div class="flex flex-col gap-1">
<h3 class="line-clamp-2 text-sm leading-snug font-medium text-balance">{{ article.title }}</h3>
<p class="text-muted-foreground text-xs">
{{ article.author }} · {{ article.publishedAt | date: 'MMM d, y' : 'UTC' }} ·
{{ article.readingTime }} min read
</p>
</div>
</a>
}
</div>
</section>
}

<section class="flex flex-col gap-6 sm:gap-8" scrollSpyItem="contribute" id="contribute">
<h2 class="font-heading mt-12 scroll-m-28 text-2xl font-medium tracking-tight first:mt-0 lg:mt-20">Contribute</h2>

<z-alert zTitle="Wrote an article about Zard UI?" [zDescription]="contributeDescription" />

<ng-template #contributeDescription>
<p>
Send the link to
<a href="mailto:hello@luizgomes.dev?subject=Zard%20UI%20article%20submission">hello&#64;luizgomes.dev</a>
and we will add it to this page. Submissions go through a review before being published, so it may take a while
until your article shows up here. Content in any language is welcome.
</p>
</ng-template>
</section>
</z-content>
111 changes: 111 additions & 0 deletions apps/web/src/app/domain/pages/featured/articles/articles.page.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';

import { ScrollSpyItemDirective } from '@doc/domain/directives/scroll-spy-item.directive';

import { ArticlesPage } from './articles.page';
import { FEATURED_ARTICLES } from '../data/featured-articles';

describe('ArticlesPage', () => {
let component: ArticlesPage;
let fixture: ComponentFixture<ArticlesPage>;

const cards = () => fixture.debugElement.queryAll(By.css('a[target="_blank"][rel="noopener noreferrer"]'));

const imageSources = () =>
fixture.debugElement.queryAll(By.css('img')).map(el => (el.nativeElement as HTMLImageElement).getAttribute('src'));

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ArticlesPage],
providers: [provideRouter([])],
}).compileComponents();

fixture = TestBed.createComponent(ArticlesPage);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('creates', () => {
expect(component).toBeTruthy();
});

describe('article cards', () => {
it('renders one card per article', () => {
expect(cards()).toHaveLength(FEATURED_ARTICLES.length);
});

it('links every card to the original post in a new tab', () => {
const hrefs = cards().map(card => (card.nativeElement as HTMLAnchorElement).getAttribute('href'));

for (const article of FEATURED_ARTICLES) {
expect(hrefs).toContain(article.url);
}
});

it('shows the author, the publication date and the reading time', () => {
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';

expect(text).toContain('Samuel Rizzon');
expect(text).toContain('Aug 19, 2025');
expect(text).toContain('4 min read');
});
});

describe('navigation', () => {
// Bound as `[scrollSpyItem]`/`[id]` on the language sections, so the directive — not the
// attribute selector — is what finds every spied section.
const sectionIds = () =>
fixture.debugElement
.queryAll(By.directive(ScrollSpyItemDirective))
.map(el => (el.nativeElement as HTMLElement).id);

it('has a navigation entry for every section in the template', () => {
const configured = component['navigationConfig']().items.map(item => item.id);

for (const id of sectionIds()) {
expect(configured).toContain(id);
}
});

it('has a section in the template for every navigation entry', () => {
const rendered = sectionIds();

for (const item of component['navigationConfig']().items) {
expect(rendered).toContain(item.id);
}
});

it('skips languages that have no article yet', () => {
const configured = component['navigationConfig']().items.map(item => item.id);

expect(configured).not.toContain('portuguese');
});
});

describe('contribute section', () => {
it('points to the submission address', () => {
const link = fixture.debugElement.query(By.css('section#contribute a[href^="mailto:"]'));

expect((link.nativeElement as HTMLAnchorElement).getAttribute('href')).toBe(
'mailto:hello@luizgomes.dev?subject=Zard%20UI%20article%20submission',
);
});
});

describe('cover fallback', () => {
it('drops the remote image when the cover fails to load', () => {
const article = FEATURED_ARTICLES[0];

expect(imageSources()).toContain(article.cover);
expect(imageSources()).not.toContain('/images/zard.svg');

component['onCoverError'](article.id);
fixture.detectChanges();

expect(imageSources()).not.toContain(article.cover);
expect(imageSources()).toContain('/images/zard.svg');
});
});
});
104 changes: 104 additions & 0 deletions apps/web/src/app/domain/pages/featured/articles/articles.page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { DatePipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';

import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideArrowUpRight } from '@ng-icons/lucide';

import { DocContentComponent } from '@doc/domain/components/doc-content/doc-content.component';
import { DocHeadingComponent } from '@doc/domain/components/doc-heading/doc-heading.component';
import { NavigationConfig } from '@doc/domain/components/dynamic-anchor/dynamic-anchor.component';
import { ScrollSpyItemDirective } from '@doc/domain/directives/scroll-spy-item.directive';
import { ScrollSpyDirective } from '@doc/domain/directives/scroll-spy.directive';
import { SeoService } from '@doc/shared/services/seo.service';

import { ZardAlertComponent } from '@zard/components/alert/alert.component';
import { ZardBadgeComponent } from '@zard/components/badge/badge.component';

import { FEATURED_ARTICLES, type FeaturedArticle, type FeaturedArticleLanguage } from '../data/featured-articles';

interface ArticleCard extends FeaturedArticle {
/** Whether the remote cover failed to load and the card must render the placeholder instead. */
coverFailed: boolean;
}

interface ArticleGroup {
id: string;
label: string;
articles: ArticleCard[];
}

const LANGUAGE_GROUPS: ReadonlyArray<{ id: string; label: string; language: FeaturedArticleLanguage }> = [
{ id: 'portuguese', label: 'Portuguese 🇧🇷', language: 'pt-BR' },
{ id: 'english', label: 'English', language: 'en' },
];

@Component({
selector: 'z-articles',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './articles.page.html',
imports: [
DatePipe,
DocContentComponent,
DocHeadingComponent,
NgIcon,
ZardAlertComponent,
ZardBadgeComponent,
ScrollSpyDirective,
ScrollSpyItemDirective,
],
viewProviders: [provideIcons({ lucideArrowUpRight })],
})
Comment thread
Luizgomess marked this conversation as resolved.
export class ArticlesPage implements OnInit {
private readonly seoService = inject(SeoService);
activeAnchor?: string;

/**
* Seeded synchronously, unlike the YouTube page: the anchor list is derived from this data, and
* filling it in after the first render leaves the server-rendered anchors and the client out of
* sync — the hydrated `@for` ends up moving the language anchor past `Contribute`. The list still
* stays out of the initial bundle because the whole route is lazy-loaded.
*/
private readonly articles = signal<readonly FeaturedArticle[]>(FEATURED_ARTICLES);
/** IDs whose cover could not be fetched (CDN down, hotlink blocked, article unpublished). */
private readonly degradedCovers = signal<ReadonlySet<string>>(new Set<string>());

protected readonly groups = computed<ArticleGroup[]>(() => {
const articles = this.articles();
const degraded = this.degradedCovers();

return LANGUAGE_GROUPS.map(group => ({
id: group.id,
label: group.label,
articles: articles
.filter(article => article.language === group.language)
// Copy before sorting: the source array is readonly and must never be mutated.
.slice()
.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt))
.map(article => ({ ...article, coverFailed: degraded.has(article.id) })),
})).filter(group => group.articles.length > 0);
});

/** Derived from the rendered groups so an empty language never leaves a dangling anchor. */
protected readonly navigationConfig = computed<NavigationConfig>(() => ({
items: [
{ id: 'overview', label: 'Overview', type: 'core' },
...this.groups().map(group => ({ id: group.id, label: group.label, type: 'custom' as const })),
{ id: 'contribute', label: 'Contribute', type: 'custom' },
],
}));

ngOnInit(): void {
this.seoService.setDocsSeo(
'Articles',
'Blog posts, tutorials and write-ups the community published about Zard UI, gathered in one place.',
'/docs/featured/articles',
'og-featured-articles.jpg',
);
}

/** Covers are hotlinked from the publisher's CDN, so a broken URL must degrade to the placeholder. */
protected onCoverError(id: string): void {
this.degradedCovers.update(current => (current.has(id) ? current : new Set(current).add(id)));
}
}
Loading
Loading