mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-13 08:15:14 +02:00
docs(site): relocate GitHub stars to sidebar footer, add light/dark switcher (#294)
Move the live GitHub stars widget into the sidebar footer pill as a type:"icon" link, sitting opposite the Slack mark (space-between) and beside the theme switcher. Render it as inner content (GitHub mark + star + compact count) so fumadocs supplies the anchor. Replace the default fumadocs theme switcher with a custom two-icon control where each icon selects its own theme. The built-in "light-dark" mode is a single blind toggle that flips on any click, so clicking the sun while already in light mode jumps to dark. useTheme is sourced from fumadocs-ui/provider/base and the icons are inlined to avoid bare next-themes / lucide-react imports.
This commit is contained in:
parent
0689d709d2
commit
e1067bf734
5 changed files with 173 additions and 131 deletions
|
|
@ -2,21 +2,10 @@ import { source } from "@/lib/source";
|
||||||
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { baseOptions } from "@/app/layout.config";
|
import { baseOptions } from "@/app/layout.config";
|
||||||
import { GitHubStars } from "@/components/github-stars";
|
|
||||||
|
|
||||||
export default function Layout({ children }: { children: ReactNode }) {
|
export default function Layout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<DocsLayout
|
<DocsLayout tree={source.pageTree} {...baseOptions}>
|
||||||
tree={source.pageTree}
|
|
||||||
{...baseOptions}
|
|
||||||
sidebar={{
|
|
||||||
banner: (
|
|
||||||
<div className="flex">
|
|
||||||
<GitHubStars />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</DocsLayout>
|
</DocsLayout>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -870,117 +870,75 @@ body::after {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════
|
/* ═══════════════════════════════════════════
|
||||||
GitHub star widget (navbar)
|
GitHub star widget (sidebar footer pill)
|
||||||
Split pill: GitHub mark + "Star" │ gold star + count.
|
Rendered as the `icon` of a fumadocs icon-link, so it sits in the footer
|
||||||
|
pill beside the Slack mark and the theme toggle. GitHub mark + star glyph
|
||||||
|
+ live count; the star rotates to coral on hover. The !important sizes win
|
||||||
|
over fumadocs' `[&_svg]:size-4.5` rule on the wrapping link.
|
||||||
═══════════════════════════════════════════ */
|
═══════════════════════════════════════════ */
|
||||||
.ktx-stars {
|
.ktx-stars {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: stretch;
|
align-items: center;
|
||||||
height: 32px;
|
gap: 6px;
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid var(--color-fd-border);
|
|
||||||
background: color-mix(in oklch, var(--color-fd-card) 72%, transparent);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
-webkit-backdrop-filter: blur(8px);
|
|
||||||
font-family: var(--font-display), var(--font-sans), sans-serif;
|
font-family: var(--font-display), var(--font-sans), sans-serif;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--color-fd-foreground);
|
|
||||||
text-decoration: none;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 1px 2px rgba(27, 27, 24, 0.04);
|
|
||||||
transition:
|
|
||||||
transform 0.3s var(--ktx-ease),
|
|
||||||
box-shadow 0.3s var(--ktx-ease),
|
|
||||||
border-color 0.3s ease;
|
|
||||||
animation: ktx-stars-in 0.5s var(--ktx-ease) both;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes ktx-stars-in {
|
/* Push the stars to the opposite (right) end of the footer pill, leaving the
|
||||||
from { opacity: 0; transform: translateY(-4px); }
|
Slack mark on the left — like justify-content: space-between. The auto margin
|
||||||
to { opacity: 1; transform: translateY(0); }
|
absorbs the pill's free space; we cancel the theme toggle's own ms-auto so
|
||||||
|
that single gap lands before the stars, not between stars and the toggle. */
|
||||||
|
#nd-sidebar a[aria-label="Star ktx on GitHub"] {
|
||||||
|
margin-inline-start: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ktx-stars:hover {
|
#nd-sidebar [data-theme-toggle] {
|
||||||
transform: translateY(-1px);
|
margin-inline-start: 0;
|
||||||
border-color: color-mix(in oklch, var(--color-fd-primary) 45%, var(--color-fd-border));
|
|
||||||
box-shadow:
|
|
||||||
0 6px 18px -8px rgba(14, 116, 144, 0.28),
|
|
||||||
0 1px 2px rgba(27, 27, 24, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ktx-stars:focus-visible {
|
|
||||||
outline: 2px solid var(--color-fd-ring);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .ktx-stars {
|
|
||||||
background: color-mix(in oklch, var(--color-fd-card) 60%, transparent);
|
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .ktx-stars:hover {
|
|
||||||
border-color: rgba(34, 211, 238, 0.4);
|
|
||||||
box-shadow:
|
|
||||||
0 6px 18px -8px rgba(34, 211, 238, 0.3),
|
|
||||||
0 1px 2px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ktx-stars-seg {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 0 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ktx-stars-seg--count {
|
|
||||||
border-left: 1px solid var(--color-fd-border);
|
|
||||||
background: color-mix(in oklch, var(--color-fd-primary) 6%, transparent);
|
|
||||||
transition: background 0.3s var(--ktx-ease);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ktx-stars:hover .ktx-stars-seg--count {
|
|
||||||
background: color-mix(in oklch, var(--color-fd-primary) 12%, transparent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ktx-stars-gh {
|
.ktx-stars-gh {
|
||||||
width: 15px;
|
width: 16px !important;
|
||||||
height: 15px;
|
height: 16px !important;
|
||||||
opacity: 0.85;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ktx-stars-text {
|
.ktx-stars-count-wrap {
|
||||||
font-weight: 500;
|
display: inline-flex;
|
||||||
letter-spacing: -0.01em;
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ktx-stars-star {
|
.ktx-stars-star {
|
||||||
width: 14px;
|
width: 12px !important;
|
||||||
height: 14px;
|
height: 12px !important;
|
||||||
fill: #f5b301;
|
flex-shrink: 0;
|
||||||
transition: transform 0.3s var(--ktx-ease), filter 0.3s var(--ktx-ease);
|
fill: currentColor;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition:
|
||||||
|
transform 0.3s var(--ktx-ease),
|
||||||
|
fill 0.3s var(--ktx-ease),
|
||||||
|
opacity 0.3s var(--ktx-ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ktx-stars:hover .ktx-stars-star {
|
/* The wrapping fumadocs link owns the hover; rotate + colour the star from it. */
|
||||||
transform: scale(1.18) rotate(-8deg);
|
#nd-sidebar a:hover .ktx-stars-star {
|
||||||
filter: drop-shadow(0 1px 4px rgba(245, 179, 1, 0.55));
|
transform: rotate(-14deg) scale(1.12);
|
||||||
|
fill: var(--ktx-coral);
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ktx-stars-count {
|
.ktx-stars-count {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
color: var(--color-fd-foreground);
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Skeleton shown only on the rare cold (uncached) fetch */
|
/* Skeleton shown only on the rare cold (uncached) fetch */
|
||||||
.ktx-stars--skeleton {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ktx-stars-skeleton-bar {
|
.ktx-stars-skeleton-bar {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 11px;
|
height: 10px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
|
|
@ -997,16 +955,8 @@ body::after {
|
||||||
to { background-position: -200% 0; }
|
to { background-position: -200% 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compact on phones: drop the "Star" word, keep mark + count */
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.ktx-stars-text { display: none; }
|
|
||||||
.ktx-stars-seg { padding: 0 9px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.ktx-stars { animation: none; transition: none; }
|
#nd-sidebar a:hover .ktx-stars-star { transform: none; }
|
||||||
.ktx-stars:hover { transform: none; }
|
|
||||||
.ktx-stars:hover .ktx-stars-star { transform: none; }
|
|
||||||
.ktx-stars-skeleton-bar { animation: none; }
|
.ktx-stars-skeleton-bar { animation: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,21 @@
|
||||||
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
|
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
|
||||||
import { Logo } from "@/components/logo";
|
import { Logo } from "@/components/logo";
|
||||||
import { SlackIcon } from "@/components/slack-icon";
|
import { SlackIcon } from "@/components/slack-icon";
|
||||||
|
import { GitHubStars, GITHUB_REPO_URL } from "@/components/github-stars";
|
||||||
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
|
|
||||||
export const baseOptions: BaseLayoutProps = {
|
export const baseOptions: BaseLayoutProps = {
|
||||||
nav: {
|
nav: {
|
||||||
title: Logo,
|
title: Logo,
|
||||||
transparentMode: "top",
|
transparentMode: "top",
|
||||||
},
|
},
|
||||||
|
// Custom two-icon switcher (light / dark) where each icon selects its own
|
||||||
|
// theme. The default "light-dark" switcher is a single blind toggle — both
|
||||||
|
// icons just flip the theme, so clicking the sun while already in light mode
|
||||||
|
// jumps to dark, which reads as broken.
|
||||||
|
slots: {
|
||||||
|
themeSwitch: ThemeToggle,
|
||||||
|
},
|
||||||
links: [
|
links: [
|
||||||
{
|
{
|
||||||
type: "icon",
|
type: "icon",
|
||||||
|
|
@ -16,5 +25,13 @@ export const baseOptions: BaseLayoutProps = {
|
||||||
url: "https://join.slack.com/t/ktxcommunity/shared_invite/zt-3y9b44m1x-LVyNNJD5nwaZHq4XS29LMQ",
|
url: "https://join.slack.com/t/ktxcommunity/shared_invite/zt-3y9b44m1x-LVyNNJD5nwaZHq4XS29LMQ",
|
||||||
external: true,
|
external: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "icon",
|
||||||
|
label: "Star ktx on GitHub",
|
||||||
|
icon: <GitHubStars />,
|
||||||
|
text: "GitHub",
|
||||||
|
url: GITHUB_REPO_URL,
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { Suspense } from "react";
|
||||||
import { GitHubIcon } from "@/components/github-icon";
|
import { GitHubIcon } from "@/components/github-icon";
|
||||||
|
|
||||||
const REPO = "kaelio/ktx";
|
const REPO = "kaelio/ktx";
|
||||||
const REPO_URL = `https://github.com/${REPO}`;
|
export const GITHUB_REPO_URL = `https://github.com/${REPO}`;
|
||||||
const API_URL = `https://api.github.com/repos/${REPO}`;
|
const API_URL = `https://api.github.com/repos/${REPO}`;
|
||||||
|
|
||||||
async function fetchStarCount(): Promise<number | null> {
|
async function fetchStarCount(): Promise<number | null> {
|
||||||
|
|
@ -41,53 +41,42 @@ function StarGlyph() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function StarsContent() {
|
async function StarsInner() {
|
||||||
const count = await fetchStarCount();
|
const count = await fetchStarCount();
|
||||||
const label =
|
|
||||||
count === null
|
|
||||||
? "Star ktx on GitHub"
|
|
||||||
: `Star ktx on GitHub — ${count.toLocaleString("en-US")} stars`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<span className="ktx-stars">
|
||||||
href={REPO_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label={label}
|
|
||||||
className="ktx-stars"
|
|
||||||
>
|
|
||||||
<span className="ktx-stars-seg ktx-stars-seg--label">
|
|
||||||
<GitHubIcon className="ktx-stars-gh" />
|
<GitHubIcon className="ktx-stars-gh" />
|
||||||
<span className="ktx-stars-text">Star</span>
|
{count !== null ? (
|
||||||
</span>
|
<span className="ktx-stars-count-wrap">
|
||||||
{count !== null && (
|
|
||||||
<span className="ktx-stars-seg ktx-stars-seg--count">
|
|
||||||
<StarGlyph />
|
<StarGlyph />
|
||||||
<span className="ktx-stars-count">{formatStars(count)}</span>
|
<span className="ktx-stars-count">{formatStars(count)}</span>
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="ktx-stars-count">Star</span>
|
||||||
)}
|
)}
|
||||||
</a>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StarsSkeleton() {
|
function StarsSkeleton() {
|
||||||
return (
|
return (
|
||||||
<span className="ktx-stars ktx-stars--skeleton" aria-hidden="true">
|
<span className="ktx-stars" aria-hidden="true">
|
||||||
<span className="ktx-stars-seg ktx-stars-seg--label">
|
|
||||||
<GitHubIcon className="ktx-stars-gh" />
|
<GitHubIcon className="ktx-stars-gh" />
|
||||||
<span className="ktx-stars-text">Star</span>
|
|
||||||
</span>
|
|
||||||
<span className="ktx-stars-seg ktx-stars-seg--count">
|
|
||||||
<span className="ktx-stars-skeleton-bar" />
|
<span className="ktx-stars-skeleton-bar" />
|
||||||
</span>
|
</span>
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Footer star widget — GitHub mark + live count. Rendered as the `icon` of a
|
||||||
|
* fumadocs `type: "icon"` link, so it lands in the sidebar footer pill beside
|
||||||
|
* the Slack icon and the theme toggle. fumadocs supplies the surrounding <a>
|
||||||
|
* (href + aria-label), so this renders inner content only — no anchor.
|
||||||
|
*/
|
||||||
export function GitHubStars() {
|
export function GitHubStars() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<StarsSkeleton />}>
|
<Suspense fallback={<StarsSkeleton />}>
|
||||||
<StarsContent />
|
<StarsInner />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
97
docs-site/components/theme-toggle.tsx
Normal file
97
docs-site/components/theme-toggle.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, type ComponentProps, type SVGProps } from "react";
|
||||||
|
import { useTheme } from "fumadocs-ui/provider/base";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-icon theme switcher (light / dark), each icon selecting its own theme —
|
||||||
|
* unlike fumadocs' default "light-dark" switcher, which is a single blind
|
||||||
|
* toggle that flips on any click. Dropped into the sidebar footer pill via
|
||||||
|
* `slots.themeSwitch`, so fumadocs passes the container className (left
|
||||||
|
* divider, `ms-auto`, rounded inner buttons); we merge it onto our own base.
|
||||||
|
*
|
||||||
|
* Icons are inlined (the project doesn't depend on `lucide-react` directly);
|
||||||
|
* `useTheme` is re-exported by fumadocs so we avoid a bare `next-themes` import.
|
||||||
|
*/
|
||||||
|
function SunIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2v2" />
|
||||||
|
<path d="M12 20v2" />
|
||||||
|
<path d="m4.93 4.93 1.41 1.41" />
|
||||||
|
<path d="m17.66 17.66 1.41 1.41" />
|
||||||
|
<path d="M2 12h2" />
|
||||||
|
<path d="M20 12h2" />
|
||||||
|
<path d="m6.34 17.66-1.41 1.41" />
|
||||||
|
<path d="m19.07 4.93-1.41 1.41" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MoonIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPTIONS = [
|
||||||
|
["light", SunIcon],
|
||||||
|
["dark", MoonIcon],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function cx(...classes: (string | false | undefined)[]): string {
|
||||||
|
return classes.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeToggle({ className, ...props }: ComponentProps<"div">) {
|
||||||
|
const { setTheme, resolvedTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
const active = mounted ? resolvedTheme : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx("inline-flex items-center overflow-hidden border", className)}
|
||||||
|
data-theme-toggle=""
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{OPTIONS.map(([key, Icon]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
aria-label={key}
|
||||||
|
onClick={() => setTheme(key)}
|
||||||
|
className={cx(
|
||||||
|
"size-6.5 p-1.5 transition-colors",
|
||||||
|
active === key
|
||||||
|
? "bg-fd-accent text-fd-accent-foreground"
|
||||||
|
: "text-fd-muted-foreground hover:text-fd-accent-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-full" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue