@haruhimemoe/ui 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -1
- package/README.md +163 -3
- package/dist/components/brand/BrandPage.d.ts +58 -0
- package/dist/components/brand/BrandPage.js +17 -0
- package/dist/components/brand/BrandSwatch.d.ts +29 -0
- package/dist/components/brand/BrandSwatch.js +35 -0
- package/dist/components/content/ContentIndex.d.ts +22 -0
- package/dist/components/content/ContentIndex.js +21 -0
- package/dist/components/content/ContentLayout.d.ts +23 -0
- package/dist/components/content/ContentLayout.js +11 -0
- package/dist/components/content/ContentNav.d.ts +29 -0
- package/dist/components/content/ContentNav.js +32 -0
- package/dist/components/content/ContentPage.d.ts +39 -0
- package/dist/components/content/ContentPage.js +16 -0
- package/dist/components/content/ContentSearch.d.ts +27 -0
- package/dist/components/content/ContentSearch.js +32 -0
- package/dist/components/content/CopyMarkdownButton.d.ts +32 -0
- package/dist/components/content/CopyMarkdownButton.js +43 -0
- package/dist/components/content/searchContent.d.ts +22 -0
- package/dist/components/content/searchContent.js +37 -0
- package/dist/components/content/types.d.ts +30 -0
- package/dist/components/content/types.js +12 -0
- package/dist/components/osu/PlayerCard.d.ts +59 -0
- package/dist/components/osu/PlayerCard.js +40 -0
- package/dist/components/osu/playerLinks.d.ts +47 -0
- package/dist/components/osu/playerLinks.js +65 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.js +13 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.11.0] - 2026-10-04
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `ContentNav` and `ContentLayout`: a content section's side navigation and page grid, a port of bb's docs sidebar and layout. `ContentNav` (client) takes `groups` (`ContentNavGroup[]`, each an optional `heading` and `ContentNavItem[]`) and an index link, marks the current page `aria-current="page"`, shows `navTitle ?? title` (clamped to two lines, the full title on the link's `title` attribute) and an optional `badge`. `ContentLayout` (server) lays the nav in a 14rem column beside the page from `lg` up, taking `nav` as an already-rendered slot so it stays section-agnostic (docs today, a blog section later). New types: `ContentNavItem`, `ContentNavGroup`, `ContentSearchItem`.
|
|
14
|
+
- `searchContent`, `ContentSearch` and `ContentIndex`: a content section's search, a port of bb's docs search. `searchContent(items, query)` is pure: a blank query returns everything, otherwise every typed word must appear in an item's `title`, `navTitle`, `description`, `badge` or `keywords`, title-prefix matches first. `ContentSearch` (client) is a `TextInput` plus a polite live-region count ("12 pages.", "1 match.", "3 matches.", the unfiltered noun set by `countNoun`, default `["page", "pages"]`) over `ContentIndex`. `ContentIndex` (server) is the bare card grid, for a section with too few entries to search (`/legal`, a handful of guides).
|
|
15
|
+
- `ContentPage` and `CopyMarkdownButton`: a content section's page. `ContentPage` (server) is `PageHeader` (`title`, `description` as its `lead`), a meta row with `lastUpdated` (a `<time dateTime>`) and, when `markdownHref` is set, `CopyMarkdownButton`, plus `actions`; optional `jsonLd` (one `Record<string, unknown>`, passed to `JsonLd`); then the body in `Prose`. `CopyMarkdownButton` (client) fetches `href` and copies the response body to the clipboard, reporting the result like `CopyButton`; any failure (the fetch, the response, or the clipboard) shows the same message and it never throws. Finished class strings (no `cx`), so `ContentPage` ships no tailwind-merge for it.
|
|
16
|
+
- `BrandPage` and `BrandSwatch`: a product's `/brand` page, `<BrandPage {...brandPageData("pools")} />` with `@haruhimemoe/brand`'s data (typed structurally; ui doesn't depend on brand). Sections: Name, Logo (each file previewed on a dark or light tile, with a `download` link), Colors (`BrandSwatch`, click to copy the hex), Type (`fonts`, default Nunito), Do's and don'ts, osu!, Family (only with `familyHref`), Contact (`mailto:`), plus `slots.afterLogo`, `slots.afterColors` and `slots.end`. `BrandSwatch` (client) has finished class strings, so `BrandPage` ships no tailwind-merge. New types: `BrandPageProps`, `BrandPageAsset`, `BrandPageFont`, `BrandSwatchProps`.
|
|
17
|
+
|
|
18
|
+
## [0.10.0] - 2026-10-04
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- `PlayerCard`: osu!-web's user card from plain props (cover, avatar, country and team flags, supporter heart, username linking to the profile, an optional status row). It never fetches; apps pass a snapshot.
|
|
23
|
+
|
|
9
24
|
## [0.9.0] - 2026-10-03
|
|
10
25
|
|
|
11
26
|
### Added
|
|
@@ -138,7 +153,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
138
153
|
- `className` on every component, and the extras passed to `buttonClasses` and `fieldClasses`, merge with tailwind-merge: a caller's class replaces a built-in one that sets the same property (`fieldClasses("w-auto")` drops `w-full`).
|
|
139
154
|
- Shell: `SiteHeader` (brand slot, nav links as data with `aria-current`, actions slot), `NavLinks`, `SiteFooter` (link columns as data, fine print, the haruhime.moe wordmark and a GitHub link) and `PageShell` (skip link, header, main, footer).
|
|
140
155
|
|
|
141
|
-
[unreleased]: https://github.com/haruhimemoe/ui/compare/v0.
|
|
156
|
+
[unreleased]: https://github.com/haruhimemoe/ui/compare/v0.11.0...HEAD
|
|
157
|
+
[0.11.0]: https://github.com/haruhimemoe/ui/compare/v0.10.0...v0.11.0
|
|
158
|
+
[0.10.0]: https://github.com/haruhimemoe/ui/compare/v0.9.0...v0.10.0
|
|
142
159
|
[0.9.0]: https://github.com/haruhimemoe/ui/compare/v0.8.0...v0.9.0
|
|
143
160
|
[0.8.0]: https://github.com/haruhimemoe/ui/compare/v0.7.0...v0.8.0
|
|
144
161
|
[0.7.0]: https://github.com/haruhimemoe/ui/compare/v0.6.0...v0.7.0
|
package/README.md
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
# @haruhimemoe/ui
|
|
4
4
|
|
|
5
|
-
React components for the haruhime.moe osu! tools on Next.js. It ships the osu!-web-style palette as a Tailwind 4 theme, plus buttons, links, badges, form fields and confirms, filter controls (toggle and choice chips, a two-thumb range slider, a filter panel), tables, osu! beatmap display pieces, a command palette (mod+k, with the defaults every tool shares) and the site header, footer, tabs, account menu and page frame. Most components are Server Components. The few that need the browser carry `"use client"` in their own files, so you import everything from one place.
|
|
5
|
+
React components for the haruhime.moe osu! tools on Next.js. It ships the osu!-web-style palette as a Tailwind 4 theme, plus buttons, links, badges, form fields and confirms, filter controls (toggle and choice chips, a two-thumb range slider, a filter panel), tables, osu! beatmap display pieces and player cards, a command palette (mod+k, with the defaults every tool shares), content section tooling (nav, search, index and page), brand pages and the site header, footer, tabs, account menu and page frame. Most components are Server Components. The few that need the browser carry `"use client"` in their own files, so you import everything from one place.
|
|
6
6
|
|
|
7
7
|
See every component in its states at [haruhime.moe/ui](https://www.haruhime.moe/ui). The page names the version it runs.
|
|
8
8
|
|
|
9
|
-
This README describes version 0.
|
|
9
|
+
This README describes version 0.11.0. Anything marked "since 0.11.0" is not in 0.10.0, anything marked "since 0.10.0" is not in 0.9.0, anything marked "since 0.9.0" is not in 0.8.0, anything marked "since 0.8.0" is not in 0.7.0, anything marked "since 0.7.0" is not in 0.6.0, anything marked "since 0.6.0" is not in 0.5.0, anything marked "since 0.5.0" is not in 0.4.0, anything marked "since 0.4.0" is not in 0.3.0, anything marked "since 0.3.0" is not in 0.2.0, and anything marked "since 0.2.0" is not in 0.1.0. [CHANGELOG.md](./CHANGELOG.md) lists what changed in each version.
|
|
10
10
|
|
|
11
11
|
## Requirements
|
|
12
12
|
|
|
@@ -155,6 +155,7 @@ Import every component from `@haruhimemoe/ui`, in Server and Client Components a
|
|
|
155
155
|
|
|
156
156
|
- **Client components:** `CopyButton`, `Chip`, `ChipGroup`, `RangeSlider` and `FilterPanel`, and since 0.4.0 `AsyncButton`, `InlineConfirm`, `Disclosure`, `ChoiceChips`, `RadioGroup`, `TypeToConfirm` and `HeaderMenu`, and since 0.5.0 `Tabs`, `VisibilitySelect` and `ReportDisclosure`, and since 0.8.0 `CommandPalette` and `CommandPaletteButton`. Each file starts with `"use client"`. They merge their classes with tailwind-merge in the browser, so a page that renders any of them loads tailwind-merge (about 9 KB gzipped), however its header renders.
|
|
157
157
|
- **`SiteHeader` and `NavLinks`** are Server Components with a small client part (since 0.2.0; in 0.1.0 `NavLinks` is a client component). When a nav link can be the current page (a path such as `/packs`), a client list reads the path to set `aria-current`. With only external or text-only links, the nav renders on the server alone and nothing in it hydrates. Relative hrefs (`#main`) skip the client list too, but they render `next/link`, which hydrates.
|
|
158
|
+
- **`ContentNav`** (since 0.11.0) reads the path to mark the current page, like `NavLinks`, but it always hydrates: its class strings are finished (no `cx`), so it ships no tailwind-merge to the browser. `CopyMarkdownButton` (since 0.11.0) is the same: finished classes, no tailwind-merge, even though `ContentPage` (a Server Component) renders it. `ContentLayout` and `ContentPage` are Server Components. `BrandSwatch` (since 0.11.0) is the same again: `BrandPage` is a Server Component and only its swatches hydrate, with no tailwind-merge.
|
|
158
159
|
- **Everything else is server-safe:** no state, no effects, no browser APIs.
|
|
159
160
|
|
|
160
161
|
A Server Component can't pass a function to a Client Component. So callback props (`onChange`, `onPressedChange`, `onClear`) have to come from your own `"use client"` file, like the filters example below. Props that are plain data (`CopyButton`'s `text`, `Chip`'s `pressed`) work from a Server Component. `Pagination` takes a function (`hrefFor`), but it is a Server Component itself, so that is fine anywhere. Its button mode (`onPageChange`) is a callback, so render that from a `"use client"` file.
|
|
@@ -717,6 +718,129 @@ schema.org structured data in a `<script type="application/ld+json">`. Every `<`
|
|
|
717
718
|
| --- | --- | --- | --- |
|
|
718
719
|
| `data` | `Record<string, unknown>` | required | The schema.org object. `@context` defaults to `https://schema.org`; set it in `data` to change it. |
|
|
719
720
|
|
|
721
|
+
### Content
|
|
722
|
+
|
|
723
|
+
Since 0.11.0. A content section's side navigation, page grid and search, ports of bb's docs sidebar, layout grid and search. `ui` has no next-kit or brand dependency: resolve your section's entries (a docs registry, a future blog) into these plain, structural types yourself.
|
|
724
|
+
|
|
725
|
+
```ts
|
|
726
|
+
type ContentNavItem = { href: string; title: string; navTitle?: string; badge?: string };
|
|
727
|
+
type ContentNavGroup = { heading?: string; items: readonly ContentNavItem[] };
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
`ContentSearchItem` (`ContentNavItem & { description: string; keywords?: readonly string[] }`) is exported too, for `searchContent`, `ContentSearch` and `ContentIndex` below.
|
|
731
|
+
|
|
732
|
+
#### `ContentNav` (client)
|
|
733
|
+
|
|
734
|
+
The index link, then each group of links, the current page marked. A column from `lg` up; a "Contents" disclosure above it on phones. Finished class strings (no `cx`).
|
|
735
|
+
|
|
736
|
+
| Prop | Type | Default | What it does |
|
|
737
|
+
| --- | --- | --- | --- |
|
|
738
|
+
| `label` | `string` | required | The nav landmark's accessible name, on both the column and the phone disclosure. |
|
|
739
|
+
| `indexHref` | `string` | required | Where the index link (the section's overview page) points. |
|
|
740
|
+
| `indexLabel` | `string` | `"Overview"` | The index link's label. |
|
|
741
|
+
| `groups` | `readonly ContentNavGroup[]` | required | Each group's heading (left out when `heading` is unset) and links. |
|
|
742
|
+
|
|
743
|
+
A link's label is `navTitle ?? title`, in a `line-clamp-2` span so a long title wraps to two lines instead of overflowing the 14rem column; the link's `title` attribute always holds the full `title`. The current page's link gets `aria-current="page"`. A `badge` (e.g. a count) shows beside the label, in `font-mono`.
|
|
744
|
+
|
|
745
|
+
#### `ContentLayout`
|
|
746
|
+
|
|
747
|
+
The grid: `nav` in a 14rem column from `lg` up, the page beside it; one column on phones. A Server Component. `nav` takes an already-rendered node (usually a `<ContentNav>`), so this stays section-agnostic. Every native `<div>` prop; `className` merges last.
|
|
748
|
+
|
|
749
|
+
| Prop | Type | Default | What it does |
|
|
750
|
+
| --- | --- | --- | --- |
|
|
751
|
+
| `nav` | `ReactNode` | required | The section's navigation, already rendered. |
|
|
752
|
+
| `children` | `ReactNode` | required | The page, inside a `min-w-0` wrapper (so a long line inside it can still shrink and wrap). |
|
|
753
|
+
|
|
754
|
+
#### `searchContent`
|
|
755
|
+
|
|
756
|
+
`searchContent(items: readonly ContentSearchItem[], query: string): ContentSearchItem[]`. Pure and server-safe. A blank (or whitespace-only) query returns every item, in input order. Otherwise `query` is lowercased and split on spaces; an item matches when every word appears somewhere in its `title`, `navTitle`, `description`, `badge` or any `keywords`. Matching items whose `title` starts with the trimmed query come first; the rest keep their input order.
|
|
757
|
+
|
|
758
|
+
#### `ContentSearch` (client)
|
|
759
|
+
|
|
760
|
+
Port of bb's docs search. A `TextInput` (id `content-search`, `type="search"`), a polite live region with the result count, then `ContentIndex` over what `searchContent` found.
|
|
761
|
+
|
|
762
|
+
| Prop | Type | Default | What it does |
|
|
763
|
+
| --- | --- | --- | --- |
|
|
764
|
+
| `items` | `readonly ContentSearchItem[]` | required | The section's entries. |
|
|
765
|
+
| `label` | `string` | required | The field's label. |
|
|
766
|
+
| `placeholder` | `string` | none | The field's placeholder. |
|
|
767
|
+
| `countNoun` | `readonly [string, string]` | `["page", "pages"]` | Singular and plural noun for the unfiltered count ("12 pages."). A filtered count always reads "1 match." or "N matches." |
|
|
768
|
+
|
|
769
|
+
#### `ContentIndex`
|
|
770
|
+
|
|
771
|
+
The same card grid as `ContentSearch` (one link per item: title, badge, description), with no search field, for a section with too few entries to bother searching (`/legal`, a handful of guides). A Server Component.
|
|
772
|
+
|
|
773
|
+
| Prop | Type | Default | What it does |
|
|
774
|
+
| --- | --- | --- | --- |
|
|
775
|
+
| `items` | `readonly ContentSearchItem[]` | required | The entries to list. |
|
|
776
|
+
|
|
777
|
+
#### `ContentPage`
|
|
778
|
+
|
|
779
|
+
Since 0.11.0. A content section's page: `PageHeader` (the title and the description as its lead), a meta row with when the page was last updated and a "Copy as Markdown" button, then the body in `Prose`. Optional JSON-LD. A Server Component.
|
|
780
|
+
|
|
781
|
+
| Prop | Type | Default | What it does |
|
|
782
|
+
| --- | --- | --- | --- |
|
|
783
|
+
| `title` | `ReactNode` | required | The page's `<h1>`, passed to `PageHeader`. |
|
|
784
|
+
| `description` | `ReactNode` | none | Passed to `PageHeader` as `lead`. |
|
|
785
|
+
| `lastUpdated` | `string` | none | A `<time dateTime>`-ready string (an ISO date, say), shown next to `lastUpdatedLabel`. |
|
|
786
|
+
| `lastUpdatedLabel` | `ReactNode` | `"Last updated"` | The label before the time. |
|
|
787
|
+
| `markdownHref` | `string` | none | The page's raw Markdown source. When set, shows a `CopyMarkdownButton` for it. |
|
|
788
|
+
| `jsonLd` | `Record<string, unknown>` | none | One schema.org object, passed to `JsonLd`. Use `ld.graph(...)` yourself for several nodes. |
|
|
789
|
+
| `actions` | `ReactNode` | none | Extra buttons or links, shown alongside the time and the copy button. |
|
|
790
|
+
| `children` | `ReactNode` | required | The page body, rendered inside `Prose`. |
|
|
791
|
+
|
|
792
|
+
#### `CopyMarkdownButton` (client)
|
|
793
|
+
|
|
794
|
+
Since 0.11.0. A button that fetches `href` and copies the response body to the clipboard, reporting the result in an `<output>` beside it, like `CopyButton`. Any failure (the fetch rejects, the response isn't ok, or the clipboard refuses) shows the same failure message; it never throws. Its own client file with finished class strings (no `cx`): `ContentPage` is a Server Component, and this keeps tailwind-merge out of the browser bundle it ships.
|
|
795
|
+
|
|
796
|
+
| Prop | Type | Default | What it does |
|
|
797
|
+
| --- | --- | --- | --- |
|
|
798
|
+
| `href` | `string` | required | Fetched on click; the response's text is copied. |
|
|
799
|
+
| `label` | `ReactNode` | `"Copy as Markdown"` | The button's text. |
|
|
800
|
+
| `copiedLabel` | `ReactNode` | `"Copied"` | Shown after a copy works. |
|
|
801
|
+
| `failedLabel` | `ReactNode` | `"Couldn't copy"` | Shown when the fetch, the response, or the clipboard fails. |
|
|
802
|
+
|
|
803
|
+
### Brand
|
|
804
|
+
|
|
805
|
+
#### `BrandPage`
|
|
806
|
+
|
|
807
|
+
Since 0.11.0. A product's `/brand` page body. Pass it `@haruhimemoe/brand`'s data as is:
|
|
808
|
+
|
|
809
|
+
```tsx
|
|
810
|
+
import { brandPageData } from "@haruhimemoe/brand/products";
|
|
811
|
+
import { BrandPage } from "@haruhimemoe/ui";
|
|
812
|
+
|
|
813
|
+
<BrandPage {...brandPageData("pools")} />;
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
The props are typed structurally, so ui doesn't depend on brand. Sections, in order, each a `Card` with an `<h2>`: Name (name, tagline, how to write it, the site), Logo (each file previewed on a dark `b6` or light tile by `dark`, with a `download` link; non-image files get the link only), `slots.afterLogo`, Colors (a `BrandSwatch` per palette token), `slots.afterColors`, Type, Do's and don'ts, osu! ("Not affiliated with osu! or ppy. osu! is a trademark of ppy Pty Ltd."), Family ("Part of the haruhime.moe family.", linking `familyHref`; hidden when it's null, as on haruhime.moe itself), Contact (a `mailto:` link), `slots.end`. The headings and the osu! and family lines are the same on every haruhime brand page. A Server Component.
|
|
817
|
+
|
|
818
|
+
| Prop | Type | Default | What it does |
|
|
819
|
+
| --- | --- | --- | --- |
|
|
820
|
+
| `name` | `string` | required | The name as the wordmark reads it. |
|
|
821
|
+
| `mark` | `string` | required | The icon's letters (part of the data shape; not shown). |
|
|
822
|
+
| `tagline` | `string` | required | The product's one-line description. |
|
|
823
|
+
| `url` | `string` | required | The product's site, linked under Name. |
|
|
824
|
+
| `writing` | `string` | required | How to write the name in running text. |
|
|
825
|
+
| `dos`, `donts` | `readonly string[]` | required | The two lists. |
|
|
826
|
+
| `palette` | `Record<string, string>` | required | Token to `"#rrggbb"`, one swatch each. |
|
|
827
|
+
| `assets` | `readonly BrandPageAsset[]` | required | `{ label, href, dark }`: `dark: false` is an on-light variant, shown on a light tile. |
|
|
828
|
+
| `contact` | `string` | required | The contact address. |
|
|
829
|
+
| `familyHref` | `string \| null` | required | The family brand page, or null to hide the Family section. |
|
|
830
|
+
| `fonts` | `readonly BrandPageFont[]` | Nunito, "Wordmarks and headings" | `{ name, usage }` under Type. |
|
|
831
|
+
| `slots` | `{ afterLogo?, afterColors?, end?: ReactNode }` | none | Extra sections, for haruhime.moe's own page. |
|
|
832
|
+
|
|
833
|
+
#### `BrandSwatch` (client)
|
|
834
|
+
|
|
835
|
+
Since 0.11.0. One palette token: a button showing the color, the token and its hex. Clicking copies the hex and reports the result in an `<output>` beside it, like `CopyButton`. The color is set inline from the data (`style={{ backgroundColor }}`), so it doesn't follow `--hue`. Finished class strings, no tailwind-merge.
|
|
836
|
+
|
|
837
|
+
| Prop | Type | Default | What it does |
|
|
838
|
+
| --- | --- | --- | --- |
|
|
839
|
+
| `token` | `string` | required | The token's name. |
|
|
840
|
+
| `hex` | `string` | required | The color, shown and copied. |
|
|
841
|
+
| `copiedLabel` | `ReactNode` | `"Copied"` | Shown after a copy works. |
|
|
842
|
+
| `failedLabel` | `ReactNode` | `"Couldn't copy"` | Shown when the clipboard refuses. |
|
|
843
|
+
|
|
720
844
|
### Shell
|
|
721
845
|
|
|
722
846
|
Links in the header and footer are data (type `SiteLinkItem`):
|
|
@@ -814,7 +938,7 @@ The page frame: a skip link, the header, `<main>` and the footer, with the foote
|
|
|
814
938
|
|
|
815
939
|
### osu!
|
|
816
940
|
|
|
817
|
-
Since 0.4.0. Display pieces for beatmaps and
|
|
941
|
+
Since 0.4.0. Display pieces for beatmaps, mod pools and (since 0.10.0) players. They take plain values (no osu! API types) and are Server Components.
|
|
818
942
|
|
|
819
943
|
#### `StarRating`
|
|
820
944
|
|
|
@@ -845,6 +969,42 @@ A mod pool slot's pill (`NM1`, `HD2`, `TB`), colored by the first two letters: N
|
|
|
845
969
|
| --- | --- | --- | --- |
|
|
846
970
|
| `mod` | `string` | required | The mod or slot label. |
|
|
847
971
|
|
|
972
|
+
#### `PlayerCard`
|
|
973
|
+
|
|
974
|
+
Since 0.10.0. osu!-web's user card (the 120px card from the friends list and user tooltips): the profile cover under a dark overlay, the 60px avatar, the country flag, the team flag and the supporter heart, the username, and an optional status row. The whole card links to the osu! profile. Every native `<div>` prop except `children`.
|
|
975
|
+
|
|
976
|
+
It never fetches. Pass a snapshot you keep yourself (from one osu! API lookup, or typed by hand), so a page of cards costs no API calls and hits no rate limits. Leave `status` out for static data: the card draws no online or offline ring unless told to. Images are plain `<img>` tags (lazy; the avatar and flags are sized), so the app needs no `images.remotePatterns` for osu!'s hosts. A cover ending in `.gif` is hidden when the visitor asks for reduced motion.
|
|
977
|
+
|
|
978
|
+
```tsx
|
|
979
|
+
<PlayerCard
|
|
980
|
+
username="peppy"
|
|
981
|
+
userId={2}
|
|
982
|
+
countryCode="AU"
|
|
983
|
+
coverUrl="https://assets.ppy.sh/user-profile-covers/2/….jpeg"
|
|
984
|
+
team={{ name: "mom?", flagUrl: "https://assets.ppy.sh/teams/flag/1/….png" }}
|
|
985
|
+
supporter
|
|
986
|
+
statusText="osu!"
|
|
987
|
+
/>
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
| Prop | Type | Default | What it does |
|
|
991
|
+
| --- | --- | --- | --- |
|
|
992
|
+
| `username` | `string` | required | The name on the card. |
|
|
993
|
+
| `userId` | `number` | none | The osu! id: the avatar comes from `https://a.ppy.sh/<id>` and the card links to `https://osu.ppy.sh/users/<id>`. |
|
|
994
|
+
| `href` | `string \| null` | the profile | Replaces the link; `null` draws none. Without a link the name is plain text. A linked card outlines on hover and on keyboard focus. |
|
|
995
|
+
| `avatarUrl` | `string` | from `userId` | Replaces the avatar. With neither, the name's first letter stands in. |
|
|
996
|
+
| `coverUrl` | `string` | none | The cover behind the card. None leaves it plain `b4`. |
|
|
997
|
+
| `countryCode` | `string` | none | ISO 3166-1 alpha-2. Draws osu!'s own flag (`osu.ppy.sh/assets/images/flags/<code points>.svg`); anything but two letters draws none. |
|
|
998
|
+
| `countryName` | `string` | English name from `Intl` | The flag's alt text. |
|
|
999
|
+
| `team` | `{ name, flagUrl }` | none | The team flag beside the country's, its alt text the team's name. |
|
|
1000
|
+
| `supporter` | `boolean` | `false` | Draws the supporter heart. |
|
|
1001
|
+
| `supporterLabel` | `string` | `"osu! supporter"` | What screen readers hear for the heart. |
|
|
1002
|
+
| `status` | `"online" \| "offline"` | none | Draws the status ring (lime online, dark offline). |
|
|
1003
|
+
| `statusText` | `ReactNode` | `"Online"`/`"Offline"` with a status | The bottom row's main line: a status, or anything short (a role). |
|
|
1004
|
+
| `statusNote` | `ReactNode` | none | A small line above it ("Last seen 29 days ago", "formerly RMarc"). |
|
|
1005
|
+
|
|
1006
|
+
The bottom row is left out when there is no status, text or note; the card keeps its 120px height so cards line up in a grid. A card with a cover gets a `b5` overlay at 80%, enough for `c2` text at 12px to keep 4.5:1 over a white cover (axe can't check text over an image).
|
|
1007
|
+
|
|
848
1008
|
### MDX (server)
|
|
849
1009
|
|
|
850
1010
|
Since 0.9.0. Three subpaths, so an app that never renders Markdown loads none of this: `@haruhimemoe/ui/mdx` (the React pieces: `mdxComponents`, `CodeBlock`, `Callout`), `@haruhimemoe/ui/remark` (plain functions, no React, for `@next/mdx` and `react-markdown`'s `remarkPlugins`), and `@haruhimemoe/ui/shiki` (opt-in code highlighting). The root `@haruhimemoe/ui` export is unchanged.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/brand/BrandPage.tsx
|
|
3
|
+
* @desc A product's `/brand` page body: name, logo files, colors, type, do's and don'ts, the
|
|
4
|
+
* osu! line, the family link and the contact, each a `Card`. Takes plain data shaped like
|
|
5
|
+
* `@haruhimemoe/brand`'s `brandPageData(...)` output (typed structurally here; ui doesn't
|
|
6
|
+
* depend on brand), so an app writes `<BrandPage {...brandPageData("pools")} />`. A Server
|
|
7
|
+
* Component; only `BrandSwatch` hydrates.
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Sun Oct 4, 2026
|
|
10
|
+
* @modified Sun Oct 4, 2026
|
|
11
|
+
*/
|
|
12
|
+
import type { ReactNode } from "react";
|
|
13
|
+
/** One downloadable brand file. */
|
|
14
|
+
export type BrandPageAsset = {
|
|
15
|
+
/** The link's text. */
|
|
16
|
+
label: string;
|
|
17
|
+
/** The file's URL. */
|
|
18
|
+
href: string;
|
|
19
|
+
/** True for a file drawn for a dark background, false for its on-light variant. */
|
|
20
|
+
dark: boolean;
|
|
21
|
+
};
|
|
22
|
+
/** One typeface and what it's used for. */
|
|
23
|
+
export type BrandPageFont = {
|
|
24
|
+
name: string;
|
|
25
|
+
usage: string;
|
|
26
|
+
};
|
|
27
|
+
/** `BrandPage`'s props: `brandPageData(...)`'s shape, plus optional fonts and slots. */
|
|
28
|
+
export type BrandPageProps = {
|
|
29
|
+
name: string;
|
|
30
|
+
mark: string;
|
|
31
|
+
tagline: string;
|
|
32
|
+
url: string;
|
|
33
|
+
/** How to write the name in running text. */
|
|
34
|
+
writing: string;
|
|
35
|
+
dos: readonly string[];
|
|
36
|
+
donts: readonly string[];
|
|
37
|
+
/** Token to `"#rrggbb"`. */
|
|
38
|
+
palette: Record<string, string>;
|
|
39
|
+
assets: readonly BrandPageAsset[];
|
|
40
|
+
/** The contact address, linked with `mailto:`. */
|
|
41
|
+
contact: string;
|
|
42
|
+
/** The family brand page; null hides the Family section. */
|
|
43
|
+
familyHref: string | null;
|
|
44
|
+
/** Defaults to Nunito for wordmarks and headings. */
|
|
45
|
+
fonts?: readonly BrandPageFont[] | undefined;
|
|
46
|
+
/** Extra sections after Logo, after Colors, and at the end. */
|
|
47
|
+
slots?: {
|
|
48
|
+
afterLogo?: ReactNode;
|
|
49
|
+
afterColors?: ReactNode;
|
|
50
|
+
end?: ReactNode;
|
|
51
|
+
} | undefined;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* @function BrandPage
|
|
55
|
+
* @param props {BrandPageProps} the product's brand data, optional fonts and slots
|
|
56
|
+
* @returns {JSX.Element} the brand page's sections, in order
|
|
57
|
+
*/
|
|
58
|
+
export declare function BrandPage({ name, tagline, url, writing, dos, donts, palette, assets, contact, familyHref, fonts, slots, }: BrandPageProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Card } from "../basics/Card.js";
|
|
3
|
+
import { linkClasses } from "../basics/linkStyles.js";
|
|
4
|
+
import { BrandSwatch } from "./BrandSwatch.js";
|
|
5
|
+
const DEFAULT_FONTS = [
|
|
6
|
+
{ name: "Nunito", usage: "Wordmarks and headings" },
|
|
7
|
+
];
|
|
8
|
+
const LINK = linkClasses();
|
|
9
|
+
const IMAGE = /\.(svg|png|jpe?g|webp)$/i;
|
|
10
|
+
/**
|
|
11
|
+
* @function BrandPage
|
|
12
|
+
* @param props {BrandPageProps} the product's brand data, optional fonts and slots
|
|
13
|
+
* @returns {JSX.Element} the brand page's sections, in order
|
|
14
|
+
*/
|
|
15
|
+
export function BrandPage({ name, tagline, url, writing, dos, donts, palette, assets, contact, familyHref, fonts = DEFAULT_FONTS, slots, }) {
|
|
16
|
+
return (_jsxs("div", { className: "flex flex-col gap-4", children: [_jsxs(Card, { title: "Name", children: [_jsx("p", { className: "font-bold text-c1 text-xl", children: name }), _jsx("p", { children: tagline }), _jsx("p", { children: writing }), _jsx("p", { children: _jsx("a", { className: LINK, href: url, children: url }) })] }), _jsx(Card, { title: "Logo", children: _jsx("ul", { className: "grid grid-cols-1 gap-3 sm:grid-cols-2", children: assets.map((asset) => (_jsxs("li", { className: "flex flex-col gap-2", children: [IMAGE.test(asset.href) ? (_jsx("div", { className: `flex h-32 items-center justify-center rounded-md p-4 ${asset.dark ? "bg-b6" : "bg-c1"}`, children: _jsx("img", { src: asset.href, alt: asset.label, className: "max-h-full max-w-full" }) })) : null, _jsx("a", { className: LINK, href: asset.href, download: true, children: asset.label })] }, asset.href))) }) }), slots?.afterLogo, _jsx(Card, { title: "Colors", children: _jsx("ul", { className: "grid grid-cols-2 gap-3 sm:grid-cols-3", children: Object.entries(palette).map(([token, hex]) => (_jsx("li", { children: _jsx(BrandSwatch, { token: token, hex: hex }) }, token))) }) }), slots?.afterColors, _jsx(Card, { title: "Type", children: _jsx("ul", { className: "flex flex-col gap-1", children: fonts.map((font) => (_jsxs("li", { children: [_jsx("span", { className: "font-bold text-c1", children: font.name }), ": ", font.usage] }, font.name))) }) }), _jsx(Card, { title: "Do's and don'ts", children: _jsxs("div", { className: "grid grid-cols-1 gap-4 sm:grid-cols-2", children: [_jsxs("div", { children: [_jsx("h3", { className: "mb-1 font-bold text-c1", children: "Do" }), _jsx("ul", { className: "list-disc pl-5", children: dos.map((item) => (_jsx("li", { children: item }, item))) })] }), _jsxs("div", { children: [_jsx("h3", { className: "mb-1 font-bold text-c1", children: "Don't" }), _jsx("ul", { className: "list-disc pl-5", children: donts.map((item) => (_jsx("li", { children: item }, item))) })] })] }) }), _jsx(Card, { title: "osu!", children: _jsx("p", { children: "Not affiliated with osu! or ppy. osu! is a trademark of ppy Pty Ltd." }) }), familyHref ? (_jsx(Card, { title: "Family", children: _jsx("a", { className: LINK, href: familyHref, children: "Part of the haruhime.moe family." }) })) : null, _jsx(Card, { title: "Contact", children: _jsx("a", { className: LINK, href: `mailto:${contact}`, children: contact }) }), slots?.end] }));
|
|
17
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/brand/BrandSwatch.tsx
|
|
3
|
+
* @desc One palette token on a brand page: a button showing the color, its token and its hex,
|
|
4
|
+
* that copies the hex to the clipboard and reports the result in an `<output>` beside it.
|
|
5
|
+
* The color comes from the brand data through an inline `backgroundColor` (hue-independent,
|
|
6
|
+
* like `StarRating`). Its own client file with finished class strings (no `cx`), so
|
|
7
|
+
* `BrandPage`, the Server Component that renders it, ships no tailwind-merge.
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Sun Oct 4, 2026
|
|
10
|
+
* @modified Sun Oct 4, 2026
|
|
11
|
+
*/
|
|
12
|
+
import type { ReactNode } from "react";
|
|
13
|
+
/** `BrandSwatch`'s props. */
|
|
14
|
+
export type BrandSwatchProps = {
|
|
15
|
+
/** The palette token's name ("h1", "b6"). */
|
|
16
|
+
token: string;
|
|
17
|
+
/** The token's color as `"#rrggbb"`; shown, painted and copied. */
|
|
18
|
+
hex: string;
|
|
19
|
+
/** Status after a successful copy. Defaults to "Copied". */
|
|
20
|
+
copiedLabel?: ReactNode | undefined;
|
|
21
|
+
/** Status when the clipboard refuses. Defaults to "Couldn't copy". */
|
|
22
|
+
failedLabel?: ReactNode | undefined;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* @function BrandSwatch
|
|
26
|
+
* @param props {BrandSwatchProps} the token, its hex, and optional status text
|
|
27
|
+
* @returns {JSX.Element} the click-to-copy swatch button and its status `<output>`
|
|
28
|
+
*/
|
|
29
|
+
export declare function BrandSwatch({ token, hex, copiedLabel, failedLabel, }: BrandSwatchProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/brand/BrandSwatch.tsx
|
|
3
|
+
* @desc One palette token on a brand page: a button showing the color, its token and its hex,
|
|
4
|
+
* that copies the hex to the clipboard and reports the result in an `<output>` beside it.
|
|
5
|
+
* The color comes from the brand data through an inline `backgroundColor` (hue-independent,
|
|
6
|
+
* like `StarRating`). Its own client file with finished class strings (no `cx`), so
|
|
7
|
+
* `BrandPage`, the Server Component that renders it, ships no tailwind-merge.
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Sun Oct 4, 2026
|
|
10
|
+
* @modified Sun Oct 4, 2026
|
|
11
|
+
*/
|
|
12
|
+
"use client";
|
|
13
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
14
|
+
import { StatusOutput } from "../actions/StatusOutput.js";
|
|
15
|
+
import { useLatestStatus } from "../actions/useLatestStatus.js";
|
|
16
|
+
const BUTTON_CLASS_NAME = "flex w-full items-center gap-3 rounded-md bg-b5 p-2 text-left transition-colors hover:bg-b3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-h1";
|
|
17
|
+
/**
|
|
18
|
+
* @function BrandSwatch
|
|
19
|
+
* @param props {BrandSwatchProps} the token, its hex, and optional status text
|
|
20
|
+
* @returns {JSX.Element} the click-to-copy swatch button and its status `<output>`
|
|
21
|
+
*/
|
|
22
|
+
export function BrandSwatch({ token, hex, copiedLabel = "Copied", failedLabel = "Couldn't copy", }) {
|
|
23
|
+
const { status, start, settle } = useLatestStatus();
|
|
24
|
+
const copy = async () => {
|
|
25
|
+
const run = start();
|
|
26
|
+
try {
|
|
27
|
+
await navigator.clipboard.writeText(hex);
|
|
28
|
+
settle(run, "copied");
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
settle(run, "failed");
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
return (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsxs("button", { type: "button", className: BUTTON_CLASS_NAME, onClick: copy, children: [_jsx("span", { "aria-hidden": "true", className: "size-10 shrink-0 rounded-md border border-b2", style: { backgroundColor: hex } }), _jsxs("span", { className: "flex flex-col", children: [_jsx("span", { className: "font-bold text-c1", children: token }), _jsx("span", { className: "font-mono text-c3 text-sm", children: hex })] })] }), _jsx(StatusOutput, { run: status?.run ?? null, children: status?.result === "copied" ? copiedLabel : failedLabel })] }));
|
|
35
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/content/ContentIndex.tsx
|
|
3
|
+
* @desc A content section's card grid with no search: one link per entry, its title, badge and
|
|
4
|
+
* description. For short sections (`/legal`, a handful of guides) where `ContentSearch`'s
|
|
5
|
+
* field would be overkill. Server-safe: no directive, no state; `ContentSearch` renders
|
|
6
|
+
* this for its own filtered list, so the card look lives in one place.
|
|
7
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
8
|
+
* @created Sun Oct 4, 2026
|
|
9
|
+
* @modified Sun Oct 4, 2026
|
|
10
|
+
*/
|
|
11
|
+
import type { ContentSearchItem } from "./types.js";
|
|
12
|
+
/** `ContentIndex`'s props. */
|
|
13
|
+
export type ContentIndexProps = {
|
|
14
|
+
items: readonly ContentSearchItem[];
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* @function ContentIndex
|
|
18
|
+
* @param props {ContentIndexProps} the section's entries
|
|
19
|
+
* @returns {JSX.Element} a two-column card grid (one column on phones), each card the entry's
|
|
20
|
+
* title, badge and description
|
|
21
|
+
*/
|
|
22
|
+
export declare function ContentIndex({ items }: ContentIndexProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* @file src/components/content/ContentIndex.tsx
|
|
4
|
+
* @desc A content section's card grid with no search: one link per entry, its title, badge and
|
|
5
|
+
* description. For short sections (`/legal`, a handful of guides) where `ContentSearch`'s
|
|
6
|
+
* field would be overkill. Server-safe: no directive, no state; `ContentSearch` renders
|
|
7
|
+
* this for its own filtered list, so the card look lives in one place.
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Sun Oct 4, 2026
|
|
10
|
+
* @modified Sun Oct 4, 2026
|
|
11
|
+
*/
|
|
12
|
+
import Link from "next/link.js";
|
|
13
|
+
/**
|
|
14
|
+
* @function ContentIndex
|
|
15
|
+
* @param props {ContentIndexProps} the section's entries
|
|
16
|
+
* @returns {JSX.Element} a two-column card grid (one column on phones), each card the entry's
|
|
17
|
+
* title, badge and description
|
|
18
|
+
*/
|
|
19
|
+
export function ContentIndex({ items }) {
|
|
20
|
+
return (_jsx("ul", { className: "grid grid-cols-1 gap-2 sm:grid-cols-2", children: items.map((item) => (_jsx("li", { children: _jsxs(Link, { href: item.href, className: "flex h-full flex-col gap-1 rounded-md bg-b4 p-3 hover:bg-b3", children: [_jsxs("span", { className: "flex items-baseline justify-between gap-2", children: [_jsx("span", { className: "font-bold text-c1", children: item.title }), item.badge ? _jsx("span", { className: "font-mono text-c4 text-xs", children: item.badge }) : null] }), _jsx("span", { className: "text-c3 text-sm", children: item.description })] }) }, item.href))) }));
|
|
21
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/content/ContentLayout.tsx
|
|
3
|
+
* @desc A content section's page grid: the nav in a 14rem column from `lg` up, the page beside
|
|
4
|
+
* it. Port of bb's docs layout grid (`bb.haruhime.moe/src/app/docs/layout.tsx`), generic
|
|
5
|
+
* over what `nav` is so a docs section and a later blog section share it.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Sun Oct 4, 2026
|
|
8
|
+
* @modified Sun Oct 4, 2026
|
|
9
|
+
*/
|
|
10
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
11
|
+
/** Every native `<div>` prop (including `ref`), plus the nav slot. */
|
|
12
|
+
export type ContentLayoutProps = Omit<ComponentProps<"div">, "children"> & {
|
|
13
|
+
/** Usually a `<ContentNav>`, already rendered by the caller. */
|
|
14
|
+
nav: ReactNode;
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* @function ContentLayout
|
|
19
|
+
* @param props {ContentLayoutProps} the nav slot, the page as children, and native div props
|
|
20
|
+
* @returns {JSX.Element} a one-column grid on phones, a 14rem nav column beside the page from
|
|
21
|
+
* `lg` up
|
|
22
|
+
*/
|
|
23
|
+
export declare function ContentLayout({ nav, children, className, ...props }: ContentLayoutProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "../../utils/cx.js";
|
|
3
|
+
/**
|
|
4
|
+
* @function ContentLayout
|
|
5
|
+
* @param props {ContentLayoutProps} the nav slot, the page as children, and native div props
|
|
6
|
+
* @returns {JSX.Element} a one-column grid on phones, a 14rem nav column beside the page from
|
|
7
|
+
* `lg` up
|
|
8
|
+
*/
|
|
9
|
+
export function ContentLayout({ nav, children, className, ...props }) {
|
|
10
|
+
return (_jsxs("div", { className: cx("grid grid-cols-1 gap-6 lg:grid-cols-[14rem_minmax(0,1fr)] lg:gap-10", className), ...props, children: [nav, _jsx("div", { className: "min-w-0", children: children })] }));
|
|
11
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/content/ContentNav.tsx
|
|
3
|
+
* @desc A content section's side navigation: an index link, then each group of links, the
|
|
4
|
+
* current page marked. A column beside the page on wide screens; a phone disclosure above
|
|
5
|
+
* it. Port of bb's docs sidebar (`bb.haruhime.moe/src/components/docs/DocsNav.tsx`), with
|
|
6
|
+
* the index link and groups taken from props instead of a hardcoded "docs" shape, so a
|
|
7
|
+
* docs section, and later a blog section, can both use it.
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Sun Oct 4, 2026
|
|
10
|
+
* @modified Sun Oct 4, 2026
|
|
11
|
+
*/
|
|
12
|
+
import type { ContentNavGroup } from "./types.js";
|
|
13
|
+
/** `ContentNav`'s props. */
|
|
14
|
+
export type ContentNavProps = {
|
|
15
|
+
/** The nav landmark's accessible name, for both the column and the phone disclosure. */
|
|
16
|
+
label: string;
|
|
17
|
+
/** Where the index link (the section's overview page) points. */
|
|
18
|
+
indexHref: string;
|
|
19
|
+
/** The index link's label. Default `"Overview"`. */
|
|
20
|
+
indexLabel?: string;
|
|
21
|
+
groups: readonly ContentNavGroup[];
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* @function ContentNav
|
|
25
|
+
* @param props {ContentNavProps} the nav's accessible name, the index link, and the groups
|
|
26
|
+
* beneath it
|
|
27
|
+
* @returns {JSX.Element} the navigation, as a column and as a phone disclosure
|
|
28
|
+
*/
|
|
29
|
+
export declare function ContentNav({ label, indexHref, indexLabel, groups }: ContentNavProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/content/ContentNav.tsx
|
|
3
|
+
* @desc A content section's side navigation: an index link, then each group of links, the
|
|
4
|
+
* current page marked. A column beside the page on wide screens; a phone disclosure above
|
|
5
|
+
* it. Port of bb's docs sidebar (`bb.haruhime.moe/src/components/docs/DocsNav.tsx`), with
|
|
6
|
+
* the index link and groups taken from props instead of a hardcoded "docs" shape, so a
|
|
7
|
+
* docs section, and later a blog section, can both use it.
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Sun Oct 4, 2026
|
|
10
|
+
* @modified Sun Oct 4, 2026
|
|
11
|
+
*/
|
|
12
|
+
"use client";
|
|
13
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
14
|
+
import Link from "next/link.js";
|
|
15
|
+
import { usePathname } from "next/navigation.js";
|
|
16
|
+
const LINK_CLASS_NAME = "flex items-baseline justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-b4 hover:text-c1 text-c3";
|
|
17
|
+
const CURRENT_CLASS_NAME = "flex items-baseline justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-b4 hover:text-c1 bg-b4 font-bold text-c1";
|
|
18
|
+
/**
|
|
19
|
+
* @function ContentNav
|
|
20
|
+
* @param props {ContentNavProps} the nav's accessible name, the index link, and the groups
|
|
21
|
+
* beneath it
|
|
22
|
+
* @returns {JSX.Element} the navigation, as a column and as a phone disclosure
|
|
23
|
+
*/
|
|
24
|
+
export function ContentNav({ label, indexHref, indexLabel = "Overview", groups }) {
|
|
25
|
+
const path = usePathname();
|
|
26
|
+
const link = (item) => {
|
|
27
|
+
const current = path === item.href;
|
|
28
|
+
return (_jsx("li", { children: _jsxs(Link, { href: item.href, "aria-current": current ? "page" : undefined, title: item.title, className: current ? CURRENT_CLASS_NAME : LINK_CLASS_NAME, children: [_jsx("span", { className: "line-clamp-2 min-w-0 break-words", children: item.navTitle ?? item.title }), item.badge ? _jsx("span", { className: "font-mono text-c4 text-xs", children: item.badge }) : null] }) }, item.href));
|
|
29
|
+
};
|
|
30
|
+
const list = (_jsxs("div", { className: "flex flex-col gap-4", children: [_jsx("ul", { children: link({ href: indexHref, title: indexLabel }) }), groups.map((group, index) => (_jsxs("div", { children: [group.heading ? (_jsx("p", { className: "mb-1 px-2 font-bold text-c4 text-xs uppercase tracking-wide", children: group.heading })) : null, _jsx("ul", { children: group.items.map((item) => link(item)) })] }, group.heading ?? index)))] }));
|
|
31
|
+
return (_jsxs(_Fragment, { children: [_jsx("nav", { "aria-label": label, className: "hidden lg:block", children: _jsx("div", { className: "sticky top-4 max-h-[calc(100vh-2rem)] overflow-y-auto pr-1", children: list }) }), _jsxs("details", { className: "rounded-md bg-b4 p-2 lg:hidden", children: [_jsx("summary", { className: "cursor-pointer px-2 py-1 font-bold text-c2 text-sm", children: "Contents" }), _jsx("nav", { "aria-label": label, className: "mt-2", children: list })] })] }));
|
|
32
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/content/ContentPage.tsx
|
|
3
|
+
* @desc A content section's page: the `PageHeader` (title, description as its lead), a meta row
|
|
4
|
+
* with when the page was last updated and a "Copy as Markdown" button, and the body in
|
|
5
|
+
* `Prose`. Optional JSON-LD structured data. Server-safe: no state, no browser APIs. A
|
|
6
|
+
* caller resolves its own entries into these props (a docs page today, a blog post later),
|
|
7
|
+
* the way `ContentNav` and `ContentLayout` do.
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Sun Oct 4, 2026
|
|
10
|
+
* @modified Sun Oct 4, 2026
|
|
11
|
+
*/
|
|
12
|
+
import type { ReactNode } from "react";
|
|
13
|
+
/** `ContentPage`'s props. */
|
|
14
|
+
export type ContentPageProps = {
|
|
15
|
+
/** The page's h1. */
|
|
16
|
+
title: ReactNode;
|
|
17
|
+
/** A sentence under the title, passed to `PageHeader` as its `lead`. */
|
|
18
|
+
description?: ReactNode | undefined;
|
|
19
|
+
/** When the page was last updated, as a `<time dateTime>`-ready string (e.g. an ISO date). */
|
|
20
|
+
lastUpdated?: string | undefined;
|
|
21
|
+
/** The label before the last-updated time. Defaults to "Last updated". */
|
|
22
|
+
lastUpdatedLabel?: ReactNode | undefined;
|
|
23
|
+
/** The page's raw Markdown source. When set, shows a "Copy as Markdown" button that fetches it. */
|
|
24
|
+
markdownHref?: string | undefined;
|
|
25
|
+
/** A schema.org object for `JsonLd`. Pass `ld.graph(...)` yourself for several nodes. */
|
|
26
|
+
jsonLd?: Record<string, unknown> | undefined;
|
|
27
|
+
/** Extra buttons or links, shown alongside the last-updated time and the copy button. */
|
|
28
|
+
actions?: ReactNode | undefined;
|
|
29
|
+
/** The page body, rendered inside `Prose`. */
|
|
30
|
+
children: ReactNode;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* @function ContentPage
|
|
34
|
+
* @param props {ContentPageProps} the title and optional description, last-updated time, Markdown
|
|
35
|
+
* source, JSON-LD data and actions, plus the page body as children
|
|
36
|
+
* @returns {JSX.Element} optional `JsonLd`, a `PageHeader` whose actions hold the last-updated
|
|
37
|
+
* time, the copy button and the caller's actions, then the body in `Prose`
|
|
38
|
+
*/
|
|
39
|
+
export declare function ContentPage({ title, description, lastUpdated, lastUpdatedLabel, markdownHref, jsonLd, actions, children, }: ContentPageProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { PageHeader } from "../basics/PageHeader.js";
|
|
3
|
+
import { Prose } from "../basics/Prose.js";
|
|
4
|
+
import { JsonLd } from "../meta/JsonLd.js";
|
|
5
|
+
import { CopyMarkdownButton } from "./CopyMarkdownButton.js";
|
|
6
|
+
/**
|
|
7
|
+
* @function ContentPage
|
|
8
|
+
* @param props {ContentPageProps} the title and optional description, last-updated time, Markdown
|
|
9
|
+
* source, JSON-LD data and actions, plus the page body as children
|
|
10
|
+
* @returns {JSX.Element} optional `JsonLd`, a `PageHeader` whose actions hold the last-updated
|
|
11
|
+
* time, the copy button and the caller's actions, then the body in `Prose`
|
|
12
|
+
*/
|
|
13
|
+
export function ContentPage({ title, description, lastUpdated, lastUpdatedLabel = "Last updated", markdownHref, jsonLd, actions, children, }) {
|
|
14
|
+
const meta = lastUpdated || markdownHref || actions;
|
|
15
|
+
return (_jsxs(_Fragment, { children: [jsonLd ? _jsx(JsonLd, { data: jsonLd }) : null, _jsx(PageHeader, { title: title, lead: description, actions: meta ? (_jsxs(_Fragment, { children: [lastUpdated ? (_jsxs("div", { className: "text-c4 text-sm", children: [lastUpdatedLabel, " ", _jsx("time", { dateTime: lastUpdated, children: lastUpdated })] })) : null, markdownHref ? _jsx(CopyMarkdownButton, { href: markdownHref }) : null, actions] })) : undefined }), _jsx(Prose, { children: children })] }));
|
|
16
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/content/ContentSearch.tsx
|
|
3
|
+
* @desc A content section's search: one field that narrows its entries as it's typed, with the
|
|
4
|
+
* result count said to screen readers, then `ContentIndex`'s card grid over what matched.
|
|
5
|
+
* Port of bb's docs search (`bb.haruhime.moe/src/components/docs/DocsSearch.tsx`), with the
|
|
6
|
+
* entries, label and count noun taken from props instead of a hardcoded docs shape.
|
|
7
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
8
|
+
* @created Sun Oct 4, 2026
|
|
9
|
+
* @modified Sun Oct 4, 2026
|
|
10
|
+
*/
|
|
11
|
+
import type { ContentSearchItem } from "./types.js";
|
|
12
|
+
/** `ContentSearch`'s props. */
|
|
13
|
+
export type ContentSearchProps = {
|
|
14
|
+
items: readonly ContentSearchItem[];
|
|
15
|
+
/** The field's label, and the search landmark's name read by nothing else here. */
|
|
16
|
+
label: string;
|
|
17
|
+
placeholder?: string;
|
|
18
|
+
/** Singular and plural noun for the unfiltered count ("12 pages."). Default `["page", "pages"]`. */
|
|
19
|
+
countNoun?: readonly [string, string];
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* @function ContentSearch
|
|
23
|
+
* @param props {ContentSearchProps} the section's entries, the field's label, an optional
|
|
24
|
+
* placeholder, and an optional singular/plural noun for the unfiltered count
|
|
25
|
+
* @returns {JSX.Element} the field, the result count (a live region) and the matching entries
|
|
26
|
+
*/
|
|
27
|
+
export declare function ContentSearch({ items, label, placeholder, countNoun, }: ContentSearchProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/content/ContentSearch.tsx
|
|
3
|
+
* @desc A content section's search: one field that narrows its entries as it's typed, with the
|
|
4
|
+
* result count said to screen readers, then `ContentIndex`'s card grid over what matched.
|
|
5
|
+
* Port of bb's docs search (`bb.haruhime.moe/src/components/docs/DocsSearch.tsx`), with the
|
|
6
|
+
* entries, label and count noun taken from props instead of a hardcoded docs shape.
|
|
7
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
8
|
+
* @created Sun Oct 4, 2026
|
|
9
|
+
* @modified Sun Oct 4, 2026
|
|
10
|
+
*/
|
|
11
|
+
"use client";
|
|
12
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
13
|
+
import { useState } from "react";
|
|
14
|
+
import { TextInput } from "../forms/TextInput.js";
|
|
15
|
+
import { ContentIndex } from "./ContentIndex.js";
|
|
16
|
+
import { searchContent } from "./searchContent.js";
|
|
17
|
+
/**
|
|
18
|
+
* @function ContentSearch
|
|
19
|
+
* @param props {ContentSearchProps} the section's entries, the field's label, an optional
|
|
20
|
+
* placeholder, and an optional singular/plural noun for the unfiltered count
|
|
21
|
+
* @returns {JSX.Element} the field, the result count (a live region) and the matching entries
|
|
22
|
+
*/
|
|
23
|
+
export function ContentSearch({ items, label, placeholder, countNoun = ["page", "pages"], }) {
|
|
24
|
+
const [query, setQuery] = useState("");
|
|
25
|
+
const found = searchContent(items, query);
|
|
26
|
+
const trimmed = query.trim();
|
|
27
|
+
const [singular, plural] = countNoun;
|
|
28
|
+
const countText = trimmed === ""
|
|
29
|
+
? `${items.length} ${items.length === 1 ? singular : plural}.`
|
|
30
|
+
: `${found.length} ${found.length === 1 ? "match" : "matches"}.`;
|
|
31
|
+
return (_jsxs("div", { className: "flex flex-col gap-4", children: [_jsx(TextInput, { id: "content-search", type: "search", label: label, placeholder: placeholder, value: query, onChange: (event) => setQuery(event.target.value), autoComplete: "off" }), _jsx("p", { className: "text-c3 text-sm", "aria-live": "polite", children: countText }), _jsx(ContentIndex, { items: found })] }));
|
|
32
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/content/CopyMarkdownButton.tsx
|
|
3
|
+
* @desc A button that fetches a page's raw Markdown and copies it to the clipboard, reporting the
|
|
4
|
+
* result in an `<output>` beside it (a polite live region), for a doc page's "Copy as
|
|
5
|
+
* Markdown" action. Any failure (the fetch rejects, the response isn't ok, or the clipboard
|
|
6
|
+
* refuses) announces the same failure message; the button never throws. Its own client file
|
|
7
|
+
* with finished class strings (no `cx`), so tailwind-merge stays out of the browser bundle
|
|
8
|
+
* for `ContentPage`, the Server Component that renders it. Not a wrapper around
|
|
9
|
+
* `CopyButton`, which imports `cx`.
|
|
10
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
11
|
+
* @created Sun Oct 4, 2026
|
|
12
|
+
* @modified Sun Oct 4, 2026
|
|
13
|
+
*/
|
|
14
|
+
import type { ReactNode } from "react";
|
|
15
|
+
/** `CopyMarkdownButton`'s props. */
|
|
16
|
+
export type CopyMarkdownButtonProps = {
|
|
17
|
+
/** Fetched on click; the response body is copied to the clipboard. */
|
|
18
|
+
href: string;
|
|
19
|
+
/** The button's text. Defaults to "Copy as Markdown". */
|
|
20
|
+
label?: ReactNode | undefined;
|
|
21
|
+
/** Status after a successful copy. Defaults to "Copied". */
|
|
22
|
+
copiedLabel?: ReactNode | undefined;
|
|
23
|
+
/** Status when the fetch, the response, or the clipboard fails. Defaults to "Couldn't copy". */
|
|
24
|
+
failedLabel?: ReactNode | undefined;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* @function CopyMarkdownButton
|
|
28
|
+
* @param props {CopyMarkdownButtonProps} the Markdown source's URL, plus optional label and
|
|
29
|
+
* status text
|
|
30
|
+
* @returns {JSX.Element} the button and an `<output>` that announces the copy's result
|
|
31
|
+
*/
|
|
32
|
+
export declare function CopyMarkdownButton({ href, label, copiedLabel, failedLabel, }: CopyMarkdownButtonProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/content/CopyMarkdownButton.tsx
|
|
3
|
+
* @desc A button that fetches a page's raw Markdown and copies it to the clipboard, reporting the
|
|
4
|
+
* result in an `<output>` beside it (a polite live region), for a doc page's "Copy as
|
|
5
|
+
* Markdown" action. Any failure (the fetch rejects, the response isn't ok, or the clipboard
|
|
6
|
+
* refuses) announces the same failure message; the button never throws. Its own client file
|
|
7
|
+
* with finished class strings (no `cx`), so tailwind-merge stays out of the browser bundle
|
|
8
|
+
* for `ContentPage`, the Server Component that renders it. Not a wrapper around
|
|
9
|
+
* `CopyButton`, which imports `cx`.
|
|
10
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
11
|
+
* @created Sun Oct 4, 2026
|
|
12
|
+
* @modified Sun Oct 4, 2026
|
|
13
|
+
*/
|
|
14
|
+
"use client";
|
|
15
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
16
|
+
import { StatusOutput } from "../actions/StatusOutput.js";
|
|
17
|
+
import { useLatestStatus } from "../actions/useLatestStatus.js";
|
|
18
|
+
// buttonClasses({ variant: "secondary" }) copied as a literal, so this file imports no cx.
|
|
19
|
+
const BUTTON_CLASS_NAME = "inline-flex items-center justify-center gap-2 rounded-full font-bold transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-h1 disabled:cursor-not-allowed disabled:opacity-50 bg-b3 text-c1 not-disabled:hover:bg-b2 h-9 px-4 text-sm";
|
|
20
|
+
/**
|
|
21
|
+
* @function CopyMarkdownButton
|
|
22
|
+
* @param props {CopyMarkdownButtonProps} the Markdown source's URL, plus optional label and
|
|
23
|
+
* status text
|
|
24
|
+
* @returns {JSX.Element} the button and an `<output>` that announces the copy's result
|
|
25
|
+
*/
|
|
26
|
+
export function CopyMarkdownButton({ href, label = "Copy as Markdown", copiedLabel = "Copied", failedLabel = "Couldn't copy", }) {
|
|
27
|
+
const { status, start, settle } = useLatestStatus();
|
|
28
|
+
const copy = async () => {
|
|
29
|
+
const run = start();
|
|
30
|
+
try {
|
|
31
|
+
const response = await fetch(href);
|
|
32
|
+
if (!response.ok)
|
|
33
|
+
throw new Error(String(response.status));
|
|
34
|
+
const text = await response.text();
|
|
35
|
+
await navigator.clipboard.writeText(text);
|
|
36
|
+
settle(run, "copied");
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
settle(run, "failed");
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
return (_jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsx("button", { type: "button", className: BUTTON_CLASS_NAME, onClick: copy, children: label }), _jsx(StatusOutput, { run: status?.run ?? null, children: status?.result === "copied" ? copiedLabel : failedLabel })] }));
|
|
43
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/content/searchContent.ts
|
|
3
|
+
* @desc The search over a content section's entries: a blank query returns everything, otherwise
|
|
4
|
+
* every typed word must appear somewhere in the entry (title, navTitle, description, badge
|
|
5
|
+
* or any keyword). Pure and server-safe, so `ContentSearch` and a future server-rendered
|
|
6
|
+
* index can both call it. Port of bb's `searchDocs` (`bb.haruhime.moe/src/utils/docs.ts`),
|
|
7
|
+
* simplified to a plain AND match over one haystack per entry instead of per-word scoring.
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Sun Oct 4, 2026
|
|
10
|
+
* @modified Sun Oct 4, 2026
|
|
11
|
+
*/
|
|
12
|
+
import type { ContentSearchItem } from "./types.js";
|
|
13
|
+
/**
|
|
14
|
+
* @function searchContent
|
|
15
|
+
* @param items {readonly ContentSearchItem[]} the section's entries
|
|
16
|
+
* @param query {string} what was typed
|
|
17
|
+
* @returns {ContentSearchItem[]} every entry (in input order) when `query` is blank, otherwise
|
|
18
|
+
* the entries where every lowercased, space-split word of `query` appears in the
|
|
19
|
+
* entry's title, navTitle, description, badge or keywords; entries whose title starts
|
|
20
|
+
* with the trimmed query come first, the rest keep their input order
|
|
21
|
+
*/
|
|
22
|
+
export declare function searchContent(items: readonly ContentSearchItem[], query: string): ContentSearchItem[];
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/content/searchContent.ts
|
|
3
|
+
* @desc The search over a content section's entries: a blank query returns everything, otherwise
|
|
4
|
+
* every typed word must appear somewhere in the entry (title, navTitle, description, badge
|
|
5
|
+
* or any keyword). Pure and server-safe, so `ContentSearch` and a future server-rendered
|
|
6
|
+
* index can both call it. Port of bb's `searchDocs` (`bb.haruhime.moe/src/utils/docs.ts`),
|
|
7
|
+
* simplified to a plain AND match over one haystack per entry instead of per-word scoring.
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Sun Oct 4, 2026
|
|
10
|
+
* @modified Sun Oct 4, 2026
|
|
11
|
+
*/
|
|
12
|
+
/** The text of an entry a query's words are matched against, lowercased. */
|
|
13
|
+
const haystackOf = (item) => [item.title, item.navTitle, item.description, item.badge, ...(item.keywords ?? [])]
|
|
14
|
+
.filter((part) => Boolean(part))
|
|
15
|
+
.join(" ")
|
|
16
|
+
.toLowerCase();
|
|
17
|
+
/**
|
|
18
|
+
* @function searchContent
|
|
19
|
+
* @param items {readonly ContentSearchItem[]} the section's entries
|
|
20
|
+
* @param query {string} what was typed
|
|
21
|
+
* @returns {ContentSearchItem[]} every entry (in input order) when `query` is blank, otherwise
|
|
22
|
+
* the entries where every lowercased, space-split word of `query` appears in the
|
|
23
|
+
* entry's title, navTitle, description, badge or keywords; entries whose title starts
|
|
24
|
+
* with the trimmed query come first, the rest keep their input order
|
|
25
|
+
*/
|
|
26
|
+
export function searchContent(items, query) {
|
|
27
|
+
const trimmed = query.trim().toLowerCase();
|
|
28
|
+
if (trimmed === "")
|
|
29
|
+
return [...items];
|
|
30
|
+
const words = trimmed.split(/\s+/).filter(Boolean);
|
|
31
|
+
const matches = items.filter((item) => {
|
|
32
|
+
const haystack = haystackOf(item);
|
|
33
|
+
return words.every((word) => haystack.includes(word));
|
|
34
|
+
});
|
|
35
|
+
const isTitlePrefix = (item) => item.title.toLowerCase().startsWith(trimmed);
|
|
36
|
+
return [...matches.filter(isTitlePrefix), ...matches.filter((item) => !isTitlePrefix(item))];
|
|
37
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/content/types.ts
|
|
3
|
+
* @desc Content navigation's data: a link (`ContentNavItem`), a run of links under an optional
|
|
4
|
+
* heading (`ContentNavGroup`), and a searchable item (`ContentSearchItem`, for task 7's
|
|
5
|
+
* `ContentSearch`). Plain, server-safe types with no next-kit or brand import: a caller
|
|
6
|
+
* resolves its own entries (a docs registry today, a blog section later) into these
|
|
7
|
+
* shapes, so `ui` stays section-agnostic.
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Sun Oct 4, 2026
|
|
10
|
+
* @modified Sun Oct 4, 2026
|
|
11
|
+
*/
|
|
12
|
+
/** One sidebar link: a resolved href, its full title, and an optional nav-length title. */
|
|
13
|
+
export type ContentNavItem = {
|
|
14
|
+
href: string;
|
|
15
|
+
title: string;
|
|
16
|
+
/** Shown in the nav instead of `title`, for a long title. */
|
|
17
|
+
navTitle?: string;
|
|
18
|
+
/** A short tag beside the label, e.g. a count or "new". */
|
|
19
|
+
badge?: string;
|
|
20
|
+
};
|
|
21
|
+
/** A run of links under an optional heading. No `heading` renders no label above them. */
|
|
22
|
+
export type ContentNavGroup = {
|
|
23
|
+
heading?: string;
|
|
24
|
+
items: readonly ContentNavItem[];
|
|
25
|
+
};
|
|
26
|
+
/** A `ContentNavItem` plus the text a fuzzy search matches against. */
|
|
27
|
+
export type ContentSearchItem = ContentNavItem & {
|
|
28
|
+
description: string;
|
|
29
|
+
keywords?: readonly string[];
|
|
30
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/content/types.ts
|
|
3
|
+
* @desc Content navigation's data: a link (`ContentNavItem`), a run of links under an optional
|
|
4
|
+
* heading (`ContentNavGroup`), and a searchable item (`ContentSearchItem`, for task 7's
|
|
5
|
+
* `ContentSearch`). Plain, server-safe types with no next-kit or brand import: a caller
|
|
6
|
+
* resolves its own entries (a docs registry today, a blog section later) into these
|
|
7
|
+
* shapes, so `ui` stays section-agnostic.
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Sun Oct 4, 2026
|
|
10
|
+
* @modified Sun Oct 4, 2026
|
|
11
|
+
*/
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/osu/PlayerCard.tsx
|
|
3
|
+
* @desc osu!-web's user card (the 120px card from the friends list and user tooltips) from plain
|
|
4
|
+
* props: cover under a b5 overlay, 60px avatar, country flag, team flag and supporter heart,
|
|
5
|
+
* the username (the whole card links to the profile), and an optional status row. It never
|
|
6
|
+
* fetches: the app passes a snapshot, so nothing here claims live data unless told to. The
|
|
7
|
+
* online ring's lime is osu!'s own green-light, hue-independent like StarRating's spectrum.
|
|
8
|
+
* Keyboard focus outlines the whole card in h1 as well as the username's own ring.
|
|
9
|
+
* Plain `<img>`s, not next/image, so apps need no `images.remotePatterns` for osu!'s hosts.
|
|
10
|
+
* Server-safe.
|
|
11
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
12
|
+
* @created Sun Oct 4, 2026
|
|
13
|
+
* @modified Sun Oct 4, 2026
|
|
14
|
+
*/
|
|
15
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
16
|
+
/** An osu! team: its name (the flag's alt text and tooltip) and its flag image. */
|
|
17
|
+
export type PlayerTeam = {
|
|
18
|
+
/** The team's name. */
|
|
19
|
+
name: string;
|
|
20
|
+
/** The team flag's URL (osu! serves them from assets.ppy.sh). */
|
|
21
|
+
flagUrl: string;
|
|
22
|
+
};
|
|
23
|
+
/** Every native `<div>` prop except children, plus the player's snapshot. */
|
|
24
|
+
export type PlayerCardProps = Omit<ComponentProps<"div">, "children"> & {
|
|
25
|
+
/** The name shown on the card, as-is. */
|
|
26
|
+
username: string;
|
|
27
|
+
/** The osu! user id: the avatar comes from a.ppy.sh and the card links to the profile. */
|
|
28
|
+
userId?: number | undefined;
|
|
29
|
+
/** Replaces the profile link; `null` draws no link even with a `userId`. */
|
|
30
|
+
href?: string | null | undefined;
|
|
31
|
+
/** Replaces the a.ppy.sh avatar. Without it and a `userId`, a letter stands in. */
|
|
32
|
+
avatarUrl?: string | undefined;
|
|
33
|
+
/** The profile cover, drawn behind everything. None leaves the card plain b4. */
|
|
34
|
+
coverUrl?: string | undefined;
|
|
35
|
+
/** ISO 3166-1 alpha-2 code; draws osu!'s flag. Anything else draws none. */
|
|
36
|
+
countryCode?: string | undefined;
|
|
37
|
+
/** The flag's alt text (default: the English name from Intl, else the code). */
|
|
38
|
+
countryName?: string | undefined;
|
|
39
|
+
/** The player's osu! team, drawn as its flag beside the country's. */
|
|
40
|
+
team?: PlayerTeam | undefined;
|
|
41
|
+
/** Draws the supporter heart. */
|
|
42
|
+
supporter?: boolean | undefined;
|
|
43
|
+
/** What screen readers hear for the heart (default "osu! supporter"). */
|
|
44
|
+
supporterLabel?: string | undefined;
|
|
45
|
+
/** Draws the status ring. Leave it out for static data: the card never guesses a status. */
|
|
46
|
+
status?: "online" | "offline" | undefined;
|
|
47
|
+
/** The bottom row's main line (default "Online" or "Offline" when `status` is set). */
|
|
48
|
+
statusText?: ReactNode;
|
|
49
|
+
/** A small line above it ("Last seen 29 days ago", "formerly RMarc"). */
|
|
50
|
+
statusNote?: ReactNode;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* @function PlayerCard
|
|
54
|
+
* @param props {PlayerCardProps} the player's snapshot (name, id, cover, flags, supporter,
|
|
55
|
+
* status) and native div props
|
|
56
|
+
* @returns {JSX.Element} a 120px osu!-web user card; the whole card links to the profile when it
|
|
57
|
+
* has a link
|
|
58
|
+
*/
|
|
59
|
+
export declare function PlayerCard({ username, userId, href, avatarUrl, coverUrl, countryCode, countryName, team, supporter, supporterLabel, status, statusText, statusNote, className, ...props }: PlayerCardProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "../../utils/cx.js";
|
|
3
|
+
import { AutoLink } from "../basics/AutoLink.js";
|
|
4
|
+
import { avatarUrl as defaultAvatarUrl, countryName as defaultCountryName, flagUrl, isAnimatedImage, normalizeCountryCode, profileUrl, } from "./playerLinks.js";
|
|
5
|
+
/** The first letter or digit of a name, for the stand-in avatar. */
|
|
6
|
+
function initialOf(name) {
|
|
7
|
+
return name.match(/[\p{L}\p{N}]/u)?.[0]?.toUpperCase() ?? "?";
|
|
8
|
+
}
|
|
9
|
+
/** Whether a status line has something to show: not null, undefined, a boolean or "". */
|
|
10
|
+
function shown(node) {
|
|
11
|
+
return node != null && typeof node !== "boolean" && node !== "";
|
|
12
|
+
}
|
|
13
|
+
/** The supporter heart: osu!'s pink-circle badge with a white heart. */
|
|
14
|
+
function SupporterHeart({ label }) {
|
|
15
|
+
return (_jsx("span", { role: "img", "aria-label": label, className: "inline-flex size-[26px] shrink-0 items-center justify-center rounded-full bg-h2 text-c1", children: _jsx("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", className: "size-3.5", fill: "currentColor", children: _jsx("path", { d: "M12 21s-7.5-4.6-10-9.3C.3 8.4 2.2 4.5 6 4.5c2.2 0 3.6 1.2 4.5 2.5l1.5 2 1.5-2c.9-1.3 2.3-2.5 4.5-2.5 3.8 0 5.7 3.9 4 7.2C19.5 16.4 12 21 12 21z" }) }) }));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* @function PlayerCard
|
|
19
|
+
* @param props {PlayerCardProps} the player's snapshot (name, id, cover, flags, supporter,
|
|
20
|
+
* status) and native div props
|
|
21
|
+
* @returns {JSX.Element} a 120px osu!-web user card; the whole card links to the profile when it
|
|
22
|
+
* has a link
|
|
23
|
+
*/
|
|
24
|
+
export function PlayerCard({ username, userId, href, avatarUrl, coverUrl, countryCode, countryName, team, supporter = false, supporterLabel = "osu! supporter", status, statusText, statusNote, className, ...props }) {
|
|
25
|
+
const link = href === null ? null : (href ?? (userId === undefined ? null : profileUrl(userId)));
|
|
26
|
+
const avatar = avatarUrl ?? (userId === undefined ? null : defaultAvatarUrl(userId));
|
|
27
|
+
const code = normalizeCountryCode(countryCode);
|
|
28
|
+
const flag = flagUrl(countryCode);
|
|
29
|
+
const mainLine = shown(statusText)
|
|
30
|
+
? statusText
|
|
31
|
+
: status === "online"
|
|
32
|
+
? "Online"
|
|
33
|
+
: status
|
|
34
|
+
? "Offline"
|
|
35
|
+
: null;
|
|
36
|
+
const note = shown(statusNote) ? statusNote : null;
|
|
37
|
+
const hasStatusRow = status !== undefined || mainLine !== null || note !== null;
|
|
38
|
+
return (_jsxs("div", { className: cx("relative isolate flex h-[120px] flex-col justify-between overflow-hidden rounded-[10px] bg-b4 text-c1", link &&
|
|
39
|
+
"hover:outline-2 hover:outline-c3 has-[a:focus-visible]:outline-2 has-[a:focus-visible]:outline-h1", className), ...props, children: [coverUrl ? (_jsx("img", { src: coverUrl, alt: "", loading: "lazy", decoding: "async", className: cx("absolute inset-0 -z-10 size-full object-cover", isAnimatedImage(coverUrl) && "motion-reduce:hidden") })) : null, coverUrl ? _jsx("div", { "aria-hidden": "true", className: "absolute inset-0 -z-10 bg-b5/80" }) : null, _jsxs("div", { className: "flex gap-2.5 p-2.5", children: [avatar ? (_jsx("img", { src: avatar, alt: "", width: 60, height: 60, loading: "lazy", decoding: "async", className: "size-[60px] shrink-0 rounded-md bg-b4 object-cover" })) : (_jsx("span", { "aria-hidden": "true", className: "flex size-[60px] shrink-0 items-center justify-center rounded-md bg-b3 font-bold text-2xl text-c3", children: initialOf(username) })), _jsxs("div", { className: "grid min-w-0 flex-1 grid-rows-[26px_1fr]", children: [_jsxs("div", { className: "flex h-[26px] items-center gap-1.5", children: [flag && code ? (_jsx("img", { src: flag, alt: countryName ?? defaultCountryName(code), width: 36, height: 26, loading: "lazy", decoding: "async", className: "h-[26px] w-auto shrink-0" })) : null, team ? (_jsx("img", { src: team.flagUrl, alt: team.name, width: 52, height: 26, loading: "lazy", decoding: "async", className: "h-[26px] w-[52px] shrink-0 rounded-sm object-cover" })) : null, supporter ? _jsx(SupporterHeart, { label: supporterLabel }) : null] }), _jsx("div", { className: "flex min-w-0 items-center", children: link ? (_jsx(AutoLink, { href: link, className: "truncate font-semibold text-base text-c1 after:absolute after:inset-0", children: username })) : (_jsx("span", { className: "truncate font-semibold text-base", children: username })) })] })] }), hasStatusRow ? (_jsxs("div", { className: "flex items-center gap-2.5 px-2.5 pb-2.5", children: [_jsx("div", { className: "flex w-[60px] shrink-0 justify-center", children: status ? (_jsx("span", { "aria-hidden": "true", className: cx("size-[25px] rounded-full border-4", status === "online" ? "border-lime-400" : "border-b6") })) : null }), _jsxs("div", { className: "flex min-w-0 flex-col", children: [note !== null ? _jsx("span", { className: "truncate text-c2 text-xs", children: note }) : null, mainLine !== null ? _jsx("span", { className: "truncate text-sm", children: mainLine }) : null] })] })) : null] }));
|
|
40
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/osu/playerLinks.ts
|
|
3
|
+
* @desc The osu! URLs and names PlayerCard builds from plain props: profile and avatar from a
|
|
4
|
+
* user id, osu-web's own country flag SVG from a two-letter code, the country's English
|
|
5
|
+
* name for the flag's alt text, and whether a cover is an animated GIF. Pure, server-safe.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Sun Oct 4, 2026
|
|
8
|
+
* @modified Sun Oct 4, 2026
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* @function profileUrl
|
|
12
|
+
* @param id {number} the osu! user id
|
|
13
|
+
* @returns {string} the user's osu! profile page
|
|
14
|
+
*/
|
|
15
|
+
export declare const profileUrl: (id: number) => string;
|
|
16
|
+
/**
|
|
17
|
+
* @function avatarUrl
|
|
18
|
+
* @param id {number} the osu! user id
|
|
19
|
+
* @returns {string} the user's current avatar on a.ppy.sh (osu! serves the default for none)
|
|
20
|
+
*/
|
|
21
|
+
export declare const avatarUrl: (id: number) => string;
|
|
22
|
+
/**
|
|
23
|
+
* @function normalizeCountryCode
|
|
24
|
+
* @param code {string | undefined} an ISO 3166-1 alpha-2 code, any case, maybe padded
|
|
25
|
+
* @returns {string | null} the code in capitals, or null when it isn't two ASCII letters
|
|
26
|
+
*/
|
|
27
|
+
export declare function normalizeCountryCode(code: string | undefined): string | null;
|
|
28
|
+
/**
|
|
29
|
+
* @function flagUrl
|
|
30
|
+
* @param code {string | undefined} an ISO 3166-1 alpha-2 code
|
|
31
|
+
* @returns {string | null} osu-web's flag SVG (named by the flag emoji's regional-indicator code
|
|
32
|
+
* points, lowercase hex joined by "-"), or null for anything but two ASCII letters
|
|
33
|
+
*/
|
|
34
|
+
export declare function flagUrl(code: string | undefined): string | null;
|
|
35
|
+
/**
|
|
36
|
+
* @function countryName
|
|
37
|
+
* @param code {string} an ISO 3166-1 alpha-2 code
|
|
38
|
+
* @returns {string} the region's English name from Intl, or the code in capitals when Intl
|
|
39
|
+
* doesn't know it
|
|
40
|
+
*/
|
|
41
|
+
export declare function countryName(code: string): string;
|
|
42
|
+
/**
|
|
43
|
+
* @function isAnimatedImage
|
|
44
|
+
* @param url {string} an image URL
|
|
45
|
+
* @returns {boolean} true when the path ends in .gif (before any query or hash)
|
|
46
|
+
*/
|
|
47
|
+
export declare const isAnimatedImage: (url: string) => boolean;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/osu/playerLinks.ts
|
|
3
|
+
* @desc The osu! URLs and names PlayerCard builds from plain props: profile and avatar from a
|
|
4
|
+
* user id, osu-web's own country flag SVG from a two-letter code, the country's English
|
|
5
|
+
* name for the flag's alt text, and whether a cover is an animated GIF. Pure, server-safe.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Sun Oct 4, 2026
|
|
8
|
+
* @modified Sun Oct 4, 2026
|
|
9
|
+
*/
|
|
10
|
+
const REGIONAL_INDICATOR_A = 0x1f1e6;
|
|
11
|
+
/**
|
|
12
|
+
* @function profileUrl
|
|
13
|
+
* @param id {number} the osu! user id
|
|
14
|
+
* @returns {string} the user's osu! profile page
|
|
15
|
+
*/
|
|
16
|
+
export const profileUrl = (id) => `https://osu.ppy.sh/users/${id}`;
|
|
17
|
+
/**
|
|
18
|
+
* @function avatarUrl
|
|
19
|
+
* @param id {number} the osu! user id
|
|
20
|
+
* @returns {string} the user's current avatar on a.ppy.sh (osu! serves the default for none)
|
|
21
|
+
*/
|
|
22
|
+
export const avatarUrl = (id) => `https://a.ppy.sh/${id}`;
|
|
23
|
+
/**
|
|
24
|
+
* @function normalizeCountryCode
|
|
25
|
+
* @param code {string | undefined} an ISO 3166-1 alpha-2 code, any case, maybe padded
|
|
26
|
+
* @returns {string | null} the code in capitals, or null when it isn't two ASCII letters
|
|
27
|
+
*/
|
|
28
|
+
export function normalizeCountryCode(code) {
|
|
29
|
+
const upper = code?.trim().toUpperCase() ?? "";
|
|
30
|
+
return /^[A-Z]{2}$/.test(upper) ? upper : null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* @function flagUrl
|
|
34
|
+
* @param code {string | undefined} an ISO 3166-1 alpha-2 code
|
|
35
|
+
* @returns {string | null} osu-web's flag SVG (named by the flag emoji's regional-indicator code
|
|
36
|
+
* points, lowercase hex joined by "-"), or null for anything but two ASCII letters
|
|
37
|
+
*/
|
|
38
|
+
export function flagUrl(code) {
|
|
39
|
+
const upper = normalizeCountryCode(code);
|
|
40
|
+
if (!upper)
|
|
41
|
+
return null;
|
|
42
|
+
const points = [...upper].map((letter) => (REGIONAL_INDICATOR_A + letter.charCodeAt(0) - 65).toString(16));
|
|
43
|
+
return `https://osu.ppy.sh/assets/images/flags/${points.join("-")}.svg`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* @function countryName
|
|
47
|
+
* @param code {string} an ISO 3166-1 alpha-2 code
|
|
48
|
+
* @returns {string} the region's English name from Intl, or the code in capitals when Intl
|
|
49
|
+
* doesn't know it
|
|
50
|
+
*/
|
|
51
|
+
export function countryName(code) {
|
|
52
|
+
const upper = code.trim().toUpperCase();
|
|
53
|
+
try {
|
|
54
|
+
return new Intl.DisplayNames(["en"], { type: "region" }).of(upper) ?? upper;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return upper;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* @function isAnimatedImage
|
|
62
|
+
* @param url {string} an image URL
|
|
63
|
+
* @returns {boolean} true when the path ends in .gif (before any query or hash)
|
|
64
|
+
*/
|
|
65
|
+
export const isAnimatedImage = (url) => /\.gif(?:[?#]|$)/i.test(url);
|
package/dist/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* their own "use client" directive, so this barrel is safe to import from Server Components.
|
|
6
6
|
* @author David @dvhsh (https://dvh.sh)
|
|
7
7
|
* @created Wed Sep 23, 2026
|
|
8
|
-
* @modified
|
|
8
|
+
* @modified Sun Oct 4, 2026
|
|
9
9
|
*/
|
|
10
10
|
export { AsyncButton, type AsyncButtonProps } from "./components/actions/AsyncButton.js";
|
|
11
11
|
export { CopyButton, type CopyButtonProps } from "./components/actions/CopyButton.js";
|
|
@@ -25,6 +25,16 @@ export { Prose, type ProseProps } from "./components/basics/Prose.js";
|
|
|
25
25
|
export { type TabItem, Tabs, type TabsProps } from "./components/basics/Tabs.js";
|
|
26
26
|
export { TextLink, type TextLinkProps } from "./components/basics/TextLink.js";
|
|
27
27
|
export { tabId, tabPanelId } from "./components/basics/tabIds.js";
|
|
28
|
+
export { BrandPage, type BrandPageAsset, type BrandPageFont, type BrandPageProps, } from "./components/brand/BrandPage.js";
|
|
29
|
+
export { BrandSwatch, type BrandSwatchProps } from "./components/brand/BrandSwatch.js";
|
|
30
|
+
export { ContentIndex, type ContentIndexProps } from "./components/content/ContentIndex.js";
|
|
31
|
+
export { ContentLayout, type ContentLayoutProps } from "./components/content/ContentLayout.js";
|
|
32
|
+
export { ContentNav, type ContentNavProps } from "./components/content/ContentNav.js";
|
|
33
|
+
export { ContentPage, type ContentPageProps } from "./components/content/ContentPage.js";
|
|
34
|
+
export { ContentSearch, type ContentSearchProps } from "./components/content/ContentSearch.js";
|
|
35
|
+
export { CopyMarkdownButton, type CopyMarkdownButtonProps, } from "./components/content/CopyMarkdownButton.js";
|
|
36
|
+
export { searchContent } from "./components/content/searchContent.js";
|
|
37
|
+
export type { ContentNavGroup, ContentNavItem, ContentSearchItem, } from "./components/content/types.js";
|
|
28
38
|
export { Chip, type ChipProps } from "./components/filters/Chip.js";
|
|
29
39
|
export { ChipGroup, type ChipGroupProps, type ChipOption } from "./components/filters/ChipGroup.js";
|
|
30
40
|
export { type ChoiceChipOption, ChoiceChips, type ChoiceChipsProps, } from "./components/filters/ChoiceChips.js";
|
|
@@ -49,6 +59,7 @@ export { HaruhimeWordmarkLink, type HaruhimeWordmarkLinkProps, } from "./compone
|
|
|
49
59
|
export { JsonLd, type JsonLdProps } from "./components/meta/JsonLd.js";
|
|
50
60
|
export { type BeatmapStatKey, BeatmapStats, type BeatmapStatsProps, } from "./components/osu/BeatmapStats.js";
|
|
51
61
|
export { ModBadge, type ModBadgeProps } from "./components/osu/ModBadge.js";
|
|
62
|
+
export { PlayerCard, type PlayerCardProps, type PlayerTeam, } from "./components/osu/PlayerCard.js";
|
|
52
63
|
export { StarRating, type StarRatingProps } from "./components/osu/StarRating.js";
|
|
53
64
|
export { CommandPalette } from "./components/palette/CommandPalette.js";
|
|
54
65
|
export { CommandPaletteButton, type CommandPaletteButtonProps, } from "./components/palette/CommandPaletteButton.js";
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* their own "use client" directive, so this barrel is safe to import from Server Components.
|
|
6
6
|
* @author David @dvhsh (https://dvh.sh)
|
|
7
7
|
* @created Wed Sep 23, 2026
|
|
8
|
-
* @modified
|
|
8
|
+
* @modified Sun Oct 4, 2026
|
|
9
9
|
*/
|
|
10
10
|
// Actions
|
|
11
11
|
export { AsyncButton } from "./components/actions/AsyncButton.js";
|
|
@@ -26,6 +26,17 @@ export { Prose } from "./components/basics/Prose.js";
|
|
|
26
26
|
export { Tabs } from "./components/basics/Tabs.js";
|
|
27
27
|
export { TextLink } from "./components/basics/TextLink.js";
|
|
28
28
|
export { tabId, tabPanelId } from "./components/basics/tabIds.js";
|
|
29
|
+
// Brand
|
|
30
|
+
export { BrandPage, } from "./components/brand/BrandPage.js";
|
|
31
|
+
export { BrandSwatch } from "./components/brand/BrandSwatch.js";
|
|
32
|
+
// Content
|
|
33
|
+
export { ContentIndex } from "./components/content/ContentIndex.js";
|
|
34
|
+
export { ContentLayout } from "./components/content/ContentLayout.js";
|
|
35
|
+
export { ContentNav } from "./components/content/ContentNav.js";
|
|
36
|
+
export { ContentPage } from "./components/content/ContentPage.js";
|
|
37
|
+
export { ContentSearch } from "./components/content/ContentSearch.js";
|
|
38
|
+
export { CopyMarkdownButton, } from "./components/content/CopyMarkdownButton.js";
|
|
39
|
+
export { searchContent } from "./components/content/searchContent.js";
|
|
29
40
|
// Filters
|
|
30
41
|
export { Chip } from "./components/filters/Chip.js";
|
|
31
42
|
export { ChipGroup } from "./components/filters/ChipGroup.js";
|
|
@@ -54,6 +65,7 @@ export { JsonLd } from "./components/meta/JsonLd.js";
|
|
|
54
65
|
// osu!
|
|
55
66
|
export { BeatmapStats, } from "./components/osu/BeatmapStats.js";
|
|
56
67
|
export { ModBadge } from "./components/osu/ModBadge.js";
|
|
68
|
+
export { PlayerCard, } from "./components/osu/PlayerCard.js";
|
|
57
69
|
export { StarRating } from "./components/osu/StarRating.js";
|
|
58
70
|
// Palette
|
|
59
71
|
export { CommandPalette } from "./components/palette/CommandPalette.js";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@haruhimemoe/ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "React components for the haruhime.moe osu! tools on Next.js: the osu!-web-style palette as a Tailwind theme, buttons, cards, form fields and confirms, filter controls, tables, osu! beatmap display pieces, a command palette and the site header and footer.",
|
|
3
|
+
"version": "0.11.0",
|
|
4
|
+
"description": "React components for the haruhime.moe osu! tools on Next.js: the osu!-web-style palette as a Tailwind theme, buttons, cards, form fields and confirms, filter controls, tables, osu! beatmap display pieces and player cards, a command palette, content section tooling, brand pages and the site header and footer.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"osu",
|
|
7
7
|
"osu!",
|