Skip to content

Commit 968b3b4

Browse files
committed
refactor: support multiple links per project card
1 parent b543a44 commit 968b3b4

9 files changed

Lines changed: 116 additions & 71 deletions

File tree

.github/workflows/ci.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
workflow_dispatch:
8+
9+
jobs:
10+
quality:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- uses: oven-sh/setup-bun@v2
16+
with:
17+
bun-version: 1.3.14
18+
19+
- name: Install dependencies
20+
run: bun install --frozen-lockfile
21+
22+
- name: Lint (ESLint)
23+
run: bun run eslint
24+
25+
- name: Lint (Stylelint)
26+
run: bun run stylelint:check
27+
28+
- name: Unit tests + 100% coverage
29+
run: bun run test:coverage

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
**Tech Lead / Senior Angular Developer (NgRx)** · 14+ years · Web (SSR) · Smart TV · Mobile (Ionic) · Desktop (Tauri/Rust)
44

5+
[![CI](https://github.com/ASDAlexey/ASDAlexey.github.io/actions/workflows/ci.yml/badge.svg)](https://github.com/ASDAlexey/ASDAlexey.github.io/actions/workflows/ci.yml)
6+
[![Deploy](https://github.com/ASDAlexey/ASDAlexey.github.io/actions/workflows/deploy.yml/badge.svg)](https://github.com/ASDAlexey/ASDAlexey.github.io/actions/workflows/deploy.yml)
7+
![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)
8+
![Tests](https://img.shields.io/badge/tests-Vitest-6E9F18?logo=vitest&logoColor=white)
9+
![Angular](https://img.shields.io/badge/Angular-22-dd0031?logo=angular&logoColor=white)
10+
![Lighthouse](https://img.shields.io/badge/Lighthouse-A11y%20·%20SEO%20·%20BP%20100-success)
11+
512
🌐 **Live:** https://asdalexey.github.io/  ·  🇬🇧 [/en/](https://asdalexey.github.io/en/)  ·  🇷🇺 [/ru/](https://asdalexey.github.io/ru/)
613

714
This repository is my personal portfolio — and the site itself is the demo: a production-grade **Angular 22**

src/app/core/models/portfolio.interface.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@ export interface Experience {
77
readonly tags: readonly string[];
88
}
99

10+
export interface ProjectLink {
11+
readonly label: string;
12+
readonly href: string;
13+
}
14+
1015
export interface Project {
1116
readonly name: string;
1217
readonly badge: string;
1318
readonly description: string;
14-
readonly url: string | null;
15-
readonly urlLabel: string | null;
19+
readonly links: readonly ProjectLink[];
1620
readonly tags: readonly string[];
1721
readonly featured: boolean;
1822
}
Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,24 @@
1-
@if (project().url) {
2-
<a class="card card--link" rel="noreferrer" target="_blank" [class.card--featured]="project().featured" [href]="project().url">
3-
<ng-container [ngTemplateOutlet]="content" />
4-
</a>
5-
} @else {
6-
<div class="card" [class.card--featured]="project().featured">
7-
<ng-container [ngTemplateOutlet]="content" />
8-
</div>
9-
}
10-
11-
<ng-template #content>
1+
<article class="card" [class.card--featured]="project().featured">
122
<div class="card__head">
13-
<h3 class="card__title">
14-
{{ project().name }}
15-
@if (project().url) {
16-
<span class="card__arrow"></span>
17-
}
18-
</h3>
3+
<h3 class="card__title">{{ project().name }}</h3>
194
<span class="card__badge">{{ project().badge }}</span>
205
</div>
6+
217
<p class="card__desc">{{ project().description }}</p>
22-
@if (project().urlLabel) {
23-
<p class="card__meta">{{ project().urlLabel }}</p>
8+
9+
@if (project().links.length) {
10+
<ul class="card__links">
11+
@for (link of project().links; track link.href) {
12+
<li>
13+
<a rel="noreferrer" target="_blank" [href]="link.href">{{ link.label }} ↗</a>
14+
</li>
15+
}
16+
</ul>
2417
}
18+
2519
<ul class="tags">
2620
@for (tag of project().tags; track tag) {
2721
<li>{{ tag }}</li>
2822
}
2923
</ul>
30-
</ng-template>
24+
</article>

src/app/shared/components/project-card/project-card.component.scss

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@
33

44
:host {
55
display: block;
6+
height: 100%;
67
}
78

89
.card {
910
@include c.card-base;
1011

1112
height: 100%;
1213

13-
&--link {
14-
@include c.card-link-hover;
14+
&:hover {
15+
border-color: var(--border-hover);
16+
box-shadow: 0 16px 50px rgb(0, 0, 0, 0.4);
1517
}
1618

1719
&--featured {
@@ -31,10 +33,6 @@
3133
@include c.card-title;
3234
}
3335

34-
.card__arrow {
35-
@include c.arrow;
36-
}
37-
3836
.card__badge {
3937
@include c.card-meta-label;
4038

@@ -46,11 +44,23 @@
4644
@include c.card-desc;
4745
}
4846

49-
.card__meta {
50-
@include t.text('label', 'medium');
51-
47+
.card__links {
48+
display: flex;
49+
flex-wrap: wrap;
5250
margin-bottom: 14px;
53-
color: var(--accent-blue);
51+
gap: 14px;
52+
list-style: none;
53+
54+
a {
55+
@include t.text('label', 'medium');
56+
57+
color: var(--accent-blue);
58+
transition: color 0.2s var(--ease);
59+
60+
&:hover {
61+
color: var(--accent-light);
62+
}
63+
}
5464
}
5565

5666
.tags {

src/app/shared/components/project-card/project-card.component.spec.ts

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,23 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
33
import { Project } from '@core/models/portfolio.interface';
44
import { ProjectCardComponent } from './project-card.component';
55

6-
const linked: Project = {
7-
name: 'Litely',
8-
badge: 'Desktop · Open Source',
9-
description: 'Cross-platform desktop app.',
10-
url: 'https://github.com/ASDAlexey/litely',
11-
urlLabel: 'asdalexey.github.io/litely',
12-
tags: ['Tauri', 'Rust'],
6+
const withLinks: Project = {
7+
name: 'vitest-auto-spy',
8+
badge: 'Library · Open Source',
9+
description: 'Typed auto-mocking for Vitest.',
10+
links: [
11+
{ label: 'npm', href: 'https://www.npmjs.com/package/vitest-auto-spy' },
12+
{ label: 'GitHub', href: 'https://github.com/ASDAlexey/vitest-auto-spy' },
13+
],
14+
tags: ['Vitest', 'TypeScript'],
1315
featured: true,
1416
};
1517

16-
const plain: Project = {
18+
const noLinks: Project = {
1719
name: 'Bonds-tracker',
1820
badge: 'Desktop · Personal',
1921
description: 'Personal bonds tracker.',
20-
url: null,
21-
urlLabel: null,
22+
links: [],
2223
tags: ['Angular'],
2324
featured: false,
2425
};
@@ -32,23 +33,23 @@ function render(project: Project): ComponentFixture<ProjectCardComponent> {
3233
}
3334

3435
describe('ProjectCardComponent', () => {
35-
it('renders a linked, featured card with arrow and url label', () => {
36-
const el = render(linked).nativeElement as HTMLElement;
37-
38-
const anchor = el.querySelector('a.card');
39-
expect(anchor?.getAttribute('href')).toBe(linked.url);
40-
expect(anchor?.classList.contains('card--featured')).toBe(true);
41-
expect(el.querySelector('.card__arrow')).toBeTruthy();
42-
expect(el.querySelector('.card__meta')?.textContent).toContain('asdalexey.github.io/litely');
36+
it('renders a featured card with all project links', () => {
37+
const el = render(withLinks).nativeElement;
38+
39+
expect(el.querySelector('.card').classList.contains('card--featured')).toBe(true);
40+
expect(el.querySelector('.card__title').textContent).toContain('vitest-auto-spy');
41+
42+
const links = el.querySelectorAll('.card__links a');
43+
expect(links.length).toBe(2);
44+
expect(links[0].getAttribute('href')).toContain('npmjs.com');
45+
expect(links[1].getAttribute('href')).toContain('github.com');
4346
});
4447

45-
it('renders a plain div card without link, arrow or meta', () => {
46-
const el = render(plain).nativeElement as HTMLElement;
48+
it('renders a plain card without a links row', () => {
49+
const el = render(noLinks).nativeElement;
4750

48-
expect(el.querySelector('a.card')).toBeNull();
49-
expect(el.querySelector('div.card')).toBeTruthy();
50-
expect(el.querySelector('.card__arrow')).toBeNull();
51-
expect(el.querySelector('.card__meta')).toBeNull();
52-
expect(el.querySelector('.card')?.classList.contains('card--featured')).toBe(false);
51+
expect(el.querySelector('.card').classList.contains('card--featured')).toBe(false);
52+
expect(el.querySelector('.card__links')).toBeNull();
53+
expect(el.querySelectorAll('.tags li').length).toBe(1);
5354
});
5455
});

src/app/shared/components/project-card/project-card.component.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
2-
import { NgTemplateOutlet } from '@angular/common';
32

43
import { Project } from '@core/models/portfolio.interface';
54

65
@Component({
76
selector: 'app-project-card',
87
changeDetection: ChangeDetectionStrategy.OnPush,
9-
imports: [NgTemplateOutlet],
108
templateUrl: './project-card.component.html',
119
styleUrl: './project-card.component.scss',
1210
})

src/app/shared/data/portfolio.data.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -75,35 +75,37 @@ export const PROJECTS: readonly Project[] = [
7575
name: 'Litely',
7676
badge: $localize`:@@proj.litely.badge:Desktop · Open Source`,
7777
description: $localize`:@@proj.litely.desc:Cross-platform desktop app for batch image / video / PDF compression (WebP, AVIF, JPEG, PNG, SVG). Browser extension pastes compressed files straight into Jira / GitLab via Cmd+V. Watch-folders, system tray, drag&drop, dark/light, before/after compare. Rust backend.`,
78-
url: 'https://github.com/ASDAlexey/litely',
79-
urlLabel: 'asdalexey.github.io/litely',
78+
links: [
79+
{ label: 'Live', href: 'https://asdalexey.github.io/litely/' },
80+
{ label: 'GitHub', href: 'https://github.com/ASDAlexey/litely' },
81+
],
8082
tags: ['Tauri', 'Angular 22', 'Rust', 'TypeScript'],
8183
featured: true,
8284
},
8385
{
8486
name: 'Bonds-tracker',
8587
badge: $localize`:@@proj.bonds.badge:Desktop · Personal`,
8688
description: $localize`:@@proj.bonds.desc:Personal bonds portfolio tracker. Real-time analytics over Tinkoff Invest / MOEX ISS / CBR APIs, Plotly charts, a Telegram bot and quant engines.`,
87-
url: null,
88-
urlLabel: null,
89+
links: [],
8990
tags: ['Angular 21', 'Tauri 2', 'Rust', 'SQLite', 'WebSocket'],
9091
featured: false,
9192
},
9293
{
9394
name: 'vitest-auto-spy',
9495
badge: $localize`:@@proj.vas.badge:Library · Open Source`,
95-
description: $localize`:@@proj.vas.desc:Typed auto-mocking for Vitest — the missing jest-auto-spies. Typed Spy<T> over conditional/mapped types, Observable mocking (nextWith/throwWith), calledWith dispatch, zoneless Angular, zero dependencies.`,
96-
url: 'https://github.com/ASDAlexey/vitest-auto-spy',
97-
urlLabel: 'github.com/ASDAlexey/vitest-auto-spy',
96+
description: $localize`:@@proj.vas.desc:Typed auto-mocking for Vitest — the missing jest-auto-spies. Typed Spy<T> over conditional/mapped types, Observable mocking (nextWith/throwWith), calledWith dispatch, zoneless Angular, zero dependencies. Published on npm and dogfooded in this site's tests.`,
97+
links: [
98+
{ label: 'npm', href: 'https://www.npmjs.com/package/vitest-auto-spy' },
99+
{ label: 'GitHub', href: 'https://github.com/ASDAlexey/vitest-auto-spy' },
100+
],
98101
tags: ['Vitest', 'TypeScript', 'Testing'],
99-
featured: false,
102+
featured: true,
100103
},
101104
{
102105
name: 'Claude Code config & skills',
103106
badge: $localize`:@@proj.claude.badge:Tooling · Open Source`,
104107
description: $localize`:@@proj.claude.desc:Custom Claude Code skills, agents and hooks that encode project conventions, code generation and token-optimization — including an AI code-review skill built for large Angular MRs.`,
105-
url: 'https://github.com/ASDAlexey/claude',
106-
urlLabel: 'github.com/ASDAlexey/claude',
108+
links: [{ label: 'GitHub', href: 'https://github.com/ASDAlexey/claude' }],
107109
tags: ['Claude Code', 'AI', 'Automation'],
108110
featured: false,
109111
},

src/locale/messages.ru.xlf

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,8 @@
183183
<target>Библиотека · Open Source</target>
184184
</trans-unit>
185185
<trans-unit id="proj.vas.desc" datatype="html">
186-
<source>Typed auto-mocking for Vitest — the missing jest-auto-spies. Typed Spy&lt;T&gt; over conditional/mapped types, Observable mocking (nextWith/throwWith), calledWith dispatch, zoneless Angular, zero dependencies.</source>
187-
<target>Типобезопасный авто-мокинг для Vitest — недостающий jest-auto-spies. Типизированный Spy&lt;T&gt; на conditional/mapped types, мокинг Observable (nextWith/throwWith), calledWith-диспетчинг, zoneless Angular, ноль зависимостей.</target>
186+
<source>Typed auto-mocking for Vitest — the missing jest-auto-spies. Typed Spy&lt;T&gt; over conditional/mapped types, Observable mocking (nextWith/throwWith), calledWith dispatch, zoneless Angular, zero dependencies. Published on npm and dogfooded in this site's tests.</source>
187+
<target>Типобезопасный авто-мокинг для Vitest — недостающий jest-auto-spies. Типизированный Spy&lt;T&gt; на conditional/mapped types, мокинг Observable (nextWith/throwWith), calledWith-диспетчинг, zoneless Angular, ноль зависимостей. Опубликован в npm и используется в тестах этого сайта.</target>
188188
</trans-unit>
189189
<trans-unit id="proj.claude.badge" datatype="html">
190190
<source>Tooling · Open Source</source>

0 commit comments

Comments
 (0)