Initial commit
This commit is contained in:
142
src/app.css
Normal file
142
src/app.css
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Global base styles. Token file (tokens-dev.css / tokens-bio.css) is loaded
|
||||
* by layout per variant; it defines --color-* and --font-*.
|
||||
*/
|
||||
|
||||
@import url('$lib/fonts.css');
|
||||
|
||||
:root {
|
||||
--font-sans: system-ui, -apple-system, blinkmacsystemfont, sans-serif;
|
||||
--font-serif: serif;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-family: var(--font-heading, var(--font-sans));
|
||||
font-variation-settings: 'opsz' var(--font-heading-opsz, 36);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.045em;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-body, var(--font-sans));
|
||||
font-size: 100%;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-fg);
|
||||
transition:
|
||||
background-color 0.25s ease,
|
||||
color 0.25s ease;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -100%;
|
||||
left: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-bg);
|
||||
z-index: 100;
|
||||
transition: top 0.2s;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 1rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.heading {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.action {
|
||||
align-items: center;
|
||||
background: var(--color-surface-elevated);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 15%);
|
||||
color: var(--color-fg);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
height: 3rem;
|
||||
justify-content: center;
|
||||
min-height: 48px;
|
||||
min-width: 48px;
|
||||
padding: 0;
|
||||
width: 3rem;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-btn {
|
||||
align-items: center;
|
||||
background: var(--color-surface-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--color-fg);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font: inherit;
|
||||
font-size: 1rem;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s ease-in-out;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
14
src/app.html
Normal file
14
src/app.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="preload" href="/assets/js/theme-store.js" as="script" />
|
||||
<title>mifi.dev</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<script src="/assets/js/theme-store.js"></script>
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
71
src/lib/components/ContactPanel.svelte
Normal file
71
src/lib/components/ContactPanel.svelte
Normal file
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import type { ProcessedLink } from '$lib/data/types';
|
||||
|
||||
import LinkIcon from './LinkIcon.svelte';
|
||||
import Panel from './Panel.svelte';
|
||||
import IconContact from './icons/IconContact.svelte';
|
||||
|
||||
let {
|
||||
open = false,
|
||||
links = [],
|
||||
onclose = () => {},
|
||||
}: {
|
||||
open: boolean;
|
||||
links?: ProcessedLink[];
|
||||
onclose: () => void;
|
||||
} = $props();
|
||||
|
||||
const componentId = $props.id();
|
||||
</script>
|
||||
|
||||
<Panel {open} icon={IconContact} title="Contact" {onclose}>
|
||||
{#snippet children()}
|
||||
<ul class="contact-panel-list">
|
||||
{#each links as link (link.label)}
|
||||
{@const descriptionId = `${componentId}-description-${link.label}`}
|
||||
<li>
|
||||
{#if link.description}
|
||||
<div id={descriptionId} class="description">{link.description}</div>
|
||||
{/if}
|
||||
<a
|
||||
href={link.href}
|
||||
class="panel-btn"
|
||||
onclick={onclose}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-describedby={link.description ? descriptionId : undefined}
|
||||
>
|
||||
{#if link.icon}
|
||||
<LinkIcon href={link.href} icon={link.icon} label={link.label} />
|
||||
{/if}
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/snippet}
|
||||
</Panel>
|
||||
|
||||
<style>
|
||||
.contact-panel-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.contact-panel-list li {
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--color-secondary-muted);
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
18
src/lib/components/Footer.svelte
Normal file
18
src/lib/components/Footer.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<footer class="footer">
|
||||
<p>© {new Date().getFullYear()} Mike Fitzpatrick. All rights reserved.</p>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.footer {
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
color: var(--color-secondary-muted);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 2.5rem;
|
||||
padding: 1.5rem 0;
|
||||
text-align: center;
|
||||
|
||||
& p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
293
src/lib/components/Hero.svelte
Normal file
293
src/lib/components/Hero.svelte
Normal file
@@ -0,0 +1,293 @@
|
||||
<script lang="ts">
|
||||
import { HeroLayout } from '$lib/data/constants';
|
||||
import type { Site } from '$lib/data/types';
|
||||
import IconContact from './icons/IconContact.svelte';
|
||||
import IconShare from './icons/IconShare.svelte';
|
||||
|
||||
let {
|
||||
contactOpen = $bindable(),
|
||||
heroLayout,
|
||||
location,
|
||||
person,
|
||||
profileImage,
|
||||
pronouns,
|
||||
pronunciation,
|
||||
shareOpen = $bindable(),
|
||||
showContactButton,
|
||||
} = $props<Site & { contactOpen: boolean; shareOpen: boolean; showContactButton: boolean }>();
|
||||
</script>
|
||||
|
||||
<div class="hero-backdrop" aria-label="Header background">
|
||||
{#if profileImage}
|
||||
<div
|
||||
class="hero-bg"
|
||||
style="background-image: url('/assets/images/{profileImage}.webp')"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
<div class="hero-actions">
|
||||
{#if showContactButton}
|
||||
<button
|
||||
type="button"
|
||||
class="action"
|
||||
aria-label="Contact"
|
||||
onclick={() => {
|
||||
contactOpen = true;
|
||||
shareOpen = false;
|
||||
}}
|
||||
>
|
||||
<IconContact size={20} />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="action"
|
||||
aria-label="Share"
|
||||
onclick={() => {
|
||||
shareOpen = true;
|
||||
contactOpen = false;
|
||||
}}
|
||||
>
|
||||
<IconShare size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card hero-header-wrapper">
|
||||
<header class="hero-header" data-layout={heroLayout ?? 'side-by-side'}>
|
||||
{#if profileImage}
|
||||
<div class="hero-avatar">
|
||||
<img
|
||||
src="/assets/images/{profileImage}.webp"
|
||||
alt="{person?.name ?? 'mifi'} profile"
|
||||
width="160"
|
||||
height="160"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="hero-content">
|
||||
<h1 class="wordmark">mifi</h1>
|
||||
<h2 class="wordmark-explainer">
|
||||
<strong>Mi</strong>ke <strong>Fi</strong>tzpatrick
|
||||
</h2>
|
||||
{#if pronunciation || pronouns || location}
|
||||
<p class="hero-meta">
|
||||
{#if pronunciation}<span>{pronunciation}</span>{/if}
|
||||
{#if pronunciation && pronouns}
|
||||
<span class="hero-meta-sep" aria-hidden="true"> · </span>{/if}
|
||||
{#if pronouns}<span>{pronouns}</span>{/if}
|
||||
{#if pronouns && location}
|
||||
{#if (heroLayout ?? HeroLayout.SIDE_BY_SIDE) === HeroLayout.SIDE_BY_SIDE}
|
||||
<br />
|
||||
{:else}
|
||||
<span class="hero-meta-sep" aria-hidden="true"> · </span>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if location}<span>{location}</span>{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="button-group">
|
||||
{#if showContactButton}
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
aria-label="Contact"
|
||||
onclick={() => {
|
||||
contactOpen = true;
|
||||
shareOpen = false;
|
||||
}}
|
||||
>
|
||||
<IconContact size={20} />
|
||||
<span>Contact</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
aria-label="Share"
|
||||
onclick={() => {
|
||||
shareOpen = true;
|
||||
contactOpen = false;
|
||||
}}
|
||||
>
|
||||
<IconShare size={20} />
|
||||
<span>Share</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hero-backdrop {
|
||||
position: relative;
|
||||
height: 24vh;
|
||||
min-height: 12rem;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.hero-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-size: 220%;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
filter: blur(40px);
|
||||
transform: scale(1.1);
|
||||
z-index: 0;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
var(--color-bg) 0%,
|
||||
transparent 50%,
|
||||
var(--color-bg) 100%
|
||||
);
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.hero-header-wrapper {
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 4px 1.5rem rgba(0, 0, 0, 0.08);
|
||||
margin: -6rem auto 0;
|
||||
max-width: 50ch;
|
||||
overflow: visible;
|
||||
padding: 1rem 1.5rem 1.5rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Hero content (avatar + wordmark) inside card */
|
||||
.hero-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
text-align: center;
|
||||
|
||||
&[data-layout='side-by-side'] {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'avatar content'
|
||||
'buttons buttons';
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 1.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
/* Avatar: extends outside card top (half in card, half above) */
|
||||
.hero-avatar {
|
||||
background: var(--color-surface-elevated);
|
||||
border: 3px solid var(--color-surface);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
|
||||
grid-area: avatar;
|
||||
flex-shrink: 0;
|
||||
height: 8rem;
|
||||
overflow: hidden;
|
||||
transform: translateY(-50%);
|
||||
width: 8rem;
|
||||
|
||||
& img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
grid-area: content;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
font-family: var(--font-wordmark, var(--font-sans));
|
||||
font-weight: 700;
|
||||
font-size: clamp(2.5rem, 5vw, 3.5rem);
|
||||
letter-spacing: unset;
|
||||
line-height: 1.1;
|
||||
margin: 0 0 0.25rem;
|
||||
font-variant-ligatures: common-ligatures discretionary-ligatures;
|
||||
}
|
||||
|
||||
.wordmark-explainer {
|
||||
font-family: var(--font-wordmark, var(--font-sans));
|
||||
font-weight: 400;
|
||||
font-size: 1rem;
|
||||
color: var(--color-primary-muted);
|
||||
margin: 0 0 0.5rem;
|
||||
|
||||
& strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-meta {
|
||||
font-family: var(--font-body, var(--font-sans));
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-secondary-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hero-meta-sep {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
grid-area: buttons;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button {
|
||||
align-items: center;
|
||||
background: var(--color-surface-elevated);
|
||||
border: 1px solid var(--color-secondary-muted);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--color-fg);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
font-size: 1rem;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
max-width: 50%;
|
||||
padding: 0.5rem 1rem;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s ease-in-out;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: var(--color-secondary-muted);
|
||||
color: var(--color-surface-elevated);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
80
src/lib/components/Link.svelte
Normal file
80
src/lib/components/Link.svelte
Normal file
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import type { ProcessedLink } from '$lib/data/types';
|
||||
import LinkIcon from './LinkIcon.svelte';
|
||||
|
||||
let { href, icon, label, description }: ProcessedLink = $props();
|
||||
</script>
|
||||
|
||||
<a {href} rel="noopener noreferrer" target="_blank" class="link">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<LinkIcon {href} {icon} {label} />
|
||||
</span>
|
||||
<div class="link-row-content">
|
||||
<span class="title">{label}</span>
|
||||
{#if description}
|
||||
<span class="description">{description}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
& .title {
|
||||
color: var(--color-link-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
& .description {
|
||||
color: var(--color-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
& .icon {
|
||||
color: var(--color-link-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
}
|
||||
}
|
||||
|
||||
.title,
|
||||
.description,
|
||||
.icon {
|
||||
transition:
|
||||
color 0.2s ease-in-out,
|
||||
text-decoration 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--color-link);
|
||||
flex-shrink: 0;
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
||||
.link-row-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--color-link);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--color-secondary-muted);
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.15rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
58
src/lib/components/LinkGroup.svelte
Normal file
58
src/lib/components/LinkGroup.svelte
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import type { ProcessedLink, Section } from '$lib/data/types';
|
||||
|
||||
import Link from './Link.svelte';
|
||||
|
||||
let {
|
||||
id,
|
||||
links,
|
||||
order,
|
||||
showHeading = false,
|
||||
title,
|
||||
} = $props<
|
||||
Omit<Section, 'links' | 'order'> & {
|
||||
links: ProcessedLink[];
|
||||
order: number;
|
||||
showHeading?: boolean;
|
||||
}
|
||||
>();
|
||||
</script>
|
||||
|
||||
<section class="card" aria-labelledby={`section-${id}`} style="order:{order}">
|
||||
{#if showHeading}<h3 id={`section-${id}`} class="link-section-heading heading">
|
||||
{title}
|
||||
</h3>{/if}
|
||||
<ul class="link-list">
|
||||
{#each links as link (link.label)}
|
||||
<li class="link-row">
|
||||
<Link {...link} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.link-section-heading {
|
||||
color: var(--color-primary);
|
||||
font-family: var(--font-heading, var(--font-sans));
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
font-variation-settings: 'opsz' var(--font-heading-opsz, 36);
|
||||
letter-spacing: 0.045em;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.link-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.link-row {
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
60
src/lib/components/LinkIcon.svelte
Normal file
60
src/lib/components/LinkIcon.svelte
Normal file
@@ -0,0 +1,60 @@
|
||||
<script lang="ts" module>
|
||||
export type IconName =
|
||||
| 'GitHub'
|
||||
| 'LinkedIn'
|
||||
| 'Instagram'
|
||||
| 'Facebook'
|
||||
| 'YouTube'
|
||||
| 'TikTok'
|
||||
| 'Snapchat'
|
||||
| 'Discord'
|
||||
| 'Strava'
|
||||
| 'Flickr'
|
||||
| 'Calendar'
|
||||
| 'Resume'
|
||||
| 'Mifi';
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import IconLink from './icons/IconLink.svelte';
|
||||
import IconGitHub from './icons/IconGitHub.svelte';
|
||||
import IconLinkedIn from './icons/IconLinkedIn.svelte';
|
||||
import IconInstagram from './icons/IconInstagram.svelte';
|
||||
import IconFacebook from './icons/IconFacebook.svelte';
|
||||
import IconYouTube from './icons/IconYouTube.svelte';
|
||||
import IconTikTok from './icons/IconTikTok.svelte';
|
||||
import IconSnapchat from './icons/IconSnapchat.svelte';
|
||||
import IconDiscord from './icons/IconDiscord.svelte';
|
||||
import IconStrava from './icons/IconStrava.svelte';
|
||||
import IconFlickr from './icons/IconFlickr.svelte';
|
||||
import IconCal from './icons/IconCal.svelte';
|
||||
import IconResume from './icons/IconResume.svelte';
|
||||
import IconMi from './icons/IconMi.svelte';
|
||||
import type { Link } from '$lib/data/types';
|
||||
|
||||
let {
|
||||
icon,
|
||||
label,
|
||||
size = 24,
|
||||
} = $props<Pick<Link, 'href' | 'icon' | 'label'> & { size?: number }>();
|
||||
|
||||
const iconMap: Record<IconName, typeof IconLink> = {
|
||||
GitHub: IconGitHub,
|
||||
LinkedIn: IconLinkedIn,
|
||||
Instagram: IconInstagram,
|
||||
Facebook: IconFacebook,
|
||||
YouTube: IconYouTube,
|
||||
TikTok: IconTikTok,
|
||||
Snapchat: IconSnapchat,
|
||||
Discord: IconDiscord,
|
||||
Strava: IconStrava,
|
||||
Flickr: IconFlickr,
|
||||
Calendar: IconCal,
|
||||
Resume: IconResume,
|
||||
Mifi: IconMi,
|
||||
};
|
||||
|
||||
const IconComponent = $derived(iconMap[(icon || label) as IconName] ?? IconLink);
|
||||
</script>
|
||||
|
||||
<IconComponent {size} />
|
||||
169
src/lib/components/Panel.svelte
Normal file
169
src/lib/components/Panel.svelte
Normal file
@@ -0,0 +1,169 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
let {
|
||||
children,
|
||||
icon: IconComponent,
|
||||
onclose = () => {},
|
||||
open = false,
|
||||
title = '',
|
||||
}: {
|
||||
children: import('svelte').Snippet;
|
||||
icon?: Component<{ size?: number }>;
|
||||
onclose: () => void;
|
||||
open: boolean;
|
||||
title: string;
|
||||
} = $props();
|
||||
|
||||
let dialogEl: HTMLDialogElement | undefined;
|
||||
let closeBtnEl: HTMLButtonElement | undefined;
|
||||
|
||||
function handleDialogClose() {
|
||||
onclose();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!dialogEl) return;
|
||||
if (open) {
|
||||
dialogEl.showModal();
|
||||
void tick().then(() => closeBtnEl?.focus());
|
||||
} else {
|
||||
dialogEl.close();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<dialog
|
||||
bind:this={dialogEl}
|
||||
class="panel-dialog"
|
||||
closedby="any"
|
||||
aria-labelledby="panel-title"
|
||||
aria-modal="true"
|
||||
onclose={handleDialogClose}
|
||||
oncancel={(e) => {
|
||||
e.preventDefault();
|
||||
onclose();
|
||||
}}
|
||||
>
|
||||
<div class="panel">
|
||||
<header class="header">
|
||||
<h2 id="panel-title" class="title heading">
|
||||
{#if IconComponent}
|
||||
<IconComponent size={24} />
|
||||
{/if}
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="close-button"
|
||||
aria-label="Close"
|
||||
bind:this={closeBtnEl}
|
||||
onclick={onclose}
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</header>
|
||||
<div class="body">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.panel-dialog {
|
||||
background-color: transparent;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
max-height: 80vh;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
inset: auto;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
|
||||
&::backdrop {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
bottom: auto;
|
||||
max-width: min(28rem, calc(100vw - 2rem));
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 1rem 1rem 0 0;
|
||||
box-shadow: 0 -4px 1rem rgb(0 0 0 / 15%);
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
max-height: 70vh;
|
||||
padding-bottom: 2rem;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: 769px) {
|
||||
max-height: 80vh;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
align-items: center;
|
||||
color: var(--color-fg);
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
font-family: var(--font-heading, var(--font-sans));
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
flex: 0 0 auto;
|
||||
font: inherit;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
padding: 0.25rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-fg);
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 1.25rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
100
src/lib/components/SharePanel.svelte
Normal file
100
src/lib/components/SharePanel.svelte
Normal file
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import Panel from './Panel.svelte';
|
||||
import IconCopy from './icons/IconCopy.svelte';
|
||||
import IconEmail from './icons/IconEmail.svelte';
|
||||
import IconShare from './icons/IconShare.svelte';
|
||||
|
||||
let {
|
||||
open = false,
|
||||
url = '',
|
||||
qrCodeImage = '',
|
||||
emailSubject = 'Link from mifi',
|
||||
emailBody = '',
|
||||
onclose = () => {},
|
||||
}: {
|
||||
open: boolean;
|
||||
url: string;
|
||||
qrCodeImage?: string | null;
|
||||
emailSubject?: string;
|
||||
emailBody?: string;
|
||||
onclose: () => void;
|
||||
} = $props();
|
||||
|
||||
let copied = $state(false);
|
||||
|
||||
function copyLink() {
|
||||
if (typeof navigator === 'undefined' || !navigator.clipboard) return;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function share() {
|
||||
if (typeof navigator === 'undefined' || !navigator.share) return;
|
||||
navigator
|
||||
.share({ title: 'mifi', url })
|
||||
.then(() => onclose())
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
const canShare = $derived(typeof navigator !== 'undefined' && !!navigator.share);
|
||||
const mailtoHref = $derived(
|
||||
`mailto:?subject=${encodeURIComponent(emailSubject)}&body=${encodeURIComponent(emailBody || url)}`,
|
||||
);
|
||||
</script>
|
||||
|
||||
<Panel {open} icon={IconShare} title="Share" {onclose}>
|
||||
{#snippet children()}
|
||||
<div class="share-panel">
|
||||
{#if qrCodeImage}
|
||||
<div class="share-qr">
|
||||
<img
|
||||
src="/assets/images/{qrCodeImage}.png"
|
||||
alt="QR code for this page"
|
||||
width="160"
|
||||
height="160"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<button type="button" class="panel-btn" onclick={copyLink}>
|
||||
<IconCopy size={20} />
|
||||
{copied ? 'Copied!' : 'Copy link'}
|
||||
</button>
|
||||
<a
|
||||
href={mailtoHref}
|
||||
class="panel-btn"
|
||||
onclick={onclose}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconEmail size={20} />
|
||||
Email link
|
||||
</a>
|
||||
{#if canShare}
|
||||
<button type="button" class="panel-btn" onclick={share}>
|
||||
<IconShare size={20} />
|
||||
Share…
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Panel>
|
||||
|
||||
<style>
|
||||
.share-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.share-qr {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
& img {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
157
src/lib/components/ThemeToggle.svelte
Normal file
157
src/lib/components/ThemeToggle.svelte
Normal file
@@ -0,0 +1,157 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Moon, SunMoon, Sun } from '@lucide/svelte';
|
||||
import { getStoredTheme, setTheme } from '$lib/theme';
|
||||
import type { ThemeMode } from '$lib/theme';
|
||||
|
||||
const SLOT_WIDTH = 48;
|
||||
const GAP = 8;
|
||||
/** When collapsed, track translateX so the active option is in the 48px viewport (left=Light, middle=Dark, right=Auto). */
|
||||
const OFFSETS: Record<ThemeMode, number> = {
|
||||
light: 0,
|
||||
dark: -(SLOT_WIDTH + GAP),
|
||||
auto: -(SLOT_WIDTH + GAP) * 2,
|
||||
};
|
||||
|
||||
let current = $state<ThemeMode>('auto');
|
||||
let expanded = $state(false);
|
||||
|
||||
const themeOffset = $derived(OFFSETS[current]);
|
||||
|
||||
onMount(() => {
|
||||
current = getStoredTheme();
|
||||
});
|
||||
|
||||
function choose(mode: ThemeMode) {
|
||||
setTheme(mode);
|
||||
current = mode;
|
||||
}
|
||||
|
||||
function handleClick(mode: ThemeMode) {
|
||||
if (expanded) {
|
||||
choose(mode);
|
||||
expanded = false;
|
||||
} else {
|
||||
expanded = true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if expanded}
|
||||
<!-- Click outside to collapse -->
|
||||
<button
|
||||
type="button"
|
||||
class="theme-toggle-backdrop"
|
||||
aria-label="Close theme menu"
|
||||
tabindex="-1"
|
||||
onclick={() => (expanded = false)}
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="theme-toggle"
|
||||
class:expanded
|
||||
role="group"
|
||||
aria-label="Color theme"
|
||||
style="--theme-offset: {themeOffset}px;"
|
||||
>
|
||||
<div class="theme-toggle-track">
|
||||
<button
|
||||
type="button"
|
||||
class="action theme-option"
|
||||
class:active={expanded && current === 'light'}
|
||||
aria-label="Light"
|
||||
aria-current={current === 'light' ? 'true' : undefined}
|
||||
title="Light"
|
||||
onclick={() => handleClick('light')}
|
||||
>
|
||||
<Sun size={24} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="action theme-option"
|
||||
class:active={expanded && current === 'dark'}
|
||||
aria-label="Dark"
|
||||
aria-current={current === 'dark' ? 'true' : undefined}
|
||||
title="Dark"
|
||||
onclick={() => handleClick('dark')}
|
||||
>
|
||||
<Moon size={24} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="action theme-option"
|
||||
class:active={expanded && current === 'auto'}
|
||||
aria-label="Auto (system)"
|
||||
aria-current={current === 'auto' ? 'true' : undefined}
|
||||
title="Auto (system)"
|
||||
onclick={() => handleClick('auto')}
|
||||
>
|
||||
<SunMoon size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.theme-toggle-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 99;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Collapsed: match hero-action (other header buttons); expanded: glass panel */
|
||||
.theme-toggle {
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
display: inline-block;
|
||||
width: 48px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
transition:
|
||||
width 0.22s ease-out,
|
||||
padding 0.22s ease-out,
|
||||
border-radius 0.22s ease-out,
|
||||
background 0.22s ease-out,
|
||||
box-shadow 0.22s ease-out,
|
||||
backdrop-filter 0.22s ease-out;
|
||||
background: var(--color-surface-elevated);
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 15%);
|
||||
}
|
||||
|
||||
.theme-toggle.expanded {
|
||||
width: 176px;
|
||||
padding: 0.5rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||
background: color-mix(in srgb, var(--color-surface-elevated) 52%, transparent);
|
||||
backdrop-filter: blur(20px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.2);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, white 12%, transparent) inset,
|
||||
0 4px 24px rgba(0, 0, 0, 0.12),
|
||||
0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.theme-toggle-track {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
width: 160px;
|
||||
transform: translateX(var(--theme-offset));
|
||||
transition: transform 0.22s ease-out;
|
||||
}
|
||||
|
||||
.theme-toggle.expanded .theme-toggle-track {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.theme-option.active {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
</style>
|
||||
22
src/lib/components/icons/IconCal.svelte
Normal file
22
src/lib/components/icons/IconCal.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
/** Calendar / booking icon. */
|
||||
let { size = 24 }: { size?: number } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
18
src/lib/components/icons/IconContact.svelte
Normal file
18
src/lib/components/icons/IconContact.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
let { size = 24 }: { size?: number } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"
|
||||
/>
|
||||
</svg>
|
||||
19
src/lib/components/icons/IconCopy.svelte
Normal file
19
src/lib/components/icons/IconCopy.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
let { size = 24 } = $props<{ size?: number }>();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||
</svg>
|
||||
16
src/lib/components/icons/IconDiscord.svelte
Normal file
16
src/lib/components/icons/IconDiscord.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
let { size = 24 }: { size?: number } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
|
||||
/>
|
||||
</svg>
|
||||
21
src/lib/components/icons/IconEmail.svelte
Normal file
21
src/lib/components/icons/IconEmail.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
let { size = 24 } = $props<{ size?: number }>();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"
|
||||
/>
|
||||
<path d="m21.854 2.147-10.94 10.939" />
|
||||
</svg>
|
||||
16
src/lib/components/icons/IconFacebook.svelte
Normal file
16
src/lib/components/icons/IconFacebook.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
let { size = 24 }: { size?: number } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"
|
||||
/>
|
||||
</svg>
|
||||
16
src/lib/components/icons/IconFlickr.svelte
Normal file
16
src/lib/components/icons/IconFlickr.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
let { size = 24 }: { size?: number } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M5.5 12c0-1.653 1.347-3 3-3s3 1.347 3 3-1.347 3-3 3-3-1.347-3-3zm10.5 0c0-1.653 1.347-3 3-3s3 1.347 3 3-1.347 3-3 3-3-1.347-3-3z"
|
||||
/>
|
||||
</svg>
|
||||
16
src/lib/components/icons/IconGitHub.svelte
Normal file
16
src/lib/components/icons/IconGitHub.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
let { size = 24 }: { size?: number } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
||||
/>
|
||||
</svg>
|
||||
16
src/lib/components/icons/IconInstagram.svelte
Normal file
16
src/lib/components/icons/IconInstagram.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
let { size = 24 }: { size?: number } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12 0C8.74 0 8.333.015 7.053.072 5.775.132 4.905.333 4.14.63c-.789.306-1.459.717-2.126 1.384S.935 3.35.63 4.14C.333 4.905.131 5.775.072 7.053.012 8.333 0 8.74 0 12s.015 3.667.072 4.947c.06 1.277.261 2.148.558 2.913.306.788.717 1.459 1.384 2.126.667.666 1.336 1.079 2.126 1.384.766.296 1.636.499 2.913.558C8.333 23.988 8.74 24 12 24s3.667-.015 4.947-.072c1.277-.06 2.148-.262 2.913-.558.788-.306 1.459-.718 2.126-1.384.666-.667 1.079-1.335 1.384-2.126.296-.765.499-1.636.558-2.913.06-1.28.072-1.687.072-4.947s-.015-3.667-.072-4.947c-.06-1.277-.262-2.149-.558-2.913-.306-.789-.718-1.459-1.384-2.126C21.319 1.347 20.651.935 19.86.63c-.765-.297-1.636-.499-2.913-.558C15.667.012 15.26 0 12 0zm0 2.16c3.203 0 3.585.016 4.85.071 1.17.055 1.805.249 2.227.415.562.217.96.477 1.382.896.419.42.679.819.896 1.381.164.422.36 1.057.413 2.227.057 1.266.07 1.646.07 4.85s-.015 3.585-.074 4.85c-.061 1.17-.256 1.805-.421 2.227-.224.562-.479.96-.899 1.382-.419.419-.824.679-1.38.896-.42.164-1.065.36-2.235.413-1.274.057-1.649.07-4.859.07-3.211 0-3.586-.015-4.859-.074-1.171-.061-1.816-.256-2.236-.421-.569-.224-.96-.479-1.379-.899-.421-.419-.69-.824-.9-1.38-.165-.42-.359-1.065-.42-2.235-.045-1.26-.061-1.649-.061-4.844 0-3.196.016-3.586.061-4.861.061-1.17.255-1.814.42-2.234.21-.57.479-.96.9-1.381.419-.419.81-.689 1.379-.898.42-.166 1.051-.361 2.221-.421 1.275-.045 1.65-.06 4.859-.06l.045.03zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162S8.597 20.163 12 20.163s6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zM12 16c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4zm7.846-10.405a1.441 1.441 0 0 1-2.88 0 1.44 1.44 0 0 1 2.88 0z"
|
||||
/>
|
||||
</svg>
|
||||
20
src/lib/components/icons/IconLink.svelte
Normal file
20
src/lib/components/icons/IconLink.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
/** Default/link icon. 24×24, currentColor. */
|
||||
let { size = 24 }: { size?: number } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
16
src/lib/components/icons/IconLinkedIn.svelte
Normal file
16
src/lib/components/icons/IconLinkedIn.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
let { size = 24 }: { size?: number } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"
|
||||
/>
|
||||
</svg>
|
||||
16
src/lib/components/icons/IconMi.svelte
Normal file
16
src/lib/components/icons/IconMi.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
let { size = 24 }: { size?: number } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 2134 2134"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M2133.333,400l0,1333.333c0,220.766 -179.234,400 -400,400l-1333.333,0c-220.766,0 -400,-179.234 -400,-400l0,-1333.333c0,-220.766 179.234,-400 400,-400l1333.333,0c220.766,0 400,179.234 400,400Zm-400.047,334.954l221.363,0l0,-236.121l-221.363,0l0,236.121Zm-806.91,230.385c-20.078,-34.566 -48.157,-64.067 -84.236,-88.502c-57.484,-38.932 -123.612,-58.398 -198.384,-58.398c-68.587,0 -128.391,16.304 -179.41,48.911c-32.843,20.991 -58.493,48.971 -76.95,83.94l0,-112.612l-208.714,0l0,916.655l221.363,0l0,-538.018c0,-40.478 7.379,-75.264 22.136,-104.357c14.758,-29.093 35.488,-51.722 62.193,-67.885c26.704,-16.163 57.765,-24.245 93.183,-24.245c36.261,0 67.463,8.082 93.605,24.245c26.142,16.163 46.521,38.721 61.138,67.674c14.617,28.953 21.926,63.809 21.926,104.568l0,538.018l221.363,0l0,-538.018c0,-40.478 7.379,-75.264 22.136,-104.357c14.758,-29.093 35.559,-51.722 62.403,-67.885c26.845,-16.163 57.836,-24.245 92.973,-24.245c36.261,0 67.463,8.082 93.605,24.245c26.142,16.163 46.521,38.721 61.138,67.674c14.617,28.953 21.926,63.809 21.926,104.568l0,538.018l221.363,0l0,-590.724c0,-68.306 -14.828,-128.461 -44.483,-180.464c-29.656,-52.003 -70.063,-92.621 -121.223,-121.855c-51.16,-29.234 -109.206,-43.851 -174.139,-43.851c-73.366,0 -138.089,18.06 -194.167,54.181c-35.933,23.145 -66.182,54.051 -90.747,92.718Zm806.91,789.994l221.363,0l0,-916.655l-221.363,0l0,916.655Z"
|
||||
/>
|
||||
</svg>
|
||||
22
src/lib/components/icons/IconResume.svelte
Normal file
22
src/lib/components/icons/IconResume.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
/** PDF document / resume icon. */
|
||||
let { size = 24 }: { size?: number } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 640 640"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<!--!Font Awesome Pro v7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2026 Fonticons, Inc.-->
|
||||
<path
|
||||
d="M240 112L128 112C119.2 112 112 119.2 112 128L112 512C112 520.8 119.2 528 128 528L208 528L208 576L128 576C92.7 576 64 547.3 64 512L64 128C64 92.7 92.7 64 128 64L261.5 64C278.5 64 294.8 70.7 306.8 82.7L429.3 205.3C441.3 217.3 448 233.6 448 250.6L448 400.1L400 400.1L400 272.1L312 272.1C272.2 272.1 240 239.9 240 200.1L240 112.1zM380.1 224L288 131.9L288 200C288 213.3 298.7 224 312 224L380.1 224zM272 444L304 444C337.1 444 364 470.9 364 504C364 537.1 337.1 564 304 564L292 564L292 592C292 603 283 612 272 612C261 612 252 603 252 592L252 464C252 453 261 444 272 444zM304 524C315 524 324 515 324 504C324 493 315 484 304 484L292 484L292 524L304 524zM400 444L432 444C460.7 444 484 467.3 484 496L484 560C484 588.7 460.7 612 432 612L400 612C389 612 380 603 380 592L380 464C380 453 389 444 400 444zM432 572C438.6 572 444 566.6 444 560L444 496C444 489.4 438.6 484 432 484L420 484L420 572L432 572zM508 464C508 453 517 444 528 444L576 444C587 444 596 453 596 464C596 475 587 484 576 484L548 484L548 508L576 508C587 508 596 517 596 528C596 539 587 548 576 548L548 548L548 592C548 603 539 612 528 612C517 612 508 603 508 592L508 464z"
|
||||
/>
|
||||
</svg>
|
||||
20
src/lib/components/icons/IconShare.svelte
Normal file
20
src/lib/components/icons/IconShare.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
let { size = 24 }: { size?: number } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="18" cy="5" r="3" />
|
||||
<circle cx="6" cy="12" r="3" />
|
||||
<circle cx="18" cy="19" r="3" />
|
||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
|
||||
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
|
||||
</svg>
|
||||
16
src/lib/components/icons/IconSnapchat.svelte
Normal file
16
src/lib/components/icons/IconSnapchat.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
let { size = 24 }: { size?: number } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12.206.793c.99 0 4.347.276 5.93 3.821.529 1.193.403 3.219.299 4.847l-.003.06c-.012.18-.022.345-.03.51.075.045.203.09.401.09.3-.016.659-.12 1.033-.301.165-.088.344-.104.464-.104.182 0 .359.029.509.09.45.149.734.479.734.838.015.449-.39.839-1.213 1.168-.089.029-.209.075-.344.119-.45.135-1.139.36-1.333.81-.09.224-.061.524.12.868l.015.015c.06.136 1.526 3.475 4.791 4.014.255.044.435.27.42.509 0 .075-.015.149-.045.225-.24.569-1.273.988-3.146 1.271-.059.091-.12.375-.164.57-.029.179-.074.36-.134.553-.076.271-.27.405-.555.405h-.03c-.135 0-.313-.031-.538-.074-.36-.075-.765-.135-1.273-.135-.3 0-.599.015-.913.074-.6.104-1.123.464-1.723.884-.853.599-1.826 1.288-3.294 1.288-.06 0-.119-.015-.18-.015h-.149c-1.468 0-2.427-.675-3.279-1.288-.599-.42-1.107-.779-1.707-.884-.314-.045-.629-.074-.928-.074-.54 0-.958.089-1.272.149-.211.043-.391.074-.54.074-.374 0-.523-.224-.583-.42-.061-.192-.09-.389-.135-.567-.046-.181-.105-.494-.166-.57-1.918-.222-2.95-.642-3.189-1.226-.031-.063-.052-.15-.052-.225-.015-.239.165-.465.42-.509 3.264-.54 4.73-3.879 4.791-4.02l.016-.029c.18-.345.224-.645.119-.869-.195-.434-.884-.658-1.333-.809-.121-.029-.24-.074-.346-.119-1.107-.435-1.257-.93-1.197-1.273.089-.479.674-.793 1.168-.793.146 0 .27.029.383.074.42.194.789.3 1.104.3.234 0 .384-.06.465-.105l-.046-.569c-.098-1.626-.225-3.651.307-4.837C7.392 1.077 10.739.807 11.727.807l.419-.015h.06z"
|
||||
/>
|
||||
</svg>
|
||||
16
src/lib/components/icons/IconStrava.svelte
Normal file
16
src/lib/components/icons/IconStrava.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
let { size = 24 }: { size?: number } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M15.387 17.944l-2.089-4.116h-3.065L15.387 24l5.15-10.172h-3.066m-7.008-5.113l2.836 5.116h3.172l-4.994-9.016c-.363-.636-1.134-.828-1.771-.828-.576 0-1.224.192-1.589.644L2.086 17.944h3.157l2.136-4.113zm4.008-5.113c-.748 0-1.271.312-1.271.312v3.177h2.453c.261 0 .523-.052.784-.156.523-.208.888-.572 1.044-1.036.156-.468.104-.988-.156-1.456-.416-.728-1.304-1.192-2.804-1.192z"
|
||||
/>
|
||||
</svg>
|
||||
16
src/lib/components/icons/IconTikTok.svelte
Normal file
16
src/lib/components/icons/IconTikTok.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
let { size = 24 }: { size?: number } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"
|
||||
/>
|
||||
</svg>
|
||||
16
src/lib/components/icons/IconYouTube.svelte
Normal file
16
src/lib/components/icons/IconYouTube.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
let { size = 24 }: { size?: number } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"
|
||||
/>
|
||||
</svg>
|
||||
84
src/lib/config.test.ts
Normal file
84
src/lib/config.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
OWN_PROPERTY_HOSTS,
|
||||
VARIANT_HOSTS,
|
||||
GA_MEASUREMENT_IDS,
|
||||
UTM_MEDIUM,
|
||||
UTM_CAMPAIGN,
|
||||
appendUtmParams,
|
||||
} from './config';
|
||||
|
||||
describe('config', () => {
|
||||
describe('constants', () => {
|
||||
it('OWN_PROPERTY_HOSTS includes expected hostnames', () => {
|
||||
expect(OWN_PROPERTY_HOSTS).toContain('mifi.dev');
|
||||
expect(OWN_PROPERTY_HOSTS).toContain('mifi.bio');
|
||||
expect(OWN_PROPERTY_HOSTS).toContain('mifi.ventures');
|
||||
expect(OWN_PROPERTY_HOSTS).toContain('cal.mifi.ventures');
|
||||
});
|
||||
|
||||
it('VARIANT_HOSTS maps dev and bio', () => {
|
||||
expect(VARIANT_HOSTS.dev).toBe('mifi.dev');
|
||||
expect(VARIANT_HOSTS.bio).toBe('mifi.bio');
|
||||
});
|
||||
|
||||
it('GA_MEASUREMENT_IDS has dev and bio', () => {
|
||||
expect(GA_MEASUREMENT_IDS.dev).toMatch(/^G-[A-Z0-9]+$/);
|
||||
expect(GA_MEASUREMENT_IDS.bio).toMatch(/^G-[A-Z0-9]+$/);
|
||||
});
|
||||
|
||||
it('UTM_MEDIUM and UTM_CAMPAIGN are set', () => {
|
||||
expect(UTM_MEDIUM).toBe('link');
|
||||
expect(UTM_CAMPAIGN).toBe('landing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendUtmParams', () => {
|
||||
it('appends utm params to own-property URL', () => {
|
||||
const href = 'https://mifi.dev/page';
|
||||
const result = appendUtmParams(href, 'mifi.dev');
|
||||
const url = new URL(result);
|
||||
expect(url.searchParams.get('utm_source')).toBe('mifi.dev');
|
||||
expect(url.searchParams.get('utm_medium')).toBe('link');
|
||||
expect(url.searchParams.get('utm_campaign')).toBe('landing');
|
||||
expect(url.origin).toBe('https://mifi.dev');
|
||||
});
|
||||
|
||||
it('appends utm_content when provided', () => {
|
||||
const href = 'https://mifi.bio/';
|
||||
const result = appendUtmParams(href, 'mifi.bio', 'hero');
|
||||
const url = new URL(result);
|
||||
expect(url.searchParams.get('utm_content')).toBe('hero');
|
||||
});
|
||||
|
||||
it('preserves existing query params and adds UTM', () => {
|
||||
const href = 'https://mifi.dev/page?foo=bar';
|
||||
const result = appendUtmParams(href, 'mifi.dev');
|
||||
const url = new URL(result);
|
||||
expect(url.searchParams.get('foo')).toBe('bar');
|
||||
expect(url.searchParams.get('utm_source')).toBe('mifi.dev');
|
||||
});
|
||||
|
||||
it('returns href unchanged for non-own-property host', () => {
|
||||
const href = 'https://example.com/page';
|
||||
expect(appendUtmParams(href, 'mifi.dev')).toBe(href);
|
||||
expect(appendUtmParams('https://github.com/the-mifi', 'mifi.dev')).toBe(
|
||||
'https://github.com/the-mifi',
|
||||
);
|
||||
});
|
||||
|
||||
it('matches hostname case-insensitively', () => {
|
||||
const result = appendUtmParams('https://MIFI.DEV/path', 'mifi.dev');
|
||||
const url = new URL(result);
|
||||
expect(url.searchParams.get('utm_source')).toBe('mifi.dev');
|
||||
});
|
||||
|
||||
it('uses base for relative href', () => {
|
||||
const result = appendUtmParams('/about', 'mifi.dev');
|
||||
const url = new URL(result);
|
||||
expect(url.hostname).toBe('mifi.dev');
|
||||
expect(url.pathname).toBe('/about');
|
||||
expect(url.searchParams.get('utm_source')).toBe('mifi.dev');
|
||||
});
|
||||
});
|
||||
});
|
||||
43
src/lib/config.ts
Normal file
43
src/lib/config.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* App config: own-property hostnames for UTM attribution, variant hostnames, GA IDs.
|
||||
*/
|
||||
|
||||
export const OWN_PROPERTY_HOSTS = [
|
||||
'mifi.ventures',
|
||||
'cal.mifi.ventures',
|
||||
'mifi.dev',
|
||||
'mifi.bio',
|
||||
] as const;
|
||||
|
||||
export const VARIANT_HOSTS: Record<'dev' | 'bio', string> = {
|
||||
dev: 'mifi.dev',
|
||||
bio: 'mifi.bio',
|
||||
};
|
||||
|
||||
export const GA_MEASUREMENT_IDS: Record<'dev' | 'bio', string> = {
|
||||
dev: 'G-P8V832WDM8',
|
||||
bio: 'G-885B0KYWZ1',
|
||||
};
|
||||
|
||||
export const UTM_MEDIUM = 'link';
|
||||
export const UTM_CAMPAIGN = 'landing';
|
||||
|
||||
/**
|
||||
* Returns href with UTM params appended if the URL's host is an own property.
|
||||
*/
|
||||
export function appendUtmParams(href: string, sourceHost: string, utmContent?: string): string {
|
||||
try {
|
||||
const url = new URL(href, 'https://mifi.dev');
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
if (!OWN_PROPERTY_HOSTS.some((h) => hostname === h)) return href;
|
||||
const params = new URLSearchParams(url.search);
|
||||
params.set('utm_source', sourceHost);
|
||||
params.set('utm_medium', UTM_MEDIUM);
|
||||
params.set('utm_campaign', UTM_CAMPAIGN);
|
||||
if (utmContent) params.set('utm_content', utmContent);
|
||||
url.search = params.toString();
|
||||
return url.toString();
|
||||
} catch {
|
||||
return href;
|
||||
}
|
||||
}
|
||||
31
src/lib/data/constants.test.ts
Normal file
31
src/lib/data/constants.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ContentVariant, HeroLayout, AvatarVariant } from './constants';
|
||||
|
||||
describe('constants', () => {
|
||||
describe('ContentVariant', () => {
|
||||
it('has bio and dev', () => {
|
||||
expect(ContentVariant.BIO).toBe('bio');
|
||||
expect(ContentVariant.DEV).toBe('dev');
|
||||
});
|
||||
|
||||
it('values are string literals usable as variant keys', () => {
|
||||
const variants: ContentVariant[] = [ContentVariant.DEV, ContentVariant.BIO];
|
||||
expect(variants).toContain('dev');
|
||||
expect(variants).toContain('bio');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeroLayout', () => {
|
||||
it('has stack and side-by-side', () => {
|
||||
expect(HeroLayout.STACK).toBe('stack');
|
||||
expect(HeroLayout.SIDE_BY_SIDE).toBe('side-by-side');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AvatarVariant', () => {
|
||||
it('has classic and tropical', () => {
|
||||
expect(AvatarVariant.CLASSIC).toBe('classic-mifi');
|
||||
expect(AvatarVariant.TROPICAL).toBe('tropical-mifi');
|
||||
});
|
||||
});
|
||||
});
|
||||
14
src/lib/data/constants.ts
Normal file
14
src/lib/data/constants.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export enum ContentVariant {
|
||||
BIO = 'bio',
|
||||
DEV = 'dev',
|
||||
}
|
||||
|
||||
export enum HeroLayout {
|
||||
STACK = 'stack',
|
||||
SIDE_BY_SIDE = 'side-by-side',
|
||||
}
|
||||
|
||||
export enum AvatarVariant {
|
||||
CLASSIC = 'classic-mifi',
|
||||
TROPICAL = 'tropical-mifi',
|
||||
}
|
||||
218
src/lib/data/links.json
Normal file
218
src/lib/data/links.json
Normal file
@@ -0,0 +1,218 @@
|
||||
{
|
||||
"siteByVariant": {
|
||||
"dev": {
|
||||
"title": "mifi.dev — the homepage of the professional Mike Fitzpatrick",
|
||||
"metaDescription": "Professional links and profiles for mifi – consultancy, code, and contact.",
|
||||
"url": "https://mifi.dev",
|
||||
"heroLayout": "side-by-side",
|
||||
"profileImage": "classic-mifi",
|
||||
"pronunciation": "MY-fy (/ˈmaɪˌfaɪ/)",
|
||||
"pronouns": "he/him",
|
||||
"location": "Boston, MA",
|
||||
"person": {
|
||||
"name": "mifi",
|
||||
"sameAs": [
|
||||
"https://mifi.ventures",
|
||||
"https://cal.mifi.ventures/the-mifi",
|
||||
"https://www.linkedin.com/in/the-mifi",
|
||||
"https://github.com/the-mifi",
|
||||
"https://git.mifi.dev/mifi"
|
||||
]
|
||||
},
|
||||
"linksHeading": "Professional Links and Profiles",
|
||||
"showContact": true,
|
||||
"qrCodeImage": null
|
||||
},
|
||||
"bio": {
|
||||
"title": "mifi.bio — the homepage of the human Mike Fitzpatrick",
|
||||
"metaDescription": "Links and profiles for mifi – professional, personal, and everything in between.",
|
||||
"url": "https://mifi.bio",
|
||||
"heroLayout": "side-by-side",
|
||||
"profileImage": "tropical-mifi",
|
||||
"pronunciation": "MY-fy (/ˈmaɪˌfaɪ/)",
|
||||
"pronouns": "he/him/mifi",
|
||||
"location": "Vitória, ES, Brasil",
|
||||
"person": {
|
||||
"name": "mifi",
|
||||
"sameAs": [
|
||||
"https://mifi.ventures",
|
||||
"https://www.linkedin.com/in/the-mifi",
|
||||
"https://github.com/the-mifi",
|
||||
"https://git.mifi.dev/mifi",
|
||||
"https://www.instagram.com/the.mifi",
|
||||
"https://www.instagram.com/mifi.no.brasil",
|
||||
"https://facebook.com/mifi79",
|
||||
"https://youtube.com/@the-real-mifi",
|
||||
"https://www.tiktok.com/@the.mifi",
|
||||
"https://www.snapchat.com/add/the.mifi",
|
||||
"https://www.discord.com/users/the_mifi",
|
||||
"https://www.strava.com/athletes/the-mifi",
|
||||
"https://flickr.com/people/michael-gerard"
|
||||
]
|
||||
},
|
||||
"linksHeading": "Links and Profiles",
|
||||
"showContact": false,
|
||||
"qrCodeImage": null
|
||||
}
|
||||
},
|
||||
"contactLinks": [
|
||||
{
|
||||
"href": "https://cal.mifi.ventures/the-mifi",
|
||||
"icon": "Calendar",
|
||||
"label": "Book a meeting",
|
||||
"description": "Book time. No games, no gatekeeping.",
|
||||
"utmContent": "contact-panel",
|
||||
"variants": ["dev", "bio"]
|
||||
}
|
||||
],
|
||||
"sections": [
|
||||
{
|
||||
"id": "professional",
|
||||
"title": "Professional / Code",
|
||||
"order": {
|
||||
"bio": 2,
|
||||
"dev": 0
|
||||
},
|
||||
"links": [
|
||||
{
|
||||
"href": "https://mifi.ventures",
|
||||
"icon": "Mifi",
|
||||
"label": "mifi Ventures",
|
||||
"description": "The LLC. Where the real work happens (and the invoices get paid).",
|
||||
"utmContent": "mifi-ventures",
|
||||
"variants": ["dev", "bio"]
|
||||
},
|
||||
{
|
||||
"href": "https://cal.mifi.ventures/the-mifi",
|
||||
"icon": "Calendar",
|
||||
"label": "Cal.com",
|
||||
"description": "Book time. No games, no gatekeeping.",
|
||||
"utmContent": "cal",
|
||||
"variants": []
|
||||
},
|
||||
{
|
||||
"href": "https://github.com/the-mifi",
|
||||
"label": "GitHub",
|
||||
"description": "Code, commits, and the occasional typo in prod.",
|
||||
"variants": ["dev", "bio"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "professional-link-site",
|
||||
"title": "Professional Link Site",
|
||||
"order": {
|
||||
"bio": 2,
|
||||
"dev": null
|
||||
},
|
||||
"links": [
|
||||
{
|
||||
"href": "https://mifi.dev",
|
||||
"icon": "Mifi",
|
||||
"label": "mifi.dev",
|
||||
"description": "The professional side. Suits optional.",
|
||||
"utmContent": "mifi-dev",
|
||||
"variants": ["bio"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "resume",
|
||||
"title": "Resumes",
|
||||
"order": {
|
||||
"bio": 3,
|
||||
"dev": 1
|
||||
},
|
||||
"links": [
|
||||
{
|
||||
"href": "https://mifi.dev/downloads/resume-2026c.pdf",
|
||||
"icon": "Resume",
|
||||
"label": "Contract",
|
||||
"description": "Need an engineering gun-for-hire? I do that.",
|
||||
"utmContent": "resume-c",
|
||||
"variants": ["dev", "bio"]
|
||||
},
|
||||
{
|
||||
"href": "https://mifi.dev/downloads/resume-2026p.pdf",
|
||||
"icon": "Resume",
|
||||
"label": "Permanent",
|
||||
"description": "I'm open to dedicated, long-term engagements, too.",
|
||||
"utmContent": "resume-p",
|
||||
"variants": ["dev", "bio"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "social",
|
||||
"title": "Social",
|
||||
"order": {
|
||||
"bio": 0,
|
||||
"dev": 1
|
||||
},
|
||||
"links": [
|
||||
{
|
||||
"href": "https://www.discord.com/users/the_mifi",
|
||||
"label": "Discord",
|
||||
"description": "Where I lurk when I should be working.",
|
||||
"variants": ["bio"]
|
||||
},
|
||||
{
|
||||
"href": "https://facebook.com/mifi79",
|
||||
"label": "Facebook",
|
||||
"description": "Yes, I'm still here. Don't @ me, poke me, or whatever we're doing these days",
|
||||
"variants": ["bio"]
|
||||
},
|
||||
{
|
||||
"href": "https://flickr.com/people/michael-gerard",
|
||||
"label": "Flickr",
|
||||
"description": "Where I used tostore my photos and videos... an archive of the ancient past.",
|
||||
"variants": ["bio"]
|
||||
},
|
||||
{
|
||||
"href": "https://www.instagram.com/the.mifi",
|
||||
"icon": "Instagram",
|
||||
"label": "Instagram (US)",
|
||||
"description": "Visual diary. Update frequency: whenever I remember.",
|
||||
"variants": ["bio"]
|
||||
},
|
||||
{
|
||||
"href": "https://www.instagram.com/mifi.no.brasil",
|
||||
"icon": "Instagram",
|
||||
"label": "Instagram (Brazil)",
|
||||
"description": "American recipes, Portuguese practice, and Reel Time chaos.",
|
||||
"variants": ["bio"]
|
||||
},
|
||||
{
|
||||
"href": "https://www.linkedin.com/in/the-mifi",
|
||||
"label": "LinkedIn",
|
||||
"description": "Where I pretend to be professional (it's mostly true).",
|
||||
"variants": ["dev", "bio"]
|
||||
},
|
||||
{
|
||||
"href": "https://www.snapchat.com/add/the.mifi",
|
||||
"label": "Snapchat",
|
||||
"description": "Ephemeral nonsense. You know the deal.",
|
||||
"variants": ["bio"]
|
||||
},
|
||||
{
|
||||
"href": "https://www.strava.com/athletes/the-mifi",
|
||||
"label": "Strava",
|
||||
"description": "Where I track my rides and other outdoor activities.",
|
||||
"variants": ["bio"]
|
||||
},
|
||||
{
|
||||
"href": "https://www.tiktok.com/@the.mifi",
|
||||
"label": "TikTok",
|
||||
"description": "Short-form chaos. You've been warned.",
|
||||
"variants": ["bio"]
|
||||
},
|
||||
{
|
||||
"href": "https://youtube.com/@the-real-mifi",
|
||||
"label": "YouTube",
|
||||
"description": "Drones, vibes, and the occasional crash.",
|
||||
"variants": ["bio"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
76
src/lib/data/types.test.ts
Normal file
76
src/lib/data/types.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { ContentVariant, Link, ProcessedLink, Site, Section, ContentData } from './types';
|
||||
import { ContentVariant as ContentVariantEnum } from './constants';
|
||||
|
||||
/**
|
||||
* Runtime shape checks for types. TypeScript types are erased at runtime;
|
||||
* these tests ensure our fixtures and expected structures match the documented shape.
|
||||
*/
|
||||
describe('types (runtime shape)', () => {
|
||||
it('ContentVariant type aligns with constants', () => {
|
||||
const variants: ContentVariant[] = [ContentVariantEnum.DEV, ContentVariantEnum.BIO];
|
||||
expect(variants).toEqual(['dev', 'bio']);
|
||||
});
|
||||
|
||||
it('Link has required fields and variants array', () => {
|
||||
const link: Link = {
|
||||
href: 'https://example.com',
|
||||
label: 'Example',
|
||||
variants: ['dev'],
|
||||
};
|
||||
expect(link).toHaveProperty('href');
|
||||
expect(link).toHaveProperty('label');
|
||||
expect(link).toHaveProperty('variants');
|
||||
expect(Array.isArray(link.variants)).toBe(true);
|
||||
});
|
||||
|
||||
it('ProcessedLink omits variants and utmContent', () => {
|
||||
const processed: ProcessedLink = {
|
||||
href: 'https://example.com',
|
||||
label: 'Example',
|
||||
};
|
||||
expect(processed).toHaveProperty('href');
|
||||
expect(processed).toHaveProperty('label');
|
||||
expect(processed).not.toHaveProperty('variants');
|
||||
expect(processed).not.toHaveProperty('utmContent');
|
||||
});
|
||||
|
||||
it('Site has required fields', () => {
|
||||
const site: Site = {
|
||||
title: 'Test',
|
||||
metaDescription: 'Desc',
|
||||
url: 'https://mifi.dev',
|
||||
};
|
||||
expect(site).toHaveProperty('title');
|
||||
expect(site).toHaveProperty('metaDescription');
|
||||
expect(site).toHaveProperty('url');
|
||||
});
|
||||
|
||||
it('Section has id, title, order, links', () => {
|
||||
const section: Section = {
|
||||
id: 'test',
|
||||
title: 'Test Section',
|
||||
order: { dev: 0, bio: 1 },
|
||||
links: [],
|
||||
};
|
||||
expect(section).toHaveProperty('id');
|
||||
expect(section).toHaveProperty('title');
|
||||
expect(section).toHaveProperty('order');
|
||||
expect(section).toHaveProperty('links');
|
||||
expect(section.order).toHaveProperty('dev');
|
||||
expect(section.order).toHaveProperty('bio');
|
||||
});
|
||||
|
||||
it('ContentData has siteByVariant, contactLinks, sections', () => {
|
||||
const data: ContentData = {
|
||||
siteByVariant: { dev: {} as Site, bio: {} as Site },
|
||||
contactLinks: [],
|
||||
sections: [],
|
||||
};
|
||||
expect(data).toHaveProperty('siteByVariant');
|
||||
expect(data).toHaveProperty('contactLinks');
|
||||
expect(data).toHaveProperty('sections');
|
||||
expect(data.siteByVariant).toHaveProperty('dev');
|
||||
expect(data.siteByVariant).toHaveProperty('bio');
|
||||
});
|
||||
});
|
||||
65
src/lib/data/types.ts
Normal file
65
src/lib/data/types.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
ContentVariant as ContentVariantEnum,
|
||||
HeroLayout as HeroLayoutEnum,
|
||||
AvatarVariant as AvatarVariantEnum,
|
||||
} from './constants';
|
||||
|
||||
import type { IconName } from '$lib/components/LinkIcon.svelte';
|
||||
|
||||
/**
|
||||
* Content types for links.json. Used at build time in +layout.server.ts.
|
||||
*/
|
||||
|
||||
export type ContentVariant = `${ContentVariantEnum}`;
|
||||
|
||||
export type HeroLayout = `${HeroLayoutEnum}`;
|
||||
export type ProfileImageName = `${AvatarVariantEnum}`;
|
||||
|
||||
export interface Site {
|
||||
title: string;
|
||||
metaDescription: string;
|
||||
url: string;
|
||||
heroLayout?: HeroLayout;
|
||||
profileImage?: ProfileImageName;
|
||||
pronunciation?: string;
|
||||
pronouns?: string;
|
||||
location?: string;
|
||||
person?: {
|
||||
name: string;
|
||||
sameAs: string[];
|
||||
};
|
||||
linksHeading?: string;
|
||||
/** If false, hide Contact button and panel for this variant. Default true. */
|
||||
showContact?: boolean;
|
||||
/** Contact panel links; if omitted, first section links are used. */
|
||||
contactLinks?: ContactLink[];
|
||||
/** Optional QR code image path (e.g. /assets/images/qr-mifi-dev.png) for Share panel. */
|
||||
qrCodeImage?: string | null;
|
||||
}
|
||||
|
||||
export interface Link {
|
||||
href: string;
|
||||
icon?: IconName;
|
||||
label: string;
|
||||
description?: string;
|
||||
variants: ContentVariant[];
|
||||
utmContent?: string;
|
||||
}
|
||||
|
||||
export type ContactLink = Link;
|
||||
|
||||
export type ProcessedLink = Omit<Link, 'variants' | 'utmContent'>;
|
||||
|
||||
export interface Section {
|
||||
id: string;
|
||||
title: string;
|
||||
/** The zero-based order of the links in the section. If null, the section is not shown for that variant. */
|
||||
order: Record<ContentVariant, number | null>;
|
||||
links: Link[];
|
||||
}
|
||||
|
||||
export interface ContentData {
|
||||
siteByVariant: Record<ContentVariant, Site>;
|
||||
contactLinks: ContactLink[];
|
||||
sections: Section[];
|
||||
}
|
||||
67
src/lib/fonts.css
Normal file
67
src/lib/fonts.css
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Self-hosted fonts in static/assets/fonts/ (Google Fonts–style filenames).
|
||||
* Plus Jakarta Sans 700 (wordmark), Fraunces 500 (headings), Inter 400/500/600 (body).
|
||||
*
|
||||
* Wordmark fi ligature: Google’s latin woff2 subsets often omit GSUB ligature tables.
|
||||
* Re-subset the full Bold TTF/OTF with ligatures kept (see README “Fonts” section):
|
||||
* pyftsubset /path/to/PlusJakartaSans-Bold.ttf --output-file=static/assets/fonts/plus-jakarta-sans-700-liga.woff2
|
||||
* --flavor=woff2 --layout-features='liga','clig' --unicodes='U+0020-007F,U+00A0-00FF,U+FB01,U+FB02'
|
||||
* Then change the url() below to plus-jakarta-sans-700-liga.woff2.
|
||||
*/
|
||||
|
||||
@font-face {
|
||||
font-family: 'Plus Jakarta Sans';
|
||||
src: url('/assets/fonts/plus-jakarta-sans-v12-latin-700.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Plus Jakarta Sans';
|
||||
src: url('/assets/fonts/plus-jakarta-sans-v12-latin-500.woff2') format('woff2');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Plus Jakarta Sans';
|
||||
src: url('/assets/fonts/plus-jakarta-sans-v12-latin-regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Fraunces;
|
||||
src: url('/assets/fonts/fraunces-variable-opsz-wght.woff2') format('woff2');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-optical-sizing: auto;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Inter;
|
||||
src: url('/assets/fonts/inter-v20-latin-regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Inter;
|
||||
src: url('/assets/fonts/inter-v20-latin-500.woff2') format('woff2');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Inter;
|
||||
src: url('/assets/fonts/inter-v20-latin-600.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
97
src/lib/theme.test.ts
Normal file
97
src/lib/theme.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { getStoredTheme, setTheme } from './theme';
|
||||
|
||||
describe('theme', () => {
|
||||
const STORAGE_KEY = 'mifi-theme';
|
||||
let localStorageMock: Record<string, string>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageMock = {};
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => localStorageMock[key] ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
localStorageMock[key] = value;
|
||||
},
|
||||
removeItem: () => {},
|
||||
clear: () => {},
|
||||
length: 0,
|
||||
key: () => null,
|
||||
});
|
||||
vi.stubGlobal('document', {
|
||||
documentElement: {
|
||||
setAttribute: vi.fn(),
|
||||
removeAttribute: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('getStoredTheme', () => {
|
||||
it('returns "auto" when window is undefined', () => {
|
||||
const originalWindow = globalThis.window;
|
||||
// @ts-expect-error unsetting for test
|
||||
delete globalThis.window;
|
||||
expect(getStoredTheme()).toBe('auto');
|
||||
globalThis.window = originalWindow;
|
||||
});
|
||||
|
||||
it('returns stored value when it is light, dark, or auto', () => {
|
||||
vi.stubGlobal('window', {});
|
||||
localStorageMock[STORAGE_KEY] = 'light';
|
||||
expect(getStoredTheme()).toBe('light');
|
||||
localStorageMock[STORAGE_KEY] = 'dark';
|
||||
expect(getStoredTheme()).toBe('dark');
|
||||
localStorageMock[STORAGE_KEY] = 'auto';
|
||||
expect(getStoredTheme()).toBe('auto');
|
||||
});
|
||||
|
||||
it('returns "auto" when stored value is invalid', () => {
|
||||
vi.stubGlobal('window', {});
|
||||
localStorageMock[STORAGE_KEY] = 'invalid';
|
||||
expect(getStoredTheme()).toBe('auto');
|
||||
localStorageMock[STORAGE_KEY] = '';
|
||||
expect(getStoredTheme()).toBe('auto');
|
||||
});
|
||||
|
||||
it('returns "auto" when nothing is stored', () => {
|
||||
vi.stubGlobal('window', {});
|
||||
expect(getStoredTheme()).toBe('auto');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setTheme', () => {
|
||||
it('does nothing when window is undefined', () => {
|
||||
const originalWindow = globalThis.window;
|
||||
// @ts-expect-error unsetting for test
|
||||
delete globalThis.window;
|
||||
expect(() => setTheme('light')).not.toThrow();
|
||||
globalThis.window = originalWindow;
|
||||
});
|
||||
|
||||
it('sets localStorage and data-theme for light and dark', () => {
|
||||
vi.stubGlobal('window', {});
|
||||
setTheme('light');
|
||||
expect(localStorageMock[STORAGE_KEY]).toBe('light');
|
||||
expect(document.documentElement.setAttribute).toHaveBeenCalledWith(
|
||||
'data-theme',
|
||||
'light',
|
||||
);
|
||||
setTheme('dark');
|
||||
expect(localStorageMock[STORAGE_KEY]).toBe('dark');
|
||||
expect(document.documentElement.setAttribute).toHaveBeenCalledWith(
|
||||
'data-theme',
|
||||
'dark',
|
||||
);
|
||||
});
|
||||
|
||||
it('removes data-theme for auto', () => {
|
||||
vi.stubGlobal('window', {});
|
||||
setTheme('auto');
|
||||
expect(localStorageMock[STORAGE_KEY]).toBe('auto');
|
||||
expect(document.documentElement.removeAttribute).toHaveBeenCalledWith('data-theme');
|
||||
});
|
||||
});
|
||||
});
|
||||
25
src/lib/theme.ts
Normal file
25
src/lib/theme.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Theme: light | dark | auto. Stored in localStorage as 'mifi-theme'.
|
||||
* Default is auto (no attribute; CSS uses prefers-color-scheme).
|
||||
*/
|
||||
|
||||
export type ThemeMode = 'light' | 'dark' | 'auto';
|
||||
|
||||
const STORAGE_KEY = 'mifi-theme';
|
||||
|
||||
export function getStoredTheme(): ThemeMode {
|
||||
if (typeof window === 'undefined') return 'auto';
|
||||
const t = localStorage.getItem(STORAGE_KEY);
|
||||
if (t === 'light' || t === 'dark' || t === 'auto') return t;
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
export function setTheme(mode: ThemeMode): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem(STORAGE_KEY, mode);
|
||||
if (mode === 'light' || mode === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', mode);
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
}
|
||||
103
src/lib/utils/getProcessedLinks.test.ts
Normal file
103
src/lib/utils/getProcessedLinks.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getProcessedLinks } from './getProcessedLinks';
|
||||
import type { ContentVariant, Link } from '$lib/data/types';
|
||||
|
||||
vi.mock('$lib/config', () => ({
|
||||
appendUtmParams: vi.fn((href: string, _sourceHost: string, utmContent?: string) => {
|
||||
const url = new URL(href, 'https://mifi.dev');
|
||||
if (utmContent) url.searchParams.set('utm_content', utmContent);
|
||||
return url.toString();
|
||||
}),
|
||||
VARIANT_HOSTS: { dev: 'mifi.dev', bio: 'mifi.bio' },
|
||||
}));
|
||||
|
||||
function link(overrides: Partial<Link> & { variants: ContentVariant[] }): Link {
|
||||
return {
|
||||
href: 'https://example.com',
|
||||
label: 'Example',
|
||||
variants: ['dev', 'bio'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('getProcessedLinks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns empty array when links is empty', () => {
|
||||
expect(getProcessedLinks([], 'dev')).toEqual([]);
|
||||
expect(getProcessedLinks([], 'bio')).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters out links that do not include the variant', () => {
|
||||
const links: Link[] = [
|
||||
link({ label: 'Dev only', variants: ['dev'] }),
|
||||
link({ label: 'Bio only', variants: ['bio'] }),
|
||||
];
|
||||
expect(getProcessedLinks(links, 'dev')).toHaveLength(1);
|
||||
expect(getProcessedLinks(links, 'dev')[0].label).toBe('Dev only');
|
||||
expect(getProcessedLinks(links, 'bio')).toHaveLength(1);
|
||||
expect(getProcessedLinks(links, 'bio')[0].label).toBe('Bio only');
|
||||
});
|
||||
|
||||
it('maps each link to ProcessedLink (href, icon, label, description) without variants or utmContent', () => {
|
||||
const links: Link[] = [
|
||||
link({
|
||||
href: 'https://mifi.dev/blog',
|
||||
label: 'Blog',
|
||||
description: 'My blog',
|
||||
icon: 'Mifi',
|
||||
variants: ['dev'],
|
||||
utmContent: 'nav',
|
||||
}),
|
||||
];
|
||||
const result = getProcessedLinks(links, 'dev');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
label: 'Blog',
|
||||
description: 'My blog',
|
||||
icon: 'Mifi',
|
||||
});
|
||||
expect(result[0]).not.toHaveProperty('variants');
|
||||
expect(result[0]).not.toHaveProperty('utmContent');
|
||||
expect(result[0].href).toContain('https://');
|
||||
expect(result[0].href).toContain('utm_content=nav');
|
||||
});
|
||||
|
||||
it('uses getValueForVariant for icon, label, and description (per-variant values)', () => {
|
||||
const links: Link[] = [
|
||||
link({
|
||||
label: { dev: 'Dev label', bio: 'Bio label' } as unknown as string,
|
||||
description: { dev: 'Dev desc', bio: 'Bio desc' } as unknown as string,
|
||||
icon: { dev: 'GitHub', bio: 'LinkedIn' } as unknown as Link['icon'],
|
||||
variants: ['dev', 'bio'],
|
||||
}),
|
||||
];
|
||||
const devResult = getProcessedLinks(links, 'dev');
|
||||
const bioResult = getProcessedLinks(links, 'bio');
|
||||
expect(devResult[0].label).toBe('Dev label');
|
||||
expect(devResult[0].description).toBe('Dev desc');
|
||||
expect(devResult[0].icon).toBe('GitHub');
|
||||
expect(bioResult[0].label).toBe('Bio label');
|
||||
expect(bioResult[0].description).toBe('Bio desc');
|
||||
expect(bioResult[0].icon).toBe('LinkedIn');
|
||||
});
|
||||
|
||||
it('passes sourceHost from VARIANT_HOSTS to appendUtmParams', async () => {
|
||||
const { appendUtmParams } = await import('$lib/config');
|
||||
const links: Link[] = [link({ href: 'https://mifi.dev/x', variants: ['dev', 'bio'] })];
|
||||
getProcessedLinks(links, 'dev');
|
||||
expect(appendUtmParams).toHaveBeenCalledWith('https://mifi.dev/x', 'mifi.dev', undefined);
|
||||
vi.clearAllMocks();
|
||||
getProcessedLinks(links, 'bio');
|
||||
expect(appendUtmParams).toHaveBeenCalledWith('https://mifi.dev/x', 'mifi.bio', undefined);
|
||||
});
|
||||
|
||||
it('passes utmContent to appendUtmParams when present', async () => {
|
||||
const { appendUtmParams } = await import('$lib/config');
|
||||
const links: Link[] = [link({ utmContent: 'hero', variants: ['dev'] })];
|
||||
getProcessedLinks(links, 'dev');
|
||||
expect(appendUtmParams).toHaveBeenCalledWith(expect.any(String), 'mifi.dev', 'hero');
|
||||
});
|
||||
});
|
||||
17
src/lib/utils/getProcessedLinks.ts
Normal file
17
src/lib/utils/getProcessedLinks.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { appendUtmParams, VARIANT_HOSTS } from '$lib/config';
|
||||
import type { ContentVariant } from '$lib/data/constants';
|
||||
import type { Link, ProcessedLink } from '$lib/data/types';
|
||||
import { getValueForVariant } from './getValueForVariant';
|
||||
|
||||
import { isShowForVariant } from './isShowForVariant';
|
||||
|
||||
export const getProcessedLinks = (links: Link[], variant: ContentVariant): ProcessedLink[] => {
|
||||
const sourceHost = VARIANT_HOSTS[variant];
|
||||
|
||||
return links.filter(isShowForVariant(variant)).map((link) => ({
|
||||
href: appendUtmParams(link.href, sourceHost, link.utmContent),
|
||||
icon: getValueForVariant(link.icon, variant),
|
||||
label: getValueForVariant(link.label, variant) as string,
|
||||
description: getValueForVariant(link.description, variant),
|
||||
}));
|
||||
};
|
||||
44
src/lib/utils/getValueForVariant.test.ts
Normal file
44
src/lib/utils/getValueForVariant.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getValueForVariant } from './getValueForVariant';
|
||||
import type { ContentVariant } from '$lib/data/types';
|
||||
|
||||
describe('getValueForVariant', () => {
|
||||
it('returns the value when it is a plain string (not a record)', () => {
|
||||
expect(getValueForVariant('Same for all', 'dev')).toBe('Same for all');
|
||||
expect(getValueForVariant('Same for all', 'bio')).toBe('Same for all');
|
||||
});
|
||||
|
||||
it('returns the value when it is a plain number (not a record)', () => {
|
||||
expect(getValueForVariant(42, 'dev')).toBe(42);
|
||||
expect(getValueForVariant(42, 'bio')).toBe(42);
|
||||
});
|
||||
|
||||
it('returns the variant key when value is a Record<ContentVariant, T>', () => {
|
||||
const perVariant = { dev: 'Dev label', bio: 'Bio label' };
|
||||
expect(getValueForVariant(perVariant, 'dev')).toBe('Dev label');
|
||||
expect(getValueForVariant(perVariant, 'bio')).toBe('Bio label');
|
||||
});
|
||||
|
||||
it('returns undefined when record has no key for variant', () => {
|
||||
const partial = { dev: 'Only dev' } as Record<ContentVariant, string>;
|
||||
expect(getValueForVariant(partial, 'dev')).toBe('Only dev');
|
||||
expect(getValueForVariant(partial, 'bio')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when record value for variant is undefined', () => {
|
||||
const withUndefined = { dev: 'Dev', bio: undefined };
|
||||
expect(getValueForVariant(withUndefined, 'dev')).toBe('Dev');
|
||||
expect(getValueForVariant(withUndefined, 'bio')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for object without variant key (e.g. array)', () => {
|
||||
const arr = ['a', 'b'];
|
||||
expect(
|
||||
getValueForVariant(arr as unknown as Record<ContentVariant, string>, 'dev'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles null by returning it as T (no indexing)', () => {
|
||||
expect(getValueForVariant(null as unknown as string, 'dev')).toBeNull();
|
||||
});
|
||||
});
|
||||
14
src/lib/utils/getValueForVariant.ts
Normal file
14
src/lib/utils/getValueForVariant.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ContentVariant } from '$lib/data/constants';
|
||||
|
||||
export function getValueForVariant<T = string>(
|
||||
value: T | Record<ContentVariant, T>,
|
||||
variant: ContentVariant,
|
||||
): T | undefined {
|
||||
if (typeof value === 'object' && value !== null && variant in value) {
|
||||
return (value as Record<ContentVariant, T>)[variant] ?? undefined;
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return undefined;
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
52
src/lib/utils/isShowForVariant.test.ts
Normal file
52
src/lib/utils/isShowForVariant.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isShowForVariant } from './isShowForVariant';
|
||||
import type { ContentVariant, Link } from '$lib/data/types';
|
||||
|
||||
function link(overrides: Partial<Link> & { variants: ContentVariant[] }): Link {
|
||||
return {
|
||||
href: 'https://example.com',
|
||||
label: 'Example',
|
||||
variants: overrides.variants,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('isShowForVariant', () => {
|
||||
it('returns a predicate function', () => {
|
||||
const predicate = isShowForVariant('dev');
|
||||
expect(typeof predicate).toBe('function');
|
||||
expect(predicate.length).toBe(1);
|
||||
});
|
||||
|
||||
it('returns true when link.variants includes the variant', () => {
|
||||
const forDev = isShowForVariant('dev');
|
||||
expect(forDev(link({ variants: ['dev'] }))).toBe(true);
|
||||
expect(forDev(link({ variants: ['dev', 'bio'] }))).toBe(true);
|
||||
expect(forDev(link({ variants: ['bio', 'dev'] }))).toBe(true);
|
||||
|
||||
const forBio = isShowForVariant('bio');
|
||||
expect(forBio(link({ variants: ['bio'] }))).toBe(true);
|
||||
expect(forBio(link({ variants: ['dev', 'bio'] }))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when link.variants does not include the variant', () => {
|
||||
const forDev = isShowForVariant('dev');
|
||||
expect(forDev(link({ variants: ['bio'] }))).toBe(false);
|
||||
expect(forDev(link({ variants: [] }))).toBe(false);
|
||||
|
||||
const forBio = isShowForVariant('bio');
|
||||
expect(forBio(link({ variants: ['dev'] }))).toBe(false);
|
||||
expect(forBio(link({ variants: [] }))).toBe(false);
|
||||
});
|
||||
|
||||
it('works when used with Array.prototype.filter', () => {
|
||||
const links: Link[] = [
|
||||
link({ label: 'A', variants: ['dev'] }),
|
||||
link({ label: 'B', variants: ['bio'] }),
|
||||
link({ label: 'C', variants: ['dev', 'bio'] }),
|
||||
];
|
||||
expect(links.filter(isShowForVariant('dev'))).toHaveLength(2);
|
||||
expect(links.filter(isShowForVariant('bio'))).toHaveLength(2);
|
||||
expect(links.filter(isShowForVariant('dev')).map((l) => l.label)).toEqual(['A', 'C']);
|
||||
});
|
||||
});
|
||||
7
src/lib/utils/isShowForVariant.ts
Normal file
7
src/lib/utils/isShowForVariant.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { ContentVariant, Link } from '$lib/data/types';
|
||||
|
||||
export const isShowForVariant =
|
||||
(variant: ContentVariant) =>
|
||||
(link: Link): boolean => {
|
||||
return link.variants.includes(variant);
|
||||
};
|
||||
27
src/lib/utm.test.ts
Normal file
27
src/lib/utm.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { appendUtmParams } from './utm';
|
||||
|
||||
describe('utm', () => {
|
||||
describe('appendUtmParams', () => {
|
||||
it('appends utm params to own-property URL', () => {
|
||||
const href = 'https://mifi.dev/page';
|
||||
const result = appendUtmParams(href, 'mifi.dev');
|
||||
const url = new URL(result);
|
||||
expect(url.searchParams.get('utm_source')).toBe('mifi.dev');
|
||||
expect(url.searchParams.get('utm_medium')).toBe('link');
|
||||
expect(url.searchParams.get('utm_campaign')).toBe('landing');
|
||||
});
|
||||
|
||||
it('appends utm_content when provided', () => {
|
||||
const href = 'https://mifi.bio/';
|
||||
const result = appendUtmParams(href, 'mifi.bio', 'hero');
|
||||
const url = new URL(result);
|
||||
expect(url.searchParams.get('utm_content')).toBe('hero');
|
||||
});
|
||||
|
||||
it('returns href unchanged for non-own-property host', () => {
|
||||
const href = 'https://example.com/page';
|
||||
expect(appendUtmParams(href, 'mifi.dev')).toBe(href);
|
||||
});
|
||||
});
|
||||
});
|
||||
28
src/lib/utm.ts
Normal file
28
src/lib/utm.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Append UTM params to own-property URLs for attribution.
|
||||
*/
|
||||
|
||||
import { UTM_MEDIUM, UTM_CAMPAIGN } from '$lib/config';
|
||||
|
||||
const OWN_PROPERTY_HOSTS = ['mifi.ventures', 'cal.mifi.ventures', 'mifi.dev', 'mifi.bio'];
|
||||
|
||||
/**
|
||||
* Returns href with UTM params appended if the URL's host is an own property.
|
||||
* Respects existing query params.
|
||||
*/
|
||||
export function appendUtmParams(href: string, sourceHost: string, utmContent?: string): string {
|
||||
try {
|
||||
const url = new URL(href, 'https://mifi.dev');
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
if (!OWN_PROPERTY_HOSTS.includes(hostname)) return href;
|
||||
const params = new URLSearchParams(url.search);
|
||||
params.set('utm_source', sourceHost);
|
||||
params.set('utm_medium', UTM_MEDIUM);
|
||||
params.set('utm_campaign', UTM_CAMPAIGN);
|
||||
if (utmContent) params.set('utm_content', utmContent);
|
||||
url.search = params.toString();
|
||||
return url.toString();
|
||||
} catch {
|
||||
return href;
|
||||
}
|
||||
}
|
||||
68
src/routes/+layout.server.ts
Normal file
68
src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import contentData from '$lib/data/links.json';
|
||||
import { VARIANT_HOSTS, GA_MEASUREMENT_IDS } from '$lib/config';
|
||||
import type { Site, ContentData, ProcessedLink } from '$lib/data/types';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import { ContentVariant, HeroLayout } from '$lib/data/constants';
|
||||
import { getProcessedLinks } from '$lib/utils/getProcessedLinks';
|
||||
|
||||
export type LayoutServerDataOut = {
|
||||
site: Site;
|
||||
contactLinks?: ProcessedLink[];
|
||||
links: {
|
||||
sections: {
|
||||
id: string;
|
||||
title: string;
|
||||
order: number;
|
||||
links: ProcessedLink[];
|
||||
}[];
|
||||
};
|
||||
variant: string;
|
||||
gaMeasurementId: string;
|
||||
};
|
||||
|
||||
export const load: LayoutServerLoad<LayoutServerDataOut> = (): LayoutServerDataOut => {
|
||||
const variant =
|
||||
process.env.CONTENT_VARIANT === ContentVariant.BIO
|
||||
? ContentVariant.BIO
|
||||
: ContentVariant.DEV;
|
||||
const sourceHost = VARIANT_HOSTS[variant];
|
||||
const siteUrl = 'https://' + sourceHost;
|
||||
const data = contentData as ContentData;
|
||||
const contactLinks = getProcessedLinks(data.contactLinks, variant);
|
||||
const sections = data.sections
|
||||
.map((section) => {
|
||||
const links: ProcessedLink[] = getProcessedLinks(section.links, variant);
|
||||
return {
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
links,
|
||||
order: section.order[variant] ?? null,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(s) => s.links.length > 0 && s.order !== null,
|
||||
) as LayoutServerDataOut['links']['sections'];
|
||||
const siteDef = data.siteByVariant[variant];
|
||||
const site: LayoutServerDataOut['site'] = {
|
||||
title: siteDef?.title ?? (variant === ContentVariant.DEV ? 'mifi.dev' : 'mifi.bio'),
|
||||
metaDescription: siteDef?.metaDescription ?? '',
|
||||
url: siteUrl,
|
||||
heroLayout: siteDef?.heroLayout ?? HeroLayout.SIDE_BY_SIDE,
|
||||
profileImage: siteDef?.profileImage,
|
||||
pronunciation: siteDef?.pronunciation,
|
||||
pronouns: siteDef?.pronouns,
|
||||
location: siteDef?.location,
|
||||
person: siteDef?.person,
|
||||
linksHeading: siteDef?.linksHeading,
|
||||
showContact: siteDef?.showContact,
|
||||
contactLinks: siteDef?.contactLinks,
|
||||
qrCodeImage: siteDef?.qrCodeImage ?? undefined,
|
||||
};
|
||||
return {
|
||||
site,
|
||||
contactLinks,
|
||||
links: { sections },
|
||||
variant,
|
||||
gaMeasurementId: GA_MEASUREMENT_IDS[variant],
|
||||
};
|
||||
};
|
||||
47
src/routes/+layout.svelte
Normal file
47
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
export let data: LayoutData;
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite' as const,
|
||||
name: data.site.title,
|
||||
url: data.site.url,
|
||||
description: data.site.metaDescription,
|
||||
};
|
||||
|
||||
$: personLd = data.site.person
|
||||
? {
|
||||
'@context': 'https://schema.org' as const,
|
||||
'@type': 'Person' as const,
|
||||
name: data.site.person.name,
|
||||
url: data.site.url,
|
||||
sameAs: data.site.person.sameAs,
|
||||
}
|
||||
: null;
|
||||
|
||||
// Inject as HTML to avoid Prettier parsing ld+json script body as JS (Babel syntax error)
|
||||
const ldJsonTag = (payload: string) =>
|
||||
'<' + 'script type="application/ld+json">' + payload + '<' + '/script>';
|
||||
$: jsonLdHtml = ldJsonTag(JSON.stringify(jsonLd));
|
||||
$: personLdHtml = personLd != null ? ldJsonTag(JSON.stringify(personLd)) : '';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="stylesheet" href="/assets/tokens-{data.variant}.css" />
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- safe: our own JSON.stringify, no user input -->
|
||||
{@html jsonLdHtml}
|
||||
{#if personLdHtml}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- safe: our own JSON.stringify, no user input -->
|
||||
{@html personLdHtml}
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||
<header class="site-header">
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
<slot />
|
||||
1
src/routes/+layout.ts
Normal file
1
src/routes/+layout.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const prerender = true;
|
||||
3
src/routes/+page.server.ts
Normal file
3
src/routes/+page.server.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = ({ parent }) => parent();
|
||||
65
src/routes/+page.svelte
Normal file
65
src/routes/+page.svelte
Normal file
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import ContactPanel from '$lib/components/ContactPanel.svelte';
|
||||
import Footer from '$lib/components/Footer.svelte';
|
||||
import Hero from '$lib/components/Hero.svelte';
|
||||
import LinkGroup from '$lib/components/LinkGroup.svelte';
|
||||
import SharePanel from '$lib/components/SharePanel.svelte';
|
||||
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let shareOpen = $state(false);
|
||||
let contactOpen = $state(false);
|
||||
|
||||
const showContactButton = $derived((data?.contactLinks?.length ?? 0) > 0);
|
||||
|
||||
const shareUrl = $derived(data.site.url);
|
||||
const shareEmailSubject = $derived(`Link from ${data.site.title}`);
|
||||
const shareEmailBody = $derived(`Check out Mike Fitzpatrick's links at: ${shareUrl}`);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.site.title}</title>
|
||||
<meta name="description" content={data.site.metaDescription} />
|
||||
</svelte:head>
|
||||
|
||||
<main id="main-content">
|
||||
<Hero bind:contactOpen bind:shareOpen {showContactButton} {...data.site} {...data.links} />
|
||||
<div class="page">
|
||||
{#each data.links.sections as section}
|
||||
<LinkGroup
|
||||
id={section.id}
|
||||
links={section.links}
|
||||
order={section.order}
|
||||
showHeading={data.links.sections.length > 1}
|
||||
title={section.title}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
<Footer />
|
||||
<SharePanel
|
||||
open={shareOpen}
|
||||
url={shareUrl}
|
||||
qrCodeImage={data.site.qrCodeImage}
|
||||
emailSubject={shareEmailSubject}
|
||||
emailBody={shareEmailBody}
|
||||
onclose={() => (shareOpen = false)}
|
||||
/>
|
||||
<ContactPanel
|
||||
open={contactOpen}
|
||||
links={data.contactLinks}
|
||||
onclose={() => (contactOpen = false)}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin: 0 auto;
|
||||
max-width: 50ch;
|
||||
padding: 4rem 1.5rem 3rem;
|
||||
}
|
||||
</style>
|
||||
101
src/routes/__tests__/layout.server.test.ts
Normal file
101
src/routes/__tests__/layout.server.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { load } from '../+layout.server';
|
||||
import { ContentVariant } from '$lib/data/constants';
|
||||
import type { ContentData } from '$lib/data/types';
|
||||
|
||||
const { mockContentData } = vi.hoisted(() => {
|
||||
const data: ContentData = {
|
||||
siteByVariant: {
|
||||
dev: {
|
||||
title: 'Dev Site',
|
||||
metaDescription: 'Dev desc',
|
||||
url: 'https://mifi.dev',
|
||||
heroLayout: 'side-by-side',
|
||||
},
|
||||
bio: {
|
||||
title: 'Bio Site',
|
||||
metaDescription: 'Bio desc',
|
||||
url: 'https://mifi.bio',
|
||||
heroLayout: 'side-by-side',
|
||||
},
|
||||
},
|
||||
contactLinks: [
|
||||
{
|
||||
href: 'https://mifi.dev/contact',
|
||||
label: 'Contact',
|
||||
variants: ['dev', 'bio'],
|
||||
},
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
id: 'main',
|
||||
title: 'Links',
|
||||
order: { dev: 0, bio: 0 },
|
||||
links: [
|
||||
{
|
||||
href: 'https://mifi.dev/x',
|
||||
label: 'Link',
|
||||
variants: ['dev', 'bio'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
return { mockContentData: data };
|
||||
});
|
||||
|
||||
vi.mock('$lib/data/links.json', () => ({
|
||||
default: mockContentData,
|
||||
}));
|
||||
|
||||
describe('+layout.server', () => {
|
||||
const originalEnv = process.env.CONTENT_VARIANT;
|
||||
|
||||
afterEach(() => {
|
||||
process.env.CONTENT_VARIANT = originalEnv;
|
||||
});
|
||||
|
||||
it('returns layout data with site, contactLinks, links, variant, gaMeasurementId', () => {
|
||||
process.env.CONTENT_VARIANT = ContentVariant.DEV;
|
||||
const result = load();
|
||||
expect(result).toHaveProperty('site');
|
||||
expect(result).toHaveProperty('contactLinks');
|
||||
expect(result).toHaveProperty('links');
|
||||
expect(result).toHaveProperty('variant');
|
||||
expect(result).toHaveProperty('gaMeasurementId');
|
||||
expect(result.variant).toBe('dev');
|
||||
expect(result.site.title).toBe('Dev Site');
|
||||
expect(result.links.sections).toHaveLength(1);
|
||||
expect(result.links.sections[0].links).toHaveLength(1);
|
||||
expect(result.links.sections[0].links[0].label).toBe('Link');
|
||||
});
|
||||
|
||||
it('uses bio variant when CONTENT_VARIANT is bio', () => {
|
||||
process.env.CONTENT_VARIANT = ContentVariant.BIO;
|
||||
const result = load();
|
||||
expect(result.variant).toBe('bio');
|
||||
expect(result.site.title).toBe('Bio Site');
|
||||
});
|
||||
|
||||
it('defaults to dev when CONTENT_VARIANT is not bio', () => {
|
||||
process.env.CONTENT_VARIANT = 'other';
|
||||
const result = load();
|
||||
expect(result.variant).toBe('dev');
|
||||
});
|
||||
|
||||
it('site.url is https + VARIANT_HOSTS[variant]', () => {
|
||||
process.env.CONTENT_VARIANT = ContentVariant.DEV;
|
||||
const result = load();
|
||||
expect(result.site.url).toBe('https://mifi.dev');
|
||||
process.env.CONTENT_VARIANT = ContentVariant.BIO;
|
||||
const resultBio = load();
|
||||
expect(resultBio.site.url).toBe('https://mifi.bio');
|
||||
});
|
||||
|
||||
it('gaMeasurementId matches variant', () => {
|
||||
process.env.CONTENT_VARIANT = ContentVariant.DEV;
|
||||
expect(load().gaMeasurementId).toMatch(/^G-/);
|
||||
process.env.CONTENT_VARIANT = ContentVariant.BIO;
|
||||
expect(load().gaMeasurementId).toMatch(/^G-/);
|
||||
});
|
||||
});
|
||||
24
src/routes/__tests__/layout.test.ts
Normal file
24
src/routes/__tests__/layout.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { LayoutData } from '../$types';
|
||||
|
||||
/**
|
||||
* LayoutData shape used by +layout.svelte. Component itself is covered by e2e.
|
||||
*/
|
||||
describe('+layout (LayoutData)', () => {
|
||||
it('LayoutData shape matches what load returns', () => {
|
||||
const mockData: LayoutData = {
|
||||
site: {
|
||||
title: 'Test',
|
||||
metaDescription: 'Desc',
|
||||
url: 'https://mifi.dev',
|
||||
},
|
||||
links: { sections: [] },
|
||||
variant: 'dev',
|
||||
gaMeasurementId: 'G-xxx',
|
||||
};
|
||||
expect(mockData.site).toHaveProperty('title');
|
||||
expect(mockData.site).toHaveProperty('url');
|
||||
expect(mockData).toHaveProperty('variant');
|
||||
expect(mockData.links).toHaveProperty('sections');
|
||||
});
|
||||
});
|
||||
19
src/routes/__tests__/page.server.test.ts
Normal file
19
src/routes/__tests__/page.server.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { load } from '../+page.server';
|
||||
|
||||
describe('+page.server', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('load returns parent() result', async () => {
|
||||
const mockParent = vi.fn().mockResolvedValue({
|
||||
site: { title: 'Parent' },
|
||||
variant: 'dev',
|
||||
});
|
||||
const event = { parent: mockParent } as Parameters<typeof load>[0];
|
||||
const result = await load(event);
|
||||
expect(mockParent).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual({ site: { title: 'Parent' }, variant: 'dev' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user