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
14 changes: 14 additions & 0 deletions assets/blog/cover-1.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions assets/blog/cover-2.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions assets/blog/cover-3.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions assets/blog/cover-4.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
83 changes: 83 additions & 0 deletions components/Blog/Byline/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import classNames from 'classnames';
import AvatarGroup from '@node-core/ui-components/Common/AvatarGroup';
import Avatar from '@node-core/ui-components/Common/AvatarGroup/Avatar';

import styles from './index.module.css';

const fullDate = new Intl.DateTimeFormat('en-US', {
day: 'numeric',
month: 'short',
timeZone: 'UTC',
year: 'numeric',
});

const shortDate = new Intl.DateTimeFormat('en-US', {
day: 'numeric',
month: 'short',
timeZone: 'UTC',
});

/**
* ui-components avatar descriptor for a GitHub username. `image` pulls the
* profile picture straight from GitHub; `url` is omitted on purpose so the
* Avatar renders as a `<div>` rather than an `<a>` (a post card already wraps
* the byline in a link, and nested anchors are invalid).
*/
const toAvatar = username => ({
nickname: username,
name: username,
image: `https://github.com/${username}.png`,
fallback: username.slice(0, 2).toUpperCase(),
});

/** Readable author label: one name, "A & B", or "A & N others". */
const authorLabel = authors => {
if (authors.length <= 1) return authors[0] ?? 'webpack';
if (authors.length === 2) return `${authors[0]} & ${authors[1]}`;
return `${authors[0]} & ${authors.length - 1} others`;
};

/**
* GitHub avatar(s) of the post author(s) beside the author name(s) and the
* publish date. A single author renders one {@link Avatar}; several render an
* {@link AvatarGroup}. `size="md"` stacks the name above the date (featured
* card); `size="sm"` is a compact inline row (grid card).
*
* @param {{
* authors: string[],
* date: string,
* size?: 'sm'|'md',
* className?: string,
* }} props
*/
export default function Byline({ authors, date, size = 'sm', className }) {
const format = size === 'md' ? fullDate : shortDate;
const avatarSize = size === 'md' ? 'medium' : 'small';

return (
<div className={classNames(styles.byline, styles[size], className)}>
{CLIENT && authors.length > 0 && (
<span className={styles.avatars}>
{authors.length === 1 ? (
<Avatar {...toAvatar(authors[0])} size={avatarSize} />
) : (
<AvatarGroup
avatars={authors.map(toAvatar)}
size={avatarSize}
as="div"
/>
)}
</span>
)}
<span className={styles.meta}>
<span className={styles.who}>{authorLabel(authors)}</span>
<span className={styles.sep} aria-hidden="true">
&middot;
</span>
<time className={styles.date} dateTime={date}>
{format.format(new Date(date))}
</time>
</span>
</div>
);
}
66 changes: 66 additions & 0 deletions components/Blog/Byline/index.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
@reference "../../../styles/index.css";

.byline {
@apply flex
items-center
gap-3;
}

.avatars {
@apply inline-flex
shrink-0
items-center;
}

.meta {
@apply flex
min-w-0
items-center
gap-1.5;
}

.who {
@apply truncate
font-semibold
text-neutral-900
dark:text-white;
}

.date {
@apply shrink-0
font-mono
text-neutral-500
dark:text-neutral-400;
}

.sep {
@apply text-neutral-300
dark:text-neutral-600;
}

.sm {
@apply gap-2.5;
}

.sm .who,
.sm .date {
@apply text-[13px];
}

.md .meta {
@apply flex-col
items-start
gap-0.5;
}

.md .sep {
@apply hidden;
}

.md .who {
@apply text-sm;
}

.md .date {
@apply text-[13px];
}
37 changes: 37 additions & 0 deletions components/Blog/CategoryFilter/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import classNames from 'classnames';

import styles from './index.module.css';

const ALL = 'all';

/**
* Category chips rendered as links. Selecting one navigates to `?category=…`
* (and back to `/blog` for "All posts"), which the layout reads from the URL.
*
* @param {{
* categories: string[],
* active: string,
* hrefFor: (category: string) => string,
* }} props
*/
export default function CategoryFilter({ categories, active, hrefFor }) {
const chips = [
{ key: ALL, label: 'All posts' },
...categories.map(category => ({ key: category, label: category })),
];

return (
<div className={styles.filters}>
{chips.map(({ key, label }) => (
<a
key={key}
href={hrefFor(key)}
className={classNames(styles.chip, key === active && styles.active)}
{...(key === active && { 'aria-current': 'true' })}
>
{label}
</a>
))}
</div>
);
}
43 changes: 43 additions & 0 deletions components/Blog/CategoryFilter/index.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
@reference "../../../styles/index.css";

.filters {
@apply flex
flex-wrap
gap-2
pt-7;
}

.chip {
@apply cursor-pointer
rounded-full
border
border-transparent
bg-neutral-100
px-3.5
py-1.5
text-[13px]
font-semibold
text-neutral-600
no-underline
transition-colors
duration-150
hover:bg-neutral-200
hover:text-neutral-900
dark:bg-neutral-900
dark:text-neutral-300
dark:hover:bg-neutral-800
dark:hover:text-white;
}

.active {
@apply border-blue-200
bg-blue-100
text-blue-700
hover:bg-blue-100
hover:text-blue-700
dark:border-blue-900
dark:bg-blue-950
dark:text-blue-300
dark:hover:bg-blue-950
dark:hover:text-blue-300;
}
31 changes: 31 additions & 0 deletions components/Blog/Cover/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import classNames from 'classnames';

import Cube from '../../Icons/Webpack.jsx';

import styles from './index.module.css';

/**
* Geometric brand cover for a post. Draws the hairline pattern and the cube
* placeholder, layering the hero `image` over it when present. `tag` renders
* the category chip.
*
* @param {{
* image?: string|null,
* tag?: string|null,
* alt?: string,
* className?: string,
* }} props
*/
export default function Cover({ image, tag, alt = '', className }) {
return (
<span className={classNames(styles.cover, className)}>
{tag && <span className={styles.tag}>{tag}</span>}
<span className={styles.cube} aria-hidden="true">
<Cube />
</span>
{image && (
<img className={styles.image} src={image} alt={alt} loading="lazy" />
)}
</span>
);
}
68 changes: 68 additions & 0 deletions components/Blog/Cover/index.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
@reference "../../../styles/index.css";

.cover {
@apply relative
flex
items-end
overflow-hidden
rounded-lg
border
border-neutral-200
bg-neutral-100
dark:border-neutral-800
dark:bg-neutral-900;
}

.cover::before {
background-image: repeating-linear-gradient(
135deg,
rgb(28 120 192 / 7%) 0 1px,
transparent 1px 14px
);
content: '';
inset: 0;
position: absolute;
}

.tag {
@apply absolute
left-3.5
top-3.5
z-20
inline-flex
items-center
rounded-full
bg-white
px-2.5
py-1
text-xs
font-bold
text-blue-700
shadow-sm
dark:bg-neutral-900
dark:text-blue-300;
}

.cube {
@apply pointer-events-none
absolute
-bottom-7
-right-7
z-0
block
w-36
opacity-[0.14];
}

.cube svg {
height: auto;
width: 100%;
}

.image {
@apply absolute
inset-0
z-10
size-full
object-cover;
}
Loading