Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,28 @@ jobs:
- name: Checkout
uses: actions/checkout@v7

- name: Setup pnpm
uses: pnpm/action-setup@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile --ignore-scripts

- name: Build static demo
run: pnpm build

- name: Configure Pages
uses: actions/configure-pages@v6

- name: Upload artifact
uses: actions/upload-pages-artifact@v5
with:
# Publish the repo root: demo/configurator + progress.js + progress.css + progress.min.js
path: '.'
path: 'out'

- name: Deploy to GitHub Pages
id: deployment
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
node_modules
.DS_Store
*.zip
.next
out
public/progress.js
public/progress.css
Empty file removed .nojekyll
Empty file.
66 changes: 66 additions & 0 deletions app/[locale]/layout.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { NextIntlClientProvider } from 'next-intl';
import { getMessages, setRequestLocale, getTranslations } from 'next-intl/server';
import { jsonLdScriptProps } from 'react-schemaorg';
import { ORIGIN, LOCALES, OG_LOCALES, FAVICON, jsonLd } from '../../lib/site';
import Providers from '../_components/Providers';
import '../demo.css';

export function generateStaticParams() {
return LOCALES.map((locale) => ({ locale }));
}

export const viewport = { themeColor: '#1a73e8' };

export async function generateMetadata({ params }) {
const { locale } = await params;
const t = await getTranslations({ locale });
const url = `${ORIGIN}/${locale}/`;
const languages = {};
LOCALES.forEach((l) => { languages[l] = `${ORIGIN}/${l}/`; });
languages['x-default'] = `${ORIGIN}/`;
const title = t('title');
const description = t('description');
return {
metadataBase: new URL(ORIGIN),
title,
description,
authors: [{ name: 'GreedyLabs' }],
robots: { index: true, follow: true },
alternates: { canonical: url, languages },
icons: { icon: FAVICON },
openGraph: {
type: 'website', siteName: 'ghost-progress-plugin', title, description, url,
images: [{ url: `${ORIGIN}/og-image.png`, width: 1200, height: 630 }],
locale: OG_LOCALES[locale]
},
twitter: {
card: 'summary_large_image', title, description,
images: [`${ORIGIN}/og-image.png`]
}
};
}

export default async function LocaleLayout({ children, params }) {
const { locale } = await params;
setRequestLocale(locale);
const messages = await getMessages();
const t = await getTranslations({ locale });
const ld = jsonLd(locale, t, `${ORIGIN}/${locale}/`);
return (
<html lang={locale} suppressHydrationWarning>
<head>
<script {...jsonLdScriptProps(ld)} />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossOrigin="anonymous" />
<link rel="dns-prefetch" href="https://umami.greedylabs.kr" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/400.css" />
<link rel="stylesheet" href="/progress.css" />
<script defer src="https://umami.greedylabs.kr/script.js" data-website-id="d8bbea54-9776-41bc-9c8a-ec6cc3381c72" />
</head>
<body>
<Providers>
<NextIntlClientProvider messages={messages}>{children}</NextIntlClientProvider>
</Providers>
</body>
</html>
);
}
62 changes: 62 additions & 0 deletions app/[locale]/page.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { setRequestLocale, getTranslations } from 'next-intl/server';
import { LOCALES, codeInstall, CODE_OPTIONS, CODE_COLOR, richText } from '../../lib/site';
import Configurator from '../_components/Configurator';
import Comments from '../_components/Comments';

export function generateStaticParams() {
return LOCALES.map((locale) => ({ locale }));
}

export default async function Page({ params }) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations({ locale });

