Skip to content

Commit ac2c05e

Browse files
authored
feat: collections v2 UI (IN-1193, IN-1194, IN-1195) (#2007)
Signed-off-by: Gašper Grom <gasper.grom@gmail.com>
1 parent e48b1b4 commit ac2c05e

26 files changed

Lines changed: 1201 additions & 285 deletions

frontend/app/components/modules/collection/components/details/badge-details.vue

Lines changed: 31 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,52 +3,48 @@ Copyright (c) 2025 The Linux Foundation and each contributor.
33
SPDX-License-Identifier: MIT
44
-->
55
<template>
6-
<div class="flex items-center gap-2">
6+
<div class="flex items-center justify-end">
77
<template v-if="badges.length > 0">
88
<lfx-popover
9-
v-for="(count, tier) in badgeCountByTier"
9+
v-for="({ tier, count }, index) in badgeCountByTier"
1010
:key="tier"
1111
placement="top"
1212
trigger-event="hover"
1313
:allow-pass-through="true"
14+
class="relative"
15+
:class="index > 0 ? '-ml-1' : ''"
16+
:style="{ zIndex: badgeCountByTier.length - index }"
1417
>
15-
<lfx-chip
16-
:style="{ background: getBadgeColor(tier as BadgeTier) }"
17-
class="flex items-center gap-1"
18+
<div
19+
class="w-6 h-[23px] flex items-center justify-center"
20+
:style="{ clipPath: HEXAGON_CLIP_PATH, background: getBadgeColor(tier) }"
1821
>
19-
<lfx-icon
20-
name="hexagon"
21-
:size="12"
22-
class="text-white"
23-
/>
24-
<span class="text-white text-xs font-semibold">
25-
{{ count }}
26-
</span>
27-
</lfx-chip>
22+
<span class="text-white text-xs font-semibold">{{ count }}</span>
23+
</div>
2824
<template #content>
2925
<div class="bg-white border border-neutral-100 rounded-xl shadow-xl overflow-hidden p-3 w-100">
3026
<div class="flex flex-col">
3127
<div
32-
v-for="(badge, index) in getBadgesByTier(tier as BadgeTier)"
33-
:key="badge.config.leaderboardKey"
28+
v-for="(tierBadge, tierBadgeIndex) in getBadgesByTier(tier)"
29+
:key="tierBadge.config.leaderboardKey"
3430
>
3531
<div class="flex gap-3 items-start py-2">
3632
<!-- Badge Image -->
3733
<div class="shrink-0 size-13">
3834
<img
39-
:src="badge.config.badgeImages[badge.tier]"
40-
:alt="badge.config.title"
35+
:src="tierBadge.config.badgeImages[tierBadge.tier]"
36+
:alt="tierBadge.config.title"
4137
class="size-13"
4238
/>
4339
</div>
4440

4541
<!-- Content -->
4642
<div class="flex-1 min-w-0 flex flex-col justify-center">
4743
<p class="text-sm font-semibold text-neutral-900 truncate">
48-
{{ badge.config.title }}
44+
{{ tierBadge.config.title }}
4945
</p>
5046
<p class="text-xs leading-4 text-neutral-500">
51-
{{ getBadgeDescription(badge) }}
47+
{{ getBadgeDescription(tierBadge) }}
5248
</p>
5349
</div>
5450

@@ -58,7 +54,7 @@ SPDX-License-Identifier: MIT
5854
<nuxt-link
5955
:to="{
6056
name: LfxRoutes.LEADERBOARD,
61-
params: { key: badge.config.leaderboardKey },
57+
params: { key: tierBadge.config.leaderboardKey },
6258
}"
6359
@click.stop
6460
>
@@ -74,15 +70,15 @@ SPDX-License-Identifier: MIT
7470
icon="share-nodes"
7571
type="transparent"
7672
size="small"
77-
@click.stop="openShareModal(badge)"
73+
@click.stop="openShareModal(tierBadge)"
7874
/>
7975
</lfx-tooltip>
8076
</div>
8177
</div>
8278

