Skip to content
Open
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
167 changes: 147 additions & 20 deletions src/components/ProfilePicture.svelte
Original file line number Diff line number Diff line change
@@ -1,44 +1,171 @@
<script lang="ts">
import maintainersWithoutAvatars from "@data/maintainers/maintainersWithoutAvatars.json";
import NoMaintainerIcon from "@data/icons/lucide-user-round-x.svg?raw";

interface Props {
username: string;
size: number;
wxh: number;
tooltipOverride?: string;
}

let { username, size, wxh }: Props = $props();
let { username, size, wxh, tooltipOverride }: Props = $props();

const isPlaceholder = (maintainersWithoutAvatars as string[]).includes(username);
const isPlaceholder = $derived.by( () => ( maintainersWithoutAvatars as string[] ).includes( username ) );
const githubUrl = $derived.by( () => `https://github.com/${username}` );
const tooltipText = $derived.by( () => tooltipOverride ?? ( isPlaceholder ? "This user's GitHub profile was not found." : username ) );
let isHovering = $state( false );

function handleMouseEnter() {
isHovering = true;
}

function handleMouseLeave() {
isHovering = false;
}
</script>

{#if isPlaceholder}
<img
class="placeholder"
alt="Placeholder Avatar"
title="This user's GitHub profile was not found."
src="/maintainers/{size}x{size}/placeholder.webp"
width={wxh}
height={wxh}
loading="lazy" />
{:else}
<img
alt="{username}'s Avatar"
src="/maintainers/{size}x{size}/{username}.webp"
width={wxh}
height={wxh}
loading="lazy" />
{/if}
<a
href={githubUrl}
class="avatar-wrapper"
data-tooltip={tooltipText}
aria-label={username}
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
class:hovering={isHovering}
style="--icon-size: {wxh}px">
{#if isPlaceholder}
<div class="placeholder">
{@html NoMaintainerIcon}
</div>
{:else}
<img
alt="{username}'s Avatar"
src="/maintainers/{size}x{size}/{username}.webp"
width={wxh}
height={wxh}
loading="lazy" />
{/if}
</a>

<style lang="scss">
.avatar-wrapper {
position: relative;
display: inline-flex;
padding: 8px;
margin: -8px;
border-radius: 50%;
text-decoration: none;
}

.avatar-wrapper:hover::before {
content: attr(data-tooltip);
position: absolute;
bottom: 125%;
left: calc(50% + var(--icon-size) / 6);
transform: translateX(-50%) translateY(-6px);
background-color: var(--surface0);
backdrop-filter: blur(5px);
color: var(--text);
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
pointer-events: none;
z-index: 1000;
animation: tooltip-fade 0.2s ease-out forwards;

will-change: opacity, transform;
backface-visibility: hidden;
-webkit-font-smoothing: antialiased;
contain: layout style paint;
}

.avatar-wrapper:hover::after {
content: "";
position: absolute;
bottom: calc(125% - 12px);
left: calc(50% + var(--icon-size) / 6);
transform: translateX(-50%) translateY(-6px);
width: 0;
height: 0;
border: 7px solid transparent;
border-top-color: var(--surface0);
pointer-events: none;
z-index: 1001;
animation: tooltip-fade 0.2s ease-out forwards;

will-change: opacity, transform;
backface-visibility: hidden;
}

@keyframes tooltip-fade {
from {
opacity: 0;
transform: translateX(-50%) translateY(4px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}

img {
display: inline-flex;
border: 2px solid var(--mantle);
background-color: var(--base);
border-radius: 50%;
cursor: pointer;

backface-visibility: hidden;
-webkit-font-smoothing: antialiased;
will-change: transform, box-shadow;
contain: layout style;
}

.avatar-wrapper.hovering img {
transform: scale(1.15) translateY(-6px);
box-shadow: 0 12px 20px rgba(var(--crust), 0.25);
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1),
box-shadow 0.25s ease-out;
}

.avatar-wrapper:not(.hovering) img {
transition: transform 0.28s cubic-bezier(0.25, 0.46, 0.45, 0.94),
box-shadow 0.28s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}

.avatar-wrapper:has(.placeholder):hover::before {
bottom: 105%;
left: 50%;
}

.avatar-wrapper:has(.placeholder):hover::after {
bottom: calc(105% - 12px);
left: 50%;
}

.placeholder {
filter: grayscale(100%);
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--icon-size);
height: var(--icon-size);

backface-visibility: hidden;
will-change: color;
}

.placeholder :global(svg) {
width: 100%;
height: 100%;
color: var(--overlay0);
stroke: currentColor;

backface-visibility: hidden;
}

.avatar-wrapper:hover .placeholder :global(svg) {
color: var(--overlay1);
}
</style>
16 changes: 7 additions & 9 deletions src/data/icons.json

Large diffs are not rendered by default.

67 changes: 54 additions & 13 deletions src/data/scripts/fetchMaintainerAvatars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,64 @@ async function maintainersToFetch() {
}

async function fetchAndProcessImage(maintainer: Collaborator) {
const response = await fetch(`${maintainer.url}.png?size=${REQUEST_SIZE}`);
if (!response.ok) {
console.warn(`Failed to fetch ${maintainer.url}: ${response.status} ${response.statusText}`);
const MAX_RETRIES = 3;
const RETRY_DELAY = 500;
let response;
let avatarUrl: string;

try {
const apiResponse = await fetch(`https://api.github.com/users/${maintainer.username}`);
if (!apiResponse.ok) {
console.warn(`Failed to fetch GitHub API for ${maintainer.username}: ${apiResponse.status}`);
maintainersWithoutAvatars.push(maintainer.username);
return;
}
const userData = await apiResponse.json();
avatarUrl = userData.avatar_url;
} catch (error) {
console.warn(
`Failed to get avatar URL for ${maintainer.username}:`,
error instanceof Error ? error.message : String(error),
);
maintainersWithoutAvatars.push(maintainer.username);
return;
}

for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
response = await fetch(avatarUrl);
break;
} catch (error) {
console.warn(`Attempt ${attempt}/${MAX_RETRIES} failed for ${maintainer.username}`);
if (attempt === MAX_RETRIES) {
maintainersWithoutAvatars.push(maintainer.username);
return;
}
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
}
}

if (!response!.ok) {
console.warn(`Failed to fetch avatar for ${maintainer.username}: ${response!.status}`);
maintainersWithoutAvatars.push(maintainer.username);
return;
}

const buffer = await response.arrayBuffer();
try {
const buffer = await response!.arrayBuffer();

await Promise.all(
SIZES.map((size) =>
sharp(buffer)
.resize(size, size)
.webp({ quality: IMAGE_QUALITY })
.toFile(`${PUBLIC_MAINTAINERS_DIR}/${size}x${size}/${maintainer.username}.webp`),
),
);
await Promise.all(
SIZES.map((size) =>
sharp(buffer)
.resize(size, size)
.webp({ quality: IMAGE_QUALITY })
.toFile(`${PUBLIC_MAINTAINERS_DIR}/${size}x${size}/${maintainer.username}.webp`),
),
);
} catch (error) {
console.error(`Failed to process ${maintainer.username}:`, error instanceof Error ? error.message : String(error));
maintainersWithoutAvatars.push(maintainer.username);
}
}

try {
Expand All @@ -46,7 +87,7 @@ try {
console.info(`[INFO]: fetching ${maintainers.length} maintainers`);

await Promise.all(maintainers.map((maintainer) => fetchAndProcessImage(maintainer)));

maintainersWithoutAvatars.push("__placeholder__");
await fs.writeFile(`${MAINTAINERS_DIR}/maintainersWithoutAvatars.json`, JSON.stringify(maintainersWithoutAvatars));
} catch (e) {
console.error("Processing failed: ", e);
Expand Down
6 changes: 3 additions & 3 deletions src/pages/ports/_components/PortMaintainers.svelte
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
<script lang="ts">
import type { PortWithIcons } from "@data/ports";
import ProfilePicture from "@components/ProfilePicture.svelte";
import NoMaintainerIcon from "@data/icons/lucide-user-round-x.svg?raw";

let { port }: { port: PortWithIcons } = $props();
</script>

{#if port.repository["current-maintainers"].length > 0}
<div class="current-maintainers" title="All users who currently maintain this port">
<div class="current-maintainers">
{#each port.repository["current-maintainers"] as maintainer}
<ProfilePicture username={maintainer.username} size={64} wxh={32} />
{/each}
</div>
{:else}
<div title="This port has no active maintainer(s)">
{@html NoMaintainerIcon}
<ProfilePicture username="__placeholder__" size={64} wxh={32}
tooltipOverride="This port has no active maintainer(s)" />
</div>
{/if}

Expand Down
4 changes: 2 additions & 2 deletions src/pages/ports/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import PortCard from "./_components/PortCard.svelte";
description="Explore Catppuccin's extensive range of ports. From applications and tools to websites, we have a port for just about anything!">
<PageIntro title="Ports">
<p id="ports-description">
Catppuccin provides <strong>{ports.length}</strong> ports, covering a wide range of applications, tools, websites,
and just about anything you can imagine!
Catppuccin provides <strong>{ports.length}</strong> ports, covering a wide range of applications, tools, websites, and
just about anything you can imagine!
</p>
</PageIntro>

Expand Down