Build the Astro site UI
This commit is contained in:
parent
e5a219499e
commit
f27e9ec3fd
84 changed files with 3510 additions and 1949 deletions
53
src/components/ArticleList.astro
Normal file
53
src/components/ArticleList.astro
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import EntryThumbnail from './EntryThumbnail.astro';
|
||||
import TagList from './TagList.astro';
|
||||
import { ARTICLE_THUMBNAIL, articlePath, formatDate, formatDateShort } from '../lib/site';
|
||||
|
||||
interface Props {
|
||||
posts: CollectionEntry<'posts'>[];
|
||||
showYear?: boolean;
|
||||
tagLimit?: number;
|
||||
// Opt-in: eagerly load the first thumbnail. Only set when the list is
|
||||
// reliably above the fold (home, tag pages). Lists below substantial
|
||||
// content (related, archives by year, 404) should leave this off.
|
||||
eagerFirstThumbnail?: boolean;
|
||||
}
|
||||
|
||||
const { posts, showYear = true, tagLimit = 3, eagerFirstThumbnail = false } = Astro.props;
|
||||
---
|
||||
|
||||
<ol class="article-list">
|
||||
{
|
||||
posts.map((post, index) => {
|
||||
const href = articlePath(post);
|
||||
return (
|
||||
<li>
|
||||
<article>
|
||||
<h3>
|
||||
<a class="entry-title" href={href}>
|
||||
{post.data.title}
|
||||
</a>
|
||||
</h3>
|
||||
<p>{post.data.description}</p>
|
||||
<TagList tags={post.data.tags} limit={tagLimit} />
|
||||
</article>
|
||||
<time datetime={post.data.date.toISOString()}>
|
||||
{showYear ? formatDate(post.data.date) : formatDateShort(post.data.date)}
|
||||
</time>
|
||||
<EntryThumbnail
|
||||
src={post.data.thumbnail.src}
|
||||
alt={post.data.thumbnail.alt}
|
||||
href={href}
|
||||
class="article-thumbnail"
|
||||
widths={ARTICLE_THUMBNAIL.widths}
|
||||
sizes={ARTICLE_THUMBNAIL.sizes}
|
||||
ariaLabel={`Open article: ${post.data.title}`}
|
||||
loading={eagerFirstThumbnail && index === 0 ? 'eager' : 'lazy'}
|
||||
fetchpriority={eagerFirstThumbnail && index === 0 ? 'high' : undefined}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ol>
|
||||
50
src/components/AtAGlance.astro
Normal file
50
src/components/AtAGlance.astro
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import ProjectLinks from './ProjectLinks.astro';
|
||||
|
||||
type Link = CollectionEntry<'projects'>['data']['links'][number];
|
||||
|
||||
interface Props {
|
||||
role?: string;
|
||||
projectPeriod?: string;
|
||||
stack?: string[];
|
||||
scale?: string;
|
||||
outcome?: string;
|
||||
links?: Link[];
|
||||
headingId: string;
|
||||
}
|
||||
|
||||
const {
|
||||
role,
|
||||
projectPeriod,
|
||||
stack = [],
|
||||
scale,
|
||||
outcome,
|
||||
links = [],
|
||||
headingId,
|
||||
} = Astro.props;
|
||||
|
||||
const rows: Array<[string, string]> = [];
|
||||
if (role) rows.push(['Role', role]);
|
||||
if (projectPeriod) rows.push(['Period', projectPeriod]);
|
||||
if (stack.length > 0) rows.push(['Stack', stack.join(', ')]);
|
||||
if (scale) rows.push(['Scale', scale]);
|
||||
if (outcome) rows.push(['Outcome', outcome]);
|
||||
---
|
||||
|
||||
{
|
||||
rows.length > 0 && (
|
||||
<aside class="at-a-glance" aria-labelledby={headingId}>
|
||||
<h2 id={headingId}>At a Glance</h2>
|
||||
<dl>
|
||||
{rows.map(([label, value]) => (
|
||||
<div class="at-a-glance__row">
|
||||
<dt>{label}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{links.length > 0 && <ProjectLinks links={links} />}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
33
src/components/Breadcrumbs.astro
Normal file
33
src/components/Breadcrumbs.astro
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
---
|
||||
interface Crumb {
|
||||
href?: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: Crumb[];
|
||||
}
|
||||
|
||||
const { items } = Astro.props;
|
||||
const lastIndex = items.length - 1;
|
||||
---
|
||||
|
||||
<nav aria-label="Breadcrumb">
|
||||
<ol class="breadcrumbs">
|
||||
{
|
||||
items.map((item, index) => {
|
||||
const isLast = index === lastIndex;
|
||||
const isLink = item.href && !isLast;
|
||||
return (
|
||||
<li>
|
||||
{isLink ? (
|
||||
<a href={item.href}>{item.label}</a>
|
||||
) : (
|
||||
<span aria-current={isLast ? 'page' : undefined}>{item.label}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ol>
|
||||
</nav>
|
||||
56
src/components/EntryThumbnail.astro
Normal file
56
src/components/EntryThumbnail.astro
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
---
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { Picture } from 'astro:assets';
|
||||
|
||||
interface Props {
|
||||
src: ImageMetadata;
|
||||
alt: string;
|
||||
href?: string;
|
||||
class?: string;
|
||||
widths: number[];
|
||||
sizes: string;
|
||||
loading?: 'lazy' | 'eager';
|
||||
fetchpriority?: 'high' | 'low' | 'auto';
|
||||
ariaLabel?: string;
|
||||
// When the listing already has a focusable, screen-reader-visible title
|
||||
// link, the thumbnail link is visually duplicative. We keep it clickable
|
||||
// for pointer users but drop it from the tab order. The link still needs
|
||||
// a name because some assistive tech exposes non-tabbable links.
|
||||
decorative?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
src,
|
||||
alt,
|
||||
href,
|
||||
class: extraClass,
|
||||
widths,
|
||||
sizes,
|
||||
loading = 'lazy',
|
||||
fetchpriority,
|
||||
ariaLabel,
|
||||
decorative = true,
|
||||
} = Astro.props;
|
||||
|
||||
const Tag = href ? 'a' : 'div';
|
||||
const isDecorativeLink = Boolean(href) && decorative;
|
||||
---
|
||||
|
||||
<Tag
|
||||
class:list={['entry-thumbnail', extraClass]}
|
||||
href={href}
|
||||
tabindex={isDecorativeLink ? -1 : undefined}
|
||||
aria-label={isDecorativeLink ? (ariaLabel ?? alt) : undefined}
|
||||
>
|
||||
<Picture
|
||||
src={src}
|
||||
alt={isDecorativeLink ? '' : alt}
|
||||
formats={['avif', 'webp']}
|
||||
fallbackFormat="jpg"
|
||||
widths={widths}
|
||||
sizes={sizes}
|
||||
loading={loading}
|
||||
decoding="async"
|
||||
fetchpriority={fetchpriority}
|
||||
/>
|
||||
</Tag>
|
||||
32
src/components/Footer.astro
Normal file
32
src/components/Footer.astro
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
import { navItems, site } from '../lib/site';
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
// Footer shows all nav items except Home (which is implicit via the site title).
|
||||
const footerNavItems = navItems.filter((item) => item.href !== '/');
|
||||
---
|
||||
|
||||
<footer class="site-footer">
|
||||
<nav aria-label="Footer">
|
||||
<ul class="footer-links">
|
||||
{
|
||||
footerNavItems.map((item) => (
|
||||
<li>
|
||||
<a href={item.href}>{item.label}</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="footer-meta">
|
||||
<span>© {year} {site.name}</span>
|
||||
{/* address wraps only the author's contact details, per HTML spec. */}
|
||||
<address class="footer-contact">
|
||||
<a href={`mailto:${site.email}`}>Email</a>
|
||||
<a href={site.cv} rel="noopener">CV</a>
|
||||
<a href={site.github} rel="noopener me">GitHub</a>
|
||||
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
|
||||
</address>
|
||||
</div>
|
||||
</footer>
|
||||
128
src/components/Header.astro
Normal file
128
src/components/Header.astro
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
---
|
||||
import { navItems, site } from '../lib/site';
|
||||
|
||||
const currentPath = Astro.url.pathname;
|
||||
const current =
|
||||
currentPath === '/' || currentPath.endsWith('/') || /\.[^/]+$/.test(currentPath)
|
||||
? currentPath
|
||||
: `${currentPath}/`;
|
||||
|
||||
// Exact match for the current page; section match (descendant URLs) for
|
||||
// ancestor links. `aria-current="page"` is reserved for the exact page,
|
||||
// `"true"` indicates an ancestor section.
|
||||
function currentState(href: string): 'page' | 'true' | undefined {
|
||||
if (current === href) return 'page';
|
||||
if (href !== '/' && current.startsWith(href)) return 'true';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Header shows nav items except Home and footer-only entries. RSS lives as a
|
||||
// dedicated icon link to the right of the nav.
|
||||
const headerNavItems = navItems.filter((item) => item.href !== '/' && !item.footerOnly);
|
||||
---
|
||||
|
||||
<a class="skip-link" href="#content">Skip to content</a>
|
||||
<header class="site-header">
|
||||
<a class="site-title" href="/" aria-current={currentState('/')}>{site.name}</a>
|
||||
<div class="header-actions">
|
||||
<nav class="site-nav" aria-label="Primary">
|
||||
{
|
||||
headerNavItems.map((item) => (
|
||||
<a href={item.href} aria-current={currentState(item.href)}>
|
||||
{item.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
<a class="rss-link" href="/rss.xml" aria-label="RSS feed">
|
||||
<svg
|
||||
class="rss-icon"
|
||||
viewBox="0 0 24 24"
|
||||
width="18"
|
||||
height="18"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M6.18 17.82a2.18 2.18 0 1 1-4.36 0 2.18 2.18 0 0 1 4.36 0ZM2 9.86v3.13a8.97 8.97 0 0 1 9.01 9.01h3.13A12.1 12.1 0 0 0 2 9.86Zm0-5.86V7.1A14.92 14.92 0 0 1 16.9 22H20A17.9 17.9 0 0 0 2 4Z"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="sr-only">RSS feed</span>
|
||||
</a>
|
||||
<button
|
||||
id="theme-switcher"
|
||||
class="theme-switcher"
|
||||
type="button"
|
||||
aria-label="Dark theme"
|
||||
aria-pressed="false"
|
||||
>
|
||||
<span class="sr-only">Toggle theme</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<script is:inline data-theme-script>
|
||||
// Co-located with the button so the initial aria state is set as soon as the
|
||||
// button parses, avoiding a flash of the wrong icon. The theme itself is
|
||||
// already on <html> from theme-init.js in <head>.
|
||||
(function () {
|
||||
var root = document.documentElement;
|
||||
var switcher = document.getElementById('theme-switcher');
|
||||
if (!switcher) return;
|
||||
|
||||
// Keep in sync with --color-bg in global.css and theme-init.js.
|
||||
var THEME_BG = { light: '#fbfaf7', dark: '#151514' };
|
||||
var themeColorMetas = document.querySelectorAll('meta[name="theme-color"]');
|
||||
|
||||
function sync(theme) {
|
||||
switcher.setAttribute('aria-pressed', String(theme === 'dark'));
|
||||
switcher.setAttribute(
|
||||
'title',
|
||||
theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'
|
||||
);
|
||||
for (var i = 0; i < themeColorMetas.length; i += 1) {
|
||||
themeColorMetas[i].setAttribute('content', THEME_BG[theme]);
|
||||
}
|
||||
}
|
||||
sync(root.dataset.theme === 'dark' ? 'dark' : 'light');
|
||||
|
||||
var reduced = matchMedia('(prefers-reduced-motion: reduce)');
|
||||
switcher.addEventListener('click', function () {
|
||||
var next = root.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||
try {
|
||||
localStorage.setItem('theme', next);
|
||||
} catch (e) {}
|
||||
var run = function () {
|
||||
root.dataset.theme = next;
|
||||
root.style.colorScheme = next;
|
||||
sync(next);
|
||||
};
|
||||
if (!reduced.matches && typeof document.startViewTransition === 'function') {
|
||||
document.startViewTransition(run);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.rss-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-block-size: 44px;
|
||||
min-inline-size: 44px;
|
||||
color: inherit;
|
||||
line-height: 0;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
.rss-link:hover,
|
||||
.rss-link:focus-visible {
|
||||
color: var(--color-link-hover);
|
||||
}
|
||||
.rss-icon {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
30
src/components/PostMedia.astro
Normal file
30
src/components/PostMedia.astro
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import PostMediaFigure from './PostMediaFigure.astro';
|
||||
|
||||
type MediaItem = CollectionEntry<'posts'>['data']['media'][number];
|
||||
|
||||
interface Props {
|
||||
items: MediaItem[];
|
||||
}
|
||||
|
||||
const { items } = Astro.props;
|
||||
|
||||
// Wrap in a gallery `<ul>` when there's more than one item; otherwise the
|
||||
// figures sit directly in the post flow.
|
||||
const isGallery = items.length > 1;
|
||||
---
|
||||
|
||||
{
|
||||
isGallery ? (
|
||||
<ul role="list" class="post-gallery">
|
||||
{items.map((item) => (
|
||||
<li>
|
||||
<PostMediaFigure item={item} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
items.map((item) => <PostMediaFigure item={item} />)
|
||||
)
|
||||
}
|
||||
80
src/components/PostMediaFigure.astro
Normal file
80
src/components/PostMediaFigure.astro
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { Picture } from 'astro:assets';
|
||||
|
||||
type MediaItem = CollectionEntry<'posts'>['data']['media'][number];
|
||||
|
||||
interface Props {
|
||||
item: MediaItem;
|
||||
}
|
||||
|
||||
const { item } = Astro.props;
|
||||
|
||||
const videoWidth = item.type === 'video' ? (item.poster?.width ?? 1280) : undefined;
|
||||
const videoHeight = item.type === 'video' ? (item.poster?.height ?? 720) : undefined;
|
||||
---
|
||||
|
||||
<figure class="post-media">
|
||||
{
|
||||
item.type === 'video' ? (
|
||||
// Decorative videos stay inert and hidden from assistive tech. Meaningful
|
||||
// videos expose controls, captions, and an accessible name.
|
||||
item.decorative ? (
|
||||
<video
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
poster={item.poster?.src}
|
||||
width={videoWidth}
|
||||
height={videoHeight}
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
{item.webm && <source src={item.webm} type="video/webm" />}
|
||||
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
|
||||
</video>
|
||||
) : (
|
||||
<video
|
||||
controls
|
||||
preload="none"
|
||||
poster={item.poster?.src}
|
||||
width={videoWidth}
|
||||
height={videoHeight}
|
||||
aria-label={item.alt}
|
||||
>
|
||||
{item.webm && <source src={item.webm} type="video/webm" />}
|
||||
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
|
||||
{item.captions && (
|
||||
<track
|
||||
kind="captions"
|
||||
src={item.captions}
|
||||
srclang="en"
|
||||
label={item.captionsLabel}
|
||||
default
|
||||
/>
|
||||
)}
|
||||
</video>
|
||||
)
|
||||
) : (
|
||||
item.src && (
|
||||
<Picture
|
||||
src={item.src}
|
||||
alt={item.decorative ? '' : (item.alt ?? '')}
|
||||
formats={['avif', 'webp']}
|
||||
widths={[480, 720, 960, 1280, 1600, 1920]}
|
||||
sizes="(max-width: 700px) calc(100vw - 2 * clamp(20px, 4vw, 32px)), (max-width: 1100px) min(calc(100vw - 4rem), 56rem), 56rem"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
)
|
||||
)
|
||||
}
|
||||
{item.caption && !item.decorative && <figcaption>{item.caption}</figcaption>}
|
||||
{
|
||||
item.transcript && (
|
||||
<p class="media-transcript">
|
||||
<strong>Transcript:</strong> {item.transcript}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
</figure>
|
||||
64
src/components/ProjectLinks.astro
Normal file
64
src/components/ProjectLinks.astro
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
|
||||
type Link = CollectionEntry<'projects'>['data']['links'][number];
|
||||
|
||||
interface Props {
|
||||
links: Link[];
|
||||
}
|
||||
|
||||
const { links } = Astro.props;
|
||||
|
||||
function isExternal(url: string) {
|
||||
return /^https?:\/\//.test(url);
|
||||
}
|
||||
---
|
||||
|
||||
{
|
||||
links.length > 0 && (
|
||||
<ul class="project-links">
|
||||
{links.map((link) => (
|
||||
<li>
|
||||
<a
|
||||
href={link.url}
|
||||
download={link.download ? '' : undefined}
|
||||
rel={isExternal(link.url) ? 'noopener noreferrer' : undefined}
|
||||
target={isExternal(link.url) ? '_blank' : undefined}
|
||||
>
|
||||
{link.label}
|
||||
{isExternal(link.url) && (
|
||||
<>
|
||||
<svg
|
||||
class="external-link-icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="0.85em"
|
||||
height="0.85em"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<line x1="10" y1="14" x2="21" y2="3" />
|
||||
</svg>
|
||||
<span class="sr-only">(opens in new tab)</span>
|
||||
</>
|
||||
)}
|
||||
{link.download && (
|
||||
<>
|
||||
<span class="download-indicator" aria-hidden="true">
|
||||
↓
|
||||
</span>
|
||||
<span class="sr-only">(download)</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
69
src/components/ProjectList.astro
Normal file
69
src/components/ProjectList.astro
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { getEntry } from 'astro:content';
|
||||
import EntryThumbnail from './EntryThumbnail.astro';
|
||||
import ProjectLinks from './ProjectLinks.astro';
|
||||
import { PROJECT_THUMBNAIL, articlePath, entrySlug } from '../lib/site';
|
||||
|
||||
interface Props {
|
||||
projects: CollectionEntry<'projects'>[];
|
||||
// Opt-in: eagerly load the first thumbnail. Only set when the list is
|
||||
// reliably above the fold. The home and projects-index lists sit below
|
||||
// other sections, so leave this off there.
|
||||
eagerFirstThumbnail?: boolean;
|
||||
}
|
||||
|
||||
const { projects, eagerFirstThumbnail = false } = Astro.props;
|
||||
|
||||
// The `essay` field is a `reference('posts')`, so when present it's always a
|
||||
// `{ collection, id }` shape that `getEntry` resolves to a CollectionEntry.
|
||||
const essayHrefs = new Map<string, string>();
|
||||
for (const project of projects) {
|
||||
const essay = project.data.essay;
|
||||
if (!essay) continue;
|
||||
const resolved = await getEntry(essay);
|
||||
if (resolved) essayHrefs.set(project.id, articlePath(resolved));
|
||||
}
|
||||
---
|
||||
|
||||
<ol class="project-list">
|
||||
{
|
||||
projects.map((project, index) => {
|
||||
const anchor = entrySlug(project);
|
||||
const titleId = `${anchor}-title`;
|
||||
const essayHref = essayHrefs.get(project.id);
|
||||
const primaryHref = essayHref ?? project.data.links[0]?.url;
|
||||
|
||||
return (
|
||||
<li class="project-card" id={anchor}>
|
||||
<EntryThumbnail
|
||||
src={project.data.thumbnail.src}
|
||||
alt={project.data.thumbnail.alt}
|
||||
href={primaryHref}
|
||||
class="project-thumbnail"
|
||||
widths={PROJECT_THUMBNAIL.widths}
|
||||
sizes={PROJECT_THUMBNAIL.sizes}
|
||||
ariaLabel={`Open project: ${project.data.title}`}
|
||||
loading={eagerFirstThumbnail && index === 0 ? 'eager' : 'lazy'}
|
||||
fetchpriority={eagerFirstThumbnail && index === 0 ? 'high' : undefined}
|
||||
/>
|
||||
<article class="project-card__summary">
|
||||
<h3 id={titleId}>
|
||||
{primaryHref ? (
|
||||
<a href={primaryHref}>{project.data.title}</a>
|
||||
) : (
|
||||
project.data.title
|
||||
)}
|
||||
{essayHref && <span class="project-essay-badge">Article</span>}
|
||||
</h3>
|
||||
<p class="project-description">{project.data.description}</p>
|
||||
<p class="project-meta">
|
||||
{project.data.period} · {project.data.technologies.join(', ')}
|
||||
</p>
|
||||
{project.data.links.length > 0 && <ProjectLinks links={project.data.links} />}
|
||||
</article>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ol>
|
||||
40
src/components/TagList.astro
Normal file
40
src/components/TagList.astro
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
import { tagPath } from '../lib/site';
|
||||
|
||||
interface Props {
|
||||
tags: readonly string[];
|
||||
currentTag?: string;
|
||||
limit?: number;
|
||||
counts?: Record<string, number>;
|
||||
}
|
||||
|
||||
const { tags, currentTag, limit, counts } = Astro.props;
|
||||
|
||||
const visibleTags = typeof limit === 'number' ? tags.slice(0, limit) : tags;
|
||||
const remaining =
|
||||
typeof limit === 'number' && tags.length > limit ? tags.length - limit : 0;
|
||||
---
|
||||
|
||||
<ul class="tag-list">
|
||||
{
|
||||
visibleTags.map((tag) => (
|
||||
<li>
|
||||
<a href={tagPath(tag)} aria-current={tag === currentTag ? 'page' : undefined}>
|
||||
{tag}
|
||||
{counts && counts[tag] !== undefined && (
|
||||
<span class="tag-count">{counts[tag]}</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
{
|
||||
remaining > 0 && (
|
||||
<li>
|
||||
<a href="/tags/" class="tag-more">
|
||||
+{remaining} more
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
</ul>
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
let isSpaceClickActive = false;
|
||||
|
||||
export const addSupportForTabNavigation = () =>
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === ' ') {
|
||||
isSpaceClickActive = true;
|
||||
(document.activeElement as HTMLElement)?.click();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
export const removeUnnecessaryOutlines = () =>
|
||||
document.addEventListener('click', () => {
|
||||
if (!isSpaceClickActive) {
|
||||
(document.activeElement as HTMLElement).blur?.();
|
||||
}
|
||||
isSpaceClickActive = false;
|
||||
});
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
export const getHeight = (e: HTMLElement): number => {
|
||||
// ignores margin collapse
|
||||
const computedStyle = getComputedStyle(e);
|
||||
|
||||
return (
|
||||
e.clientHeight +
|
||||
parseFloat(computedStyle.marginTop) +
|
||||
parseFloat(computedStyle.marginBottom) +
|
||||
parseFloat(computedStyle.borderTopWidth) +
|
||||
parseFloat(computedStyle.borderBottomWidth)
|
||||
);
|
||||
};
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export const mix = (from: number, to: number, q: number): number =>
|
||||
from + (to - from) * q;
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
export class Random {
|
||||
// don't set the seed 0
|
||||
public constructor(public seed: number = 42) {}
|
||||
|
||||
public get next(): number {
|
||||
// result is in [0, 1)
|
||||
|
||||
this.seed = Math.imul(48271, this.seed);
|
||||
return ((2 ** 31 - 1) & this.seed) / 2 ** 31;
|
||||
}
|
||||
|
||||
public choose<T>(list: Array<T>): T {
|
||||
return list[Math.floor(this.inInterval(0, list.length))];
|
||||
}
|
||||
|
||||
public inInterval(aClosed: number, bOpen: number): number {
|
||||
return (bOpen - aClosed) * this.next + aClosed;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
export const scrollToFragment = () => {
|
||||
// it might be necessary when the page takes too long to load
|
||||
if (location.hash) {
|
||||
document.getElementById(location.hash.slice(1))?.scrollIntoView();
|
||||
}
|
||||
};
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export const sum = (list: ArrayLike<number>): number =>
|
||||
// @ts-ignore
|
||||
Array.prototype.reduce.call(list, (a: number, sum: number) => a + sum, 0);
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
export const titleToFragment = (title: string): string =>
|
||||
'#' +
|
||||
encodeURIComponent(
|
||||
title.toLocaleLowerCase().replace(/&.*?;/g, '').replace(/\W+/g, '-')
|
||||
);
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
||||
<meta property="og:title" content="Portfolio | Andras Schmelczer" />
|
||||
<meta property="og:description" content="Discover my projects." />
|
||||
<meta property="og:url" content="https://schmelczer.dev" />
|
||||
|
||||
<meta property="og:image:width" content="1920" />
|
||||
<meta property="og:image:height" content="1920" />
|
||||
<meta property="og:image" content="https://schmelczer.dev/og-image.jpg" />
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" href="favicon.ico" type="image/x-icon" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta
|
||||
name="description"
|
||||
content="I'm Andras Schmelczer, and this is my portfolio. Discover some of my projects. I'm passionate about solving challenging problems and designing large-scale systems, especially in the context of machine learning."
|
||||
/>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#b7455e" />
|
||||
|
||||
<title>Portfolio | Andras Schmelczer</title>
|
||||
|
||||
<link inline inline-asset="index.css" inline-asset-delete />
|
||||
</head>
|
||||
<body>
|
||||
<noscript>JavaScript is required for this website.</noscript>
|
||||
<script inline inline-asset="index.js" inline-asset-delete></script>
|
||||
</body>
|
||||
</html>
|
||||
105
src/index.scss
105
src/index.scss
|
|
@ -1,105 +0,0 @@
|
|||
@use 'style/fonts';
|
||||
@use 'style/vars';
|
||||
@use 'style/mixins' as *;
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
html[animations='off'] {
|
||||
&,
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
-webkit-font-smooth: antialiased;
|
||||
|
||||
@media (min-width: 1000px) and (max-width: 1440px) {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@media (max-width: 999px) {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
@media print {
|
||||
& {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background);
|
||||
transition: background-color linear var(--transition-time);
|
||||
|
||||
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom)
|
||||
env(safe-area-inset-left);
|
||||
|
||||
height: 100%;
|
||||
|
||||
@media print {
|
||||
& {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
noscript {
|
||||
@include square(100%);
|
||||
@include center-children();
|
||||
@include sub-title-font();
|
||||
}
|
||||
|
||||
img,
|
||||
video,
|
||||
iframe {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
stroke: var(--normal-text-color);
|
||||
}
|
||||
|
||||
p {
|
||||
@include main-font();
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
:focus {
|
||||
outline: none;
|
||||
|
||||
&:not(:hover) {
|
||||
outline: var(--accent-color) solid var(--line-width);
|
||||
}
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: var(--accent-color);
|
||||
color: var(--very-light-text-color);
|
||||
}
|
||||
33
src/index.ts
33
src/index.ts
|
|
@ -1,33 +0,0 @@
|
|||
import '../static/no-change/404.html';
|
||||
import '../static/no-change/favicons/android-chrome-192x192.png';
|
||||
import '../static/no-change/favicons/android-chrome-512x512.png';
|
||||
import '../static/no-change/favicons/apple-touch-icon.png';
|
||||
import '../static/no-change/favicons/favicon-16x16.png';
|
||||
import '../static/no-change/favicons/favicon-32x32.png';
|
||||
import '../static/no-change/favicons/favicon.ico';
|
||||
import '../static/no-change/favicons/site.webmanifest';
|
||||
import '../static/no-change/og-image.jpg';
|
||||
import '../static/no-change/robots.txt';
|
||||
import { init as plausibleInit } from '@plausible-analytics/tracker';
|
||||
import { portfolio } from './data/portfolio';
|
||||
import {
|
||||
addSupportForTabNavigation,
|
||||
removeUnnecessaryOutlines,
|
||||
} from './helper/accessibility';
|
||||
import { scrollToFragment } from './helper/scroll-to-fragment';
|
||||
import './index.scss';
|
||||
|
||||
plausibleInit({
|
||||
domain: 'schmelczer.dev',
|
||||
endpoint: 'https://stats.schmelczer.dev/status',
|
||||
autoCapturePageviews: true,
|
||||
captureOnLocalhost: true,
|
||||
logging: true,
|
||||
fileDownloads: true,
|
||||
hashBasedRouting: true,
|
||||
});
|
||||
|
||||
addSupportForTabNavigation();
|
||||
removeUnnecessaryOutlines();
|
||||
portfolio.forEach((e) => e.attachToDOM(document.body));
|
||||
scrollToFragment();
|
||||
178
src/layouts/Base.astro
Normal file
178
src/layouts/Base.astro
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
---
|
||||
import Footer from '../components/Footer.astro';
|
||||
import Header from '../components/Header.astro';
|
||||
import { absoluteUrl, optimizeOgImage, site } from '../lib/site';
|
||||
import defaultOg from '../assets/og-default.jpg';
|
||||
import themeInit from '../scripts/theme-init.js?raw';
|
||||
import '../styles/global.css';
|
||||
|
||||
interface ArticleMeta {
|
||||
publishedTime: string;
|
||||
modifiedTime?: string;
|
||||
tags?: readonly string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
canonicalPath?: string;
|
||||
ogImage?: string;
|
||||
ogImageAlt?: string;
|
||||
ogImageWidth?: number;
|
||||
ogImageHeight?: number;
|
||||
ogType?: 'website' | 'article' | 'profile';
|
||||
article?: ArticleMeta;
|
||||
jsonLd?: Record<string, unknown> | Array<Record<string, unknown>>;
|
||||
noindex?: boolean;
|
||||
preloadMono?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
title = site.title,
|
||||
description = site.description,
|
||||
canonicalPath: rawCanonicalPath = Astro.url.pathname,
|
||||
ogImage,
|
||||
ogImageAlt = "Andras Schmelczer's personal site",
|
||||
ogImageWidth,
|
||||
ogImageHeight,
|
||||
ogType = 'website',
|
||||
article,
|
||||
jsonLd,
|
||||
noindex = false,
|
||||
preloadMono = false,
|
||||
} = Astro.props;
|
||||
|
||||
const isRoot = title === site.title;
|
||||
const pageTitle = isRoot ? site.title : `${title} · ${site.name}`;
|
||||
const ogTitle = isRoot ? site.title : title;
|
||||
const canonicalPath =
|
||||
rawCanonicalPath === '/' ||
|
||||
rawCanonicalPath.endsWith('/') ||
|
||||
/\.[^/]+$/.test(rawCanonicalPath)
|
||||
? rawCanonicalPath
|
||||
: `${rawCanonicalPath}/`;
|
||||
const canonical = absoluteUrl(canonicalPath);
|
||||
|
||||
let resolvedOgImage = ogImage;
|
||||
let resolvedOgWidth = ogImageWidth ?? 1200;
|
||||
let resolvedOgHeight = ogImageHeight ?? 630;
|
||||
|
||||
if (!resolvedOgImage) {
|
||||
const generated = await optimizeOgImage(defaultOg);
|
||||
resolvedOgImage = generated.src;
|
||||
resolvedOgWidth = 1200;
|
||||
resolvedOgHeight = 630;
|
||||
}
|
||||
|
||||
const ogImageUrl = resolvedOgImage.startsWith('http')
|
||||
? resolvedOgImage
|
||||
: absoluteUrl(resolvedOgImage);
|
||||
const ogImageExt = ogImageUrl
|
||||
.match(/\.(png|webp|gif|svg|jpe?g)(?:\?|$)/i)?.[1]
|
||||
?.toLowerCase();
|
||||
const ogImageType =
|
||||
ogImageExt === 'png'
|
||||
? 'image/png'
|
||||
: ogImageExt === 'webp'
|
||||
? 'image/webp'
|
||||
: ogImageExt === 'gif'
|
||||
? 'image/gif'
|
||||
: ogImageExt === 'svg'
|
||||
? 'image/svg+xml'
|
||||
: 'image/jpeg';
|
||||
const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
|
||||
const jsonLdStrings = jsonLdEntries.map((entry) =>
|
||||
JSON.stringify(entry).replace(/</g, '\\u003c')
|
||||
);
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en" class="no-js">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,viewport-fit=cover"
|
||||
/>
|
||||
<title>{pageTitle}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta name="author" content={site.name} />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
{noindex && <meta name="robots" content="noindex,follow" />}
|
||||
{!noindex && <link rel="canonical" href={canonical} />}
|
||||
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#151514" media="(prefers-color-scheme: dark)" />
|
||||
<script is:inline data-theme-script set:html={themeInit} />
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/source-sans-3-latin-variable.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
{
|
||||
preloadMono && (
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/ibm-plex-mono-latin-400.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
)
|
||||
}
|
||||
<link
|
||||
rel="alternate"
|
||||
type="application/rss+xml"
|
||||
title={`${site.name} RSS`}
|
||||
href="/rss.xml"
|
||||
/>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta property="og:site_name" content={site.name} />
|
||||
<meta property="og:title" content={ogTitle} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:image" content={ogImageUrl} />
|
||||
<meta property="og:image:type" content={ogImageType} />
|
||||
<meta property="og:image:alt" content={ogImageAlt} />
|
||||
<meta property="og:image:width" content={String(resolvedOgWidth)} />
|
||||
<meta property="og:image:height" content={String(resolvedOgHeight)} />
|
||||
<meta property="og:type" content={ogType} />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
{
|
||||
article && (
|
||||
<>
|
||||
<meta property="article:published_time" content={article.publishedTime} />
|
||||
{article.modifiedTime && (
|
||||
<meta property="article:modified_time" content={article.modifiedTime} />
|
||||
)}
|
||||
<meta property="article:author" content={absoluteUrl('/about/')} />
|
||||
{article.tags?.map((tag) => (
|
||||
<meta property="article:tag" content={tag} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={ogTitle} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={ogImageUrl} />
|
||||
<meta name="twitter:image:alt" content={ogImageAlt} />
|
||||
{
|
||||
jsonLdStrings.map((jsonLdString) => (
|
||||
<script is:inline type="application/ld+json" set:html={jsonLdString} />
|
||||
))
|
||||
}
|
||||
</head>
|
||||
<body>
|
||||
<Header />
|
||||
<main id="content" tabindex="-1">
|
||||
<slot />
|
||||
</main>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
22
src/layouts/Page.astro
Normal file
22
src/layouts/Page.astro
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
---
|
||||
import type { ComponentProps } from 'astro/types';
|
||||
import Base from './Base.astro';
|
||||
|
||||
type Props = Omit<ComponentProps<typeof Base>, 'title'> & { title: string };
|
||||
|
||||
const { title, description } = Astro.props;
|
||||
if (!title) {
|
||||
throw new Error('Page layout requires a `title` prop.');
|
||||
}
|
||||
---
|
||||
|
||||
<Base {...Astro.props}>
|
||||
<div class="page-shell">
|
||||
<header class="page-header">
|
||||
<slot name="breadcrumbs" />
|
||||
<h1>{title}</h1>
|
||||
{description && <p>{description}</p>}
|
||||
</header>
|
||||
<slot />
|
||||
</div>
|
||||
</Base>
|
||||
207
src/layouts/Post.astro
Normal file
207
src/layouts/Post.astro
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { render } from 'astro:content';
|
||||
import { Picture } from 'astro:assets';
|
||||
import ArticleList from '../components/ArticleList.astro';
|
||||
import AtAGlance from '../components/AtAGlance.astro';
|
||||
import Breadcrumbs from '../components/Breadcrumbs.astro';
|
||||
import PostMedia from '../components/PostMedia.astro';
|
||||
import TagList from '../components/TagList.astro';
|
||||
import {
|
||||
absoluteUrl,
|
||||
adjacentPosts,
|
||||
articlePath,
|
||||
buildBreadcrumbJsonLd,
|
||||
buildPersonJsonLd,
|
||||
buildBreadcrumbTrail,
|
||||
formatDate,
|
||||
getPublishedPosts,
|
||||
getRelatedPosts,
|
||||
optimizeOgImage,
|
||||
} from '../lib/site';
|
||||
import Base from './Base.astro';
|
||||
|
||||
interface Props {
|
||||
post: CollectionEntry<'posts'>;
|
||||
}
|
||||
|
||||
const { post } = Astro.props;
|
||||
const { Content, headings } = await render(post);
|
||||
|
||||
const allPosts = await getPublishedPosts();
|
||||
const { previous, next } = adjacentPosts(allPosts, post);
|
||||
const related = getRelatedPosts(allPosts, post, 3);
|
||||
|
||||
const ogImageOptimized = await optimizeOgImage(post.data.thumbnail.src);
|
||||
|
||||
const trail = buildBreadcrumbTrail({ post });
|
||||
const breadcrumbTrail = trail.map((c, i) => ({
|
||||
label: c.name,
|
||||
href: i === trail.length - 1 ? undefined : c.href,
|
||||
}));
|
||||
|
||||
// Reading time: words in body / 200 wpm, rounded up.
|
||||
const wordCount = post.body ? post.body.trim().split(/\s+/).filter(Boolean).length : 0;
|
||||
const readingMinutes = Math.max(1, Math.ceil(wordCount / 200));
|
||||
|
||||
// Only preload the monospace font if the post body actually contains code
|
||||
// (inline `…` or fenced ``` blocks). Saves ~15 KB on every code-free article.
|
||||
const hasCode = !!post.body && /(^|[^`])`[^`\n]+`|```/m.test(post.body);
|
||||
|
||||
// TOC: only show when there are >= 3 h2 headings.
|
||||
const h2Headings = headings.filter((h) => h.depth === 2);
|
||||
const showToc = h2Headings.length >= 3;
|
||||
|
||||
const personId = absoluteUrl('/about/#person');
|
||||
|
||||
const blogPosting = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: post.data.title,
|
||||
description: post.data.description,
|
||||
datePublished: post.data.date.toISOString(),
|
||||
...(post.data.updated && { dateModified: post.data.updated.toISOString() }),
|
||||
author: { '@id': personId },
|
||||
publisher: { '@id': personId },
|
||||
image: absoluteUrl(ogImageOptimized.src),
|
||||
url: absoluteUrl(articlePath(post)),
|
||||
keywords: post.data.tags.join(', '),
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': absoluteUrl(articlePath(post)),
|
||||
},
|
||||
};
|
||||
|
||||
const breadcrumbJsonLd = buildBreadcrumbJsonLd(trail);
|
||||
const personJsonLd = buildPersonJsonLd();
|
||||
---
|
||||
|
||||
<Base
|
||||
title={post.data.title}
|
||||
description={post.data.description}
|
||||
canonicalPath={articlePath(post)}
|
||||
ogImage={ogImageOptimized.src}
|
||||
ogImageAlt={post.data.thumbnail.alt}
|
||||
ogImageWidth={1200}
|
||||
ogImageHeight={630}
|
||||
ogType="article"
|
||||
preloadMono={hasCode}
|
||||
article={{
|
||||
publishedTime: post.data.date.toISOString(),
|
||||
modifiedTime: post.data.updated?.toISOString(),
|
||||
tags: post.data.tags,
|
||||
}}
|
||||
jsonLd={[blogPosting, breadcrumbJsonLd, personJsonLd]}
|
||||
>
|
||||
<article class="post">
|
||||
<header class="post-header">
|
||||
<Breadcrumbs items={breadcrumbTrail} />
|
||||
<p class="eyebrow">{post.data.projectPeriod ?? 'Article'}</p>
|
||||
<h1>{post.data.title}</h1>
|
||||
<p class="dek">{post.data.description}</p>
|
||||
<div class="post-meta">
|
||||
<time datetime={post.data.date.toISOString()}>
|
||||
{formatDate(post.data.date)}
|
||||
</time>
|
||||
{
|
||||
post.data.updated && (
|
||||
<>
|
||||
{' · '}
|
||||
<span>
|
||||
Updated{' '}
|
||||
<time datetime={post.data.updated.toISOString()}>
|
||||
{formatDate(post.data.updated)}
|
||||
</time>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{' · '}
|
||||
<span>{readingMinutes} min read</span>
|
||||
</div>
|
||||
<TagList tags={post.data.tags} />
|
||||
</header>
|
||||
|
||||
<div class="post-thumbnail">
|
||||
<Picture
|
||||
src={post.data.thumbnail.src}
|
||||
alt={post.data.thumbnail.alt}
|
||||
formats={['avif', 'webp']}
|
||||
fallbackFormat="jpg"
|
||||
widths={[640, 960, 1280, 1600, 1920]}
|
||||
sizes="(max-width: 700px) calc(100vw - 3rem), (max-width: 1100px) calc(100vw - 4rem), 56rem"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AtAGlance
|
||||
headingId={`at-a-glance-${post.id}`}
|
||||
role={post.data.role}
|
||||
projectPeriod={post.data.projectPeriod}
|
||||
stack={post.data.stack}
|
||||
scale={post.data.scale}
|
||||
outcome={post.data.outcome}
|
||||
links={post.data.links}
|
||||
/>
|
||||
|
||||
{
|
||||
showToc && (
|
||||
<nav class="post-toc" aria-label="On this page">
|
||||
<ol>
|
||||
{h2Headings.map((heading) => (
|
||||
<li>
|
||||
<a href={`#${heading.slug}`}>{heading.text}</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="prose">
|
||||
<Content />
|
||||
</div>
|
||||
|
||||
<PostMedia items={post.data.media} />
|
||||
|
||||
{
|
||||
related.length > 0 && (
|
||||
<section class="related-posts">
|
||||
<h2>Related articles</h2>
|
||||
<ArticleList posts={related} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
(previous || next) && (
|
||||
<nav class="post-nav" aria-label="Adjacent articles">
|
||||
<ul class="post-nav__list">
|
||||
{previous && (
|
||||
<li class="post-nav__prev">
|
||||
<a class="previous" href={articlePath(previous)} rel="prev">
|
||||
<span class="post-nav__label">
|
||||
<span aria-hidden="true">←</span> Previous
|
||||
</span>
|
||||
<span class="post-nav__title">{previous.data.title}</span>
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{next && (
|
||||
<li class="post-nav__next">
|
||||
<a class="next" href={articlePath(next)} rel="next">
|
||||
<span class="post-nav__label">
|
||||
Next <span aria-hidden="true">→</span>
|
||||
</span>
|
||||
<span class="post-nav__title">{next.data.title}</span>
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
</article>
|
||||
</Base>
|
||||
235
src/lib/site.ts
Normal file
235
src/lib/site.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import type { CollectionEntry } from 'astro:content';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { getImage } from 'astro:assets';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
|
||||
export const site = {
|
||||
name: 'Andras Schmelczer',
|
||||
title: 'Andras Schmelczer — Software engineer',
|
||||
description:
|
||||
'Andras Schmelczer writes about software systems, AI deployment, graphics, simulations, and tools.',
|
||||
url: 'https://schmelczer.dev',
|
||||
email: 'andras@schmelczer.dev',
|
||||
github: 'https://github.com/schmelczer',
|
||||
linkedin: 'https://www.linkedin.com/in/andras-schmelczer',
|
||||
cv: '/media/downloads/cv-andras-schmelczer.pdf',
|
||||
};
|
||||
|
||||
// Single source of truth for primary navigation. The Header consumes every
|
||||
// entry where `footerOnly` is falsy AND `href !== '/'` (Home is implicit via
|
||||
// the site title). The Footer renders every entry regardless. Items marked
|
||||
// `footerOnly: true` appear only in the Footer.
|
||||
export interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
footerOnly?: boolean;
|
||||
}
|
||||
|
||||
export const navItems: readonly NavItem[] = [
|
||||
{ href: '/', label: 'Home' },
|
||||
{ href: '/articles/', label: 'Articles' },
|
||||
{ href: '/projects/', label: 'Projects' },
|
||||
{ href: '/about/', label: 'About' },
|
||||
{ href: '/tags/', label: 'Tags' },
|
||||
{ href: '/rss.xml', label: 'RSS', footerOnly: true },
|
||||
];
|
||||
|
||||
export function formatDate(date: Date) {
|
||||
return new Intl.DateTimeFormat('en', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function formatDateShort(date: Date) {
|
||||
return new Intl.DateTimeFormat('en', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function yearOf(date: Date) {
|
||||
return new Intl.DateTimeFormat('en', { year: 'numeric' }).format(date);
|
||||
}
|
||||
|
||||
export function entrySlug(entry: { id: string }) {
|
||||
return entry.id.replace(/\.mdx?$/, '').replace(/\/index$/, '');
|
||||
}
|
||||
|
||||
export function articlePath(entry: { id: string } | string) {
|
||||
const slug = typeof entry === 'string' ? entry : entrySlug(entry);
|
||||
return `/articles/${slug}/`;
|
||||
}
|
||||
|
||||
export function tagSlug(tag: string) {
|
||||
return tag
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
export function tagPath(tag: string) {
|
||||
return `/tags/${tagSlug(tag)}/`;
|
||||
}
|
||||
|
||||
export function getAllTags(posts: { data: { tags: readonly string[] } }[]) {
|
||||
return [...new Set(posts.flatMap((post) => post.data.tags))].sort((a, b) =>
|
||||
a.localeCompare(b)
|
||||
);
|
||||
}
|
||||
|
||||
// Memoized published-posts loader. Build steps call `getPublishedPosts()`
|
||||
// from many pages (index, articles, RSS, sitemap, tag pages, post layouts).
|
||||
// Caching the promise means `getCollection('posts')` runs once per build.
|
||||
let publishedPostsPromise: Promise<CollectionEntry<'posts'>[]> | undefined;
|
||||
|
||||
export function getPublishedPosts(): Promise<CollectionEntry<'posts'>[]> {
|
||||
if (!publishedPostsPromise) {
|
||||
publishedPostsPromise = getCollection('posts').then((posts) =>
|
||||
posts
|
||||
.filter((post) => !post.data.draft)
|
||||
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
|
||||
);
|
||||
}
|
||||
return publishedPostsPromise;
|
||||
}
|
||||
|
||||
export async function getProjects(): Promise<CollectionEntry<'projects'>[]> {
|
||||
return (await getCollection('projects')).sort(
|
||||
(a, b) => b.data.sortDate.valueOf() - a.data.sortDate.valueOf()
|
||||
);
|
||||
}
|
||||
|
||||
export function adjacentPosts(
|
||||
posts: CollectionEntry<'posts'>[],
|
||||
current: CollectionEntry<'posts'>
|
||||
) {
|
||||
const index = posts.findIndex((post) => post.id === current.id);
|
||||
if (index === -1) return { previous: undefined, next: undefined };
|
||||
return {
|
||||
previous: index < posts.length - 1 ? posts[index + 1] : undefined,
|
||||
next: index > 0 ? posts[index - 1] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRelatedPosts(
|
||||
posts: CollectionEntry<'posts'>[],
|
||||
current: CollectionEntry<'posts'>,
|
||||
limit = 3
|
||||
) {
|
||||
const currentTags = new Set(current.data.tags);
|
||||
return posts
|
||||
.filter((post) => post.id !== current.id)
|
||||
.map((post) => ({
|
||||
post,
|
||||
overlap: post.data.tags.filter((tag) => currentTags.has(tag)).length,
|
||||
}))
|
||||
.filter(({ overlap }) => overlap > 0)
|
||||
.sort((a, b) => b.overlap - a.overlap)
|
||||
.slice(0, limit)
|
||||
.map(({ post }) => post);
|
||||
}
|
||||
|
||||
export function absoluteUrl(path: string) {
|
||||
return new URL(path, site.url).toString();
|
||||
}
|
||||
|
||||
// Canonical Person JSON-LD. Used by the home page and About page; both share
|
||||
// `@id` so search engines treat them as the same entity. Pass `extra` to
|
||||
// add or override fields (e.g. `jobTitle`, richer `description`).
|
||||
export function buildPersonJsonLd(extra?: Record<string, unknown>) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Person',
|
||||
'@id': absoluteUrl('/about/#person'),
|
||||
name: site.name,
|
||||
url: site.url,
|
||||
email: `mailto:${site.email}`,
|
||||
sameAs: [site.github, site.linkedin],
|
||||
description: site.description,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
// Responsive image config shared by entry listings. Centralized here so a
|
||||
// change to one breakpoint set is a single edit, not two component changes.
|
||||
export const ARTICLE_THUMBNAIL = {
|
||||
widths: [120, 180, 240, 320, 480],
|
||||
sizes: '(max-width: 700px) clamp(64px, 22vw, 80px), (max-width: 960px) 7rem, 8rem',
|
||||
};
|
||||
|
||||
export const PROJECT_THUMBNAIL = {
|
||||
widths: [240, 320, 480, 640, 800],
|
||||
sizes: '(max-width: 700px) 7rem, (max-width: 960px) clamp(7rem, 18vw, 9.5rem), 19rem',
|
||||
};
|
||||
|
||||
// Wraps `getImage` with the standard OG dimensions (1200x630 JPEG). Used by
|
||||
// Base.astro for the default OG image and by Post.astro for per-post
|
||||
// thumbnails. Keeps OG output consistent across the site.
|
||||
export function optimizeOgImage(src: ImageMetadata) {
|
||||
return getImage({
|
||||
src,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
format: 'jpg',
|
||||
});
|
||||
}
|
||||
|
||||
interface BreadcrumbCrumb {
|
||||
name: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface BreadcrumbInput {
|
||||
articles?: boolean;
|
||||
projects?: boolean;
|
||||
tagsIndex?: boolean;
|
||||
tag?: string;
|
||||
post?: CollectionEntry<'posts'>;
|
||||
}
|
||||
|
||||
// Builds the breadcrumb trail shared by JSON-LD (BreadcrumbList) and the
|
||||
// visible Breadcrumbs component. Home is always first. Flags append crumbs
|
||||
// in a fixed order: Articles → Tags → tag → Post (or Projects). A `tag`
|
||||
// implies both Articles and Tags so callers don't have to set every flag.
|
||||
export function buildBreadcrumbTrail({
|
||||
articles,
|
||||
projects,
|
||||
tagsIndex,
|
||||
tag,
|
||||
post,
|
||||
}: BreadcrumbInput): BreadcrumbCrumb[] {
|
||||
const trail: BreadcrumbCrumb[] = [{ name: 'Home', href: '/' }];
|
||||
if (articles || post || tagsIndex || tag) {
|
||||
trail.push({ name: 'Articles', href: '/articles/' });
|
||||
}
|
||||
if (tagsIndex || tag) {
|
||||
trail.push({ name: 'Tags', href: '/tags/' });
|
||||
}
|
||||
if (tag) {
|
||||
trail.push({ name: `#${tag}`, href: tagPath(tag) });
|
||||
}
|
||||
if (post) {
|
||||
trail.push({ name: post.data.title, href: articlePath(post) });
|
||||
}
|
||||
if (projects) {
|
||||
trail.push({ name: 'Projects', href: '/projects/' });
|
||||
}
|
||||
return trail;
|
||||
}
|
||||
|
||||
// Builds the schema.org BreadcrumbList JSON-LD object for a given trail.
|
||||
// Shared by every page that emits breadcrumb structured data.
|
||||
export function buildBreadcrumbJsonLd(trail: BreadcrumbCrumb[]) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: trail.map((crumb, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: crumb.name,
|
||||
item: absoluteUrl(crumb.href),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { html } from '../../types/html';
|
||||
import './contact.scss';
|
||||
|
||||
// @ts-ignore: injected by webpack
|
||||
const LAST_EDIT = new Date(__CURRENT_DATE__);
|
||||
|
||||
export const Contact = ({
|
||||
title,
|
||||
links,
|
||||
lastEditText,
|
||||
}: {
|
||||
title: string;
|
||||
links: Array<html>;
|
||||
lastEditText: string;
|
||||
}): html => `
|
||||
<section id="contact">
|
||||
|
||||
<h2>
|
||||
<a href="#contact">${title}</a>
|
||||
</h2>
|
||||
|
||||
<div class="links">
|
||||
${links.join('')}
|
||||
</div>
|
||||
|
||||
<aside>
|
||||
<p>
|
||||
${lastEditText}
|
||||
<time datetime="${LAST_EDIT.toISOString()}">${LAST_EDIT.toLocaleDateString()}</time>
|
||||
</p>
|
||||
</aside>
|
||||
</section>
|
||||
`;
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
@use '../../style/mixins' as *;
|
||||
|
||||
#contact {
|
||||
text-align: center;
|
||||
margin: var(--large-margin) auto 0 auto;
|
||||
|
||||
> h2 {
|
||||
margin-bottom: var(--normal-margin);
|
||||
|
||||
> a {
|
||||
@include title-font();
|
||||
@include title-fragment-link();
|
||||
}
|
||||
}
|
||||
|
||||
> .links {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
> aside {
|
||||
margin: var(--large-margin) auto 0 auto;
|
||||
padding-bottom: var(--line-height); // margin-bottom doesn't work in Firefox
|
||||
|
||||
> p {
|
||||
@include special-text-font();
|
||||
color: var(--normal-text-color);
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import { ResponsiveImage } from '../../../types/responsive-image';
|
||||
import { ImageViewer } from '../../image-viewer/image-viewer';
|
||||
import { Image } from '../../image/image.html';
|
||||
import { Figure } from '../figure';
|
||||
import './bordered-image.scss';
|
||||
|
||||
export class BorderedImage extends Figure {
|
||||
public constructor(
|
||||
options: {
|
||||
image: ResponsiveImage;
|
||||
alt: string;
|
||||
sizes?: string | null;
|
||||
isEagerLoaded?: boolean;
|
||||
},
|
||||
public imageViewer?: ImageViewer
|
||||
) {
|
||||
super(Image(options));
|
||||
}
|
||||
|
||||
protected async onClick() {
|
||||
this.imageViewer?.showImage(this.query('img') as HTMLImageElement);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import play from '../../../static/icons/play-button.svg';
|
||||
import { html } from '../../types/html';
|
||||
import './figure.scss';
|
||||
|
||||
export const generate = ({
|
||||
children,
|
||||
hasButton,
|
||||
invertButton,
|
||||
}: {
|
||||
children: html;
|
||||
hasButton: boolean;
|
||||
invertButton: boolean;
|
||||
}): html => `
|
||||
<div class="figure-container" tabindex=0 >
|
||||
${children}
|
||||
${
|
||||
hasButton
|
||||
? `<div class="start-button ${invertButton ? 'inverted' : ''}" >${play}</div>`
|
||||
: ''
|
||||
}
|
||||
</div>`;
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
@use '../../style/mixins' as *;
|
||||
|
||||
.figure-container {
|
||||
box-shadow: var(--inset-shadow);
|
||||
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
||||
> .start-button {
|
||||
@include image-button(var(--large-icon-size));
|
||||
@include absolute-center;
|
||||
@include square(calc(var(--large-icon-size) + var(--normal-margin) * 2));
|
||||
|
||||
&:hover > svg {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
> svg {
|
||||
border-radius: 1000px;
|
||||
@include blurred-background;
|
||||
transition:
|
||||
transform var(--transition-time),
|
||||
box-shadow var(--transition-time);
|
||||
}
|
||||
|
||||
&.inverted > svg {
|
||||
fill: var(--accent-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { html } from '../../types/html';
|
||||
import { PageElement } from '../page-element';
|
||||
import { generate } from './figure.html';
|
||||
|
||||
export abstract class Figure extends PageElement {
|
||||
public constructor(
|
||||
children: html,
|
||||
{
|
||||
hasButton = false,
|
||||
invertButton = false,
|
||||
}: {
|
||||
hasButton?: boolean;
|
||||
invertButton?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
super(generate({ children, hasButton, invertButton }));
|
||||
this.htmlRoot.addEventListener('click', this.onClick.bind(this));
|
||||
}
|
||||
|
||||
protected abstract onClick(): unknown;
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import loading from '../../../../static/icons/loading.svg';
|
||||
import { html } from '../../../types/html';
|
||||
import { ResponsiveImage } from '../../../types/responsive-image';
|
||||
import { Image } from '../../image/image.html';
|
||||
import './preview.scss';
|
||||
|
||||
export const generate = ({
|
||||
alt,
|
||||
poster,
|
||||
}: {
|
||||
alt: string;
|
||||
poster: ResponsiveImage;
|
||||
}): html =>
|
||||
`${Image({
|
||||
image: poster,
|
||||
alt,
|
||||
})}
|
||||
<div class="overlay">
|
||||
<div class="loading">${loading}</div>
|
||||
<iframe title="${alt}" allowfullscreen loading="lazy"></iframe>
|
||||
</div>`;
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
@use '../../../style/mixins' as *;
|
||||
|
||||
.figure-container {
|
||||
> .overlay {
|
||||
@include square(100%);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
pointer-events: none;
|
||||
|
||||
> .loading {
|
||||
@include square(var(--large-icon-size));
|
||||
@include absolute-center;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
> iframe {
|
||||
@include square(100%);
|
||||
border: none;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.loaded {
|
||||
> .start-button {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
> .overlay {
|
||||
pointer-events: all;
|
||||
|
||||
> .loading {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import { ResponsiveImage } from '../../../types/responsive-image';
|
||||
import { Figure } from '../figure';
|
||||
import { generate } from './preview.html';
|
||||
|
||||
export class Preview extends Figure {
|
||||
public constructor(
|
||||
poster: ResponsiveImage,
|
||||
private readonly url: string,
|
||||
alt: string
|
||||
) {
|
||||
super(generate({ poster, alt }), {
|
||||
hasButton: true,
|
||||
});
|
||||
this.url += '?portfolioView';
|
||||
}
|
||||
|
||||
protected onClick() {
|
||||
this.htmlRoot.classList.add('loaded');
|
||||
(this.query('iframe') as HTMLIFrameElement).src = this.url;
|
||||
}
|
||||
|
||||
protected initialize() {
|
||||
new IntersectionObserver((e) => {
|
||||
if (!e[0].isIntersecting) {
|
||||
this.htmlRoot.classList.remove('loaded');
|
||||
(this.query('iframe') as HTMLIFrameElement).src = '';
|
||||
}
|
||||
}).observe(this.htmlRoot.parentElement!);
|
||||
|
||||
super.initialize();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { ResponsiveImage } from '../../../types/responsive-image';
|
||||
import { url } from '../../../types/url';
|
||||
|
||||
export interface VideoParameters {
|
||||
mp4: url;
|
||||
webm: url;
|
||||
poster: ResponsiveImage;
|
||||
altText: string;
|
||||
invertButton?: boolean;
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { html } from '../../../types/html';
|
||||
import { Image } from '../../image/image.html';
|
||||
import { VideoParameters } from './video-parameters';
|
||||
import './video.scss';
|
||||
|
||||
export const generate = ({ webm, mp4, poster, altText }: VideoParameters): html => `
|
||||
${Image({
|
||||
image: poster,
|
||||
alt: altText,
|
||||
})}
|
||||
<video playsinline controls preload="none">
|
||||
<source src="${webm}" type="video/webm"/>
|
||||
<source src="${mp4}" type="video/mp4"/>
|
||||
</video>`;
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
@use '../../../style/mixins' as *;
|
||||
|
||||
.figure-container {
|
||||
&.loaded > .start-button,
|
||||
&:not(.loaded) > video {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
> video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { Figure } from '../figure';
|
||||
import { VideoParameters } from './video-parameters';
|
||||
import { generate } from './video.html';
|
||||
|
||||
export class Video extends Figure {
|
||||
public constructor(options: VideoParameters) {
|
||||
super(generate(options), {
|
||||
hasButton: true,
|
||||
invertButton: options.invertButton,
|
||||
});
|
||||
}
|
||||
|
||||
protected async onClick() {
|
||||
this.query('.start-button').style.visibility = 'hidden';
|
||||
this.htmlRoot.classList.add('loaded');
|
||||
|
||||
await (this.query('video') as HTMLVideoElement).play();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { html } from '../../types/html';
|
||||
import './header.scss';
|
||||
|
||||
export const generate = ({
|
||||
name,
|
||||
about,
|
||||
}: {
|
||||
name: string;
|
||||
about: Array<string>;
|
||||
}): html => `
|
||||
<header id="about">
|
||||
<div class="profile-picture">
|
||||
<img/>
|
||||
<div class="placeholder"></div>
|
||||
</div>
|
||||
|
||||
<h1>${name}</h1>
|
||||
|
||||
${about.map((t) => `<p>${t}</p>`).join('')}
|
||||
</header>
|
||||
`;
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
@use 'sass:math';
|
||||
@use '../../style/mixins' as *;
|
||||
|
||||
#about {
|
||||
box-shadow: var(--shadow);
|
||||
padding: var(--normal-margin);
|
||||
background-color: var(--accent-color);
|
||||
|
||||
@include on-small-screen {
|
||||
:focus:not(:hover) {
|
||||
outline: var(--very-light-text-color) solid var(--line-width);
|
||||
}
|
||||
|
||||
$img-size: 11rem;
|
||||
> .profile-picture {
|
||||
@include square($img-size);
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
> h1 {
|
||||
text-align: center;
|
||||
margin-top: calc(var(--line-height) + 4px);
|
||||
}
|
||||
}
|
||||
|
||||
@include on-large-screen {
|
||||
$img-size: 12.5rem;
|
||||
|
||||
width: var(--body-width);
|
||||
margin: calc(#{var(--normal-margin)} + #{$img-size} * 1 / 3) auto var(--large-margin)
|
||||
auto;
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
> .profile-picture {
|
||||
> .figure-container {
|
||||
@include square($img-size);
|
||||
|
||||
position: absolute;
|
||||
left: calc(#{math.div(-$img-size, 3)} - var(--normal-margin));
|
||||
top: calc(#{math.div(-$img-size, 3)} - var(--normal-margin));
|
||||
}
|
||||
|
||||
> .placeholder {
|
||||
@include square(calc(#{$img-size} * 2 / 3 - #{var(--normal-margin)}));
|
||||
box-sizing: content-box;
|
||||
float: left;
|
||||
margin: 0 0.75ex 0.2ex 0;
|
||||
}
|
||||
}
|
||||
|
||||
> p {
|
||||
text-align: justify;
|
||||
}
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: var(--very-light-text-color);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
> h1,
|
||||
> .profile-picture > .placeholder {
|
||||
@include title-font();
|
||||
}
|
||||
|
||||
> .profile-picture {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
.figure-container {
|
||||
&,
|
||||
> .image {
|
||||
border-radius: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> h1,
|
||||
> p,
|
||||
a {
|
||||
color: var(--very-light-text-color);
|
||||
|
||||
:focus:not(:hover) {
|
||||
outline: var(--very-light-text-color) solid var(--line-width);
|
||||
}
|
||||
}
|
||||
|
||||
> p {
|
||||
margin-top: var(--line-height);
|
||||
}
|
||||
|
||||
a {
|
||||
border-bottom: 2px solid var(--very-light-text-color);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { ResponsiveImage } from '../../types/responsive-image';
|
||||
import { BorderedImage } from '../figure/bordered-image/bordered-image';
|
||||
import { ImageViewer } from '../image-viewer/image-viewer';
|
||||
import { PageElement } from '../page-element';
|
||||
import { generate } from './header.html';
|
||||
import { ThemeSwitcher } from './theme-switcher/theme-switcher';
|
||||
|
||||
export class Header extends PageElement {
|
||||
public constructor({
|
||||
name,
|
||||
image,
|
||||
imageAltText,
|
||||
about,
|
||||
imageViewer,
|
||||
}: {
|
||||
name: string;
|
||||
image: ResponsiveImage;
|
||||
imageAltText: string;
|
||||
about: Array<string>;
|
||||
imageViewer?: ImageViewer;
|
||||
}) {
|
||||
super(
|
||||
generate({
|
||||
name,
|
||||
about,
|
||||
})
|
||||
);
|
||||
|
||||
this.attachElementByReplacing(
|
||||
'img',
|
||||
new BorderedImage(
|
||||
{
|
||||
image,
|
||||
alt: imageAltText,
|
||||
sizes: '(max-width: 924px) 11rem, 12.5rem',
|
||||
},
|
||||
imageViewer
|
||||
)
|
||||
);
|
||||
this.attachElement(new ThemeSwitcher());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { html } from '../../../types/html';
|
||||
import './theme-switcher.scss';
|
||||
|
||||
export const generate = (): html => `
|
||||
<input id="theme-switcher" aria-label="color-theme-switch" type="checkbox" name="switch-theme"/>
|
||||
`;
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
@use '../../../style/mixins' as *;
|
||||
|
||||
#theme-switcher {
|
||||
cursor: pointer;
|
||||
background-color: var(--accent-color);
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
display: block;
|
||||
|
||||
$size: var(--icon-size);
|
||||
width: calc(2 * #{$size});
|
||||
height: $size;
|
||||
$icon-size: calc(0.7 * #{$size});
|
||||
$margin: calc((#{$size} - #{$icon-size}) / 2);
|
||||
|
||||
border-radius: 1000px;
|
||||
box-shadow:
|
||||
inset 0 0 10px 2px rgba(0, 0, 0, 0.175),
|
||||
inset 0 0 1px rgba(0, 0, 0, 0.4);
|
||||
|
||||
@include on-large-screen {
|
||||
position: absolute;
|
||||
top: var(--normal-margin);
|
||||
right: var(--normal-margin);
|
||||
}
|
||||
|
||||
@include on-small-screen {
|
||||
position: relative;
|
||||
margin: var(--normal-margin) auto 0 auto;
|
||||
}
|
||||
|
||||
&:before {
|
||||
// moon + sun
|
||||
@include square($icon-size);
|
||||
}
|
||||
|
||||
&:after {
|
||||
// sun blocking moon
|
||||
@include square(calc(#{$icon-size} * 0.8));
|
||||
}
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
display: block;
|
||||
border-radius: 1000px;
|
||||
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
|
||||
transition:
|
||||
transform var(--transition-time),
|
||||
background-color var(--transition-time);
|
||||
}
|
||||
|
||||
&:not(:checked) {
|
||||
&:before {
|
||||
transform: translateY(-50%) translateX(calc(3 * #{$margin} + #{$icon-size}));
|
||||
|
||||
animation: shine 2s linear alternate infinite;
|
||||
background-color: var(--sun-color);
|
||||
@keyframes shine {
|
||||
from {
|
||||
filter: brightness(1.01);
|
||||
box-shadow: 0 0 4px 2px var(--sun-color);
|
||||
}
|
||||
|
||||
to {
|
||||
filter: brightness(1.2);
|
||||
box-shadow: 0 0 15px 2px var(--sun-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:after {
|
||||
transform: translateY(-50%) translateX(calc(#{$size} * 2 - #{$icon-size}));
|
||||
}
|
||||
}
|
||||
|
||||
&:checked {
|
||||
&:before {
|
||||
background-color: var(--normal-text-color);
|
||||
transform: translateY(-50%) translateX($margin);
|
||||
}
|
||||
|
||||
&:after {
|
||||
background-color: var(--accent-color);
|
||||
transform: translateY(-50%) translateX(calc(#{$margin} + #{$icon-size} * 0.33));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import { PageElement } from '../../page-element';
|
||||
import { generate } from './theme-switcher.html';
|
||||
|
||||
export class ThemeSwitcher extends PageElement {
|
||||
private static readonly localStorageKey = 'dark-mode';
|
||||
|
||||
public constructor() {
|
||||
super(generate());
|
||||
|
||||
const storedIsDark = ThemeSwitcher.loadFromLocalStorage();
|
||||
const isDark = storedIsDark ?? isSystemLevelDarkModeEnabled();
|
||||
|
||||
if (isDark) {
|
||||
(this.htmlRoot as HTMLInputElement).checked = true;
|
||||
turnOffAnimations();
|
||||
turnOnDarkMode();
|
||||
setTimeout(turnOnAnimations, 0);
|
||||
} else {
|
||||
turnOnLightMode();
|
||||
}
|
||||
|
||||
this.htmlRoot.onchange = this.handleThemeChange.bind(this);
|
||||
}
|
||||
|
||||
private handleThemeChange() {
|
||||
const isDark = (this.htmlRoot as HTMLInputElement).checked;
|
||||
if (isDark) {
|
||||
turnOnDarkMode();
|
||||
} else {
|
||||
turnOnLightMode();
|
||||
}
|
||||
|
||||
ThemeSwitcher.saveToLocalStorage(isDark);
|
||||
}
|
||||
|
||||
private static saveToLocalStorage(darkModeEnabled: boolean) {
|
||||
localStorage?.setItem(ThemeSwitcher.localStorageKey, JSON.stringify(darkModeEnabled));
|
||||
}
|
||||
|
||||
private static loadFromLocalStorage(): boolean | null {
|
||||
try {
|
||||
return JSON.parse(localStorage!.getItem(ThemeSwitcher.localStorageKey)!);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const isSystemLevelDarkModeEnabled = (): boolean =>
|
||||
matchMedia && matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
export const turnOnDarkMode = () =>
|
||||
document.documentElement.setAttribute('theme', 'dark');
|
||||
|
||||
export const turnOnLightMode = () =>
|
||||
document.documentElement.setAttribute('theme', 'light');
|
||||
|
||||
export const turnOnAnimations = () =>
|
||||
document.documentElement.setAttribute('animations', 'on');
|
||||
|
||||
export const turnOffAnimations = () =>
|
||||
document.documentElement.setAttribute('animations', 'off');
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import { url } from '../../types/url';
|
||||
import './image-anchor.scss';
|
||||
|
||||
export const ImageAnchorFactory =
|
||||
(
|
||||
svg: string,
|
||||
title: string,
|
||||
{ shouldDownload = false }: { shouldDownload?: boolean } = {}
|
||||
) =>
|
||||
(href: url) =>
|
||||
`<a rel="noopener" target="_blank" href="${href}" ${
|
||||
shouldDownload ? 'download' : ''
|
||||
} class="image-anchor">
|
||||
${svg}
|
||||
<span>${title}</span>
|
||||
</a>`;
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
@use '../../style/mixins' as *;
|
||||
|
||||
.image-anchor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:not(:first-child) {
|
||||
padding-top: var(--line-height);
|
||||
}
|
||||
|
||||
svg {
|
||||
@include square(var(--icon-size));
|
||||
margin-right: calc(var(--small-margin) / 2);
|
||||
}
|
||||
|
||||
span {
|
||||
@include link;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import { url } from '../../types/url';
|
||||
import './image-button.scss';
|
||||
|
||||
export const ImageButtonFactory =
|
||||
(
|
||||
svg: string,
|
||||
title: string,
|
||||
{ shouldDownload = false }: { shouldDownload?: boolean } = {}
|
||||
) =>
|
||||
(href?: url) =>
|
||||
`
|
||||
<button class="image-button">
|
||||
${
|
||||
href
|
||||
? `<a href="${href}" tabindex="-1" rel="noopener" target="_blank" ${
|
||||
shouldDownload ? 'download' : ''
|
||||
}>`
|
||||
: ''
|
||||
}
|
||||
<div class="svg-container">${svg}</div>
|
||||
<p>${title}</p>
|
||||
${href ? '</a>' : ''}
|
||||
</button>`;
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
@use '../../style/mixins' as *;
|
||||
|
||||
.image-button {
|
||||
@include image-button(var(--icon-size));
|
||||
padding: var(--small-margin) 8px;
|
||||
text-align: center;
|
||||
|
||||
.svg-container {
|
||||
position: relative;
|
||||
margin: auto;
|
||||
@include square(var(--icon-size));
|
||||
|
||||
> svg {
|
||||
transition:
|
||||
stroke var(--transition-time),
|
||||
transform var(--transition-time);
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.9rem;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import cancel from '../../../static/icons/cancel.svg';
|
||||
import { html } from '../../types/html';
|
||||
import './image-viewer.scss';
|
||||
|
||||
export const generate = (): html => `
|
||||
<div id="image-viewer">
|
||||
<img height="0" width="0" />
|
||||
<button id="cancel">${cancel}</button>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
@use '../../style/mixins' as *;
|
||||
|
||||
#image-viewer {
|
||||
@include center-children();
|
||||
@include blurred-background();
|
||||
|
||||
visibility: hidden;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
img {
|
||||
@include square(auto);
|
||||
box-shadow: var(--shadow);
|
||||
max-width: 80%;
|
||||
max-height: 80%;
|
||||
|
||||
@include on-small-screen {
|
||||
max-width: 95%;
|
||||
max-height: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
#cancel {
|
||||
@include image-button(var(--large-icon-size));
|
||||
@include square(calc(var(--large-icon-size) + var(--normal-margin) * 2));
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
|
||||
&:focus:not(:hover) {
|
||||
outline: var(--very-light-text-color) solid var(--line-width);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { PageElement } from '../page-element';
|
||||
import { generate } from './image-viewer.html';
|
||||
|
||||
export class ImageViewer extends PageElement {
|
||||
public constructor() {
|
||||
super(generate());
|
||||
|
||||
document.body.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
this.hideImage();
|
||||
}
|
||||
});
|
||||
|
||||
this.htmlRoot.addEventListener('click', this.hideImage.bind(this));
|
||||
}
|
||||
|
||||
public showImage(source: HTMLImageElement) {
|
||||
const image = this.query('img') as HTMLImageElement;
|
||||
image.src = '';
|
||||
image.src = source.src;
|
||||
image.height = source.height;
|
||||
image.width = source.width;
|
||||
this.htmlRoot.style.visibility = 'visible';
|
||||
}
|
||||
|
||||
private hideImage() {
|
||||
this.htmlRoot.style.visibility = 'hidden';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { html } from '../../types/html';
|
||||
import { ResponsiveImage } from '../../types/responsive-image';
|
||||
import './image.scss';
|
||||
|
||||
export const Image = ({
|
||||
image,
|
||||
alt,
|
||||
sizes = null,
|
||||
isEagerLoaded = false,
|
||||
}: {
|
||||
image: ResponsiveImage;
|
||||
alt: string;
|
||||
sizes?: string | null;
|
||||
isEagerLoaded?: boolean;
|
||||
}): html => `
|
||||
<div
|
||||
class="image"
|
||||
style="background-size: cover; background-image: url('${
|
||||
image.placeholder
|
||||
}'); aspect-ratio: ${image.width / image.height};"
|
||||
>
|
||||
<img
|
||||
${isEagerLoaded ? '' : 'loading="lazy"'}
|
||||
srcset="${image.srcSet}"
|
||||
${sizes ? `sizes="${sizes}"` : ''}
|
||||
src="${image.src}"
|
||||
alt="${alt}"
|
||||
/>
|
||||
</div>`;
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
.image {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
z-index: -1;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { url } from '../../types/url';
|
||||
import './link.scss';
|
||||
|
||||
export const Link = (title: string, href: url) =>
|
||||
`<a class="link" href="${href}">${title}</a>`;
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
@use '../../style/mixins' as *;
|
||||
|
||||
.link {
|
||||
border-bottom: 2px solid var(--special-text-color);
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { html } from '../../types/html';
|
||||
import './main.scss';
|
||||
|
||||
export const generate = (perspective: number): html => `
|
||||
<main style="perspective: ${perspective}px"></main>
|
||||
`;
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
@use '../../style/mixins' as *;
|
||||
|
||||
main {
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
scroll-behavior: smooth;
|
||||
|
||||
// chrome scrolling does not work on PC without this
|
||||
background: rgba(0, 0, 0, 0.005);
|
||||
|
||||
@media (hover: hover) {
|
||||
&::-webkit-scrollbar-track,
|
||||
&::-webkit-scrollbar {
|
||||
background-color: transparent;
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: var(--accent-color);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
}
|
||||
|
||||
> .blob {
|
||||
top: 0;
|
||||
position: absolute;
|
||||
width: 140px;
|
||||
border-radius: 1000px;
|
||||
transition: background-color var(--transition-time);
|
||||
|
||||
&:nth-child(odd) {
|
||||
background-color: #fff9e0;
|
||||
}
|
||||
|
||||
&:nth-child(even) {
|
||||
background-color: #ffd6d6;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include in-dark-mode {
|
||||
main > .blob {
|
||||
background-color: #2c477a;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
import { getHeight } from '../../helper/get-height';
|
||||
import { mix } from '../../helper/mix';
|
||||
import { Random } from '../../helper/random';
|
||||
import { sum } from '../../helper/sum';
|
||||
import { PageElement } from '../page-element';
|
||||
import { generate } from './main.html';
|
||||
|
||||
export class Main extends PageElement {
|
||||
private static readonly perspective = 5;
|
||||
private static readonly zMin = 6;
|
||||
private static readonly zMax = 40;
|
||||
private static readonly minHeight = 360;
|
||||
private static readonly maxHeight = 740;
|
||||
private static readonly minBlobCount = 20;
|
||||
private static readonly blobCountScaler = 0.035;
|
||||
private static readonly stableSeed = 42551;
|
||||
|
||||
private readonly topOffsetElementCount = 1;
|
||||
private readonly bottomOffsetElementCount = 1;
|
||||
|
||||
private random = new Random();
|
||||
private stableRandom = new Random();
|
||||
private blobs: Array<HTMLElement> = [];
|
||||
private contentHeight = 0;
|
||||
|
||||
constructor(...children: Array<PageElement | string>) {
|
||||
const actualChildren = children.map((c) =>
|
||||
c instanceof PageElement ? c : new PageElement(c)
|
||||
);
|
||||
|
||||
super(generate(Main.perspective), actualChildren);
|
||||
|
||||
actualChildren.forEach((c) => this.attachElement(c));
|
||||
}
|
||||
|
||||
protected initialize() {
|
||||
super.initialize();
|
||||
this.maintainYPosition();
|
||||
}
|
||||
|
||||
private maintainBlobCount() {
|
||||
const targetCount = Math.max(
|
||||
Main.minBlobCount,
|
||||
Math.ceil(window.innerWidth * Main.blobCountScaler)
|
||||
);
|
||||
const deltaCount = targetCount - this.blobs.length;
|
||||
|
||||
for (let i = 0; i < deltaCount; i++) {
|
||||
const blob = this.createBlob();
|
||||
this.blobs.push(blob);
|
||||
this.htmlRoot.appendChild(blob);
|
||||
}
|
||||
for (let i = 0; i < -deltaCount; i++) {
|
||||
const blob = this.blobs.pop();
|
||||
this.htmlRoot.removeChild(blob!);
|
||||
}
|
||||
}
|
||||
|
||||
private createBlob(): HTMLElement {
|
||||
const blob = document.createElement('div');
|
||||
blob.className = 'blob';
|
||||
|
||||
const z = this.random.inInterval(Main.zMin, Main.zMax);
|
||||
const halfScreenWidthAtFarPlane = Main.zMax / Main.perspective / 2 + 0.5;
|
||||
|
||||
const x = this.random.inInterval(
|
||||
mix(0, 0.5 - halfScreenWidthAtFarPlane, z / Main.zMax),
|
||||
mix(1, 0.5 + halfScreenWidthAtFarPlane, z / Main.zMax)
|
||||
);
|
||||
|
||||
blob.style.left = `${x * 100}%`;
|
||||
blob.style.transform = `translate3D(-50%, 0, ${-z}px) rotate(-20deg)`;
|
||||
|
||||
blob.style.zIndex = (-z).toFixed(0);
|
||||
blob.style.opacity = (1 - (z - Main.zMin) / (Main.zMax - Main.zMin)).toString();
|
||||
blob.style.height = `${this.random.inInterval(Main.minHeight, Main.maxHeight)}px`;
|
||||
|
||||
return blob;
|
||||
}
|
||||
|
||||
private maintainYPosition() {
|
||||
const siblings = Array.prototype.slice
|
||||
.call(this.htmlRoot.childNodes)
|
||||
.filter((n: HTMLElement) => !n.classList.contains('blob'));
|
||||
|
||||
const viewHeight = getHeight(this.htmlRoot);
|
||||
const currentContentHeight = this.htmlRoot.scrollHeight / viewHeight;
|
||||
|
||||
if (currentContentHeight !== this.contentHeight) {
|
||||
this.contentHeight = currentContentHeight;
|
||||
|
||||
this.maintainBlobCount();
|
||||
|
||||
const topOffset =
|
||||
sum(siblings.slice(0, this.topOffsetElementCount).map(getHeight)) / viewHeight;
|
||||
const bottomOffset =
|
||||
sum(siblings.slice(-this.bottomOffsetElementCount).map(getHeight)) / viewHeight;
|
||||
|
||||
this.stableRandom.seed = Main.stableSeed;
|
||||
|
||||
this.blobs.forEach((b) => {
|
||||
const y = this.stableRandom.inInterval(
|
||||
topOffset,
|
||||
this.contentHeight - bottomOffset - parseFloat(b.style.height) / viewHeight
|
||||
);
|
||||
|
||||
b.style.top = `${y * 100}%`;
|
||||
});
|
||||
}
|
||||
|
||||
requestAnimationFrame(this.maintainYPosition.bind(this));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import { html } from '../types/html';
|
||||
|
||||
export class PageElement {
|
||||
public readonly htmlRoot: HTMLElement;
|
||||
|
||||
public constructor(
|
||||
content: html,
|
||||
protected children: Array<PageElement> = []
|
||||
) {
|
||||
this.htmlRoot = PageElement.createElement(content);
|
||||
}
|
||||
|
||||
public attachToDOM(target: HTMLElement) {
|
||||
target.appendChild(this.htmlRoot);
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
protected initialize(): void {
|
||||
this.children.forEach((c) => c.initialize());
|
||||
}
|
||||
|
||||
protected query(query: string): HTMLElement {
|
||||
return this.htmlRoot.querySelector(query) as HTMLElement;
|
||||
}
|
||||
|
||||
protected attachElement(element: PageElement) {
|
||||
this.htmlRoot.appendChild(element.htmlRoot);
|
||||
this.children.push(element);
|
||||
}
|
||||
|
||||
protected attachElementByReplacing(query: string, element: PageElement) {
|
||||
const old = this.query(query);
|
||||
old.parentElement!.replaceChild(element.htmlRoot, old);
|
||||
this.children.push(element);
|
||||
}
|
||||
|
||||
private static createElement(from: html): HTMLElement {
|
||||
// won't work for all elements, eg.: <td>
|
||||
const element: HTMLElement = document.createElement('div');
|
||||
element.innerHTML = from;
|
||||
return element.firstElementChild as HTMLElement;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { html } from '../../types/html';
|
||||
import { Figure } from '../figure/figure';
|
||||
|
||||
export interface TimelineElementParameters {
|
||||
date: string;
|
||||
figure: Figure;
|
||||
title: string;
|
||||
description: string;
|
||||
more?: Array<html>;
|
||||
links: Array<html>;
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import info from '../../../static/icons/info.svg';
|
||||
import { titleToFragment } from '../../helper/title-to-fragment';
|
||||
import { html } from '../../types/html';
|
||||
import { ImageButtonFactory } from '../image-button/image-button.html';
|
||||
import { TimelineElementParameters } from './timeline-element-parameters';
|
||||
import './timeline-element.scss';
|
||||
|
||||
export const generate = (
|
||||
{ date, title, description, more, links }: TimelineElementParameters,
|
||||
showMore: string
|
||||
): html => `
|
||||
<article id="${titleToFragment(title).replace('#', '')}" class="timeline-element">
|
||||
<div class="line-container">
|
||||
<div class="line"></div>
|
||||
<p class="date">${date}</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="figure"></div>
|
||||
<div class="lower">
|
||||
<h2>
|
||||
<a href="${titleToFragment(title)}">${title}</a>
|
||||
</h2>
|
||||
|
||||
<p class="description">${description}</p>
|
||||
|
||||
${
|
||||
more
|
||||
? `
|
||||
<div class="more">
|
||||
${more.map((t) => `<p>${t}</p>`).join('')}
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
|
||||
<div class="buttons">
|
||||
${more ? ImageButtonFactory(info, showMore)() : ''}
|
||||
${links.join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
@use '../../style/mixins' as *;
|
||||
|
||||
@mixin q-dependent-line-container($q) {
|
||||
> .line {
|
||||
height: calc(#{$q} - var(--icon-size) / 2);
|
||||
|
||||
&:before {
|
||||
height: calc(100% - #{$q} - var(--icon-size) / 2);
|
||||
}
|
||||
|
||||
&:after {
|
||||
top: calc(#{$q} - var(--icon-size) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
> .date {
|
||||
top: calc(#{$q} - 0.5ch);
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-element {
|
||||
display: flex;
|
||||
width: var(--body-width);
|
||||
margin: auto;
|
||||
|
||||
> .line-container {
|
||||
position: relative;
|
||||
@include q-dependent-line-container(33%);
|
||||
transform: translate3d(0, 0, 0); // fix visual glitches in webkit
|
||||
|
||||
> .line {
|
||||
&,
|
||||
&:before {
|
||||
background: var(--accent-color);
|
||||
width: var(--line-width);
|
||||
}
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
&:before {
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&:after {
|
||||
@include square(var(--icon-size));
|
||||
border-radius: 1000px;
|
||||
border: var(--line-width) solid var(--accent-color);
|
||||
left: calc((var(--line-width) - var(--icon-size)) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
> .date {
|
||||
@include special-text-font();
|
||||
position: absolute;
|
||||
transform-origin: left center;
|
||||
transform: rotate(30deg) translateX(calc(var(--icon-size) / 2 + 6px))
|
||||
translateY(-10%);
|
||||
|
||||
padding-right: var(--normal-margin);
|
||||
}
|
||||
}
|
||||
|
||||
> .card {
|
||||
@include blurred-background();
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--blurred-card-color);
|
||||
transition: background-color var(--transition-time);
|
||||
|
||||
> .figure-container {
|
||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||
}
|
||||
|
||||
> .lower {
|
||||
> * {
|
||||
padding: 0 var(--normal-margin);
|
||||
margin-top: var(--small-margin);
|
||||
}
|
||||
|
||||
> h2 {
|
||||
text-align: center;
|
||||
margin-bottom: -6px;
|
||||
|
||||
> a {
|
||||
@include sub-title-font();
|
||||
@include title-fragment-link();
|
||||
}
|
||||
}
|
||||
|
||||
> .description {
|
||||
text-align: center;
|
||||
padding: 0 var(--large-margin);
|
||||
}
|
||||
|
||||
> .more {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
height: 0;
|
||||
transition: height var(--transition-time-long);
|
||||
|
||||
> p {
|
||||
margin-top: var(--line-height);
|
||||
}
|
||||
}
|
||||
|
||||
$border-width: 1px;
|
||||
|
||||
> .buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
border-top: $border-width solid var(--normal-text-color);
|
||||
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin-top: var(--small-margin);
|
||||
|
||||
> * {
|
||||
flex: 1;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-right: $border-width solid var(--normal-text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include on-large-screen {
|
||||
&:first-of-type > .line-container > .line {
|
||||
border-radius: 100px 100px 0 0;
|
||||
}
|
||||
|
||||
&:last-of-type > .line-container > .line:before {
|
||||
border-radius: 0 0 100px 100px;
|
||||
}
|
||||
|
||||
> .line-container {
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
&:not(:first-of-type) > .card {
|
||||
margin-top: var(--large-margin);
|
||||
}
|
||||
}
|
||||
|
||||
@include on-small-screen {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
> .line-container {
|
||||
@include q-dependent-line-container(50%);
|
||||
height: 150px;
|
||||
min-width: 64%;
|
||||
|
||||
> .date {
|
||||
transform: translateX(calc(var(--icon-size) / 2 + 12px)) translateY(-10%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
import { titleToFragment } from '../../helper/title-to-fragment';
|
||||
import { BorderedImage } from '../figure/bordered-image/bordered-image';
|
||||
import { ImageViewer } from '../image-viewer/image-viewer';
|
||||
import { PageElement } from '../page-element';
|
||||
import { TimelineElementParameters } from './timeline-element-parameters';
|
||||
import { generate } from './timeline-element.html';
|
||||
|
||||
export class TimelineElement extends PageElement {
|
||||
private isOpen = false;
|
||||
private readonly more?: HTMLElement;
|
||||
|
||||
public constructor(
|
||||
private timelineElement: TimelineElementParameters,
|
||||
private readonly showMore: string,
|
||||
private readonly showLess: string,
|
||||
imageViewer?: ImageViewer
|
||||
) {
|
||||
super(generate(timelineElement, showMore));
|
||||
|
||||
addEventListener('resize', this.handleResize.bind(this));
|
||||
|
||||
this.more = this.query('.more');
|
||||
if (this.more) {
|
||||
this.query('.buttons > .image-button').addEventListener(
|
||||
'click',
|
||||
this.toggleOpen.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
if (timelineElement.figure instanceof BorderedImage) {
|
||||
timelineElement.figure.imageViewer = imageViewer;
|
||||
}
|
||||
|
||||
this.attachElementByReplacing('.figure', timelineElement.figure);
|
||||
}
|
||||
|
||||
protected initialize(): void {
|
||||
super.initialize();
|
||||
|
||||
if (titleToFragment(this.timelineElement.title) === window.location.hash) {
|
||||
setTimeout(this.openMore.bind(this), 100);
|
||||
}
|
||||
}
|
||||
|
||||
private toggleOpen() {
|
||||
if (this.isOpen) {
|
||||
this.closeMore();
|
||||
} else {
|
||||
this.openMore();
|
||||
}
|
||||
}
|
||||
|
||||
private openMore() {
|
||||
if (!this.more) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isOpen = true;
|
||||
|
||||
this.query('.buttons > .image-button p').innerText = this.showLess;
|
||||
|
||||
const deltaHeight = this.more.scrollHeight;
|
||||
this.more.style.height = `${deltaHeight.toString()}px`;
|
||||
}
|
||||
|
||||
private closeMore() {
|
||||
if (!this.more) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isOpen = false;
|
||||
|
||||
this.query('.buttons > .image-button p').innerText = this.showMore;
|
||||
|
||||
this.more.style.height = '0';
|
||||
}
|
||||
|
||||
private handleResize() {
|
||||
if (!this.more) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isOpen) {
|
||||
this.more.style.height = 'auto';
|
||||
setTimeout(this.openMore.bind(this), 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import arrow from '../../../static/icons/arrow.svg';
|
||||
import { html } from '../../types/html';
|
||||
import './up-arrow-button.scss';
|
||||
|
||||
export const generate = (label: string): html => `
|
||||
<button id="up-arrow-button" class="down" aria-label="${label}">
|
||||
${arrow}
|
||||
</button>
|
||||
`;
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
@use '../../style/mixins' as *;
|
||||
|
||||
#up-arrow-button {
|
||||
@include blurred-background();
|
||||
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow);
|
||||
transition:
|
||||
opacity var(--transition-time),
|
||||
transform var(--transition-time-long);
|
||||
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
position: fixed;
|
||||
bottom: var(--small-margin);
|
||||
right: var(--normal-margin);
|
||||
padding: 0.25rem;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&.down {
|
||||
transform: rotate(180deg);
|
||||
&:hover {
|
||||
transform: scale(1.1) rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
@include square(var(--large-icon-size));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
import { PageElement } from '../page-element';
|
||||
import { generate } from './up-arrow-button.html';
|
||||
|
||||
export class UpArrowButton extends PageElement {
|
||||
private static readonly defaultTimeToLive = 3500;
|
||||
private static readonly interval = 50;
|
||||
private timeToLive = 0;
|
||||
|
||||
public constructor(
|
||||
private scrollTarget: PageElement,
|
||||
private turningThreshold: PageElement,
|
||||
label: string
|
||||
) {
|
||||
super(generate(label));
|
||||
|
||||
this.htmlRoot.addEventListener('click', this.scrollToTop.bind(this));
|
||||
|
||||
setInterval(() => {
|
||||
this.timeToLive = Math.max(0, this.timeToLive - UpArrowButton.interval);
|
||||
if (this.timeToLive == 0) {
|
||||
this.htmlRoot.style.opacity = '0';
|
||||
}
|
||||
}, UpArrowButton.interval);
|
||||
}
|
||||
|
||||
protected initialize() {
|
||||
this.scrollTarget.htmlRoot.addEventListener('scroll', () => {
|
||||
this.timeToLive = UpArrowButton.defaultTimeToLive;
|
||||
this.htmlRoot.style.opacity = '1';
|
||||
});
|
||||
|
||||
this.htmlRoot.addEventListener('mouseover', () => {
|
||||
this.timeToLive = UpArrowButton.defaultTimeToLive;
|
||||
this.htmlRoot.style.opacity = '1';
|
||||
});
|
||||
|
||||
new IntersectionObserver((e) => {
|
||||
if (e[0].isIntersecting) {
|
||||
this.htmlRoot.classList.remove('down');
|
||||
} else {
|
||||
this.htmlRoot.classList.add('down');
|
||||
}
|
||||
}).observe(this.turningThreshold.htmlRoot);
|
||||
|
||||
super.initialize();
|
||||
}
|
||||
|
||||
private scrollToTop() {
|
||||
this.scrollTarget.htmlRoot.scrollTo({
|
||||
top: this.htmlRoot.classList.contains('down')
|
||||
? this.scrollTarget.htmlRoot.scrollHeight
|
||||
: 0,
|
||||
left: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}
|
||||
33
src/pages/404.astro
Normal file
33
src/pages/404.astro
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
---
|
||||
import ArticleList from '../components/ArticleList.astro';
|
||||
import Page from '../layouts/Page.astro';
|
||||
import { getPublishedPosts } from '../lib/site';
|
||||
|
||||
const RECENT_ARTICLES = 5;
|
||||
|
||||
const posts = await getPublishedPosts();
|
||||
const recent = posts.slice(0, RECENT_ARTICLES);
|
||||
---
|
||||
|
||||
<Page
|
||||
title="This page doesn't exist"
|
||||
description="The link you followed may be broken, or the page may have moved."
|
||||
noindex
|
||||
>
|
||||
<div class="empty-state">
|
||||
<p>
|
||||
Try the <a href="/articles/">articles archive</a>, the
|
||||
<a href="/projects/">project index</a>, the
|
||||
<a href="/tags/">tag index</a>, or head back to the
|
||||
<a href="/">homepage</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section class="home-section">
|
||||
<div class="section-heading">
|
||||
<h2 id="recent-articles-404">Recent articles</h2>
|
||||
<a href="/articles/">All articles <span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
<ArticleList posts={recent} />
|
||||
</section>
|
||||
</Page>
|
||||
120
src/pages/about.astro
Normal file
120
src/pages/about.astro
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
---
|
||||
import ArticleList from '../components/ArticleList.astro';
|
||||
import Page from '../layouts/Page.astro';
|
||||
import {
|
||||
absoluteUrl,
|
||||
buildPersonJsonLd,
|
||||
getPublishedPosts,
|
||||
optimizeOgImage,
|
||||
site,
|
||||
} from '../lib/site';
|
||||
import defaultOg from '../assets/og-default.jpg';
|
||||
|
||||
const STARTING_POINTS = 4;
|
||||
|
||||
const posts = await getPublishedPosts();
|
||||
const startingPoints = posts
|
||||
.filter((post) => post.data.audience === 'recruiter-relevant')
|
||||
.sort((a, b) => (a.data.featuredOrder ?? 99) - (b.data.featuredOrder ?? 99))
|
||||
.slice(0, STARTING_POINTS);
|
||||
|
||||
const personImage = await optimizeOgImage(defaultOg);
|
||||
|
||||
// Canonical Person JSON-LD. Other pages reference this entity by @id.
|
||||
const personJsonLd = buildPersonJsonLd({
|
||||
jobTitle: 'Software Engineer',
|
||||
description:
|
||||
'Software engineer with an MSc in Computer Science working on AI/ML systems, web platforms, graphics, simulations, and tools.',
|
||||
knowsAbout: [
|
||||
'Software architecture',
|
||||
'AI/ML systems',
|
||||
'Web platforms',
|
||||
'Computer graphics',
|
||||
'Simulations',
|
||||
'Data visualization',
|
||||
],
|
||||
image: absoluteUrl(personImage.src),
|
||||
mainEntityOfPage: absoluteUrl('/about/'),
|
||||
});
|
||||
---
|
||||
|
||||
<Page
|
||||
title="About"
|
||||
description="A direct summary of my background, technical interests, and best starting points."
|
||||
jsonLd={personJsonLd}
|
||||
ogType="profile"
|
||||
>
|
||||
<div class="prose">
|
||||
<p>
|
||||
I am Andras Schmelczer, a software engineer with an MSc in Computer Science and more
|
||||
than six years of professional engineering experience. My work spans AI/ML systems,
|
||||
web platforms, graphics, simulations, and tools, and I like projects where
|
||||
architecture, constraints, and product usefulness all matter.
|
||||
</p>
|
||||
<p>
|
||||
I am especially interested in architecting and building large-scale systems,
|
||||
particularly around AI/ML. In my own time I also return to shaders, data
|
||||
visualization, simulations, and occasionally microcontrollers. The
|
||||
<a href="/articles/">articles</a> and <a href="/projects/">projects</a> indexes are the
|
||||
best way to understand that range; the CV and contact links are here when a direct summary
|
||||
is more useful.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section class="about-section facts">
|
||||
<h2 id="quick-facts">Quick Facts</h2>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Focus</dt>
|
||||
<dd>
|
||||
Software systems, AI deployment, architecture, graphics, data visualization
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Education</dt>
|
||||
<dd>MSc in Computer Science</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Contact</dt>
|
||||
<dd>
|
||||
<address>
|
||||
<a href={`mailto:${site.email}`}>{site.email}</a>
|
||||
</address>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Links</dt>
|
||||
<dd class="about-links">
|
||||
<a href={site.cv} rel="noopener">CV</a>
|
||||
<a href={site.github} rel="noopener me">GitHub</a>
|
||||
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="about-section">
|
||||
<div class="section-heading">
|
||||
<h2 id="best-starting-points">Best Starting Points</h2>
|
||||
<a href="/articles/">Browse all articles <span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
<ArticleList posts={startingPoints} />
|
||||
</section>
|
||||
|
||||
<section class="about-section facts">
|
||||
<h2 id="working-style">How I Work</h2>
|
||||
<div class="prose">
|
||||
<p>
|
||||
I am strongest when I can reason through a system end to end: the data model, the
|
||||
API shape, the performance constraints, the operational risks, and the human path
|
||||
through the tool. The projects on this site are older and newer examples of that
|
||||
habit.
|
||||
</p>
|
||||
<p>
|
||||
I care about simple interfaces over accidental complexity, and I prefer technical
|
||||
depth that can be explained clearly. That is why this site is structured around
|
||||
articles rather than screenshots and slogans.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</Page>
|
||||
16
src/pages/articles/[slug].astro
Normal file
16
src/pages/articles/[slug].astro
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
import Post from '../../layouts/Post.astro';
|
||||
import { entrySlug, getPublishedPosts } from '../../lib/site';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getPublishedPosts();
|
||||
return posts.map((post) => ({
|
||||
params: { slug: entrySlug(post) },
|
||||
props: { post },
|
||||
}));
|
||||
}
|
||||
|
||||
const { post } = Astro.props;
|
||||
---
|
||||
|
||||
<Post post={post} />
|
||||
74
src/pages/articles/index.astro
Normal file
74
src/pages/articles/index.astro
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
---
|
||||
import ArticleList from '../../components/ArticleList.astro';
|
||||
import TagList from '../../components/TagList.astro';
|
||||
import Page from '../../layouts/Page.astro';
|
||||
import {
|
||||
absoluteUrl,
|
||||
articlePath,
|
||||
buildBreadcrumbJsonLd,
|
||||
buildBreadcrumbTrail,
|
||||
getAllTags,
|
||||
getPublishedPosts,
|
||||
optimizeOgImage,
|
||||
site,
|
||||
yearOf,
|
||||
} from '../../lib/site';
|
||||
|
||||
const description =
|
||||
'Technical articles and notes about projects, systems, AI deployment, graphics, simulations, and tools.';
|
||||
|
||||
const posts = await getPublishedPosts();
|
||||
const years = [...new Set(posts.map((post) => yearOf(post.data.date)))];
|
||||
const tags = getAllTags(posts);
|
||||
|
||||
const postOgImages = await Promise.all(
|
||||
posts.map((post) => optimizeOgImage(post.data.thumbnail.src))
|
||||
);
|
||||
|
||||
const personId = absoluteUrl('/about/#person');
|
||||
const blogJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Blog',
|
||||
name: `${site.name} — Articles`,
|
||||
url: absoluteUrl('/articles/'),
|
||||
description,
|
||||
publisher: { '@id': personId },
|
||||
blogPost: posts.map((post, index) => ({
|
||||
'@type': 'BlogPosting',
|
||||
headline: post.data.title,
|
||||
description: post.data.description,
|
||||
datePublished: post.data.date.toISOString(),
|
||||
url: absoluteUrl(articlePath(post)),
|
||||
author: { '@id': personId },
|
||||
image: absoluteUrl(postOgImages[index].src),
|
||||
keywords: post.data.tags.join(', '),
|
||||
})),
|
||||
};
|
||||
|
||||
const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ articles: true }));
|
||||
|
||||
const jsonLd = [blogJsonLd, breadcrumbJsonLd];
|
||||
---
|
||||
|
||||
<Page title="Articles" description={description} jsonLd={jsonLd}>
|
||||
<nav id="tag-filter" class="tag-filter" aria-label="Browse by tag">
|
||||
<span>Browse by tag</span>
|
||||
<TagList tags={tags} />
|
||||
</nav>
|
||||
|
||||
{
|
||||
years.map((year) => {
|
||||
const postsForYear = posts.filter((post) => yearOf(post.data.date) === year);
|
||||
return (
|
||||
<section class="archive-year">
|
||||
<h2 id={`year-${year}`}>{year}</h2>
|
||||
<ArticleList
|
||||
posts={postsForYear}
|
||||
showYear={false}
|
||||
eagerFirstThumbnail={year === years[0]}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
})
|
||||
}
|
||||
</Page>
|
||||
69
src/pages/index.astro
Normal file
69
src/pages/index.astro
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
---
|
||||
import ArticleList from '../components/ArticleList.astro';
|
||||
import ProjectList from '../components/ProjectList.astro';
|
||||
import TagList from '../components/TagList.astro';
|
||||
import Base from '../layouts/Base.astro';
|
||||
import {
|
||||
buildPersonJsonLd,
|
||||
getAllTags,
|
||||
getProjects,
|
||||
getPublishedPosts,
|
||||
} from '../lib/site';
|
||||
|
||||
const LATEST_ARTICLES = 5;
|
||||
|
||||
const posts = await getPublishedPosts();
|
||||
const latestPosts = posts.slice(0, LATEST_ARTICLES);
|
||||
const projects = await getProjects();
|
||||
const selectedProjects = projects.filter((project) => project.data.selected);
|
||||
const tags = getAllTags(posts);
|
||||
|
||||
// Reference the canonical Person (defined on /about/) by @id.
|
||||
const personJsonLd = buildPersonJsonLd();
|
||||
---
|
||||
|
||||
<Base jsonLd={personJsonLd}>
|
||||
<section class="home-intro">
|
||||
<p class="eyebrow">
|
||||
Software systems, AI deployment, graphics, simulations, and tools
|
||||
</p>
|
||||
<h1>
|
||||
Andras Schmelczer writes about building software that has to work under real
|
||||
constraints.
|
||||
</h1>
|
||||
<p>
|
||||
I am a software engineer with an MSc in Computer Science. This site is mostly a
|
||||
notebook of technical articles and project writeups; the hiring details live on the
|
||||
<a href="/about/">About</a> page.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="home-section">
|
||||
<div class="section-heading">
|
||||
<h2 id="latest-articles">Latest Articles</h2>
|
||||
<a href="/articles/"
|
||||
>All {posts.length}
|
||||
{posts.length === 1 ? 'article' : 'articles'}
|
||||
<span aria-hidden="true">→</span></a
|
||||
>
|
||||
</div>
|
||||
<ArticleList posts={latestPosts} />
|
||||
</section>
|
||||
|
||||
<section class="home-section">
|
||||
<div class="section-heading">
|
||||
<h2 id="home-selected-projects">Selected Projects</h2>
|
||||
<a href="/projects/">All projects <span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
<ProjectList projects={selectedProjects} />
|
||||
</section>
|
||||
|
||||
<section class="home-section">
|
||||
<div class="section-heading">
|
||||
<h2 id="browse-by-topic">Browse by Topic</h2>
|
||||
</div>
|
||||
<div class="tag-cloud">
|
||||
<TagList tags={tags} />
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
42
src/pages/projects/index.astro
Normal file
42
src/pages/projects/index.astro
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
---
|
||||
import ProjectList from '../../components/ProjectList.astro';
|
||||
import Page from '../../layouts/Page.astro';
|
||||
import {
|
||||
absoluteUrl,
|
||||
buildBreadcrumbJsonLd,
|
||||
buildBreadcrumbTrail,
|
||||
getProjects,
|
||||
site,
|
||||
} from '../../lib/site';
|
||||
|
||||
const description =
|
||||
'A compact index of systems, tools, simulations, graphics experiments, games, and older work.';
|
||||
|
||||
const projects = await getProjects();
|
||||
const selected = projects.filter((project) => project.data.selected);
|
||||
const older = projects.filter((project) => !project.data.selected);
|
||||
|
||||
const collectionJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: `${site.name} — Projects`,
|
||||
url: absoluteUrl('/projects/'),
|
||||
description,
|
||||
};
|
||||
|
||||
const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ projects: true }));
|
||||
|
||||
const jsonLd = [collectionJsonLd, breadcrumbJsonLd];
|
||||
---
|
||||
|
||||
<Page title="Projects" description={description} jsonLd={jsonLd}>
|
||||
<section class="project-section">
|
||||
<h2 id="selected-projects">Selected Projects</h2>
|
||||
<ProjectList projects={selected} eagerFirstThumbnail />
|
||||
</section>
|
||||
|
||||
<section class="project-section">
|
||||
<h2 id="older-projects">Older and Smaller Projects</h2>
|
||||
<ProjectList projects={older} />
|
||||
</section>
|
||||
</Page>
|
||||
103
src/pages/rss.xml.ts
Normal file
103
src/pages/rss.xml.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import rss from '@astrojs/rss';
|
||||
import type { APIRoute } from 'astro';
|
||||
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
|
||||
import { render } from 'astro:content';
|
||||
import ogDefault from '../assets/og-default.jpg';
|
||||
import {
|
||||
absoluteUrl,
|
||||
articlePath,
|
||||
getPublishedPosts,
|
||||
optimizeOgImage,
|
||||
site,
|
||||
} from '../lib/site';
|
||||
|
||||
// Escape characters that would otherwise break XML parsing inside text nodes
|
||||
// (the `customData` strings are inserted as-is by @astrojs/rss).
|
||||
function escapeXml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Rewrite root-relative URLs to absolute so RSS readers (which load the HTML
|
||||
// outside any page context) can still resolve assets and links.
|
||||
function absolutizeUrls(html: string, baseUrl: string) {
|
||||
return html
|
||||
.replace(/(<(?:a|link)\b[^>]*\bhref=")(\/[^"]*)(")/g, `$1${baseUrl}$2$3`)
|
||||
.replace(
|
||||
/(<(?:img|source|video|audio)\b[^>]*\bsrc=")(\/[^"]*)(")/g,
|
||||
`$1${baseUrl}$2$3`
|
||||
)
|
||||
.replace(/(\bsrcset=")([^"]+)(")/g, (_, prefix, value, suffix) => {
|
||||
const rewritten = value
|
||||
.split(',')
|
||||
.map((candidate: string) => {
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed.startsWith('/')) return trimmed;
|
||||
return baseUrl + trimmed;
|
||||
})
|
||||
.join(', ');
|
||||
return prefix + rewritten + suffix;
|
||||
});
|
||||
}
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
const posts = await getPublishedPosts();
|
||||
const feedUrl = absoluteUrl('/rss.xml');
|
||||
const channelImage = await optimizeOgImage(ogDefault);
|
||||
const channelImageUrl = absoluteUrl(channelImage.src);
|
||||
const creator = escapeXml(site.name);
|
||||
const container = await AstroContainer.create();
|
||||
|
||||
const items = await Promise.all(
|
||||
posts.map(async (post) => {
|
||||
const url = absoluteUrl(articlePath(post));
|
||||
const updated = post.data.updated
|
||||
? `<atom:updated>${post.data.updated.toISOString()}</atom:updated>`
|
||||
: '';
|
||||
const { Content } = await render(post);
|
||||
const html = await container.renderToString(Content);
|
||||
// @astrojs/rss XML-escapes the `content` string and emits it inside
|
||||
// <content:encoded>. RSS readers decode the escaped HTML the same as if
|
||||
// it were wrapped in CDATA, so escaping is fine and safer to author.
|
||||
const content = absolutizeUrls(html, site.url);
|
||||
return {
|
||||
title: post.data.title,
|
||||
description: post.data.description,
|
||||
pubDate: post.data.date,
|
||||
link: url,
|
||||
author: `${site.email} (${site.name})`,
|
||||
categories: [...post.data.tags],
|
||||
content,
|
||||
customData: [`<dc:creator>${creator}</dc:creator>`, updated]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return rss({
|
||||
title: site.name,
|
||||
description: site.description,
|
||||
site: site.url,
|
||||
xmlns: {
|
||||
atom: 'http://www.w3.org/2005/Atom',
|
||||
content: 'http://purl.org/rss/1.0/modules/content/',
|
||||
dc: 'http://purl.org/dc/elements/1.1/',
|
||||
},
|
||||
customData: [
|
||||
'<language>en-us</language>',
|
||||
`<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>`,
|
||||
`<atom:link href="${feedUrl}" rel="self" type="application/rss+xml" />`,
|
||||
'<image>',
|
||||
` <url>${channelImageUrl}</url>`,
|
||||
` <title>${escapeXml(site.name)}</title>`,
|
||||
` <link>${site.url}</link>`,
|
||||
'</image>',
|
||||
].join('\n'),
|
||||
items,
|
||||
});
|
||||
};
|
||||
49
src/pages/tags/[tag].astro
Normal file
49
src/pages/tags/[tag].astro
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
---
|
||||
import ArticleList from '../../components/ArticleList.astro';
|
||||
import Breadcrumbs from '../../components/Breadcrumbs.astro';
|
||||
import TagList from '../../components/TagList.astro';
|
||||
import Page from '../../layouts/Page.astro';
|
||||
import {
|
||||
buildBreadcrumbJsonLd,
|
||||
buildBreadcrumbTrail,
|
||||
getAllTags,
|
||||
getPublishedPosts,
|
||||
tagSlug,
|
||||
} from '../../lib/site';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getPublishedPosts();
|
||||
return getAllTags(posts).map((tag) => ({
|
||||
params: { tag: tagSlug(tag) },
|
||||
props: { tag },
|
||||
}));
|
||||
}
|
||||
|
||||
const { tag } = Astro.props;
|
||||
const posts = await getPublishedPosts();
|
||||
const allTags = getAllTags(posts);
|
||||
const filteredPosts = posts.filter((post) => post.data.tags.some((t) => t === tag));
|
||||
const title = `Articles tagged "${tag}"`;
|
||||
const trail = buildBreadcrumbTrail({ tag });
|
||||
const visibleTrail = trail.map((c, i) => ({
|
||||
label: c.name,
|
||||
href: i === trail.length - 1 ? undefined : c.href,
|
||||
}));
|
||||
const breadcrumbJsonLd = buildBreadcrumbJsonLd(trail);
|
||||
---
|
||||
|
||||
<Page
|
||||
title={title}
|
||||
description={`Project articles and technical notes filed under #${tag}.`}
|
||||
jsonLd={breadcrumbJsonLd}
|
||||
noindex
|
||||
>
|
||||
<Breadcrumbs slot="breadcrumbs" items={visibleTrail} />
|
||||
<nav class="tag-filter" aria-label="Browse other tags">
|
||||
<span>Browse other tags</span>
|
||||
<TagList tags={allTags} currentTag={tag} />
|
||||
</nav>
|
||||
|
||||
<h2 class="sr-only">Articles</h2>
|
||||
<ArticleList posts={filteredPosts} eagerFirstThumbnail />
|
||||
</Page>
|
||||
45
src/pages/tags/index.astro
Normal file
45
src/pages/tags/index.astro
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
---
|
||||
import TagList from '../../components/TagList.astro';
|
||||
import Page from '../../layouts/Page.astro';
|
||||
import {
|
||||
absoluteUrl,
|
||||
buildBreadcrumbJsonLd,
|
||||
buildBreadcrumbTrail,
|
||||
getAllTags,
|
||||
getPublishedPosts,
|
||||
site,
|
||||
} from '../../lib/site';
|
||||
|
||||
const description = 'Every tag used across the articles archive.';
|
||||
|
||||
const posts = await getPublishedPosts();
|
||||
const tags = getAllTags(posts);
|
||||
|
||||
const tagCounts: Record<string, number> = {};
|
||||
for (const post of posts) {
|
||||
for (const tag of post.data.tags) {
|
||||
tagCounts[tag] = (tagCounts[tag] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const collectionJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: `${site.name} — Tags`,
|
||||
url: absoluteUrl('/tags/'),
|
||||
description,
|
||||
};
|
||||
|
||||
const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ tagsIndex: true }));
|
||||
|
||||
const jsonLd = [collectionJsonLd, breadcrumbJsonLd];
|
||||
---
|
||||
|
||||
<Page title="Tags" description={description} jsonLd={jsonLd}>
|
||||
<p class="dek">
|
||||
{posts.length}
|
||||
{posts.length === 1 ? 'article' : 'articles'} across {tags.length}
|
||||
{tags.length === 1 ? 'tag' : 'tags'}.
|
||||
</p>
|
||||
<TagList tags={tags} counts={tagCounts} />
|
||||
</Page>
|
||||
26
src/scripts/theme-init.js
Normal file
26
src/scripts/theme-init.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// FOUC prevention: runs in <head> before paint. Sets the theme on <html> so
|
||||
// the page renders with the right colors on first load. The theme switcher
|
||||
// button is wired up separately, after it is parsed, in Header.astro.
|
||||
//
|
||||
// Keep THEME_BG values in sync with --color-bg in global.css. They drive the
|
||||
// browser-chrome <meta name="theme-color"> so it follows the user's manual
|
||||
// toggle (the static media-keyed metas only tracked OS preference).
|
||||
(function () {
|
||||
document.documentElement.classList.remove('no-js');
|
||||
document.documentElement.classList.add('js');
|
||||
|
||||
var STORAGE_KEY = 'theme';
|
||||
var THEME_BG = { light: '#fbfaf7', dark: '#151514' };
|
||||
var saved = null;
|
||||
try {
|
||||
var value = localStorage.getItem(STORAGE_KEY);
|
||||
if (value === 'light' || value === 'dark') saved = value;
|
||||
} catch (e) {}
|
||||
var theme = saved || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
var themeColorMetas = document.querySelectorAll('meta[name="theme-color"]');
|
||||
for (var i = 0; i < themeColorMetas.length; i += 1) {
|
||||
themeColorMetas[i].setAttribute('content', THEME_BG[theme]);
|
||||
}
|
||||
})();
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
/* comfortaa-regular - latin */
|
||||
@font-face {
|
||||
font-family: 'Comfortaa';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src:
|
||||
local(''),
|
||||
url('../../static/fonts/comfortaa-v40-latin-regular.woff2') format('woff2'),
|
||||
/* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
url('../../static/fonts/comfortaa-v40-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
}
|
||||
|
||||
/* open-sans-regular - latin */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src:
|
||||
local(''),
|
||||
url('../../static/fonts/open-sans-v34-latin-regular.woff2') format('woff2'),
|
||||
/* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
url('../../static/fonts/open-sans-v34-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
}
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
@use 'sass:math';
|
||||
|
||||
$breakpoint-width: 700px !default;
|
||||
|
||||
@mixin on-small-screen() {
|
||||
@media (max-width: ($breakpoint-width - 1px)) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin on-large-screen() {
|
||||
@media (min-width: $breakpoint-width) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin in-dark-mode() {
|
||||
html[theme='dark'] {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin image-button($icon-size) {
|
||||
display: block;
|
||||
box-sizing: content-box;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover svg {
|
||||
transform: translateX(-50%) translateY(-50%) scale(1.15);
|
||||
}
|
||||
|
||||
svg {
|
||||
@include absolute-center;
|
||||
@include square($icon-size);
|
||||
transition: transform var(--transition-time);
|
||||
transform-origin: center center;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin title-fragment-link() {
|
||||
position: relative;
|
||||
|
||||
&:before {
|
||||
content: '#';
|
||||
position: absolute;
|
||||
left: -0.5ch;
|
||||
top: 50%;
|
||||
opacity: 0;
|
||||
transform: translateX(-100%) translateY(-50%);
|
||||
transition: opacity var(--transition-time);
|
||||
}
|
||||
|
||||
&:hover:before {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin center-children() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@mixin absolute-center() {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translateX(-50%) translateY(-50%);
|
||||
}
|
||||
|
||||
@mixin blurred-background() {
|
||||
backdrop-filter: blur(var(--blur-radius));
|
||||
-webkit-backdrop-filter: blur(var(--blur-radius));
|
||||
|
||||
@supports not (
|
||||
(backdrop-filter: blur(var(--blur-radius))) or
|
||||
(-webkit-backdrop-filter: blur(var(--blur-radius)))
|
||||
) {
|
||||
background-color: var(--card-color);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin square($size) {
|
||||
width: $size;
|
||||
height: $size;
|
||||
}
|
||||
|
||||
@mixin title-font() {
|
||||
font:
|
||||
400 3rem 'Comfortaa',
|
||||
sans-serif;
|
||||
color: var(--normal-text-color);
|
||||
line-height: 1;
|
||||
|
||||
@include on-small-screen {
|
||||
font-size: 3rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin sub-title-font() {
|
||||
font:
|
||||
400 1.75rem 'Comfortaa',
|
||||
sans-serif;
|
||||
color: var(--normal-text-color);
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
@mixin main-font() {
|
||||
font:
|
||||
400 1.1rem 'Open Sans',
|
||||
sans-serif;
|
||||
color: var(--normal-text-color);
|
||||
line-height: 1.8;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
@mixin special-text-font() {
|
||||
font:
|
||||
400 1rem 'Open Sans',
|
||||
sans-serif;
|
||||
color: var(--special-text-color);
|
||||
hyphens: auto;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@mixin link {
|
||||
$border-shift: 10px;
|
||||
$line-width: 2px;
|
||||
|
||||
@include special-text-font();
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
|
||||
padding: 0 3px $line-width 0;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&:before {
|
||||
width: calc(100% + #{$border-shift});
|
||||
border-bottom: $line-width dashed var(--accent-color);
|
||||
transition: transform var(--transition-time);
|
||||
}
|
||||
|
||||
&:after {
|
||||
width: 100%;
|
||||
height: $line-width;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--card-color) 0,
|
||||
transparent 4px,
|
||||
transparent calc(100% - 4px),
|
||||
var(--card-color) 100%
|
||||
);
|
||||
}
|
||||
|
||||
&:hover:before {
|
||||
transform: translateX(-$border-shift);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
@use 'mixins' as *;
|
||||
|
||||
:root {
|
||||
--transition-time: 200ms;
|
||||
--transition-time-long: 350ms;
|
||||
--line-width: 4px;
|
||||
--line-height: 1.125rem;
|
||||
--accent-color: #b7455e;
|
||||
--sun-color: #f7f78c;
|
||||
--very-light-text-color: #ffffff;
|
||||
--background: #ffffff;
|
||||
--normal-text-color: #31343f;
|
||||
--card-color: #ffffff;
|
||||
--blurred-card-color: transparent;
|
||||
--blur-radius: 12px;
|
||||
--special-text-color: var(--accent-color);
|
||||
--inset-shadow: inset 0 0 4px 1px rgba(0, 0, 0, 0.05), inset 0 0 5px rgba(0, 0, 0, 0.2);
|
||||
--border-radius: 0.85rem;
|
||||
|
||||
--large-margin: 4.6rem;
|
||||
--normal-margin: 2.8rem;
|
||||
--small-margin: 1.4rem;
|
||||
--shadow: 0 0 5px 2px rgba(0, 0, 0, 0.15), 0 0 1px rgba(0, 0, 0, 0.2);
|
||||
--icon-size: 2.8rem;
|
||||
--large-icon-size: 3.75rem;
|
||||
--body-width: min(80%, 60rem);
|
||||
}
|
||||
|
||||
@include on-small-screen {
|
||||
:root {
|
||||
--body-width: 90%;
|
||||
--large-margin: 2.8rem;
|
||||
--normal-margin: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@include in-dark-mode {
|
||||
--background: #242638;
|
||||
--normal-text-color: #ffffff;
|
||||
--card-color: #263551;
|
||||
--blurred-card-color: #212f4a77;
|
||||
--special-text-color: #ffffff;
|
||||
--inset-shadow: inset 0 0 10px 2px rgba(0, 0, 0, 0.3), inset 0 0 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
1656
src/styles/global.css
Normal file
1656
src/styles/global.css
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1 +0,0 @@
|
|||
export type html = string;
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { url } from './url';
|
||||
|
||||
export type ResponsiveImage = {
|
||||
srcSet: string;
|
||||
src: url;
|
||||
placeholder: string;
|
||||
width: number;
|
||||
height: number;
|
||||
images: Array<{
|
||||
path: url;
|
||||
width: number;
|
||||
height: number;
|
||||
}>;
|
||||
};
|
||||
|
|
@ -1 +0,0 @@
|
|||
export type url = string;
|
||||
Loading…
Add table
Add a link
Reference in a new issue