8379
<!-- Divider -->
8480
<div
85-
v-if="index < getBadgesByTier(tier as BadgeTier).length - 1"
81+
v-if="tierBadgeIndex < getBadgesByTier(tier).length - 1"
8682
class="border-t border-neutral-100"
8783
/>
8884
</div>
@@ -110,10 +106,8 @@ import badgeConfigs, {
110106
type ProjectBadge,
111107
tierConfigs,
112108
} from '~/components/modules/badges/config/badge.config';
113-
import LfxIcon from '~/components/uikit/icon/icon.vue';
114109
import LfxIconButton from '~/components/uikit/icon-button/icon-button.vue';
115110
import LfxTooltip from '~/components/uikit/tooltip/tooltip.vue';
116-
import LfxChip from '~/components/uikit/chip/chip.vue';
117111
import LfxPopover from '~/components/uikit/popover/popover.vue';
118112
import LfxBadgesShareModal from '~/components/modules/badges/components/share/badges-share-modal.vue';
119113
import { LfxRoutes } from '~/components/shared/types/routes';
@@ -151,15 +145,18 @@ const badges = computed<ProjectBadge[]>(() => {
151145
.filter((badge): badge is ProjectBadge => badge !== null);
152146
});
153147
154-
const badgeCountByTier = computed(() => {
155-
return badges.value.reduce(
156-
(acc, badge) => {
157-
acc[badge.tier] = (acc[badge.tier] || 0) + 1;
158-
return acc;
159-
},
160-
{} as Record<BadgeTier, number>,
161-
);
162-
});
148+
// Flat-top hexagon with rounded corners, matching the Collections v2 Achievements design (Figma node 3265-25479).
149+
const HEXAGON_CLIP_PATH =
150+
"path('M5.02 2.61Q6.5 0 9.5 0L14.5 0Q17.5 0 18.98 2.61L22.52 8.89Q24 11.5 22.52 14.11L18.98 20.39Q17.5 23 14.5 23L9.5 23Q6.5 23 5.02 20.39L1.48 14.11Q0 11.5 1.48 8.89Z')";
151+
152+
const tierOrder: BadgeTier[] = [BadgeTier.BLACK, BadgeTier.GOLD, BadgeTier.SILVER, BadgeTier.BRONZE];
153+
154+
// One hexagon per tier the project has badges in, showing the count of achievements in that tier.
155+
const badgeCountByTier = computed(() =>
156+
tierOrder
157+
.map((tier) => ({ tier, count: badges.value.filter((badge) => badge.tier === tier).length }))
158+
.filter(({ count }) => count > 0),
159+
);
163160
164161
const getBadgeColor = (tier: BadgeTier) => {
165162
const radialGradient = `radial-gradient(107.08% 85.59% at 86.3% 87.5%, #0000003B 0%, #00000000 86.18%),
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<!--
2+
Copyright (c) 2025 The Linux Foundation and each contributor.
3+
SPDX-License-Identifier: MIT
4+
-->
5+
<template>
6+
<lfx-chip
7+
v-if="props.unavailable"
8+
type="bordered"
9+
size="small"
10+
>
11+
<span class="text-xs font-medium text-neutral-500">Unavailable</span>
12+
</lfx-chip>
13+
<lfx-chip
14+
v-else
15+
type="bordered"
16+
size="small"
17+
class="flex items-center gap-1"
18+
>
19+
<span
20+
class="size-1.5 rounded-full shrink-0"
21+
:class="healthScoreDotClass"
22+
/>
23+
<span class="text-xs font-medium text-neutral-900">{{ healthScoreLabel }}</span>
24+
<span class="text-xs font-medium text-neutral-500">({{ props.score }})</span>
25+
</lfx-chip>
26+
</template>
27+
28+
<script setup lang="ts">
29+
import { computed } from 'vue';
30+
import LfxChip from '~/components/uikit/chip/chip.vue';
31+
32+
const props = defineProps<{
33+
score: number;
34+
unavailable?: boolean;
35+
}>();
36+
37+
// Mirrors the dot+label+score thresholds/colors in collection-metrics-row.vue, scoped to the
38+
// collection project table's Health Score column (a bordered table-cell pill instead of a
39+
// labeled metrics chip). Duplicated rather than shared: only two call sites (row/card) and the
40+
// logic is ~10 lines, so a composable would be more ceremony than the duplication it avoids.
41+
// health-score.vue itself is intentionally left untouched (used elsewhere in the app).
42+
const healthScoreLabel = computed(() => {
43+
const score = props.score;
44+
if (score >= 80) return 'Excellent';
45+
if (score >= 60) return 'Healthy';
46+
if (score >= 40) return 'Fair';
47+
if (score >= 20) return 'Concerning';
48+
return 'Critical';
49+
});
50+
51+
const healthScoreDotClass = computed(() => {
52+
const score = props.score;
53+
if (score >= 60) return 'bg-health-healthy';
54+
if (score >= 20) return 'bg-health-concerning';
55+
return 'bg-health-critical';
56+
});
57+
</script>
58+
59+
<script lang="ts">
60+
export default {
61+
name: 'LfxCollectionHealthScorePill',
62+
};
63+
</script>
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<!--
2+
Copyright (c) 2025 The Linux Foundation and each contributor.
3+
SPDX-License-Identifier: MIT
4+
-->
5+
<template>
6+
<div class="bg-white">
7+
<div class="container">
8+
<div class="flex items-center gap-3 overflow-x-auto py-5">
9+
<lfx-menu-button
10+
v-for="link of lfCollectionAggregateLinks"
11+
:key="link.key"
12+
:to="{ name: link.routeName, params: { slug: props.slug } }"
13+
:exact="true"
14+
>
15+
<template #default="{ isActive }">
16+
<lfx-icon
17+
:name="link.icon"
18+
:type="isActive ? 'solid' : 'light'"
19+
/>
20+
{{ link.label }}
21+
</template>
22+
</lfx-menu-button>
23+
</div>
24+
</div>
25+
</div>
26+
</template>
27+
28+
<script setup lang="ts">
29+
import LfxIcon from '~/components/uikit/icon/icon.vue';
30+
import LfxMenuButton from '~/components/uikit/menu-button/menu-button.vue';
31+
import { lfCollectionAggregateLinks } from '~/components/modules/collection/config/collection-links';
32+
33+
const props = defineProps<{
34+
slug: string;
35+
}>();
36+
</script>
37+
38+
<script lang="ts">
39+
export default {
40+
name: 'LfxCollectionMenu',
41+
};
42+
</script>
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<!--
2+
Copyright (c) 2025 The Linux Foundation and each contributor.
3+
SPDX-License-Identifier: MIT
4+
-->
5+
<template>
6+
<lfx-card class="!p-0 !border-none !shadow-none !bg-transparent">
7+
<div class="flex items-center gap-3 flex-wrap">
8+
<template v-if="loading">
9+
<lfx-skeleton
10+
v-for="i in 3"
11+
:key="i"
12+
height="2rem"
13+
width="9rem"
14+
class="rounded-full"
15+
/>
16+
</template>
17+
<template v-else>
18+
<lfx-chip
19+
type="bordered"
20+
size="small"
21+
class="flex items-center gap-1"
22+
>
23+
<lfx-icon
24+
name="layer-group"
25+
:size="14"
26+
class="text-neutral-600"
27+
/>
28+
<span class="text-xs text-neutral-500">Projects / Repositories:</span>
29+
<span class="text-xs font-medium text-neutral-900">
30+
{{ projectAndRepositoryCount !== undefined ? formatNumber(projectAndRepositoryCount) : '—' }}
31+
</span>
32+
</lfx-chip>
33+
34+
<lfx-chip
35+
type="bordered"
36+
size="small"
37+
class="flex items-center gap-1"
38+
>
39+
<lfx-icon
40+
name="people-group"
41+
:size="14"
42+
class="text-neutral-600"
43+
/>
44+
<span class="text-xs text-neutral-500">Contributors:</span>
45+
<span class="text-xs font-medium text-neutral-900">
46+
{{ contributorCount !== undefined ? formatNumberShort(contributorCount) : '—' }}
47+
</span>
48+
</lfx-chip>
49+
50+
<lfx-chip
51+
type="bordered"
52+
size="small"
53+
class="flex items-center gap-1"
54+
>
55+
<lfx-icon
56+
name="heart"
57+
:size="14"
58+
class="text-neutral-600"
59+
/>
60+
<span class="text-xs text-neutral-500">Avg. Health:</span>
61+
<template v-if="avgHealthScore !== undefined && avgHealthScore !== null">
62+
<span
63+
class="size-1.5 rounded-full shrink-0"
64+
:class="healthScoreDotClass"
65+
/>
66+
<span class="text-xs font-medium text-neutral-900"> {{ healthScoreLabel }} ({{ avgHealthScore }}) </span>
67+
</template>
68+
<span
69+
v-else
70+
class="text-xs font-medium text-neutral-900"
71+
>
72+
73+
</span>
74+
</lfx-chip>
75+
</template>
76+
</div>
77+
</lfx-card>
78+
</template>
79+
80+
<script setup lang="ts">
81+
import { computed } from 'vue';
82+
import type { CollectionMetrics } from '~~/types/collection';
83+
import LfxCard from '~/components/uikit/card/card.vue';
84+
import LfxChip from '~/components/uikit/chip/chip.vue';
85+
import LfxIcon from '~/components/uikit/icon/icon.vue';
86+
import LfxSkeleton from '~/components/uikit/skeleton/skeleton.vue';
87+
import { formatNumber, formatNumberShort } from '~/components/shared/utils/formatter';
88+
89+
const props = defineProps<{
90+
metrics?: CollectionMetrics;
91+
loading?: boolean;
92+
}>();
93+
94+
const projectAndRepositoryCount = computed(() => props.metrics?.projectAndRepositoryCount);
95+
const contributorCount = computed(() => props.metrics?.uniqueContributorCount);
96+
const avgHealthScore = computed(() => props.metrics?.avgHealthScore);
97+
98+
// Labels are a deliberate, scoped divergence from health-score.vue's tiers for the Collections v2
99+
// metrics-row dot only (see IN-1191 design fidelity report). health-score.vue itself is
100+
// intentionally left untouched. Colors use the shared health-* tokens from colors.ts.
101+
const healthScoreLabel = computed(() => {
102+
const score = avgHealthScore.value ?? 0;
103+
if (score >= 80) return 'Excellent';
104+
if (score >= 60) return 'Healthy';
105+
if (score >= 40) return 'Fair';
106+
if (score >= 20) return 'Concerning';
107+
return 'Critical';
108+
});
109+
110+
const healthScoreDotClass = computed(() => {
111+
const score = avgHealthScore.value ?? 0;
112+
if (score >= 60) return 'bg-health-healthy';
113+
if (score >= 20) return 'bg-health-concerning';
114+
return 'bg-health-critical';
115+
});
116+
</script>
117+
118+
<script lang="ts">
119+
export default {
120+
name: 'LfxCollectionMetricsRow',
121+
};
122+
</script>

0 commit comments

Comments
 (0)