diff --git a/starter/hyv-guarded-blog/.eslintrc.json b/starter/hyv-guarded-blog/.eslintrc.json new file mode 100644 index 0000000000..bffb357a71 --- /dev/null +++ b/starter/hyv-guarded-blog/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/starter/hyv-guarded-blog/.gitignore b/starter/hyv-guarded-blog/.gitignore new file mode 100644 index 0000000000..8777267507 --- /dev/null +++ b/starter/hyv-guarded-blog/.gitignore @@ -0,0 +1,40 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/starter/hyv-guarded-blog/README.md b/starter/hyv-guarded-blog/README.md new file mode 100644 index 0000000000..14ff929d94 --- /dev/null +++ b/starter/hyv-guarded-blog/README.md @@ -0,0 +1,54 @@ +--- +name: AI Voice Gate Blog Starter +slug: hyv-guarded-blog +description: A Next.js blog that fails its own build if posts sound like ChatGPT wrote them. Runs 100% locally, zero API calls. +framework: Next.js +useCase: Blog +css: Tailwind +publisher: Hold Your Voice +deployUrl: https://vercel.com/new/clone?repository-url=https://github.com/shashank-sn/hold-your-voice +demoUrl: https://hold-your-voice-vercel.vercel.app +relatedTemplates: + - blog-starter-kit +--- + +# a next.js blog that fails its own build if it sounds like chatgpt wrote it + +``` +hyv scan _posts/ai-slop-example.md + + ● line 16: ai.holistic — describe the actual approach + ● line 16: ai.fast-paced — cut this — every industry says this + ● line 16: ai.cut[t]?ing-edge — name the specific technology + ● line 20: ai.paradigm — describe the actual shift + ○ line 20: formula.firstly — just start — "firstly" is filler + ○ line 24: ai.foster — use: build, grow, encourage, support + ● line 32: ai.delve — use a specific verb: dig, explore, look at + ● line 36: ai.ever-evolving — cut this — it says nothing + ● line 36: formula.in-conclusion — just end. readers know it's the end. + + 34 issues (14 red, 20 yellow) + score: 0/100 + + exit code 2 -- build refused +``` + +prebuild runs `hyv scan` on every post with `--fail-on-hit`. exits 2 on any hit. vercel refuses the deploy. + +## Demo + +https://hold-your-voice-vercel.vercel.app + +## Deploy your own + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/shashank-sn/hold-your-voice) + +## How to use + +```bash +npx create-next-app --example https://github.com/shashank-sn/hold-your-voice hyv-guarded-blog +``` + +install it once: `npm i -g @holdyourvoice/hyv@latest`. runs 100% locally. zero api calls. free forever. + +$1 first month → [holdyourvoice.com](https://holdyourvoice.com) diff --git a/starter/hyv-guarded-blog/_posts/ai-slop-example.md b/starter/hyv-guarded-blog/_posts/ai-slop-example.md new file mode 100644 index 0000000000..af82931cac --- /dev/null +++ b/starter/hyv-guarded-blog/_posts/ai-slop-example.md @@ -0,0 +1,38 @@ +--- +title: "leveraging innovation for sustainable business growth" +excerpt: "explore key strategies for driving sustainable growth through innovation in the modern digital landscape" +description: "a comprehensive guide to implementing robust innovation frameworks that foster meaningful transformation and deliver tangible business results" +coverImage: "/assets/blog/hello-world/cover.jpg" +date: "2026-07-02T00:00:00.000Z" +author: + name: hold your voice + picture: "/assets/blog/authors/tim.jpeg" +ogImage: + url: "/assets/blog/hello-world/cover.jpg" +--- + +# Leveraging Innovation for Sustainable Business Growth + +In today's fast-paced digital landscape, businesses must harness the power of innovation to stay ahead of the curve. The modern marketplace demands a holistic approach that seamlessly integrates cutting-edge technology with robust strategic frameworks. + +## The Paradigm Shift + +Firstly, it is important to note that the business environment has undergone a profound paradigm shift. Organizations that fail to adapt risk being left behind in an increasingly competitive ecosystem. Moreover, the convergence of artificial intelligence, data-driven decision making, and agile methodologies has created unprecedented opportunities for growth. + +## Key Strategies for Success + +To foster meaningful innovation, companies must embrace a multifaceted approach. This involves leveraging synergies across departments, optimizing workflows, and creating a culture of continuous improvement. + +It is crucial to understand that innovation is not just about implementing new technologies. Rather, it encompasses a comprehensive transformation of organizational mindsets and processes. By empowering teams and encouraging cross-functional collaboration, businesses can unlock hidden potential. + +## Driving Tangible Results + +The implementation of robust innovation frameworks yields tangible outcomes. Organizations that prioritize digital transformation report significant improvements in efficiency, customer satisfaction, and bottom-line performance. These results underscore the importance of taking a proactive stance. + +Let's delve into the specific ways innovation drives growth. Enhanced data analytics capabilities enable more informed decision-making. Streamlined operations reduce costs while improving quality. And perhaps most importantly, a forward-thinking approach helps businesses anticipate market shifts before they occur. + +## Conclusion + +In conclusion, the path to sustainable growth lies in embracing innovation as a core business function. By fostering a culture of creativity, investing in the right tools, and maintaining a relentless focus on execution, organizations can position themselves for long-term success in an ever-evolving landscape. + +The future belongs to those who are willing to challenge conventional wisdom and push the boundaries of what is possible. Are you ready to take the first step? diff --git a/starter/hyv-guarded-blog/_posts/clean-example.md b/starter/hyv-guarded-blog/_posts/clean-example.md new file mode 100644 index 0000000000..0fdc4bb446 --- /dev/null +++ b/starter/hyv-guarded-blog/_posts/clean-example.md @@ -0,0 +1,28 @@ +--- +title: "ship the ugly version first" +date: "2026-07-01T00:00:00.000Z" +excerpt: "stop rewriting and start publishing your work before it feels ready for anyone else to see" +description: "six months polishing a landing page that nobody saw taught me that volume beats perfection and the version you never ship always costs far more than the one you send out too early" +coverImage: "/assets/blog/hello-world/cover.jpg" +ogImage: + url: "/assets/blog/hello-world/cover.jpg" +author: + name: hold your voice + picture: "/assets/blog/authors/tim.jpeg" +--- + +# ship the ugly version first + +I spent six months rewriting a landing page nobody ever saw. + +The copy was fine. The design worked. I just convinced myself it needed to be cleaner, sharper, more "on brand." So the tab stayed open and I tweaked sentences instead of shipping. + +Nobody remembers the version you shipped too early. Everyone remembers the thing you never shipped at all. + +A first draft is a negotiation between your taste and what you can actually produce. Your taste is sharp. Your output, at the start, is not. Volume closes the gap. More writing, more shipping, more cringing at the old stuff. + +"If you are embarrassed by the thing you built six months ago, you are moving at the right pace." A friend told me that. It stuck. + +I follow a simple rule now. Ship when the thing works. Polish when someone complains. When nobody complains, you were already good enough. + +Some projects deserve a three month rebrand. Others just need to exist. Ship accordingly. diff --git a/starter/hyv-guarded-blog/package.json b/starter/hyv-guarded-blog/package.json new file mode 100644 index 0000000000..74dfad7a49 --- /dev/null +++ b/starter/hyv-guarded-blog/package.json @@ -0,0 +1,31 @@ +{ + "name": "hyv-guarded-blog", + "repository": "https://github.com/shashank-sn/hold-your-voice", + "license": "MIT", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "prebuild": "bash -c 'set -e; for f in _posts/*.md; do npx @holdyourvoice/hyv@latest scan \"$f\" --fail-on-hit --ignore voice.staccato-triplet; done'", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "classnames": "^2.5.1", + "date-fns": "^3.6.0", + "gray-matter": "^4.0.3", + "next": "^16.2.10", + "react": "^19", + "react-dom": "^19", + "remark": "^15.0.1", + "remark-html": "^16.0.1" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "typescript": "^5.5.2" + } +} diff --git a/starter/hyv-guarded-blog/postcss.config.js b/starter/hyv-guarded-blog/postcss.config.js new file mode 100644 index 0000000000..12a703d900 --- /dev/null +++ b/starter/hyv-guarded-blog/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/starter/hyv-guarded-blog/public/assets/blog/authors/hyv.jpeg b/starter/hyv-guarded-blog/public/assets/blog/authors/hyv.jpeg new file mode 100644 index 0000000000..e3d521436a Binary files /dev/null and b/starter/hyv-guarded-blog/public/assets/blog/authors/hyv.jpeg differ diff --git a/starter/hyv-guarded-blog/public/assets/blog/authors/jj.jpeg b/starter/hyv-guarded-blog/public/assets/blog/authors/jj.jpeg new file mode 100644 index 0000000000..e3d521436a Binary files /dev/null and b/starter/hyv-guarded-blog/public/assets/blog/authors/jj.jpeg differ diff --git a/starter/hyv-guarded-blog/public/assets/blog/authors/joe.jpeg b/starter/hyv-guarded-blog/public/assets/blog/authors/joe.jpeg new file mode 100644 index 0000000000..d9677ad61c Binary files /dev/null and b/starter/hyv-guarded-blog/public/assets/blog/authors/joe.jpeg differ diff --git a/starter/hyv-guarded-blog/public/assets/blog/authors/tim.jpeg b/starter/hyv-guarded-blog/public/assets/blog/authors/tim.jpeg new file mode 100644 index 0000000000..cc49257b82 Binary files /dev/null and b/starter/hyv-guarded-blog/public/assets/blog/authors/tim.jpeg differ diff --git a/starter/hyv-guarded-blog/public/assets/blog/dynamic-routing/cover.jpg b/starter/hyv-guarded-blog/public/assets/blog/dynamic-routing/cover.jpg new file mode 100644 index 0000000000..c660c92679 Binary files /dev/null and b/starter/hyv-guarded-blog/public/assets/blog/dynamic-routing/cover.jpg differ diff --git a/starter/hyv-guarded-blog/public/assets/blog/hello-world/cover.jpg b/starter/hyv-guarded-blog/public/assets/blog/hello-world/cover.jpg new file mode 100644 index 0000000000..33b7dc4b73 Binary files /dev/null and b/starter/hyv-guarded-blog/public/assets/blog/hello-world/cover.jpg differ diff --git a/starter/hyv-guarded-blog/public/assets/blog/preview/cover.jpg b/starter/hyv-guarded-blog/public/assets/blog/preview/cover.jpg new file mode 100644 index 0000000000..6a975fb36d Binary files /dev/null and b/starter/hyv-guarded-blog/public/assets/blog/preview/cover.jpg differ diff --git a/starter/hyv-guarded-blog/public/favicon/android-chrome-192x192.png b/starter/hyv-guarded-blog/public/favicon/android-chrome-192x192.png new file mode 100644 index 0000000000..2f07282a59 Binary files /dev/null and b/starter/hyv-guarded-blog/public/favicon/android-chrome-192x192.png differ diff --git a/starter/hyv-guarded-blog/public/favicon/android-chrome-512x512.png b/starter/hyv-guarded-blog/public/favicon/android-chrome-512x512.png new file mode 100644 index 0000000000..dbb0faea84 Binary files /dev/null and b/starter/hyv-guarded-blog/public/favicon/android-chrome-512x512.png differ diff --git a/starter/hyv-guarded-blog/public/favicon/apple-touch-icon.png b/starter/hyv-guarded-blog/public/favicon/apple-touch-icon.png new file mode 100644 index 0000000000..8f4033b2a8 Binary files /dev/null and b/starter/hyv-guarded-blog/public/favicon/apple-touch-icon.png differ diff --git a/starter/hyv-guarded-blog/public/favicon/browserconfig.xml b/starter/hyv-guarded-blog/public/favicon/browserconfig.xml new file mode 100644 index 0000000000..9824d87b11 --- /dev/null +++ b/starter/hyv-guarded-blog/public/favicon/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #000000 + + + diff --git a/starter/hyv-guarded-blog/public/favicon/favicon-16x16.png b/starter/hyv-guarded-blog/public/favicon/favicon-16x16.png new file mode 100644 index 0000000000..29deaf6716 Binary files /dev/null and b/starter/hyv-guarded-blog/public/favicon/favicon-16x16.png differ diff --git a/starter/hyv-guarded-blog/public/favicon/favicon-32x32.png b/starter/hyv-guarded-blog/public/favicon/favicon-32x32.png new file mode 100644 index 0000000000..e3b4277bf0 Binary files /dev/null and b/starter/hyv-guarded-blog/public/favicon/favicon-32x32.png differ diff --git a/starter/hyv-guarded-blog/public/favicon/favicon.ico b/starter/hyv-guarded-blog/public/favicon/favicon.ico new file mode 100644 index 0000000000..ea2f437d9d Binary files /dev/null and b/starter/hyv-guarded-blog/public/favicon/favicon.ico differ diff --git a/starter/hyv-guarded-blog/public/favicon/mstile-150x150.png b/starter/hyv-guarded-blog/public/favicon/mstile-150x150.png new file mode 100644 index 0000000000..f2dfd904bf Binary files /dev/null and b/starter/hyv-guarded-blog/public/favicon/mstile-150x150.png differ diff --git a/starter/hyv-guarded-blog/public/favicon/safari-pinned-tab.svg b/starter/hyv-guarded-blog/public/favicon/safari-pinned-tab.svg new file mode 100644 index 0000000000..72ab6e050c --- /dev/null +++ b/starter/hyv-guarded-blog/public/favicon/safari-pinned-tab.svg @@ -0,0 +1,33 @@ + + + + +Created by potrace 1.11, written by Peter Selinger 2001-2013 + + + + + + + + + + diff --git a/starter/hyv-guarded-blog/public/favicon/site.webmanifest b/starter/hyv-guarded-blog/public/favicon/site.webmanifest new file mode 100644 index 0000000000..a672d9a233 --- /dev/null +++ b/starter/hyv-guarded-blog/public/favicon/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "Next.js", + "short_name": "Next.js", + "icons": [ + { + "src": "/favicons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/favicons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#000000", + "background_color": "#000000", + "display": "standalone" +} diff --git a/starter/hyv-guarded-blog/src/app/_components/alert.tsx b/starter/hyv-guarded-blog/src/app/_components/alert.tsx new file mode 100644 index 0000000000..d758ba016e --- /dev/null +++ b/starter/hyv-guarded-blog/src/app/_components/alert.tsx @@ -0,0 +1,48 @@ +import Container from "@/app/_components/container"; +import { EXAMPLE_PATH } from "@/lib/constants"; +import cn from "classnames"; + +type Props = { + preview?: boolean; +}; + +const Alert = ({ preview }: Props) => { + return ( +
+ +
+ {preview ? ( + <> + This page is a preview.{" "} + + Click here + {" "} + to exit preview mode. + + ) : ( + <> + The source code for this blog is{" "} + + available on GitHub + + . + + )} +
+
+
+ ); +}; + +export default Alert; diff --git a/starter/hyv-guarded-blog/src/app/_components/avatar.tsx b/starter/hyv-guarded-blog/src/app/_components/avatar.tsx new file mode 100644 index 0000000000..9c2ea4d381 --- /dev/null +++ b/starter/hyv-guarded-blog/src/app/_components/avatar.tsx @@ -0,0 +1,15 @@ +type Props = { + name: string; + picture: string; +}; + +const Avatar = ({ name, picture }: Props) => { + return ( +
+ {name} +
{name}
+
+ ); +}; + +export default Avatar; diff --git a/starter/hyv-guarded-blog/src/app/_components/container.tsx b/starter/hyv-guarded-blog/src/app/_components/container.tsx new file mode 100644 index 0000000000..17458eb8ed --- /dev/null +++ b/starter/hyv-guarded-blog/src/app/_components/container.tsx @@ -0,0 +1,9 @@ +type Props = { + children?: React.ReactNode; +}; + +const Container = ({ children }: Props) => { + return
{children}
; +}; + +export default Container; diff --git a/starter/hyv-guarded-blog/src/app/_components/cover-image.tsx b/starter/hyv-guarded-blog/src/app/_components/cover-image.tsx new file mode 100644 index 0000000000..dc1c0924dc --- /dev/null +++ b/starter/hyv-guarded-blog/src/app/_components/cover-image.tsx @@ -0,0 +1,36 @@ +import cn from "classnames"; +import Link from "next/link"; +import Image from "next/image"; + +type Props = { + title: string; + src: string; + slug?: string; +}; + +const CoverImage = ({ title, src, slug }: Props) => { + const image = ( + {`Cover + ); + return ( +
+ {slug ? ( + + {image} + + ) : ( + image + )} +
+ ); +}; + +export default CoverImage; diff --git a/starter/hyv-guarded-blog/src/app/_components/date-formatter.tsx b/starter/hyv-guarded-blog/src/app/_components/date-formatter.tsx new file mode 100644 index 0000000000..b092f5ec13 --- /dev/null +++ b/starter/hyv-guarded-blog/src/app/_components/date-formatter.tsx @@ -0,0 +1,12 @@ +import { parseISO, format } from "date-fns"; + +type Props = { + dateString: string; +}; + +const DateFormatter = ({ dateString }: Props) => { + const date = parseISO(dateString); + return ; +}; + +export default DateFormatter; diff --git a/starter/hyv-guarded-blog/src/app/_components/footer.tsx b/starter/hyv-guarded-blog/src/app/_components/footer.tsx new file mode 100644 index 0000000000..c764a82f2d --- /dev/null +++ b/starter/hyv-guarded-blog/src/app/_components/footer.tsx @@ -0,0 +1,32 @@ +import Container from "@/app/_components/container"; +import { EXAMPLE_PATH } from "@/lib/constants"; + +export function Footer() { + return ( + + ); +} + +export default Footer; diff --git a/starter/hyv-guarded-blog/src/app/_components/header.tsx b/starter/hyv-guarded-blog/src/app/_components/header.tsx new file mode 100644 index 0000000000..0aa782e854 --- /dev/null +++ b/starter/hyv-guarded-blog/src/app/_components/header.tsx @@ -0,0 +1,14 @@ +import Link from "next/link"; + +const Header = () => { + return ( +

+ + Blog + + . +

+ ); +}; + +export default Header; diff --git a/starter/hyv-guarded-blog/src/app/_components/hero-post.tsx b/starter/hyv-guarded-blog/src/app/_components/hero-post.tsx new file mode 100644 index 0000000000..0eef0d00be --- /dev/null +++ b/starter/hyv-guarded-blog/src/app/_components/hero-post.tsx @@ -0,0 +1,47 @@ +import Avatar from "@/app/_components/avatar"; +import CoverImage from "@/app/_components/cover-image"; +import { type Author } from "@/interfaces/author"; +import Link from "next/link"; +import DateFormatter from "./date-formatter"; + +type Props = { + title: string; + coverImage: string; + date: string; + excerpt: string; + author: Author; + slug: string; +}; + +export function HeroPost({ + title, + coverImage, + date, + excerpt, + author, + slug, +}: Props) { + return ( +
+
+ +
+
+
+

+ + {title} + +

+
+ +
+
+
+

{excerpt}

+ +
+
+
+ ); +} diff --git a/starter/hyv-guarded-blog/src/app/_components/intro.tsx b/starter/hyv-guarded-blog/src/app/_components/intro.tsx new file mode 100644 index 0000000000..1364d14e34 --- /dev/null +++ b/starter/hyv-guarded-blog/src/app/_components/intro.tsx @@ -0,0 +1,21 @@ +import { CMS_NAME } from "@/lib/constants"; + +export function Intro() { + return ( +
+

+ Blog. +

+

+ A statically generated blog example using{" "} + + Next.js + {" "} + and {CMS_NAME}. +

+
+ ); +} diff --git a/starter/hyv-guarded-blog/src/app/_components/markdown-styles.module.css b/starter/hyv-guarded-blog/src/app/_components/markdown-styles.module.css new file mode 100644 index 0000000000..95d4f8b041 --- /dev/null +++ b/starter/hyv-guarded-blog/src/app/_components/markdown-styles.module.css @@ -0,0 +1,18 @@ +.markdown { + @apply text-lg leading-relaxed; +} + +.markdown p, +.markdown ul, +.markdown ol, +.markdown blockquote { + @apply my-6; +} + +.markdown h2 { + @apply text-3xl mt-12 mb-4 leading-snug; +} + +.markdown h3 { + @apply text-2xl mt-8 mb-4 leading-snug; +} diff --git a/starter/hyv-guarded-blog/src/app/_components/more-stories.tsx b/starter/hyv-guarded-blog/src/app/_components/more-stories.tsx new file mode 100644 index 0000000000..17191da76a --- /dev/null +++ b/starter/hyv-guarded-blog/src/app/_components/more-stories.tsx @@ -0,0 +1,29 @@ +import { Post } from "@/interfaces/post"; +import { PostPreview } from "./post-preview"; + +type Props = { + posts: Post[]; +}; + +export function MoreStories({ posts }: Props) { + return ( +
+

+ More Stories +

+
+ {posts.map((post) => ( + + ))} +
+
+ ); +} diff --git a/starter/hyv-guarded-blog/src/app/_components/post-body.tsx b/starter/hyv-guarded-blog/src/app/_components/post-body.tsx new file mode 100644 index 0000000000..065883c74e --- /dev/null +++ b/starter/hyv-guarded-blog/src/app/_components/post-body.tsx @@ -0,0 +1,16 @@ +import markdownStyles from "./markdown-styles.module.css"; + +type Props = { + content: string; +}; + +export function PostBody({ content }: Props) { + return ( +
+
+
+ ); +} diff --git a/starter/hyv-guarded-blog/src/app/_components/post-header.tsx b/starter/hyv-guarded-blog/src/app/_components/post-header.tsx new file mode 100644 index 0000000000..f377766db3 --- /dev/null +++ b/starter/hyv-guarded-blog/src/app/_components/post-header.tsx @@ -0,0 +1,34 @@ +import Avatar from "./avatar"; +import CoverImage from "./cover-image"; +import DateFormatter from "./date-formatter"; +import { PostTitle } from "@/app/_components/post-title"; +import { type Author } from "@/interfaces/author"; + +type Props = { + title: string; + coverImage: string; + date: string; + author: Author; +}; + +export function PostHeader({ title, coverImage, date, author }: Props) { + return ( + <> + {title} +
+ +
+
+ +
+
+
+ +
+
+ +
+
+ + ); +} diff --git a/starter/hyv-guarded-blog/src/app/_components/post-preview.tsx b/starter/hyv-guarded-blog/src/app/_components/post-preview.tsx new file mode 100644 index 0000000000..07415d1232 --- /dev/null +++ b/starter/hyv-guarded-blog/src/app/_components/post-preview.tsx @@ -0,0 +1,41 @@ +import { type Author } from "@/interfaces/author"; +import Link from "next/link"; +import Avatar from "./avatar"; +import CoverImage from "./cover-image"; +import DateFormatter from "./date-formatter"; + +type Props = { + title: string; + coverImage: string; + date: string; + excerpt: string; + author: Author; + slug: string; +}; + +export function PostPreview({ + title, + coverImage, + date, + excerpt, + author, + slug, +}: Props) { + return ( +
+
+ +
+

+ + {title} + +

+
+ +
+

{excerpt}

+ +
+ ); +} diff --git a/starter/hyv-guarded-blog/src/app/_components/post-title.tsx b/starter/hyv-guarded-blog/src/app/_components/post-title.tsx new file mode 100644 index 0000000000..5d2dc0bc1e --- /dev/null +++ b/starter/hyv-guarded-blog/src/app/_components/post-title.tsx @@ -0,0 +1,13 @@ +import { ReactNode } from "react"; + +type Props = { + children?: ReactNode; +}; + +export function PostTitle({ children }: Props) { + return ( +

+ {children} +

+ ); +} diff --git a/starter/hyv-guarded-blog/src/app/_components/section-separator.tsx b/starter/hyv-guarded-blog/src/app/_components/section-separator.tsx new file mode 100644 index 0000000000..62412c0a69 --- /dev/null +++ b/starter/hyv-guarded-blog/src/app/_components/section-separator.tsx @@ -0,0 +1,3 @@ +export function SectionSeparator() { + return
; +} diff --git a/starter/hyv-guarded-blog/src/app/_components/switch.module.css b/starter/hyv-guarded-blog/src/app/_components/switch.module.css new file mode 100644 index 0000000000..9ab3692de5 --- /dev/null +++ b/starter/hyv-guarded-blog/src/app/_components/switch.module.css @@ -0,0 +1,56 @@ +.switch { + all: unset; + position: absolute; + right: 20px; + top: 70px; + display: inline-block; + color: currentColor; + border-radius: 50%; + border: 1px dashed currentColor; + cursor: pointer; + --size: 24px; + height: var(--size); + width: var(--size); + transition: all 0.3s ease-in-out 0s !important; +} + +[data-mode="system"] .switch::after { + position: absolute; + height: 100%; + width: 100%; + top: 0; + left: 0; + font-weight: 600; + font-size: calc(var(--size) / 2); + display: flex; + align-items: center; + justify-content: center; + content: "A"; +} + +[data-mode="light"] .switch { + box-shadow: 0 0 50px 10px yellow; + background-color: yellow; + border: 1px solid orangered; +} + +[data-mode="dark"] .switch { + box-shadow: calc(var(--size) / 4) calc(var(--size) / -4) calc(var(--size) / 8) + inset #fff; + border: none; + background: transparent; + animation: n linear 0.5s; +} + +@keyframes n { + 40% { + transform: rotate(-15deg); + } + 80% { + transform: rotate(10deg); + } + 0%, + 100% { + transform: rotate(0deg); + } +} diff --git a/starter/hyv-guarded-blog/src/app/_components/theme-switcher.tsx b/starter/hyv-guarded-blog/src/app/_components/theme-switcher.tsx new file mode 100644 index 0000000000..c02678fada --- /dev/null +++ b/starter/hyv-guarded-blog/src/app/_components/theme-switcher.tsx @@ -0,0 +1,113 @@ +"use client"; + +import styles from "./switch.module.css"; +import { memo, useEffect, useState } from "react"; + +declare global { + var updateDOM: () => void; +} + +type ColorSchemePreference = "system" | "dark" | "light"; + +const STORAGE_KEY = "nextjs-blog-starter-theme"; +const modes: ColorSchemePreference[] = ["system", "dark", "light"]; + +/** to reuse updateDOM function defined inside injected script */ + +/** function to be injected in script tag for avoiding FOUC (Flash of Unstyled Content) */ +export const NoFOUCScript = (storageKey: string) => { + /* can not use outside constants or function as this script will be injected in a different context */ + const [SYSTEM, DARK, LIGHT] = ["system", "dark", "light"]; + + /** Modify transition globally to avoid patched transitions */ + const modifyTransition = () => { + const css = document.createElement("style"); + css.textContent = "*,*:after,*:before{transition:none !important;}"; + document.head.appendChild(css); + + return () => { + /* Force restyle */ + getComputedStyle(document.body); + /* Wait for next tick before removing */ + setTimeout(() => document.head.removeChild(css), 1); + }; + }; + + const media = matchMedia(`(prefers-color-scheme: ${DARK})`); + + /** function to add remove dark class */ + window.updateDOM = () => { + const restoreTransitions = modifyTransition(); + const mode = localStorage.getItem(storageKey) ?? SYSTEM; + const systemMode = media.matches ? DARK : LIGHT; + const resolvedMode = mode === SYSTEM ? systemMode : mode; + const classList = document.documentElement.classList; + if (resolvedMode === DARK) classList.add(DARK); + else classList.remove(DARK); + document.documentElement.setAttribute("data-mode", mode); + restoreTransitions(); + }; + window.updateDOM(); + media.addEventListener("change", window.updateDOM); +}; + +let updateDOM: () => void; + +/** + * Switch button to quickly toggle user preference. + */ +const Switch = () => { + const [mode, setMode] = useState( + () => + ((typeof localStorage !== "undefined" && + localStorage.getItem(STORAGE_KEY)) ?? + "system") as ColorSchemePreference, + ); + + useEffect(() => { + // store global functions to local variables to avoid any interference + updateDOM = window.updateDOM; + /** Sync the tabs */ + addEventListener("storage", (e: StorageEvent): void => { + e.key === STORAGE_KEY && setMode(e.newValue as ColorSchemePreference); + }); + }, []); + + useEffect(() => { + localStorage.setItem(STORAGE_KEY, mode); + updateDOM(); + }, [mode]); + + /** toggle mode */ + const handleModeSwitch = () => { + const index = modes.indexOf(mode); + setMode(modes[(index + 1) % modes.length]); + }; + return ( +