├── .gitignore ├── .vscode └── settings.json ├── README.md ├── app └── keystatic │ ├── [[...params]] │ └── page.tsx │ ├── keystatic.tsx │ └── layout.tsx ├── assets ├── icons │ └── stars.svg └── logo.svg ├── components ├── AvatarList.tsx ├── Banner.tsx ├── Button.tsx ├── ComponentBlocks.tsx ├── Divider.tsx ├── Footer.tsx ├── Header.tsx ├── Image.tsx ├── InlineCTA.tsx ├── LoopingVideo.tsx ├── Seo.tsx ├── Testimonial.tsx ├── TweetEmbed.tsx └── YouTubeEmbed.tsx ├── content ├── authors │ ├── dr-james-lee.yaml │ ├── dr-maria-garcia.yaml │ ├── dr-sarah-ahmed.yaml │ ├── emilia-blight.yaml │ └── karim-daouk.yaml ├── externalArticles │ ├── .gitkeep │ └── an-external-article │ │ └── index.yaml ├── pages │ ├── about │ │ ├── content.mdoc │ │ └── index.yaml │ └── home │ │ ├── heading.mdoc │ │ └── index.yaml └── posts │ ├── biometric-authentication-how-it-works-and-why-it-s-more-secure-than-passwords │ ├── content.mdoc │ └── index.yaml │ ├── biometrics-in-healthcare-revolutionizing-patient-identification-and-access-control │ ├── content.mdoc │ └── index.yaml │ ├── the-benefits-and-drawbacks-of-biometric-security-systems │ ├── content.mdoc │ └── index.yaml │ ├── the-ethical-concerns-of-biometric-data-collection-and-privacy-protection │ ├── content.mdoc │ └── index.yaml │ └── the-evolution-of-biometrics-from-fingerprinting-to-facial-recognition │ ├── content.mdoc │ └── index.yaml ├── keystatic.config.tsx ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages ├── [post].tsx ├── _app.tsx ├── about.tsx ├── api │ └── keystatic │ │ └── [[...params]].tsx └── index.tsx ├── pnpm-lock.yaml ├── postcss.config.js ├── prettier.config.js ├── public ├── external-article.png ├── favicon.ico ├── images │ ├── authors │ │ ├── dr-james-lee │ │ │ └── avatar.jpg │ │ ├── dr-maria-garcia │ │ │ └── avatar.jpg │ │ ├── dr-sarah-ahmed │ │ │ └── avatar.jpg │ │ ├── emilia-blight │ │ │ └── avatar.jpg │ │ └── karim-daouk │ │ │ └── avatar.jpg │ ├── external-articles │ │ └── an-external-article │ │ │ └── coverImage.jpg │ ├── posts │ │ ├── biometric-authentication-how-it-works-and-why-it-s-more-secure-than-passwords │ │ │ └── coverImage.jpg │ │ ├── biometrics-in-healthcare-revolutionizing-patient-identification-and-access-control │ │ │ └── coverImage.jpg │ │ ├── the-benefits-and-drawbacks-of-biometric-security-systems │ │ │ └── coverImage.jpg │ │ ├── the-ethical-concerns-of-biometric-data-collection-and-privacy-protection │ │ │ └── coverImage.jpg │ │ └── the-evolution-of-biometrics-from-fingerprinting-to-facial-recognition │ │ │ └── coverImage.jpg │ └── seo-image.png ├── keystatic.svg └── phone-bench.jpg ├── styles ├── global.css └── scoped-preflight.css ├── tailwind.config.js ├── tsconfig.json └── utils ├── cx.tsx ├── dateFormatter.tsx ├── maybeTruncateTextBlock.tsx ├── readTime.tsx ├── slugHelpers.ts └── youtubeEmbedIdGenerator.tsx /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # macos 107 | .DS_Store -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/.pnpm/typescript@4.9.4/node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Keystatic Demo – Blog 2 | A basic blog templatebuilt with Keystatic, Next.js and Tailwind CSS. 3 | 4 | Install dependencies: 5 | 6 | npm install 7 | Start the dev server: 8 | 9 | npm run dev 10 | Visit http://127.0.0.1:3000/keystatic to see the Keystatic Admin UI. 11 | -------------------------------------------------------------------------------- /app/keystatic/[[...params]]/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return null 3 | } 4 | -------------------------------------------------------------------------------- /app/keystatic/keystatic.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { makePage } from "@keystatic/next/ui/app"; 4 | import config from "../../keystatic.config"; 5 | 6 | export default makePage(config); 7 | -------------------------------------------------------------------------------- /app/keystatic/layout.tsx: -------------------------------------------------------------------------------- 1 | import KeystaticApp from "./keystatic"; 2 | 3 | export const metadata = { 4 | title: "Keystatic: Admin UI", 5 | }; 6 | 7 | export default function RootLayout() { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /assets/icons/stars.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /components/AvatarList.tsx: -------------------------------------------------------------------------------- 1 | import Img from "next/image"; 2 | 3 | type Props = { 4 | authors: { 5 | slug: string | null; 6 | name?: string | undefined; 7 | avatar?: string | null | undefined; 8 | role?: string | null | undefined; 9 | }[]; 10 | }; 11 | 12 | const AvatarList = ({ authors }: Props) => { 13 | return ( 14 | 29 | ); 30 | }; 31 | 32 | export default AvatarList; 33 | -------------------------------------------------------------------------------- /components/Banner.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import stars from "../assets/icons/stars.svg"; 3 | 4 | export default function Banner({ 5 | heading, 6 | bodyText, 7 | externalLink, 8 | }: { 9 | heading: string; 10 | bodyText: string; 11 | externalLink?: { 12 | href: string; 13 | label: string; 14 | }; 15 | }) { 16 | return ( 17 |
18 | 24 | 25 |
26 |

