Added Sidenote Component
This commit is contained in:
@@ -9,7 +9,7 @@ export default function SiteLayout({
|
||||
<MenuProvider>
|
||||
<PageHeader />
|
||||
<PageMenu />
|
||||
<main>{children}</main>
|
||||
{children}
|
||||
</MenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
29
src/app/(site)/meta/page.tsx
Normal file
29
src/app/(site)/meta/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { Author, Tag } from '@/lib/types/taxonomy';
|
||||
import Article from '@/components/Article';
|
||||
import { getMetaHome } from '@/lib/readers/meta/posts';
|
||||
import generatePageMetaData from '@/lib/next/generatePageMetaData';
|
||||
import { getAuthorBySlug } from '@/lib/readers/taxonomy/authors';
|
||||
import { getTagBySlug } from '@/lib/readers/taxonomy/tags';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const article = await getMetaHome();
|
||||
|
||||
if (!article) return { title: 'Not Found' };
|
||||
|
||||
return generatePageMetaData(article);
|
||||
}
|
||||
|
||||
export default async function MetaHome() {
|
||||
const article = await getMetaHome();
|
||||
if (!article) notFound();
|
||||
|
||||
const author = await getAuthorBySlug(article.meta.author);
|
||||
const tags = await Promise.all(
|
||||
article.meta.tags.map((slug: string) => getTagBySlug(slug))
|
||||
);
|
||||
|
||||
return <Article article={article} tags={tags} author={author} />;
|
||||
}
|
||||
@@ -6,488 +6,6 @@ export default function Home() {
|
||||
<h1>
|
||||
DAVE! DAVE! Do Not Let Us Die In The Dark Night Of This Cold Winter!
|
||||
</h1>
|
||||
<h2>Background / Image effects</h2>
|
||||
<div className={styles.grid}>
|
||||
<div className={`${styles.demoCard}`}>
|
||||
<h3 className={styles.demoTitle}>Overexposure Blast</h3>
|
||||
<div className={styles.imageContainer}>
|
||||
<img
|
||||
className={`${styles.demoImage} anim-overexposure`}
|
||||
src="https://images.pexels.com/photos/1096925/pexels-photo-1096925.jpeg"
|
||||
alt="Demo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.demoCard}`}>
|
||||
<h3 className={styles.demoTitle}>Contrast Slam</h3>
|
||||
<div className={styles.imageContainer}>
|
||||
<img
|
||||
className={`${styles.demoImage} anim-contrastslam`}
|
||||
src="https://images.pexels.com/photos/119809/pexels-photo-119809.jpeg"
|
||||
alt="Demo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.demoCard}`}>
|
||||
<h3 className={styles.demoTitle}>Colorbleed</h3>
|
||||
<div className={styles.imageContainer}>
|
||||
<img
|
||||
className={`${styles.demoImage} anim-colorbleed`}
|
||||
src="https://images.pexels.com/photos/12924931/pexels-photo-12924931.jpeg"
|
||||
alt="Demo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.demoCard}`}>
|
||||
<h3 className={styles.demoTitle}>Film Burn</h3>
|
||||
<div className={styles.imageContainer}>
|
||||
<img
|
||||
className={`${styles.demoImage} anim-filmburn`}
|
||||
src="https://images.pexels.com/photos/1031357/pexels-photo-1031357.jpeg"
|
||||
alt="Demo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.demoCard}`}>
|
||||
<h3 className={styles.demoTitle}>Photocopier Malfunction</h3>
|
||||
<div className={styles.imageContainer}>
|
||||
<img
|
||||
className={`${styles.demoImage} anim-malfunction`}
|
||||
src="https://images.pexels.com/photos/3054252/pexels-photo-3054252.jpeg"
|
||||
alt="Demo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.demoCard}`}>
|
||||
<h3 className={styles.demoTitle}>Toner Starvation</h3>
|
||||
<div className={styles.imageContainer}>
|
||||
<img
|
||||
className={`${styles.demoImage} anim-tonerstarvation`}
|
||||
src="https://images.pexels.com/photos/3822728/pexels-photo-3822728.jpeg"
|
||||
alt="Demo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.demoCard}`}>
|
||||
<h3 className={styles.demoTitle}>Digital Corruption</h3>
|
||||
<div className={styles.imageContainer}>
|
||||
<img
|
||||
className={`${styles.demoImage} anim-corruption`}
|
||||
src="https://images.pexels.com/photos/220793/pexels-photo-220793.jpeg"
|
||||
alt="Demo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.demoCard}`}>
|
||||
<h3 className={styles.demoTitle}>Stark Flash</h3>
|
||||
<div className={styles.imageContainer}>
|
||||
<img
|
||||
className={`${styles.demoImage} anim-starkflash`}
|
||||
src="https://images.pexels.com/photos/13744675/pexels-photo-13744675.jpeg"
|
||||
alt="Demo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.instructions}>
|
||||
<h2>Instructions</h2>
|
||||
<p>
|
||||
Hover over each image to see the effect. These are designed to work
|
||||
with your base filter:
|
||||
</p>
|
||||
<code>filter: grayscale(1) contrast(150%) brightness(120%)</code>
|
||||
<p>
|
||||
You can replace the demo images with your own by changing the src
|
||||
attributes. All effects use stepped animations or sharp transitions to
|
||||
maintain that industrial, non-digital feel.
|
||||
</p>
|
||||
<p>
|
||||
The effects range from subtle (Contrast Slam) to more dramatic
|
||||
(Digital Corruption). Choose based on how aggressive you want the
|
||||
interaction to feel.
|
||||
</p>
|
||||
</div>
|
||||
<h2>Link Effects</h2>
|
||||
<div className={styles.grid}>
|
||||
{/* Your Ideas */}
|
||||
<div className={styles.demoCard}>
|
||||
<h3 className={styles.demoTitle}>Strikethrough Mark (Marker)</h3>
|
||||
<p>
|
||||
This is some text with a{' '}
|
||||
<a href="#" className="anim-strikethroughmarker">
|
||||
strikethrough link
|
||||
</a>{' '}
|
||||
in the middle of it.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.demoCard}>
|
||||
<h3 className={styles.demoTitle}>Strikethrough Mark (Industrial)</h3>
|
||||
<p>
|
||||
This is some text with a{' '}
|
||||
<a href="#" className="anim-strikethroughindustrial">
|
||||
strikethrough link
|
||||
</a>{' '}
|
||||
in the middle of it.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.demoCard}>
|
||||
<h3 className={styles.demoTitle}>Marker Highlight [Industrial]</h3>
|
||||
<p>
|
||||
This is some text with a{' '}
|
||||
<a href="#" className="anim-markerhighlightindustrial">
|
||||
marker highlight link
|
||||
</a>{' '}
|
||||
in the middle of it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stamping/Punching Effects */}
|
||||
<div className={styles.demoCard}>
|
||||
<h3 className={styles.demoTitle}>Label Maker</h3>
|
||||
<p>
|
||||
This is some text with a{' '}
|
||||
<a href="#" className="anim-labelmaker">
|
||||
label maker link
|
||||
</a>{' '}
|
||||
in the middle of it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.demoCard}>
|
||||
<h3 className={styles.demoTitle}>Rubber Stamp</h3>
|
||||
<p>
|
||||
This is some text with a{' '}
|
||||
<a href="#" className="anim-rubberstamp">
|
||||
rubber stamp link
|
||||
</a>{' '}
|
||||
in the middle of it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Industrial/Mechanical */}
|
||||
<div className={styles.demoCard}>
|
||||
<h3 className={styles.demoTitle}>Press/Stamp</h3>
|
||||
<p>
|
||||
This is some text with a{' '}
|
||||
<a href="#" className="anim-pressstamp">
|
||||
pressed link
|
||||
</a>{' '}
|
||||
in the middle of it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.demoCard}>
|
||||
<h3 className={styles.demoTitle}>Typewriter Underline</h3>
|
||||
<p>
|
||||
This is some text with a{' '}
|
||||
<a href="#" className="anim-typewriter">
|
||||
typewriter link
|
||||
</a>{' '}
|
||||
in the middle of it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.demoCard}>
|
||||
<h3 className={styles.demoTitle}>Hard Invert</h3>
|
||||
<p>
|
||||
This is some text with a{' '}
|
||||
<a href="#" className="anim-hardInvert">
|
||||
hard invert link
|
||||
</a>{' '}
|
||||
in the middle of it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Marking/Annotation */}
|
||||
<div className={styles.demoCard}>
|
||||
<h3 className={styles.demoTitle}>Bracket Annotation</h3>
|
||||
<p>
|
||||
This is some text with a{' '}
|
||||
<a href="#" className="anim-pointer">
|
||||
bracket link
|
||||
</a>{' '}
|
||||
in the middle of it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.demoCard}>
|
||||
<h3 className={styles.demoTitle}>Corner Box</h3>
|
||||
<p>
|
||||
This is some text with a{' '}
|
||||
<a href="#" className="anim-cornerbox">
|
||||
corner box link
|
||||
</a>{' '}
|
||||
in the middle of it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Glitch/Digital */}
|
||||
<div className={styles.demoCard}>
|
||||
<h3 className={styles.demoTitle}>Character Glitch</h3>
|
||||
<p>
|
||||
This is some text with a{' '}
|
||||
<a href="#" className="anim-characterglitch">
|
||||
glitch link
|
||||
</a>{' '}
|
||||
in the middle of it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.demoCard}>
|
||||
<h3 className={styles.demoTitle}>Pixel Shift</h3>
|
||||
<p>
|
||||
This is some text with a{' '}
|
||||
<a href="#" className="anim-pixelshift">
|
||||
shifting link
|
||||
</a>{' '}
|
||||
in the middle of it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Extra Ideas */}
|
||||
<div className={styles.demoCard}>
|
||||
<h3 className={styles.demoTitle}>Redacted/Censored</h3>
|
||||
<p>
|
||||
This is some text with a{' '}
|
||||
<a href="#" className="anim-redacted">
|
||||
redacted link
|
||||
</a>{' '}
|
||||
in the middle of it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.demoCard}>
|
||||
<h3 className={styles.demoTitle}>X-Ray/Negative</h3>
|
||||
<p>
|
||||
This is some text with an{' '}
|
||||
<a href="#" className="anim-xray">
|
||||
x-ray link
|
||||
</a>{' '}
|
||||
in the middle of it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="content">
|
||||
<h1>
|
||||
Successful Isildur's brink again throttle flank tightening splash.
|
||||
</h1>
|
||||
<p>
|
||||
Help illusion embrace liquor tightening intelligence Maggot's whips
|
||||
bit forests. 17 sing impassable helps Southrons beheading. What's the
|
||||
Elvish word for 'friend'?
|
||||
</p>
|
||||
<h2>Give Hobbitses lend yours lads picking uniting sometime.</h2>
|
||||
<p>
|
||||
Inferno shaken skin undo wars close circles verse suck Dwarves. I gave
|
||||
you the chance of aiding me willingly, but you have elected the way of
|
||||
pain! Precautions tower tied Rivendell everyone agents wouldn't?
|
||||
</p>
|
||||
<h3>Doorway Mithrandir clearing wielder strengths floor?</h3>
|
||||
<p>
|
||||
Pillaged pointy-eared mix charm Grond confounded able-bodied tact
|
||||
glimpse instruction open dear. Suffering powerful capable gulls
|
||||
famousest stroke breathes Bilbo squeaked pace chances. Let the
|
||||
Ring-bearer decide.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Mirkwood.</li>
|
||||
<li>Tom.</li>
|
||||
<li>Bilbo's.</li>
|
||||
<li>Gandalf's.</li>
|
||||
<li>Dwarvish.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>Queen wants Oin loose heads decay piety!</li>
|
||||
<li>Large happening arrived owes legends wit war bled Durin's.</li>
|
||||
<li>
|
||||
Pursuit exactly during relief mission meats cause Noldorin ablaze
|
||||
tracked.
|
||||
</li>
|
||||
<li>Darken knife midday meat Goblinses.</li>
|
||||
<li>
|
||||
Your Rabble-rousers greatest could beast thirty-four t wizards
|
||||
slumbers reforge.
|
||||
</li>
|
||||
</ul>
|
||||
<h3>Late vagabond knowing Ent legends there flattened cultured?</h3>
|
||||
<p>
|
||||
Swords are no more use here. Deny Chubbs restored. Scare rebuild
|
||||
Argonath tracked day's large.
|
||||
</p>
|
||||
<ol>
|
||||
<li>Easterlings!</li>
|
||||
<li>Bill.</li>
|
||||
<li>Dúnedain!</li>
|
||||
<li>Gandalf.</li>
|
||||
<li>Orcrist.</li>
|
||||
</ol>
|
||||
<ol>
|
||||
<li>Answers feelings Elrond conjurer runs.</li>
|
||||
<li>Sul nudge powerless jelly dumping hair grows log forgave?</li>
|
||||
<li>
|
||||
Retaken succumbed funeral courtyard Glóin incident mere somewhere
|
||||
commander assistance?
|
||||
</li>
|
||||
<li>Bore outrun stead fight Athelas guardroom willing contains.</li>
|
||||
<li>Hunted Angmar wager noose arguing?</li>
|
||||
</ol>
|
||||
<h3>Elros next own wisp whence cakehole right!</h3>
|
||||
<p>
|
||||
Thank hid its lessened lined tells prefers were Stone-Giants thousand
|
||||
troubles. Earendil staff pines bog finest mushroom consumption.
|
||||
Mistaken streaming fates paths arts puppet Barad-dûr uniting? You
|
||||
shall not pass!
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Ales</th>
|
||||
<th>Deeply</th>
|
||||
<th>Mortality</th>
|
||||
<th>Open</th>
|
||||
<th>Single-handedly</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Band</td>
|
||||
<td>appeared</td>
|
||||
<td>waited</td>
|
||||
<td>whose</td>
|
||||
<td>plate</td>
|
||||
<td>marshaling</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Meat</td>
|
||||
<td>nest</td>
|
||||
<td>thatched</td>
|
||||
<td>rallying</td>
|
||||
<td>claimed</td>
|
||||
<td>hungers</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Mouse</td>
|
||||
<td>also</td>
|
||||
<td>birdses</td>
|
||||
<td>moons</td>
|
||||
<td>strain</td>
|
||||
<td>brightest</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Hate</td>
|
||||
<td>different</td>
|
||||
<td>arrangements</td>
|
||||
<td>chiefest</td>
|
||||
<td>think</td>
|
||||
<td>try</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Something's</td>
|
||||
<td>task</td>
|
||||
<td>here's</td>
|
||||
<td>decent</td>
|
||||
<td>someone</td>
|
||||
<td>uses</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Shirt</td>
|
||||
<td>tonight</td>
|
||||
<td>bay</td>
|
||||
<td>beautifully</td>
|
||||
<td>tad</td>
|
||||
<td>cloaks</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td>revenge</td>
|
||||
<td>teaching</td>
|
||||
<td>mischief</td>
|
||||
<td>shores</td>
|
||||
<td>dreams</td>
|
||||
<td>tested</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<h4>Stage dishcloth 20 horses Tooks souls crawl crime.</h4>
|
||||
<p>
|
||||
Approve Sul wilderness grave embellishment greatly over these rack
|
||||
struggle. End ringing bell Anor halls pairs thirst fortress curtain
|
||||
cleared? Dwelt language 1296 Underhill nasty. I gave you the chance of
|
||||
aiding me willingly, but you have elected the way of pain!
|
||||
</p>
|
||||
<blockquote>
|
||||
<p>You are full of surprises, Master Baggins.</p>
|
||||
<footer>
|
||||
—Girion, <cite>warmongering appears</cite>
|
||||
</footer>
|
||||
</blockquote>
|
||||
<h5>
|
||||
Sung outscoring fingers Fundin reaction inquiries buggers deadliest.
|
||||
</h5>
|
||||
<p>
|
||||
Mustn't powerless pierces Muil sorry crossing diamond brandy. Rip
|
||||
rockets hinder Braga go had web ought sakes hail. A wizard is never
|
||||
late, Frodo Baggins. Nor is he early. He arrives precisely when he
|
||||
means to. Ashes tie Gamgee dicky!
|
||||
</p>
|
||||
<pre>
|
||||
Rhudaur fancy tilled heart beggars. Dwarf nothing talked foot club.
|
||||
Slaughtered flatten Hobbit journey's four-day?
|
||||
</pre>
|
||||
<h6>Pearl tact tomb bits Arwen Evenstar worry?</h6>
|
||||
<p>
|
||||
Times unspoiled <time>defenses</time> Silvan.{' '}
|
||||
<del>Sigrid Pippin Gandalf</del> thin stubbornness noises easily
|
||||
spread. Eldar warriors <code>won answered</code> filth yourself
|
||||
pocket. Showing store consistency <kbd>M</kbd> crevice. Decision
|
||||
feverfew <sup>giving</sup> Misty Mountain lord supplant. Gorgoroth{' '}
|
||||
<dfn>load born fulfilled plenty</dfn> fates serpent. Doorstep Pippin's{' '}
|
||||
<cite>pity bridge</cite> long weak weep? Brightest <abbr>Chubbs</abbr>{' '}
|
||||
jewels understand. Somewhat Erebor <sub>noise</sub> squealing moved?
|
||||
Pippin's <mark>feels overrun</mark> hours brown burns. <var>Anor</var>{' '}
|
||||
turning pick prophecy. Surrounded <q>entered needlessly weary</q> vile
|
||||
hmm Bagshot Row. Consent <ins>outwitted dotage slug</ins> Homely hear.
|
||||
Parapet <strong>protected favored defied roam</strong> quiet Dori sick
|
||||
bent. <samp>Homage store hurricane</samp> prove ferret Helm's Deep
|
||||
lately? <small>Excellent regret fun often</small> returned Wood-elves
|
||||
apocalypse. Théodred's rights rat drawing{' '}
|
||||
<a>examine dared bygone residence deeply</a>.{' '}
|
||||
<em>Greenway Girion Rohirrim</em> trammel waiting edge.
|
||||
</p>
|
||||
<hr />
|
||||
<dl>
|
||||
<dt>Turn</dt>
|
||||
<dd>
|
||||
Bare protuberance arrived forging funny salvage Cair except first
|
||||
banners.
|
||||
</dd>
|
||||
<dt>Foes</dt>
|
||||
<dd>Bore river large house shadows it's Tuckborough warn.</dd>
|
||||
<dd>
|
||||
Stirring Greenwood nest sapphire grant gob flagon famous mean!
|
||||
</dd>
|
||||
<dt>Unprepared</dt>
|
||||
<dd>
|
||||
Single-handed wriggling creatures lock canopy anytime horses defense
|
||||
Hobbit's?
|
||||
</dd>
|
||||
<dd>
|
||||
Nûmenor dungeons achieving encourage fretting dines believes
|
||||
understand.
|
||||
</dd>
|
||||
<dd>Ease love shine legs wee harbor Udùn adventure tumble stays.</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
3
src/components/Article/Article.module.css
Normal file
3
src/components/Article/Article.module.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.article {
|
||||
@mixin layout-wrapper;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
.wrapper {
|
||||
@mixin mx auto;
|
||||
@mixin px var(--spacing-comfortable);
|
||||
|
||||
max-width: 90ch;
|
||||
font-size: clamp(1rem, 2.5vw, 1.5rem);
|
||||
}
|
||||
|
||||
.hasMargin {
|
||||
@media screen and (--bp-margin) {
|
||||
@mixin px 0;
|
||||
|
||||
display: flex;
|
||||
gap: var(--spacing-generous);
|
||||
max-width: none;
|
||||
|
||||
& .content {
|
||||
flex: 1 1 auto;
|
||||
min-width: 50ch;
|
||||
max-width: 75ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
56
src/components/Article/Content/MarkdocRenderer/index.tsx
Normal file
56
src/components/Article/Content/MarkdocRenderer/index.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import Markdoc from '@markdoc/markdoc';
|
||||
import type { Node } from '@markdoc/markdoc';
|
||||
|
||||
import styles from './MarkdocRenderer.module.css';
|
||||
|
||||
import { nodes } from '@/lib/markdoc/nodes';
|
||||
import { tags } from '@/lib/markdoc/tags';
|
||||
|
||||
import { collectSideNotes, hasComponents } from '@/lib/markdoc/utils';
|
||||
|
||||
import { Column } from '@/components/Content/Grid/Column';
|
||||
import { Row } from '@/components/Content/Grid/Row';
|
||||
import Sidenote from '@/components/Content/Sidenote/Item';
|
||||
import SidenoteContainer from '@/components/Content/Sidenote/Container';
|
||||
|
||||
interface MarkdocRendererProps {
|
||||
content: () => Promise<{ node: Node }>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MARGIN_COMPONENTS = ['Sidenote'];
|
||||
|
||||
export default async function MarkdocRenderer({
|
||||
content,
|
||||
className,
|
||||
}: MarkdocRendererProps) {
|
||||
const components = {
|
||||
Column,
|
||||
Row,
|
||||
Sidenote,
|
||||
};
|
||||
|
||||
const { node } = await content();
|
||||
const errors = Markdoc.validate(node, { tags, nodes });
|
||||
|
||||
if (errors.length) {
|
||||
console.error('Markdoc validation errors:', errors);
|
||||
throw new Error('Invalid Content');
|
||||
}
|
||||
|
||||
const renderable = Markdoc.transform(node, { tags, nodes });
|
||||
const sidenotes = collectSideNotes(renderable);
|
||||
const showMargin = hasComponents(renderable, MARGIN_COMPONENTS);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.wrapper} ${className} ${showMargin ? styles.hasMargin : ''}`}
|
||||
>
|
||||
<div className={`${styles.content} content`}>
|
||||
{Markdoc.renderers.react(renderable, React, { components })}
|
||||
</div>
|
||||
<SidenoteContainer items={sidenotes} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
src/components/Article/Content/index.tsx
Normal file
10
src/components/Article/Content/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import MarkdocRenderer from '@/components/Article/Content/MarkdocRenderer';
|
||||
import { ArticleContent } from '@/lib/types/content';
|
||||
|
||||
interface ContentProps {
|
||||
article: ArticleContent;
|
||||
}
|
||||
|
||||
export default function Content({ article }: ContentProps) {
|
||||
return <MarkdocRenderer content={article.content} />;
|
||||
}
|
||||
0
src/components/Article/Footer/Footer.module.tsx
Normal file
0
src/components/Article/Footer/Footer.module.tsx
Normal file
0
src/components/Article/Footer/index.tsx
Normal file
0
src/components/Article/Footer/index.tsx
Normal file
36
src/components/Article/Header/Cover/index.tsx
Normal file
36
src/components/Article/Header/Cover/index.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
import styles from './Cover.module.css';
|
||||
|
||||
interface CoverProps {
|
||||
cover: {
|
||||
readonly src: string;
|
||||
readonly alt: string;
|
||||
readonly caption: string;
|
||||
readonly showInHeader: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export default function Cover({ cover }: CoverProps) {
|
||||
return (
|
||||
<figure className={styles.cover}>
|
||||
<Image
|
||||
src={cover.src}
|
||||
alt={cover.alt || 'Standard Alt'}
|
||||
width={0}
|
||||
height={0}
|
||||
sizes={'100vw'}
|
||||
className={styles.image}
|
||||
/>
|
||||
<figcaption className={styles.caption}>
|
||||
<div className={styles.captionwrapper}>
|
||||
{cover.caption ? (
|
||||
<span className={styles.captiontext}>{cover.caption}</span>
|
||||
) : (
|
||||
<span className={styles.covermeta}>{cover.src}</span>
|
||||
)}
|
||||
</div>
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
0
src/components/Article/Header/Header.module.css
Normal file
0
src/components/Article/Header/Header.module.css
Normal file
0
src/components/Article/Header/Meta/Meta.module.css
Normal file
0
src/components/Article/Header/Meta/Meta.module.css
Normal file
60
src/components/Article/Header/Meta/index.tsx
Normal file
60
src/components/Article/Header/Meta/index.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Author, Tag } from '@/lib/types/taxonomy';
|
||||
import { toMilitaryDTG } from '@/lib/utils/date';
|
||||
|
||||
import styles from './Meta.module.css';
|
||||
|
||||
interface MetaProps {
|
||||
author: Author | null;
|
||||
tags: Tag[];
|
||||
updateDate?: string;
|
||||
publicationDate: string;
|
||||
}
|
||||
|
||||
export default function Meta({
|
||||
author,
|
||||
tags,
|
||||
updateDate,
|
||||
publicationDate,
|
||||
}: MetaProps) {
|
||||
return (
|
||||
<div className={styles.meta}>
|
||||
{author && (
|
||||
<div className={styles.section}>
|
||||
<span className={styles.label}>Author</span>
|
||||
<span className={styles.author}>
|
||||
<Link href={author.slug}>{author.name}</Link>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{tags && tags.length > 0 && (
|
||||
<div className={styles.section}>
|
||||
<span className={styles.label}>Tags</span>
|
||||
<span className={styles.author}>
|
||||
<ul className={styles.taglist}>
|
||||
{tags.map((tag) => (
|
||||
<li key={tag.slug} className={styles.tag}>
|
||||
<Link className={styles.link} href={`/tags/${tag.slug}`}>
|
||||
{tag.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{publicationDate && (
|
||||
<div className={styles.metasection}>
|
||||
<span className={styles.metalabel}>Last Update</span>
|
||||
<time className={styles.updatedate}>
|
||||
⟫
|
||||
{updateDate
|
||||
? toMilitaryDTG(updateDate)
|
||||
: toMilitaryDTG(publicationDate)}
|
||||
</time>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/components/Article/Header/Overline/index.tsx
Normal file
46
src/components/Article/Header/Overline/index.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import styles from './Overline.module.css';
|
||||
import { toMilitaryDTG } from '@/lib/utils/date';
|
||||
|
||||
interface OverlineProps {
|
||||
breadcrumbs?: string[];
|
||||
publicationDate: string;
|
||||
}
|
||||
|
||||
export default function Overline({
|
||||
breadcrumbs = [],
|
||||
publicationDate,
|
||||
}: OverlineProps) {
|
||||
const breadcrumber = breadcrumbs.map((crumb, index) => {
|
||||
const isLast = index === breadcrumbs.length - 1;
|
||||
const href = `/${breadcrumbs.slice(0, index + 1).join('/')}`;
|
||||
return (
|
||||
<li key={index} className={styles.crumb}>
|
||||
<span className={styles.separator}>/</span>
|
||||
{isLast ? (
|
||||
<span className={styles.current}>{crumb}</span>
|
||||
) : (
|
||||
<Link href={href} className={styles.link}>
|
||||
{crumb}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<div className={styles.overline}>
|
||||
<ul className={styles.breadcrumbs}>
|
||||
<li className={styles.crumb}>
|
||||
<Link href="/" className={styles.link}>
|
||||
dave-dmg.de
|
||||
</Link>
|
||||
</li>
|
||||
{breadcrumber}
|
||||
</ul>
|
||||
<time className={styles.publicationDate}>
|
||||
⟫ {toMilitaryDTG(publicationDate)}
|
||||
</time>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
src/components/Article/Header/Title/index.tsx
Normal file
14
src/components/Article/Header/Title/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import styles from './Title.module.css';
|
||||
import { JSX } from 'react';
|
||||
|
||||
interface TitleProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function Title({ title }: TitleProps) {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/components/Article/Header/index.tsx
Normal file
41
src/components/Article/Header/index.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ArticleContent } from '@/lib/types/content';
|
||||
import { Author, Tag } from '@/lib/types/taxonomy';
|
||||
import { isValidCover } from '@/lib/utils/guards';
|
||||
|
||||
import styles from './Header.module.css';
|
||||
import Overline from '@/components/Article/Header/Overline';
|
||||
import Title from '@/components/Article/Header/Title';
|
||||
import Meta from '@/components/Article/Header/Meta';
|
||||
import Cover from '@/components/Article/Header/Cover';
|
||||
|
||||
interface HeaderProps {
|
||||
article: ArticleContent;
|
||||
breadcrumbs?: string[];
|
||||
author: Author | null;
|
||||
tags: Tag[];
|
||||
}
|
||||
|
||||
export default function Header({
|
||||
article,
|
||||
breadcrumbs,
|
||||
author,
|
||||
tags,
|
||||
}: HeaderProps) {
|
||||
const { title, meta, cover } = article;
|
||||
return (
|
||||
<header className={styles.container}>
|
||||
<Overline
|
||||
publicationDate={article.meta.publicationDate}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
<Title title={article.title} />
|
||||
{cover && isValidCover(cover) && <Cover cover={cover} />}
|
||||
<Meta
|
||||
tags={tags}
|
||||
author={author}
|
||||
updateDate={meta.updateDate}
|
||||
publicationDate={meta.publicationDate}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
32
src/components/Article/index.tsx
Normal file
32
src/components/Article/index.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { ArticleContent } from '@/lib/types/content';
|
||||
import { Author, Tag } from '@/lib/types/taxonomy';
|
||||
import Header from './Header';
|
||||
import Content from '@/components/Article/Content';
|
||||
|
||||
import styles from './Article.module.css';
|
||||
|
||||
interface ArticleProps {
|
||||
article: ArticleContent;
|
||||
author: Author | null;
|
||||
tags: Tag[];
|
||||
breadcrumbs?: string[];
|
||||
}
|
||||
|
||||
export default function Article({
|
||||
article,
|
||||
author,
|
||||
tags,
|
||||
breadcrumbs,
|
||||
}: ArticleProps) {
|
||||
return (
|
||||
<article className={styles.article}>
|
||||
<Header
|
||||
article={article}
|
||||
author={author}
|
||||
tags={tags}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
<Content article={article} />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
3
src/components/Content/Grid/Column/Column.module.css
Normal file
3
src/components/Content/Grid/Column/Column.module.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.column {
|
||||
position: relative;
|
||||
}
|
||||
23
src/components/Content/Grid/Column/index.tsx
Normal file
23
src/components/Content/Grid/Column/index.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
|
||||
import styles from './Column.module.css';
|
||||
|
||||
interface ColumnProps {
|
||||
style: {
|
||||
colspan: number;
|
||||
};
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Column({ style, children }: ColumnProps) {
|
||||
return (
|
||||
<div
|
||||
className={styles.column}
|
||||
style={{
|
||||
gridColumn: `span ${style.colspan}`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
src/components/Content/Grid/Row/Row.module.css
Normal file
3
src/components/Content/Grid/Row/Row.module.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.row {
|
||||
display: grid;
|
||||
}
|
||||
29
src/components/Content/Grid/Row/index.tsx
Normal file
29
src/components/Content/Grid/Row/index.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
import styles from './Row.module.css';
|
||||
|
||||
interface RowProps {
|
||||
style: {
|
||||
cols: number;
|
||||
gap: string;
|
||||
align: string;
|
||||
justify: string;
|
||||
};
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Row({ style, children }: RowProps) {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.row}`}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${style.cols}, 1 fr)`,
|
||||
gap: `${style.gap}`,
|
||||
alignItems: `${style.align}`,
|
||||
justifyContent: `${style.justify}`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
.container {
|
||||
flex: 1 0 auto;
|
||||
min-width: 16rem;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
@media screen and (--bp-margin) {
|
||||
position: absolute;
|
||||
top: anchor(top);
|
||||
}
|
||||
}
|
||||
|
||||
.note {
|
||||
position: relative;
|
||||
min-width: 16rem;
|
||||
font-family: var(--font-mono);
|
||||
|
||||
@media screen and (--bp-margin) {
|
||||
@mixin text-xs;
|
||||
|
||||
margin-right: var(--spacing-generous);
|
||||
padding: var(--spacing-cozy) var(--spacing-cozy) var(--spacing-cozy) var(--size-12) ;
|
||||
border: var(--size-1) solid var(--color-surface-inverse);
|
||||
|
||||
background: var(--color-surface-elevated-1);
|
||||
box-shadow: 2px 2px 0 var(--color-palette-woodsmoke), 4px 4px 0 var(--color-palette-charcoal-gray);
|
||||
}
|
||||
}
|
||||
|
||||
.marker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: var(--size-8);
|
||||
|
||||
font-size: var(--typo-size-3xl);
|
||||
font-weight: var(--typo-weight-black);
|
||||
color: var(--color-text-inverse);
|
||||
|
||||
background-color: var(--color-surface-inverse);
|
||||
}
|
||||
|
||||
.content {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ref {
|
||||
@mixin ml var(--spacing-snug);
|
||||
@mixin anim-txt-characterglitch;
|
||||
|
||||
font-weight: var(--typo-weight-black);
|
||||
color: var(--color-text-quarternary);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
33
src/components/Content/Sidenote/Container/index.tsx
Normal file
33
src/components/Content/Sidenote/Container/index.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Sidenote } from '@/lib/types/components';
|
||||
import styles from './Container.module.css';
|
||||
|
||||
interface ContainerProps {
|
||||
items: Sidenote[];
|
||||
}
|
||||
|
||||
export default function Container({ items }: ContainerProps) {
|
||||
return (
|
||||
<aside className={styles.container}>
|
||||
{items.map((sidenote: Sidenote) => (
|
||||
<div
|
||||
key={sidenote.id}
|
||||
id={sidenote.id}
|
||||
className={styles.wrapper}
|
||||
style={{ positionAnchor: `--note-${sidenote.id}` }}
|
||||
>
|
||||
<div className={`${styles.note} ${styles[sidenote.type]}`}>
|
||||
<span className={styles.marker}>
|
||||
<span className={styles.symbol}>{sidenote.marker}</span>
|
||||
</span>
|
||||
<div className={styles.content}>
|
||||
{sidenote.content}
|
||||
<a href={`#ref-${sidenote.id}`} className={styles.ref}>
|
||||
↩
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
11
src/components/Content/Sidenote/Item/Item.module.css
Normal file
11
src/components/Content/Sidenote/Item/Item.module.css
Normal file
@@ -0,0 +1,11 @@
|
||||
.ref {
|
||||
@mixin px var(--spacing-tight);
|
||||
|
||||
display: inline-block;
|
||||
font-weight: var(--typo-weight-black);
|
||||
transition: color 0.5s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
17
src/components/Content/Sidenote/Item/index.tsx
Normal file
17
src/components/Content/Sidenote/Item/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Sidenote } from '@/lib/types/components';
|
||||
import styles from './Item.module.css';
|
||||
|
||||
export default function SidenoteItem({ id, marker, content, type }: Sidenote) {
|
||||
return (
|
||||
<sup>
|
||||
<a
|
||||
href={`#${id}`}
|
||||
className={styles.ref}
|
||||
id={`#ref-${id}`}
|
||||
style={{ anchorName: `--note-${id}` }}
|
||||
>
|
||||
[{marker}]
|
||||
</a>
|
||||
</sup>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
}
|
||||
|
||||
.inner {
|
||||
@mixin responsive-wrapper;
|
||||
@mixin layout-wrapper;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@@ -60,7 +60,8 @@
|
||||
--area-bg-animation-keyframe: none;
|
||||
--area-bg--animation-duration: 0s;
|
||||
--area-bg--animation-timing: linear;
|
||||
--area-bg-filter: grayscale(100%) contrast(150) brightness(150);
|
||||
--area-bg-filter: grayscale(100%) contrast(10) brightness(250);
|
||||
--area-bg-blendmode: color-dodge;
|
||||
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
@@ -70,6 +71,7 @@
|
||||
|
||||
object-fit: cover;
|
||||
filter: var(--area-bg-filter);
|
||||
mix-blend-mode: var(--area-bg-blendmode);
|
||||
|
||||
@media screen and (--bp-tablet-down) {
|
||||
display: none;
|
||||
@@ -93,27 +95,10 @@
|
||||
--subtitle-transition: all 0.3s ease-in-out;
|
||||
--title-transition: color 0.3s ease-in-out;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
opacity: 0;
|
||||
background: alpharize(var(--grid-bg), 0.66);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--divider-color: var(--color-tertiary);
|
||||
--title-color: var(--color-tertiary);
|
||||
--subtitle-color: var(--color-tertiary);
|
||||
|
||||
&::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
.menu {
|
||||
--grid-bg: var(--color-palette-charcoal-gray);
|
||||
--grid-fg: var(--color-palette-light-silver);
|
||||
--area-blendmode: normal;
|
||||
|
||||
/* === MenuTitle Vars === */
|
||||
--title-color: var(--grid-fg);
|
||||
@@ -40,13 +41,14 @@
|
||||
--divider-width: var(--size-12);
|
||||
--divider-height: var(--size-2);
|
||||
--divider-font: var(--font-mono);
|
||||
--divider-font-size: var(--typo-size-2xl);
|
||||
--divider-line-height: 1;
|
||||
--divider-font-size: var(--typo-size-3xl);
|
||||
--divider-padding: 0 var(--typo-spacing-cozy);
|
||||
--subtitle-font: var(--font-mono);
|
||||
--subtitle-color: var(--grid-fg);
|
||||
--subtitle-font-size: var(--typo-size-xl);
|
||||
--subtitle-text-transform: uppercase;
|
||||
--subtitle-letter-spacing: var(--typo-spacing-cozy);
|
||||
--subtitle-letter-spacing: var(--typo-spacing-snug);
|
||||
--subtitle-transition: none;
|
||||
|
||||
pointer-events: none;
|
||||
|
||||
13
src/keystatic/collections/meta/article.ts
Normal file
13
src/keystatic/collections/meta/article.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { collection } from '@keystatic/core';
|
||||
|
||||
import { createArticleField } from '@/keystatic/fields/article';
|
||||
|
||||
export default collection({
|
||||
label: 'Meta - Posts',
|
||||
slugField: 'title',
|
||||
path: 'content/meta/*',
|
||||
format: { contentField: 'content' },
|
||||
schema: {
|
||||
...createArticleField('meta'),
|
||||
},
|
||||
});
|
||||
46
src/keystatic/collections/taxonomy/authors.ts
Normal file
46
src/keystatic/collections/taxonomy/authors.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { collection, fields } from '@keystatic/core';
|
||||
|
||||
export default collection({
|
||||
label: 'Authors',
|
||||
slugField: 'name',
|
||||
path: 'content/taxonomy/authors/*',
|
||||
format: { data: 'json' },
|
||||
schema: {
|
||||
name: fields.slug({ name: { label: 'Name' } }),
|
||||
avatar: fields.image({
|
||||
label: 'Avatar',
|
||||
directory: 'public/images/authors',
|
||||
publicPath: '/images/authors',
|
||||
}),
|
||||
description: fields.text({ label: 'Description', multiline: true }),
|
||||
contacts: fields.array(
|
||||
fields.object({
|
||||
type: fields.select({
|
||||
label: 'Contact Type',
|
||||
defaultValue: 'email',
|
||||
options: [
|
||||
{
|
||||
value: 'email',
|
||||
label: 'E-Mail',
|
||||
},
|
||||
{
|
||||
value: 'discord',
|
||||
label: 'Discord',
|
||||
},
|
||||
{
|
||||
value: 'other',
|
||||
label: 'Other',
|
||||
},
|
||||
],
|
||||
}),
|
||||
url: fields.text({
|
||||
label: 'URL',
|
||||
}),
|
||||
}),
|
||||
{
|
||||
label: 'Contact Type',
|
||||
itemLabel: (props) => `${props.fields.type.value}`,
|
||||
}
|
||||
),
|
||||
},
|
||||
});
|
||||
41
src/keystatic/collections/taxonomy/tags.ts
Normal file
41
src/keystatic/collections/taxonomy/tags.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { collection, fields } from '@keystatic/core';
|
||||
|
||||
export default collection({
|
||||
label: 'Tags',
|
||||
slugField: 'name',
|
||||
path: 'content/taxonomy/tags/*',
|
||||
format: { data: 'json' },
|
||||
schema: {
|
||||
name: fields.slug({ name: { label: 'Name' } }),
|
||||
icon: fields.conditional(
|
||||
fields.select({
|
||||
label: 'Icon Type',
|
||||
defaultValue: 'none',
|
||||
options: [
|
||||
{
|
||||
value: 'none',
|
||||
label: 'None',
|
||||
},
|
||||
{
|
||||
value: 'glyph',
|
||||
label: 'Glyph',
|
||||
},
|
||||
{
|
||||
value: 'img',
|
||||
label: 'Image',
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
none: fields.empty(),
|
||||
glyph: fields.text({ label: 'Glyph' }),
|
||||
img: fields.image({
|
||||
label: 'icon',
|
||||
publicPath: '/images/icons',
|
||||
directory: 'public/images/icons',
|
||||
}),
|
||||
}
|
||||
),
|
||||
description: fields.text({ label: 'Description', multiline: true }),
|
||||
},
|
||||
});
|
||||
61
src/keystatic/components/general/grid.ts
Normal file
61
src/keystatic/components/general/grid.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { grid2X2Icon } from '@keystar/ui/icon/icons/grid2X2Icon';
|
||||
import { fields } from '@keystatic/core';
|
||||
import { repeating, wrapper } from '@keystatic/core/content-components';
|
||||
|
||||
export const gridComponents = {
|
||||
Row: repeating({
|
||||
label: 'Row',
|
||||
icon: grid2X2Icon,
|
||||
children: ['Column'], // This defines what can go inside
|
||||
schema: {
|
||||
style: fields.object({
|
||||
cols: fields.integer({
|
||||
label: 'Columns',
|
||||
defaultValue: 2,
|
||||
validation: { min: 2, max: 12 },
|
||||
}),
|
||||
gap: fields.text({
|
||||
label: 'Gap',
|
||||
description: 'Enter a variable or value',
|
||||
}),
|
||||
align: fields.select({
|
||||
label: 'Align Items',
|
||||
defaultValue: 'flex-start',
|
||||
options: [
|
||||
{ label: 'Start', value: 'flex-start' },
|
||||
{ label: 'End', value: 'flex-end' },
|
||||
{ label: 'Center', value: 'center' },
|
||||
{ label: 'Baseline', value: 'baseline' },
|
||||
{ label: 'Stretch', value: 'stretch' },
|
||||
],
|
||||
}),
|
||||
justify: fields.select({
|
||||
label: 'Justify Content',
|
||||
defaultValue: 'flex-start',
|
||||
options: [
|
||||
{ label: 'Start', value: 'flex-start' },
|
||||
{ label: 'End', value: 'flex-end' },
|
||||
{ label: 'Center', value: 'center' },
|
||||
{ label: 'Space Between', value: 'space-between' },
|
||||
{ label: 'Space Around', value: 'space-around' },
|
||||
{ label: 'Evenly', value: 'evenly' },
|
||||
{ label: 'Stretch', value: 'stretch' },
|
||||
{ label: 'Baseline', value: 'baseline' },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
Column: wrapper({
|
||||
label: 'Column',
|
||||
forSpecificLocations: true,
|
||||
schema: {
|
||||
style: fields.object({
|
||||
colspan: fields.integer({
|
||||
label: 'Colspan',
|
||||
defaultValue: 1,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
4
src/keystatic/components/general/index.ts
Normal file
4
src/keystatic/components/general/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { gridComponents } from '@/keystatic/components/general/grid';
|
||||
import { sidenoteComponents } from '@/keystatic/components/general/sidenote';
|
||||
|
||||
export const generalComponents = { ...gridComponents, ...sidenoteComponents };
|
||||
59
src/keystatic/components/general/sidenote.ts
Normal file
59
src/keystatic/components/general/sidenote.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { panelRightDashedIcon } from '@keystar/ui/icon/icons/panelRightDashedIcon';
|
||||
import { fields } from '@keystatic/core';
|
||||
import { inline } from '@keystatic/core/content-components';
|
||||
|
||||
export const sidenoteComponents = {
|
||||
Sidenote: inline({
|
||||
label: 'Sidenote',
|
||||
icon: panelRightDashedIcon,
|
||||
schema: {
|
||||
id: fields.text({
|
||||
label: 'ID',
|
||||
description: 'Unique Identifier (Auto-generated)',
|
||||
}),
|
||||
marker: fields.text({
|
||||
label: 'Marker',
|
||||
description: 'Number or Glyph for reference',
|
||||
defaultValue: '⋄',
|
||||
validation: {
|
||||
length: {
|
||||
min: 1,
|
||||
max: 3,
|
||||
},
|
||||
},
|
||||
}),
|
||||
content: fields.text({
|
||||
label: 'Note Content',
|
||||
multiline: true,
|
||||
validation: {
|
||||
length: {
|
||||
min: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
type: fields.select({
|
||||
label: 'Type',
|
||||
description: 'Visual Style',
|
||||
options: [
|
||||
{
|
||||
label: 'Default',
|
||||
value: 'default',
|
||||
},
|
||||
{
|
||||
label: 'Lore',
|
||||
value: 'lore',
|
||||
},
|
||||
{
|
||||
label: 'Crunch',
|
||||
value: 'crunch',
|
||||
},
|
||||
{
|
||||
label: 'Example',
|
||||
value: 'example',
|
||||
},
|
||||
],
|
||||
defaultValue: 'default',
|
||||
}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
26
src/keystatic/fields/article.ts
Normal file
26
src/keystatic/fields/article.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { fields } from '@keystatic/core';
|
||||
|
||||
import { createContentField } from '@/keystatic/fields/content';
|
||||
import { createSEOField } from '@/keystatic/fields/seo';
|
||||
import { createMetaField } from '@/keystatic/fields/meta';
|
||||
|
||||
export const createArticleField = (imageSubfolder: string) => ({
|
||||
title: fields.slug({ name: { label: 'Title' } }),
|
||||
summary: fields.text({ label: 'Summary', multiline: true }),
|
||||
cover: fields.object({
|
||||
src: fields.image({
|
||||
label: 'Cover Image',
|
||||
directory: `public/images/covers/${imageSubfolder}`,
|
||||
publicPath: `/images/covers/${imageSubfolder}`,
|
||||
}),
|
||||
alt: fields.text({ label: 'Alt' }),
|
||||
caption: fields.text({ label: 'Caption', multiline: true }),
|
||||
showInHeader: fields.checkbox({
|
||||
label: 'Show in Header',
|
||||
defaultValue: false,
|
||||
}),
|
||||
}),
|
||||
meta: createMetaField(),
|
||||
seo: createSEOField(),
|
||||
content: createContentField(imageSubfolder),
|
||||
});
|
||||
22
src/keystatic/fields/content.ts
Normal file
22
src/keystatic/fields/content.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { fields } from '@keystatic/core';
|
||||
import type { ContentComponent } from '@keystatic/core/content-components';
|
||||
|
||||
import { generalComponents } from '@/keystatic/components/general';
|
||||
|
||||
export const createContentField = (
|
||||
imageSubfolder: string,
|
||||
additionalComponents?: Record<string, ContentComponent>
|
||||
) =>
|
||||
fields.markdoc({
|
||||
label: 'Content',
|
||||
options: {
|
||||
image: {
|
||||
directory: `public/images/content/${imageSubfolder}`,
|
||||
publicPath: `/images/content/${imageSubfolder}`,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
...generalComponents,
|
||||
...additionalComponents,
|
||||
},
|
||||
});
|
||||
44
src/keystatic/fields/meta.ts
Normal file
44
src/keystatic/fields/meta.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { ComponentSchema } from '@keystatic/core';
|
||||
import { fields } from '@keystatic/core';
|
||||
|
||||
export const createMetaField = (): ComponentSchema =>
|
||||
fields.object(
|
||||
{
|
||||
publicationDate: fields.datetime({
|
||||
label: 'Publication Date',
|
||||
defaultValue: { kind: 'now' },
|
||||
}),
|
||||
updateDate: fields.datetime({ label: 'Update Date' }),
|
||||
status: fields.select({
|
||||
label: 'Status',
|
||||
defaultValue: 'draft',
|
||||
options: [
|
||||
{ label: 'Draft', value: 'draft' },
|
||||
{ label: 'Published', value: 'published' },
|
||||
{ label: 'Archived', value: 'archived' },
|
||||
],
|
||||
}),
|
||||
isFeatured: fields.checkbox({
|
||||
label: 'Featured',
|
||||
description: 'Show on Homepage',
|
||||
}),
|
||||
tags: fields.array(
|
||||
fields.relationship({
|
||||
label: 'Tags',
|
||||
collection: 'tags',
|
||||
}),
|
||||
{
|
||||
label: 'Tags',
|
||||
itemLabel: (props) => props.value || 'Select Tag',
|
||||
}
|
||||
),
|
||||
author: fields.relationship({
|
||||
label: 'Author',
|
||||
collection: 'authors',
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Meta Information',
|
||||
layout: [4, 4, 4, 12, 12, 12],
|
||||
}
|
||||
);
|
||||
24
src/keystatic/fields/seo.ts
Normal file
24
src/keystatic/fields/seo.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ComponentSchema } from '@keystatic/core';
|
||||
import { fields } from '@keystatic/core';
|
||||
|
||||
export const createSEOField = (): ComponentSchema =>
|
||||
fields.object(
|
||||
{
|
||||
title: fields.text({
|
||||
label: 'SEO Title',
|
||||
validation: { length: { max: 60 } },
|
||||
}),
|
||||
description: fields.text({
|
||||
label: 'SEO Description',
|
||||
multiline: true,
|
||||
validation: { length: { max: 160 } },
|
||||
}),
|
||||
noIndex: fields.checkbox({
|
||||
label: 'No Index',
|
||||
description: 'Prevent search engines from indexing',
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'SEO Settings',
|
||||
}
|
||||
);
|
||||
7
src/lib/markdoc/nodes.ts
Normal file
7
src/lib/markdoc/nodes.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Config } from '@markdoc/markdoc';
|
||||
|
||||
export const nodes: Config['nodes'] = {
|
||||
document: {
|
||||
render: '',
|
||||
},
|
||||
};
|
||||
43
src/lib/markdoc/tags.ts
Normal file
43
src/lib/markdoc/tags.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Config } from '@markdoc/markdoc';
|
||||
|
||||
export const tags: Config['tags'] = {
|
||||
Row: {
|
||||
render: 'Row',
|
||||
attributes: {
|
||||
style: {
|
||||
type: 'Object',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Column: {
|
||||
render: 'Column',
|
||||
attributes: {
|
||||
style: {
|
||||
type: 'Object',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Sidenote: {
|
||||
render: 'Sidenote',
|
||||
attributes: {
|
||||
id: {
|
||||
type: 'String',
|
||||
required: false,
|
||||
},
|
||||
marker: {
|
||||
type: 'String',
|
||||
required: true,
|
||||
},
|
||||
content: {
|
||||
type: 'String',
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: 'String',
|
||||
default: 'default',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
34
src/lib/markdoc/utils.ts
Normal file
34
src/lib/markdoc/utils.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { RenderableTreeNode } from '@markdoc/markdoc';
|
||||
import type { Sidenote } from '@/lib/types/components';
|
||||
|
||||
export function hasComponents(
|
||||
renderable: RenderableTreeNode,
|
||||
components: string[]
|
||||
): boolean {
|
||||
const renderablestring = JSON.stringify(renderable);
|
||||
return components.some((component) => renderablestring.includes(component));
|
||||
}
|
||||
|
||||
export function collectSideNotes(
|
||||
node: RenderableTreeNode | RenderableTreeNode[]
|
||||
): Sidenote[] {
|
||||
const sidenotes: Sidenote[] = [];
|
||||
|
||||
function walk(n: any) {
|
||||
if (!n) return;
|
||||
if (Array.isArray(n)) {
|
||||
n.forEach(walk);
|
||||
} else if (n.name === 'Sidenote' && n.attributes) {
|
||||
sidenotes.push({
|
||||
id: n.attributes.id,
|
||||
marker: n.attributes.marker,
|
||||
content: n.attributes.content,
|
||||
type: n.attributes.type || 'default',
|
||||
});
|
||||
}
|
||||
if (n.children) walk(n.children);
|
||||
}
|
||||
|
||||
walk(node);
|
||||
return sidenotes;
|
||||
}
|
||||
14
src/lib/next/generatePageMetaData.ts
Normal file
14
src/lib/next/generatePageMetaData.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Metadata } from 'next';
|
||||
import { ArticleContent } from '@/lib/types/content';
|
||||
|
||||
export default function generatePageMetaData(
|
||||
article: ArticleContent
|
||||
): Metadata {
|
||||
const seo = article.seo;
|
||||
|
||||
return {
|
||||
title: seo.title || article.title,
|
||||
description: seo.description || article.summary,
|
||||
robots: seo.noIndex,
|
||||
};
|
||||
}
|
||||
15
src/lib/readers/meta/posts.ts
Normal file
15
src/lib/readers/meta/posts.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cache, reader } from '@/lib/readers/base';
|
||||
|
||||
export const getMetaPosts = cache(async () =>
|
||||
reader.collections['meta_posts'].all()
|
||||
);
|
||||
|
||||
export const getMetaPostBySlug = cache(async (slug: string) => {
|
||||
const article = await reader.collections['meta_posts'].read(slug);
|
||||
return article ? { ...article, slug } : null;
|
||||
});
|
||||
|
||||
export const getMetaHome = cache(async () => {
|
||||
const article = await reader.collections['meta_posts'].read('index');
|
||||
return article ? { ...article } : null;
|
||||
});
|
||||
8
src/lib/readers/taxonomy/authors.ts
Normal file
8
src/lib/readers/taxonomy/authors.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { cache, reader } from '@/lib/readers/base';
|
||||
|
||||
export const getAuthors = cache(async () => reader.collections.authors.all());
|
||||
|
||||
export const getAuthorBySlug = cache(async (slug: string) => {
|
||||
const author = await reader.collections.authors.read(slug);
|
||||
return author ? { ...author, slug } : null;
|
||||
});
|
||||
8
src/lib/readers/taxonomy/tags.ts
Normal file
8
src/lib/readers/taxonomy/tags.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { cache, reader } from '@/lib/readers/base';
|
||||
|
||||
export const getTags = cache(async () => reader.collections.tags.all());
|
||||
|
||||
export const getTagBySlug = cache(async (slug: string) => {
|
||||
const tag = await reader.collections.tags.read(slug);
|
||||
return tag ? { ...tag, slug } : null;
|
||||
});
|
||||
6
src/lib/types/components.ts
Normal file
6
src/lib/types/components.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface Sidenote {
|
||||
id: string;
|
||||
marker: string;
|
||||
content: string;
|
||||
type: 'default' | 'lore' | 'crunch' | 'example';
|
||||
}
|
||||
3
src/lib/types/content.ts
Normal file
3
src/lib/types/content.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { MetaContent } from '@/lib/types/meta';
|
||||
|
||||
export type ArticleContent = MetaContent;
|
||||
8
src/lib/types/meta.ts
Normal file
8
src/lib/types/meta.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Entry } from '@keystatic/core/reader';
|
||||
import keystaticConfig from '~/keystatic.config';
|
||||
|
||||
export type MetaPost = Entry<
|
||||
(typeof keystaticConfig.collections)['meta_posts']
|
||||
>;
|
||||
|
||||
export type MetaContent = MetaPost;
|
||||
8
src/lib/types/taxonomy.ts
Normal file
8
src/lib/types/taxonomy.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Entry } from '@keystatic/core/reader';
|
||||
import keystaticConfig from '~/keystatic.config';
|
||||
|
||||
export type AuthorData = Entry<(typeof keystaticConfig.collections)['authors']>;
|
||||
export type TagData = Entry<(typeof keystaticConfig.collections)['tags']>;
|
||||
|
||||
export type Author = AuthorData & { slug: string };
|
||||
export type Tag = TagData & { slug: string };
|
||||
24
src/lib/utils/date.ts
Normal file
24
src/lib/utils/date.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export function toMilitaryDTG(datestring: string, timeZone = 'A') {
|
||||
const date = new Date(datestring);
|
||||
const day = date.getUTCDate().toString().padStart(2, '0');
|
||||
const hours = date.getUTCHours().toString().padStart(2, '0');
|
||||
const minutes = date.getUTCMinutes().toString().padStart(2, '0');
|
||||
const months = [
|
||||
'JAN',
|
||||
'FEB',
|
||||
'MAR',
|
||||
'APR',
|
||||
'MAY',
|
||||
'JUN',
|
||||
'JUL',
|
||||
'AUG',
|
||||
'SEP',
|
||||
'OCT',
|
||||
'NOV',
|
||||
'DEC',
|
||||
];
|
||||
const month = months[date.getUTCMonth()];
|
||||
const year = date.getUTCFullYear().toString().slice(-2);
|
||||
|
||||
return `${day}${hours}${minutes}${timeZone}${month}${year}`;
|
||||
}
|
||||
13
src/lib/utils/guards.ts
Normal file
13
src/lib/utils/guards.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export function isValidCover(cover: {
|
||||
src: string | null;
|
||||
alt: string;
|
||||
caption: string;
|
||||
showInHeader: boolean;
|
||||
}): cover is {
|
||||
src: string;
|
||||
alt: string;
|
||||
caption: string;
|
||||
showInHeader: boolean;
|
||||
} {
|
||||
return cover.showInHeader && cover.src !== null;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
@layer content {
|
||||
.content {
|
||||
font-family: var(--font-body);
|
||||
|
||||
& h1 {
|
||||
margin-block: var(--el-h1-vspace-top) var(--el-h1-vspace-bottom);
|
||||
padding-bottom: var(--spacing-snug);
|
||||
@@ -430,19 +432,21 @@
|
||||
}
|
||||
|
||||
a {
|
||||
@mixin anim-txt-pixelshift 0.6s steps(15, end), var(--color-primary);
|
||||
&:not([class]) {
|
||||
@mixin anim-txt-typewriter;
|
||||
|
||||
color: var(--color-text-tertiary);
|
||||
transition: color 0.2s ease-in-out;
|
||||
color: var(--color-text-tertiary);
|
||||
transition: color 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
&:visited,
|
||||
&:active {
|
||||
color: var(--color-primary-emphasis);
|
||||
&:visited,
|
||||
&:active {
|
||||
color: var(--color-primary-emphasis);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,4 +4,6 @@
|
||||
@custom-media --bp-tablet-up (width >= 48rem);
|
||||
@custom-media --bp-tablet-down (width < 80rem);
|
||||
@custom-media --bp-desktop (width >= 80rem);
|
||||
@custom-media --max-layout (width <= 92rem);
|
||||
@custom-media --bp-margin (width >= 64rem);
|
||||
}
|
||||
@@ -29,8 +29,8 @@
|
||||
--size-192: 48rem;
|
||||
--size-256: 64rem;
|
||||
--size-320: 80rem;
|
||||
--size-384: 96rem;
|
||||
--size-360: 90rem;
|
||||
--size-384: 96rem;
|
||||
--size-400: 100rem;
|
||||
--size-480: 120rem;
|
||||
|
||||
@@ -91,5 +91,9 @@
|
||||
--spacing-generous: var(--size-16);
|
||||
--spacing-luxurious: var(--size-24);
|
||||
--spacing-expansive: var(--size-32);
|
||||
|
||||
/* == Responsive Content Dimensions */
|
||||
--layout-max-width: 1280px;
|
||||
--content-max-width: 75ch;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
--font-mono: var(--font-iosevka-mono);
|
||||
|
||||
/* == Font Sizes == */
|
||||
--typo-size-responsive: clamp(1rem, 2.5vw, 1.25rem);
|
||||
--typo-size-responsive: clamp(1rem, 2.5vw, 1.5rem);
|
||||
--typo-size-base: 16px;
|
||||
--typo-size-8xl: 8em;
|
||||
--typo-size-7xl: 6.375em;
|
||||
@@ -68,6 +68,5 @@
|
||||
--vspace-comfortable: 1.75;
|
||||
--vspace-loose: 2;
|
||||
--vspace-spacious: 2.5;
|
||||
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,16 @@
|
||||
padding: $vspacing $hspacing;
|
||||
font-family: var(--font-body);
|
||||
font-size: $fontSize;
|
||||
}
|
||||
|
||||
@define-mixin layout-wrapper{
|
||||
@mixin mx auto;
|
||||
|
||||
width: 100%;
|
||||
max-width: var(--layout-max-width);
|
||||
|
||||
@media screen and (--max-layout) {
|
||||
@mixin px var(--spacing-comfortable);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user