feat: polish custom widgets and workshop

This commit is contained in:
Thomas Camlong committed 2026-07-22 19:33:09 +02:00
1 parent e94b4d4bc9
commit 0066c8bc28
98 files changed
+9097 -1624

No files matched your search

@@ -11,6 +11,6 @@ Templates read `data.requestId`, `status.requestId`, `options.name`, and tempora
</SubFetch>
```
`SubFetch` with `trigger="manual"` renders its own load button. Its optional second callback argument contains `{ ok, status, statusText, loading: false }`; loading and request failures are rendered by `SubFetch` before the callback runs. Do not author `onClick` or a fetch callback.
`SubFetch` with `trigger="manual"` renders its own load button. To make a card or image launch the request, pass that node through `triggerContent` and provide `triggerAriaLabel`; Homarr supplies the click and keyboard behavior. Its optional second callback argument contains `{ ok, status, statusText, loading: false }`; loading and request failures are rendered by `SubFetch` before the callback runs. Do not author `onClick` or a fetch callback.
Use expression callbacks for supported collection methods and trusted slots. Do not use callback blocks, IIFEs, authored recursion, or raw events. Bounded regex literals work only with safe string matching/replacement operations.
@@ -0,0 +1,115 @@
---
target: Workshop store in the docs
total_score: 20
p0_count: 1
p1_count: 3
timestamp: 2026-07-21T11-32-50Z
slug: apps-docs-src-components-workshop
---
# Workshop Store Critique
## Design Health Score
| # | Heuristic | Score | Key issue |
|---|---|---:|---|
| 1 | Visibility of system status | 1/4 | Fetch failure can show beside an empty-store message, so the system communicates two incompatible states. |
| 2 | Match between system and real world | 3/4 | Widget, CSS, voting, revisions, and comments use familiar language. |
| 3 | User control and freedom | 2/4 | Filters are reversible, but the crashed route and failed fetch state offer no useful recovery. |
| 4 | Consistency and standards | 2/4 | The submission editor uses CodeMirror, while revision editing falls back to a textarea; several tiny custom controls do not follow the shared button vocabulary. |
| 5 | Error prevention | 2/4 | Submission validation exists, but disabled progression does not explain what is missing and destructive actions use a brief inline confirmation. |
| 6 | Recognition rather than recall | 3/4 | Labels, type choices, and the submission stepper are understandable without documentation. |
| 7 | Flexibility and efficiency | 2/4 | Search covers titles only, signed-out users can select Yours, and installation is reduced to ambiguous Copy and Download actions. |
| 8 | Aesthetic and minimalist design | 3/4 | The restrained visual system is clean, but the browse toolbar and card action rows become dense. |
| 9 | Help users recover from errors | 0/4 | The production route crashes with React error 130; the fetch error has no retry action or useful context. |
| 10 | Help and documentation | 2/4 | The editor explains its fields, but the store does not teach the full inspect, trust, download, and install journey. |
| **Total** | | **20/40** | **Needs work before release** |
## Anti-Patterns Verdict
The surface does not look like obvious AI-generated decoration. Its visual restraint, familiar controls, and practical editor are credible product UI. The weakness is product behavior rather than styling: contradictory states, tiny action controls, an overloaded responsive toolbar, and a primary installation journey that is not explicit.
The deterministic Impeccable scan returned zero findings for `apps/docs/src/components/workshop`. This is useful but incomplete. The detector did not catch runtime failures, semantic state conflicts, touch-target sizing, or responsive grouping. Browser evidence found a full route crash, React error 130, and an earlier state that rendered both `Something went wrong.` and `No submissions yet`.
No visual overlay was injected because the available browser evaluation surface is read-only. Screenshots, DOM snapshots, console logs, and source inspection were used instead.
## Overall Impression
The submission modal is now one of the stronger parts of the Workshop. The store around it is not release-ready. The single biggest opportunity is to make discovery and installation a dependable, explicit path: load reliably, show one truthful state, help users evaluate a submission, and give them one obvious next action.
## What Is Working
1. The visual system is restrained and consistent with the Homarr documentation. Typography, color, radii, and spacing do not compete with the content.
2. The submission flow chunks a complex task into Type, Details, and Media. The shared JSON/CSS editor, drag-and-drop import, metadata autofill, and capped media previews reduce creator effort.
3. The detail design exposes revision, author, reports, changelog, source content, and comments. This supports inspection rather than treating community code as a blind install.
## Priority Issues
### P0: The Workshop route crashes in the production docs build
**Why it matters:** No visual polish matters if the store becomes a Docusaurus error page. A fresh independent tab consistently reached `This page crashed` with React error 130. The console attributed the thrown error to the Kapa widget bundle.
**Fix:** Isolate or disable the Kapa script on Workshop routes, prevent third-party widget failures from reaching the Docusaurus route boundary, and add a route-level Workshop boundary that preserves navigation and retry. Reproduce against the built Docker docs image, not only the development server.
**Suggested command:** `$impeccable harden`
### P1: Failure and empty states contradict each other
**Why it matters:** Showing `Something went wrong.` followed by `No submissions yet` makes users believe the store is both broken and empty. The generic message gives no cause and no recovery.
**Fix:** Make loading, failure, empty, and results mutually exclusive. The failure state should say that listings could not be loaded, preserve the current filters, and provide `Try loading again`. Do not render filters or an empty store as authoritative data after the initial request fails.
**Suggested command:** `$impeccable harden`
### P1: Mobile discovery controls are too dense and do not form a responsive hierarchy
**Why it matters:** The toolbar's inner row contains four type choices, sorting, and outdated visibility without wrapping. Most controls are 24 to 28 pixels tall. At narrow widths this is difficult to scan, easy to clip, and below a comfortable touch target.
**Fix:** Put a full-width search first on mobile, keep All, Widgets, and CSS as the primary segmented choice, move sorting and outdated visibility into a compact secondary row or popover, and hide Yours when signed out or make it a sign-in affordance. Use at least 40 to 44 pixel touch targets. Add `aria-pressed` or proper tab semantics to the type filter.
**Suggested command:** `$impeccable adapt`
### P1: The store does not make the install journey obvious
**Why it matters:** A first-time user sees Copy and Download without knowing which is recommended, what will be copied, how to inspect safety, or where the file goes in Homarr. This weakens the store's main purpose.
**Fix:** Make the detail page the decision surface. Use one primary action named for its result, such as `Download widget JSON` or `Copy CSS`, with a short `Install in Homarr` explanation linked to the correct in-app flow. Cards should prioritize title, preview, compatibility, score, and `View details`; secondary utilities should not compete with evaluation.
**Suggested command:** `$impeccable clarify`
### P2: Card and moderation actions are too small and too numerous
**Why it matters:** Vote, copy, download, comment, delete, and report controls compete in a compact footer. Low-contrast icon buttons are hard to discover and hard to use by touch. CSS columns also create a top-to-bottom visual reading order that can differ from the expected row order.
**Fix:** Use a regular responsive grid, remove hover scaling, keep one or two visible card actions, and move owner/moderation actions to a menu. Keep voting visible but increase its target size. Correct the report copy: reports and reporter identities are moderation data, not public social proof.
**Suggested command:** `$impeccable distill`
## Persona Red Flags
### Jordan, first-time self-hoster
Jordan opens Workshop to install a widget. The route can crash before any listing appears. In the partial failure state, Jordan sees both an error and an empty store. If listings load, Copy and Download do not explain the Homarr destination or recommended path. Jordan is likely to leave before installing anything.
### Alex, experienced Homarr administrator
Alex can understand the JSON and appreciates the source viewer, but title-only search and limited filtering make a larger catalog inefficient. Compatibility and required secret information are not prominent at browse time. Tiny moderation and owner controls slow repeated management tasks.
### Morgan, mobile user
Morgan encounters a toolbar with more controls than fit comfortably in one decision group, 24 to 28 pixel targets, and a signed-out Yours filter that leads to an unhelpful no-results state. Submission cards add another dense row of small actions.
## Minor Observations
- Future submission steps are focusable buttons even when clicking them does nothing. Disable them or render non-interactive progress markers.
- The report textarea relies on placeholder text and needs a persistent accessible label.
- The revision editor should reuse the same JSON/CSS CodeMirror component as creation.
- Search should include title, description, and author at minimum.
- The timed inline `Confirm?` deletion pattern is easy to miss and weak for keyboard and touch users.
- The detail-page changelog uses an uppercase tracked label, which is unnecessary in this otherwise restrained interface.
- Loading comments should use the same skeleton vocabulary as listings rather than centered status text.
## Questions to Consider
1. Is Workshop primarily a catalog for discovery, or a technical exchange for copying source? The primary card and detail actions should commit to one answer.
2. Should users evaluate compatibility and required secrets before opening a listing, or only on the detail page?
3. Does Workshop need every moderation and ownership action visible inline, or should those actions live behind a menu?
@@ -0,0 +1,63 @@
---
target: Workshop store in the docs
total_score: 38
p0_count: 0
p1_count: 0
timestamp: 2026-07-21T12-29-41Z
slug: apps-docs-src-components-workshop
---
# Workshop Store Critique — Post-Polish
## Design Health Score
| # | Heuristic | Score | Evidence |
|---|---|---:|---|
| 1 | Visibility of system status | 4/4 | Loading, initial failure, stale refresh failure, empty, and results states are mutually exclusive; recovery actions are visible. |
| 2 | Match between system and real world | 4/4 | Actions describe their result: Download widget JSON, Copy CSS, View details, and Install in Homarr. |
| 3 | User control and freedom | 4/4 | Filters can be cleared, errors retried, dialogs cancelled, and destructive submission deletion requires explicit confirmation. |
| 4 | Consistency and standards | 4/4 | Creation and revision editing share CodeMirror, buttons follow the shared system, and cards use a predictable responsive grid. |
| 5 | Error prevention | 3/4 | Validation, disabled progression, pending states, RLS, and confirmations are strong; disabled form progression could explain unmet requirements more explicitly. |
| 6 | Recognition rather than recall | 4/4 | Persistent labels, type descriptions, step status, source previews, and installation instructions minimize recall. |
| 7 | Flexibility and efficiency | 4/4 | Search spans title, description, and author; signed-in filtering, sorting, responsive controls, and direct detail actions support repeat use. |
| 8 | Aesthetic and minimalist design | 3/4 | The catalog is restrained and scannable; long source documents and the complete creator form necessarily remain dense on small screens. |
| 9 | Help users recover from errors | 4/4 | Route, listing, edit, report, copy, OAuth, and detail failures remain local and provide a clear next action. |
| 10 | Help and documentation | 4/4 | The detail page explains the exact Homarr destination and links to updated Workshop documentation. |
| **Total** | | **38/40** | **Release-quality; minor polish remains** |
## Anti-Patterns Verdict
The surface is product-led rather than decorative. The redesign removes the overloaded card footer, hover scaling, tiny touch controls, public moderation details, contradictory states, and ambiguous install actions. The deterministic Impeccable detector reports zero findings.
## What Improved
1. The production route no longer collapses into the Docusaurus crash screen without recovery. A route-local boundary protects navigation, and the fresh-volume production path renders without console errors.
2. Loading, failure, empty, stale-results, and populated states are truthful and mutually exclusive.
3. Search and filters form a clear responsive hierarchy. Mobile targets are 40–44 pixels, signed-out users do not see Yours, and cards no longer overflow at 390 pixels.
4. Cards prioritize evaluation: title, author, type, score, description, comments, and View details. Copy, download, reporting, editing, and deletion moved to the detail page.
5. The detail page now teaches the installation journey and names its primary action precisely.
6. Reports and reporter identities are admin-only through PocketBase rules. Public catalog UI no longer turns reports into social proof.
7. The creator and revision dialogs share the CodeMirror editor, use bounded internal scrolling, expose pending/error states, and avoid step-selection layout shifts.
## Remaining Minor Opportunities
### P2: Explain disabled progression inline
The Type and Details steps are understandable, but a disabled Next button could state the missing requirement for keyboard and first-time users. This is a refinement, not a blocker, because required fields and validation messages are already present.
### P2: Consider a compact source summary on very small screens
The read-only source viewer is intentionally complete and horizontally scrollable. A future collapse/expand summary could reduce page length for nontechnical visitors without hiding inspection entirely.
## Verification Evidence
- Fresh Docker Compose PocketBase volume and production docs image.
- Populated catalog with widget, CSS, and outdated examples.
- Desktop and 390px mobile layouts in light and dark themes.
- Widget detail, installation instructions, CodeMirror source view, and comments.
- Signed-in submission wizard through Type and Details, including the shared editor.
- Zero browser console errors after the final fixes.
- Impeccable deterministic detector: zero findings.
## Compatibility Note
An existing local development volume created from an earlier unreleased version of the reset migration still has a stale Workshop view. It is intentionally not migrated or deleted. A fresh volume created from the current unreleased migration is the supported validation target and works correctly.
@@ -0,0 +1,105 @@
---
target: Homarr Workshop docs UI
total_score: 27
p0_count: 0
p1_count: 2
timestamp: 2026-07-22T15-58-15Z
slug: apps-docs-src-components-workshop
---
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|---|---:|---|
| 1 | Visibility of System Status | 3 | Strong loading, retry, stale-data, pending, and copy states; successful report/edit actions still end silently. |
| 2 | Match System / Real World | 3 | Core Workshop language is clear, but `networkScope`, query/action, and schema terminology assumes technical fluency. |
| 3 | User Control and Freedom | 3 | Back, cancel, clear filters, vote toggling, and destructive confirmations are present; destructive actions have no undo. |
| 4 | Consistency and Standards | 3 | Shared primitives are coherent, though Docusaurus chrome and the denser app-store vocabulary still feel slightly stitched together. |
| 5 | Error Prevention | 2 | Validation and confirmations are good, but install safety and credential context appears after download/copy actions on small screens. |
| 6 | Recognition Rather Than Recall | 3 | Author, revision, source, filters, and capabilities are visible; installation still asks users to remember a separate Homarr navigation path. |
| 7 | Flexibility and Efficiency | 2 | Search, filters, sort, drag-and-drop, autofill, and comment shortcuts help; the install handoff remains manual. |
| 8 | Aesthetic and Minimalist Design | 2 | Hierarchy is clear, but repeated panels, badges, uppercase metadata labels, and overlapping install actions add noise. |
| 9 | Error Recognition and Recovery | 3 | Errors are plain-language and usually preserve work; the global action error can be detached from the failed control. |
| 10 | Help and Documentation | 3 | The installation guide and source explanation are useful; security terminology lacks inline definitions. |
| **Total** | | **27/40** | **Acceptable, significant improvements needed** |
## Anti-Patterns Verdict
**LLM assessment:** Moderate AI-assisted feel, not outright slop. Domain-specific copy, extensive state coverage, and transparent source/capability information make the feature credible. The generic card grid, repeated rounded panels, icon tiles, uppercase tracked labels, and decorative changelog stripe still expose a component-recipe grammar. It looks competent but not fully edited into a distinctive Homarr store.
**Deterministic scan:** `detect.mjs --json apps/docs/src/components/workshop` exited 0 with exact output `[]`: zero findings, zero triggered rules, and no false positives. The scan found none of Impeccable's deterministic anti-patterns.
**Visual overlays:** No reliable user-visible overlay is available. The independent detector agent had no browser backend. The parent fallback opened a fresh browser tab and visually inspected the listing, but the browser API did not expose a mutable evaluation method for the required preflight, so injection and `[Human]` visibility were correctly skipped. Browser/source evidence was used instead.
## Overall Impression
The Workshop is already substantially better than a generic gallery: it communicates authorship, source code, API capabilities, secrets, revision history, reports, and discussion. The biggest opportunity is to turn that information into a deliberate trust sequence. Today the interface lets the user act first and understand safety later, especially on mobile.
## What's Working
- **State coverage is unusually strong.** Listing failures, stale data, loading skeletons, empty results, filter recovery, comment errors, pending actions, and destructive confirmations have explicit UI.
- **Pre-install transparency is excellent.** API hosts, methods, configurable options, network scope, and credential requirements are visible instead of hidden behind a download.
- **The new primitive layer is coherent.** Search, filters, menus, switches, dialogs, and code presentation share one restrained interaction vocabulary across light and dark themes.
## Priority Issues
### [P1] Safety context arrives after the install action on mobile
**Why it matters:** Download and copy actions appear in the header, while source capabilities, credential requirements, and installation guidance live in an aside that drops below the long source and discussion sections under the desktop breakpoint. A first-time mobile user can commit before understanding what the widget contacts or what secrets it needs.
**Fix:** Put a compact “Before you install” summary immediately below the header on small screens: API hosts, network scopes, credential count, and action/query counts. Keep the full sticky details panel on desktop.
**Suggested command:** `$impeccable layout`
### [P1] Primary button and own-comment contrast misses AA
**Why it matters:** The requested theme's primary red with white foreground is about 3.4:1, below 4.5:1 for small text. The same pairing affects primary buttons and the author's comment bubbles.
**Fix:** Darken the primary red or use a dark foreground in light mode, then verify default buttons and comment bubbles in both themes.
**Suggested command:** `$impeccable colorize`
### [P2] The listing's server-rendered fallback is blank
**Why it matters:** Before hydration, the listing renders an empty block instead of preserving the page's hierarchy and indicating progress. This creates a visible dead zone on slower self-hosted deployments.
**Fix:** Render the listing heading, toolbar silhouette, and shared skeleton cards as the `BrowserOnly` fallback, matching the detail page's loading treatment.
**Suggested command:** `$impeccable harden`
### [P2] Download, copy, source, and install compete for the same task
**Why it matters:** “Download widget,” “Copy JSON,” a full source viewer, and a separate installation panel all appear as parallel paths. Users must infer the recommended workflow.
**Fix:** Make “Download widget JSON” the single primary header action. Keep copy beside the source viewer and place the next import step directly beside or immediately after download.
**Suggested command:** `$impeccable clarify`
### [P2] Repeated metadata scaffolding creates visual noise
**Why it matters:** Card shells, badges, icon tiles, uppercase tracked labels, and the changelog stripe make the detail page feel assembled from recipes and slow scanning.
**Fix:** Flatten the widget details into a compact definition list, reserve badges for status, and remove the decorative changelog stripe.
**Suggested command:** `$impeccable distill`
## Persona Red Flags
**Jordan, first-timer:** “Network scope,” queries, actions, options, and the manifest schema appear without inline explanation. Download versus copy has no stated recommendation. On mobile, credential and installation context arrives after source and discussion.
**Sam, accessibility-dependent user:** Primary controls and author comment bubbles miss small-text contrast. Copy, optimistic votes, filter result changes, and successful report/edit completion do not expose explicit live-region behavior. Gallery controls and dots are below comfortable touch-target sizes.
**Casey, distracted mobile user:** The most important safety panel falls to the bottom, while primary actions stay at the top. Long source and discussion content separate the decision from its explanation. The submit type selector remains dense at narrow widths.
## Minor Observations
- The internal installation guide uses an external-link icon.
- The final publication action says “Submit” rather than “Publish submission.”
- Dark-theme tokens are declared in two blocks, increasing drift risk.
- Screenshot dots rely on opacity alone and lack `aria-current`.
- Successful report and edit actions deserve a small confirmation instead of silent dismissal.
## Questions to Consider
- If source review is important enough to say “Review exactly what will be installed,” why can users download before seeing the capability and credential summary?
- Is Workshop primarily a code catalog or a safety-conscious app store? The current action hierarchy tries to be both.
- Should the successful end state be a downloaded file, or a confident handoff into Homarr with the next step already visible?
@@ -36,9 +36,9 @@ Bounded regex literals are supported for string matching, splitting, search, and
## Runtime components
- `SubFetch` runs a manual query with its `params`.
- `SubFetch` runs a manual query with its `params`. Set `triggerContent` to a card or image to make that content the accessible click and keyboard trigger instead of showing the default load button.
- `ActionButton` and `ToggleSwitch` run actions.
- `RefreshButton` refreshes load queries.
- `RefreshButton` renders a compact, accessible icon control that refreshes load queries.
- `Icon`/`TablerIcon` resolves installed safe icons.
Always include responsive loading, empty, error, and success states. Runtime failures remain inside the widget tile.
+17 -6
View File
@@ -27,14 +27,14 @@ The workshop contains community-made content. Widgets may contain unwanted or ma
- Filter by **Widgets** or **CSS**, show or hide outdated entries, and sort by popularity, creation date, recent updates, or discussion activity.
- Sign in with **GitHub** to upvote/downvote, submit your own creations, or report a submission.
- Each submission shows its title, description, linked GitHub author, revision, optional screenshots, comments, and full content.
- Each submission shows its title, description, linked GitHub author, revision, optional screenshots, comments, and full content. Widget pages also summarize API sources, actions, options, network access, and required credentials before installation.
- A new submission receives one real upvote owned by its author. Votes remain unique per user and submission.
- Report details and reporter identities are visible only to Workshop administrators. Reports do not automatically remove a submission.
- Report details and reporter identities are visible only to Workshop administrators. Reported submissions are highlighted in the administration page, where each open report can be dismissed or the submission can be deleted. Reports do not automatically remove a submission.
- The manual **Outdated** badge warns visitors that a submission may need an update.
## Comments
Comments are publicly readable. Signed-in users can post; authors can edit or delete their own comments, and Workshop administrators can delete any comment. Failed posts keep the text you typed. When SMTP and `WORKSHOP_PUBLIC_ORIGIN` are configured, the submission author receives one email for a comment from another user. Self-comments, edits, and deletions do not send mail.
Comments are publicly readable and appear in chronological conversation bubbles with the author's avatar and GitHub profile. Signed-in users can post; authors can edit or delete their own comments, and Workshop administrators can delete any comment. Failed posts keep the text you typed. When SMTP and `WORKSHOP_PUBLIC_ORIGIN` are configured, the submission author receives one email for a comment from another user. Self-comments, edits, and deletions do not send mail.
## Submitting
@@ -47,6 +47,9 @@ Comments are publicly readable. Signed-in users can post; authors can edit or de
4. Submit the entry.
Submissions are published immediately. Reported entries are reviewed and may be removed.
Authors can edit a submission later to publish a new revision. The edit form can keep or remove existing screenshots and add new PNG, JPG, or WebP images without changing the Workshop URL.
When SMTP and `WORKSHOP_PUBLIC_ORIGIN` are configured, every current Workshop administrator receives an email when a report is submitted. If an administrator deletes the reported submission, its author receives a separate removal email. Author self-deletion does not send a moderation email.
## Submission guidelines
@@ -58,8 +61,8 @@ that primarily promotes unrelated products or services.
You can install workshop entries without leaving Homarr:
- **Custom widgets** — go to **Manage → Custom Widgets**, click **Install from Workshop**, pick an
entry, and it is imported (secrets such as API keys must be re-entered afterwards).
- **Custom widgets** — go to **Manage → Custom Widgets**, or use **Install from Workshop** in the
board widget picker. Pick an entry, review its capabilities, and enter any required local secrets before import.
- **Custom CSS** — open a board's **Settings → Custom CSS**, click **Install from Workshop**, and
pick a theme to load it into the editor before saving.
@@ -73,6 +76,14 @@ Workshop is powered by [PocketBase](https://pocketbase.io). The production image
Run `pnpm docker:workshop` for the PocketBase development service or `pnpm docker:docs` for the built documentation and Workshop site. Both use `apps/workshop/docker-compose.yml` and the persistent `pb_data` volume. Normal start and stop commands retain data; `docker compose -f apps/workshop/docker-compose.yml down --volumes` intentionally recreates the database.
GitHub needs only the central callback `https://<workshop-host>/api/oauth2-redirect`. Keep PocketBase origins unrestricted unless every self-hosted Homarr caller is explicitly listed. Configure `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `WORKSHOP_PUBLIC_ORIGIN`, and PocketBase SMTP settings for authentication and comment email.
Because Workshop is still unreleased, reset an older development volume if it predates the current schema and comments report that their collection context is missing. No compatibility migrations are maintained for these pre-release databases.
GitHub needs only the central callback `https://<workshop-host>/api/oauth2-redirect`. Keep PocketBase origins unrestricted unless every self-hosted Homarr caller is explicitly listed. Configure `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `WORKSHOP_PUBLIC_ORIGIN`, and PocketBase SMTP settings for authentication, comment email, report alerts, and submission-removal notices.
Homarr instances use `NEXT_PUBLIC_WORKSHOP_API_URL` to reach the central API and `NEXT_PUBLIC_WORKSHOP_URL` for links to the public page. Docusaurus uses `WORKSHOP_URL` only when its development server runs separately; the combined production image always uses its own origin.
## Client architecture
Workshop access is centralized in `@homarr/workshop/backend`. It exports the typed `WorkshopBackend`, its typed PocketBase instance, stable TanStack Query option factories, and the `useWorkshop…` query and mutation hooks used by Homarr. The documentation site uses the same backend rather than maintaining a second API contract.
Listing fields derived by the `workshop_listings` view—such as submission type, score, and counts—are normalized by the shared client. Healthy deployments keep filtering, sorting, and pagination in PocketBase. If a persistent development volume still has an older view that rejects those fields, the client retries without them and applies the same operations locally; it can also fall back to the base submissions collection. Production deployments should nevertheless apply the current migration so the view exposes the complete metadata.
+16 -4
View File
@@ -115,6 +115,7 @@ const config: Config = {
label: "Blog",
position: "left",
to: "/blog",
className: "navbar-item--secondary",
},
{
label: "Workshop",
@@ -125,16 +126,19 @@ const config: Config = {
label: "About us",
position: "left",
to: "/about-us",
className: "navbar-item--collapse-first",
},
{
to: "https://demo.homarr.dev/",
label: "Demo",
position: "right",
className: "navbar-item--secondary",
},
{
to: "https://opencollective.com/homarr",
label: "💴 Donate",
position: "right",
className: "navbar-item--collapse-first",
},
{
type: "dropdown",
@@ -233,11 +237,19 @@ const config: Config = {
},
],
logo: {
alt: "Homarr Logo",
src: "img/logo.png",
height: 100,
alt: "Homarr home",
src: "img/logo.svg",
href: "/",
width: 86,
height: 58,
},
copyright: `<span class="copyright_text">Copyright © ${new Date().getFullYear()} Homarr<span> — <a href="/docs/community/license">License</a>`,
copyright: `
<div class="footer__brand-copy">
<strong class="footer__brand-name">Homarr</strong>
<span class="footer__brand-tagline">Your dashboard for the services you run.</span>
<span class="copyright_text">© ${new Date().getFullYear()} Homarr · <a href="/docs/community/license">License</a></span>
</div>
`,
},
prism: {
theme: prismThemes.github,
+10
View File
@@ -34,20 +34,30 @@
"@mdx-js/react": "^3.1.1",
"@nivo/line": "^0.99.0",
"@scalar/api-reference-react": "catalog:",
"@shadcn/react": "catalog:",
"@tabler/icons-react": "catalog:",
"@tailwindcss/postcss": "^4.3.2",
"class-variance-authority": "^0.7.1",
"clsx": "catalog:",
"cmdk": "catalog:",
"date-fns": "catalog:",
"dayjs": "catalog:",
"docusaurus-plugin-image-zoom": "^3.0.1",
"embla-carousel-react": "catalog:",
"input-otp": "catalog:",
"leader-line-new": "^1.1.9",
"next-themes": "catalog:",
"pocketbase": "catalog:",
"postcss": "^8.5.16",
"posthog-docusaurus": "^2.0.5",
"prism-react-renderer": "^2.4.1",
"random-word-slugs": "^0.1.7",
"react": "catalog:",
"react-day-picker": "catalog:",
"react-dom": "catalog:",
"react-resizable-panels": "catalog:",
"recharts": "3.8.0",
"sonner": "catalog:",
"swiper": "^14.0.5",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.2"
+60
View File
@@ -0,0 +1,60 @@
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion";
import { cn } from "@/lib/utils";
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
return <AccordionPrimitive.Root data-slot="accordion" className={cn("flex w-full flex-col", className)} {...props} />;
}
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
return (
<AccordionPrimitive.Item data-slot="accordion-item" className={cn("not-last:border-b", className)} {...props} />
);
}
function AccordionTrigger({ className, children, ...props }: AccordionPrimitive.Trigger.Props) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className,
)}
{...props}
>
{children}
<IconChevronDown
data-slot="accordion-trigger-icon"
className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
/>
<IconChevronUp
data-slot="accordion-trigger-icon"
className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline"
/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({ className, children, ...props }: AccordionPrimitive.Panel.Props) {
return (
<AccordionPrimitive.Panel
data-slot="accordion-content"
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props}
>
<div
className={cn(
"h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className,
)}
>
{children}
</div>
</AccordionPrimitive.Panel>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
@@ -0,0 +1,158 @@
"use client";
import * as React from "react";
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
}
function AlertDialogOverlay({ className, ...props }: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
size = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: "default" | "sm";
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className,
)}
{...props}
/>
);
}
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className,
)}
{...props}
/>
);
}
function AlertDialogTitle({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className,
)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className,
)}
{...props}
/>
);
}
function AlertDialogAction({ className, ...props }: React.ComponentProps<typeof Button>) {
return <Button data-slot="alert-dialog-action" className={cn(className)} {...props} />;
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: AlertDialogPrimitive.Close.Props & Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<AlertDialogPrimitive.Close
data-slot="alert-dialog-cancel"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
};
+40
View File
@@ -0,0 +1,40 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-3 [&>svg]:mt-0.5 [&>svg]:size-4",
{
variants: {
variant: {
default: "border-border bg-card text-card-foreground",
success:
"border-emerald-600/30 bg-emerald-500/10 text-emerald-800 dark:border-emerald-400/30 dark:text-emerald-300 [&_[data-slot=alert-description]]:text-emerald-800/85 dark:[&_[data-slot=alert-description]]:text-emerald-300/85",
destructive:
"border-destructive/30 bg-destructive/10 text-destructive [&_[data-slot=alert-description]]:text-destructive/85",
},
},
defaultVariants: { variant: "default" },
},
);
function Alert({ className, variant, ...props }: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return <div role="alert" data-slot="alert" className={cn(alertVariants({ variant }), className)} {...props} />;
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="alert-title" className={cn("col-start-2 font-medium leading-none", className)} {...props} />;
}
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn("col-start-2 text-sm leading-relaxed text-muted-foreground", className)}
{...props}
/>
);
}
export { Alert, AlertDescription, AlertTitle };
@@ -0,0 +1,18 @@
import { cn } from "@/lib/utils";
function AspectRatio({ ratio, className, ...props }: React.ComponentProps<"div"> & { ratio: number }) {
return (
<div
data-slot="aspect-ratio"
style={
{
"--ratio": ratio,
} as React.CSSProperties
}
className={cn("relative aspect-(--ratio)", className)}
{...props}
/>
);
}
export { AspectRatio };
+185
View File
@@ -0,0 +1,185 @@
import * as React from "react";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
const attachmentVariants = cva(
"group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-xl border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/50 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed",
{
variants: {
size: {
default:
"gap-2 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2",
sm: "gap-2.5 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5",
xs: "gap-1.5 rounded-lg text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1",
},
orientation: {
horizontal: "min-w-40 items-center",
vertical: "w-24 flex-col has-data-[slot=attachment-content]:w-30",
},
},
},
);
function Attachment({
className,
state = "done",
size = "default",
orientation = "horizontal",
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof attachmentVariants> & {
state?: "idle" | "uploading" | "processing" | "error" | "done";
}) {
return (
<div
data-slot="attachment"
data-state={state}
data-size={size}
data-orientation={orientation}
className={cn(attachmentVariants({ size, orientation }), className)}
{...props}
/>
);
}
const attachmentMediaVariants = cva(
"relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5",
{
variants: {
variant: {
icon: "",
image:
"opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover",
},
},
defaultVariants: {
variant: "icon",
},
},
);
function AttachmentMedia({
className,
variant = "icon",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof attachmentMediaVariants>) {
return (
<div
data-slot="attachment-media"
data-variant={variant}
className={cn(attachmentMediaVariants({ variant }), className)}
{...props}
/>
);
}
function AttachmentContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-content"
className={cn(
"max-w-full min-w-0 flex-1 leading-tight group-data-[orientation=vertical]/attachment:px-1",
className,
)}
{...props}
/>
);
}
function AttachmentTitle({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-title"
className={cn(
"block max-w-full min-w-0 truncate font-medium group-data-[state=processing]/attachment:shimmer group-data-[state=uploading]/attachment:shimmer",
className,
)}
{...props}
/>
);
}
function AttachmentDescription({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-description"
className={cn(
"mt-0.5 block min-w-0 truncate text-xs text-muted-foreground group-data-[state=error]/attachment:text-destructive/80",
"max-w-full",
className,
)}
{...props}
/>
);
}
function AttachmentActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-actions"
className={cn(
"relative z-20 flex shrink-0 items-center group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-3 group-data-[orientation=vertical]/attachment:right-3 group-data-[orientation=vertical]/attachment:gap-1",
className,
)}
{...props}
/>
);
}
function AttachmentAction({ className, variant, size = "icon-xs", ...props }: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="attachment-action"
variant={variant ?? "ghost"}
size={size}
className={cn(className)}
{...props}
/>
);
}
function AttachmentTrigger({ className, render, type, ...props }: useRender.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
type: render ? type : (type ?? "button"),
className: cn("absolute inset-0 z-10 outline-none", className),
},
props,
),
render,
state: {
slot: "attachment-trigger",
},
});
}
function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-group"
className={cn(
"flex min-w-0 scroll-fade-x snap-x snap-mandatory scroll-px-1 scrollbar-none gap-3 overflow-x-auto overscroll-x-contain py-1 *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start",
className,
)}
{...props}
/>
);
}
export {
Attachment,
AttachmentGroup,
AttachmentMedia,
AttachmentContent,
AttachmentTitle,
AttachmentDescription,
AttachmentActions,
AttachmentAction,
AttachmentTrigger,
};
+41
View File
@@ -0,0 +1,41 @@
"use client";
import * as React from "react";
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
import { cn } from "@/lib/utils";
function Avatar({ className, ...props }: AvatarPrimitive.Root.Props) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
{...props}
/>
);
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full object-cover", className)}
{...props}
/>
);
}
function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground",
className,
)}
{...props}
/>
);
}
export { Avatar, AvatarFallback, AvatarImage };
@@ -0,0 +1,92 @@
import * as React from "react";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cn } from "@/lib/utils";
import { IconChevronRight, IconDots } from "@tabler/icons-react";
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" className={cn(className)} {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn("flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground", className)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return <li data-slot="breadcrumb-item" className={cn("inline-flex items-center gap-1", className)} {...props} />;
}
function BreadcrumbLink({ className, render, ...props }: useRender.ComponentProps<"a">) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn("transition-colors hover:text-foreground", className),
},
props,
),
render,
state: {
slot: "breadcrumb-link",
},
});
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <IconChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-5 items-center justify-center [&>svg]:size-4", className)}
{...props}
>
<IconDots />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
+118
View File
@@ -0,0 +1,118 @@
import * as React from "react";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="bubble-group" className={cn("flex min-w-0 flex-col gap-2", className)} {...props} />;
}
const bubbleVariants = cva(
"group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",
{
variants: {
variant: {
default:
"*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80",
secondary:
"*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]",
muted:
"*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]",
tinted:
"*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",
outline:
"*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",
ghost:
"border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50",
destructive:
"*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Bubble({
variant = "default",
align = "start",
className,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof bubbleVariants> & {
align?: "start" | "end";
}) {
return (
<div
data-slot="bubble"
data-variant={variant}
data-align={align}
className={cn(bubbleVariants({ variant }), className)}
{...props}
/>
);
}
function BubbleContent({ className, render, ...props }: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50",
className,
),
},
props,
),
render,
state: {
slot: "bubble-content",
},
});
}
const bubbleReactionsVariants = cva(
"absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0",
{
variants: {
side: {
top: "top-0 -translate-y-3/4",
bottom: "bottom-0 translate-y-3/4",
},
align: {
start: "left-3",
end: "right-3",
},
},
defaultVariants: {
side: "bottom",
align: "end",
},
},
);
function BubbleReactions({
side = "bottom",
align = "end",
className,
...props
}: React.ComponentProps<"div"> & {
align?: "start" | "end";
side?: "top" | "bottom";
}) {
return (
<div
data-slot="bubble-reactions"
data-align={align}
data-side={side}
className={cn(bubbleReactionsVariants({ side, align }), className)}
{...props}
/>
);
}
export { BubbleGroup, Bubble, BubbleContent, BubbleReactions };
@@ -0,0 +1,78 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
const buttonGroupVariants = cva(
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
vertical:
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
},
},
defaultVariants: {
orientation: "horizontal",
},
},
);
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
);
}
function ButtonGroupText({ className, render, ...props }: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
),
},
props,
),
render,
state: {
slot: "button-group-text",
},
});
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
className,
)}
{...props}
/>
);
}
export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants };
+169
View File
@@ -0,0 +1,169 @@
import * as React from "react";
import { DayPicker, getDefaultClassNames, type DayButton, type Locale } from "react-day-picker";
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
import { IconChevronLeft, IconChevronRight, IconChevronDown } from "@tabler/icons-react";
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
locale,
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
locale={locale}
formatters={{
formatMonthDropdown: (date) => date.toLocaleString(locale?.code, { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn("relative flex flex-col gap-4 md:flex-row", defaultClassNames.months),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", defaultClassNames.nav),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_next,
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption,
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns,
),
dropdown_root: cn("relative rounded-(--cell-radius)", defaultClassNames.dropdown_root),
dropdown: cn("absolute inset-0 bg-popover opacity-0", defaultClassNames.dropdown),
caption_label: cn(
"font-medium select-none",
captionLayout === "label"
? "text-sm"
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label,
),
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
defaultClassNames.weekday,
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn("w-(--cell-size) select-none", defaultClassNames.week_number_header),
week_number: cn("text-[0.8rem] text-muted-foreground select-none", defaultClassNames.week_number),
day: cn(
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
defaultClassNames.day,
),
range_start: cn(
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
defaultClassNames.range_start,
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn(
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
defaultClassNames.range_end,
),
today: cn(
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
defaultClassNames.today,
),
outside: cn("text-muted-foreground aria-selected:text-muted-foreground", defaultClassNames.outside),
disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return <IconChevronLeft className={cn("size-4", className)} {...props} />;
}
if (orientation === "right") {
return <IconChevronRight className={cn("size-4", className)} {...props} />;
}
return <IconChevronDown className={cn("size-4", className)} {...props} />;
},
DayButton: ({ ...props }) => <CalendarDayButton locale={locale} {...props} />,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">{children}</div>
</td>
);
},
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({
className,
day,
modifiers,
locale,
...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={
modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className,
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };
+212
View File
@@ -0,0 +1,212 @@
"use client";
import * as React from "react";
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden" data-slot="carousel-content">
<div className={cn("flex", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", className)} {...props} />
</div>
);
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel();
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn("min-w-0 shrink-0 grow-0 basis-full", orientation === "horizontal" ? "pl-4" : "pt-4", className)}
{...props}
/>
);
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal" ? "inset-y-0 -left-12 my-auto" : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<IconChevronLeft />
<span className="sr-only">Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal" ? "inset-y-0 -right-12 my-auto" : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<IconChevronRight />
<span className="sr-only">Next slide</span>
</Button>
);
}
export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext, useCarousel };
+307
View File
@@ -0,0 +1,307 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import type { TooltipValueType } from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
const INITIAL_DIMENSION = { width: 320, height: 200 } as const;
type TooltipNameType = number | string;
export type ChartConfig = Record<
string,
{
label?: React.ReactNode;
icon?: React.ComponentType;
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> })
>;
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
initialDimension = INITIAL_DIMENSION,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
initialDimension?: {
width: number;
height: number;
};
}) {
const uniqueId = React.useId();
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer initialDimension={initialDimension}>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([, config]) => config.theme ?? config.color);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ?? itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
} & Omit<RechartsPrimitive.DefaultTooltipContentProps<TooltipValueType, TooltipNameType>, "accessibilityLayer">) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value = !labelKey && typeof label === "string" ? (config[label]?.label ?? label) : itemConfig?.label;
if (labelFormatter) {
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>;
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color ?? item.payload?.fill ?? item.color;
return (
<div
key={index}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn("shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)", {
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
})}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">{itemConfig?.label ?? item.name}</span>
</div>
{item.value != null && (
<span className="font-mono font-medium text-foreground tabular-nums">
{typeof item.value === "number" ? item.value.toLocaleString() : String(item.value)}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> & {
hideIcon?: boolean;
nameKey?: string;
} & RechartsPrimitive.DefaultLegendContentProps) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div className={cn("flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", className)}>
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={index}
className={cn("flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground")}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key];
}
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
import { cn } from "@/lib/utils";
import { IconCheck } from "@tabler/icons-react";
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<IconCheck />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };
@@ -0,0 +1,15 @@
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible";
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return <CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />;
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return <CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />;
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+261
View File
@@ -0,0 +1,261 @@
"use client";
import * as React from "react";
import { Combobox as ComboboxPrimitive } from "@base-ui/react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { IconChevronDown, IconX, IconCheck } from "@tabler/icons-react";
const Combobox = ComboboxPrimitive.Root;
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
}
function ComboboxTrigger({ className, children, ...props }: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<IconChevronDown className="pointer-events-none size-4 text-muted-foreground" />
</ComboboxPrimitive.Trigger>
);
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<IconX className="pointer-events-none" />
</ComboboxPrimitive.Clear>
);
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean;
showClear?: boolean;
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input render={<InputGroupInput disabled={disabled} />} {...props} />
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
render={<ComboboxTrigger />}
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
/>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
);
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<ComboboxPrimitive.Positioner.Props, "side" | "align" | "sideOffset" | "alignOffset" | "anchor">) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn(
"group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
);
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
className,
)}
{...props}
/>
);
}
function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />}
>
<IconCheck className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
);
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return <ComboboxPrimitive.Group data-slot="combobox-group" className={cn(className)} {...props} />;
}
function ComboboxLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
);
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return <ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />;
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
className,
)}
{...props}
/>
);
}
function ComboboxSeparator({ className, ...props }: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> & ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean;
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className,
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<IconX className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
);
}
function ComboboxChipsInput({ className, ...props }: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
);
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null);
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
};
+151
View File
@@ -0,0 +1,151 @@
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { cn } from "@/lib/utils";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group";
import { IconSearch, IconCheck } from "@tabler/icons-react";
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
children: React.ReactNode;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0", className)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
);
}
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn("w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50", className)}
{...props}
/>
<InputGroupAddon>
<IconSearch className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
);
}
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn("no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none", className)}
{...props}
/>
);
}
function CommandEmpty({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
);
}
function CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
);
}
function CommandSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
);
}
function CommandItem({ className, children, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className,
)}
{...props}
>
{children}
<IconCheck className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
);
}
function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className,
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
@@ -0,0 +1,235 @@
"use client";
import * as React from "react";
import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu";
import { cn } from "@/lib/utils";
import { IconChevronRight, IconCheck } from "@tabler/icons-react";
function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) {
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />;
}
function ContextMenuTrigger({ className, ...props }: ContextMenuPrimitive.Trigger.Props) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
className={cn("select-none", className)}
{...props}
/>
);
}
function ContextMenuContent({
className,
align = "start",
alignOffset = 4,
side = "right",
sideOffset = 0,
...props
}: ContextMenuPrimitive.Popup.Props &
Pick<ContextMenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<ContextMenuPrimitive.Popup
data-slot="context-menu-content"
className={cn(
"z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Positioner>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) {
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />;
}
function ContextMenuLabel({
className,
inset,
...props
}: ContextMenuPrimitive.GroupLabel.Props & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.GroupLabel
data-slot="context-menu-label"
data-inset={inset}
className={cn("px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7", className)}
{...props}
/>
);
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: ContextMenuPrimitive.Item.Props & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
className,
)}
{...props}
/>
);
}
function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) {
return <ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} />;
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: ContextMenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubmenuTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<IconChevronRight className="ml-auto" />
</ContextMenuPrimitive.SubmenuTrigger>
);
}
function ContextMenuSubContent({ ...props }: React.ComponentProps<typeof ContextMenuContent>) {
return <ContextMenuContent data-slot="context-menu-sub-content" className="shadow-lg" side="right" {...props} />;
}
function ContextMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: ContextMenuPrimitive.CheckboxItem.Props & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.CheckboxItemIndicator>
<IconCheck />
</ContextMenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioGroup({ ...props }: ContextMenuPrimitive.RadioGroup.Props) {
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />;
}
function ContextMenuRadioItem({
className,
children,
inset,
...props
}: ContextMenuPrimitive.RadioItem.Props & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.RadioItemIndicator>
<IconCheck />
</ContextMenuPrimitive.RadioItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuSeparator({ className, ...props }: ContextMenuPrimitive.Separator.Props) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
className,
)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};
@@ -0,0 +1 @@
export { DirectionProvider, useDirection } from "@base-ui/react/direction-provider";
+209
View File
@@ -0,0 +1,209 @@
"use client";
import * as React from "react";
import { Drawer as DrawerPrimitive } from "@base-ui/react/drawer";
import { cn } from "@/lib/utils";
type DrawerContextProps = {
hasSnapPoints: boolean;
modal: DrawerPrimitive.Root.Props["modal"];
showSwipeHandle: boolean;
swipeDirection: NonNullable<DrawerPrimitive.Root.Props["swipeDirection"]>;
};
const DrawerContext = React.createContext<DrawerContextProps | null>(null);
function useDrawer() {
const context = React.useContext(DrawerContext);
if (!context) {
throw new Error("useDrawer must be used within a Drawer.");
}
return context;
}
function Drawer({
modal = true,
showSwipeHandle = false,
snapPoints,
swipeDirection = "down",
...props
}: DrawerPrimitive.Root.Props & {
showSwipeHandle?: boolean;
}) {
const hasSnapPoints = snapPoints != null && snapPoints.length > 0;
const contextValue = React.useMemo(
() => ({ hasSnapPoints, modal, showSwipeHandle, swipeDirection }),
[hasSnapPoints, modal, showSwipeHandle, swipeDirection],
);
return (
<DrawerContext.Provider value={contextValue}>
<DrawerPrimitive.Root
data-slot="drawer"
modal={modal}
snapPoints={snapPoints}
swipeDirection={swipeDirection}
{...props}
/>
</DrawerContext.Provider>
);
}
function DrawerTrigger({ ...props }: DrawerPrimitive.Trigger.Props) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({ ...props }: DrawerPrimitive.Portal.Props) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({ ...props }: DrawerPrimitive.Close.Props) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({ className, ...props }: DrawerPrimitive.Backdrop.Props) {
return (
<DrawerPrimitive.Backdrop
data-slot="drawer-overlay"
className={cn(
"fixed inset-0 z-50 min-h-dvh bg-black/10 opacity-[max(var(--drawer-overlay-min-opacity,0),calc(1-var(--drawer-swipe-progress)))] transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] select-none data-ending-style:pointer-events-none data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-snap-points:[--drawer-overlay-min-opacity:0.5] data-starting-style:opacity-0 data-swiping:duration-0 supports-backdrop-filter:backdrop-blur-xs supports-[-webkit-touch-callout:none]:absolute",
className,
)}
{...props}
/>
);
}
function DrawerSwipeHandle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-swipe-handle"
aria-hidden="true"
className={cn(
"relative z-10 flex shrink-0 cursor-grab transition-opacity duration-200 group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 group-data-[swipe-axis=x]/drawer-popup:h-full group-data-[swipe-axis=x]/drawer-popup:w-3 group-data-[swipe-axis=x]/drawer-popup:items-center group-data-[swipe-axis=y]/drawer-popup:h-3 group-data-[swipe-axis=y]/drawer-popup:w-full group-data-[swipe-axis=y]/drawer-popup:justify-center group-data-[swipe-direction=down]/drawer-popup:items-end group-data-[swipe-direction=left]/drawer-popup:order-last group-data-[swipe-direction=left]/drawer-popup:justify-start group-data-[swipe-direction=right]/drawer-popup:justify-end group-data-[swipe-direction=up]/drawer-popup:order-last group-data-[swipe-direction=up]/drawer-popup:items-start after:block after:shrink-0 after:rounded-full after:bg-muted group-data-[swipe-axis=x]/drawer-popup:after:h-24 group-data-[swipe-axis=x]/drawer-popup:after:w-1 group-data-[swipe-axis=y]/drawer-popup:after:h-1 group-data-[swipe-axis=y]/drawer-popup:after:w-24 active:cursor-grabbing",
className,
)}
{...props}
/>
);
}
function DrawerContent({ className, children, ...props }: DrawerPrimitive.Popup.Props) {
const { hasSnapPoints, modal, showSwipeHandle, swipeDirection } = useDrawer();
const swipeAxis = swipeDirection === "down" || swipeDirection === "up" ? "y" : "x";
return (
<DrawerPortal data-slot="drawer-portal">
{modal === true && <DrawerOverlay data-snap-points={hasSnapPoints ? "" : undefined} />}
<DrawerPrimitive.Viewport
data-slot="drawer-viewport"
data-modal={modal}
className="pointer-events-none fixed inset-0 z-50 select-none data-[modal=true]:pointer-events-auto"
>
<DrawerPrimitive.Popup
data-slot="drawer-popup"
data-swipe-axis={swipeAxis}
data-snap-points={hasSnapPoints ? "" : undefined}
className={cn(
// Base.
"group/drawer-popup pointer-events-auto fixed z-50 m-(--drawer-inset,0px) flex h-(--drawer-content-height) max-h-(--drawer-content-max-height,none) min-h-0 w-(--drawer-content-width,auto) transform-[translate3d(var(--translate-x,0px),var(--translate-y,0px),0)_scale(var(--stack-scale))] flex-col bg-popover text-sm text-popover-foreground transition-[transform,height,opacity,filter] duration-450 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform outline-none select-none [interpolate-size:allow-keywords] data-[swipe-direction=down]:rounded-t-xl data-[swipe-direction=down]:border-t data-[swipe-direction=left]:rounded-r-xl data-[swipe-direction=left]:border-r data-[swipe-direction=right]:rounded-l-xl data-[swipe-direction=right]:border-l data-[swipe-direction=up]:rounded-b-xl data-[swipe-direction=up]:border-b",
// Nested.
"data-nested-drawer-open:overflow-hidden data-nested-drawer-open:brightness-95",
// Bleed.
"after:pointer-events-none after:absolute after:bg-(--drawer-bleed-background,var(--color-popover)) data-[swipe-axis=x]:after:inset-y-0 data-[swipe-axis=x]:after:w-(--bleed) data-[swipe-axis=y]:after:inset-x-0 data-[swipe-axis=y]:after:h-(--bleed) data-[swipe-direction=down]:after:top-full data-[swipe-direction=left]:after:right-full data-[swipe-direction=right]:after:left-full data-[swipe-direction=up]:after:bottom-full",
// Sizing.
"[--drawer-content-height:var(--drawer-height,auto)] data-[swipe-axis=x]:[--drawer-content-width:75%] data-[swipe-axis=y]:[--drawer-content-max-height:calc(100dvh-6rem)] data-[swipe-axis=y]:data-snap-points:[--drawer-content-height:100dvh] data-[swipe-axis=x]:sm:[--drawer-content-width:24rem]",
// Stack.
"[--bleed:3rem] [--peek:1rem] [--stack-height:var(--drawer-frontmost-height,var(--drawer-height,0px))] [--stack-peek-offset:max(0px,calc((var(--nested-drawers)-var(--stack-progress))*var(--peek)))] [--stack-progress:clamp(0,var(--drawer-swipe-progress),1)] [--stack-scale-base:max(0,calc(1-(var(--nested-drawers)*var(--stack-step))))] [--stack-scale:clamp(0,calc(var(--stack-scale-base)+(var(--stack-step)*var(--stack-progress))),1)] [--stack-shrink:calc(1-var(--stack-scale))] [--stack-step:0.05]",
// Transitions.
"data-ending-style:transform-(--closed-transform) data-ending-style:opacity-[0.9999] data-ending-style:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-nested-drawer-swiping:duration-0 data-ending-style:data-nested-drawer-swiping:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-starting-style:transform-(--closed-transform) data-swiping:duration-0 data-ending-style:data-swiping:duration-[calc(var(--drawer-swipe-strength)*400ms)]",
// Axis: y.
"data-[swipe-axis=y]:inset-x-0 data-[swipe-axis=y]:data-nested-drawer-open:h-(--stack-height)",
// Axis: x.
"data-[swipe-axis=x]:inset-y-0 data-[swipe-axis=x]:flex-row",
// Direction: down.
"data-[swipe-direction=down]:bottom-0 data-[swipe-direction=down]:origin-bottom data-[swipe-direction=down]:[--closed-transform:translate3d(0,calc(100%+var(--drawer-inset,0px)+2px),0)] data-[swipe-direction=down]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)-var(--stack-peek-offset)-(var(--stack-shrink)*var(--stack-height)))]",
// Direction: up.
"data-[swipe-direction=up]:top-0 data-[swipe-direction=up]:origin-top data-[swipe-direction=up]:[--closed-transform:translate3d(0,calc(-100%-var(--drawer-inset,0px)-2px),0)] data-[swipe-direction=up]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)+var(--stack-peek-offset)+(var(--stack-shrink)*var(--stack-height)))]",
// Direction: left.
"data-[swipe-direction=left]:left-0 data-[swipe-direction=left]:origin-left data-[swipe-direction=left]:[--closed-transform:translate3d(calc(-100%-var(--drawer-inset,0px)-2px),0,0)] data-[swipe-direction=left]:[--translate-x:calc(var(--drawer-swipe-movement-x)+var(--stack-peek-offset)+(var(--stack-shrink)*100%))]",
// Direction: right.
"data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:origin-right data-[swipe-direction=right]:[--closed-transform:translate3d(calc(100%+var(--drawer-inset,0px)+2px),0,0)] data-[swipe-direction=right]:[--translate-x:calc(var(--drawer-swipe-movement-x)-var(--stack-peek-offset)-(var(--stack-shrink)*100%))]",
className,
)}
{...props}
>
{showSwipeHandle && <DrawerSwipeHandle />}
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"flex min-h-0 flex-1 flex-col overflow-hidden overscroll-contain rounded-[inherit] transition-opacity duration-300 ease-[cubic-bezier(0.45,1.005,0,1.005)] select-text group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 group-data-swiping/drawer-popup:select-none",
)}
>
{children}
</DrawerPrimitive.Content>
</DrawerPrimitive.Popup>
</DrawerPrimitive.Viewport>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex shrink-0 flex-col gap-0.5 p-4 pb-0 group-data-[swipe-axis=y]/drawer-popup:text-center md:gap-0.5 md:text-left",
className,
)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex shrink-0 flex-col gap-2 p-4 pt-0", className)}
{...props}
/>
);
}
function DrawerTitle({ className, ...props }: DrawerPrimitive.Title.Props) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("font-heading text-base font-medium text-foreground", className)}
{...props}
/>
);
}
function DrawerDescription({ className, ...props }: DrawerPrimitive.Description.Props) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-sm text-balance text-muted-foreground", className)}
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerSwipeHandle,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};
@@ -0,0 +1,252 @@
import * as React from "react";
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { cn } from "@/lib/utils";
import { IconChevronRight, IconCheck } from "@tabler/icons-react";
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props & Pick<MenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
"z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
);
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn("px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7", className)}
{...props}
/>
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<IconChevronRight className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
);
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn(
"w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<IconCheck />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return <MenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<IconCheck />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
);
}
function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
+87
View File
@@ -0,0 +1,87 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
className,
)}
{...props}
/>
);
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="empty-header" className={cn("flex max-w-sm flex-col items-center gap-2", className)} {...props} />
);
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
},
},
);
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
);
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("font-heading text-sm font-medium tracking-tight", className)}
{...props}
/>
);
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className,
)}
{...props}
/>
);
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn("flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance", className)}
{...props}
/>
);
}
export { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia };
+218
View File
@@ -0,0 +1,218 @@
"use client";
import { useMemo } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className,
)}
{...props}
/>
);
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn("mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base", className)}
{...props}
/>
);
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className,
)}
{...props}
/>
);
}
const fieldVariants = cva("group/field flex w-full gap-2 data-[invalid=true]:text-destructive", {
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
});
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
);
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn("group/field-content flex flex-1 flex-col gap-0.5 leading-snug", className)}
{...props}
/>
);
}
function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className,
)}
{...props}
/>
);
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
className,
)}
{...props}
/>
);
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className,
)}
{...props}
/>
);
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode;
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn("relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2", className)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
);
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>;
}) {
const content = useMemo(() => {
if (children) {
return children;
}
if (!errors?.length) {
return null;
}
const uniqueErrors = [...new Map(errors.map((error) => [error?.message, error])).values()];
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message;
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map((error, index) => error?.message && <li key={index}>{error.message}</li>)}
</ul>
);
}, [children, errors]);
if (!content) {
return null;
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
);
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
};
@@ -0,0 +1,46 @@
"use client";
import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card";
import { cn } from "@/lib/utils";
function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) {
return <PreviewCardPrimitive.Root data-slot="hover-card" {...props} />;
}
function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) {
return <PreviewCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />;
}
function HoverCardContent({
className,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 4,
...props
}: PreviewCardPrimitive.Popup.Props &
Pick<PreviewCardPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
return (
<PreviewCardPrimitive.Portal data-slot="hover-card-portal">
<PreviewCardPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PreviewCardPrimitive.Popup
data-slot="hover-card-content"
className={cn(
"z-50 w-64 origin-(--transform-origin) rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
</PreviewCardPrimitive.Positioner>
</PreviewCardPrimitive.Portal>
);
}
export { HoverCard, HoverCardTrigger, HoverCardContent };
+130
View File
@@ -0,0 +1,130 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className,
)}
{...props}
/>
);
}
const inputGroupAddonVariants = cva(
"flex h-auto items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start": "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end": "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end": "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
},
);
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
{...props}
/>
);
}
const inputGroupButtonVariants = cva("flex items-center gap-2 text-sm shadow-none", {
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs": "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
});
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset";
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function InputGroupInput({ className, ...props }: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className,
)}
{...props}
/>
);
}
function InputGroupTextarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className,
)}
{...props}
/>
);
}
export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupInput, InputGroupTextarea };
+81
View File
@@ -0,0 +1,81 @@
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { cn } from "@/lib/utils";
import { IconMinus } from "@tabler/icons-react";
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn("cn-input-otp flex items-center has-disabled:opacity-50", containerClassName)}
spellCheck={false}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn(
"flex items-center rounded-lg has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"relative flex size-8 items-center justify-center border-y border-r border-input text-sm transition-all outline-none first:rounded-l-lg first:border-l last:rounded-r-lg aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-3 data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:bg-input/30 dark:data-[active=true]:aria-invalid:ring-destructive/40",
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-separator"
className="flex items-center [&_svg:not([class*='size-'])]:size-4"
role="separator"
{...props}
>
<IconMinus />
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
+179
View File
@@ -0,0 +1,179 @@
import * as React from "react";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn(
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
className,
)}
{...props}
/>
);
}
function ItemSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return <Separator data-slot="item-separator" orientation="horizontal" className={cn("my-2", className)} {...props} />;
}
const itemVariants = cva(
"group/item flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted",
{
variants: {
variant: {
default: "border-transparent",
outline: "border-border",
muted: "border-transparent bg-muted/50",
},
size: {
default: "gap-2.5 px-3 py-2.5",
sm: "gap-2.5 px-3 py-2.5",
xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Item({
className,
variant = "default",
size = "default",
render,
...props
}: useRender.ComponentProps<"div"> & VariantProps<typeof itemVariants>) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(itemVariants({ variant, size, className })),
},
props,
),
render,
state: {
slot: "item",
variant,
size,
},
});
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
{
variants: {
variant: {
default: "bg-transparent",
icon: "[&_svg:not([class*='size-'])]:size-4",
image:
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
},
);
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
);
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
className,
)}
{...props}
/>
);
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4",
className,
)}
{...props}
/>
);
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className,
)}
{...props}
/>
);
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="item-actions" className={cn("flex items-center gap-2", className)} {...props} />;
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn("flex basis-full items-center justify-between gap-2", className)}
{...props}
/>
);
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn("flex basis-full items-center justify-between gap-2", className)}
{...props}
/>
);
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
};
+20
View File
@@ -0,0 +1,20 @@
import { cn } from "@/lib/utils";
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
className,
)}
{...props}
/>
);
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return <kbd data-slot="kbd-group" className={cn("inline-flex items-center gap-1", className)} {...props} />;
}
export { Kbd, KbdGroup };
+22
View File
@@ -0,0 +1,22 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
// Generic primitive: consumers either provide htmlFor or wrap the associated control.
// oxlint-disable-next-line jsx-a11y/label-has-associated-control
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };
+68
View File
@@ -0,0 +1,68 @@
import * as React from "react";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const markerVariants = cva(
"group/marker relative flex min-h-4 w-full items-center gap-2 text-left text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [a]:underline [a]:underline-offset-3 [a]:hover:text-foreground",
{
variants: {
variant: {
default: "",
separator:
"before:mr-1 before:h-px before:min-w-0 before:flex-1 before:bg-border after:ml-1 after:h-px after:min-w-0 after:flex-1 after:bg-border",
border: "border-b border-border pb-2",
},
},
},
);
function Marker({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"div"> & VariantProps<typeof markerVariants>) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(markerVariants({ variant, className })),
},
props,
),
render,
state: {
slot: "marker",
variant,
},
});
}
function MarkerIcon({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="marker-icon"
aria-hidden="true"
className={cn("size-4 shrink-0 [&_svg:not([class*='size-'])]:size-4", className)}
{...props}
/>
);
}
function MarkerContent({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="marker-content"
className={cn(
"min-w-0 wrap-break-word group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className,
)}
{...props}
/>
);
}
export { Marker, MarkerIcon, MarkerContent, markerVariants };
+256
View File
@@ -0,0 +1,256 @@
import * as React from "react";
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { Menubar as MenubarPrimitive } from "@base-ui/react/menubar";
import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { IconCheck } from "@tabler/icons-react";
function Menubar({ className, ...props }: MenubarPrimitive.Props) {
return (
<MenubarPrimitive
data-slot="menubar"
className={cn("flex h-8 items-center gap-0.5 rounded-lg border p-[3px]", className)}
{...props}
/>
);
}
function MenubarMenu({ ...props }: React.ComponentProps<typeof DropdownMenu>) {
return <DropdownMenu data-slot="menubar-menu" {...props} />;
}
function MenubarGroup({ ...props }: React.ComponentProps<typeof DropdownMenuGroup>) {
return <DropdownMenuGroup data-slot="menubar-group" {...props} />;
}
function MenubarPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPortal>) {
return <DropdownMenuPortal data-slot="menubar-portal" {...props} />;
}
function MenubarTrigger({ className, ...props }: React.ComponentProps<typeof DropdownMenuTrigger>) {
return (
<DropdownMenuTrigger
data-slot="menubar-trigger"
className={cn(
"flex items-center rounded-sm px-1.5 py-[2px] text-sm font-medium outline-hidden select-none hover:bg-muted aria-expanded:bg-muted",
className,
)}
{...props}
/>
);
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"min-w-36 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
className,
)}
{...props}
/>
);
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuItem>) {
return (
<DropdownMenuItem
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/menubar-item gap-1.5 rounded-md px-1.5 py-1 text-sm focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive!",
className,
)}
{...props}
/>
);
}
function MenubarCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenuPrimitive.CheckboxItemIndicator>
<IconCheck />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
);
}
function MenubarRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuRadioGroup>) {
return <DropdownMenuRadioGroup data-slot="menubar-radio-group" {...props} />;
}
function MenubarRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.RadioItem
data-slot="menubar-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenuPrimitive.RadioItemIndicator>
<IconCheck />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
);
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuLabel> & {
inset?: boolean;
}) {
return (
<DropdownMenuLabel
data-slot="menubar-label"
data-inset={inset}
className={cn("px-1.5 py-1 text-sm font-medium data-inset:pl-7", className)}
{...props}
/>
);
}
function MenubarSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuSeparator>) {
return (
<DropdownMenuSeparator
data-slot="menubar-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function MenubarShortcut({ className, ...props }: React.ComponentProps<typeof DropdownMenuShortcut>) {
return (
<DropdownMenuShortcut
data-slot="menubar-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/menubar-item:text-accent-foreground",
className,
)}
{...props}
/>
);
}
function MenubarSub({ ...props }: React.ComponentProps<typeof DropdownMenuSub>) {
return <DropdownMenuSub data-slot="menubar-sub" {...props} />;
}
function MenubarSubTrigger({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuSubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuSubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"gap-1.5 rounded-md px-1.5 py-1 text-sm focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function MenubarSubContent({ className, ...props }: React.ComponentProps<typeof DropdownMenuSubContent>) {
return (
<DropdownMenuSubContent
data-slot="menubar-sub-content"
className={cn(
"min-w-32 rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
);
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
};
@@ -0,0 +1,117 @@
"use client";
import * as React from "react";
import {
MessageScroller as MessageScrollerPrimitive,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
} from "@shadcn/react/message-scroller";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { IconArrowDown } from "@tabler/icons-react";
function MessageScrollerProvider(props: React.ComponentProps<typeof MessageScrollerPrimitive.Provider>) {
return <MessageScrollerPrimitive.Provider {...props} />;
}
function MessageScroller({ className, ...props }: React.ComponentProps<typeof MessageScrollerPrimitive.Root>) {
return (
<MessageScrollerPrimitive.Root
data-slot="message-scroller"
className={cn("group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden", className)}
{...props}
/>
);
}
function MessageScrollerViewport({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Viewport>) {
return (
<MessageScrollerPrimitive.Viewport
data-slot="message-scroller-viewport"
className={cn(
"size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent",
className,
)}
{...props}
/>
);
}
function MessageScrollerContent({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Content>) {
return (
<MessageScrollerPrimitive.Content
data-slot="message-scroller-content"
className={cn("flex h-max min-h-full flex-col gap-6", className)}
{...props}
/>
);
}
function MessageScrollerItem({
className,
scrollAnchor = false,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Item>) {
return (
<MessageScrollerPrimitive.Item
data-slot="message-scroller-item"
scrollAnchor={scrollAnchor}
className={cn("min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]", className)}
{...props}
/>
);
}
function MessageScrollerButton({
direction = "end",
className,
children,
render,
variant = "secondary",
size = "icon-sm",
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Button> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<MessageScrollerPrimitive.Button
data-slot="message-scroller-button"
data-direction={direction}
data-variant={variant}
data-size={size}
direction={direction}
className={cn(
"absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180",
className,
)}
render={render ?? <Button variant={variant} size={size} />}
{...props}
>
{children ?? (
<>
<IconArrowDown />
<span className="sr-only">{direction === "end" ? "Scroll to end" : "Scroll to start"}</span>
</>
)}
</MessageScrollerPrimitive.Button>
);
}
export {
MessageScrollerProvider,
MessageScroller,
MessageScrollerViewport,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerButton,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
};
+75
View File
@@ -0,0 +1,75 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function MessageGroup({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="message-group" className={cn("flex min-w-0 flex-col gap-2", className)} {...props} />;
}
function Message({ className, align = "start", ...props }: React.ComponentProps<"div"> & { align?: "start" | "end" }) {
return (
<div
data-slot="message"
data-align={align}
className={cn(
"group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse",
className,
)}
{...props}
/>
);
}
function MessageAvatar({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-avatar"
className={cn(
"flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8",
className,
)}
{...props}
/>
);
}
function MessageContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-content"
className={cn(
"flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end",
className,
)}
{...props}
/>
);
}
function MessageHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-header"
className={cn(
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0",
className,
)}
{...props}
/>
);
}
function MessageFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-footer"
className={cn(
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end",
className,
)}
{...props}
/>
);
}
export { MessageGroup, Message, MessageAvatar, MessageContent, MessageFooter, MessageHeader };
@@ -0,0 +1,48 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { IconSelector } from "@tabler/icons-react";
type NativeSelectProps = Omit<React.ComponentProps<"select">, "size"> & {
size?: "sm" | "default";
};
function NativeSelect({ className, size = "default", ...props }: NativeSelectProps) {
return (
<div
className={cn("group/native-select relative w-fit has-[select:disabled]:opacity-50", className)}
data-slot="native-select-wrapper"
data-size={size}
>
<select
data-slot="native-select"
data-size={size}
className="h-8 w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
{...props}
/>
<IconSelector
className="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-muted-foreground select-none"
aria-hidden="true"
data-slot="native-select-icon"
/>
</div>
);
}
function NativeSelectOption({ className, ...props }: React.ComponentProps<"option">) {
return (
<option data-slot="native-select-option" className={cn("bg-[Canvas] text-[CanvasText]", className)} {...props} />
);
}
function NativeSelectOptGroup({ className, ...props }: React.ComponentProps<"optgroup">) {
return (
<optgroup
data-slot="native-select-optgroup"
className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props}
/>
);
}
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption };
@@ -0,0 +1,144 @@
import { NavigationMenu as NavigationMenuPrimitive } from "@base-ui/react/navigation-menu";
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { IconChevronDown } from "@tabler/icons-react";
function NavigationMenu({
align = "start",
className,
children,
...props
}: NavigationMenuPrimitive.Root.Props & Pick<NavigationMenuPrimitive.Positioner.Props, "align">) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
className={cn("group/navigation-menu relative flex max-w-max flex-1 items-center justify-center", className)}
{...props}
>
{children}
<NavigationMenuPositioner align={align} />
</NavigationMenuPrimitive.Root>
);
}
function NavigationMenuList({ className, ...props }: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn("group flex flex-1 list-none items-center justify-center gap-0", className)}
{...props}
/>
);
}
function NavigationMenuItem({ className, ...props }: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item data-slot="navigation-menu-item" className={cn("relative", className)} {...props} />
);
}
const navigationMenuTriggerStyle = cva(
"group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded-lg px-2.5 py-1.5 text-sm font-medium transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted data-open:bg-muted/50 data-open:hover:bg-muted data-open:focus:bg-muted",
);
function NavigationMenuTrigger({ className, children, ...props }: NavigationMenuPrimitive.Trigger.Props) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<IconChevronDown
className="relative top-px ml-1 size-3 transition duration-300 group-data-popup-open/navigation-menu-trigger:rotate-180 group-data-open/navigation-menu-trigger:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
);
}
function NavigationMenuContent({ className, ...props }: NavigationMenuPrimitive.Content.Props) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-ending-style:data-activation-direction=left:translate-x-[50%] data-ending-style:data-activation-direction=right:translate-x-[-50%] data-starting-style:data-activation-direction=left:translate-x-[-50%] data-starting-style:data-activation-direction=right:translate-x-[50%] h-full w-auto p-1 transition-[opacity,transform,translate] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[viewport=false]/navigation-menu:rounded-lg group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:ring-1 group-data-[viewport=false]/navigation-menu:ring-foreground/10 group-data-[viewport=false]/navigation-menu:duration-300 data-ending-style:opacity-0 data-starting-style:opacity-0 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none group-data-[viewport=false]/navigation-menu:data-open:animate-in group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95",
className,
)}
{...props}
/>
);
}
function NavigationMenuPositioner({
className,
side = "bottom",
sideOffset = 8,
align = "start",
alignOffset = 0,
...props
}: NavigationMenuPrimitive.Positioner.Props) {
return (
<NavigationMenuPrimitive.Portal>
<NavigationMenuPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
className={cn(
"isolate z-50 h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] data-instant:transition-none data-[side=bottom]:before:top-[-10px] data-[side=bottom]:before:right-0 data-[side=bottom]:before:left-0",
className,
)}
{...props}
>
<NavigationMenuPrimitive.Popup className="data-[ending-style]:easing-[ease] xs:w-(--popup-width) relative h-(--popup-height) w-(--popup-width) origin-(--transform-origin) rounded-lg bg-popover text-popover-foreground shadow ring-1 ring-foreground/10 transition-[opacity,transform,width,height,scale,translate] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] outline-none data-ending-style:scale-90 data-ending-style:opacity-0 data-ending-style:duration-150 data-starting-style:scale-90 data-starting-style:opacity-0">
<NavigationMenuPrimitive.Viewport className="relative size-full overflow-hidden" />
</NavigationMenuPrimitive.Popup>
</NavigationMenuPrimitive.Positioner>
</NavigationMenuPrimitive.Portal>
);
}
function NavigationMenuLink({ className, ...props }: NavigationMenuPrimitive.Link.Props) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"flex items-center gap-2 rounded-lg p-2 text-sm transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 in-data-[slot=navigation-menu-content]:rounded-md data-active:bg-muted/50 data-active:hover:bg-muted data-active:focus:bg-muted [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.Icon>) {
return (
<NavigationMenuPrimitive.Icon
data-slot="navigation-menu-indicator"
className={cn(
"top-full z-1 flex h-1.5 items-end justify-center overflow-hidden data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:animate-in data-[state=visible]:fade-in",
className,
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Icon>
);
}
export {
NavigationMenu,
NavigationMenuContent,
NavigationMenuIndicator,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
NavigationMenuPositioner,
};
@@ -0,0 +1,96 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { IconChevronLeft, IconChevronRight, IconDots } from "@tabler/icons-react";
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
}
function PaginationContent({ className, ...props }: React.ComponentProps<"ul">) {
return <ul data-slot="pagination-content" className={cn("flex items-center gap-0.5", className)} {...props} />;
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">;
function PaginationLink({ className, isActive, size = "icon", ...props }: PaginationLinkProps) {
return (
<Button
variant={isActive ? "outline" : "ghost"}
size={size}
className={cn(className)}
nativeButton={false}
render={
// Base UI moves Button children into this render element at runtime.
// oxlint-disable-next-line jsx-a11y/anchor-has-content
<a aria-current={isActive ? "page" : undefined} data-slot="pagination-link" data-active={isActive} {...props} />
}
/>
);
}
function PaginationPrevious({
className,
text = "Previous",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink aria-label="Go to previous page" size="default" className={cn("pl-1.5!", className)} {...props}>
<IconChevronLeft data-icon="inline-start" />
<span className="hidden sm:block">{text}</span>
</PaginationLink>
);
}
function PaginationNext({
className,
text = "Next",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink aria-label="Go to next page" size="default" className={cn("pr-1.5!", className)} {...props}>
<span className="hidden sm:block">{text}</span>
<IconChevronRight data-icon="inline-end" />
</PaginationLink>
);
}
function PaginationEllipsis({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
<IconDots />
<span className="sr-only">More pages</span>
</span>
);
}
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
};
+63
View File
@@ -0,0 +1,63 @@
import * as React from "react";
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
import { cn } from "@/lib/utils";
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<PopoverPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
);
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="popover-header" className={cn("flex flex-col gap-0.5 text-sm", className)} {...props} />;
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return <PopoverPrimitive.Title data-slot="popover-title" className={cn("font-medium", className)} {...props} />;
}
function PopoverDescription({ className, ...props }: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
);
}
export { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger };
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { Progress as ProgressPrimitive } from "@base-ui/react/progress";
import { cn } from "@/lib/utils";
function Progress({ className, children, value, ...props }: ProgressPrimitive.Root.Props) {
return (
<ProgressPrimitive.Root
value={value}
data-slot="progress"
className={cn("flex flex-wrap gap-3", className)}
{...props}
>
{children}
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</ProgressPrimitive.Root>
);
}
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
return (
<ProgressPrimitive.Track
className={cn("relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted", className)}
data-slot="progress-track"
{...props}
/>
);
}
function ProgressIndicator({ className, ...props }: ProgressPrimitive.Indicator.Props) {
return (
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn("h-full bg-primary transition-all", className)}
{...props}
/>
);
}
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
return (
<ProgressPrimitive.Label className={cn("text-sm font-medium", className)} data-slot="progress-label" {...props} />
);
}
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
return (
<ProgressPrimitive.Value
className={cn("ml-auto text-sm text-muted-foreground tabular-nums", className)}
data-slot="progress-value"
{...props}
/>
);
}
export { Progress, ProgressTrack, ProgressIndicator, ProgressLabel, ProgressValue };
@@ -0,0 +1,27 @@
import { Radio as RadioPrimitive } from "@base-ui/react/radio";
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group";
import { cn } from "@/lib/utils";
function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
return <RadioGroupPrimitive data-slot="radio-group" className={cn("grid w-full gap-2", className)} {...props} />;
}
function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
return (
<RadioPrimitive.Root
data-slot="radio-group-item"
className={cn(
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className,
)}
{...props}
>
<RadioPrimitive.Indicator data-slot="radio-group-indicator" className="flex size-4 items-center justify-center">
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
</RadioPrimitive.Indicator>
</RadioPrimitive.Root>
);
}
export { RadioGroup, RadioGroupItem };
+42
View File
@@ -0,0 +1,42 @@
"use client";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
function ResizablePanelGroup({ className, ...props }: ResizablePrimitive.GroupProps) {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn("flex h-full w-full aria-[orientation=vertical]:flex-col", className)}
{...props}
/>
);
}
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: ResizablePrimitive.SeparatorProps & {
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && <div className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-border" />}
</ResizablePrimitive.Separator>
);
}
export { ResizableHandle, ResizablePanel, ResizablePanelGroup };
@@ -0,0 +1,38 @@
import * as React from "react";
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area";
import { cn } from "@/lib/utils";
function ScrollArea({ className, children, ...props }: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root data-slot="scroll-area" className={cn("relative", className)} {...props}>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({ className, orientation = "vertical", ...props }: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className,
)}
{...props}
>
<ScrollAreaPrimitive.Thumb data-slot="scroll-area-thumb" className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.Scrollbar>
);
}
export { ScrollArea, ScrollBar };
+102
View File
@@ -0,0 +1,102 @@
import * as React from "react";
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { IconX } from "@tabler/icons-react";
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={<Button variant="ghost" className="absolute top-3 right-3" size="icon-sm" />}
>
<IconX />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sheet-header" className={cn("flex flex-col gap-0.5 p-4", className)} {...props} />;
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sheet-footer" className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />;
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-heading text-base font-medium text-foreground", className)}
{...props}
/>
);
}
function SheetDescription({ className, ...props }: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription };
+673
View File
@@ -0,0 +1,673 @@
import * as React from "react";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { IconLayoutSidebar } from "@tabler/icons-react";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn("group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar", className)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn("flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground", className)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<IconLayoutSidebar />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}
{...props}
/>
);
}
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
render,
...props
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className,
),
},
props,
),
render,
state: {
slot: "sidebar-group-label",
sidebar: "group-label",
},
});
}
function SidebarGroupAction({
className,
render,
...props
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className,
),
},
props,
),
render,
state: {
slot: "sidebar-group-action",
sidebar: "group-action",
},
});
}
function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function SidebarMenuButton({
render,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar();
const comp = useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
},
props,
),
render: !tooltip ? render : <TooltipTrigger render={render} />,
state: {
slot: "sidebar-menu-button",
sidebar: "menu-button",
size,
active: isActive,
},
});
if (!tooltip) {
return comp;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
{comp}
<TooltipContent side="right" align="center" hidden={state !== "collapsed" || isMobile} {...tooltip} />
</Tooltip>
);
}
function SidebarMenuAction({
className,
render,
showOnHover = false,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
showOnHover?: boolean;
}) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className,
),
},
props,
),
render,
state: {
slot: "sidebar-menu-action",
sidebar: "menu-action",
},
});
}
function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className,
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
});
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
render,
size = "md",
isActive = false,
className,
...props
}: useRender.ComponentProps<"a"> &
React.ComponentProps<"a"> & {
size?: "sm" | "md";
isActive?: boolean;
}) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className,
),
},
props,
),
render,
state: {
slot: "sidebar-menu-sub-button",
sidebar: "menu-sub-button",
size,
active: isActive,
},
});
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};
+7
View File
@@ -0,0 +1,7 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="skeleton" className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />;
}
export { Skeleton };
+41
View File
@@ -0,0 +1,41 @@
import { Slider as SliderPrimitive } from "@base-ui/react/slider";
import { cn } from "@/lib/utils";
function Slider({ className, defaultValue, value, min = 0, max = 100, ...props }: SliderPrimitive.Root.Props) {
const _values = Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max];
return (
<SliderPrimitive.Root
className={cn("data-horizontal:w-full data-vertical:h-full", className)}
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
thumbAlignment="edge"
{...props}
>
<SliderPrimitive.Control className="relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col">
<SliderPrimitive.Track
data-slot="slider-track"
className="relative grow overflow-hidden rounded-full bg-muted select-none data-horizontal:h-1 data-horizontal:w-full data-vertical:h-full data-vertical:w-1"
>
<SliderPrimitive.Indicator
data-slot="slider-range"
className="bg-primary select-none data-horizontal:h-full data-vertical:w-full"
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="relative block size-3 shrink-0 rounded-full border border-ring bg-white ring-ring/50 transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Control>
</SliderPrimitive.Root>
);
}
export { Slider };
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { useColorMode } from "@docusaurus/theme-common";
import { Toaster as Sonner, type ToasterProps } from "sonner";
import { IconCircleCheck, IconInfoCircle, IconAlertTriangle, IconAlertOctagon, IconLoader } from "@tabler/icons-react";
const Toaster = ({ ...props }: ToasterProps) => {
const { colorMode } = useColorMode();
return (
<Sonner
theme={colorMode}
className="toaster group"
icons={{
success: <IconCircleCheck className="size-4" />,
info: <IconInfoCircle className="size-4" />,
warning: <IconAlertTriangle className="size-4" />,
error: <IconAlertOctagon className="size-4" />,
loading: <IconLoader className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
);
};
export { Toaster };
+16
View File
@@ -0,0 +1,16 @@
import { cn } from "@/lib/utils";
import { IconLoader } from "@tabler/icons-react";
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<IconLoader
data-slot="spinner"
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
);
}
export { Spinner };
+30
View File
@@ -0,0 +1,30 @@
import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
import { cn } from "@/lib/utils";
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default";
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
);
}
export { Switch };
+75
View File
@@ -0,0 +1,75 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div data-slot="table-container" className="relative w-full overflow-x-auto">
<table data-slot="table" className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />;
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return <tbody data-slot="table-body" className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn("p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
);
}
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
return (
<caption data-slot="table-caption" className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
);
}
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
+69
View File
@@ -0,0 +1,69 @@
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
function Tabs({ className, orientation = "horizontal", ...props }: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn("group/tabs flex gap-2 data-horizontal:flex-col", className)}
{...props}
/>
);
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
},
);
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className,
)}
{...props}
/>
);
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel data-slot="tabs-content" className={cn("flex-1 text-sm outline-none", className)} {...props} />
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
@@ -0,0 +1,87 @@
"use client";
import * as React from "react";
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group";
import { type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number;
orientation?: "horizontal" | "vertical";
}
>({
size: "default",
variant: "default",
spacing: 2,
orientation: "horizontal",
});
function ToggleGroup({
className,
variant,
size,
spacing = 2,
orientation = "horizontal",
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number;
orientation?: "horizontal" | "vertical";
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className,
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing, orientation }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
);
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext);
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className,
)}
{...props}
>
{children}
</TogglePrimitive>
);
}
export { ToggleGroup, ToggleGroupItem };
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default: "h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Toggle({
className,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return <TogglePrimitive data-slot="toggle" className={cn(toggleVariants({ variant, size, className }))} {...props} />;
}
export { Toggle, toggleVariants };
+52
View File
@@ -0,0 +1,52 @@
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
import { cn } from "@/lib/utils";
function TooltipProvider({ delay = 0, ...props }: TooltipPrimitive.Provider.Props) {
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delay={delay} {...props} />;
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<TooltipPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
@@ -1,78 +1,78 @@
import React, { useEffect, useState } from "react";
import { IconCheck, IconEdit, IconSend, IconTrash, IconX } from "@tabler/icons-react";
import React, { useCallback, useEffect, useState } from "react";
import { IconCheck, IconEdit, IconMessageCircle, IconRefresh, IconSend, IconTrash, IconX } from "@tabler/icons-react";
import type { WorkshopComment } from "@site/src/lib/pocketbase";
import { getPocketBase } from "@site/src/lib/pocketbase";
import { getWorkshopBackend } from "@site/src/lib/pocketbase";
import type { WorkshopUser } from "@homarr/workshop/schema";
import { errorMessage } from "@site/src/lib/utils";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { formatRelativeTime } from "./format";
const commentAuthorName = (comment: WorkshopComment) =>
comment.expand?.author?.displayName || comment.expand?.author?.githubUsername || "unknown";
interface CommentsSectionProps {
submissionId: string;
pb: ReturnType<typeof getPocketBase>;
currentUserId?: string;
currentUserIsAdmin?: boolean;
backend: ReturnType<typeof getWorkshopBackend>;
currentUser: WorkshopUser | null;
onRequireAuth: (action: string) => Promise<string | null>;
onError: (message: string) => void;
}
export const CommentsSection = ({
submissionId,
pb,
currentUserId,
currentUserIsAdmin,
onRequireAuth,
}: CommentsSectionProps) => {
const avatarFallback = (name: string) => name.trim().slice(0, 1).toUpperCase() || "?";
export const CommentsSection = ({ submissionId, backend, currentUser, onRequireAuth }: CommentsSectionProps) => {
const [rows, setRows] = useState<WorkshopComment[]>([]);
const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const [newComment, setNewComment] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editContent, setEditContent] = useState("");
const [mutationError, setMutationError] = useState<string | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const loadComments = useCallback(async () => {
setLoading(true);
setFetchError(false);
pb.collection("comments")
.getFullList<WorkshopComment>({
filter: pb.filter("submission = {:id}", { id: submissionId }),
sort: "-created",
expand: "author",
})
.then((data) => {
if (!cancelled) setRows(data);
})
.catch(() => {
if (!cancelled) setFetchError(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [pb, submissionId]);
setFetchError(null);
try {
setRows(await backend.listComments(submissionId));
} catch (caught) {
const message = errorMessage(caught, "Failed to load comments");
setFetchError(
/collection context/iu.test(message)
? "Comments are unavailable because this Workshop database has not applied the current comments migration."
: message,
);
} finally {
setLoading(false);
}
}, [backend, submissionId]);
useEffect(() => {
void loadComments();
}, [loadComments, reloadKey]);
const handleAdd = async () => {
const trimmed = newComment.trim();
if (!trimmed) return;
const userId = await onRequireAuth("comment");
if (!userId) return;
if (!trimmed || !(await onRequireAuth("comment"))) return;
try {
setMutationError(null);
const created = await pb
.collection("comments")
.create<WorkshopComment>({ submission: submissionId, content: trimmed, author: userId }, { expand: "author" });
setRows((prev) => [created, ...prev]);
const created = await backend.createComment(submissionId, trimmed);
setRows((previous) => [...previous, created]);
setNewComment("");
} catch (caught) {
setMutationError(errorMessage(caught, "Failed to post comment"));
@@ -84,10 +84,8 @@ export const CommentsSection = ({
if (!trimmed) return;
try {
setMutationError(null);
const updated = await pb
.collection("comments")
.update<WorkshopComment>(id, { content: trimmed }, { expand: "author" });
setRows((prev) => prev.map((comment) => (comment.id === id ? updated : comment)));
const updated = await backend.updateComment(id, trimmed);
setRows((previous) => previous.map((comment) => (comment.id === id ? updated : comment)));
setEditingId(null);
} catch (caught) {
setMutationError(errorMessage(caught, "Failed to update comment"));
@@ -95,14 +93,10 @@ export const CommentsSection = ({
};
const handleDelete = async (id: string) => {
if (confirmDeleteId !== id) {
setConfirmDeleteId(id);
return;
}
try {
setMutationError(null);
await pb.collection("comments").delete(id);
setRows((prev) => prev.filter((comment) => comment.id !== id));
await backend.deleteComment(id);
setRows((previous) => previous.filter((comment) => comment.id !== id));
setConfirmDeleteId(null);
} catch (caught) {
setMutationError(errorMessage(caught, "Failed to delete comment"));
@@ -110,117 +104,254 @@ export const CommentsSection = ({
};
return (
<div className="flex flex-col gap-3">
<h2 className="text-sm font-medium">Comments</h2>
<div className="flex items-center gap-2">
<Input
className="h-10 flex-1"
placeholder="Write a comment…"
aria-label="Write a comment"
value={newComment}
onChange={(event) => setNewComment(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void handleAdd();
}}
/>
<Button className="size-10" size="icon-lg" onClick={() => void handleAdd()} aria-label="Post comment">
<IconSend size={14} />
</Button>
<section aria-labelledby="workshop-comments-heading" className="space-y-5">
<div className="flex items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<IconMessageCircle size={18} className="text-muted-foreground" />
<h2 id="workshop-comments-heading" className="text-lg font-semibold">
Discussion
</h2>
{!loading && <Badge variant="secondary">{rows.length}</Badge>}
</div>
<p className="m-0 mt-1 text-sm text-muted-foreground">Questions, fixes, and feedback from the community.</p>
</div>
</div>
{mutationError && (
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{mutationError}
</p>
)}
{loading && (
<div className="space-y-2" aria-label="Loading comments">
{Array.from({ length: 2 }, (_, index) => (
<div key={index} className="animate-pulse rounded-lg bg-muted/30 px-3 py-3">
<div className="h-3 w-32 rounded bg-muted" />
<div className="mt-3 h-3 w-4/5 rounded bg-muted" />
</div>
))}
<div className="space-y-4" aria-label="Loading comments">
<div className="h-20 w-3/4 animate-pulse rounded-xl bg-muted" />
<div className="ml-auto h-20 w-2/3 animate-pulse rounded-xl bg-primary/10" />
</div>
)}
{!loading && fetchError && <p className="py-4 text-center text-sm text-destructive">Failed to load comments</p>}
{!loading && !fetchError && rows.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">No comments yet</p>
{!loading && fetchError && (
<Alert variant="destructive">
<IconMessageCircle />
<AlertTitle>Comments could not be loaded</AlertTitle>
<AlertDescription>{fetchError}</AlertDescription>
<Button
variant="ghost"
size="sm"
className="col-start-2 mt-2 w-fit text-destructive hover:bg-destructive/10"
onClick={() => setReloadKey((value) => value + 1)}
>
<IconRefresh size={14} /> Retry comments
</Button>
</Alert>
)}
<div className="space-y-2">
{rows.map((comment) => {
const canManage = currentUserId === comment.author || currentUserIsAdmin;
const isEditing = editingId === comment.id;
const author = comment.expand?.author;
const edited = comment.updated !== comment.created;
return (
<div key={comment.id} className="rounded-lg bg-muted/30 px-3 py-2 dark:bg-input/20">
<div className="flex items-center gap-2 text-xs">
<a
href={author?.githubProfileUrl || undefined}
target={author?.githubProfileUrl ? "_blank" : undefined}
rel="noreferrer"
className="inline-flex items-center gap-1.5 font-medium hover:underline"
>
{author?.avatarUrl && (
<img src={author.avatarUrl} alt="" className="size-5 rounded-full object-cover" />
)}
{commentAuthorName(comment)}
</a>
<span className="text-muted-foreground/60">{formatRelativeTime(comment.created)}</span>
{edited && <span className="text-muted-foreground/60">edited</span>}
{canManage && !isEditing && (
<div className="ml-auto flex gap-0.5">
<button
type="button"
className="rounded p-1 hover:bg-accent"
aria-label="Edit comment"
onClick={() => {
setEditingId(comment.id);
setEditContent(comment.content);
}}
{!loading && !fetchError && rows.length === 0 && (
<div className="rounded-xl bg-muted/40 px-4 py-8 text-center">
<IconMessageCircle size={24} className="mx-auto text-muted-foreground" />
<p className="m-0 mt-2 text-sm font-medium">Start the discussion</p>
<p className="m-0 mt-1 text-sm text-muted-foreground">
Share a question or something you learned using this submission.
</p>
</div>
)}
{!loading && !fetchError && rows.length > 0 && (
<div className="space-y-4">
{rows.map((comment) => {
const ownComment = currentUser?.id === comment.author;
const canDelete = ownComment || currentUser?.isAdmin === true;
const isEditing = editingId === comment.id;
const edited = comment.updated !== comment.created;
const authorName = ownComment ? (currentUser?.displayName ?? comment.authorName) : comment.authorName;
const avatarUrl = ownComment
? (currentUser?.avatarUrl ?? comment.authorAvatarUrl)
: comment.authorAvatarUrl;
const profileUrl = ownComment
? (currentUser?.githubProfileUrl ?? comment.authorGithubProfileUrl)
: comment.authorGithubProfileUrl;
return (
<article
key={comment.id}
className={ownComment ? "ml-auto w-full max-w-[44rem]" : "w-full max-w-[44rem]"}
>
<div className={ownComment ? "flex flex-row-reverse items-start gap-3" : "flex items-start gap-3"}>
<a
href={profileUrl || undefined}
target={profileUrl ? "_blank" : undefined}
rel="noreferrer"
aria-label={profileUrl ? `Open ${authorName}'s GitHub profile` : undefined}
className="mt-0.5 shrink-0 rounded-full outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
<Avatar className="size-9 ring-1 ring-border">
{avatarUrl && <AvatarImage src={avatarUrl} alt="" />}
<AvatarFallback>{avatarFallback(authorName)}</AvatarFallback>
</Avatar>
</a>
<div className={ownComment ? "flex min-w-0 flex-1 flex-col items-end" : "min-w-0 flex-1"}>
<div
className={
ownComment
? "mb-1.5 flex flex-wrap items-center justify-end gap-x-2 px-1"
: "mb-1.5 flex flex-wrap items-center gap-x-2 px-1"
}
>
<IconEdit size={12} />
</button>
<button
type="button"
className="rounded p-1 hover:bg-destructive/20 hover:text-destructive"
aria-label="Delete comment"
onClick={() => void handleDelete(comment.id)}
<a
href={profileUrl || undefined}
target={profileUrl ? "_blank" : undefined}
rel="noreferrer"
className="truncate text-sm font-semibold text-primary hover:underline"
>
{authorName}
</a>
{ownComment && (
<Badge variant="secondary" className="h-5 px-1.5 text-[0.6875rem]">
You
</Badge>
)}
<span className="text-xs text-muted-foreground">
{formatRelativeTime(comment.created)}
{edited && " · edited"}
</span>
</div>
<div
className={
ownComment
? "w-fit max-w-full rounded-xl rounded-tr-sm bg-primary px-3.5 py-2.5 text-primary-foreground"
: "w-fit max-w-full rounded-xl rounded-tl-sm border border-border/70 bg-muted/60 px-3.5 py-2.5 text-foreground"
}
>
<IconTrash size={12} />
{confirmDeleteId === comment.id && <span className="ml-1">Confirm?</span>}
</button>
{isEditing ? (
<div className="flex items-center gap-1.5">
<Input
className="min-w-48 bg-background text-foreground"
aria-label="Edit comment"
value={editContent}
onChange={(event) => setEditContent(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void handleUpdate(comment.id);
}}
/>
<Button
size="icon-sm"
variant="secondary"
aria-label="Save comment"
onClick={() => void handleUpdate(comment.id)}
>
<IconCheck size={12} />
</Button>
<Button
size="icon-sm"
variant="ghost"
aria-label="Cancel editing"
onClick={() => setEditingId(null)}
>
<IconX size={12} />
</Button>
</div>
) : (
<p className="m-0 break-words whitespace-pre-wrap text-[0.9375rem] leading-6">
{comment.content}
</p>
)}
</div>
{(ownComment || canDelete) && !isEditing && (
<div className={ownComment ? "mt-1 flex justify-end gap-1" : "mt-1 flex gap-1"}>
{ownComment && (
<Button
variant="ghost"
size="xs"
className="text-muted-foreground"
onClick={() => {
setEditingId(comment.id);
setEditContent(comment.content);
}}
>
<IconEdit /> Edit
</Button>
)}
{canDelete && (
<Button
variant="ghost"
size="xs"
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={() => setConfirmDeleteId(comment.id)}
>
<IconTrash /> Delete
</Button>
)}
</div>
)}
</div>
)}
</div>
{isEditing ? (
<div className="mt-2 flex items-center gap-1">
<Input
className="flex-1"
aria-label="Edit comment"
value={editContent}
onChange={(event) => setEditContent(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void handleUpdate(comment.id);
}}
/>
<Button size="icon-sm" aria-label="Save comment" onClick={() => void handleUpdate(comment.id)}>
<IconCheck size={12} />
</Button>
<Button variant="ghost" size="icon-sm" aria-label="Cancel editing" onClick={() => setEditingId(null)}>
<IconX size={12} />
</Button>
</div>
) : (
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">{comment.content}</p>
)}
</div>
);
})}
</article>
);
})}
</div>
)}
<div className="rounded-xl border border-border bg-muted/30 p-3">
<div className="flex items-start gap-2.5">
<Avatar className="mt-0.5 size-9 bg-background ring-1 ring-border">
{currentUser?.avatarUrl && <AvatarImage src={currentUser.avatarUrl} alt="" />}
<AvatarFallback className="bg-background">
{avatarFallback(currentUser?.displayName ?? "Guest")}
</AvatarFallback>
</Avatar>
<Textarea
className="min-h-16 flex-1 resize-none bg-background"
placeholder={currentUser ? "Add to the discussion…" : "Write a comment, you will be asked to sign in…"}
aria-label="Write a comment"
value={newComment}
maxLength={2000}
onChange={(event) => setNewComment(event.target.value)}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") void handleAdd();
}}
/>
<Button
size="icon-lg"
className="self-end"
disabled={!newComment.trim()}
onClick={() => void handleAdd()}
aria-label="Post comment"
>
<IconSend size={15} />
</Button>
</div>
<p className="m-0 mt-2 pl-12 text-xs text-muted-foreground">Press Ctrl or ⌘ + Enter to post.</p>
{mutationError && (
<Alert variant="destructive" className="mt-3">
<IconMessageCircle />
<AlertTitle>Comment was not saved</AlertTitle>
<AlertDescription>{mutationError}</AlertDescription>
</Alert>
)}
</div>
</div>
<AlertDialog
open={Boolean(confirmDeleteId)}
onOpenChange={(open) => {
if (!open) setConfirmDeleteId(null);
}}
>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Delete this comment?</AlertDialogTitle>
<AlertDialogDescription>This cannot be undone.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
if (confirmDeleteId) void handleDelete(confirmDeleteId);
}}
>
Delete comment
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
};
File diff suppressed because it is too large. Load diff
@@ -5,29 +5,40 @@ import { Highlight, themes } from "prism-react-renderer";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@site/src/lib/utils";
const prismThemes = { light: themes.github, dark: themes.dracula } as const;
const colorModeKeys = { dark: "dark", light: "light" } as const;
const dotClass = ["bg-white/40", "bg-white"];
export const DetailSkeleton = () => (
<div className="mx-auto max-w-4xl px-4 py-8 animate-pulse space-y-6">
<div className="h-4 w-24 rounded bg-muted" />
<div className="aspect-video w-full rounded-lg bg-muted" />
<div className="space-y-2">
<div className="h-7 w-2/3 rounded bg-muted" />
<div className="h-4 w-40 rounded bg-muted" />
<div className="mx-auto max-w-[90rem] space-y-6 px-4 py-8 sm:px-6 lg:px-8">
<Skeleton className="h-4 w-24" />
<div className="grid gap-10 xl:grid-cols-[minmax(0,1fr)_22rem]">
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-9 w-2/3" />
<Skeleton className="h-4 w-2/5" />
</div>
<Skeleton className="aspect-video w-full rounded-xl" />
<Skeleton className="h-96 rounded-xl" />
</div>
<div className="space-y-5">
<Skeleton className="h-40 rounded-xl" />
<Skeleton className="h-72 rounded-xl" />
</div>
</div>
<div className="h-24 rounded-lg bg-muted" />
<div className="h-48 rounded-lg bg-muted" />
</div>
);
@@ -35,40 +46,56 @@ export const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: stri
const [idx, setIdx] = useState(0);
return (
<div className="group/gallery relative w-full">
<div className="aspect-video overflow-hidden rounded-lg bg-muted">
<img className="h-full w-full object-cover" src={urls[idx]} alt={`${title} screenshot ${idx + 1}`} />
<div className="group/gallery w-full">
<div className="relative aspect-video overflow-hidden rounded-lg border border-border bg-muted">
<img className="h-full w-full object-contain" src={urls[idx]} alt={`${title} screenshot ${idx + 1}`} />
{urls.length > 1 && (
<>
<button
type="button"
className="absolute left-3 top-1/2 flex size-11 -translate-y-1/2 items-center justify-center rounded-lg bg-background/85 opacity-80 shadow-sm transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring sm:size-9"
onClick={() => setIdx((i) => (i - 1 + urls.length) % urls.length)}
aria-label="Previous screenshot"
>
<IconChevronLeft size={16} />
</button>
<button
type="button"
className="absolute right-3 top-1/2 flex size-11 -translate-y-1/2 items-center justify-center rounded-lg bg-background/85 opacity-80 shadow-sm transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring sm:size-9"
onClick={() => setIdx((i) => (i + 1) % urls.length)}
aria-label="Next screenshot"
>
<IconChevronRight size={16} />
</button>
</>
)}
</div>
{urls.length > 1 && (
<>
<button
type="button"
className="absolute left-3 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
onClick={() => setIdx((i) => (i - 1 + urls.length) % urls.length)}
aria-label="Previous screenshot"
>
<IconChevronLeft size={16} />
</button>
<button
type="button"
className="absolute right-3 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
onClick={() => setIdx((i) => (i + 1) % urls.length)}
aria-label="Next screenshot"
>
<IconChevronRight size={16} />
</button>
<div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 gap-1.5 rounded-full bg-black/50 px-2.5 py-1">
{urls.map((_, i) => (
<button
type="button"
key={i}
onClick={() => setIdx(i)}
aria-label={`Screenshot ${i + 1}`}
className={cn("size-2 rounded-full transition-all", dotClass[Number(i === idx)])}
/>
))}
<fieldset className="mt-3 min-w-0">
<legend className="sr-only">Screenshot previews</legend>
<div className="flex gap-2 overflow-x-auto pb-1">
{urls.map((url, i) => {
const selected = i === idx;
return (
<button
type="button"
key={url}
onClick={() => setIdx(i)}
aria-label={`Show screenshot ${i + 1}`}
aria-pressed={selected}
className={cn(
"aspect-video w-24 shrink-0 overflow-hidden rounded-md border-2 bg-muted transition-[border-color,opacity,box-shadow] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 sm:w-28",
selected
? "border-primary opacity-100 ring-2 ring-primary/20"
: "border-border opacity-65 hover:border-muted-foreground/60 hover:opacity-100",
)}
>
<img className="h-full w-full object-cover" src={url} alt="" loading={selected ? "eager" : "lazy"} />
</button>
);
})}
</div>
</>
</fieldset>
)}
</div>
);
@@ -98,43 +125,33 @@ export const CodeBlock = ({ content, language }: { content: string; language: st
);
};
export const DeleteConfirmButton = ({ onConfirm }: { onConfirm: () => void }) => {
const [opened, setOpened] = useState(false);
export const DeleteConfirmButton = ({ onConfirm, className }: { onConfirm: () => void; className?: string }) => {
return (
<>
<Button
variant="outline"
size="sm"
className="h-10 text-destructive hover:bg-destructive/10 sm:h-7"
onClick={() => setOpened(true)}
<AlertDialog>
<AlertDialogTrigger
render={
<Button variant="outline" size="sm" className={cn("text-destructive hover:bg-destructive/10", className)} />
}
>
<IconTrash size={14} /> Delete
</Button>
<Dialog open={opened} onOpenChange={setOpened}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete submission?</DialogTitle>
<DialogDescription>
This permanently removes the Workshop listing, screenshots, votes, reports, and comments.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpened(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
setOpened(false);
onConfirm();
}}
>
Delete submission
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<IconTrash />
</AlertDialogMedia>
<AlertDialogTitle>Delete submission?</AlertDialogTitle>
<AlertDialogDescription>
This permanently removes the Workshop listing, screenshots, votes, reports, and comments.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={onConfirm}>
Delete submission
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
@@ -0,0 +1,125 @@
import { useState } from "react";
import { IconPhoto, IconUpload, IconX } from "@tabler/icons-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export interface ScreenshotEditorItem {
id: string;
src: string;
badge?: string;
}
interface ScreenshotEditorProps {
items: ScreenshotEditorItem[];
onAdd: (files: FileList | File[]) => void;
onRemove: (id: string) => void;
disabled?: boolean;
description?: string;
max?: number;
}
const dropState = {
idle: { Icon: IconUpload, title: "Choose or drop images", iconClass: "bg-muted text-muted-foreground" },
active: { Icon: IconPhoto, title: "Drop images here", iconClass: "bg-primary text-primary-foreground" },
} as const;
export const ScreenshotEditor = ({
items,
onAdd,
onRemove,
disabled = false,
description = "Add screenshots to help others preview your submission.",
max = 5,
}: ScreenshotEditorProps) => {
const [dragOver, setDragOver] = useState(false);
const full = items.length >= max;
const { Icon, title, iconClass } = dropState[dragOver ? "active" : "idle"];
return (
<section className="flex flex-col gap-3" aria-labelledby="workshop-screenshots-heading">
<div className="flex items-start justify-between gap-4">
<div>
<h3 id="workshop-screenshots-heading" className="text-sm font-medium">
Screenshots
</h3>
<p className="mt-0.5 text-xs text-muted-foreground">{description}</p>
</div>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{items.length}/{max}
</span>
</div>
{items.length > 0 && (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
{items.map((item, index) => (
<div
key={item.id}
className="group relative aspect-[16/10] overflow-hidden rounded-lg border border-border bg-muted"
>
<img src={item.src} alt={`Screenshot ${index + 1}`} className="size-full object-cover" />
{item.badge && (
<span className="absolute bottom-1.5 left-1.5 rounded-md bg-background/90 px-1.5 py-0.5 text-[10px] font-medium text-foreground shadow-sm backdrop-blur-sm">
{item.badge}
</span>
)}
<Button
type="button"
variant="destructive"
size="icon-sm"
disabled={disabled}
onClick={() => onRemove(item.id)}
className="absolute top-1.5 right-1.5 opacity-90 shadow-sm transition-opacity group-hover:opacity-100"
aria-label={`Remove screenshot ${index + 1}`}
>
<IconX size={14} />
</Button>
</div>
))}
</div>
)}
{!full && (
// oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<label
onDragOver={(event) => {
event.preventDefault();
event.stopPropagation();
if (!disabled) setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(event) => {
event.preventDefault();
event.stopPropagation();
setDragOver(false);
if (!disabled) onAdd(event.dataTransfer.files);
}}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-lg border border-dashed border-border px-4 py-3 transition-colors hover:border-primary/50 hover:bg-primary/5",
dragOver && "border-primary bg-primary/10",
disabled && "pointer-events-none opacity-50",
)}
>
<span className={cn("flex size-9 shrink-0 items-center justify-center rounded-lg", iconClass)}>
<Icon size={18} />
</span>
<span className="min-w-0">
<span className="block text-sm font-medium">{title}</span>
<span className="block text-xs text-muted-foreground">PNG, JPG, or WebP · up to 5 MB each</span>
</span>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
disabled={disabled}
className="hidden"
onChange={(event) => {
if (event.target.files) onAdd(event.target.files);
event.currentTarget.value = "";
}}
/>
</label>
)}
</section>
);
};
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
IconAlertCircle,
IconBraces,
IconCheck,
IconChevronLeft,
@@ -7,9 +8,6 @@ import {
IconFileUpload,
IconLoader2,
IconPalette,
IconPhoto,
IconUpload,
IconX,
} from "@tabler/icons-react";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
@@ -17,6 +15,7 @@ import { validateSubmissionContent } from "@site/src/lib/workshop-schema";
import { CustomWidgetCodeInput } from "@/components/custom-widget-code";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -31,6 +30,7 @@ import { Textarea } from "@/components/ui/textarea";
import { cn, errorMessage } from "@/lib/utils";
import type { SubmitInput } from "./useWorkshop";
import { ScreenshotEditor } from "./ScreenshotEditor";
interface Props {
onClose: () => void;
@@ -41,7 +41,7 @@ const steps = ["Type", "Details", "Media"] as const;
const navDirection = [-1, 1];
const stepAnimClass = ["submit-step-from-left", "submit-step-from-right"];
const backLabels = ["Cancel", "Back"];
const submitLabels = ["Submit", "Submitting…"];
const submitLabels = ["Publish submission", "Publishing…"];
const connectorClass = ["bg-border", "bg-primary"];
const dropOverlayClass = ["pointer-events-none opacity-0", "opacity-100"];
@@ -246,9 +246,11 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => {
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs font-medium text-destructive">
{error}
</div>
<Alert variant="destructive">
<IconAlertCircle />
<AlertTitle>Submission needs attention</AlertTitle>
<AlertDescription className="whitespace-pre-wrap">{error}</AlertDescription>
</Alert>
)}
<div className="relative min-h-[280px] overflow-hidden">
@@ -268,7 +270,11 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => {
/>
)}
{step === 2 && (
<StepMedia previews={previews} addFiles={addImageFiles} removeScreenshot={removeScreenshot} />
<ScreenshotEditor
items={previews.map((src) => ({ id: src, src }))}
onAdd={addImageFiles}
onRemove={(id) => removeScreenshot(previews.indexOf(id))}
/>
)}
</div>
</div>
@@ -489,89 +495,3 @@ const StepDetails = ({
</div>
</div>
);
const mediaDropState = {
idle: { Icon: IconUpload, title: "Drag & drop or click to upload", iconClass: "bg-muted text-muted-foreground" },
active: { Icon: IconPhoto, title: "Drop images here", iconClass: "bg-primary text-primary-foreground" },
} as const;
const StepMedia = ({
previews,
addFiles,
removeScreenshot,
}: {
previews: string[];
addFiles: (files: FileList | File[]) => void;
removeScreenshot: (idx: number) => void;
}) => {
const [dragOver, setDragOver] = useState(false);
const { Icon: DropIcon, title: dropTitle, iconClass: dropIconClass } = mediaDropState[dragOver ? "active" : "idle"];
return (
<div className="flex flex-col gap-4">
<div>
<span className="text-xs font-medium text-muted-foreground">Screenshots (optional, up to 5)</span>
<p className="mt-0.5 text-xs text-muted-foreground/70">
Add screenshots to help others preview your submission.
</p>
</div>
{/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<label
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
addFiles(e.dataTransfer.files);
}}
className={cn(
"flex cursor-pointer flex-col items-center gap-2 rounded-xl border-2 border-dashed border-border p-8 text-center transition-all hover:border-primary/50 hover:bg-primary/5",
dragOver && "border-primary bg-primary/10",
previews.length >= 5 && "pointer-events-none opacity-50",
)}
>
<div className={cn("flex size-10 items-center justify-center rounded-xl transition-colors", dropIconClass)}>
<DropIcon size={20} />
</div>
<div>
<p className="font-heading text-sm font-medium">{dropTitle}</p>
<p className="mt-0.5 text-xs text-muted-foreground">PNG, JPG, WebP up to 5 images</p>
</div>
<input
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files) addFiles(e.target.files);
}}
/>
</label>
{previews.length > 0 && (
<div className="max-h-44 overflow-y-auto pr-1">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
{previews.map((src, i) => (
<div key={i} className="group relative h-24 overflow-hidden rounded-xl border border-border bg-muted">
<img src={src} alt={`Preview ${i + 1}`} className="h-full w-full object-cover" />
<button
type="button"
onClick={() => removeScreenshot(i)}
className="absolute inset-0 flex items-center justify-center bg-black/60 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
aria-label={`Remove screenshot ${i + 1}`}
>
<IconX size={16} className="text-white" />
</button>
</div>
))}
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,118 @@
import React, { useEffect, useRef, useState } from "react";
import { IconBrandGithub, IconChevronDown, IconExternalLink, IconLogout } from "@tabler/icons-react";
import type { WorkshopUser } from "@homarr/workshop/schema";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const avatarFallback = (name: string) => name.trim().slice(0, 1).toUpperCase() || "?";
const githubProfileUrl = (user: WorkshopUser) =>
user.githubProfileUrl ||
(user.githubUsername ? `https://github.com/${encodeURIComponent(user.githubUsername)}` : undefined);
interface WorkshopAccountMenuProps {
user: WorkshopUser;
onSignOut: () => void;
}
export const WorkshopAccountMenu = ({ user, onSignOut }: WorkshopAccountMenuProps) => {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const profileUrl = githubProfileUrl(user);
const accountLabel = user.githubUsername ? `@${user.githubUsername}` : user.displayName || "Account";
useEffect(() => {
if (!open) return;
const closeOnOutsidePointer = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("pointerdown", closeOnOutsidePointer);
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("pointerdown", closeOnOutsidePointer);
document.removeEventListener("keydown", closeOnEscape);
};
}, [open]);
return (
<div ref={rootRef} className="relative">
<Button
variant="ghost"
size="sm"
className="h-10 gap-2 px-2 sm:h-8"
aria-label="Workshop account menu"
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
>
<Avatar className="size-6">
{user.avatarUrl && <AvatarImage src={user.avatarUrl} alt="" />}
<AvatarFallback className="text-[10px]">{avatarFallback(user.displayName)}</AvatarFallback>
</Avatar>
<span className="max-w-36 truncate">{accountLabel}</span>
<IconChevronDown
size={13}
aria-hidden="true"
className={cn("text-muted-foreground transition-transform duration-150", open && "rotate-180")}
/>
</Button>
{open && (
<div
role="menu"
aria-label="Workshop account"
className="absolute right-0 z-50 mt-2 w-64 rounded-lg border border-border bg-popover p-1.5 text-popover-foreground shadow-md"
>
<div className="flex items-center gap-3 px-2 py-2">
<Avatar className="size-9">
{user.avatarUrl && <AvatarImage src={user.avatarUrl} alt="" />}
<AvatarFallback>{avatarFallback(user.displayName)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{user.displayName}</p>
{user.githubUsername && <p className="truncate text-xs text-muted-foreground">@{user.githubUsername}</p>}
</div>
</div>
<div className="my-1 h-px bg-border" />
{profileUrl && (
<a
role="menuitem"
href={profileUrl}
target="_blank"
rel="noreferrer"
className="flex h-9 items-center gap-2 rounded-md px-2 text-sm text-foreground no-underline hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
onClick={() => setOpen(false)}
>
<IconBrandGithub size={16} aria-hidden="true" />
View GitHub profile
<IconExternalLink size={14} aria-hidden="true" className="ml-auto text-muted-foreground" />
</a>
)}
<button
type="button"
role="menuitem"
className="flex h-9 w-full items-center gap-2 rounded-md px-2 text-sm text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
onClick={() => {
setOpen(false);
onSignOut();
}}
>
<IconLogout size={16} aria-hidden="true" />
Sign out
</button>
</div>
)}
</div>
);
};
@@ -1,36 +1,43 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { WorkshopReport, WorkshopSubmissionSummary, WorkshopUser } from "@homarr/workshop";
import { WorkshopClient } from "@homarr/workshop";
import {
IconBrandGithub,
IconExternalLink,
IconFlag,
IconLoader2,
IconRefresh,
IconShield,
IconTrash,
} from "@tabler/icons-react";
const styles = {
shell: "marketplace mx-auto max-w-5xl px-4 pb-16 text-foreground",
hero: "py-8 [&_h1]:text-2xl [&_h1]:font-semibold [&_p]:mt-1 [&_p]:text-sm [&_p]:text-muted-foreground",
actions: "mt-4 flex flex-wrap items-center gap-2",
button: "rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground",
secondary: "rounded-md border border-border px-3 py-2 text-sm hover:bg-muted disabled:opacity-50",
danger: "rounded-md bg-destructive px-3 py-2 text-sm font-medium text-destructive-foreground disabled:opacity-50",
error: "mb-4 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive",
adminList: "space-y-4",
adminSummary: "flex items-center justify-between rounded-lg border border-border bg-card px-4 py-3 text-sm",
adminGroup: "rounded-xl border border-border bg-card p-5 shadow-sm",
adminHeading:
"flex flex-wrap items-start justify-between gap-3 [&_h2]:font-semibold [&_span]:text-sm [&_span]:text-muted-foreground",
reportCount: "rounded-full bg-destructive/10 px-2 py-1 !text-destructive",
meta: "mt-3 flex flex-wrap gap-4 text-xs text-muted-foreground",
moderationReport:
"mt-4 rounded-lg border border-destructive/20 bg-destructive/5 p-4 [&_strong]:block [&_span]:text-xs [&_span]:text-muted-foreground [&_p]:my-2 [&_p]:text-sm",
empty: "py-12 text-center text-sm text-muted-foreground",
} as const;
import { WorkshopBackend } from "@homarr/workshop/backend";
import type { WorkshopReport, WorkshopSubmissionSummary, WorkshopUser } from "@homarr/workshop/schema";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
const avatarFallback = (name: string) => name.trim().slice(0, 1).toUpperCase() || "?";
export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
const client = useMemo(() => {
const url = workshopUrl || (typeof window === "undefined" ? "" : window.location.origin);
return url ? new WorkshopClient(url) : new WorkshopClient();
return url ? new WorkshopBackend(url) : new WorkshopBackend();
}, [workshopUrl]);
const [user, setUser] = useState<WorkshopUser | null>(null);
const [reports, setReports] = useState<WorkshopReport[]>([]);
const [submissions, setSubmissions] = useState<WorkshopSubmissionSummary[]>([]);
const [busyId, setBusyId] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<WorkshopSubmissionSummary | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
@@ -60,6 +67,16 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
return unsubscribe;
}, [client, load]);
const signIn = async () => {
try {
const nextUser = await client.signInWithGitHub();
setUser(nextUser);
if (nextUser?.isAdmin) await load();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "GitHub sign-in failed");
}
};
const dismiss = async (reportId: string) => {
setBusyId(reportId);
try {
@@ -72,11 +89,12 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
}
};
const remove = async (submission: WorkshopSubmissionSummary) => {
if (!window.confirm(`Delete ${submission.title}? Installed local copies will remain.`)) return;
setBusyId(submission.id);
const remove = async () => {
if (!pendingDelete) return;
setBusyId(pendingDelete.id);
try {
await client.delete(submission.id);
await client.delete(pendingDelete.id);
setPendingDelete(null);
await load();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Unable to delete submission");
@@ -85,103 +103,217 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
}
};
const grouped = submissions.map((submission) => ({
submission,
reports: reports.filter((report) => report.submission === submission.id),
}));
const grouped = submissions
.map((submission) => ({
submission,
reports: reports.filter((report) => report.submission === submission.id),
}))
.sort((left, right) => Number(right.reports.length > 0) - Number(left.reports.length > 0));
return (
<main className={styles.shell}>
<section className={styles.hero}>
<h1>Workshop administration</h1>
<p>Review community reports and remove submissions. PocketBase rules enforce every moderation action.</p>
<div className={styles.actions}>
<a className={styles.secondary} href="/workshop">
<main className="marketplace mx-auto max-w-6xl px-4 pb-20 text-foreground sm:px-6">
<header className="flex flex-col gap-5 border-b border-border py-8 sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="mb-2 flex items-center gap-2 text-primary">
<IconShield size={18} />
<span className="text-sm font-medium">Restricted area</span>
</div>
<h1 className="text-3xl font-semibold tracking-tight">Workshop administration</h1>
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">
Review reports and remove unsafe or outdated submissions. PocketBase rules enforce every action.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" nativeButton={false} render={<a href="/workshop" aria-label="Back to Workshop" />}>
Back to Workshop
</a>
</Button>
{!user && (
<button
className={styles.button}
onClick={() =>
void client
.signInWithGitHub()
.then((nextUser) => {
setUser(nextUser);
if (nextUser?.isAdmin) void load();
})
.catch((cause: Error) => setError(cause.message))
}
>
Sign in with GitHub
</button>
<Button onClick={() => void signIn()}>
<IconBrandGithub size={15} /> Sign in with GitHub
</Button>
)}
{user?.isAdmin && (
<button className={styles.secondary} disabled={loading} onClick={() => void load()}>
{loading ? "Refreshing…" : "Refresh"}
</button>
<Button variant="outline" disabled={loading} onClick={() => void load()}>
<IconRefresh size={15} className={loading ? "animate-spin" : undefined} />
{loading ? "Refreshing" : "Refresh data"}
</Button>
)}
</div>
</section>
</header>
{user && !user.isAdmin && <div className={styles.error}>This Workshop account is not an administrator.</div>}
{error && <div className={styles.error}>{error}</div>}
{user?.isAdmin && (
<section className={styles.adminList} aria-busy={loading}>
<div className={styles.adminSummary}>
<strong>{reports.length} open reports</strong>
<span>{submissions.length} published submissions</span>
</div>
{grouped.map(({ submission, reports: submissionReports }) => (
<article key={submission.id} className={styles.adminGroup}>
<div className={styles.adminHeading}>
<div>
<h2>{submission.title}</h2>
<span>by {submission.authorName}</span>
</div>
<span className={submissionReports.length > 0 ? styles.reportCount : undefined}>
{submissionReports.length} {submissionReports.length === 1 ? "report" : "reports"}
</span>
</div>
<p>{submission.description || "No description"}</p>
<div className={styles.meta}>
<span>
▲ {submission.upvotes} · ▼ {submission.downvotes} · score {submission.score}
</span>
<span>Updated {new Date(submission.updated).toLocaleDateString()}</span>
</div>
{submissionReports.map((report) => (
<div key={report.id} className={styles.moderationReport}>
<div>
<strong>{report.category}</strong>
<span>Reported by {report.reporterName}</span>
</div>
<p>{report.explanation}</p>
<button
className={styles.secondary}
disabled={busyId === report.id}
onClick={() => void dismiss(report.id)}
>
{busyId === report.id ? "Dismissing…" : "Dismiss report"}
</button>
</div>
))}
<div className={styles.actions}>
<a className={styles.secondary} href={`/workshop/${submission.id}`}>
Inspect
</a>
<button
className={styles.danger}
disabled={busyId === submission.id}
onClick={() => void remove(submission)}
<div className="py-6">
{user && !user.isAdmin && (
<Alert variant="destructive" className="mb-5">
<IconShield />
<AlertTitle>Administrator access required</AlertTitle>
<AlertDescription>This GitHub account cannot access Workshop moderation.</AlertDescription>
</Alert>
)}
{error && (
<Alert variant="destructive" className="mb-5">
<IconFlag />
<AlertTitle>Workshop administration failed</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{user?.isAdmin && (
<section aria-busy={loading}>
{reports.length > 0 && (
<Alert variant="destructive" className="mb-5">
<IconFlag />
<AlertTitle>
{reports.length} open {reports.length === 1 ? "report needs" : "reports need"} review
</AlertTitle>
<AlertDescription>
Reported submissions are highlighted and shown first. Dismiss reports that need no action, or delete
the submission when it should no longer be available.
</AlertDescription>
</Alert>
)}
<div className="mb-5 flex flex-wrap items-center gap-2 text-sm">
<Badge variant={reports.length > 0 ? "destructive" : "secondary"}>
{reports.length} open {reports.length === 1 ? "report" : "reports"}
</Badge>
<span className="text-muted-foreground">
{submissions.length} published {submissions.length === 1 ? "submission" : "submissions"}
</span>
</div>
<div className="space-y-4">
{grouped.map(({ submission, reports: submissionReports }) => (
<Card
key={submission.id}
className={
submissionReports.length > 0
? "overflow-visible bg-destructive/[0.025] ring-destructive/50"
: "overflow-visible"
}
>
{busyId === submission.id ? "Deleting…" : "Delete submission"}
</button>
<CardHeader className="border-b border-border pb-4 sm:grid-cols-[1fr_auto]">
<div className="flex min-w-0 items-center gap-3">
<Avatar className="size-10">
{submission.authorAvatarUrl && <AvatarImage src={submission.authorAvatarUrl} alt="" />}
<AvatarFallback>{avatarFallback(submission.authorName)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<CardTitle className="truncate text-base">{submission.title}</CardTitle>
<a
href={submission.authorGithubProfileUrl || undefined}
target={submission.authorGithubProfileUrl ? "_blank" : undefined}
rel="noreferrer"
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
>
{submission.authorName}
</a>
</div>
</div>
<Badge variant={submissionReports.length > 0 ? "destructive" : "secondary"}>
{submissionReports.length} {submissionReports.length === 1 ? "report" : "reports"}
</Badge>
</CardHeader>
<CardContent className="space-y-5 pt-1">
<div>
<p className="text-sm leading-relaxed text-muted-foreground">
{submission.description || "No description was provided."}
</p>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>{submission.upvotes} upvotes</span>
<span>{submission.downvotes} downvotes</span>
<span>Score {submission.score}</span>
<span>Updated {new Date(submission.updated).toLocaleDateString()}</span>
</div>
</div>
{submissionReports.length > 0 && (
<div className="space-y-3">
{submissionReports.map((report) => (
<Alert key={report.id} variant="destructive">
<IconFlag />
<div className="col-start-2 min-w-0">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<AlertTitle className="col-auto capitalize">{report.category} report</AlertTitle>
<p className="m-0 mt-1 text-xs text-destructive/75">
Reported by {report.reporterName} · {new Date(report.created).toLocaleString()}
</p>
</div>
<Button
variant="outline"
size="sm"
className="border-destructive/30 bg-background/70 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={busyId === report.id}
onClick={() => void dismiss(report.id)}
>
{busyId === report.id && <IconLoader2 size={14} className="animate-spin" />}
{busyId === report.id ? "Dismissing" : "Dismiss report"}
</Button>
</div>
<AlertDescription className="col-auto mt-3 whitespace-pre-wrap">
{report.explanation}
</AlertDescription>
</div>
</Alert>
))}
</div>
)}
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-4">
<Button
variant="outline"
size="sm"
nativeButton={false}
render={<a href={`/workshop/${submission.id}`} aria-label={`Inspect ${submission.title}`} />}
>
<IconExternalLink size={14} /> Inspect submission
</Button>
<Button
variant={submissionReports.length > 0 ? "destructive" : "ghost"}
size="sm"
className={submissionReports.length > 0 ? undefined : "text-destructive hover:text-destructive"}
onClick={() => setPendingDelete(submission)}
>
<IconTrash size={14} /> Delete submission
</Button>
</div>
</CardContent>
</Card>
))}
</div>
{!loading && grouped.length === 0 && (
<div className="rounded-xl border border-dashed border-border py-16 text-center">
<IconShield size={28} className="mx-auto text-muted-foreground" />
<p className="mt-3 text-sm font-medium">No Workshop submissions</p>
<p className="mt-1 text-sm text-muted-foreground">Published submissions will appear here.</p>
</div>
</article>
))}
{!loading && grouped.length === 0 && <p className={styles.empty}>No Workshop submissions.</p>}
</section>
)}
)}
</section>
)}
</div>
<Dialog open={pendingDelete !== null} onOpenChange={(open) => !open && !busyId && setPendingDelete(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete {pendingDelete?.title}?</DialogTitle>
<DialogDescription>
This removes the Workshop listing and its reports, votes, screenshots, and comments. Installed local
copies remain. The submission author will be notified by email.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" disabled={busyId !== null} onClick={() => setPendingDelete(null)}>
Cancel
</Button>
<Button variant="destructive" disabled={busyId !== null} onClick={() => void remove()}>
{busyId && <IconLoader2 size={14} className="animate-spin" />}
{busyId ? "Deleting" : "Delete submission"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</main>
);
}
+136 -103
View File
@@ -8,7 +8,6 @@ import {
IconChevronLeft,
IconChevronRight,
IconEye,
IconLogout,
IconMessage,
IconPackage,
IconPlus,
@@ -17,9 +16,11 @@ import {
IconSearch,
} from "@tabler/icons-react";
import { getSubmissionFileUrl, type WorkshopSubmission } from "@site/src/lib/pocketbase";
import type { WorkshopSubmission } from "@site/src/lib/pocketbase";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -31,9 +32,16 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { cn } from "@/lib/utils";
import { SubmitForm } from "./SubmitForm";
import { WorkshopAccountMenu } from "./WorkshopAccountMenu";
import { formatRelativeTime } from "./format";
import type { SortKey, TypeFilter } from "./useWorkshop";
import { useWorkshop } from "./useWorkshop";
@@ -45,8 +53,7 @@ const typeIcons: Record<SubmissionType, React.ComponentType<{ size: number; clas
customWidget: IconPuzzle,
};
const typeBgColors: Record<SubmissionType, string> = { customCss: "bg-blue-500/5", customWidget: "bg-yellow-500/5" };
const filterActiveClass = "bg-background text-foreground shadow-sm";
const filterInactiveClass = "text-muted-foreground hover:text-foreground";
const cardMediaClassName = "aspect-video w-full overflow-hidden bg-muted";
const emptyState = {
none: { title: "No submissions yet", hint: "Be the first to share something." },
filtered: { title: "No matching results", hint: "Try adjusting your filters or search." },
@@ -69,6 +76,8 @@ const stopCardNavigation = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
};
const avatarFallback = (name: string) => name.trim().slice(0, 1).toUpperCase() || "?";
export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
const workshop = useWorkshop(workshopUrl);
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
@@ -122,17 +131,13 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
<Button className="h-10 sm:h-8" onClick={() => setShowSubmit(true)}>
<IconPlus size={14} /> Share yours
</Button>
<Button
variant="ghost"
size="sm"
className="h-10 sm:h-7"
onClick={() => {
<WorkshopAccountMenu
user={workshop.user}
onSignOut={() => {
setTypeFilter("all");
workshop.logout();
}}
>
<IconLogout size={14} /> {workshop.user?.displayName || workshop.user?.username || "Account"}
</Button>
/>
</>
) : (
<div className="flex flex-col items-start gap-1 sm:items-end">
@@ -147,69 +152,68 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
{workshop.submissions.length > 0 && (
<div className="mb-6 flex flex-col gap-3 rounded-lg border border-border bg-card p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative order-first w-full sm:order-last sm:w-60">
<IconSearch size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
className="h-11 w-full rounded-md border border-input bg-transparent pl-9 pr-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 sm:h-9"
placeholder="Search by title, description, or author"
<InputGroup className="order-first h-11 w-full sm:order-last sm:h-9 sm:w-64">
<InputGroupAddon>
<IconSearch size={16} />
</InputGroupAddon>
<InputGroupInput
placeholder="Search"
aria-label="Search submissions"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
</InputGroup>
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
<fieldset
className={cn(
"grid min-w-0 rounded-md border-0 bg-muted p-0.5 sm:flex",
workshop.user ? "grid-cols-4" : "grid-cols-3",
)}
<ToggleGroup
value={[typeFilter]}
onValueChange={(values) => values[0] && setTypeFilter(values[0] as TypeFilter)}
variant="default"
size="sm"
spacing={0}
className={cn("grid min-w-0 bg-muted/40 sm:flex", workshop.user ? "grid-cols-4" : "grid-cols-3")}
aria-label="Submission type"
>
{availableTypeFilters.map((opt) => (
<button
type="button"
<ToggleGroupItem
key={opt.value}
onClick={() => setTypeFilter(opt.value)}
aria-pressed={typeFilter === opt.value}
className={cn(
"inline-flex min-h-10 items-center justify-center gap-1.5 rounded px-3 text-sm font-medium transition-colors sm:min-h-8 sm:text-xs",
typeFilter === opt.value ? filterActiveClass : filterInactiveClass,
)}
value={opt.value}
className="min-h-10 min-w-0 gap-1.5 px-3 text-sm data-pressed:bg-background data-pressed:text-foreground data-pressed:shadow-sm sm:min-h-8 sm:text-xs"
>
{opt.dot && <span className={cn("size-2 rounded-full", opt.dot)} />}
{opt.label}
</button>
</ToggleGroupItem>
))}
</fieldset>
</ToggleGroup>
<div className="flex items-center gap-2">
<select
aria-label="Sort submissions"
className="h-10 min-w-0 flex-1 rounded-md border border-input bg-transparent px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 sm:h-8 sm:flex-none sm:text-xs dark:bg-input/30"
value={sort}
onChange={(event) => setSort(event.target.value as SortKey)}
>
{sortOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<button
type="button"
onClick={() => setIncludeOutdated((value) => !value)}
aria-pressed={!includeOutdated}
className={cn(
"h-10 shrink-0 rounded-md border border-input px-3 text-sm transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 sm:h-8 sm:text-xs",
includeOutdated ? "bg-transparent text-muted-foreground" : filterActiveClass,
)}
>
{includeOutdated ? "Hide outdated" : "Show outdated"}
</button>
<Select value={sort} onValueChange={(value) => setSort(value as SortKey)}>
<SelectTrigger aria-label="Sort submissions" className="h-10 min-w-40 flex-1 sm:h-8 sm:flex-none">
<SelectValue>{(value) => sortOptions.find((option) => option.value === value)?.label}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
{sortOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Label className="h-10 shrink-0 cursor-pointer rounded-lg border border-input bg-background px-3 text-xs text-muted-foreground sm:h-8">
<Switch
size="sm"
checked={!includeOutdated}
onCheckedChange={(checked) => setIncludeOutdated(!checked)}
/>
Current only
</Label>
</div>
</div>
</div>
)}
<p className="sr-only" aria-live="polite">
{workshop.loading ? "Loading Workshop listings" : `${visible.length} submissions shown`}
</p>
{initialLoadFailed && (
<div className="flex min-h-80 flex-col items-center justify-center gap-4 rounded-xl border border-border bg-card px-6 py-12 text-center">
<div className="flex size-11 items-center justify-center rounded-xl bg-destructive/10 text-destructive">
@@ -228,18 +232,22 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
)}
{workshop.error && workshop.submissions.length > 0 && (
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm">
<span className="text-destructive">Some Workshop data could not be refreshed.</span>
<Alert variant="destructive" className="mb-4 grid-cols-[auto_1fr_auto] items-center">
<IconAlertCircle />
<div>
<AlertTitle>Workshop data is out of date</AlertTitle>
<AlertDescription>The last refresh failed. Existing listings are still available.</AlertDescription>
</div>
<Button variant="ghost" size="sm" onClick={() => void workshop.refresh()}>
<IconRefresh size={14} /> Retry
</Button>
</div>
</Alert>
)}
{workshop.loading && workshop.submissions.length === 0 && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
<div className="grid auto-rows-fr grid-cols-1 items-stretch gap-4 lg:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }, (_, i) => (
<SkeletonCard key={i} hasImage={i % 3 === 0} />
<SkeletonCard key={i} />
))}
</div>
)}
@@ -272,12 +280,12 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
</div>
)}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
<div className="grid auto-rows-fr grid-cols-1 items-stretch gap-4 lg:grid-cols-2 xl:grid-cols-3">
{visible.map((submission) => (
<SubmissionCard
key={submission.id}
submission={submission}
pb={workshop.pb}
backend={workshop.backend}
userVote={workshop.votes[submission.id]?.value}
onVote={workshop.vote}
/>
@@ -298,24 +306,24 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
interface SubmissionCardProps {
submission: WorkshopSubmission;
pb: ReturnType<typeof useWorkshop>["pb"];
backend: ReturnType<typeof useWorkshop>["backend"];
userVote?: 1 | -1;
onVote: (submissionId: string, value: 1 | -1) => void;
}
const SubmissionCard = ({ submission, pb, userVote, onVote }: SubmissionCardProps) => {
const SubmissionCard = ({ submission, backend, userVote, onVote }: SubmissionCardProps) => {
const score = submission.upvotes - submission.downvotes;
const screenshotUrls = useMemo(
() => submission.screenshots?.map((f) => getSubmissionFileUrl(pb.baseURL, submission.id, f)) ?? [],
[submission, pb],
() => submission.screenshots?.map((file) => backend.fileUrl(submission.id, file)) ?? [],
[submission, backend],
);
const hasScreenshots = screenshotUrls.length > 0;
const TypeIcon = typeIcons[submission.type];
return (
<Card className="relative flex h-full flex-col">
<a href={`/workshop/${submission.id}/`} className="block">
<Card className="relative flex h-full min-w-0 w-full flex-col">
<a href={`/workshop/${submission.id}/`} className="block shrink-0">
{hasScreenshots ? (
<div className="relative">
<Badge
@@ -328,7 +336,7 @@ const SubmissionCard = ({ submission, pb, userVote, onVote }: SubmissionCardProp
<ScreenshotGallery urls={screenshotUrls} title={submission.title} />
</div>
) : (
<div className={cn("flex items-center justify-center py-6", typeBgColors[submission.type])}>
<div className={cn(cardMediaClassName, "flex items-center justify-center", typeBgColors[submission.type])}>
<TypeIcon size={32} className="text-muted-foreground/20" />
</div>
)}
@@ -346,19 +354,22 @@ const SubmissionCard = ({ submission, pb, userVote, onVote }: SubmissionCardProp
</Badge>
)}
</div>
<CardDescription className="text-xs">
<CardDescription className="flex min-w-0 items-center gap-1 text-xs">
<a
href={submission.authorGithubProfileUrl || undefined}
target={submission.authorGithubProfileUrl ? "_blank" : undefined}
rel="noreferrer"
className="inline-flex items-center gap-1.5 hover:text-foreground"
className="inline-flex min-w-0 items-center gap-1.5 hover:text-foreground"
>
{submission.authorAvatarUrl && (
<img src={submission.authorAvatarUrl} alt="" className="size-4 rounded-full object-cover" />
)}
{submission.authorName}
</a>{" "}
· v{submission.revision} · {formatRelativeTime(submission.created)}
<Avatar className="size-4">
{submission.authorAvatarUrl && <AvatarImage src={submission.authorAvatarUrl} alt="" />}
<AvatarFallback className="text-[9px]">{avatarFallback(submission.authorName)}</AvatarFallback>
</Avatar>
<span className="truncate">{submission.authorName}</span>
</a>
<span className="shrink-0 whitespace-nowrap">
· v{submission.revision} · {formatRelativeTime(submission.created)}
</span>
</CardDescription>
<CardAction className="col-start-auto row-span-1 row-start-auto self-auto justify-self-start sm:col-start-2 sm:row-span-2 sm:row-start-1 sm:self-start sm:justify-self-end">
<div className="flex items-center gap-px rounded-md border border-border bg-muted/40 p-px">
@@ -374,13 +385,7 @@ const SubmissionCard = ({ submission, pb, userVote, onVote }: SubmissionCardProp
>
<IconArrowBigUp size={14} />
</button>
<span
className={cn(
"min-w-5 text-center text-xs font-semibold tabular-nums",
score > 0 && "text-foreground",
score < 0 && "text-destructive",
)}
>
<span aria-live="polite" className="min-w-5 text-center text-xs font-semibold tabular-nums text-foreground">
{score}
</span>
<button
@@ -399,7 +404,7 @@ const SubmissionCard = ({ submission, pb, userVote, onVote }: SubmissionCardProp
</CardAction>
</CardHeader>
<CardContent className="flex-1">
<CardContent className="min-h-[4.5rem] flex-1 overflow-hidden">
{submission.outdated && (
<div className="mb-2 flex flex-wrap gap-1.5">
<Badge variant="secondary">Outdated</Badge>
@@ -427,36 +432,61 @@ const SubmissionCard = ({ submission, pb, userVote, onVote }: SubmissionCardProp
);
};
const SkeletonCard = ({ hasImage }: { hasImage: boolean }) => (
<Card>
{hasImage && <div className="aspect-video animate-pulse bg-muted" />}
const SkeletonCard = () => (
<Card className="h-full min-w-0 w-full">
<Skeleton className="aspect-video rounded-none" />
<CardHeader>
<div className="flex items-center gap-2">
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
<div className="h-4 w-12 animate-pulse rounded-full bg-muted" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-12 rounded-full" />
</div>
<div className="h-3 w-40 animate-pulse rounded bg-muted" />
<Skeleton className="h-3 w-40" />
</CardHeader>
<CardContent>
<CardContent className="min-h-[4.5rem]">
<div className="space-y-1.5">
<div className="h-3 w-full animate-pulse rounded bg-muted" />
<div className="h-3 w-2/3 animate-pulse rounded bg-muted" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-2/3" />
</div>
</CardContent>
<CardFooter className="justify-between gap-3 px-3 py-2.5">
<div className="h-3 w-20 animate-pulse rounded bg-muted" />
<div className="h-9 w-28 animate-pulse rounded bg-muted" />
<Skeleton className="h-3 w-20" />
<Skeleton className="h-9 w-28" />
</CardFooter>
</Card>
);
export const WorkshopListingFallback = () => (
<div className="mx-auto max-w-7xl px-4 pb-16" aria-busy="true" aria-label="Loading Workshop listings">
<div className="flex flex-col gap-5 py-8 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Workshop</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Discover community-made widgets and CSS. Review the source, then import it into Homarr.
</p>
</div>
<Skeleton className="h-10 w-40 sm:h-8" />
</div>
<div className="mb-6 flex flex-col gap-3 rounded-xl border border-border bg-card p-3 sm:flex-row sm:justify-between">
<Skeleton className="h-10 w-full sm:w-80" />
<Skeleton className="h-10 w-full sm:w-64" />
</div>
<div className="grid auto-rows-fr grid-cols-1 items-stretch gap-4 lg:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }, (_, index) => (
<SkeletonCard key={index} />
))}
</div>
</div>
);
const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) => {
const [idx, setIdx] = useState(0);
const dotClass = ["bg-white/40", "bg-white"];
return (
<div className="group/gallery relative">
<div className="aspect-video overflow-hidden bg-muted">
<div className={cardMediaClassName}>
<img
className="h-full w-full object-cover"
src={urls[idx]}
@@ -468,7 +498,7 @@ const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) =
<>
<button
type="button"
className="absolute left-2 top-1/2 flex size-6 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
className="absolute left-2 top-1/2 flex size-10 -translate-y-1/2 items-center justify-center rounded-lg bg-background/85 opacity-80 shadow-sm transition-opacity hover:opacity-100 sm:size-8"
onClick={(event) => {
stopCardNavigation(event);
setIdx((i) => (i - 1 + urls.length) % urls.length);
@@ -479,7 +509,7 @@ const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) =
</button>
<button
type="button"
className="absolute right-2 top-1/2 flex size-6 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
className="absolute right-2 top-1/2 flex size-10 -translate-y-1/2 items-center justify-center rounded-lg bg-background/85 opacity-80 shadow-sm transition-opacity hover:opacity-100 sm:size-8"
onClick={(event) => {
stopCardNavigation(event);
setIdx((i) => (i + 1) % urls.length);
@@ -488,7 +518,7 @@ const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) =
>
<IconChevronRight size={14} />
</button>
<div className="absolute bottom-2 left-1/2 flex -translate-x-1/2 gap-1 rounded-full bg-black/50 px-2 py-1">
<div className="absolute bottom-2 left-1/2 flex -translate-x-1/2 rounded-full bg-black/60 px-1 py-0.5">
{urls.map((_, i) => (
<button
type="button"
@@ -498,8 +528,11 @@ const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) =
setIdx(i);
}}
aria-label={`Screenshot ${i + 1}`}
className={cn("size-1.5 rounded-full transition-all", dotClass[Number(i === idx)])}
/>
aria-current={i === idx ? "true" : undefined}
className="flex size-8 items-center justify-center rounded-full focus-visible:ring-2 focus-visible:ring-white"
>
<span className={cn("size-1.5 rounded-full transition-colors", dotClass[Number(i === idx)])} />
</button>
))}
</div>
</>
+55 -109
View File
@@ -2,9 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import dayjs from "dayjs";
import type { WorkshopComment, WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase";
import { getPocketBase, parseWorkshopSubmission, signInWithGitHub } from "@site/src/lib/pocketbase";
import { getWorkshopBackend } from "@site/src/lib/pocketbase";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { schemaVersionByType, validateSubmissionContent } from "@site/src/lib/workshop-schema";
import { validateSubmissionContent } from "@site/src/lib/workshop-schema";
import { errorMessage, oauthErrorMessage } from "@site/src/lib/utils";
import { voteDelta } from "./workshop-utils";
@@ -35,63 +35,47 @@ const sorters: Record<SortKey, (a: WorkshopSubmission, b: WorkshopSubmission) =>
discussed: (a, b) => b.commentCount - a.commentCount || dayjs(b.created).valueOf() - dayjs(a.created).valueOf(),
};
const isNotFoundError = (error: unknown) =>
typeof error === "object" && error !== null && "status" in error && error.status === 404;
export const useWorkshop = (workshopUrl: string) => {
const pb = useMemo(() => getPocketBase(workshopUrl), [workshopUrl]);
const backend = useMemo(() => getWorkshopBackend(workshopUrl), [workshopUrl]);
const [submissions, setSubmissions] = useState<WorkshopSubmission[]>([]);
const [votes, setVotes] = useState<Record<string, WorkshopVote>>({});
const [user, setUser] = useState(pb.authStore.record);
const [user, setUser] = useState(backend.currentUser);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const votingIds = useRef(new Set<string>());
const refreshVotes = useCallback(async () => {
if (!pb.authStore.isValid || !pb.authStore.record) {
if (!backend.currentUser) {
setVotes({});
return;
}
const rows = await pb.collection("votes").getFullList<WorkshopVote>({
filter: pb.filter("user = {:id}", { id: pb.authStore.record.id }),
});
const rows = await backend.listVotesForCurrentUser();
setVotes(Object.fromEntries(rows.map((row) => [row.submission, row])));
}, [pb]);
}, [backend]);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const rows = await pb.collection("workshop_listings").getFullList<WorkshopSubmission>({ sort: "-created" });
const parsed = rows
.map(parseWorkshopSubmission)
.filter((submission): submission is WorkshopSubmission => submission !== null);
setSubmissions(parsed);
if (parsed.length !== rows.length)
setError(`${rows.length - parsed.length} Workshop submission could not be displayed`);
const rows = await backend.listAll({ sort: "newest" });
setSubmissions(rows.map((submission) => ({ ...submission, content: "" })));
await refreshVotes();
} catch (caught) {
setError(errorMessage(caught, "Failed to load the workshop"));
} finally {
setLoading(false);
}
}, [pb, refreshVotes]);
}, [backend, refreshVotes]);
useEffect(() => {
let cancelled = false;
const unsubscribe = pb.authStore.onChange(() => {
setUser(pb.authStore.record);
if (!pb.authStore.isValid) setVotes({});
const unsubscribe = backend.subscribeToAuth((nextUser) => {
setUser(nextUser);
if (!nextUser) setVotes({});
});
const load = async () => {
if (pb.authStore.isValid) {
try {
await pb.collection("users").authRefresh();
} catch {
pb.authStore.clear();
}
}
await backend.refreshAuth();
if (!cancelled) await refresh();
};
@@ -100,14 +84,14 @@ export const useWorkshop = (workshopUrl: string) => {
cancelled = true;
unsubscribe();
};
}, [pb, refresh]);
}, [backend, refresh]);
const ensureAuth = useCallback(async () => {
if (pb.authStore.isValid) return true;
await signInWithGitHub(pb);
if (backend.currentUser) return true;
await backend.signInWithGitHub();
await refreshVotes();
return pb.authStore.isValid;
}, [pb, refreshVotes]);
return backend.currentUser !== null;
}, [backend, refreshVotes]);
const requireUserId = useCallback(
async (action: string) => {
@@ -123,14 +107,14 @@ export const useWorkshop = (workshopUrl: string) => {
setError(`Sign in to ${action}`);
return null;
}
const userId = pb.authStore.record?.id;
const userId = backend.currentUser?.id;
if (!userId) {
setError(`Sign in to ${action}`);
return null;
}
return userId;
},
[pb, ensureAuth],
[backend, ensureAuth],
);
const login = useCallback(async () => {
@@ -174,30 +158,13 @@ export const useWorkshop = (workshopUrl: string) => {
);
try {
const existing = prev?.id ? prev : undefined;
if (!existing) {
const created = await pb
.collection("votes")
.create<WorkshopVote>({ submission: submissionId, value, user: userId });
setVotes((current) => ({ ...current, [submissionId]: created }));
} else if (isToggleOff) {
try {
await pb.collection("votes").delete(existing.id);
} catch (caught) {
if (!isNotFoundError(caught)) throw caught;
}
} else {
try {
const updated = await pb.collection("votes").update<WorkshopVote>(existing.id, { value });
setVotes((current) => ({ ...current, [submissionId]: updated }));
} catch (caught) {
if (!isNotFoundError(caught)) throw caught;
const created = await pb
.collection("votes")
.create<WorkshopVote>({ submission: submissionId, value, user: userId });
setVotes((current) => ({ ...current, [submissionId]: created }));
}
}
const saved = await backend.vote(submissionId, value);
setVotes((current) => {
const next = { ...current };
if (saved) next[submissionId] = saved;
else delete next[submissionId];
return next;
});
} catch (caught) {
setVotes((v) => {
const next = { ...v };
@@ -215,7 +182,7 @@ export const useWorkshop = (workshopUrl: string) => {
votingIds.current.delete(submissionId);
}
},
[pb, votes, requireUserId],
[backend, votes, requireUserId],
);
const report = useCallback(
@@ -225,26 +192,19 @@ export const useWorkshop = (workshopUrl: string) => {
explanation: string,
) => {
try {
const userId = await requireUserId("report");
if (!userId) return;
await pb.collection("reports").create({
submission: submissionId,
reporter: userId,
category,
explanation,
status: "open",
});
if (!(await requireUserId("report"))) return;
await backend.report(submissionId, category, explanation);
} catch (caught) {
setError(errorMessage(caught, "Failed to submit your report"));
}
},
[pb, requireUserId],
[backend, requireUserId],
);
const deleteSubmission = useCallback(
async (submissionId: string) => {
try {
await pb.collection("submissions").delete(submissionId);
await backend.delete(submissionId);
setSubmissions((prev) => prev.filter((s) => s.id !== submissionId));
return true;
} catch (caught) {
@@ -252,7 +212,7 @@ export const useWorkshop = (workshopUrl: string) => {
return false;
}
},
[pb],
[backend],
);
const submit = useCallback(
@@ -260,79 +220,65 @@ export const useWorkshop = (workshopUrl: string) => {
const validation = validateSubmissionContent(input.type, input.content);
if (!validation.success) throw new Error(validation.error);
if (!(await ensureAuth())) throw new Error("Sign in required to submit");
const userId = pb.authStore.record?.id;
if (!userId) throw new Error("Sign in required to submit");
const data = new FormData();
data.set("type", input.type);
data.set("title", input.title);
data.set("description", input.description);
data.set("widgetSchema", schemaVersionByType[input.type]);
data.set("content", input.content);
data.set("author", userId);
data.set("revision", "1");
data.set("changelog", input.changelog || "Initial publication");
data.set("outdated", "false");
for (const file of input.screenshots) data.append("screenshots", file);
await pb.collection("submissions").create(data);
await backend.create(
{
type: input.type,
title: input.title,
description: input.description,
content: input.content,
changelog: input.changelog || "Initial publication",
outdated: false,
},
input.screenshots,
);
await refresh();
return true;
},
[pb, ensureAuth, refresh],
[backend, ensureAuth, refresh],
);
const fetchComments = useCallback(
(submissionId: string) =>
pb.collection("comments").getFullList<WorkshopComment>({
filter: pb.filter("submission = {:id}", { id: submissionId }),
sort: "-created",
expand: "author",
}),
[pb],
);
const fetchComments = useCallback((submissionId: string) => backend.listComments(submissionId), [backend]);
const addComment = useCallback(
async (submissionId: string, content: string) => {
const userId = await requireUserId("comment");
if (!userId) return null;
try {
return await pb
.collection("comments")
.create<WorkshopComment>({ submission: submissionId, content, author: userId }, { expand: "author" });
return await backend.createComment(submissionId, content);
} catch (caught) {
setError(errorMessage(caught, "Failed to post comment"));
return null;
}
},
[pb, requireUserId],
[backend, requireUserId],
);
const updateComment = useCallback(
async (commentId: string, content: string) => {
try {
return await pb.collection("comments").update<WorkshopComment>(commentId, { content }, { expand: "author" });
return await backend.updateComment(commentId, content);
} catch (caught) {
setError(errorMessage(caught, "Failed to update comment"));
return null;
}
},
[pb],
[backend],
);
const deleteComment = useCallback(
async (commentId: string) => {
try {
await pb.collection("comments").delete(commentId);
await backend.deleteComment(commentId);
return true;
} catch (caught) {
setError(errorMessage(caught, "Failed to delete comment"));
return false;
}
},
[pb],
[backend],
);
const logout = useCallback(() => pb.authStore.clear(), [pb]);
const logout = useCallback(() => backend.signOut(), [backend]);
const comments = useMemo<CommentActions>(
() => ({
@@ -345,7 +291,7 @@ export const useWorkshop = (workshopUrl: string) => {
);
return {
pb,
backend,
submissions,
votes,
user,
+262 -70
View File
@@ -1,16 +1,16 @@
@import url("https://api.fontshare.com/v2/css?f[]=work-sans@2,1&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap");
@import "tailwindcss/theme" layer(theme);
@import "tailwindcss/utilities" layer(utilities);
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
/* shadcn preset b4gOK4dHc — base-nova style, mist base color */
/* tweakcn theme cmrw8cy5g000104jv0b88cnsb on shadcn base-nova */
@theme inline {
--spacing-128: 32rem;
--breakpoint-3xl: 1697px;
--font-heading: var(--font-sans);
--font-sans: "Work Sans", sans-serif;
--font-sans: "Inter", sans-serif;
--color-background: var(--background);
--color-foreground: var(--foreground);
@@ -51,6 +51,32 @@
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
--font-mono: "JetBrains Mono", monospace;
--font-serif: Georgia, serif;
--radius: 0.75rem;
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
--tracking-normal: var(--tracking-normal);
--shadow-2xl: var(--shadow-2xl);
--shadow-xl: var(--shadow-xl);
--shadow-lg: var(--shadow-lg);
--shadow-md: var(--shadow-md);
--shadow: var(--shadow);
--shadow-sm: var(--shadow-sm);
--shadow-xs: var(--shadow-xs);
--shadow-2xs: var(--shadow-2xs);
--spacing: var(--spacing);
--letter-spacing: var(--letter-spacing);
--shadow-offset-y: var(--shadow-offset-y);
--shadow-offset-x: var(--shadow-offset-x);
--shadow-spread: var(--shadow-spread);
--shadow-blur: var(--shadow-blur);
--shadow-opacity: var(--shadow-opacity);
--color-shadow-color: var(--shadow-color);
--color-destructive-foreground: var(--destructive-foreground);
}
:root {
@@ -69,7 +95,7 @@
--aa-muted-color-rgb: #fa5252 !important;
--aa-background-color-rgb: 200, 200, 200 !important;
--ifm-font-family-base: "Work Sans", sans-serif;
--ifm-font-family-base: var(--font-sans);
--ifm-color-content-inverse: var(--ifm-color-emphasis-100);
/* Bridge Docusaurus to shadcn theme */
@@ -78,38 +104,59 @@
--ifm-navbar-background-color: var(--card);
--ifm-footer-background-color: var(--card);
--radius: 0.45rem;
--radius: 0.75rem;
--background: oklch(1 0 0);
--foreground: oklch(0.148 0.004 228.8);
--foreground: oklch(0.2178 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.148 0.004 228.8);
--card-foreground: oklch(0.2178 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.148 0.004 228.8);
--primary: oklch(0.514 0.222 16.935);
--primary-foreground: oklch(0.969 0.015 12.422);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.963 0.002 197.1);
--muted-foreground: oklch(0.56 0.021 213.5);
--accent: oklch(0.963 0.002 197.1);
--accent-foreground: oklch(0.218 0.008 223.9);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.925 0.005 214.3);
--input: oklch(0.925 0.005 214.3);
--ring: oklch(0.723 0.014 214.4);
--chart-1: oklch(0.808 0.114 19.571);
--chart-2: oklch(0.637 0.237 25.331);
--chart-3: oklch(0.577 0.245 27.325);
--chart-4: oklch(0.505 0.213 27.518);
--chart-5: oklch(0.444 0.177 26.899);
--sidebar: oklch(0.987 0.002 197.1);
--sidebar-foreground: oklch(0.148 0.004 228.8);
--sidebar-primary: oklch(0.586 0.253 17.585);
--sidebar-primary-foreground: oklch(0.969 0.015 12.422);
--sidebar-accent: oklch(0.963 0.002 197.1);
--sidebar-accent-foreground: oklch(0.218 0.008 223.9);
--sidebar-border: oklch(0.925 0.005 214.3);
--sidebar-ring: oklch(0.723 0.014 214.4);
--popover-foreground: oklch(0.2178 0 0);
--primary: #fa5252;
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.2178 0 0);
--secondary-foreground: oklch(1 0 0);
--muted: oklch(0.9674 0.0013 286.3752);
--muted-foreground: oklch(0.5517 0.0138 285.9385);
--accent: oklch(0.955 0.025 25.6509);
--accent-foreground: oklch(0.48 0.18 25.6509);
--destructive: oklch(0.6368 0.2078 25.3313);
--border: oklch(0.9197 0.004 286.3202);
--input: oklch(0.9197 0.004 286.3202);
--ring: #fa5252;
--chart-1: oklch(0.6646 0.2222 25.6509);
--chart-2: oklch(0.2178 0 0);
--chart-3: oklch(0.6231 0.188 259.8145);
--chart-4: oklch(0.6959 0.1491 162.4796);
--chart-5: oklch(0.7686 0.1647 70.0804);
--sidebar: oklch(0.9791 0 0);
--sidebar-foreground: oklch(0.2178 0 0);
--sidebar-primary: #fa5252;
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.9551 0 0);
--sidebar-accent-foreground: oklch(0.6646 0.2222 25.6509);
--sidebar-border: oklch(0.9276 0.0058 264.5313);
--sidebar-ring: oklch(0.56 0.205 25.6509);
--destructive-foreground: oklch(0.9851 0 0);
--font-sans: "Inter", sans-serif;
--font-serif: Georgia, serif;
--font-mono: "JetBrains Mono", monospace;
--shadow-color: #000000;
--shadow-opacity: 0.1;
--shadow-blur: 10px;
--shadow-spread: -2px;
--shadow-offset-x: 0px;
--shadow-offset-y: 4px;
--letter-spacing: -0.02em;
--spacing: 0.25rem;
--shadow-2xs: 0px 4px 10px -2px hsl(0 0% 0% / 0.05);
--shadow-xs: 0px 4px 10px -2px hsl(0 0% 0% / 0.05);
--shadow-sm: 0px 4px 10px -2px hsl(0 0% 0% / 0.1), 0px 1px 2px -3px hsl(0 0% 0% / 0.1);
--shadow: 0px 4px 10px -2px hsl(0 0% 0% / 0.1), 0px 1px 2px -3px hsl(0 0% 0% / 0.1);
--shadow-md: 0px 4px 10px -2px hsl(0 0% 0% / 0.1), 0px 2px 4px -3px hsl(0 0% 0% / 0.1);
--shadow-lg: 0px 4px 10px -2px hsl(0 0% 0% / 0.1), 0px 4px 6px -3px hsl(0 0% 0% / 0.1);
--shadow-xl: 0px 4px 10px -2px hsl(0 0% 0% / 0.1), 0px 8px 10px -3px hsl(0 0% 0% / 0.1);
--shadow-2xl: 0px 4px 10px -2px hsl(0 0% 0% / 0.25);
--tracking-normal: -0.02em;
}
body {
@@ -133,45 +180,20 @@ body {
--aa-primary-color-rgb: #fa5252 !important;
--aa-muted-color-rgb: #fa5252 !important;
--aa-background-color-rgb: 24, 24, 24 !important;
/* Preserve Docusaurus's familiar charcoal canvas instead of a near-black app shell. */
--background: #1b1b1d;
--foreground: oklch(0.987 0.002 197.1);
--card: oklch(0.245 0.006 223.9);
--card-foreground: oklch(0.987 0.002 197.1);
--popover: oklch(0.245 0.006 223.9);
--popover-foreground: oklch(0.987 0.002 197.1);
--primary: oklch(0.455 0.188 13.697);
--primary-foreground: oklch(0.969 0.015 12.422);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.275 0.011 216.9);
--muted-foreground: oklch(0.723 0.014 214.4);
--accent: oklch(0.275 0.011 216.9);
--accent-foreground: oklch(0.987 0.002 197.1);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.56 0.021 213.5);
--chart-1: oklch(0.808 0.114 19.571);
--chart-2: oklch(0.637 0.237 25.331);
--chart-3: oklch(0.577 0.245 27.325);
--chart-4: oklch(0.505 0.213 27.518);
--chart-5: oklch(0.444 0.177 26.899);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.987 0.002 197.1);
--sidebar-primary: oklch(0.645 0.246 16.439);
--sidebar-primary-foreground: oklch(0.969 0.015 12.422);
--sidebar-accent: oklch(0.275 0.011 216.9);
--sidebar-accent-foreground: oklch(0.987 0.002 197.1);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.56 0.021 213.5);
}
/* Reset form elements that don't inherit by default (no preflight in Docusaurus).
Must live inside @layer theme so layer(utilities) wins in the cascade. */
@layer theme {
:where(main.marketplace, [data-slot="dialog-portal"]) :where(button, input, select, textarea) {
:where(
main.marketplace,
[data-slot="dialog-portal"],
[data-slot="alert-dialog-content"],
[data-slot="dropdown-menu-content"],
[data-slot="select-content"],
[data-slot="popover-content"]
)
:where(button, input, select, textarea) {
font-family: inherit;
font-size: inherit;
line-height: inherit;
@@ -182,11 +204,25 @@ body {
margin: 0;
}
:where(main.marketplace, [data-slot="dialog-portal"]) :where(button) {
:where(
main.marketplace,
[data-slot="dialog-portal"],
[data-slot="alert-dialog-content"],
[data-slot="dropdown-menu-content"],
[data-slot="select-content"],
[data-slot="popover-content"]
)
:where(button) {
cursor: pointer;
}
:where([data-slot="dialog-portal"]) {
:where(
[data-slot="dialog-portal"],
[data-slot="alert-dialog-content"],
[data-slot="dropdown-menu-content"],
[data-slot="select-content"],
[data-slot="popover-content"]
) {
font-family: var(--ifm-font-family-base);
color: var(--foreground);
}
@@ -223,8 +259,89 @@ body {
background-color: var(--homarr-secondary-bg) !important;
}
.footer__bottom {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 1.25rem;
width: min(100%, 38rem);
margin: 2.5rem auto 0;
padding-top: 2rem;
border-top: 1px solid color-mix(in srgb, var(--foreground) 14%, transparent);
text-align: left !important;
}
.footer__bottom .margin-bottom--sm {
margin: 0 !important;
}
.footer__logo {
display: block;
width: 5.375rem;
height: auto;
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
.footer__bottom a:hover .footer__logo {
transform: translateY(-2px);
}
.footer__copyright {
text-align: left;
}
.footer__brand-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.2rem;
}
.footer__brand-name {
color: var(--foreground);
font-size: 1.125rem;
line-height: 1.25;
letter-spacing: -0.025em;
}
.footer__brand-tagline {
color: color-mix(in srgb, var(--foreground) 78%, transparent);
font-size: 0.875rem;
line-height: 1.45;
}
.footer__copyright span.copyright_text {
opacity: 0.6;
color: color-mix(in srgb, var(--foreground) 72%, transparent);
font-size: 0.75rem;
}
@media (max-width: 640px) {
.footer__bottom {
grid-template-columns: 1fr;
justify-items: center;
gap: 0.875rem;
margin-top: 2rem;
padding-top: 1.5rem;
text-align: center !important;
}
.footer__copyright {
text-align: center;
}
.footer__brand-copy {
align-items: center;
}
}
@media (prefers-reduced-motion: reduce) {
.footer__logo {
transition: none;
}
.footer__bottom a:hover .footer__logo {
transform: none;
}
}
.medium-zoom-overlay {
@@ -255,6 +372,20 @@ body {
white-space: nowrap;
}
/* Keep the primary docs destinations readable before Docusaurus switches to its mobile menu. */
@media (min-width: 997px) and (max-width: 1399px) {
.navbar__inner > .navbar__items > .navbar-item--collapse-first,
.navbar__inner > .navbar__items > .navbar-item--secondary {
display: none;
}
}
@media (min-width: 997px) and (max-width: 1179px) {
.navbar__inner .navbar__title {
display: none;
}
}
/*.navbar__items.navbar__items--right .navbar__item.navbar__link:first-of-type {
background-color: var(--ifm-color-primary-light) !important;
border-radius: 30px;
@@ -375,3 +506,64 @@ html[data-theme="dark"] div#__docusaurus div[role="banner"] button svg {
opacity: 1;
}
}
[data-theme="dark"] {
--background: #1b1b1d;
--foreground: oklch(0.9791 0 0);
--card: oklch(0.245 0.006 223.9);
--card-foreground: oklch(0.9791 0 0);
--popover: oklch(0.245 0.006 223.9);
--popover-foreground: oklch(0.9791 0 0);
--primary: #fa5252;
--primary-foreground: oklch(0.15 0.025 25.6509);
--secondary: oklch(0.2739 0.0055 286.0326);
--secondary-foreground: oklch(0.9851 0 0);
--muted: oklch(0.2739 0.0055 286.0326);
--muted-foreground: oklch(0.7118 0.0129 286.0665);
--accent: oklch(0.31 0.055 25.6509);
--accent-foreground: oklch(0.86 0.1 25.6509);
--destructive: oklch(0.3958 0.1331 25.723);
--destructive-foreground: oklch(0.9705 0.0129 17.3797);
--border: oklch(0.2739 0.0055 286.0326);
--input: oklch(0.2739 0.0055 286.0326);
--ring: #fa5252;
--chart-1: oklch(0.6646 0.2222 25.6509);
--chart-2: oklch(0.4419 0.0146 285.7864);
--chart-3: oklch(0.7137 0.1434 254.624);
--chart-4: oklch(0.7729 0.1535 163.2231);
--chart-5: oklch(0.8369 0.1644 84.4286);
--radius: 0.75rem;
--sidebar: oklch(0.2178 0 0);
--sidebar-foreground: oklch(0.9791 0 0);
--sidebar-primary: #fa5252;
--sidebar-primary-foreground: oklch(0.15 0.025 25.6509);
--sidebar-accent: oklch(0.2739 0.0055 286.0326);
--sidebar-accent-foreground: oklch(0.6646 0.2222 25.6509);
--sidebar-border: oklch(0.2739 0.0055 286.0326);
--sidebar-ring: oklch(0.72 0.16 25.6509);
--font-sans: "Inter", sans-serif;
--font-serif: Georgia, serif;
--font-mono: "JetBrains Mono", monospace;
--shadow-color: #000000;
--shadow-opacity: 0.5;
--shadow-blur: 20px;
--shadow-spread: -5px;
--shadow-offset-x: 0px;
--shadow-offset-y: 10px;
--letter-spacing: -0.02em;
--spacing: 0.25rem;
--shadow-2xs: 0px 10px 20px -5px hsl(0 0% 0% / 0.25);
--shadow-xs: 0px 10px 20px -5px hsl(0 0% 0% / 0.25);
--shadow-sm: 0px 10px 20px -5px hsl(0 0% 0% / 0.5), 0px 1px 2px -6px hsl(0 0% 0% / 0.5);
--shadow: 0px 10px 20px -5px hsl(0 0% 0% / 0.5), 0px 1px 2px -6px hsl(0 0% 0% / 0.5);
--shadow-md: 0px 10px 20px -5px hsl(0 0% 0% / 0.5), 0px 2px 4px -6px hsl(0 0% 0% / 0.5);
--shadow-lg: 0px 10px 20px -5px hsl(0 0% 0% / 0.5), 0px 4px 6px -6px hsl(0 0% 0% / 0.5);
--shadow-xl: 0px 10px 20px -5px hsl(0 0% 0% / 0.5), 0px 8px 10px -6px hsl(0 0% 0% / 0.5);
--shadow-2xl: 0px 10px 20px -5px hsl(0 0% 0% / 1.25);
}
@layer base {
body {
letter-spacing: var(--tracking-normal);
}
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}
+12 -72
View File
@@ -1,81 +1,21 @@
import PocketBase from "pocketbase";
import { WorkshopBackend, type TypedWorkshopPocketBase } from "@homarr/workshop/backend";
import type { WorkshopSubmissionSummary } from "@homarr/workshop/schema";
import { workshopSubmissionSummarySchema, type WorkshopSubmissionSummary } from "@homarr/workshop/schema";
export type { WorkshopComment, WorkshopVote } from "@homarr/workshop/schema";
let client: PocketBase | undefined;
let clientUrl: string | undefined;
const backends = new Map<string, WorkshopBackend>();
export const getPocketBase = (url: string): PocketBase => {
if (!client || clientUrl !== url) {
client = new PocketBase(url);
clientUrl = url;
}
return client;
export const getWorkshopBackend = (url: string): WorkshopBackend => {
const normalizedUrl = url.replace(/\/$/u, "");
const existing = backends.get(normalizedUrl);
if (existing) return existing;
const backend = new WorkshopBackend(normalizedUrl);
backends.set(normalizedUrl, backend);
return backend;
};
export const getSubmissionFileUrl = (baseUrl: string, submissionId: string, filename: string) =>
`${baseUrl}/api/files/submissions/${submissionId}/${encodeURIComponent(filename)}`;
export const signInWithGitHub = async (pb: PocketBase) => {
const auth = await pb.collection("users").authWithOAuth2({
provider: "github",
createData: { displayName: "GitHub user" },
});
const meta = auth.meta as Record<string, unknown>;
const rawUser = meta.rawUser && typeof meta.rawUser === "object" ? (meta.rawUser as Record<string, unknown>) : {};
const githubUsername = String(meta.username || rawUser.login || "");
const displayName = String(meta.name || rawUser.name || githubUsername || "GitHub user").slice(0, 100);
const avatarUrl = String(meta.avatarUrl || meta.avatarURL || rawUser.avatar_url || "");
const updated = await pb.collection("users").update(auth.record.id, {
displayName,
avatarUrl: avatarUrl.startsWith("https://") ? avatarUrl : "",
githubUsername,
githubProfileUrl: githubUsername ? `https://github.com/${encodeURIComponent(githubUsername)}` : "",
});
pb.authStore.save(auth.token, updated);
return updated;
};
export const getPocketBase = (url: string): TypedWorkshopPocketBase => getWorkshopBackend(url).pocketBase;
export type WorkshopSubmission = WorkshopSubmissionSummary & {
collectionId?: string;
collectionName?: string;
content: string;
};
export const parseWorkshopSubmission = (value: unknown): WorkshopSubmission | null => {
const result = workshopSubmissionSummarySchema.safeParse(value);
if (!result.success) return null;
const record = value as Record<string, unknown>;
return {
...result.data,
collectionId: typeof record.collectionId === "string" ? record.collectionId : undefined,
collectionName: typeof record.collectionName === "string" ? record.collectionName : undefined,
content: typeof record.content === "string" ? record.content : "",
};
};
export interface WorkshopVote {
id: string;
submission: string;
user: string;
value: 1 | -1;
}
export interface WorkshopComment {
id: string;
submission: string;
author: string;
content: string;
created: string;
updated: string;
expand?: {
author?: {
id: string;
displayName?: string;
avatarUrl?: string;
githubUsername?: string;
githubProfileUrl?: string;
isAdmin?: boolean;
};
};
}
+2 -2
View File
@@ -3,7 +3,7 @@ import BrowserOnly from "@docusaurus/BrowserOnly";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import Layout from "@theme/Layout";
import { WorkshopApp } from "@site/src/components/workshop/WorkshopApp";
import { WorkshopApp, WorkshopListingFallback } from "@site/src/components/workshop/WorkshopApp";
import { WorkshopErrorBoundary } from "@site/src/components/workshop/WorkshopErrorBoundary";
export default function WorkshopPage() {
@@ -13,7 +13,7 @@ export default function WorkshopPage() {
return (
<Layout title="Workshop" description="Community custom CSS and custom widgets for Homarr">
<main className="marketplace bg-background text-foreground min-h-[80vh]">
<BrowserOnly fallback={<div style={{ minHeight: "50vh" }} />}>
<BrowserOnly fallback={<WorkshopListingFallback />}>
{() => (
<WorkshopErrorBoundary>
<WorkshopApp workshopUrl={configuredWorkshopUrl || window.location.origin} />
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"mantineVersion":"9.4.1","customWidgetVersion":"2.0.0","types":["\"\" | \"anonymous\" | \"use-credentials\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"auto\" | \"fixed\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"auto\" | \"all\" | \"fill\" | \"stroke\" | \"painted\" | \"visible\" | \"visibleFill\" | \"visiblePainted\" | \"visibleStroke\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"block\" | \"inline\" | \"both\" | \"horizontal\" | \"vertical\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"contain\" | \"cover\" | \"fill\" | \"scale-down\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"wrap\" | \"nowrap\" | \"wrap-reverse\"","\"12h\" | \"24h\"","\"activity\" | \"display-none\"","\"all\" | \"parent\" | \"child\"","\"all\" | \"segment\"","\"always\" | \"hover\" | \"not-hover\" | \"never\"","\"area\" | \"radial\" | \"scatter\"","\"auto\" | \"scroll\" | \"always\" | \"hover\" | \"never\"","\"auto-fit\" | \"auto-fill\"","\"block\" | \"grid\"","\"both\" | \"rightSection\" | \"clear\"","\"bottom\" | \"top\"","\"bottom\" | \"top\" | \"baseline\" | \"middle\"","\"bottom\" | \"top\" | \"middle\"","\"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\" | \"bottom-center\" | \"top-center\" | \"middle-center\" | \"middle-end\" | \"middle-start\"","\"button\" | \"submit\" | \"reset\"","\"center\" | \"bottom\"","\"center\" | \"left\" | \"right\"","\"center\" | \"left\" | \"right\" | \"justify\" | \"char\"","\"center\" | \"side\" | \"merge\"","\"checkbox\" | \"radio\"","\"dashed\" | \"dotted\" | \"solid\"","\"default\" | \"gradient\"","\"default\" | \"responsive\"","\"default\" | \"stacked\" | \"percent\" | \"split\"","\"default\" | \"stacked\" | \"percent\" | \"waterfall\"","\"div\" | \"label\"","\"end\" | \"start\"","\"filled\" | \"light\"","\"fixed\" | \"absolute\"","\"hex\" | \"hexa\" | \"rgba\" | \"rgb\" | \"hsl\" | \"hsla\"","\"horizontal\" | \"vertical\"","\"hover\" | \"click\" | \"click-hover\"","\"inherit\" | \"auto\" | \"baseline\" | \"before-edge\" | \"text-before-edge\" | \"middle\" | \"central\" | \"after-edge\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\"","\"inherit\" | \"auto\" | \"sRGB\" | \"linearRGB\"","\"inherit\" | \"auto\" | \"text-before-edge\" | \"middle\" | \"central\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\" | \"use-script\" | \"no-change\" | \"reset-size\"","\"inherit\" | \"end\" | \"start\" | \"middle\"","\"inherit\" | \"nonzero\" | \"evenodd\"","\"inherit\" | \"round\" | \"butt\" | \"square\"","\"inherit\" | \"round\" | \"miter\" | \"bevel\"","\"initial\" | \"medial\" | \"terminal\" | \"isolated\"","\"inside\" | \"outside\"","\"left\" | \"right\"","\"left\" | \"right\" | \"bottom\" | \"top\" | \"left-end\" | \"left-start\" | \"right-end\" | \"right-start\" | \"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\"","\"left\" | \"right\" | \"inside\"","\"link\" | \"none\" | \"copy\" | \"execute\" | \"move\" | \"popup\"","\"media\" | \"container\"","\"month\" | \"year\" | \"decade\"","\"native\" | \"scrollarea\"","\"none\" | \"all\" | \"rows\" | \"groups\" | \"columns\"","\"none\" | \"all\" | \"with-indicators\"","\"none\" | \"ascending\" | \"descending\" | \"other\"","\"none\" | \"inline\" | \"list\" | \"both\"","\"none\" | \"strict\" | \"blur\"","\"none\" | \"sum\"","\"none\" | \"thousand\" | \"lakh\" | \"wan\"","\"none\" | \"x\" | \"y\" | \"xy\"","\"number\" | RegExp | \"alphanumeric\"","\"off\" | \"assertive\" | \"polite\"","\"off\" | \"on\"","\"ordered\" | \"unordered\"","\"right-to-left\" | \"left-to-right\"","\"search\" | \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"send\"","\"search\" | \"text\" | \"none\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"","\"step\" | \"bump\" | \"linear\" | \"natural\" | \"monotone\" | \"stepBefore\" | \"stepAfter\"","\"stepInactive\" | \"stepProgress\" | \"stepCompleted\"","\"sum\" | \"replace\"","\"td\" | \"th\"","\"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"","\"text\" | \"tel\" | \"password\"","\"time\" | \"duration\"","\"up\" | \"down\"","\"value\" | \"name\" | \"percent\"","\"viewport\"","\"wrap\" | \"nowrap\" | \"balance\" | \"pretty\" | \"stable\"","\"x\" | \"y\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | (string & {})","\"yes\" | \"no\"","'Text' | 'Title' | 'Badge' | 'Code' | 'Image'","'auto' | 'manual'","'json' | 'text'","'xs' | 'sm' | 'md' | 'lg' | 'xl'","(\"input\" | \"label\" | \"description\" | \"error\")[]","(\"next\" | \"previLine truncated
{"schemaVersion":1,"mantineVersion":"9.4.1","customWidgetVersion":"2.0.0","types":["\"\" | \"anonymous\" | \"use-credentials\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"auto\" | \"fixed\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"auto\" | \"all\" | \"fill\" | \"stroke\" | \"painted\" | \"visible\" | \"visibleFill\" | \"visiblePainted\" | \"visibleStroke\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"block\" | \"inline\" | \"both\" | \"horizontal\" | \"vertical\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"contain\" | \"cover\" | \"fill\" | \"scale-down\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"wrap\" | \"nowrap\" | \"wrap-reverse\"","\"12h\" | \"24h\"","\"activity\" | \"display-none\"","\"all\" | \"parent\" | \"child\"","\"all\" | \"segment\"","\"always\" | \"hover\" | \"not-hover\" | \"never\"","\"area\" | \"radial\" | \"scatter\"","\"auto\" | \"scroll\" | \"always\" | \"hover\" | \"never\"","\"auto-fit\" | \"auto-fill\"","\"block\" | \"grid\"","\"both\" | \"rightSection\" | \"clear\"","\"bottom\" | \"top\"","\"bottom\" | \"top\" | \"baseline\" | \"middle\"","\"bottom\" | \"top\" | \"middle\"","\"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\" | \"bottom-center\" | \"top-center\" | \"middle-center\" | \"middle-end\" | \"middle-start\"","\"button\" | \"submit\" | \"reset\"","\"center\" | \"bottom\"","\"center\" | \"left\" | \"right\"","\"center\" | \"left\" | \"right\" | \"justify\" | \"char\"","\"center\" | \"side\" | \"merge\"","\"checkbox\" | \"radio\"","\"dashed\" | \"dotted\" | \"solid\"","\"default\" | \"gradient\"","\"default\" | \"responsive\"","\"default\" | \"stacked\" | \"percent\" | \"split\"","\"default\" | \"stacked\" | \"percent\" | \"waterfall\"","\"div\" | \"label\"","\"end\" | \"start\"","\"filled\" | \"light\"","\"fixed\" | \"absolute\"","\"hex\" | \"hexa\" | \"rgba\" | \"rgb\" | \"hsl\" | \"hsla\"","\"horizontal\" | \"vertical\"","\"hover\" | \"click\" | \"click-hover\"","\"inherit\" | \"auto\" | \"baseline\" | \"before-edge\" | \"text-before-edge\" | \"middle\" | \"central\" | \"after-edge\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\"","\"inherit\" | \"auto\" | \"sRGB\" | \"linearRGB\"","\"inherit\" | \"auto\" | \"text-before-edge\" | \"middle\" | \"central\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\" | \"use-script\" | \"no-change\" | \"reset-size\"","\"inherit\" | \"end\" | \"start\" | \"middle\"","\"inherit\" | \"nonzero\" | \"evenodd\"","\"inherit\" | \"round\" | \"butt\" | \"square\"","\"inherit\" | \"round\" | \"miter\" | \"bevel\"","\"initial\" | \"medial\" | \"terminal\" | \"isolated\"","\"inside\" | \"outside\"","\"left\" | \"right\"","\"left\" | \"right\" | \"bottom\" | \"top\" | \"left-end\" | \"left-start\" | \"right-end\" | \"right-start\" | \"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\"","\"left\" | \"right\" | \"inside\"","\"link\" | \"none\" | \"copy\" | \"execute\" | \"move\" | \"popup\"","\"media\" | \"container\"","\"month\" | \"year\" | \"decade\"","\"native\" | \"scrollarea\"","\"none\" | \"all\" | \"rows\" | \"groups\" | \"columns\"","\"none\" | \"all\" | \"with-indicators\"","\"none\" | \"ascending\" | \"descending\" | \"other\"","\"none\" | \"inline\" | \"list\" | \"both\"","\"none\" | \"strict\" | \"blur\"","\"none\" | \"sum\"","\"none\" | \"thousand\" | \"lakh\" | \"wan\"","\"none\" | \"x\" | \"y\" | \"xy\"","\"number\" | RegExp | \"alphanumeric\"","\"off\" | \"assertive\" | \"polite\"","\"off\" | \"on\"","\"ordered\" | \"unordered\"","\"right-to-left\" | \"left-to-right\"","\"search\" | \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"send\"","\"search\" | \"text\" | \"none\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"","\"step\" | \"bump\" | \"linear\" | \"natural\" | \"monotone\" | \"stepBefore\" | \"stepAfter\"","\"stepInactive\" | \"stepProgress\" | \"stepCompleted\"","\"sum\" | \"replace\"","\"td\" | \"th\"","\"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"","\"text\" | \"tel\" | \"password\"","\"time\" | \"duration\"","\"up\" | \"down\"","\"value\" | \"name\" | \"percent\"","\"viewport\"","\"wrap\" | \"nowrap\" | \"balance\" | \"pretty\" | \"stable\"","\"x\" | \"y\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | (string & {})","\"yes\" | \"no\"","'Text' | 'Title' | 'Badge' | 'Code' | 'Image'","'auto' | 'manual'","'json' | 'text'","'xs' | 'sm' | 'md' | 'lg' | 'xl'","(\"input\" | \"label\" | \"description\" | \"error\")[]","(\"next\" | \"previLine truncated
@@ -175,6 +175,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
message: t("notification.success.message"),
});
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync(`/boards/${board.name}`);
close();
},
@@ -14,6 +14,7 @@ import type { TablerIcon } from "@homarr/ui";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button";
import { useItemActions } from "./item-actions";
export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, innerProps }) => {
@@ -159,59 +160,59 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne
/>
))}
{filteredCustomWidgets.length > 0 && (
<>
<Divider
label={t("customWidget.page.list.title")}
labelPosition="center"
my="sm"
style={{ gridColumn: "1 / -1" }}
/>
{filteredCustomWidgets.map((def) => (
<Card
key={def.id}
h={selectGridCardHeight}
withBorder
pos="relative"
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
{def.iconUrl ? (
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} />
) : (
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} />
)}
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{def.name}
</Text>
</Group>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{def.description ?? ""}
</Text>
</Stack>
<Box
pos="absolute"
bottom={0}
left={0}
right={0}
p="xs"
style={{
opacity: "var(--_hover-opacity)",
transition: "opacity 150ms ease",
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
}}
>
<Button onClick={() => handleAddCustomWidget(def)} variant="light" size="xs" fullWidth>
{t("item.create.addToBoard")}
</Button>
</Box>
</Card>
))}
</>
)}
<Divider
label={t("customWidget.page.list.title")}
labelPosition="center"
my="sm"
style={{ gridColumn: "1 / -1" }}
/>
{filteredCustomWidgets.map((def) => (
<Card
key={def.id}
h={selectGridCardHeight}
withBorder
pos="relative"
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
{def.iconUrl ? (
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} />
) : (
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} />
)}
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{def.name}
</Text>
</Group>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{def.description ?? ""}
</Text>
</Stack>
<Box
pos="absolute"
bottom={0}
left={0}
right={0}
p="xs"
style={{
opacity: "var(--_hover-opacity)",
transition: "opacity 150ms ease",
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
}}
>
<Button onClick={() => handleAddCustomWidget(def)} variant="light" size="xs" fullWidth>
{t("item.create.addToBoard")}
</Button>
</Box>
</Card>
))}
<Box style={{ gridColumn: "1 / -1" }}>
<WorkshopInstallButton fullWidth>{t("workshop.installDialog")}</WorkshopInstallButton>
</Box>
{filteredItems.length === 0 && filteredCustomWidgets.length === 0 && (
<Center p="xl">
@@ -32,17 +32,28 @@ import {
IconThumbDown,
IconThumbUp,
} from "@tabler/icons-react";
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import type { WorkshopReport, WorkshopSubmissionDetail, WorkshopSubmissionType, WorkshopUser } from "@homarr/workshop";
import {
useWorkshopCssImportMutation,
useWorkshopQuery,
useWorkshopReportMutation,
useWorkshopSubmissionQuery,
useWorkshopVoteMutation,
useWorkshopWidgetImportMutation,
} from "@homarr/workshop/backend";
import type {
WorkshopReport,
WorkshopSubmissionDetail,
WorkshopSubmissionType,
WorkshopUser,
} from "@homarr/workshop/schema";
import {
validateWorkshopContent,
validateWorkshopWidget,
WORKSHOP_CSS_SCHEMA,
workshopExportFilename,
} from "@homarr/workshop";
} from "@homarr/workshop/schema";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
@@ -68,7 +79,6 @@ interface WorkshopBrowserProps {
export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }: WorkshopBrowserProps) {
const t = useScopedI18n("workshop");
const client = useMemo(createWorkshopClient, []);
const queryClient = useQueryClient();
const [user, setUser] = useState<WorkshopUser | null>(null);
const [page, setPage] = useState(1);
const [sort, setSort] = useState<"top" | "newest">("top");
@@ -88,16 +98,8 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
return unsubscribe;
}, [client]);
const list = useQuery({
queryKey: ["workshop", "list", type, page, sort, debouncedSearch],
queryFn: ({ signal }) => client.list({ page, perPage: 12, type, sort, search: debouncedSearch, signal }),
placeholderData: keepPreviousData,
});
const detail = useQuery({
queryKey: ["workshop", "detail", selectedId],
queryFn: ({ signal }) => client.get(selectedId ?? "", signal),
enabled: selectedId !== null,
});
const list = useWorkshopQuery(client, { page, perPage: 12, type, sort, search: debouncedSearch });
const detail = useWorkshopSubmissionQuery(client, selectedId ?? "");
const detailValidation = useMemo(() => {
if (!detail.data) return null;
return type === "customCss"
@@ -120,33 +122,24 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
})
.finally(() => setLoginPending(false));
};
const install = useMutation({
mutationFn: async (widget: HomarrCustomWidgetV2) => onInstall?.(widget),
onSuccess: () => setSelectedId(null),
});
const useCss = useMutation({
mutationFn: async (css: string) => onUseCss?.(css),
onSuccess: () => {
setCssAwaitingConfirmation(null);
setSelectedId(null);
},
});
const vote = useMutation({
mutationFn: ({ submission, value }: { submission: string; value: 1 | -1 }) => client.vote(submission, value),
onSuccess: async () =>
Promise.all([
queryClient.invalidateQueries({ queryKey: ["workshop", "list"] }),
queryClient.invalidateQueries({ queryKey: ["workshop", "detail"] }),
]),
});
const report = useMutation({
mutationFn: () => client.report(selectedId ?? "", reportCategory, reportExplanation),
onSuccess: () => {
reportControls.close();
setReportExplanation("");
showSuccessNotification({ title: t("reportSent"), message: t("reportSentDescription") });
},
const install = useWorkshopWidgetImportMutation(onInstall, () => setSelectedId(null));
const useCss = useWorkshopCssImportMutation(onUseCss, () => {
setCssAwaitingConfirmation(null);
setSelectedId(null);
});
const vote = useWorkshopVoteMutation(client);
const report = useWorkshopReportMutation(client);
const submitReport = () =>
report.mutate(
{ submission: selectedId ?? "", category: reportCategory, explanation: reportExplanation },
{
onSuccess: () => {
reportControls.close();
setReportExplanation("");
showSuccessNotification({ title: t("reportSent"), message: t("reportSentDescription") });
},
},
);
return (
<Stack gap="lg">
@@ -433,11 +426,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
onChange={(event) => setReportExplanation(event.currentTarget.value)}
/>
{report.error && <Alert color="red">{report.error.message}</Alert>}
<Button
loading={report.isPending}
disabled={reportExplanation.trim().length < 3}
onClick={() => report.mutate()}
>
<Button loading={report.isPending} disabled={reportExplanation.trim().length < 3} onClick={submitReport}>
{t("reportSend")}
</Button>
</Stack>
+8 -5
View File
@@ -38,8 +38,10 @@ https://<workshop-host>/api/oauth2-redirect
Self-hosted Homarr origins do not need to be registered with GitHub. Keep `PB_ALLOWED_ORIGINS=*` (the default) so localhost and arbitrary self-hosted domains can open the central PocketBase OAuth popup. Set a restricted origin list only if it includes every Homarr origin that should use Workshop sign-in.
Set `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` in the repository-root `.env`. Set
`WORKSHOP_PUBLIC_ORIGIN=https://<workshop-host>` so new-comment emails link to the public submission page. Configure the
sender and SMTP transport in PocketBase. Comment email failures are logged and never reject a saved comment.
`WORKSHOP_PUBLIC_ORIGIN=https://<workshop-host>` so notification emails link to the public Workshop. Configure the sender
and SMTP transport in PocketBase. PocketBase emails the submission author about new comments, emails every current
Workshop administrator about a new report, and emails the author after an administrator deletes their submission. Mail
failures are logged and never reject a comment, report, or deletion.
Run the disposable service integration test from the repository root:
@@ -55,9 +57,10 @@ The Workshop does not maintain a second Custom Widget schema. Its frontend valid
The validated `$schema` is also stored as `widgetSchema` for listing compatibility badges; it must match the downloaded
manifest before Homarr enables installation.
PocketBase API rules enforce submission and comment ownership, one vote/report per user and submission, and
administrator-only moderation. Each new submission receives one real author-owned upvote. Reports remain public and are
dismissed by changing their status. PocketBase cascade deletion removes related votes, comments, and reports when a
submission is deleted. Authors and administrators can mark a submission outdated without making it un-installable.
administrator-only moderation. Each new submission receives one real author-owned upvote. Reports are private to
administrators and are dismissed by changing their status. PocketBase cascade deletion removes related votes, comments,
and reports when a submission is deleted. Authors and administrators can mark a submission outdated without making it
un-installable.
## Production docs preview
+53
View File
@@ -0,0 +1,53 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Workshop report</title>
</head>
<body style="margin: 0; padding: 0; background: #f4f4f4; font-family: Helvetica, Arial, sans-serif">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #f4f4f4">
<tr>
<td align="center" style="padding: 50px 20px">
<table
role="presentation"
width="600"
cellpadding="0"
cellspacing="0"
style="width: 600px; max-width: 100%; background: #ffffff; border: 1px solid #dc2626"
>
<tr>
<td style="background: #991b1b; padding: 28px 36px; color: #ffffff">
<div style="font-size: 12px; font-weight: bold; letter-spacing: 1px; text-transform: uppercase">
Moderation alert
</div>
<div style="margin-top: 8px; font-size: 22px; font-weight: bold">A Workshop submission was reported</div>
</td>
</tr>
<tr>
<td style="padding: 36px; color: #18181b; font-size: 15px; line-height: 1.65">
<p style="margin: 0 0 18px">
<strong>{{reporterName}}</strong> reported <a href="{{submissionUrl}}">{{submissionTitle}}</a>.
</p>
<p style="margin: 0 0 8px; font-size: 12px; font-weight: bold; text-transform: uppercase; color: #991b1b">
{{category}}
</p>
<blockquote
style="margin: 0 0 26px; padding: 16px 18px; border-left: 3px solid #dc2626; background: #fef2f2"
>
{{explanation}}
</blockquote>
<a
href="{{adminUrl}}"
style="display: inline-block; padding: 12px 20px; background: #991b1b; color: #ffffff; font-weight: bold; text-decoration: none"
>
REVIEW REPORT
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,44 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Workshop submission removed</title>
</head>
<body style="margin: 0; padding: 0; background: #f4f4f4; font-family: Helvetica, Arial, sans-serif">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #f4f4f4">
<tr>
<td align="center" style="padding: 50px 20px">
<table
role="presentation"
width="600"
cellpadding="0"
cellspacing="0"
style="width: 600px; max-width: 100%; background: #ffffff; border: 1px solid #27272a"
>
<tr>
<td style="background: #18181b; padding: 28px 36px; color: #ffffff">
<div style="font-size: 12px; font-weight: bold; letter-spacing: 1px; text-transform: uppercase">
Homarr Workshop
</div>
<div style="margin-top: 8px; font-size: 22px; font-weight: bold">Your submission was removed</div>
</td>
</tr>
<tr>
<td style="padding: 36px; color: #18181b; font-size: 15px; line-height: 1.65">
<p style="margin: 0 0 18px">A Workshop administrator removed your submission:</p>
<div style="margin: 0 0 24px; padding: 16px 18px; border-left: 3px solid #dc2626; background: #fef2f2">
<strong>{{submissionTitle}}</strong>
</div>
<p style="margin: 0">
The submission is no longer available in Workshop. Widgets or CSS already installed by users are not
removed from their Homarr instances.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+105 -12
View File
@@ -22,6 +22,24 @@ const escapeHtml = (value) =>
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
const emailTemplate = (filename) => {
const hooksPath = $os.getenv("WORKSHOP_PB_HOOKS_DIR") || $filepath.dirname(__filepath);
return $os.readFile($filepath.join(hooksPath, filename));
};
const sendEmail = (app, recipientEmail, subject, text, html) => {
const sender = app.settings().meta;
app.newMailClient().send(
new MailerMessage({
from: { address: sender.senderAddress, name: sender.senderName },
to: [{ address: recipientEmail }],
subject,
text,
html,
}),
);
};
onRecordAfterCreateSuccess((event) => {
try {
const submission = event.app.findRecordById("submissions", event.record.get("submission"));
@@ -39,25 +57,100 @@ onRecordAfterCreateSuccess((event) => {
const submissionTitle = submission.getString("title");
const rawExcerpt = event.record.getString("content").slice(0, 280);
const submissionUrl = `${publicOrigin}/workshop/${submission.id}/`;
const sender = event.app.settings().meta;
const templatePath = $os.getenv("WORKSHOP_PB_HOOKS_DIR") || $filepath.dirname(__filepath);
const template = $os
.readFile($filepath.join(templatePath, "comment-email.html"))
const template = emailTemplate("comment-email.html")
.replaceAll("{{commenterName}}", escapeHtml(commenterName))
.replaceAll("{{submissionTitle}}", escapeHtml(submissionTitle))
.replaceAll("{{commentExcerpt}}", escapeHtml(rawExcerpt))
.replaceAll("{{submissionUrl}}", escapeHtml(submissionUrl));
const message = new MailerMessage({
from: { address: sender.senderAddress, name: sender.senderName },
to: [{ address: recipientEmail }],
subject: `${commenterName} commented on ${submissionTitle}`,
text: `${commenterName} commented on “${submissionTitle}”:\n\n${rawExcerpt}\n\n${submissionUrl}`,
html: template,
});
event.app.newMailClient().send(message);
sendEmail(
event.app,
recipientEmail,
`${commenterName} commented on ${submissionTitle}`,
`${commenterName} commented on “${submissionTitle}”:\n\n${rawExcerpt}\n\n${submissionUrl}`,
template,
);
} catch (error) {
console.log(`Workshop comment email failed for ${event.record.id}: ${error}`);
}
event.next();
}, "comments");
onRecordAfterCreateSuccess((event) => {
try {
const submission = event.app.findRecordById("submissions", event.record.get("submission"));
const reporter = event.app.findRecordById("users", event.record.get("reporter"));
const publicOrigin = $os.getenv("WORKSHOP_PUBLIC_ORIGIN").replace(/\/$/, "");
const admins = event.app
.findAllRecords("users")
.filter((user) => user.getBool("isAdmin") && Boolean(user.email()));
if (!publicOrigin || admins.length === 0) {
event.next();
return;
}
const reporterName = reporter.getString("displayName") || "A community member";
const submissionTitle = submission.getString("title");
const category = event.record.getString("category");
const explanation = event.record.getString("explanation");
const adminUrl = `${publicOrigin}/workshop/admin`;
const submissionUrl = `${publicOrigin}/workshop/${submission.id}/`;
const template = emailTemplate("report-email.html")
.replaceAll("{{reporterName}}", escapeHtml(reporterName))
.replaceAll("{{submissionTitle}}", escapeHtml(submissionTitle))
.replaceAll("{{category}}", escapeHtml(category))
.replaceAll("{{explanation}}", escapeHtml(explanation))
.replaceAll("{{adminUrl}}", escapeHtml(adminUrl))
.replaceAll("{{submissionUrl}}", escapeHtml(submissionUrl));
for (const admin of admins) {
try {
sendEmail(
event.app,
admin.email(),
`Workshop report: ${submissionTitle}`,
`${reporterName} reported “${submissionTitle}” for ${category}.\n\n${explanation}\n\nReview: ${adminUrl}`,
template,
);
} catch (error) {
console.log(`Workshop report email failed for admin ${admin.id}: ${error}`);
}
}
} catch (error) {
console.log(`Workshop report email failed for ${event.record.id}: ${error}`);
}
event.next();
}, "reports");
onRecordDeleteRequest((event) => {
const submissionId = event.record.id;
const submissionTitle = event.record.getString("title");
const authorId = event.record.get("author");
const actor = event.requestInfo().auth;
const notifyAuthor = actor && actor.getBool("isAdmin") && actor.id !== authorId;
event.next();
if (!notifyAuthor) return;
try {
const author = event.app.findRecordById("users", authorId);
const recipientEmail = author.email();
if (!recipientEmail) return;
const publicOrigin = $os.getenv("WORKSHOP_PUBLIC_ORIGIN").replace(/\/$/, "");
const workshopUrl = publicOrigin ? `${publicOrigin}/workshop/` : "";
const template = emailTemplate("submission-deleted-email.html").replaceAll(
"{{submissionTitle}}",
escapeHtml(submissionTitle),
);
const text = `A Workshop administrator removed your submission “${submissionTitle}”.${
workshopUrl ? `\n\nReturn to Workshop: ${workshopUrl}` : ""
}`;
sendEmail(event.app, recipientEmail, `Your Workshop submission was removed`, text, template);
} catch (error) {
console.log(`Workshop deletion email failed for ${submissionId}: ${error}`);
}
}, "submissions");
@@ -181,7 +181,7 @@ migrate(
viewRule: "",
viewQuery: `
SELECT s.id, s.type, s.title, s.description, s.widgetSchema, s.screenshots, s.author,
u.displayName AS authorName, u.avatarUrl AS authorAvatarUrl,
u.displayName AS authorName, u.avatar AS authorAvatar, u.avatarUrl AS authorAvatarUrl,
u.githubUsername AS authorGithubUsername, u.githubProfileUrl AS authorGithubProfileUrl,
s.revision, s.changelog, s.outdated, s.created, s.updated,
COALESCE((SELECT COUNT(*) FROM votes v WHERE v.submission = s.id AND v.value = 1), 0) AS upvotes,
+3 -3
View File
@@ -243,10 +243,10 @@ const report = await request("/api/collections/reports/records", {
status: "open",
}),
});
const ownReport = await request(`/api/collections/reports/records/${report.id}`, { headers: visitorSession.headers });
if (ownReport.reporter !== visitor.id) throw new Error("Reporters must be able to view their own report");
await expectStatus(`/api/collections/reports/records/${report.id}`, { headers: visitorSession.headers }, 404);
await expectStatus("/api/collections/reports/records", { headers: visitorSession.headers }, 200);
const publicReports = await request("/api/collections/reports/records", { headers: visitorSession.headers });
if (publicReports.items.length !== 1) throw new Error("Report details must remain publicly visible");
if (publicReports.items.length !== 0) throw new Error("Report details must remain private to Workshop administrators");
await expectStatus(
`/api/collections/reports/records/${report.id}`,
{ method: "DELETE", headers: visitorSession.headers },
@@ -636,6 +636,8 @@ const HOMARR_COMPONENT_PROPS: Readonly<Record<string, Readonly<Record<string, Ho
loadingLabel: optionalProp("string"),
errorMessage: optionalProp("string"),
fallback: optionalProp("ReactNode"),
triggerContent: optionalProp("ReactNode", undefined, "Custom accessible trigger shown before a manual query runs."),
triggerAriaLabel: optionalProp("string", undefined, "Accessible label for a custom manual-query trigger."),
path: optionalProp("string"),
as: optionalProp("'json' | 'text'", ["json", "text"]),
trigger: optionalProp("'auto' | 'manual'", ["auto", "manual"]),
@@ -103,7 +103,7 @@ export const BUNDLED_CUSTOM_WIDGETS: readonly BundledCustomWidget[] = [
<Group gap="xs"><Badge color={options.accent} variant="light">{data.pokemon?.count?.toLocaleString() ?? "—"} species</Badge><RefreshButton /></Group>
</Group>
<TextInput bind="search" label="Filter loaded Pokémon" placeholder="Search by name…" />
{status.pokemon?.loading ? <SimpleGrid cols={{ base: 1, xs: 2 }}>{[1, 2, 3, 4].map(item => <Skeleton key={item} height={116} radius="md" />)}</SimpleGrid> : status.pokemon?.error ? <Alert color="red" title="The Pokédex could not be loaded">{status.pokemon.error}</Alert> : (data.pokemon?.results ?? []).filter(pokemon => pokemon.name.toLowerCase().includes(String(inputs.search ?? "").toLowerCase())).length === 0 ? <Alert color="gray" title="No Pokémon found">Try another name or increase the result limit in widget options.</Alert> : <ScrollArea h={300} type="auto"><SimpleGrid cols={{ base: 1, xs: 2 }} spacing="sm">{(data.pokemon?.results ?? []).filter(pokemon => pokemon.name.toLowerCase().includes(String(inputs.search ?? "").toLowerCase())).map((pokemon, index) => <Card key={pokemon.name} withBorder radius="md" padding="sm"><Stack gap="sm"><Group justify="space-between"><Group gap="xs"><Badge color={options.accent} variant="outline">#{options.offset + index + 1}</Badge><Text fw={700} tt="capitalize">{pokemon.name}</Text></Group><Text size="xs" c="dimmed">Details</Text></Group><SubFetch requestId="detail" params={{ name: pokemon.name }} trigger="manual" loadingLabel="Loading Pokémon…">{(detail) => <Stack gap="sm"><Group align="flex-start" wrap="nowrap"><Image src={detail.sprites?.other?.["official-artwork"]?.front_default ?? detail.sprites?.front_default} alt={detail.name} w={92} h={92} fit="contain" fallbackSrc="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/poke-ball.png" /><Stack gap="xs"><Group gap={6}>{(detail.types ?? []).map(type => <Badge key={type.type?.name} variant="light" tt="capitalize">{type.type?.name}</Badge>)}</Group><Text size="xs" c="dimmed">Height {detail.height / 10} m · Weight {detail.weight / 10} kg</Text><Text size="xs"><Text span fw={600}>Abilities: </Text>{(detail.abilities ?? []).map(ability => ability.ability?.name?.replace("-", " ")).join(", ")}</Text></Stack></Group><Divider /><Stack gap={6}>{(detail.stats ?? []).map(stat => <Stack key={stat.stat?.name} gap={3}><Group justify="space-between"><Text size="xs" tt="capitalize">{stat.stat?.name?.replace("-", " ")}</Text><Text size="xs" fw={700}>{stat.base_stat}</Text></Group><Progress value={Math.min(100, stat.base_stat / 2)} color={options.accent} size="sm" /></Stack>)}</Stack></Stack>}</SubFetch></Stack></Card>)}</SimpleGrid></ScrollArea>}
{status.pokemon?.loading ? <Stack gap="xs">{[1, 2, 3, 4].map(item => <Skeleton key={item} height={82} radius="md" />)}</Stack> : status.pokemon?.error ? <Alert color="red" title="The Pokédex could not be loaded">{status.pokemon.error}</Alert> : (data.pokemon?.results ?? []).filter(pokemon => pokemon.name.toLowerCase().includes(String(inputs.search ?? "").toLowerCase())).length === 0 ? <Alert color="gray" title="No Pokémon found">Try another name or increase the result limit in widget options.</Alert> : <ScrollArea h={390} type="auto"><PaginatedList pageSize={8}>{(data.pokemon?.results ?? []).filter(pokemon => pokemon.name.toLowerCase().includes(String(inputs.search ?? "").toLowerCase())).map(pokemon => <SubFetch key={pokemon.name} requestId="detail" params={{ name: pokemon.name }} trigger="manual" triggerAriaLabel={"Open details for " + pokemon.name} loadingLabel="Loading Pokémon…" fallback={<Card withBorder radius="md" p="xs" mb="xs"><Skeleton height={64} radius="sm" /></Card>} triggerContent={<Card withBorder radius="md" p="xs" mb="xs"><Group wrap="nowrap" justify="space-between"><Group wrap="nowrap" gap="sm"><Image src={"https://cdn.jsdelivr.net/gh/PokeAPI/sprites@master/sprites/pokemon/" + pokemon.url.split("/").slice(-2, -1)[0] + ".png"} alt={pokemon.name} w={64} h={64} fit="contain" radius="sm" fallbackSrc="https://cdn.jsdelivr.net/gh/PokeAPI/sprites@master/sprites/items/poke-ball.png" /><Stack gap={0}><Text fw={700} tt="capitalize">{pokemon.name}</Text><Text size="xs" c="dimmed">#{String(pokemon.url.split("/").slice(-2, -1)[0]).padStart(3, "0")}</Text></Stack></Group><Badge color={options.accent} variant="light">Details</Badge></Group></Card>}>{(detail) => <Card withBorder radius="md" p="sm" mb="xs"><Stack gap="sm"><Group align="flex-start" wrap="nowrap"><Image src={"https://cdn.jsdelivr.net/gh/PokeAPI/sprites@master/sprites/pokemon/other/official-artwork/" + detail.id + ".png"} alt={detail.name} w={80} h={80} fit="contain" radius="md" fallbackSrc="https://cdn.jsdelivr.net/gh/PokeAPI/sprites@master/sprites/items/poke-ball.png" /><Stack gap="xs" style={{ flex: 1 }}><Group justify="space-between"><Text fw={750} size="lg" tt="capitalize">{detail.name}</Text><Group gap={6}>{(detail.types ?? []).map(type => <Badge key={type.type?.name} variant="light" tt="capitalize">{type.type?.name}</Badge>)}</Group></Group><Text size="xs" c="dimmed">Height {detail.height / 10} m · Weight {detail.weight / 10} kg</Text><Text size="xs"><Text span fw={600}>Abilities: </Text>{(detail.abilities ?? []).map(ability => ability.ability?.name?.replace("-", " ")).join(", ")}</Text></Stack></Group><Divider /><SimpleGrid cols={{ base: 1, xs: 2 }} spacing="xs">{(detail.stats ?? []).map(stat => <Stack key={stat.stat?.name} gap={3}><Group justify="space-between"><Text size="xs" tt="capitalize">{stat.stat?.name?.replace("-", " ")}</Text><Text size="xs" fw={700}>{stat.base_stat}</Text></Group><Progress value={Math.min(100, stat.base_stat / 2)} color={options.accent} size="sm" /></Stack>)}</SimpleGrid></Stack></Card>}</SubFetch>)}</PaginatedList></ScrollArea>}
</Stack>`,
},
},
@@ -1 +1 @@
{"schemaVersion":1,"mantineVersion":"9.4.1","customWidgetVersion":"2.0.0","types":["\"\" | \"anonymous\" | \"use-credentials\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"auto\" | \"fixed\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"auto\" | \"all\" | \"fill\" | \"stroke\" | \"painted\" | \"visible\" | \"visibleFill\" | \"visiblePainted\" | \"visibleStroke\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"block\" | \"inline\" | \"both\" | \"horizontal\" | \"vertical\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"contain\" | \"cover\" | \"fill\" | \"scale-down\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"wrap\" | \"nowrap\" | \"wrap-reverse\"","\"12h\" | \"24h\"","\"activity\" | \"display-none\"","\"all\" | \"parent\" | \"child\"","\"all\" | \"segment\"","\"always\" | \"hover\" | \"not-hover\" | \"never\"","\"area\" | \"radial\" | \"scatter\"","\"auto\" | \"scroll\" | \"always\" | \"hover\" | \"never\"","\"auto-fit\" | \"auto-fill\"","\"block\" | \"grid\"","\"both\" | \"rightSection\" | \"clear\"","\"bottom\" | \"top\"","\"bottom\" | \"top\" | \"baseline\" | \"middle\"","\"bottom\" | \"top\" | \"middle\"","\"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\" | \"bottom-center\" | \"top-center\" | \"middle-center\" | \"middle-end\" | \"middle-start\"","\"button\" | \"submit\" | \"reset\"","\"center\" | \"bottom\"","\"center\" | \"left\" | \"right\"","\"center\" | \"left\" | \"right\" | \"justify\" | \"char\"","\"center\" | \"side\" | \"merge\"","\"checkbox\" | \"radio\"","\"dashed\" | \"dotted\" | \"solid\"","\"default\" | \"gradient\"","\"default\" | \"responsive\"","\"default\" | \"stacked\" | \"percent\" | \"split\"","\"default\" | \"stacked\" | \"percent\" | \"waterfall\"","\"div\" | \"label\"","\"end\" | \"start\"","\"filled\" | \"light\"","\"fixed\" | \"absolute\"","\"hex\" | \"hexa\" | \"rgba\" | \"rgb\" | \"hsl\" | \"hsla\"","\"horizontal\" | \"vertical\"","\"hover\" | \"click\" | \"click-hover\"","\"inherit\" | \"auto\" | \"baseline\" | \"before-edge\" | \"text-before-edge\" | \"middle\" | \"central\" | \"after-edge\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\"","\"inherit\" | \"auto\" | \"sRGB\" | \"linearRGB\"","\"inherit\" | \"auto\" | \"text-before-edge\" | \"middle\" | \"central\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\" | \"use-script\" | \"no-change\" | \"reset-size\"","\"inherit\" | \"end\" | \"start\" | \"middle\"","\"inherit\" | \"nonzero\" | \"evenodd\"","\"inherit\" | \"round\" | \"butt\" | \"square\"","\"inherit\" | \"round\" | \"miter\" | \"bevel\"","\"initial\" | \"medial\" | \"terminal\" | \"isolated\"","\"inside\" | \"outside\"","\"left\" | \"right\"","\"left\" | \"right\" | \"bottom\" | \"top\" | \"left-end\" | \"left-start\" | \"right-end\" | \"right-start\" | \"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\"","\"left\" | \"right\" | \"inside\"","\"link\" | \"none\" | \"copy\" | \"execute\" | \"move\" | \"popup\"","\"media\" | \"container\"","\"month\" | \"year\" | \"decade\"","\"native\" | \"scrollarea\"","\"none\" | \"all\" | \"rows\" | \"groups\" | \"columns\"","\"none\" | \"all\" | \"with-indicators\"","\"none\" | \"ascending\" | \"descending\" | \"other\"","\"none\" | \"inline\" | \"list\" | \"both\"","\"none\" | \"strict\" | \"blur\"","\"none\" | \"sum\"","\"none\" | \"thousand\" | \"lakh\" | \"wan\"","\"none\" | \"x\" | \"y\" | \"xy\"","\"number\" | RegExp | \"alphanumeric\"","\"off\" | \"assertive\" | \"polite\"","\"off\" | \"on\"","\"ordered\" | \"unordered\"","\"right-to-left\" | \"left-to-right\"","\"search\" | \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"send\"","\"search\" | \"text\" | \"none\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"","\"step\" | \"bump\" | \"linear\" | \"natural\" | \"monotone\" | \"stepBefore\" | \"stepAfter\"","\"stepInactive\" | \"stepProgress\" | \"stepCompleted\"","\"sum\" | \"replace\"","\"td\" | \"th\"","\"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"","\"text\" | \"tel\" | \"password\"","\"time\" | \"duration\"","\"up\" | \"down\"","\"value\" | \"name\" | \"percent\"","\"viewport\"","\"wrap\" | \"nowrap\" | \"balance\" | \"pretty\" | \"stable\"","\"x\" | \"y\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | (string & {})","\"yes\" | \"no\"","'Text' | 'Title' | 'Badge' | 'Code' | 'Image'","'auto' | 'manual'","'json' | 'text'","'xs' | 'sm' | 'md' | 'lg' | 'xl'","(\"input\" | \"label\" | \"description\" | \"error\")[]","(\"next\" | \"previLine truncated
{"schemaVersion":1,"mantineVersion":"9.4.1","customWidgetVersion":"2.0.0","types":["\"\" | \"anonymous\" | \"use-credentials\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"auto\" | \"fixed\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"auto\" | \"all\" | \"fill\" | \"stroke\" | \"painted\" | \"visible\" | \"visibleFill\" | \"visiblePainted\" | \"visibleStroke\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"block\" | \"inline\" | \"both\" | \"horizontal\" | \"vertical\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"contain\" | \"cover\" | \"fill\" | \"scale-down\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"wrap\" | \"nowrap\" | \"wrap-reverse\"","\"12h\" | \"24h\"","\"activity\" | \"display-none\"","\"all\" | \"parent\" | \"child\"","\"all\" | \"segment\"","\"always\" | \"hover\" | \"not-hover\" | \"never\"","\"area\" | \"radial\" | \"scatter\"","\"auto\" | \"scroll\" | \"always\" | \"hover\" | \"never\"","\"auto-fit\" | \"auto-fill\"","\"block\" | \"grid\"","\"both\" | \"rightSection\" | \"clear\"","\"bottom\" | \"top\"","\"bottom\" | \"top\" | \"baseline\" | \"middle\"","\"bottom\" | \"top\" | \"middle\"","\"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\" | \"bottom-center\" | \"top-center\" | \"middle-center\" | \"middle-end\" | \"middle-start\"","\"button\" | \"submit\" | \"reset\"","\"center\" | \"bottom\"","\"center\" | \"left\" | \"right\"","\"center\" | \"left\" | \"right\" | \"justify\" | \"char\"","\"center\" | \"side\" | \"merge\"","\"checkbox\" | \"radio\"","\"dashed\" | \"dotted\" | \"solid\"","\"default\" | \"gradient\"","\"default\" | \"responsive\"","\"default\" | \"stacked\" | \"percent\" | \"split\"","\"default\" | \"stacked\" | \"percent\" | \"waterfall\"","\"div\" | \"label\"","\"end\" | \"start\"","\"filled\" | \"light\"","\"fixed\" | \"absolute\"","\"hex\" | \"hexa\" | \"rgba\" | \"rgb\" | \"hsl\" | \"hsla\"","\"horizontal\" | \"vertical\"","\"hover\" | \"click\" | \"click-hover\"","\"inherit\" | \"auto\" | \"baseline\" | \"before-edge\" | \"text-before-edge\" | \"middle\" | \"central\" | \"after-edge\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\"","\"inherit\" | \"auto\" | \"sRGB\" | \"linearRGB\"","\"inherit\" | \"auto\" | \"text-before-edge\" | \"middle\" | \"central\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\" | \"use-script\" | \"no-change\" | \"reset-size\"","\"inherit\" | \"end\" | \"start\" | \"middle\"","\"inherit\" | \"nonzero\" | \"evenodd\"","\"inherit\" | \"round\" | \"butt\" | \"square\"","\"inherit\" | \"round\" | \"miter\" | \"bevel\"","\"initial\" | \"medial\" | \"terminal\" | \"isolated\"","\"inside\" | \"outside\"","\"left\" | \"right\"","\"left\" | \"right\" | \"bottom\" | \"top\" | \"left-end\" | \"left-start\" | \"right-end\" | \"right-start\" | \"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\"","\"left\" | \"right\" | \"inside\"","\"link\" | \"none\" | \"copy\" | \"execute\" | \"move\" | \"popup\"","\"media\" | \"container\"","\"month\" | \"year\" | \"decade\"","\"native\" | \"scrollarea\"","\"none\" | \"all\" | \"rows\" | \"groups\" | \"columns\"","\"none\" | \"all\" | \"with-indicators\"","\"none\" | \"ascending\" | \"descending\" | \"other\"","\"none\" | \"inline\" | \"list\" | \"both\"","\"none\" | \"strict\" | \"blur\"","\"none\" | \"sum\"","\"none\" | \"thousand\" | \"lakh\" | \"wan\"","\"none\" | \"x\" | \"y\" | \"xy\"","\"number\" | RegExp | \"alphanumeric\"","\"off\" | \"assertive\" | \"polite\"","\"off\" | \"on\"","\"ordered\" | \"unordered\"","\"right-to-left\" | \"left-to-right\"","\"search\" | \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"send\"","\"search\" | \"text\" | \"none\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"","\"step\" | \"bump\" | \"linear\" | \"natural\" | \"monotone\" | \"stepBefore\" | \"stepAfter\"","\"stepInactive\" | \"stepProgress\" | \"stepCompleted\"","\"sum\" | \"replace\"","\"td\" | \"th\"","\"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"","\"text\" | \"tel\" | \"password\"","\"time\" | \"duration\"","\"up\" | \"down\"","\"value\" | \"name\" | \"percent\"","\"viewport\"","\"wrap\" | \"nowrap\" | \"balance\" | \"pretty\" | \"stable\"","\"x\" | \"y\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | (string & {})","\"yes\" | \"no\"","'Text' | 'Title' | 'Badge' | 'Code' | 'Image'","'auto' | 'manual'","'json' | 'text'","'xs' | 'sm' | 'md' | 'lg' | 'xl'","(\"input\" | \"label\" | \"description\" | \"error\")[]","(\"next\" | \"previLine truncated
@@ -73,10 +73,16 @@ export function parseRequestCapabilities(value: unknown): CustomJsxRequestCapabi
}
class RendererErrorBoundary extends Component<
{ children: ReactNode; onError(error: Error): void },
{ error: Error | null }
{ children: ReactNode; resetKey: string; onError(error: Error): void },
{ error: Error | null; resetKey: string }
> {
public state = { error: null } as { error: Error | null };
public state = { error: null, resetKey: "" } as { error: Error | null; resetKey: string };
public static getDerivedStateFromProps(
props: Readonly<{ resetKey: string }>,
state: Readonly<{ error: Error | null; resetKey: string }>,
) {
return props.resetKey === state.resetKey ? null : { error: null, resetKey: props.resetKey };
}
public static getDerivedStateFromError(error: Error) {
return { error };
}
@@ -195,7 +201,7 @@ function CustomJsxRendererSession({
registerInput={registerInput}
setInputValue={setInputValue}
>
<RendererErrorBoundary key={boundaryKey} onError={handleError}>
<RendererErrorBoundary resetKey={boundaryKey} onError={handleError}>
{rendered.node}
</RendererErrorBoundary>
</CustomJsxInputsProvider>
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Button } from "@mantine/core";
import { ActionIcon } from "@mantine/core";
import { IconRefresh } from "@tabler/icons-react";
import { useCustomWidgetRuntime } from "./context";
@@ -13,7 +13,7 @@ export interface RefreshButtonProps {
size?: string;
}
export function RefreshButton({ label, color = "blue", variant = "light", size = "sm" }: RefreshButtonProps) {
export function RefreshButton({ label, color = "gray", variant = "subtle", size = "sm" }: RefreshButtonProps) {
const runtime = useCustomWidgetRuntime();
const [loading, setLoading] = useState(false);
const refresh = async () => {
@@ -30,16 +30,16 @@ export function RefreshButton({ label, color = "blue", variant = "light", size =
}
};
return (
<Button
<ActionIcon
aria-label={label ?? runtime.messages.refresh}
color={color}
variant={variant as never}
size={size as never}
leftSection={<IconRefresh size={16} />}
onClick={() => void refresh()}
loading={loading}
disabled={(!runtime.itemId && !runtime.previewSessionId) || runtime.isEditMode}
>
{label ?? runtime.messages.refresh}
</Button>
<IconRefresh size={16} />
</ActionIcon>
);
}
@@ -2,7 +2,7 @@
import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
import { Alert, Button, Code, Skeleton, Text } from "@mantine/core";
import { Alert, Box, Button, Code, Skeleton, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
@@ -27,6 +27,8 @@ export interface SubFetchProps {
loadingLabel?: string;
errorMessage?: string;
fallback?: ReactNode;
triggerContent?: ReactNode;
triggerAriaLabel?: string;
path?: string;
as?: "json" | "text";
trigger?: "auto" | "manual";
@@ -55,7 +57,9 @@ export function SubFetch(props: SubFetchProps) {
refetchInterval: refreshMs,
});
useEffect(() => {
if (!props.requestId || !setQueryState) return;
// Manual queries are local to their SubFetch instance. Publishing them by request ID makes
// repeated cards compete for the same data slot and can remount the active query mid-request.
if (!props.requestId || !setQueryState || props.trigger === "manual") return;
if (!enabled) {
setQueryState(props.requestId, null);
return;
@@ -71,13 +75,48 @@ export function SubFetch(props: SubFetchProps) {
error: result?.error ?? (query.error ? messages.requestFailed : undefined),
},
});
}, [enabled, messages.requestFailed, props.requestId, query.data, query.error, query.isFetching, setQueryState]);
}, [
enabled,
messages.requestFailed,
props.requestId,
props.trigger,
query.data,
query.error,
query.isFetching,
setQueryState,
]);
if (!props.requestId) return <RequestIdRequiredAlert />;
if (queriesDisabled) return null;
if (!itemId && !previewSessionId) return props.fallback ?? <Text c="dimmed">{messages.unsavedPreview}</Text>;
if (queriesDisabled) return props.trigger === "manual" ? (props.triggerContent ?? null) : null;
if (!itemId && !previewSessionId)
return props.trigger === "manual" && props.triggerContent
? props.triggerContent
: (props.fallback ?? <Text c="dimmed">{messages.unsavedPreview}</Text>);
if (!params) return <RequestAlert message={messages.invalidParams} />;
if (props.trigger === "manual" && !manualRun) {
if (props.triggerContent) {
return (
<Box
component="button"
type="button"
aria-label={props.triggerAriaLabel ?? messages.loadRequest}
onClick={() => setManualRun(true)}
style={{
appearance: "none",
background: "transparent",
border: 0,
color: "inherit",
cursor: "pointer",
font: "inherit",
padding: 0,
textAlign: "inherit",
width: "100%",
}}
>
{props.triggerContent}
</Box>
);
}
return (
<Button size="compact-sm" variant="light" onClick={() => setManualRun(true)}>
{messages.loadRequest}
@@ -214,6 +214,37 @@ describe("Custom Widget runtime ports", () => {
expect(setItem).not.toHaveBeenCalled();
});
it("preserves input focus across temporary binding updates", async () => {
const components = createCustomJsxComponents({
TablerIcon: (() => null) as never,
copyLabels: { copy: "Copy", copied: "Copied" },
});
await render(
<CustomJsxRenderer
template={'<Stack><TextInput bind="search"/><Text>{inputs.search}</Text></Stack>'}
data={{}}
components={components}
createBindings={() => ({})}
messages={{ noTemplate: "No template", templateWarnings: (count) => `${count} warnings` }}
/>,
createPort(),
);
await settle();
const input = host.querySelector("input") as HTMLInputElement;
input.focus();
await act(async () => {
input.value = "p";
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: "p", inputType: "insertText" }));
});
await settle();
expect(input.isConnected).toBe(true);
expect(document.activeElement).toBe(input);
expect(input.value).toBe("p");
expect(host.textContent).toContain("p");
});
it("resets temporary bindings when the template changes", async () => {
const rendererMessages = {
noTemplate: "No template",
@@ -328,6 +359,50 @@ describe("Custom Widget runtime ports", () => {
expect(host.textContent).toContain("Bulbasaur:200:false");
});
it("keeps manual query results local instead of publishing a shared request slot", async () => {
const port = createPort();
const publish = vi.fn();
await render(
<SubFetch requestId="details" trigger="manual" triggerContent={<span>Bulbasaur card</span>}>
{(data) => <span>{(data as { name: string }).name}</span>}
</SubFetch>,
port,
[],
publish,
);
await act(async () => (host.querySelector("button") as HTMLButtonElement).click());
await settle();
expect(port.query).toHaveBeenCalledOnce();
expect(publish).not.toHaveBeenCalled();
expect(host.textContent).toContain("Bulbasaur");
});
it("runs a manual query from a custom accessible trigger", async () => {
const port = createPort();
await render(
<SubFetch
requestId="details"
trigger="manual"
triggerAriaLabel="Open Bulbasaur"
triggerContent={<span>Bulbasaur card</span>}
>
{(data) => <span>{(data as { name: string }).name}</span>}
</SubFetch>,
port,
);
const trigger = host.querySelector("button") as HTMLButtonElement;
expect(trigger.getAttribute("aria-label")).toBe("Open Bulbasaur");
expect(host.textContent).toContain("Bulbasaur card");
await act(async () => trigger.click());
await settle();
expect(port.query).toHaveBeenCalledOnce();
expect(host.textContent).toContain("Bulbasaur");
});
it("passes cancellation to the port when the component unmounts", async () => {
let signal: AbortSignal | undefined;
const port = createPort({
@@ -43,6 +43,9 @@ export default function CustomApiWidget({
);
if (query.error) {
const errorCode = query.error.data?.code;
if (isEditMode && errorCode === "NOT_FOUND") {
return <Unavailable message={t("editModePending")} />;
}
const isUnavailable = errorCode === "NOT_FOUND" || errorCode === "FORBIDDEN";
return (
<Unavailable
@@ -104,7 +104,7 @@ describe("CustomJsxDisplay", () => {
it("keeps runtime components inside an inert provider before a preview session exists", async () => {
await renderDisplay({ template: '<RefreshButton label="Refresh" />', data: {} });
expect(container.textContent).toContain("Refresh");
expect(container.querySelector('button[aria-label="Refresh"]')).not.toBeNull();
expect(container.textContent).not.toContain("RUNTIME_RENDER_ERROR");
expect(container.querySelector("button")?.disabled).toBe(true);
});
@@ -114,13 +114,29 @@ describe("CustomJsxDisplay", () => {
expect(pokedex).toBeDefined();
await renderDisplay({
template: pokedex?.template,
data: { pokemon: { count: 2, results: [{ name: "bulbasaur" }, { name: "pikachu" }] } },
data: {
pokemon: {
count: 2,
results: [
{ name: "bulbasaur", url: "https://pokeapi.co/api/v2/pokemon/1/" },
{ name: "pikachu", url: "https://pokeapi.co/api/v2/pokemon/25/" },
],
},
},
status: { pokemon: { loading: false, ok: true, status: 200 } },
options: getCustomWidgetDefaultOptions(pokedex?.options ?? {}),
});
expect(container.textContent).toContain("bulbasaur");
expect(container.textContent).toContain("pikachu");
expect(
container.querySelector('img[src="https://cdn.jsdelivr.net/gh/PokeAPI/sprites@master/sprites/pokemon/1.png"]'),
).not.toBeNull();
expect(
container.querySelector('img[src="https://cdn.jsdelivr.net/gh/PokeAPI/sprites@master/sprites/pokemon/25.png"]'),
).not.toBeNull();
expect(container.querySelectorAll("img.mantine-Image-root")).toHaveLength(2);
expect(container.querySelectorAll(".mantine-Card-root")).toHaveLength(2);
expect(container.textContent).not.toContain("RUNTIME_RENDER_ERROR");
});
+86 -27
View File
@@ -24,6 +24,7 @@ import type {
} from "./schema";
import {
WORKSHOP_API_URL,
MAX_WORKSHOP_SCREENSHOTS,
WORKSHOP_REQUEST_TIMEOUT_MS,
workshopCommentSchema,
workshopReportSchema,
@@ -55,7 +56,13 @@ export interface WorkshopPage<T> {
items: T[];
}
export interface WorkshopScreenshotChanges {
additions?: File[];
removals?: string[];
}
interface WorkshopBaseRecord {
[key: string]: unknown;
id: string;
collectionId: string;
collectionName: string;
@@ -95,6 +102,7 @@ export interface WorkshopSubmissionRecord extends WorkshopBaseRecord {
export interface WorkshopListingRecord extends Omit<WorkshopSubmissionRecord, "content" | "screenshots"> {
screenshots: string[] | string;
authorName: string;
authorAvatar: string;
authorAvatarUrl: string;
authorGithubUsername: string;
authorGithubProfileUrl: string;
@@ -220,7 +228,10 @@ function listingFilter(pocketBase: TypedWorkshopPocketBase, options: WorkshopLis
const isViewCompatibilityError = (error: unknown) =>
error instanceof ClientResponseError && (error.status === 400 || error.status === 404);
const enrichAuthor = (row: unknown) => {
const enrichAuthor = (
row: unknown,
resolveAvatarUrl?: (record: Record<string, unknown>, user: Record<string, unknown>) => string,
) => {
if (typeof row !== "object" || row === null || Array.isArray(row)) return row;
const record = row as Record<string, unknown>;
const expand = record.expand;
@@ -234,7 +245,7 @@ const enrichAuthor = (row: unknown) => {
return {
...record,
authorName: record.authorName || user.displayName || user.username,
authorAvatarUrl: record.authorAvatarUrl || user.avatarUrl,
authorAvatarUrl: resolveAvatarUrl?.(record, user) || record.authorAvatarUrl || user.avatarUrl,
authorGithubUsername: githubUsername,
authorGithubProfileUrl:
record.authorGithubProfileUrl ||
@@ -245,9 +256,12 @@ const enrichAuthor = (row: unknown) => {
};
};
const parseListings = (rows: unknown[]) =>
const parseListings = (
rows: unknown[],
resolveAvatarUrl?: (record: Record<string, unknown>, user: Record<string, unknown>) => string,
) =>
rows.flatMap((row) => {
const result = workshopSubmissionSummarySchema.safeParse(enrichAuthor(row));
const result = workshopSubmissionSummarySchema.safeParse(enrichAuthor(row, resolveAvatarUrl));
return result.success ? [result.data] : [];
});
@@ -282,15 +296,33 @@ export class WorkshopBackend {
this.pocketBase.autoCancellation(false);
}
private userAvatarUrl(record: Record<string, unknown>) {
const avatar = typeof record.avatar === "string" ? record.avatar : "";
if (avatar) return this.pocketBase.files.getURL(record, avatar);
return typeof record.avatarUrl === "string" ? record.avatarUrl : "";
}
private listingAuthorAvatarUrl(record: Record<string, unknown>, user: Record<string, unknown>) {
const expandedAvatarUrl = this.userAvatarUrl(user);
if (expandedAvatarUrl) return expandedAvatarUrl;
const author = typeof record.author === "string" ? record.author : "";
const avatar = typeof record.authorAvatar === "string" ? record.authorAvatar : "";
if (!author || !avatar) return "";
return `${this.baseUrl}/api/files/users/${encodeURIComponent(author)}/${encodeURIComponent(avatar)}`;
}
public get currentUser(): WorkshopUser | null {
const record = this.pocketBase.authStore.record;
if (!record) return null;
return workshopUserSchema.parse({
id: record.id,
displayName: record.displayName || record.name || record.username || "Community member",
avatarUrl: record.avatarUrl || (record.avatar ? this.pocketBase.files.getURL(record, record.avatar) : ""),
githubUsername: record.githubUsername || record.username || "",
githubProfileUrl: record.githubProfileUrl || "",
avatarUrl: this.userAvatarUrl(record),
githubUsername: record.githubUsername || "",
githubProfileUrl:
record.githubProfileUrl ||
(record.githubUsername ? `https://github.com/${encodeURIComponent(record.githubUsername)}` : ""),
isAdmin: record.isAdmin === true,
});
}
@@ -342,7 +374,10 @@ export class WorkshopBackend {
sort: listingSort(options.sort),
signal: requestSignal(options.signal),
});
return { ...result, items: parseListings(result.items) };
return {
...result,
items: parseListings(result.items, (record, user) => this.listingAuthorAvatarUrl(record, user)),
};
} catch (error) {
if (!isViewCompatibilityError(error)) throw error;
const items = filterListings(await this.loadListingsFallback(options.signal), options);
@@ -368,7 +403,7 @@ export class WorkshopBackend {
sort: listingSort(options.sort),
signal: requestSignal(options.signal),
});
return parseListings(rows);
return parseListings(rows, (record, user) => this.listingAuthorAvatarUrl(record, user));
} catch (error) {
if (!isViewCompatibilityError(error)) throw error;
return filterListings(await this.loadListingsFallback(options.signal), options);
@@ -388,7 +423,7 @@ export class WorkshopBackend {
return this.pocketBase.collection("submissions").getFullList({ ...options, expand: "author" });
throw error;
});
return parseListings(rows);
return parseListings(rows, (record, user) => this.listingAuthorAvatarUrl(record, user));
}
public async get(id: string, signal?: AbortSignal): Promise<WorkshopSubmissionDetail> {
@@ -404,7 +439,10 @@ export class WorkshopBackend {
throw error;
});
return workshopSubmissionDetailSchema.parse(
enrichAuthor({ ...submission, ...listing, expand: submission.expand, content: submission.content }),
enrichAuthor(
{ ...submission, ...listing, expand: submission.expand, content: submission.content },
(record, user) => this.listingAuthorAvatarUrl(record, user),
),
);
} catch (error) {
throw workshopError(error, "Submission not found");
@@ -432,15 +470,24 @@ export class WorkshopBackend {
}
}
public async update(id: string, input: WorkshopSubmissionInput, screenshots: File[] = []) {
public async update(id: string, input: WorkshopSubmissionInput, screenshotChanges: WorkshopScreenshotChanges = {}) {
const parsed = workshopSubmissionInputSchema.parse(input);
workshopScreenshotsSchema.parse(screenshots);
const additions = screenshotChanges.additions ?? [];
const removals = screenshotChanges.removals ?? [];
workshopScreenshotsSchema.parse(additions);
const current = await this.get(id);
const data = new FormData();
Object.entries(parsed).forEach(([key, value]) => data.set(key, typeof value === "string" ? value : String(value)));
data.set("widgetSchema", parsed.type === "customCss" ? WORKSHOP_CSS_SCHEMA : CUSTOM_WIDGET_SCHEMA);
data.set("revision", String(current.revision + 1));
screenshots.forEach((file) => data.append("screenshots", file));
const removalSet = new Set(removals);
const retainedCount = current.screenshots.filter((filename) => !removalSet.has(filename)).length;
if (retainedCount + additions.length > MAX_WORKSHOP_SCREENSHOTS)
throw new Error(`A submission can have up to ${MAX_WORKSHOP_SCREENSHOTS} screenshots`);
const data: Record<string, unknown> = {
...parsed,
widgetSchema: parsed.type === "customCss" ? WORKSHOP_CSS_SCHEMA : CUSTOM_WIDGET_SCHEMA,
revision: current.revision + 1,
};
if (additions.length > 0) data["screenshots+"] = additions;
if (removals.length > 0) data["screenshots-"] = removals;
try {
await this.pocketBase.collection("submissions").update(id, data);
return this.get(id);
@@ -554,8 +601,12 @@ export class WorkshopBackend {
workshopCommentSchema.parse({
...row,
authorName: row.expand?.author?.displayName,
authorAvatarUrl: row.expand?.author?.avatarUrl,
authorGithubProfileUrl: row.expand?.author?.githubProfileUrl,
authorAvatarUrl: row.expand?.author ? this.userAvatarUrl(row.expand.author) : undefined,
authorGithubProfileUrl:
row.expand?.author?.githubProfileUrl ||
(row.expand?.author?.githubUsername
? `https://github.com/${encodeURIComponent(row.expand.author.githubUsername)}`
: undefined),
}),
);
} catch (error) {
@@ -571,8 +622,12 @@ export class WorkshopBackend {
return workshopCommentSchema.parse({
...row,
authorName: row.expand?.author?.displayName,
authorAvatarUrl: row.expand?.author?.avatarUrl,
authorGithubProfileUrl: row.expand?.author?.githubProfileUrl,
authorAvatarUrl: row.expand?.author ? this.userAvatarUrl(row.expand.author) : undefined,
authorGithubProfileUrl:
row.expand?.author?.githubProfileUrl ||
(row.expand?.author?.githubUsername
? `https://github.com/${encodeURIComponent(row.expand.author.githubUsername)}`
: undefined),
});
} catch (error) {
throw workshopError(error, "Failed to post comment");
@@ -587,8 +642,12 @@ export class WorkshopBackend {
return workshopCommentSchema.parse({
...row,
authorName: row.expand?.author?.displayName,
authorAvatarUrl: row.expand?.author?.avatarUrl,
authorGithubProfileUrl: row.expand?.author?.githubProfileUrl,
authorAvatarUrl: row.expand?.author ? this.userAvatarUrl(row.expand.author) : undefined,
authorGithubProfileUrl:
row.expand?.author?.githubProfileUrl ||
(row.expand?.author?.githubUsername
? `https://github.com/${encodeURIComponent(row.expand.author.githubUsername)}`
: undefined),
});
} catch (error) {
throw workshopError(error, "Failed to update comment");
@@ -661,12 +720,12 @@ export const workshopUpdateMutationOptions = (backend: WorkshopBackend) =>
mutationFn: ({
id,
input,
screenshots = [],
screenshotChanges,
}: {
id: string;
input: WorkshopSubmissionInput;
screenshots?: File[];
}) => backend.update(id, input, screenshots),
screenshotChanges?: WorkshopScreenshotChanges;
}) => backend.update(id, input, screenshotChanges),
});
export const workshopDeleteMutationOptions = (backend: WorkshopBackend) =>
+36
View File
@@ -82,6 +82,42 @@ describe("WorkshopBackend", () => {
expect(mocks.autoCancellation).toHaveBeenCalledWith(false);
});
test("appends and removes screenshots without replacing retained files", async () => {
const client = new WorkshopBackend("https://workshop.example.com");
const current = {
...listingRecord(),
type: "customCss" as const,
content: ".dashboard { color: red; }",
screenshots: ["keep.png", "remove.png"],
revision: 2,
};
vi.spyOn(client, "get").mockResolvedValue(current as never);
mocks.update.mockResolvedValue(current);
const addition = new File(["image"], "new.png", { type: "image/png" });
await client.update(
current.id,
{
type: "customCss",
title: current.title,
description: current.description,
content: current.content,
changelog: "Updated screenshots",
outdated: false,
},
{ additions: [addition], removals: ["remove.png"] },
);
expect(mocks.update).toHaveBeenCalledWith(
current.id,
expect.objectContaining({
"screenshots+": [addition],
"screenshots-": ["remove.png"],
revision: 3,
}),
);
});
test("uses PocketBase filtering and pagination when the listing view is current", async () => {
mocks.getList.mockResolvedValue({
page: 1,
-393
View File
@@ -1,393 +0,0 @@
import PocketBase, { ClientResponseError } from "pocketbase";
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
import type {
WorkshopComment,
WorkshopReport,
WorkshopSubmissionDetail,
WorkshopSubmissionInput,
WorkshopSubmissionSummary,
WorkshopSubmissionType,
WorkshopUser,
WorkshopVote,
} from "./schema";
import {
WORKSHOP_API_URL,
WORKSHOP_REQUEST_TIMEOUT_MS,
workshopCommentSchema,
workshopReportSchema,
workshopSubmissionDetailSchema,
workshopSubmissionSummarySchema,
workshopSubmissionInputSchema,
workshopScreenshotsSchema,
workshopUserSchema,
workshopVoteSchema,
WORKSHOP_CSS_SCHEMA,
} from "./schema";
export interface WorkshopListOptions {
page?: number;
perPage?: number;
sort?: "top" | "newest" | "recent" | "discussed";
search?: string;
author?: string;
type?: WorkshopSubmissionType | "all";
includeOutdated?: boolean;
signal?: AbortSignal;
}
export interface WorkshopPage<T> {
page: number;
perPage: number;
totalPages: number;
totalItems: number;
items: T[];
}
const requestSignal = (signal?: AbortSignal) =>
AbortSignal.any(
signal
? [signal, AbortSignal.timeout(WORKSHOP_REQUEST_TIMEOUT_MS)]
: [AbortSignal.timeout(WORKSHOP_REQUEST_TIMEOUT_MS)],
);
function oauthText(...values: unknown[]) {
return values.find((value): value is string => typeof value === "string" && value.trim().length > 0)?.trim();
}
function githubProfile(meta: Record<string, unknown> | undefined, record: Record<string, unknown>) {
const rawUser =
meta?.rawUser && typeof meta.rawUser === "object" ? (meta.rawUser as Record<string, unknown>) : undefined;
const displayName = oauthText(meta?.name, meta?.username, rawUser?.name, rawUser?.login);
const avatarUrl = oauthText(meta?.avatarUrl, meta?.avatarURL, rawUser?.avatar_url);
const githubUsername = oauthText(meta?.username, rawUser?.login);
return {
displayName: (displayName ?? `GitHub user ${String(record.id ?? "").slice(0, 8)}`).slice(0, 100),
avatarUrl: avatarUrl?.startsWith("https://") ? avatarUrl : undefined,
githubUsername,
githubProfileUrl: githubUsername ? `https://github.com/${encodeURIComponent(githubUsername)}` : undefined,
};
}
const listingSort = (sort: WorkshopListOptions["sort"]) => {
if (sort === "newest") return "-created";
if (sort === "recent") return "-updated";
if (sort === "discussed") return "-commentCount,-created";
return "-score,-created";
};
function workshopError(error: unknown, fallback: string) {
const rawMessage = error instanceof Error ? error.message : String(error ?? "");
if (/popup.*block|blocked.*popup/iu.test(rawMessage))
return new Error("GitHub sign-in popup was blocked. Allow popups for this site and try again.", { cause: error });
if (/cancel|closed.*popup|popup.*closed/iu.test(rawMessage))
return new Error("GitHub sign-in was cancelled before it completed.", { cause: error });
if (
/provider|oauth|redirect|callback|client.?id/iu.test(rawMessage) &&
/invalid|missing|not found|failed/iu.test(rawMessage)
)
return new Error("GitHub sign-in is not configured correctly on the Workshop server.", { cause: error });
if (error instanceof ClientResponseError) {
const sdkMessage = error.data?.message || error.message;
const originalMessage =
error.originalError instanceof Error ? error.originalError.message : String(error.originalError?.message ?? "");
const message = !sdkMessage || sdkMessage === "Something went wrong." ? originalMessage || fallback : sdkMessage;
return new Error(message, { cause: error });
}
if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError"))
return new Error(`${fallback}. The request timed out.`, { cause: error });
return error instanceof Error ? error : new Error(fallback);
}
export class WorkshopClient {
public readonly pocketBase: PocketBase;
public constructor(public readonly baseUrl = WORKSHOP_API_URL) {
this.pocketBase = new PocketBase(baseUrl.replace(/\/$/u, ""));
}
public get currentUser(): WorkshopUser | null {
const record = this.pocketBase.authStore.record;
if (!record) return null;
return workshopUserSchema.parse({
id: record.id,
displayName: record.displayName || record.name || record.username || "Community member",
avatarUrl: record.avatarUrl || (record.avatar ? this.pocketBase.files.getURL(record, record.avatar) : ""),
githubUsername: record.githubUsername || record.username || "",
githubProfileUrl: record.githubProfileUrl || "",
isAdmin: record.isAdmin === true,
});
}
public subscribeToAuth(listener: (user: WorkshopUser | null) => void) {
return this.pocketBase.authStore.onChange(() => listener(this.currentUser), true);
}
public async refreshAuth() {
if (!this.pocketBase.authStore.isValid) return null;
try {
await this.pocketBase.collection("users").authRefresh();
return this.currentUser;
} catch (error) {
if (error instanceof ClientResponseError && (error.status === 401 || error.status === 403)) {
this.pocketBase.authStore.clear();
return null;
}
return this.currentUser;
}
}
public async signInWithGitHub() {
try {
const auth = await this.pocketBase.collection("users").authWithOAuth2({
provider: "github",
createData: { displayName: "GitHub user" },
});
const profile = githubProfile(auth.meta, auth.record);
const updated = await this.pocketBase.collection("users").update(auth.record.id, profile);
this.pocketBase.authStore.save(auth.token, updated);
return this.currentUser;
} catch (error) {
throw workshopError(error, "GitHub sign-in failed");
}
}
public signOut() {
this.pocketBase.authStore.clear();
}
public async list(options: WorkshopListOptions = {}): Promise<WorkshopPage<WorkshopSubmissionSummary>> {
const filters: string[] = [];
if (options.type && options.type !== "all")
filters.push(this.pocketBase.filter("type = {:type}", { type: options.type }));
if (options.search?.trim())
filters.push(this.pocketBase.filter("title ~ {:search}", { search: options.search.trim() }));
if (options.author) filters.push(this.pocketBase.filter("author = {:author}", { author: options.author }));
if (options.includeOutdated === false) filters.push("outdated = false");
try {
const result = await this.pocketBase
.collection("workshop_listings")
.getList(options.page ?? 1, options.perPage ?? 24, {
filter: filters.join(" && "),
sort: listingSort(options.sort),
signal: requestSignal(options.signal),
});
return { ...result, items: result.items.map((item) => workshopSubmissionSummarySchema.parse(item)) };
} catch (error) {
throw workshopError(error, "Failed to load Workshop submissions");
}
}
public async listAll(options: Omit<WorkshopListOptions, "page" | "perPage"> = {}) {
const filters: string[] = [];
if (options.type && options.type !== "all")
filters.push(this.pocketBase.filter("type = {:type}", { type: options.type }));
if (options.search?.trim())
filters.push(this.pocketBase.filter("title ~ {:search}", { search: options.search.trim() }));
if (options.author) filters.push(this.pocketBase.filter("author = {:author}", { author: options.author }));
if (options.includeOutdated === false) filters.push("outdated = false");
try {
const items = await this.pocketBase.collection("workshop_listings").getFullList({
batch: 200,
filter: filters.join(" && "),
sort: listingSort(options.sort),
signal: requestSignal(options.signal),
});
return items.map((item) => workshopSubmissionSummarySchema.parse(item));
} catch (error) {
throw workshopError(error, "Failed to load Workshop submissions");
}
}
public async get(id: string, signal?: AbortSignal): Promise<WorkshopSubmissionDetail> {
try {
const [submission, listing] = await Promise.all([
this.pocketBase.collection("submissions").getOne(id, { signal: requestSignal(signal) }),
this.pocketBase.collection("workshop_listings").getOne(id, { signal: requestSignal(signal) }),
]);
return workshopSubmissionDetailSchema.parse({ ...submission, ...listing, content: submission.content });
} catch (error) {
throw workshopError(error, "Submission not found");
}
}
public fileUrl(submissionId: string, filename: string, thumb?: string) {
return `${this.baseUrl}/api/files/submissions/${submissionId}/${encodeURIComponent(filename)}${thumb ? `?thumb=${encodeURIComponent(thumb)}` : ""}`;
}
public async create(input: WorkshopSubmissionInput, screenshots: File[] = []) {
const parsed = workshopSubmissionInputSchema.parse(input);
workshopScreenshotsSchema.parse(screenshots);
const data = new FormData();
Object.entries(parsed).forEach(([key, value]) => data.set(key, typeof value === "string" ? value : String(value)));
data.set("widgetSchema", parsed.type === "customCss" ? WORKSHOP_CSS_SCHEMA : CUSTOM_WIDGET_SCHEMA);
data.set("author", this.currentUser?.id ?? "");
data.set("revision", "1");
screenshots.forEach((file) => data.append("screenshots", file));
try {
const result = await this.pocketBase.collection("submissions").create(data);
return this.get(result.id);
} catch (error) {
throw workshopError(error, "Failed to publish submission");
}
}
public async update(id: string, input: WorkshopSubmissionInput, screenshots: File[] = []) {
const parsed = workshopSubmissionInputSchema.parse(input);
workshopScreenshotsSchema.parse(screenshots);
const current = await this.get(id);
const data = new FormData();
Object.entries(parsed).forEach(([key, value]) => data.set(key, typeof value === "string" ? value : String(value)));
data.set("widgetSchema", parsed.type === "customCss" ? WORKSHOP_CSS_SCHEMA : CUSTOM_WIDGET_SCHEMA);
data.set("revision", String(current.revision + 1));
screenshots.forEach((file) => data.append("screenshots", file));
try {
await this.pocketBase.collection("submissions").update(id, data);
return this.get(id);
} catch (error) {
throw workshopError(error, "Failed to update submission");
}
}
public async delete(id: string) {
try {
await this.pocketBase.collection("submissions").delete(id);
} catch (error) {
throw workshopError(error, "Failed to delete submission");
}
}
public async vote(submission: string, value: 1 | -1): Promise<WorkshopVote | null> {
const user = this.currentUser?.id ?? "";
const filter = this.pocketBase.filter("submission = {:submission} && user = {:user}", { submission, user });
try {
const existing = await this.pocketBase
.collection("votes")
.getFirstListItem(filter)
.catch((error: unknown) =>
error instanceof ClientResponseError && error.status === 404 ? null : Promise.reject(error),
);
if (existing?.value === value) {
await this.pocketBase.collection("votes").delete(existing.id);
return null;
}
const record = existing
? await this.pocketBase.collection("votes").update(existing.id, { value })
: await this.pocketBase.collection("votes").create({ submission, user, value });
return workshopVoteSchema.parse(record);
} catch (error) {
throw workshopError(error, "Failed to save vote");
}
}
public async report(submission: string, category: WorkshopReport["category"], explanation: string) {
try {
return workshopReportSchema.parse(
await this.pocketBase.collection("reports").create({
submission,
reporter: this.currentUser?.id ?? "",
category,
explanation: explanation.trim(),
status: "open",
}),
);
} catch (error) {
throw workshopError(error, "Failed to report submission");
}
}
public async listReports(): Promise<WorkshopReport[]> {
try {
const items = await this.pocketBase
.collection("reports")
.getFullList({ batch: 200, filter: "status = 'open'", sort: "-created", expand: "reporter,submission" });
return items.map((item) =>
workshopReportSchema.parse({
...item,
reporterName: item.expand?.reporter?.displayName,
submissionTitle: item.expand?.submission?.title,
}),
);
} catch (error) {
throw workshopError(error, "Failed to load reports");
}
}
public async dismissReport(id: string) {
try {
await this.pocketBase.collection("reports").update(id, { status: "dismissed" });
} catch (error) {
throw workshopError(error, "Failed to dismiss report");
}
}
public async toggleOutdated(id: string, outdated: boolean) {
try {
await this.pocketBase.collection("submissions").update(id, { outdated });
return this.get(id);
} catch (error) {
throw workshopError(error, "Failed to update submission status");
}
}
public async listComments(submission: string): Promise<WorkshopComment[]> {
try {
const rows = await this.pocketBase.collection("comments").getFullList({
filter: this.pocketBase.filter("submission = {:submission}", { submission }),
sort: "-created",
expand: "author",
});
return rows.map((row) =>
workshopCommentSchema.parse({
...row,
authorName: row.expand?.author?.displayName,
authorAvatarUrl: row.expand?.author?.avatarUrl,
authorGithubProfileUrl: row.expand?.author?.githubProfileUrl,
}),
);
} catch (error) {
throw workshopError(error, "Failed to load comments");
}
}
public async createComment(submission: string, content: string): Promise<WorkshopComment> {
try {
const row = await this.pocketBase
.collection("comments")
.create({ submission, author: this.currentUser?.id ?? "", content: content.trim() }, { expand: "author" });
return workshopCommentSchema.parse({
...row,
authorName: row.expand?.author?.displayName,
authorAvatarUrl: row.expand?.author?.avatarUrl,
authorGithubProfileUrl: row.expand?.author?.githubProfileUrl,
});
} catch (error) {
throw workshopError(error, "Failed to post comment");
}
}
public async updateComment(id: string, content: string): Promise<WorkshopComment> {
try {
const row = await this.pocketBase
.collection("comments")
.update(id, { content: content.trim() }, { expand: "author" });
return workshopCommentSchema.parse({
...row,
authorName: row.expand?.author?.displayName,
authorAvatarUrl: row.expand?.author?.avatarUrl,
authorGithubProfileUrl: row.expand?.author?.githubProfileUrl,
});
} catch (error) {
throw workshopError(error, "Failed to update comment");
}
}
public async deleteComment(id: string) {
try {
await this.pocketBase.collection("comments").delete(id);
} catch (error) {
throw workshopError(error, "Failed to delete comment");
}
}
}
+1 -1
View File
@@ -1,2 +1,2 @@
export * from "./client";
export * from "./backend";
export * from "./schema";
+532 -7
View File
@@ -147,6 +147,9 @@ catalogs:
'@semantic-release/release-notes-generator':
specifier: ^14.1.1
version: 14.1.1
'@shadcn/react':
specifier: ^0.2.1
version: 0.2.1
'@t3-oss/env-nextjs':
specifier: ^0.13.11
version: 0.13.11
@@ -336,6 +339,9 @@ catalogs:
clsx:
specifier: ^2.1.1
version: 2.1.1
cmdk:
specifier: ^1.1.1
version: 1.1.1
codemirror:
specifier: ^6.0.2
version: 6.0.2
@@ -360,6 +366,9 @@ catalogs:
crypto-js:
specifier: ^4.2.0
version: 4.2.0
date-fns:
specifier: ^4.4.0
version: 4.4.0
dayjs:
specifier: ^1.11.21
version: 1.11.21
@@ -387,6 +396,9 @@ catalogs:
drizzle-zod:
specifier: ^0.8.3
version: 0.8.3
embla-carousel-react:
specifier: ^8.6.0
version: 8.6.0
esbuild:
specifier: ^0.28.0
version: 0.28.0
@@ -405,6 +417,9 @@ catalogs:
ical.js:
specifier: ^2.2.1
version: 2.2.1
input-otp:
specifier: ^1.4.2
version: 1.4.2
ioredis:
specifier: 5.11.1
version: 5.11.1
@@ -459,6 +474,9 @@ catalogs:
next-intl:
specifier: 4.13.1
version: 4.13.1
next-themes:
specifier: ^0.4.6
version: 0.4.6
node-loader:
specifier: ^2.1.0
version: 2.1.0
@@ -495,6 +513,9 @@ catalogs:
react:
specifier: 19.2.7
version: 19.2.7
react-day-picker:
specifier: ^10.0.1
version: 10.0.1
react-dom:
specifier: 19.2.7
version: 19.2.7
@@ -507,11 +528,14 @@ catalogs:
react-quill-new:
specifier: ^3.8.3
version: 3.8.3
react-resizable-panels:
specifier: ^4.12.2
version: 4.12.2
react-simple-code-editor:
specifier: ^0.14.1
version: 0.14.1
recharts:
specifier: ^3.9.1
specifier: ^3.8.0
version: 3.9.1
sass:
specifier: ^1.101.0
@@ -522,6 +546,9 @@ catalogs:
semver-parser:
specifier: ^4.2.1
version: 4.2.1
sonner:
specifier: ^2.0.7
version: 2.0.7
sql.js:
specifier: ^1.14.1
version: 1.14.1
@@ -708,7 +735,7 @@ importers:
dependencies:
'@base-ui/react':
specifier: ^1.5.0
version: 1.6.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 1.6.0(@date-fns/tz@1.5.0)(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@docusaurus/core':
specifier: ^3.10.1
version: 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.3(@swc/helpers@0.5.15))(esbuild@0.28.1)(postcss@8.5.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@swc/helpers@0.5.15)(esbuild@0.28.1)(postcss@8.5.16))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.7))(@rspack/core@1.7.11(@swc/helpers@0.5.15))(@swc/core@1.15.3(@swc/helpers@0.5.15))(@typescript/typescript6@6.0.2)(esbuild@0.28.1)(postcss@8.5.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -751,6 +778,9 @@ importers:
'@scalar/api-reference-react':
specifier: 'catalog:'
version: 0.9.54(@typescript/typescript6@6.0.2)(nprogress@0.2.0)(react@19.2.7)(tailwindcss@4.3.2)(zod@4.4.3)
'@shadcn/react':
specifier: 'catalog:'
version: 0.2.1(@types/react@19.2.17)(react@19.2.7)
'@tabler/icons-react':
specifier: 'catalog:'
version: 3.44.0(react@19.2.7)
@@ -763,15 +793,30 @@ importers:
clsx:
specifier: 'catalog:'
version: 2.1.1
cmdk:
specifier: 'catalog:'
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
date-fns:
specifier: 'catalog:'
version: 4.4.0
dayjs:
specifier: 'catalog:'
version: 1.11.21
docusaurus-plugin-image-zoom:
specifier: ^3.0.1
version: 3.0.1(@docusaurus/theme-classic@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.3(@swc/helpers@0.5.15))(esbuild@0.28.1)(postcss@8.5.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@swc/helpers@0.5.15)(esbuild@0.28.1)(postcss@8.5.16))(@rspack/core@1.7.11(@swc/helpers@0.5.15))(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/react@19.2.17)(@typescript/typescript6@6.0.2)(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))
embla-carousel-react:
specifier: 'catalog:'
version: 8.6.0(react@19.2.7)
input-otp:
specifier: 'catalog:'
version: 1.4.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
leader-line-new:
specifier: ^1.1.9
version: 1.1.9
next-themes:
specifier: 'catalog:'
version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
pocketbase:
specifier: 'catalog:'
version: 0.26.9
@@ -790,9 +835,21 @@ importers:
react:
specifier: 'catalog:'
version: 19.2.7
react-day-picker:
specifier: 'catalog:'
version: 10.0.1(@types/react@19.2.17)(react@19.2.7)
react-dom:
specifier: 'catalog:'
version: 19.2.7(react@19.2.7)
react-resizable-panels:
specifier: 'catalog:'
version: 4.12.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
recharts:
specifier: 3.8.0
version: 3.8.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)
sonner:
specifier: 'catalog:'
version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
swiper:
specifier: ^14.0.5
version: 14.0.5
@@ -4216,6 +4273,9 @@ packages:
'@dabh/diagnostics@2.0.8':
resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==}
'@date-fns/tz@1.5.0':
resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
'@discoveryjs/json-ext@0.5.7':
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
engines: {node: '>=10.0.0'}
@@ -6683,6 +6743,168 @@ packages:
resolution: {integrity: sha512-OWOaE7Ieufo7VMv957iahOzv/w5oXyyXO28Jh2x6rLU96ZLqC508kAKTgdMJRXgVjda2s+/0C6Kno+k16OmJsQ==}
engines: {bun: '>=1.0.0', node: '>=20.0.0'}
'@radix-ui/primitive@1.1.5':
resolution: {integrity: sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==}
'@radix-ui/react-compose-refs@1.1.3':
resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-context@1.2.0':
resolution: {integrity: sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-dialog@1.1.19':
resolution: {integrity: sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-dismissable-layer@1.1.15':
resolution: {integrity: sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-focus-guards@1.1.4':
resolution: {integrity: sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-focus-scope@1.1.12':
resolution: {integrity: sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-id@1.1.2':
resolution: {integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-portal@1.1.13':
resolution: {integrity: sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-presence@1.1.7':
resolution: {integrity: sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@2.1.7':
resolution: {integrity: sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.3.0':
resolution: {integrity: sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-callback-ref@1.1.2':
resolution: {integrity: sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-controllable-state@1.2.3':
resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-effect-event@0.0.3':
resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-layout-effect@1.1.2':
resolution: {integrity: sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@react-spring/animated@10.1.0':
resolution: {integrity: sha512-dvGVSfNYhbkzTAnyB1S1940ZiKp6/Ey+b2vQc70dJ5k6gzH/GdlfeDzCh65V94b6OPqF7Xl4VUICVUQpc07iUg==}
peerDependencies:
@@ -7083,6 +7305,17 @@ packages:
peerDependencies:
semantic-release: '>=20.1.0'
'@shadcn/react@0.2.1':
resolution: {integrity: sha512-5krgi3dRMKb5jH6a+qPzVJUy/54s0kKE4Rw4LjDfLqOdVQTWKUgxWf1kW8r912I0jX/Lzxqc+pgjkjWxUIK5BQ==}
peerDependencies:
'@types/react': '>=19'
react: '>=19'
peerDependenciesMeta:
'@types/react':
optional: true
react:
optional: true
'@sideway/address@4.1.5':
resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==}
@@ -9175,6 +9408,12 @@ packages:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'}
cmdk@1.1.1:
resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
co-body@6.2.0:
resolution: {integrity: sha512-Kbpv2Yd1NdL1V/V4cwLVxraHDV6K8ayohr2rmH0J87Er8+zJjcTa6dAn9QMPC9CRgU8+aNajKbSf1TzDB1yKPA==}
engines: {node: '>=8.0.0'}
@@ -9758,6 +9997,9 @@ packages:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
date-fns@4.4.0:
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
dayjs@1.11.21:
resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==}
@@ -10104,6 +10346,19 @@ packages:
electron-to-chromium@1.5.361:
resolution: {integrity: sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==}
embla-carousel-react@8.6.0:
resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==}
peerDependencies:
react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
embla-carousel-reactive-utils@8.6.0:
resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==}
peerDependencies:
embla-carousel: 8.6.0
embla-carousel@8.6.0:
resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==}
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
@@ -11071,6 +11326,9 @@ packages:
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
immer@10.2.0:
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
immer@11.1.9:
resolution: {integrity: sha512-sc/z0Cyti70bZa0ZU4sWfAElfovFb9Ni8tArJZLuklYWxegPiK3pDOql1Rq5H0FIRAW9LSQRG6OX4KqBldbhBA==}
@@ -11129,6 +11387,12 @@ packages:
inline-style-parser@0.2.4:
resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==}
input-otp@1.4.2:
resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
internmap@1.0.1:
resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==}
@@ -12299,6 +12563,12 @@ packages:
typescript:
optional: true
next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@16.2.10:
resolution: {integrity: sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==}
engines: {node: '>=20.9.0'}
@@ -13567,6 +13837,16 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
react-day-picker@10.0.1:
resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==}
engines: {node: '>=18'}
peerDependencies:
'@types/react': '>=16.8.0'
react: '>=16.8.0'
peerDependenciesMeta:
'@types/react':
optional: true
react-dom@19.2.7:
resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==}
peerDependencies:
@@ -13653,6 +13933,12 @@ packages:
'@types/react':
optional: true
react-resizable-panels@4.12.2:
resolution: {integrity: sha512-NwY5LCo4WrxVvDh0xoMML6EMLPONP/8ckKcIdpnojxexoatZdjLiRqLJQjQK5CPkd4SYiB/2M5BVrjZBQtOO7Q==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
react-router-config@5.1.1:
resolution: {integrity: sha512-DuanZjaD8mQp1ppHjgnnUnyOlqYXZVjnov/JzFhjLEwd3Z4dYjMSnqrEzzGThH47vpCOqPPwJM2FtthLeJ8Pbg==}
peerDependencies:
@@ -13743,6 +14029,14 @@ packages:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
recharts@3.8.0:
resolution: {integrity: sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^19.2.7
recharts@3.9.1:
resolution: {integrity: sha512-WMcwlXcB7l+BbxiEdyClkG+1sxrMHNZpzT577LEvU4+rXPd8oTAy1wXk72hnk2KOOmxuLvw3z5DtXT7HEAydtg==}
engines: {node: '>=18'}
@@ -13886,6 +14180,9 @@ packages:
requires-port@1.0.0:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
reselect@5.2.0:
resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
@@ -14219,6 +14516,12 @@ packages:
sonic-boom@4.2.1:
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
sonner@2.0.7:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
peerDependencies:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
sort-css-media-queries@2.2.0:
resolution: {integrity: sha512-0xtkGhWCC9MGt/EzgnvbbbKhqWjl1+/rncmhTh5qCpbYguXh6S/qwePfv/JQ8jePXXmqingylxoC49pCkSPIbA==}
engines: {node: '>= 6.3.0'}
@@ -16563,7 +16866,7 @@ snapshots:
'@balena/dockerignore@1.0.2': {}
'@base-ui/react@1.6.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@base-ui/react@1.6.0(@date-fns/tz@1.5.0)(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@babel/runtime': 7.29.7
'@base-ui/utils': 0.3.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -16573,7 +16876,9 @@ snapshots:
react-dom: 19.2.7(react@19.2.7)
use-sync-external-store: 1.6.0(react@19.2.7)
optionalDependencies:
'@date-fns/tz': 1.5.0
'@types/react': 19.2.17
date-fns: 4.4.0
'@base-ui/utils@0.3.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
@@ -17147,6 +17452,8 @@ snapshots:
enabled: 2.0.0
kuler: 2.0.0
'@date-fns/tz@1.5.0': {}
'@discoveryjs/json-ext@0.5.7': {}
'@dnd-kit/accessibility@3.1.1(react@19.2.7)':
@@ -20162,6 +20469,141 @@ snapshots:
'@pwshub/bellajs@13.0.2': {}
'@radix-ui/primitive@1.1.5': {}
'@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.17)(react@19.2.7)':
dependencies:
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-context@1.2.0(@types/react@19.2.17)(react@19.2.7)':
dependencies:
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-dialog@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.5
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
aria-hidden: 1.2.6
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-dismissable-layer@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.5
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-focus-guards@1.1.4(@types/react@19.2.17)(react@19.2.7)':
dependencies:
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-focus-scope@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-id@1.1.2(@types/react@19.2.17)(react@19.2.7)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-portal@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-presence@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-primitive@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-slot@1.3.0(@types/react@19.2.17)(react@19.2.7)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-use-callback-ref@1.1.2(@types/react@19.2.17)(react@19.2.7)':
dependencies:
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.2.17)(react@19.2.7)':
dependencies:
'@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-use-effect-event@0.0.3(@types/react@19.2.17)(react@19.2.7)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-use-layout-effect@1.1.2(@types/react@19.2.17)(react@19.2.7)':
dependencies:
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@react-spring/animated@10.1.0(react@19.2.7)':
dependencies:
'@react-spring/shared': 10.1.0(react@19.2.7)
@@ -20799,6 +21241,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@shadcn/react@0.2.1(@types/react@19.2.17)(react@19.2.7)':
optionalDependencies:
'@types/react': 19.2.17
react: 19.2.7
'@sideway/address@4.1.5':
dependencies:
'@hapi/hoek': 9.3.0
@@ -22964,6 +23411,18 @@ snapshots:
cluster-key-slot@1.1.2: {}
cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-dialog': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
transitivePeerDependencies:
- '@types/react'
- '@types/react-dom'
co-body@6.2.0:
dependencies:
'@hapi/bourne': 3.0.0
@@ -23592,6 +24051,8 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
date-fns@4.4.0: {}
dayjs@1.11.21: {}
debounce@1.2.1: {}
@@ -23862,6 +24323,18 @@ snapshots:
electron-to-chromium@1.5.361: {}
embla-carousel-react@8.6.0(react@19.2.7):
dependencies:
embla-carousel: 8.6.0
embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0)
react: 19.2.7
embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0):
dependencies:
embla-carousel: 8.6.0
embla-carousel@8.6.0: {}
emoji-regex@10.6.0: {}
emoji-regex@8.0.0: {}
@@ -24394,9 +24867,9 @@ snapshots:
dependencies:
websocket-driver: 0.7.5
fdir@6.5.0(picomatch@4.0.4):
fdir@6.5.0(picomatch@4.0.5):
optionalDependencies:
picomatch: 4.0.4
picomatch: 4.0.5
fecha@4.2.3: {}
@@ -25158,6 +25631,8 @@ snapshots:
immediate@3.0.6: {}
immer@10.2.0: {}
immer@11.1.9: {}
immutable@5.1.5: {}
@@ -25198,6 +25673,11 @@ snapshots:
inline-style-parser@0.2.4: {}
input-otp@1.4.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
internmap@1.0.1: {}
internmap@2.0.3: {}
@@ -26590,6 +27070,11 @@ snapshots:
transitivePeerDependencies:
- '@swc/helpers'
next-themes@0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
next@16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0):
dependencies:
'@next/env': 16.2.10
@@ -27931,6 +28416,14 @@ snapshots:
minimist: 1.2.8
strip-json-comments: 2.0.1
react-day-picker@10.0.1(@types/react@19.2.17)(react@19.2.7):
dependencies:
'@date-fns/tz': 1.5.0
date-fns: 4.4.0
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
react-dom@19.2.7(react@19.2.7):
dependencies:
react: 19.2.7
@@ -28020,6 +28513,11 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
react-resizable-panels@4.12.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
react-router-config@5.1.1(react-router@5.3.4(react@19.2.7))(react@19.2.7):
dependencies:
'@babel/runtime': 7.29.7
@@ -28143,6 +28641,26 @@ snapshots:
real-require@0.2.0: {}
recharts@3.8.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1))(react@19.2.7)
clsx: 2.1.1
decimal.js-light: 2.5.1
es-toolkit: 1.46.1
eventemitter3: 5.0.4
immer: 10.2.0
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
react-is: 19.2.7
react-redux: 9.2.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1)
reselect: 5.1.1
tiny-invariant: 1.3.3
use-sync-external-store: 1.6.0(react@19.2.7)
victory-vendor: 37.3.6
transitivePeerDependencies:
- '@types/react'
- redux
recharts@3.9.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1))(react@19.2.7)
@@ -28378,6 +28896,8 @@ snapshots:
requires-port@1.0.0: {}
reselect@5.1.1: {}
reselect@5.2.0: {}
reserved-identifiers@1.2.0: {}
@@ -28838,6 +29358,11 @@ snapshots:
dependencies:
atomic-sleep: 1.0.0
sonner@2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
sort-css-media-queries@2.2.0: {}
source-map-js@1.2.1: {}
@@ -29295,8 +29820,8 @@ snapshots:
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
fdir: 6.5.0(picomatch@4.0.5)
picomatch: 4.0.5
tinypool@1.1.1: {}
+10 -1
View File
@@ -100,6 +100,7 @@ catalog:
"@types/react": 19.2.17
"@types/react-dom": 19.2.3
"@scalar/api-reference-react": ^0.9.54
"@shadcn/react": ^0.2.1
"@types/video.js": ^7.3.58
"@types/ws": ^8.18.1
"@uiw/react-codemirror": ^4.25.11
@@ -130,7 +131,9 @@ catalog:
croner: 10.0.1
cross-env: ^10.1.0
crypto-js: ^4.2.0
cmdk: ^1.1.1
dayjs: ^1.11.21
date-fns: ^4.4.0
deepmerge: 4.3.1
dns-caching: ^0.2.9
dockerode: ^5.0.1
@@ -139,12 +142,14 @@ catalog:
drizzle-kit: ^0.31.10
drizzle-orm: ^0.45.2
drizzle-zod: ^0.8.3
embla-carousel-react: ^8.6.0
esbuild: ^0.28.0
fast-xml-parser: ^5.9.3
fastify: ^5.9.0
flag-icons: ^7.5.0
glob: ^13.0.6
ical.js: ^2.2.1
input-otp: ^1.4.2
ioredis: 5.11.1
isomorphic-dompurify: ^3.18.0
jotai: ^2.20.1
@@ -163,6 +168,7 @@ catalog:
next: 16.2.10
next-auth: 5.0.0-beta.31
next-intl: 4.13.1
next-themes: ^0.4.6
node-loader: ^2.1.0
octokit: ^5.0.5
oxfmt: ^0.58.0
@@ -172,15 +178,18 @@ catalog:
prismjs: ^1.30.0
proxmox-api: 1.1.1
react: 19.2.7
react-day-picker: ^10.0.1
react-dom: 19.2.7
react-error-boundary: ^6.1.2
react-markdown: ^10.1.0
react-quill-new: ^3.8.3
react-simple-code-editor: ^0.14.1
recharts: ^3.9.1
react-resizable-panels: ^4.12.2
recharts: ^3.8.0
sass: ^1.101.0
semantic-release: ^25.0.5
semver-parser: ^4.2.1
sonner: ^2.0.7
superjson: 2.2.6
svgson: ^5.3.1
testcontainers: ^12.0.4