{heading}

27 |

28 | {bodyText} 29 | {externalLink && externalLink.href && ( 30 | 36 | {" "} 37 | {externalLink.label} 38 | 39 | )} 40 |

41 |
42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /components/Button.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | type IsInternalLink = { 4 | onClick?: never; 5 | href: string; 6 | externalLink: false; 7 | }; 8 | 9 | type IsExternalLink = { 10 | onClick?: never; 11 | href: string; 12 | externalLink: true; 13 | }; 14 | 15 | type ButtonOrLinkProps = IsInternalLink | IsExternalLink; 16 | 17 | export type ButtonProps = ButtonOrLinkProps & { 18 | label: string; 19 | }; 20 | 21 | const buttonClasses = 22 | "px-4 py-2 bg-text-cyan-700 text-white hover:text-white hover:bg-black rounded-md no-underline hover:cursor-pointer"; 23 | 24 | const Button = ({ href, externalLink, label }: ButtonProps) => { 25 | if (externalLink) { 26 | return ( 27 | 33 | {label} 34 | 35 | ); 36 | } 37 | return ( 38 | 39 | {label} 40 | 41 | ); 42 | }; 43 | 44 | export default Button; 45 | -------------------------------------------------------------------------------- /components/ComponentBlocks.tsx: -------------------------------------------------------------------------------- 1 | import { component, fields } from "@keystatic/core"; 2 | 3 | import Banner from "./Banner"; 4 | import InlineCTA from "./InlineCTA"; 5 | import Divider from "./Divider"; 6 | import YouTubeEmbed from "./YouTubeEmbed"; 7 | import TweetEmbed from "./TweetEmbed"; 8 | import LoopingVideo from "./LoopingVideo"; 9 | import Image from "./Image"; 10 | import Testimonial from "./Testimonial"; 11 | 12 | export const ComponentBlocks = { 13 | divider: component({ 14 | label: "Divider", 15 | preview: (props) => , 16 | schema: { 17 | noIcon: fields.checkbox({ label: "No Icon" }), 18 | }, 19 | }), 20 | inlineCta: component({ 21 | label: "Inline CTA", 22 | preview: (props) => ( 23 | 32 | ), 33 | schema: { 34 | title: fields.text({ label: "Title" }), 35 | summary: fields.text({ label: "Summary" }), 36 | linkLabel: fields.text({ label: "Link Label" }), 37 | href: fields.url({ 38 | label: "Link", 39 | defaultValue: "", 40 | validation: { isRequired: true }, 41 | }), 42 | externalLink: fields.checkbox({ 43 | label: "External Link", 44 | }), 45 | }, 46 | }), 47 | banner: component({ 48 | label: "Banner", 49 | preview: (props) => ( 50 | 58 | ), 59 | schema: { 60 | heading: fields.text({ 61 | label: "Heading", 62 | }), 63 | bodyText: fields.text({ 64 | label: "Body Text", 65 | }), 66 | externalLinkHref: fields.url({ 67 | label: "External Link", 68 | }), 69 | externalLinkLabel: fields.text({ 70 | label: "Link Label", 71 | }), 72 | }, 73 | }), 74 | youtubeEmbed: component({ 75 | label: "YouTube Embed", 76 | preview: (props) => { 77 | const youtubeLink = props.fields.youtubeLink.value; 78 | return youtubeLink ? : null; 79 | }, 80 | schema: { 81 | youtubeLink: fields.url({ 82 | label: "YouTube URL", 83 | }), 84 | }, 85 | }), 86 | tweetEmbed: component({ 87 | label: "Tweet Embed", 88 | preview: (props) => , 89 | schema: { 90 | tweet: fields.url({ 91 | label: "Tweet URL", 92 | }), 93 | }, 94 | }), 95 | loopingVideo: component({ 96 | label: "Looping Video", 97 | preview: (props) => ( 98 | 102 | ), 103 | schema: { 104 | src: fields.text({ 105 | label: "File Name", 106 | }), 107 | caption: fields.text({ 108 | label: "Caption", 109 | }), 110 | }, 111 | }), 112 | image: component({ 113 | label: "Image", 114 | preview: (props) => ( 115 | {props.fields.alt.value} 120 | ), 121 | schema: { 122 | src: fields.text({ 123 | label: "File Name", 124 | validation: { length: { min: 4 } }, 125 | }), 126 | alt: fields.text({ 127 | label: "Alt text", 128 | validation: { length: { min: 4 } }, 129 | }), 130 | caption: fields.text({ label: "Caption" }), 131 | }, 132 | }), 133 | testimonial: component({ 134 | label: "Testimonial", 135 | preview: (props) => ( 136 | 142 | ), 143 | schema: { 144 | quote: fields.text({ 145 | label: "Quote", 146 | multiline: true, 147 | }), 148 | author: fields.text({ 149 | label: "Author", 150 | }), 151 | workplaceOrSocial: fields.text({ 152 | label: "Workplace or Social account name", 153 | }), 154 | socialLink: fields.url({ 155 | label: "Social media link", 156 | }), 157 | }, 158 | }), 159 | }; 160 | -------------------------------------------------------------------------------- /components/Divider.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Stars from "../assets/icons/stars.svg"; 3 | 4 | const Divider = ({ noIcon = false }: { noIcon?: boolean }) => { 5 | return ( 6 |
10 | {noIcon ? ( 11 |
12 | ) : ( 13 | <> 14 |
15 | 21 |
22 | 23 | )} 24 |
25 | ); 26 | }; 27 | 28 | export default Divider; 29 | -------------------------------------------------------------------------------- /components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { usePathname } from "next/navigation"; 2 | import { cx } from "../utils/cx"; 3 | import { baseClasses, NavItems } from "./Header"; 4 | 5 | export default function Footer() { 6 | const pathname = usePathname(); 7 | return ( 8 | 102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { usePathname } from "next/navigation"; 2 | import Image from "next/image"; 3 | import Logo from "../assets/logo.svg"; 4 | import { cx } from "../utils/cx"; 5 | import React from "react"; 6 | 7 | export const baseClasses = 8 | "no-underline justify-center items-center focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 text-gray-800 px-0 hover:text-cyan-700 hover:bg-none bg-none font-medium shrink-0"; 9 | 10 | export const NavItems = [ 11 | { 12 | name: "Home", 13 | slug: "/", 14 | description: 15 | "A collection of readings on the power and potential of biometrics", 16 | }, 17 | { 18 | name: "About", 19 | slug: "/about", 20 | description: "Solaris is a simple blog template for Keystatic.", 21 | }, 22 | ]; 23 | 24 | const KeystaticBanner = () => { 25 | return ( 26 |
27 | You're looking at a{" "} 28 | Keystatic logo{" "} 29 | KEYSTATIC template.{" "} 30 | 36 | Learn more about Keystatic 37 | opens in a new tab 38 | {" "} 39 | and get this template for free. 40 |
41 | ); 42 | }; 43 | 44 | const Header = () => { 45 | const pathname = usePathname(); 46 | const [menuOpen, setMenuOpen] = React.useState(false); 47 | 48 | const MobileMenu = () => { 49 | return ( 50 |
51 | {/* Nav items */} 52 |
53 | {NavItems.map((item) => ( 54 | 61 |

67 | {item.name} 68 |

69 |

{item.description}

70 |
71 | ))} 72 |
73 |
74 | ); 75 | }; 76 | 77 | return ( 78 |
79 | 80 |
81 | 86 | 🌎   Solaris Daily News 87 | 88 | {/* Mobile Hamburger Icon button */} 89 | 132 | {/* Desktop nav */} 133 | 149 |
150 | {menuOpen ? : null} 151 |
152 | ); 153 | }; 154 | 155 | export default Header; 156 | -------------------------------------------------------------------------------- /components/Image.tsx: -------------------------------------------------------------------------------- 1 | const Image = ({ 2 | src, 3 | alt, 4 | caption, 5 | }: { 6 | src: string; 7 | alt: string; 8 | caption?: string; 9 | }) => { 10 | return ( 11 |
12 | {alt} 13 | {caption &&

{caption}

} 14 |
15 | ); 16 | }; 17 | 18 | export default Image; 19 | -------------------------------------------------------------------------------- /components/InlineCTA.tsx: -------------------------------------------------------------------------------- 1 | import Button from "./Button"; 2 | 3 | type InlineCTAProps = { 4 | title: string; 5 | summary: string; 6 | linkButton: { href: string; label: string; externalLink: boolean }; 7 | }; 8 | 9 | const InlineCTA = (props: InlineCTAProps) => { 10 | return ( 11 |
12 |
13 |

{props.title}

14 |

{props.summary}

15 | {props.linkButton.href && ( 16 |
23 |
24 |
25 | ); 26 | }; 27 | 28 | export default InlineCTA; 29 | -------------------------------------------------------------------------------- /components/LoopingVideo.tsx: -------------------------------------------------------------------------------- 1 | const LoopingVideo = ({ src, caption }: { src: string; caption?: string }) => { 2 | return ( 3 |
4 | 7 | {caption &&

{caption}

} 8 |
9 | ); 10 | }; 11 | 12 | export default LoopingVideo; 13 | -------------------------------------------------------------------------------- /components/Seo.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { useRouter } from "next/router"; 3 | import NextHead from "next/head"; 4 | 5 | type MetaData = { 6 | title?: string; 7 | description?: string; 8 | imagePath?: string; 9 | imageAlt?: string; 10 | }; 11 | 12 | const defaultMeta = { 13 | title: "Keystatic | Blog Template", 14 | description: 15 | "Solaris Daily News is a fictive blog about biometrics to demo Keystatic. Built by Thinkmill with Tailwind CSS and Next.js.", 16 | imagePath: "/images/seo-image.png", 17 | imageAlt: "cover image for the Keystatic Blog Template", 18 | }; 19 | 20 | export default function Seo({ 21 | title = defaultMeta.title, 22 | description = defaultMeta.description, 23 | imagePath = defaultMeta.imagePath, 24 | imageAlt = defaultMeta.imageAlt, 25 | }: MetaData) { 26 | // Get correct domain to pass it to SEO image 27 | const router = useRouter(); 28 | const [rootUrl, setRootUrl] = useState(""); 29 | const [currentUrl, setCurrentUrl] = useState(""); 30 | useEffect(() => { 31 | const root = window.location.origin; 32 | const current = root + window.location.pathname; 33 | setRootUrl(root); 34 | setCurrentUrl(current); 35 | }, [router.pathname]); 36 | 37 | return ( 38 | 39 | {title} 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /components/Testimonial.tsx: -------------------------------------------------------------------------------- 1 | const Testimonial = ({ 2 | quote, 3 | author, 4 | workplaceOrSocial, 5 | socialLink, 6 | }: { 7 | quote: string; 8 | author: string; 9 | workplaceOrSocial?: string; 10 | socialLink?: string; 11 | }) => { 12 | return ( 13 |
14 |

“{quote}”

15 |
16 |

{author}

17 | {socialLink && workplaceOrSocial ? ( 18 | 24 | {workplaceOrSocial} 25 | 26 | ) : workplaceOrSocial ? ( 27 |

{workplaceOrSocial}

28 | ) : null} 29 |
30 |
31 | ); 32 | }; 33 | 34 | export default Testimonial; 35 | -------------------------------------------------------------------------------- /components/TweetEmbed.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const TweetEmbed = ({ tweet }: { tweet: string }) => { 4 | const wrapper = React.useRef(null); 5 | React.useEffect(() => { 6 | const script = document.createElement("script"); 7 | script.setAttribute("src", "https://platform.twitter.com/widgets.js"); 8 | wrapper.current!.appendChild(script); 9 | }, []); 10 | return ( 11 |
12 |
13 | 14 |
15 |
16 | ); 17 | }; 18 | 19 | export default TweetEmbed; 20 | -------------------------------------------------------------------------------- /components/YouTubeEmbed.tsx: -------------------------------------------------------------------------------- 1 | import { youtubeEmbedId } from "../utils/youtubeEmbedIdGenerator"; 2 | const YouTubeEmbed = ({ youtubeLink }: { youtubeLink: string }) => { 3 | const embedId = youtubeEmbedId(youtubeLink); 4 | return ( 5 |
6 |