return (
<>
<Configurator locale={locale} />

<div className="demo-hero">{t('heroText')}</div>

<article className="demo-article gh-content gh-canvas">
<h1>{t('demoTitle')}</h1>
<p>{richText(t('demoIntro'))}</p>

<h2>{t('h_intro')}</h2>
<p>{richText(t('bodyIntro'))}</p>

<h2>{t('h_install')}</h2>
<p>{richText(t('bodyInstall'))}</p>
<pre><code>{codeInstall()}</code></pre>
<h3>{t('h_codeinjection')}</h3>
<p>{richText(t('bodyCodeInjection'))}</p>
<h3>{t('h_options')}</h3>
<p>{richText(t('bodyOptions'))}</p>
<pre><code>{CODE_OPTIONS}</code></pre>

<h2>{t('h_customize')}</h2>
<p>{richText(t('bodyCustomize'))}</p>
<h3>{t('h_color')}</h3>
<p>{richText(t('bodyColor'))}</p>
<pre><code>{CODE_COLOR}</code></pre>
<h3>{t('h_position')}</h3>
<p>{richText(t('bodyPosition'))}</p>

<h2>{t('h_faq')}</h2>
<h4>{t('faqQ1')}</h4>
<p>{richText(t('faqA1'))}</p>
<h4>{t('faqQ2')}</h4>
<p>{richText(t('faqA2'))}</p>
<h4>{t('faqQ3')}</h4>
<p>{richText(t('faqA3'))}</p>

<h2>{t('h_closing')}</h2>
<p>{richText(t('bodyClosing'))}</p>
</article>

<section className="demo-comments gh-canvas">
<Comments locale={locale} />
</section>
</>
);
}
24 changes: 24 additions & 0 deletions app/_components/Comments.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use client';
import Giscus from '@giscus/react';
import { useTheme } from 'next-themes';
import { GISCUS_LANG } from '../../lib/site';

export default function Comments({ locale }) {
const { resolvedTheme } = useTheme();
return (
<Giscus
repo="GreedyLabs/giscus-comment"
repoId="R_kgDOTHbOrw"
category="ghost-progress-plugin.greedylabs.kr"
categoryId="DIC_kwDOTHbOr84DAePj"
mapping="specific"
term="ghost-progress-plugin"
reactionsEnabled="1"
emitMetadata="0"
inputPosition="top"
theme={resolvedTheme || 'preferred_color_scheme'}
lang={GISCUS_LANG[locale] || 'en'}
loading="lazy"
/>
);
}
145 changes: 145 additions & 0 deletions app/_components/Configurator.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useTheme } from 'next-themes';
import { CDN, LOCALES, NAMES } from '../../lib/site';

const DEF = { height: 4, zIndex: 100 };
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');

export default function Configurator({ locale }) {
const t = useTranslations();
const [o, setO] = useState({
content: '.gh-content', position: 'top', height: DEF.height,
useThemeColor: true, color: '#1a73e8', zIndex: DEF.zIndex
});
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [copied, setCopied] = useState(false);
const [ready, setReady] = useState(false);
const inst = useRef(null);
const set = (k) => (e) => setO((p) => ({ ...p, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }));
useEffect(() => setMounted(true), []);

// Load the local widget and drive it via its API (data-auto="false" skips its auto-init).
useEffect(() => {
if (window.GreedyLabsGhostProgress) { setReady(true); return; }
const s = document.createElement('script');
s.src = '/progress.js'; s.setAttribute('data-auto', 'false');
s.onload = () => setReady(true);
document.body.appendChild(s);
}, []);

useEffect(() => {
if (!ready || !window.GreedyLabsGhostProgress) return;
if (inst.current) { inst.current.destroy(); inst.current = null; }
inst.current = window.GreedyLabsGhostProgress.create({
content: '.demo-article', position: o.position, height: num(o.height),
color: o.useThemeColor ? undefined : o.color, zIndex: num(o.zIndex)
});
return () => { if (inst.current) { inst.current.destroy(); inst.current = null; } };
}, [ready, o]);

function num(v) { const n = parseInt(v, 10); return isNaN(n) ? undefined : n; }
const snippet = buildSnippet(o);

function copy() {
copyText(snippet).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); });
}

