Added Sidenote Component
71
content/meta/index.mdoc
Normal file
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: A shape of things to come
|
||||
cover:
|
||||
src: /images/covers/meta/index/cover/src.jpeg
|
||||
alt: Pile of Skulls
|
||||
caption: >-
|
||||
To crush your players, see them driven before you, and to hear their
|
||||
wailings of their characters
|
||||
showInHeader: true
|
||||
meta:
|
||||
publicationDate: 2025-10-01T10:30:00.000Z
|
||||
status: draft
|
||||
isFeatured: true
|
||||
tags:
|
||||
- how-and-what
|
||||
author: dave-damage
|
||||
seo:
|
||||
noIndex: false
|
||||
---
|
||||
Vester cælestis pascit illa{% Sidenote #Test marker="⋄" content="In partes Galilææ. Et veniens habitavit in civitate quæ vocatur Nazareth ut adimpleretur quod dictum est per prophetas Quoniam Nazaræus vocabitur" type="default" /%}. Nonne vos magis pluris estis illis? Quis autem vestrum cogitans potest adjicere ad staturam suam cubitum unum? Et de vestimento quid solliciti estis? Considerate lilia agri quomodo crescunt non laborant, neque nent. Dico autem vobis, quoniam nec Salomon in omni gloria sua coopertus est sicut unum ex istis. Si autem fœnum agri, quod hodie est, et cras in clibanum mittitur, Deus sic vestit, quanto magis vos modicæ fidei? Nolite ergo solliciti esse, dicentes Quid manducabimus, aut quid bibemus, aut quo operiemur? hæc enim omnia gentes inquirunt. Scit enim Pater vester, quia his omnibus indigetis. Quærite.
|
||||
|
||||
{% table %}
|
||||
- Ubi
|
||||
- Stell
|
||||
---
|
||||
- 3986
|
||||
- 7176
|
||||
---
|
||||
- 7673
|
||||
- 3875
|
||||
{% /table %}
|
||||
|
||||
## Videantur ab hominibus
|
||||
|
||||
In partes Galilææ. Et veniens habitavit in civitate quæ vocatur Nazareth ut adimpleretur quod dictum est per prophetas Quoniam Nazaræus vocabitur. In diebus autem illis venit Joannes **multorum** Baptista prædicans in deserto Judææ, et dicens Pœnitentiam agite appropinquavit enim regnum cælorum. Hic est enim, qui dictus est per Isaiam prophetam dicentem Vox clamantis in deserto Parate viam Domini; rectas facite semitas ejus. Ipse autem Joannes habebat vestimentum **fiant qui** de pilis camelorum, et zonam pelliceam circa lumbos suos esca autem ejus erat locustæ, et mel silvestre. Tunc exibat ad eum Jerosolyma, et omnis Judæa.
|
||||
|
||||
### Venti et mare obediunt
|
||||
|
||||
- Discipulis ejus ait illi domine permitte.
|
||||
- Inducas in tentationem sed libera nos a **pallium** malo.
|
||||
- Recumbent cum abraham et isaac et
|
||||
- Meum jesus autem ait illi sequere *omnis enim that small* me et.
|
||||
- Sua nolite judicare ut not judicemini in
|
||||
- Fidei tunc *altare et* **autem sermo vester** surgens imperavit.
|
||||
- Es mitte te *infirmitatem* deorsum scriptum.
|
||||
- Judorum vidimus enim stellam *dico vobis quia* ejus.
|
||||
|
||||
#### Procedit de ore dei
|
||||
|
||||
1. De vestimento **sua nolite judicare ut** quid solliciti **quoniam ipsi consolabuntur** estis considerate lilia.
|
||||
1. Animam pueri qui consurgens accepit puerum et.
|
||||
1. Dicens beati pauperes spiritu quoniam
|
||||
1. Vobis quoniam nec salomon in.
|
||||
1. Estis sal terr quod si *eleemosynam nesciat sinistra* sal evanuerit.
|
||||
1. Jesus manum tetigit eum dicens volo [Ab eo](https://example.com/erant/fructum) *arcta is qu ducit* **in manu** mundare.
|
||||
1. Dimisit eam febris et surrexit et ministrabat *dei* eis.
|
||||
1. Tuus qui ==sic omnis== *per isaiam* videt in.
|
||||
|
||||
##### Which dificavit domum suam
|
||||
|
||||
Dico vobis quia omnis qui viderit mulierem ad concupiscendum eam, jam mœchatus est eam in corde suo. Quod si oculus tuus dexter scandalizat te, erue eum, et projice abs te expedit enim tibi ut pereat unum membrorum tuorum, quam totum corpus tuum mittatur in gehennam. Et si dextra manus tua scandalizat te, abscide eam, et projice abs te expedit enim tibi ut pereat unum membrorum tuorum, quam totum corpus tuum eat in gehennam. Dictum est autem Quicumque dimiserit uxorem suam, det ei libellum repudii. Ego autem dico vobis quia omnis.
|
||||
|
||||
###### Malo est audistis
|
||||
|
||||
Ipsi Deum videbunt. Beati pacifici quoniam filii Dei vocabuntur. Beati qui persecutionem patiuntur propter justitiam quoniam ipsorum est regnum cælorum. Beati estis cum maledixerint vobis, et persecuti vos fuerint, et dixerint omne malum adversum vos mentientes, propter me gaudete, et exsultate, quoniam merces vestra copiosa est in cælis. Sic enim persecuti sunt prophetas, qui fuerunt ante vos. Vos estis sal terræ. Quod si sal evanuerit, in quo salietur? ad nihilum valet ultra, nisi ut mittatur foras, et conculcetur ab hominibus. Vos estis lux mundi. Non potest civitas abscondi.
|
||||
|
||||
Prophetam dicentem dicens Surge, et accipe puerum, et *qui dmonia habebant et* matrem ejus, et vade in terram Israël defuncti sunt enim qui quærebant animam pueri. Qui consurgens, accepit puerum, et matrem **grotationes** ejus, et venit in terram Israël. Audiens autem quod Archelaus regnaret in Judæa **reddes autem domino juramenta** pro Herode patre suo, timuit illo ire et admonitus in somnis, secessit in partes Galilææ. Et veniens habitavit in civitate quæ vocatur Nazareth ut adimpleretur quod dictum est *in* per prophetas Quoniam Nazaræus vocabitur. In.
|
||||
|
||||
Videns autem multos pharisæorum{% Sidenote #non-unique marker="*" content="In partes Galilææ. Et veniens habitavit in civitate quæ vocatur Nazareth ut adimpleretur quod dictum est per prophetas Quoniam Nazaræus vocabitur. In diebus autem illis venit Joannes multorum Baptista prædicans in deserto Judææ, et dicens Pœnitentiam agite appropinquavit enim regnum cælorum. Hic est enim, qui dictus est per Isaiam prophetam dicentem Vox clamantis in deserto Parate viam Domini; rectas facite semitas ejus. Ipse autem Joannes habebat vestimentum fiant qui de pilis camelorum, et zonam pelliceam circa lumbos suos esca autem ejus erat locustæ, et mel silvestre. Tunc exibat ad eum Jerosolyma, et omnis Judæa." type="default" /%}, et sadducæorum, venientes ad baptismum suum, dixit eis Progenies viperarum, quis demonstravit vobis fugere a ventura ira? Facite ergo fructum dignum pœnitentiæ. Et ne velitis dicere intra vos Patrem habemus Abraham. Dico enim vobis quoniam potens est Deus de lapidibus istis suscitare filios Abrahæ. Jam enim securis ad radicem arborum posita est. Omnis ergo arbor, quæ non facit fructum bonum, excidetur, et in ignem mittetur. Ego **flumina** quidem baptizo vos **mentientes propter** in aqua in.
|
||||
|
||||
Sal terræ. Quod si sal evanuerit, in quo salietur? ad nihilum valet ultra, nisi ut mittatur foras, et conculcetur ab hominibus. Vos estis lux mundi. Non potest civitas abscondi supra montem posita, neque accendunt lucernam, et ponunt eam sub modio, sed super candelabrum, ut luceat omnibus qui in domo sunt. Sic luceat lux vestra coram hominibus ut videant opera vestra bona, **admirabantur turb super doctrina** et glorificent Patrem vestrum, qui in cælis est. Nolite putare quoniam veni solvere legem aut prophetas non veni solvere, sed adimplere. Amen quippe dico vobis, donec transeat cælum et terra, jota unum aut unus apex non præteribit **jussit** a.
|
||||
@@ -14,7 +14,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Kitchensink",
|
||||
"path": "/",
|
||||
"path": "/kitchensink",
|
||||
"gridPosition": "area_2",
|
||||
"variant": "kitchensink",
|
||||
"sublinks": {
|
||||
@@ -123,17 +123,17 @@
|
||||
"path": "/chainbreaker",
|
||||
"gridPosition": "area_5",
|
||||
"variant": "chainbreaker",
|
||||
"background": "/images/categories/items/4/background.png",
|
||||
"background": "/images/categories/items/4/background.jpg",
|
||||
"sublinks": {
|
||||
"discriminant": false
|
||||
},
|
||||
"subtitle": {
|
||||
"discriminant": true,
|
||||
"value": {
|
||||
"content": "Spear & Animism",
|
||||
"content": "A history of Violence",
|
||||
"divider": {
|
||||
"discriminant": true,
|
||||
"value": "⎊"
|
||||
"value": "⨳"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
15
content/taxonomy/authors/dave-damage.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "Dave Damage",
|
||||
"avatar": "/images/authors/dave-damage/avatar.png",
|
||||
"description": "Nothing nice 2 say",
|
||||
"contacts": [
|
||||
{
|
||||
"type": "email",
|
||||
"url": "ottonom@gmail.com"
|
||||
},
|
||||
{
|
||||
"type": "discord",
|
||||
"url": "381079209197699083"
|
||||
}
|
||||
]
|
||||
}
|
||||
8
content/taxonomy/tags/how-and-what.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "Was ist Was?",
|
||||
"icon": {
|
||||
"discriminant": "glyph",
|
||||
"value": "‽"
|
||||
},
|
||||
"description": "General information and introductions for stuff"
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
import { config } from '@keystatic/core';
|
||||
|
||||
import navigation from '@/keystatic/singletons/navigation';
|
||||
import NavSingleton from '@/keystatic/singletons/navigation';
|
||||
import AuthorsCollection from '@/keystatic/collections/taxonomy/authors';
|
||||
import TagsCollection from '@/keystatic/collections/taxonomy/tags';
|
||||
import MetaPostsCollection from '@/keystatic/collections/meta/article';
|
||||
|
||||
export default config({
|
||||
storage: {
|
||||
kind: 'local',
|
||||
},
|
||||
collections: {},
|
||||
singletons: { navigation },
|
||||
singletons: { navigation: NavSingleton },
|
||||
collections: {
|
||||
authors: AuthorsCollection,
|
||||
tags: TagsCollection,
|
||||
meta_posts: MetaPostsCollection,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@keystar/ui": "^0.7.19",
|
||||
"@keystatic/core": "^0.5.48",
|
||||
"@keystatic/next": "^5.0.4",
|
||||
"@markdoc/markdoc": "^0.5.4",
|
||||
|
||||
3
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@keystar/ui':
|
||||
specifier: ^0.7.19
|
||||
version: 0.7.19(next@15.5.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@keystatic/core':
|
||||
specifier: ^0.5.48
|
||||
version: 0.5.48(next@15.5.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
|
||||
BIN
public/images/authors/dave-damage/avatar.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
public/images/categories/items/4/background.jpg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 5.9 MiB |
BIN
public/images/covers/meta/index/cover/src.jpeg
Normal file
|
After Width: | Height: | Size: 514 KiB |
BIN
public/images/jon-butterworth-rXjSnBx8Kuc-unsplash.jpg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/images/pexels-chalta-phirta-307182428-32933283.jpg
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
public/images/pexels-clickerhappy-534590.jpg
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/images/pexels-david-bartus-43782-450100.jpg
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/pexels-deann-dasilva-1997359-6440968.jpg
Normal file
|
After Width: | Height: | Size: 738 KiB |
BIN
public/images/pexels-igor-haritanovich-814387-1695050.jpg
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
public/images/pile-of-skulls.jpeg
Normal file
|
After Width: | Height: | Size: 514 KiB |
BIN
public/images/swamp.jpeg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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/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/Meta/Meta.module.css
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,3 @@
|
||||
.column {
|
||||
position: relative;
|
||||
}
|
||||
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
@@ -0,0 +1,3 @@
|
||||
.row {
|
||||
display: grid;
|
||||
}
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,7 @@
|
||||
import { Config } from '@markdoc/markdoc';
|
||||
|
||||
export const nodes: Config['nodes'] = {
|
||||
document: {
|
||||
render: '',
|
||||
},
|
||||
};
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,3 @@
|
||||
import { MetaContent } from '@/lib/types/meta';
|
||||
|
||||
export type ArticleContent = MetaContent;
|
||||
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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
}
|
||||
}
|
||||
@@ -6,3 +6,15 @@
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||