initial commit
This commit is contained in:
331
src/app.css
Normal file
331
src/app.css
Normal file
@@ -0,0 +1,331 @@
|
||||
@import url('https://fonts.cdnfonts.com/css/jetbrains-mono-2');
|
||||
|
||||
:root {
|
||||
--line-height: 1.2rem;
|
||||
--border-thickness: 2px;
|
||||
--text-color: black;
|
||||
font-optical-sizing: auto;
|
||||
font-variant-numeric: tabular-nums lining-nums;
|
||||
}
|
||||
|
||||
.dark {
|
||||
:root {
|
||||
--text-color: white;
|
||||
}
|
||||
}
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
text-decoration-thickness: var(--border-thickness);
|
||||
}
|
||||
|
||||
*::selection {
|
||||
@apply bg-black text-white dark:bg-white dark:text-black;
|
||||
}
|
||||
|
||||
.button {
|
||||
border: var(--border-thickness) solid;
|
||||
padding:
|
||||
calc(var(--line-height) / 2 - var(--border-thickness)) calc(1ch - var(--border-thickness));
|
||||
margin: 0;
|
||||
height: calc(var(--line-height) * 2);
|
||||
width: auto;
|
||||
overflow: visible;
|
||||
line-height: normal;
|
||||
-webkit-font-smoothing: inherit;
|
||||
-moz-osx-font-smoothing: inherit;
|
||||
-webkit-appearance: none;
|
||||
|
||||
@apply select-none bg-white dark:bg-black px-1h shadow-box active:shadow-none active:translate-x-[3px] active:translate-y-[3px];
|
||||
}
|
||||
|
||||
.button:focus:not(:active) {
|
||||
--border-thickness: 3px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
hr {
|
||||
@apply h-2v block relative text-black dark:text-white border-none my-1v;
|
||||
}
|
||||
|
||||
hr:after {
|
||||
@apply block absolute left-0 h-0 w-full border-black dark:border-white;
|
||||
content: "";
|
||||
top: calc(var(--line-height) - var(--border-thickness));
|
||||
border-top: calc(var(--border-thickness) * 3) double;
|
||||
}
|
||||
|
||||
.jump-text:hover>.jump-text {
|
||||
animation: jump 0.25s ease-in-out;
|
||||
animation-delay: var(--animation-delay);
|
||||
}
|
||||
|
||||
@keyframes jump {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-7px);
|
||||
}
|
||||
}
|
||||
|
||||
details {
|
||||
border: var(--border-thickness) solid var(--text-color);
|
||||
padding: calc(var(--line-height) - var(--border-thickness)) 1ch;
|
||||
margin-bottom: var(--line-height);
|
||||
margin-top: var(--line-height);
|
||||
}
|
||||
|
||||
summary {
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
details[open] summary {
|
||||
margin-bottom: var(--line-height);
|
||||
}
|
||||
|
||||
details ::marker {
|
||||
display: inline-block;
|
||||
content: '▶';
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
details[open] ::marker {
|
||||
content: '▼';
|
||||
}
|
||||
|
||||
details :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
/* DITHER ANIMATION */
|
||||
.dither {
|
||||
background-repeat: repeat;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
image-rendering: optimizeSpeed;
|
||||
/* STOP SMOOTHING, GIVE ME SPEED */
|
||||
image-rendering: -moz-crisp-edges;
|
||||
/* Firefox */
|
||||
image-rendering: -o-crisp-edges;
|
||||
/* Opera */
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
/* Chrome (and eventually Safari) */
|
||||
image-rendering: pixelated;
|
||||
/* Universal support since 2021 */
|
||||
image-rendering: optimize-contrast;
|
||||
/* CSS3 Proposed */
|
||||
-ms-interpolation-mode: nearest-neighbor;
|
||||
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dark {
|
||||
.dither {
|
||||
filter: invert(1)
|
||||
}
|
||||
.wave-image {
|
||||
filter: invert(1)
|
||||
}
|
||||
}
|
||||
|
||||
.dither-1 {
|
||||
background-image: url(/images/dither_light_3.png);
|
||||
}
|
||||
|
||||
.dither-2 {
|
||||
background-image: url(/images/dither_light_3.png);
|
||||
background-position: 50px 50px;
|
||||
}
|
||||
|
||||
.dither-3 {
|
||||
background-image: url(/images/dither_light_3.png);
|
||||
background-position: 100px 100px;
|
||||
}
|
||||
|
||||
|
||||
.tree,
|
||||
.tree ul {
|
||||
position: relative;
|
||||
padding-left: 0;
|
||||
list-style-type: none;
|
||||
line-height: var(--line-height);
|
||||
}
|
||||
|
||||
.tree ul {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tree ul li {
|
||||
position: relative;
|
||||
padding-left: 1.5ch;
|
||||
margin-left: 1.5ch;
|
||||
border-left: var(--border-thickness) solid var(--text-color);
|
||||
}
|
||||
|
||||
.tree ul li:before {
|
||||
position: absolute;
|
||||
display: block;
|
||||
top: calc(var(--line-height) / 2);
|
||||
left: 0;
|
||||
content: "";
|
||||
width: 1ch;
|
||||
border-bottom: var(--border-thickness) solid var(--text-color);
|
||||
}
|
||||
|
||||
.tree ul li:last-child {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.tree ul li:last-child:after {
|
||||
position: absolute;
|
||||
display: block;
|
||||
top: 0;
|
||||
left: 0;
|
||||
content: "";
|
||||
height: calc(var(--line-height) / 2);
|
||||
border-left: var(--border-thickness) solid var(--text-color);
|
||||
}
|
||||
|
||||
|
||||
/* DEBUG UTILITIES */
|
||||
|
||||
.debug .debug-grid {
|
||||
--line-height: 1.2rem;
|
||||
--text-color: #000000;
|
||||
--background-color: #FFFFFF;
|
||||
--color: color-mix(in srgb, var(--text-color) 10%, var(--background-color) 90%);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: -1;
|
||||
background-image:
|
||||
repeating-linear-gradient(var(--color) 0 1px, transparent 1px 100%),
|
||||
repeating-linear-gradient(90deg, var(--color) 0 1px, transparent 1px 100%);
|
||||
background-size: 1ch var(--line-height);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code:not(pre code) {
|
||||
@apply bg-black text-white dark:bg-white dark:text-black px-1h;
|
||||
@apply selection:dark:bg-black selection:dark:text-white selection:bg-white selection:text-black;
|
||||
}
|
||||
|
||||
.debug .off-grid {
|
||||
background: rgba(255, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.debug-toggle-label {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.debug img {
|
||||
/* border: 1px solid black; */
|
||||
}
|
||||
|
||||
|
||||
.formkit-form {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
.formkit-form [data-style="clean"] {
|
||||
@apply !pt-2v !px-0 !pb-1v;
|
||||
}
|
||||
|
||||
.formkit-fields {
|
||||
@apply !m-0;
|
||||
}
|
||||
|
||||
.formkit-field {
|
||||
@apply !m-0 !mr-2h;
|
||||
}
|
||||
|
||||
.formkit-input {
|
||||
border: var(--border-thickness) solid !important;
|
||||
padding:
|
||||
calc(var(--line-height) / 2 - var(--border-thickness)) calc(1ch - var(--border-thickness)) !important;
|
||||
margin: 0 !important;
|
||||
height: calc(var(--line-height) * 2) !important;
|
||||
width: 100% !important;
|
||||
overflow: visible !important;
|
||||
line-height: normal !important;
|
||||
-webkit-font-smoothing: inherit !important;
|
||||
-moz-osx-font-smoothing: inherit !important;
|
||||
-webkit-appearance: none !important;
|
||||
@apply !font-medium;
|
||||
}
|
||||
|
||||
.formkit-input:focus:not(:active) {
|
||||
--border-thickness: 3px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.formkit-submit {
|
||||
border: var(--border-thickness) solid !important;
|
||||
padding:
|
||||
calc(var(--line-height) / 2 - var(--border-thickness)) calc(1ch - var(--border-thickness)) !important;
|
||||
margin: 0 !important;
|
||||
height: calc(var(--line-height) * 2) !important;
|
||||
width: auto !important;
|
||||
overflow: visible !important;
|
||||
line-height: normal !important;
|
||||
-webkit-font-smoothing: inherit !important;
|
||||
-moz-osx-font-smoothing: inherit !important;
|
||||
-webkit-appearance: none !important;
|
||||
@apply !select-none !bg-white !text-black !px-1h !shadow-box !py-0;
|
||||
}
|
||||
|
||||
.formkit-submit:active {
|
||||
@apply !shadow-none !translate-x-[3px] !translate-y-[3px]
|
||||
}
|
||||
|
||||
.formkit-alert-success {
|
||||
border-width: 0 !important;
|
||||
@apply !bg-transparent !text-black !m-0 !p-0 !font-bold;
|
||||
}
|
||||
|
||||
.dark {
|
||||
.formkit-input {
|
||||
@apply !border-white !bg-black !text-white;
|
||||
}
|
||||
|
||||
.formkit-submit {
|
||||
@apply !bg-black !text-white;
|
||||
}
|
||||
|
||||
.formkit-alert-success {
|
||||
@apply !text-white;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.formkit-submit span {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.formkit-submit:hover span {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.formkit-submit:focus:not(:active) {
|
||||
--border-thickness: 3px !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.pixelated {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
/* <div id="a6d9b30e24" class="formkit-alert formkit-alert-success" data-element="success" data-group="alert">Success! Check your email to confirm the subscription.</div> */
|
5
src/app.css.d.ts
vendored
Normal file
5
src/app.css.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare const styles: {
|
||||
readonly "visually-hidden": string;
|
||||
};
|
||||
export = styles;
|
||||
|
38
src/app.tsx
Normal file
38
src/app.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Router } from "@solidjs/router";
|
||||
import { FileRoutes } from "@solidjs/start/router";
|
||||
import { createSignal, onCleanup, onMount, Suspense } from "solid-js";
|
||||
import "./app.css";
|
||||
import { Layout } from "./components/Layout";
|
||||
import { MetaProvider, Title } from "@solidjs/meta";
|
||||
|
||||
export default function App() {
|
||||
onMount(() => {
|
||||
const listener = (e: KeyboardEvent) => {
|
||||
if (e.metaKey && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault(); // Prevent the default action (optional)
|
||||
document.body.classList.toggle("debug");
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", listener);
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("keydown", listener);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<MetaProvider>
|
||||
<Title>minhtrannhat.com</Title>
|
||||
<Router
|
||||
root={(props) => {
|
||||
return (
|
||||
<Layout>
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</Layout>
|
||||
);
|
||||
}}
|
||||
>
|
||||
<FileRoutes />
|
||||
</Router>
|
||||
</MetaProvider>
|
||||
);
|
||||
}
|
9
src/components/Button.tsx
Normal file
9
src/components/Button.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { ParentComponent } from "solid-js";
|
||||
|
||||
export const Button: ParentComponent<{ onClick?: () => void }> = (props) => {
|
||||
return (
|
||||
<button class="button" type="button" onClick={props.onClick}>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
};
|
69
src/components/DarkModeToggle.tsx
Normal file
69
src/components/DarkModeToggle.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { createSignal, onMount } from "solid-js";
|
||||
|
||||
export const DarkModeToggle = () => {
|
||||
let ref!: HTMLButtonElement;
|
||||
|
||||
const [dark, setDark] = createSignal(false);
|
||||
const size = 64;
|
||||
const max = 5;
|
||||
const time = 70;
|
||||
let direction = dark() ? -1 : 1;
|
||||
let current = dark() ? 0 : max;
|
||||
|
||||
onMount(() => {
|
||||
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
setDark(isDark);
|
||||
if (isDark) {
|
||||
direction = -1;
|
||||
current = 0;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
play();
|
||||
});
|
||||
});
|
||||
|
||||
const coord = (n: number) => -n * size;
|
||||
|
||||
const play = () => {
|
||||
ref.style.backgroundPositionX = `${coord(current)}px`;
|
||||
if (direction === -1 && current === 2) {
|
||||
document.documentElement.classList.add("dark");
|
||||
// ref.style.filter = "invert(0)";
|
||||
}
|
||||
if (direction === 1 && current === 3) {
|
||||
document.documentElement.classList.remove("dark");
|
||||
// ref.style.filter = "invert(1)";
|
||||
}
|
||||
if (direction === -1 && current === 0) return;
|
||||
if (direction === 1 && current === max) return;
|
||||
current += direction;
|
||||
setTimeout(play, time);
|
||||
};
|
||||
const toggle = () => {
|
||||
if (dark()) {
|
||||
direction = 1;
|
||||
} else {
|
||||
direction = -1;
|
||||
}
|
||||
play();
|
||||
setDark((d) => !d);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="absolute top-1v right-4h">
|
||||
<button
|
||||
onClick={toggle}
|
||||
ref={ref}
|
||||
class="pixelated"
|
||||
style={{
|
||||
scale: "1.5",
|
||||
height: "32px",
|
||||
width: "64px",
|
||||
"background-image": `url("/images/toggle.png")`,
|
||||
}}
|
||||
aria-hidden
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
62
src/components/Layout.tsx
Normal file
62
src/components/Layout.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { A } from "@solidjs/router";
|
||||
import { For, type ParentComponent } from "solid-js";
|
||||
import { TextHoverJump } from "./TextHoverJump";
|
||||
import { clientOnly } from "@solidjs/start";
|
||||
|
||||
const DarkModeToggle = clientOnly(() =>
|
||||
import("./DarkModeToggle").then((r) => ({
|
||||
default: r.DarkModeToggle,
|
||||
})),
|
||||
);
|
||||
|
||||
export const Layout: ParentComponent = (props) => {
|
||||
return (
|
||||
<>
|
||||
<a href="#main-content" class="sr-only">
|
||||
Skip to main content
|
||||
</a>
|
||||
<div class="flex flex-col min-h-screen pt-2v py-1v px-2h max-w-thread mx-auto relative overflow-x-hidden leading-1 box-border decoration-2 underline-offset-2">
|
||||
<header class="flex flex-col items-center justify-center gap-2v px-4h py-2v">
|
||||
<a href="/" class="text-2v leading-2 font-bold">
|
||||
<TextHoverJump text="~/minhtrannhat.com" />
|
||||
</a>
|
||||
|
||||
<DarkModeToggle />
|
||||
|
||||
<nav>
|
||||
<ul class="flex items-center gap-7h">
|
||||
<A end class="hover:underline" activeClass="font-bold" href={"/"}>
|
||||
Home
|
||||
</A>
|
||||
<A
|
||||
end
|
||||
class="hover:underline"
|
||||
activeClass="font-bold"
|
||||
href={"/articles"}
|
||||
>
|
||||
Articles
|
||||
</A>
|
||||
<A
|
||||
end
|
||||
class="hover:underline"
|
||||
activeClass="font-bold"
|
||||
href={"/tags"}
|
||||
>
|
||||
Tags
|
||||
</A>
|
||||
<a href="/resume.pdf" target="_blank" rel="noreferrer">
|
||||
Resume
|
||||
</a>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main-content" class="mt-1v flex-auto">
|
||||
{props.children}
|
||||
</main>
|
||||
|
||||
<div class="debug-grid" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
207
src/components/Markdown.tsx
Normal file
207
src/components/Markdown.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
type Component,
|
||||
createMemo,
|
||||
type ParentComponent,
|
||||
type JSXElement,
|
||||
createSignal,
|
||||
Show,
|
||||
onMount,
|
||||
} from "solid-js";
|
||||
|
||||
const P: ParentComponent = (props) => <p class="mt-1v">{props.children}</p>;
|
||||
|
||||
const Ol: ParentComponent = (props) => (
|
||||
<ol class="list-decimal [&>li]:ml-3h">{props.children}</ol>
|
||||
);
|
||||
const Ul: ParentComponent = (props) => (
|
||||
<ul class="list-square [&>li]:ml-2h">{props.children}</ul>
|
||||
);
|
||||
|
||||
const Li: ParentComponent = (props) => <li class="">{props.children}</li>;
|
||||
|
||||
export const Blockquote: ParentComponent = (props) => (
|
||||
<blockquote class="my-2v pl-1h text-slate-700 dark:text-slate-200 font-medium italic grid grid-cols-[max-content_1fr]">
|
||||
<span class="w-2h">{"> "}</span>
|
||||
<div class="[&>p]:mt-0">{props.children}</div>
|
||||
</blockquote>
|
||||
);
|
||||
|
||||
const Pre: ParentComponent<{ lang: string; lines?: string; file?: string }> = (
|
||||
props,
|
||||
) => {
|
||||
const [copied, setCopied] = createSignal(false);
|
||||
let ref!: HTMLPreElement;
|
||||
|
||||
const onCopy = () => {
|
||||
setCopied(true);
|
||||
navigator.clipboard.writeText(ref.innerText);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="my-1v">
|
||||
<div class="bg-black text-white dark:bg-white dark:text-black flex justify-between px-1h text-sm leading-1">
|
||||
<Show when={props.file} fallback={<span aria-hidden />}>
|
||||
<span>{props.file}</span>
|
||||
</Show>
|
||||
<button type="button" onClick={onCopy}>
|
||||
{copied() ? "Copied!" : "Copy code"}
|
||||
</button>
|
||||
</div>
|
||||
<pre ref={ref} class={`language-${props.lang}`} data-line={props.lines}>
|
||||
{props.children}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const headingLink = (children: JSXElement) =>
|
||||
children?.toString().toLowerCase().replaceAll(" ", "-").replaceAll(",", "");
|
||||
|
||||
const HeadlineLink: Component<{ link: string; class: string }> = (props) => {
|
||||
return (
|
||||
<a href={props.link} class="relative top-[1px]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
class={props.class}
|
||||
fill="none"
|
||||
>
|
||||
<title>link</title>
|
||||
<path
|
||||
d="M9.52051 14.4359L14.4335 9.52283"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M12.5685 15.1086C13.3082 16.249 13.1108 17.418 12.2563 18.2725L9.26109 21.2678C8.28269 22.2462 6.69638 22.2462 5.71798 21.2678L2.73185 18.2816C1.75345 17.3032 1.75345 15.7169 2.73185 14.7385L5.72706 11.7433C6.429 11.0413 7.76312 10.636 8.90958 11.4662M15.1083 12.5688C16.2487 13.3085 17.4177 13.1111 18.2722 12.2566L21.2674 9.26138C22.2458 8.28297 22.2458 6.69666 21.2674 5.71825L18.2813 2.7321C17.3029 1.75369 15.7166 1.75369 14.7382 2.7321L11.743 5.72733C11.041 6.42927 10.6357 7.7634 11.4659 8.90986"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const H2: ParentComponent = (props) => (
|
||||
<h2
|
||||
id={headingLink(props.children)}
|
||||
class="text-2xl leading-2 font-bold mt-2v mb-1v flex items-center gap-1h scroll-mt-2v"
|
||||
>
|
||||
{props.children}
|
||||
<HeadlineLink class="w-5 h-5" link={`#${headingLink(props.children)}`} />
|
||||
</h2>
|
||||
);
|
||||
|
||||
const H3: ParentComponent = (props) => (
|
||||
<h3
|
||||
id={headingLink(props.children)}
|
||||
class="text-xl leading-2 font-bold mt-2v mb-1v flex items-center gap-1h scroll-mt-2v"
|
||||
>
|
||||
{props.children}
|
||||
<HeadlineLink class="w-4 h-4" link={`#${headingLink(props.children)}`} />
|
||||
</h3>
|
||||
);
|
||||
|
||||
const H4: ParentComponent = (props) => (
|
||||
<h4
|
||||
id={headingLink(props.children)}
|
||||
class="text-lg leading-1 font-bold mt-2v mb-1v flex items-center gap-1h scroll-mt-2v"
|
||||
>
|
||||
{props.children}
|
||||
<HeadlineLink class="w-3 h-3" link={`#${headingLink(props.children)}`} />
|
||||
</h4>
|
||||
);
|
||||
|
||||
const A: ParentComponent<{ href: string }> = (props) => {
|
||||
const isLocal = createMemo(() =>
|
||||
["/", "./", "#"].some((s) => props.href.startsWith(s)),
|
||||
);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={props.href}
|
||||
target={isLocal() ? "" : "_blank"}
|
||||
class="underline underline-offset-2"
|
||||
>
|
||||
{props.children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
function gridCellDimensions() {
|
||||
const element = document.createElement("div");
|
||||
element.style.position = "fixed";
|
||||
element.style.height = "var(--line-height)";
|
||||
element.style.width = "1ch";
|
||||
document.body.appendChild(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
document.body.removeChild(element);
|
||||
return { width: rect.width, height: rect.height };
|
||||
}
|
||||
|
||||
export const PostImage: Component<{
|
||||
src: string;
|
||||
alt: string;
|
||||
attr?: JSXElement;
|
||||
class?: string;
|
||||
}> = (props) => {
|
||||
let ref!: HTMLImageElement;
|
||||
|
||||
onMount(() => {
|
||||
const cell = gridCellDimensions();
|
||||
function setHeightFromRatio() {
|
||||
const ratio = ref.naturalWidth / ref.naturalHeight;
|
||||
const rect = ref.getBoundingClientRect();
|
||||
const realHeight = rect.width / ratio;
|
||||
const diff = cell.height - (realHeight % cell.height);
|
||||
ref.style.setProperty("padding-bottom", `${diff}px`);
|
||||
}
|
||||
|
||||
if (ref.complete) {
|
||||
setHeightFromRatio();
|
||||
} else {
|
||||
ref.addEventListener("load", () => {
|
||||
setHeightFromRatio();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<img
|
||||
ref={ref}
|
||||
src={props.src}
|
||||
alt={props.alt}
|
||||
class="w-full"
|
||||
classList={{ [props.class || ""]: !!props.class }}
|
||||
/>
|
||||
{props.attr}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Aside: ParentComponent = (props) => (
|
||||
<aside class="border-l-2 border-black dark:border-white pl-1h mt-1v">
|
||||
<div class="uppercase text-sm leading-1 font-medium select-none">Aside</div>
|
||||
<div class="[&_*:first-child]:mt-0">{props.children}</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
export const markdownComponents = {
|
||||
a: A,
|
||||
p: P,
|
||||
li: Li,
|
||||
ol: Ol,
|
||||
ul: Ul,
|
||||
blockquote: Blockquote,
|
||||
pre: Pre,
|
||||
h2: H2,
|
||||
h3: H3,
|
||||
h4: H4,
|
||||
};
|
22
src/components/Posts.tsx
Normal file
22
src/components/Posts.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import dayjs from "dayjs";
|
||||
import { type Component, For } from "solid-js";
|
||||
import type { Post } from "~/types";
|
||||
|
||||
export const Posts: Component<{ posts: Post[] }> = (props) => {
|
||||
return (
|
||||
<ol class="">
|
||||
<For each={props.posts}>
|
||||
{(post) => (
|
||||
<li class="list-square ml-2h mb-1v">
|
||||
<a class="font-medium underline block" href={`/blog/${post.slug}`}>
|
||||
{post.title}
|
||||
</a>
|
||||
<span class="text-xs leading-1 text-slate-600 dark:text-slate-400">
|
||||
{dayjs(post.date).format("MMMM YYYY")}
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ol>
|
||||
);
|
||||
};
|
20
src/components/TextHoverJump.tsx
Normal file
20
src/components/TextHoverJump.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { For, type Component } from "solid-js";
|
||||
|
||||
export const TextHoverJump: Component<{ text: string }> = (props) => {
|
||||
return (
|
||||
<span class="jump-text flex items-baseline">
|
||||
<For each={[...props.text]}>
|
||||
{(i, index) => (
|
||||
<span
|
||||
class="jump-text block"
|
||||
style={{
|
||||
"--animation-delay": `${index() * 20}ms`,
|
||||
}}
|
||||
>
|
||||
{i}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</span>
|
||||
);
|
||||
};
|
27
src/components/Tree.tsx
Normal file
27
src/components/Tree.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { type Component, For, Show } from "solid-js";
|
||||
|
||||
type Node = { l: string; c: TreeNode[] };
|
||||
type TreeNode = string | Node;
|
||||
|
||||
const Subtree: Component<{ tree: TreeNode }> = (props) => {
|
||||
return (
|
||||
<Show
|
||||
when={typeof props.tree !== "string"}
|
||||
fallback={<li>{props.tree as string}</li>}
|
||||
>
|
||||
<li>
|
||||
<span>{(props.tree as Node).l}</span>
|
||||
<ul class="incremental">
|
||||
<For each={(props.tree as Node).c}>{(c) => <Subtree tree={c} />}</For>
|
||||
</ul>
|
||||
</li>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
export const Tree: Component<{ tree: TreeNode }> = (props) => {
|
||||
return (
|
||||
<ul class="tree [&>li>span]:font-bold">
|
||||
<Subtree tree={props.tree} />
|
||||
</ul>
|
||||
);
|
||||
};
|
199
src/css/prism-theme.css
Normal file
199
src/css/prism-theme.css
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Shades of Purple Theme for Prism.js
|
||||
*
|
||||
* @author Ahmad Awais <https://twitter.com/MrAhmadAwais/>
|
||||
* @support Follow/tweet at https://twitter.com/MrAhmadAwais/
|
||||
*/
|
||||
|
||||
code[class*='language-'],
|
||||
pre[class*='language-'] {
|
||||
@apply text-black dark:text-white font-medium;
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
word-spacing: normal;
|
||||
word-break: normal;
|
||||
|
||||
-moz-tab-size: 4;
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4;
|
||||
|
||||
-webkit-hyphens: none;
|
||||
-moz-hyphens: none;
|
||||
-ms-hyphens: none;
|
||||
hyphens: none;
|
||||
}
|
||||
|
||||
pre[class*='language-']::-moz-selection,
|
||||
pre[class*='language-'] ::-moz-selection,
|
||||
code[class*='language-']::-moz-selection,
|
||||
code[class*='language-'] ::-moz-selection,
|
||||
pre[class*='language-']::selection,
|
||||
pre[class*='language-'] ::selection,
|
||||
code[class*='language-']::selection,
|
||||
code[class*='language-'] ::selection {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Code blocks. */
|
||||
pre[class*='language-'] {
|
||||
@apply box-border border-black dark:border-white border-2;
|
||||
padding: calc(var(--line-height) - var(--border-thickness)) calc(2ch - var(--border-thickness));
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
:not(pre)>code[class*='language-'],
|
||||
pre[class*='language-'] {
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
|
||||
:not(pre)>code[class*='language-'],
|
||||
pre[class*='language-'] {
|
||||
@apply border-white;
|
||||
}
|
||||
} */
|
||||
|
||||
|
||||
/* Inline code */
|
||||
/* :not(pre)>code[class*='language-'] {
|
||||
|
||||
} */
|
||||
|
||||
.token {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.token.comment,
|
||||
.token.prolog,
|
||||
.token.cdata {
|
||||
@apply text-slate-600 dark:text-slate-400;
|
||||
}
|
||||
|
||||
.token.delimiter,
|
||||
.token.keyword,
|
||||
.token.selector,
|
||||
.token.important,
|
||||
.token.atrule {
|
||||
@apply text-black dark:text-white;
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.attr-name {
|
||||
@apply font-medium text-black dark:text-white;
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
@apply text-slate-500;
|
||||
}
|
||||
|
||||
.token.boolean {
|
||||
@apply font-medium text-black dark:text-white;
|
||||
}
|
||||
|
||||
.token.tag,
|
||||
.token.tag .punctuation,
|
||||
.token.doctype,
|
||||
.token.builtin {
|
||||
@apply font-medium text-black dark:text-white;
|
||||
}
|
||||
|
||||
.token.entity,
|
||||
.token.symbol {
|
||||
@apply font-medium text-black dark:text-white;
|
||||
|
||||
}
|
||||
|
||||
.token.number {
|
||||
@apply font-medium text-black dark:text-white;
|
||||
}
|
||||
|
||||
.token.property,
|
||||
.token.constant,
|
||||
.token.variable {
|
||||
@apply font-medium text-black dark:text-white;
|
||||
}
|
||||
|
||||
.token.string,
|
||||
.token.char {
|
||||
@apply text-slate-800 dark:text-slate-200;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.attr-value,
|
||||
.token.attr-value .punctuation {
|
||||
color: #a5c261;
|
||||
}
|
||||
|
||||
.token.attr-value .punctuation:first-child {
|
||||
color: #a9b7c6;
|
||||
}
|
||||
|
||||
.token.url {
|
||||
color: #287bde;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.token.function {
|
||||
@apply font-bold text-black dark:text-white;
|
||||
}
|
||||
|
||||
/* .token.regex {
|
||||
|
||||
} */
|
||||
|
||||
.token.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.token.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.inserted {
|
||||
background: #00ff00;
|
||||
}
|
||||
|
||||
.token.deleted {
|
||||
background: #ff000d;
|
||||
}
|
||||
|
||||
code.language-css .token.property,
|
||||
code.language-css .token.property+.token.punctuation {
|
||||
color: #a9b7c6;
|
||||
}
|
||||
|
||||
code.language-css .token.id {
|
||||
color: #ffc66d;
|
||||
}
|
||||
|
||||
code.language-css .token.selector>.token.class,
|
||||
code.language-css .token.selector>.token.attribute,
|
||||
code.language-css .token.selector>.token.pseudo-class,
|
||||
code.language-css .token.selector>.token.pseudo-element {
|
||||
color: #ffc66d;
|
||||
}
|
||||
|
||||
.token.class-name {
|
||||
@apply font-medium text-slate-700 dark:text-slate-300;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url,
|
||||
.language-css .token.string,
|
||||
.style .token.string {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.line-highlight.line-highlight {
|
||||
margin-top: 36px;
|
||||
background: linear-gradient(to right, rgba(179, 98, 255, 0.17), transparent);
|
||||
}
|
||||
|
||||
.line-highlight.line-highlight:before,
|
||||
.line-highlight.line-highlight[data-end]:after {
|
||||
content: '';
|
||||
}
|
0
src/data/posts.json
Normal file
0
src/data/posts.json
Normal file
8
src/data/posts.ts
Normal file
8
src/data/posts.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// @ts-expect-error
|
||||
import Posts from "./posts.json";
|
||||
import type { Post } from "~/types";
|
||||
|
||||
export const posts: Post[] = Posts.map((p: Post) => ({
|
||||
...p,
|
||||
date: new Date(p.date),
|
||||
}));
|
21
src/data/tags.ts
Normal file
21
src/data/tags.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { posts } from "~/data/posts";
|
||||
import type { Tag } from "~/types";
|
||||
|
||||
export const tags: Record<string, Tag> = posts.reduce(
|
||||
(a, p, i) => {
|
||||
if (Array.isArray(p.tags)) {
|
||||
for (const t of p.tags) {
|
||||
if (!a[t]) {
|
||||
a[t] = {
|
||||
id: t,
|
||||
posts: [],
|
||||
};
|
||||
}
|
||||
a[t].posts.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
return a;
|
||||
},
|
||||
{} as Record<string, Tag>,
|
||||
);
|
48
src/drafts/solid-start-enumerate-routes.mdx
Normal file
48
src/drafts/solid-start-enumerate-routes.mdx
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
date: 20204-05-11
|
||||
tags:
|
||||
- solidjs
|
||||
- webdev
|
||||
title: How to enumerate routes in solid-start
|
||||
---
|
||||
|
||||
This website is built with [solid-start](https://start.solidjs.com/getting-started/what-is-solidstart).
|
||||
|
||||
I needed a way to enumerate all of the posts under `/blog` so that I could dynamically generate lists, both for the [home page](/), and for the tags pages: [/tags/solidjs](/tags/solidjs).
|
||||
|
||||
However, the solid-start `<FileRouter>` doesn't expose any of that information, either at build-time or run-time. We have to figure it out ourselves.
|
||||
|
||||
My first thought was to have a script that watches the directory and writes to a file.
|
||||
|
||||
That would've worked. But while browsing through the docs trying to figure out how to get a file watcher to be part of the vite dev server, I found out about [virtual modules](https://vitejs.dev/guide/api-plugin#virtual-modules-convention).
|
||||
|
||||
Essentially, you can define a `js` module that has dynamic content by exporting a `js` string.
|
||||
|
||||
```js lang="js" lines="2,14"
|
||||
export default function myPlugin() {
|
||||
const virtualModuleId = 'virtual:my-module'
|
||||
const resolvedVirtualModuleId = '\0' + virtualModuleId
|
||||
|
||||
return {
|
||||
name: 'my-plugin', // required, will show up in warnings and errors
|
||||
resolveId(id) {
|
||||
if (id === virtualModuleId) {
|
||||
return resolvedVirtualModuleId
|
||||
}
|
||||
},
|
||||
load(id) {
|
||||
if (id === resolvedVirtualModuleId) {
|
||||
return `export const msg = "from virtual module"`
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
After adding `myPlugin` to your vite plugins list, you can import the virtual module anywhere in the code and use it as if it were a real file.
|
||||
|
||||
```js lang="js"
|
||||
import {msg} from "virtual:my-module"
|
||||
```
|
||||
|
||||
This seemed more "elegant" than a file watcher.
|
34
src/drafts/this-blog-uses-solid-start.mdx
Normal file
34
src/drafts/this-blog-uses-solid-start.mdx
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
date: 20204-05-11
|
||||
tags:
|
||||
- solidjs
|
||||
- webdev
|
||||
title: This blog uses solid-start.
|
||||
description: How to set up a blog using solid-start and mdx.
|
||||
---
|
||||
|
||||
This website uses [solid-start](https://start.solidjs.com/getting-started/what-is-solidstart).
|
||||
|
||||
solid-start is a Next.js-like framework for creating SSR / SSG websites. But instead of react, it builds on top of solid-js.
|
||||
|
||||
I've been using solid and solid-start professionally for almost 2 years. And while solid-js feels rock-solid, solid-start still feels rough around the edges.
|
||||
|
||||
|
||||
For each of the features I wanted to implement, I ran into issues that delayed this from being a weekend project into taking about 4 weekends instead.
|
||||
|
||||
Here's what I wanted to accomplish:
|
||||
1. Use [mdx](https://mdxjs.com/) for writing the posts
|
||||
2. Define metadata for each post in the same mdx file
|
||||
3. Full SSG
|
||||
4. Code highlighting at compile time
|
||||
5. Tags
|
||||
|
||||
And here's how I did it:
|
||||
|
||||
## Using mdx
|
||||
|
||||
MDX is markdown that you can intersprinkle with jsx.
|
||||
|
||||
The initial scaffolding is easy, follow [these steps](https://docs.solidjs.com/solid-start/getting-started), and choose the `with-mdx` option.
|
||||
|
||||
I opted to have my posts live under the `/blog` path.
|
5
src/entry-client.tsx
Normal file
5
src/entry-client.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
// @refresh reload
|
||||
import { mount, StartClient } from "@solidjs/start/client";
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
mount(() => <StartClient />, document.getElementById("app")!);
|
25
src/entry-server.tsx
Normal file
25
src/entry-server.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
// @refresh reload
|
||||
import { createHandler, StartServer } from "@solidjs/start/server";
|
||||
|
||||
export default createHandler(() => (
|
||||
<StartServer
|
||||
document={({ assets, children, scripts }) => (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<script>
|
||||
{`document.documentElement.classList.toggle('dark', window.matchMedia('(prefers-color-scheme: dark)').matches)`}
|
||||
</script>
|
||||
{assets}
|
||||
</head>
|
||||
<body class="font-mono bg-white dark:bg-black dark:text-white">
|
||||
<div id="app">{children}</div>
|
||||
{scripts}
|
||||
</body>
|
||||
</html>
|
||||
)}
|
||||
/>
|
||||
));
|
1
src/global.d.ts
vendored
Normal file
1
src/global.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="@solidjs/start/env" />
|
67
src/routes/(home).tsx
Normal file
67
src/routes/(home).tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { For } from "solid-js";
|
||||
import { posts } from "~/data/posts";
|
||||
import { Posts } from "~/components/Posts";
|
||||
|
||||
const links = [
|
||||
"https://github.com/minhtrannhat",
|
||||
"https://linkedin.com/in/minh-tran-nhat",
|
||||
];
|
||||
const Homepage = () => {
|
||||
return (
|
||||
<div>
|
||||
<section class="flex flex-col sm:flex-row gap-2v sm:gap-3h">
|
||||
<div class="font-medium">
|
||||
<div class="flex items-end mb-1v gap-1h">
|
||||
<img
|
||||
class="inline-block h-2v select-none wave-image"
|
||||
alt="wave emoji"
|
||||
src="/images/wave-pixel.png"
|
||||
/>
|
||||
<p>Hi, Minh here.</p>
|
||||
</div>
|
||||
<p class="mb-1v">
|
||||
I'm Minh Tran, a Computer Engineering student at Concordia
|
||||
University, Montreal, Canada.
|
||||
<br />
|
||||
<br />
|
||||
I'm most passionate about designing distributed systems that scales
|
||||
but I'm also interested in compilers and systems programming. When
|
||||
I'm not coding, I read books, listen to podcasts or study music
|
||||
theory.
|
||||
</p>
|
||||
<p>
|
||||
Say hi:{" "}
|
||||
<a
|
||||
class="underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="mailto:minh@minhtrannhat.com"
|
||||
>
|
||||
minh@minhtrannhat.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<ul class="sm:mt-3v text-slate-600 dark:text-slate-200 text-base sm:text-sm leading-1">
|
||||
<For each={links}>
|
||||
{(link) => (
|
||||
<li class="list-square hover:text-black dark:hover:text-white ml-2h leading-1">
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={link}
|
||||
class="underline"
|
||||
>
|
||||
{link.replace("https://", "")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Homepage;
|
5
src/routes/[...404].mdx
Normal file
5
src/routes/[...404].mdx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { HttpStatusCode } from "@solidjs/start";
|
||||
|
||||
<HttpStatusCode code={404} />
|
||||
|
||||
# Page Not Found
|
30
src/routes/articles/index.tsx
Normal file
30
src/routes/articles/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { posts } from "~/data/posts";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { For } from "solid-js";
|
||||
|
||||
const Articles = () => {
|
||||
return (
|
||||
<div>
|
||||
<ol class="flex flex-col gap-1v list-square ml-2h">
|
||||
<For each={Object.values(posts)}>
|
||||
{(post) => (
|
||||
<li class="list-square ml-2h mb-1v">
|
||||
<a
|
||||
class="font-medium underline block"
|
||||
href={`/blog/${post.slug}`}
|
||||
>
|
||||
{post.title}
|
||||
</a>
|
||||
<span class="text-xs leading-1 text-slate-600 dark:text-slate-400">
|
||||
{dayjs(post.date).format("MMMM YYYY")}
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Articles;
|
87
src/routes/blog.tsx
Normal file
87
src/routes/blog.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { For, Show } from "solid-js";
|
||||
import type { RouteSectionProps } from "@solidjs/router";
|
||||
import { Meta, Title } from "@solidjs/meta";
|
||||
import { posts } from "~/data/posts";
|
||||
import { MDXProvider } from "solid-mdx";
|
||||
import { markdownComponents, PostImage } from "~/components/Markdown";
|
||||
import dayjs from "dayjs";
|
||||
import "../css/prism-theme.css";
|
||||
import type { Post } from "~/types";
|
||||
|
||||
const Blog = (props: RouteSectionProps<unknown>) => {
|
||||
const meta = () =>
|
||||
posts.find((p) => props.location.pathname.endsWith(p.slug)) as Post;
|
||||
const index = () => posts.indexOf(meta());
|
||||
|
||||
const prevMeta = () =>
|
||||
index() === posts.length - 1 ? undefined : posts[index() + 1];
|
||||
const nextMeta = () => (index() === 0 ? undefined : posts[index() - 1]);
|
||||
|
||||
return (
|
||||
<article class="pb-5v">
|
||||
<Title>minhtrannhat.com - {meta()?.title}</Title>
|
||||
<Meta name="og:title" content={meta().title} />
|
||||
<Meta name="description" content={meta().description} />
|
||||
<Meta name="og:description" content={meta().description} />
|
||||
|
||||
<Show when={meta().featuredImage}>
|
||||
<PostImage
|
||||
class="mb-3v saturate-0"
|
||||
src={meta().featuredImage || ""}
|
||||
alt={meta().featuredImageDesc || ""}
|
||||
/>
|
||||
</Show>
|
||||
<h1 class="text-2v leading-2 font-bold mb-1v">{meta().title}</h1>
|
||||
|
||||
<div class="flex items-center gap-4h mb-2v text-sm leading-1">
|
||||
<p>{dayjs(meta().date).format("D MMMM YYYY")}</p>
|
||||
|
||||
<div class="">
|
||||
<For each={meta().tags}>
|
||||
{(tag, index) => (
|
||||
<>
|
||||
<a
|
||||
href={`/tags/${tag}`}
|
||||
class="font-medium underline underline-offset-2 italic"
|
||||
>
|
||||
{tag}
|
||||
</a>
|
||||
{index() === meta().tags.length - 1 ? "" : ", "}
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MDXProvider components={markdownComponents}>
|
||||
{props.children}
|
||||
</MDXProvider>
|
||||
|
||||
<div class="mt-3v flex flex-col gap-1v">
|
||||
<Show when={prevMeta()} fallback={<div />}>
|
||||
<div class="flex gap-1h">
|
||||
<span>Previous:</span>
|
||||
<a
|
||||
class="underline underline-offset-2"
|
||||
href={`/blog/${prevMeta()?.slug}`}
|
||||
>
|
||||
{prevMeta()?.title}
|
||||
</a>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={nextMeta()} fallback={<div />}>
|
||||
<div class="flex gap-1h">
|
||||
<span>Next:</span>
|
||||
<a
|
||||
class="underline underline-offset-2"
|
||||
href={`/blog/${nextMeta()?.slug}`}
|
||||
>
|
||||
{nextMeta()?.title}
|
||||
</a>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
export default Blog;
|
27
src/routes/tags/(tags).tsx
Normal file
27
src/routes/tags/(tags).tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { For } from "solid-js";
|
||||
import { tags } from "~/data/tags";
|
||||
|
||||
const Tags = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1 class="text-xl font-bold mt-2v mb-1v">All tags:</h1>
|
||||
<ol class="flex flex-col gap-1v list-square ml-2h">
|
||||
<For each={Object.values(tags)}>
|
||||
{(tag) => (
|
||||
<li class="">
|
||||
<a class="underline underline-offset-2" href={`/tags/${tag.id}`}>
|
||||
{tag.id}
|
||||
</a>
|
||||
<span>
|
||||
{" "}
|
||||
- {tag.posts.length} Post{tag.posts.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tags;
|
20
src/routes/tags/[id].tsx
Normal file
20
src/routes/tags/[id].tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { RouteSectionProps } from "@solidjs/router";
|
||||
import { type Component, Show } from "solid-js";
|
||||
import { posts } from "~/data/posts";
|
||||
import { Posts } from "~/components/Posts";
|
||||
import { tags } from "~/data/tags";
|
||||
|
||||
const TagId: Component<RouteSectionProps<unknown>> = (props) => {
|
||||
const tag = () => tags[props.params.id];
|
||||
return (
|
||||
<Show when={tag()} fallback={<div>No posts with that tag</div>}>
|
||||
<div>
|
||||
<h1 class="text-lg font-bold mb-6">Tag: {tag().id}</h1>
|
||||
|
||||
<Posts posts={tag().posts.map((i) => posts[i])} />
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagId;
|
16
src/types.ts
Normal file
16
src/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type Post = {
|
||||
title: string;
|
||||
date: Date;
|
||||
slug: string;
|
||||
tags: string[];
|
||||
featuredImage?: string;
|
||||
featuredImageDesc?: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type Tag = {
|
||||
// id/name of tag
|
||||
id: string;
|
||||
// indexes of posts with tag (they point to the posts list coming from virtual:blog-posts)
|
||||
posts: number[];
|
||||
};
|
73
src/useDitherAnimation.tsx
Normal file
73
src/useDitherAnimation.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Accessor } from "solid-js";
|
||||
import { isServer } from "solid-js/web";
|
||||
import { useRouteTransitionTiming } from "./useRouteTransitionTiming";
|
||||
|
||||
export const useDitherAnimation = (ref: Accessor<HTMLElement | undefined>) => {
|
||||
if (!isServer) {
|
||||
const d1 = document.createElement("div");
|
||||
d1.classList.add("dither", "dither-1");
|
||||
const d2 = document.createElement("div");
|
||||
d2.classList.add("dither", "dither-2");
|
||||
const d3 = document.createElement("div");
|
||||
d3.classList.add("dither", "dither-3");
|
||||
|
||||
let started = false;
|
||||
useRouteTransitionTiming(
|
||||
300,
|
||||
() => {
|
||||
ref()?.appendChild(d1);
|
||||
setTimeout(() => {
|
||||
ref()?.appendChild(d2);
|
||||
}, 100);
|
||||
setTimeout(() => {
|
||||
ref()?.appendChild(d3);
|
||||
}, 200);
|
||||
started = true;
|
||||
},
|
||||
() => {
|
||||
const rnd = () =>
|
||||
setTimeout(() => {
|
||||
try {
|
||||
d1.style.backgroundPosition = `${Math.round(
|
||||
Math.random() * 100,
|
||||
)}px ${Math.round(Math.random() * 100)}px`;
|
||||
d2.style.backgroundPosition = `${Math.round(
|
||||
Math.random() * 100,
|
||||
)}px ${Math.round(Math.random() * 100)}px`;
|
||||
d3.style.backgroundPosition = `${Math.round(
|
||||
Math.random() * 100,
|
||||
)}px ${Math.round(Math.random() * 100)}px`;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
if (started) {
|
||||
rnd();
|
||||
}
|
||||
}, 100);
|
||||
rnd();
|
||||
},
|
||||
() => {
|
||||
started = false;
|
||||
try {
|
||||
ref()?.removeChild(d3);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
setTimeout(() => {
|
||||
try {
|
||||
ref()?.removeChild(d2);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, 100);
|
||||
setTimeout(() => {
|
||||
try {
|
||||
ref()?.removeChild(d1);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, 200);
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
24
src/useRouteTransitionTiming.tsx
Normal file
24
src/useRouteTransitionTiming.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useIsRouting, useBeforeLeave } from "@solidjs/router";
|
||||
import { createEffect } from "solid-js";
|
||||
|
||||
export const useRouteTransitionTiming = (
|
||||
transitionTime: number,
|
||||
onEnter: () => void,
|
||||
onLoading: () => void,
|
||||
onExit: () => void,
|
||||
) => {
|
||||
const isRouting = useIsRouting();
|
||||
createEffect((oldR: boolean | undefined) => {
|
||||
const r = isRouting();
|
||||
if (oldR && !r) onExit();
|
||||
return r;
|
||||
});
|
||||
useBeforeLeave((e) => {
|
||||
e.preventDefault();
|
||||
onEnter();
|
||||
setTimeout(() => {
|
||||
e.retry(true);
|
||||
onLoading();
|
||||
}, transitionTime);
|
||||
});
|
||||
};
|
Reference in New Issue
Block a user