return (
<aside className="panel">
<h1>ghost-progress-plugin</h1>
<div className="panel-meta">
<div className="field row">
<div>
<label htmlFor="lang-select">{t('langLabel')}</label>
<select id="lang-select" value={`/${locale}/`} onChange={(e) => { location.href = e.target.value; }}>
{LOCALES.map((l) => <option key={l} value={`/${l}/`}>{NAMES[l]}</option>)}
</select>
</div>
<div>
<label htmlFor="theme-select">{t('themeLabel')}</label>
<select id="theme-select" value={mounted ? (theme || 'system') : 'system'} onChange={(e) => setTheme(e.target.value)}>
<option value="system">{t('themeSystem')}</option>
<option value="light">{t('themeLight')}</option>
<option value="dark">{t('themeDark')}</option>
</select>
</div>
</div>
</div>

<div className="field">
<label htmlFor="f-content">{t('lblContent')} <span style={{ fontWeight: 400, color: 'var(--ui-muted)' }}>{t('hintContent')}</span></label>
<input type="text" id="f-content" value={o.content} onChange={set('content')} />
<div className="presets">
<button type="button" className="preset" onClick={() => setO((p) => ({ ...p, content: '.gh-content' }))}>Ghost</button>
<button type="button" className="preset" onClick={() => setO((p) => ({ ...p, content: '.notion-page-content' }))}>Notion</button>
</div>
</div>

<div className="field row">
<div>
<label htmlFor="f-position">{t('lblPosition')}</label>
<select id="f-position" value={o.position} onChange={set('position')}>
<option value="top">{t('optTop')}</option>
<option value="bottom">{t('optBottom')}</option>
</select>
</div>
<div><label htmlFor="f-height">{t('lblHeight')}</label><input type="number" id="f-height" value={o.height} step="1" onChange={set('height')} /></div>
</div>

<div className="field check">
<input type="checkbox" id="f-useThemeColor" checked={o.useThemeColor} onChange={set('useThemeColor')} />
<label htmlFor="f-useThemeColor">{t('lblAutoColor')}</label>
</div>
<div className="field row">
<div><label htmlFor="f-color">{t('lblColor')}</label><input type="color" id="f-color" value={o.color} disabled={o.useThemeColor} onChange={set('color')} /></div>
<div><label htmlFor="f-zindex">{t('lblZindex')}</label><input type="number" id="f-zindex" value={o.zIndex} step="1" onChange={set('zIndex')} /></div>
</div>

<div className="code">
<div className="code-head"><strong>{t('embedLabel')}</strong><button className={'copy' + (copied ? ' ok' : '')} id="copy" onClick={copy}>{copied ? t('copyDone') : t('copy')}</button></div>
<pre><code id="snippet">{snippet}</code></pre>
</div>

<div className="panel-support">
<p>{t('support')}</p>
<a href="https://www.buymeacoffee.com/daeho.ro" target="_blank" rel="noopener">
<img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy me a coffee" height="40" width="145" />
</a>
</div>
</aside>
);
}

function buildSnippet(o) {
const a = [
`data-content="${esc(o.content)}"`,
`data-position="${esc(o.position)}"`
];
const ni = (v) => parseInt(v, 10);
if (ni(o.height) !== DEF.height) a.push(`data-height="${ni(o.height)}"`);
if (!o.useThemeColor) a.push(`data-color="${esc(o.color)}"`);
if (ni(o.zIndex) !== DEF.zIndex) a.push(`data-z-index="${ni(o.zIndex)}"`);
return `<link rel="stylesheet" href="${CDN}/progress.css">\n<script src="${CDN}/progress.min.js"\n ${a.join('\n ')}></script>`;
}

function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text).catch(() => legacyCopy(text));
}
return legacyCopy(text);
}
function legacyCopy(text) {
return new Promise((resolve, reject) => {
const ta = document.createElement('textarea');
ta.value = text; ta.setAttribute('readonly', '');
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
document.body.appendChild(ta); ta.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch (e) {}
document.body.removeChild(ta);
ok ? resolve() : reject();
});
}
10 changes: 10 additions & 0 deletions app/_components/Providers.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
'use client';
import { ThemeProvider } from 'next-themes';

export default function Providers({ children }) {
return (
<ThemeProvider attribute="data-theme" defaultTheme="system" enableSystem storageKey="gprog-theme">
{children}
</ThemeProvider>
);
}
Loading