@panaversity/ksor 0.0.26 → 0.0.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/dist/cli.mjs +67 -4
- package/dist/{gateway-api-BF06IsJ--D-eI--yB.mjs → gateway-api-8lNruq9e-CuohjtoK.mjs} +1 -1
- package/dist/gateway.mjs +1 -1
- package/package.json +1 -1
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +83 -2
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +83 -2
- package/templates/scaffold/AGENTS.md +49 -0
- package/templates/scaffold/Dockerfile +10 -6
- package/templates/scaffold/README.md +3 -2
- package/templates/scaffold/dockerignore +5 -0
- package/templates/scaffold/knowledge/what-is-a-ksor.flashcards.yaml +25 -0
- package/templates/scaffold/knowledge/what-is-a-ksor.summary.md +15 -0
- package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +45 -7
- package/templates/scaffold/system/site/components/document-actions.tsx +106 -0
- package/templates/scaffold/system/site/components/flashcards.tsx +743 -0
- package/templates/scaffold/system/site/components/governance.tsx +17 -25
- package/templates/scaffold/system/site/components/record-views.tsx +241 -0
- package/templates/scaffold/system/site/components/study-aids.tsx +61 -0
- package/templates/scaffold/system/site/components/ui/card.tsx +76 -0
- package/templates/scaffold/system/site/components/ui/dropdown-menu.tsx +229 -0
- package/templates/scaffold/system/site/components/ui/progress.tsx +29 -0
- package/templates/scaffold/system/site/lib/attachment-rule.ts +124 -0
- package/templates/scaffold/system/site/lib/attachments.ts +105 -0
- package/templates/scaffold/system/site/lib/deck.ts +59 -0
- package/templates/scaffold/system/site/lib/reading-time.ts +45 -0
- package/templates/scaffold/system/site/lib/srs.ts +219 -0
- package/templates/scaffold/system/site/lib/stage-knowledge.ts +68 -0
- package/templates/scaffold/system/site/source.config.ts +54 -1
- package/templates/scaffold/system/site/components/copy-markdown.tsx +0 -70
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { FileText } from "lucide-react";
|
|
2
1
|
import Link from "next/link";
|
|
3
2
|
|
|
4
|
-
import {
|
|
3
|
+
import { DocumentActions } from "@/components/document-actions";
|
|
5
4
|
import type { ReactElement } from "react";
|
|
6
5
|
|
|
7
6
|
import {
|
|
@@ -182,29 +181,22 @@ export function GovernanceMeta({
|
|
|
182
181
|
// On the governance row, not as a footnote below the sources: it is
|
|
183
182
|
// how a reader hands this document to an agent, and it was previously
|
|
184
183
|
// the smallest text on the page, last (research/site-design.md F2).
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
<FileText aria-hidden className="size-3.5" />
|
|
202
|
-
Markdown
|
|
203
|
-
</a>
|
|
204
|
-
{/* Beside the link, not instead of it: opening the markdown and
|
|
205
|
-
handing it to an agent are different acts, and a reader who wants
|
|
206
|
-
the second should not have to perform the first. */}
|
|
207
|
-
<CopyMarkdown href={markdownUrl} />
|
|
184
|
+
// ONE control rather than two: opening the markdown and copying it are
|
|
185
|
+
// different acts, but two bare controls on a row of read-only facts
|
|
186
|
+
// made the row look half clickable. It wears neither the bordered badge
|
|
187
|
+
// (that means "a status the record declares") nor the accent at rest —
|
|
188
|
+
// the page had gone blue enough that the accent had stopped meaning
|
|
189
|
+
// anything (owner, 2026-08-22).
|
|
190
|
+
// RIGHT-ALIGNED (owner, and a reversal). The 2026-08-21 finding stands
|
|
191
|
+
// on its own terms: `ms-auto` once parked this 498px from the nearest
|
|
192
|
+
// thing on a two-fact document, where it read as belonging to nothing.
|
|
193
|
+
// What changed is the column beside it — the reading time now sits at
|
|
194
|
+
// the right end of the strip directly below, so the far edge is no
|
|
195
|
+
// longer empty space but a line the eye already follows. The two form
|
|
196
|
+
// a right-hand column of things you DO, against a left-hand row of
|
|
197
|
+
// things the record DECLARES.
|
|
198
|
+
<span className="ms-auto">
|
|
199
|
+
<DocumentActions href={markdownUrl} />
|
|
208
200
|
</span>
|
|
209
201
|
)}
|
|
210
202
|
</dl>
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Clock, FileText, ListTree } from "lucide-react";
|
|
4
|
+
import {
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useId,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
type ReactElement,
|
|
11
|
+
type ReactNode,
|
|
12
|
+
} from "react";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The two READINGS of one document: the record's own words, and its summary.
|
|
16
|
+
*
|
|
17
|
+
* Study aids are NOT here. A deck — and the quiz that will sit beside it — is
|
|
18
|
+
* something you do AFTER reading, so it belongs at the end of the page rather
|
|
19
|
+
* than behind a tab that hides the document while you use it. See
|
|
20
|
+
* components/study-aids.tsx.
|
|
21
|
+
*
|
|
22
|
+
* PRESENCE-DRIVEN. With neither attachment there is no tab strip at all — not a
|
|
23
|
+
* disabled tab, not a greyed affordance, not an empty panel. A control that
|
|
24
|
+
* cannot do anything is worse than no control, and a governed record whose
|
|
25
|
+
* documents mostly have no study attachments would otherwise grow a row of dead
|
|
26
|
+
* chrome on every page.
|
|
27
|
+
*
|
|
28
|
+
* EVERY PANEL IS IN THE SERVER-RENDERED HTML, hidden rather than unmounted.
|
|
29
|
+
* The panels arrive as props — React elements built on the server — so an agent
|
|
30
|
+
* parsing the page, a crawler, and a reader whose bundle failed all get the
|
|
31
|
+
* text. A summary that only exists once JavaScript runs is a summary missing
|
|
32
|
+
* from the printout, which is the same argument governance.tsx makes for
|
|
33
|
+
* rendering governance as plain markup.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
type ViewId = "document" | "summary";
|
|
37
|
+
|
|
38
|
+
interface View {
|
|
39
|
+
readonly id: ViewId;
|
|
40
|
+
readonly label: string;
|
|
41
|
+
readonly icon: ReactElement;
|
|
42
|
+
readonly panel: ReactNode;
|
|
43
|
+
/** About how long this reading takes, in minutes. */
|
|
44
|
+
readonly minutes?: number | undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RecordViewsProps {
|
|
48
|
+
/** The document's own body — always present. */
|
|
49
|
+
readonly children: ReactNode;
|
|
50
|
+
/** Rendered summary, or null when the document has none. */
|
|
51
|
+
readonly summary: ReactNode | null;
|
|
52
|
+
/** Minutes to read the document, and the summary if there is one. */
|
|
53
|
+
readonly documentMinutes?: number | undefined;
|
|
54
|
+
readonly summaryMinutes?: number | undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const ICON = "size-3.5 shrink-0";
|
|
58
|
+
|
|
59
|
+
export function RecordViews({
|
|
60
|
+
children,
|
|
61
|
+
summary,
|
|
62
|
+
documentMinutes,
|
|
63
|
+
summaryMinutes,
|
|
64
|
+
}: RecordViewsProps): ReactElement {
|
|
65
|
+
const views: View[] = [
|
|
66
|
+
{
|
|
67
|
+
id: "document",
|
|
68
|
+
label: "Document",
|
|
69
|
+
icon: <FileText className={ICON} />,
|
|
70
|
+
panel: children,
|
|
71
|
+
minutes: documentMinutes,
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
if (summary !== null) {
|
|
75
|
+
views.push({
|
|
76
|
+
id: "summary",
|
|
77
|
+
label: "Summary",
|
|
78
|
+
icon: <ListTree className={ICON} />,
|
|
79
|
+
panel: summary,
|
|
80
|
+
minutes: summaryMinutes,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// No attachment, no chrome. Returned before any hook state matters — the
|
|
85
|
+
// hooks below still run, because a conditional hook is a different bug.
|
|
86
|
+
const only = views.length === 1;
|
|
87
|
+
|
|
88
|
+
const [active, setActive] = useState<ViewId>("document");
|
|
89
|
+
const activeMinutes = views.find((view) => view.id === active)?.minutes;
|
|
90
|
+
const tabRefs = useRef(new Map<ViewId, HTMLButtonElement>());
|
|
91
|
+
const base = useId();
|
|
92
|
+
const tabId = (id: ViewId): string => `${base}-tab-${id}`;
|
|
93
|
+
const panelId = (id: ViewId): string => `${base}-panel-${id}`;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The view is in the URL fragment, so a summary is linkable and survives a
|
|
97
|
+
* reload. Read on mount rather than during render: the server has no
|
|
98
|
+
* fragment, and choosing a tab from it during render would hydrate a tree the
|
|
99
|
+
* server never produced.
|
|
100
|
+
*/
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
const fromHash = window.location.hash.replace("#", "");
|
|
103
|
+
if (fromHash === "summary") {
|
|
104
|
+
setActive((current) =>
|
|
105
|
+
views.some((v) => v.id === fromHash) ? (fromHash as ViewId) : current,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
// Views are derived from props that do not change for a given page.
|
|
109
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
const select = useCallback((id: ViewId, focus = false) => {
|
|
113
|
+
setActive(id);
|
|
114
|
+
// replaceState, not a hash assignment: assigning location.hash scrolls the
|
|
115
|
+
// matching element to the top of the viewport, which on a tab strip means
|
|
116
|
+
// the page jumps every time a reader changes view.
|
|
117
|
+
// pathname + search, not pathname alone: dropping back to the document
|
|
118
|
+
// view must not silently discard a query string the reader arrived with.
|
|
119
|
+
const base = `${window.location.pathname}${window.location.search}`;
|
|
120
|
+
window.history.replaceState(null, "", id === "document" ? base : `${base}#${id}`);
|
|
121
|
+
if (focus) tabRefs.current.get(id)?.focus();
|
|
122
|
+
}, []);
|
|
123
|
+
|
|
124
|
+
const onKeyDown = useCallback(
|
|
125
|
+
(event: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
|
|
126
|
+
const last = views.length - 1;
|
|
127
|
+
const to =
|
|
128
|
+
event.key === "ArrowRight"
|
|
129
|
+
? index === last
|
|
130
|
+
? 0
|
|
131
|
+
: index + 1
|
|
132
|
+
: event.key === "ArrowLeft"
|
|
133
|
+
? index === 0
|
|
134
|
+
? last
|
|
135
|
+
: index - 1
|
|
136
|
+
: event.key === "Home"
|
|
137
|
+
? 0
|
|
138
|
+
: event.key === "End"
|
|
139
|
+
? last
|
|
140
|
+
: null;
|
|
141
|
+
if (to === null) return;
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
const target = views[to];
|
|
144
|
+
if (target !== undefined) select(target.id, true);
|
|
145
|
+
},
|
|
146
|
+
[select, views],
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// No summary, so no tabs — but the strip row still carries the reading time.
|
|
150
|
+
// It sits here rather than up with the title because this row is about the
|
|
151
|
+
// reading you are ABOUT to do, and a lone line above the facts row read as
|
|
152
|
+
// floating between two things it belonged to neither of.
|
|
153
|
+
if (only) {
|
|
154
|
+
return (
|
|
155
|
+
<div className="ksor-views">
|
|
156
|
+
{documentMinutes === undefined ? null : (
|
|
157
|
+
<p className="mb-6 flex items-center justify-end gap-2 border-b border-fd-border pb-2.5 text-sm text-fd-muted-foreground">
|
|
158
|
+
<Clock aria-hidden className="size-3.5 shrink-0" />
|
|
159
|
+
<span>{documentMinutes} min read</span>
|
|
160
|
+
</p>
|
|
161
|
+
)}
|
|
162
|
+
{children}
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<div className="ksor-views">
|
|
169
|
+
{/* The rule spans the whole strip, and the clock sits at its far end.
|
|
170
|
+
ONE clock rather than a number on every tab: the reader is on one view
|
|
171
|
+
at a time, and the time they care about is the time for the view they
|
|
172
|
+
are looking at. The rule is on this wrapper rather than the tablist so
|
|
173
|
+
it runs the full width beneath both. */}
|
|
174
|
+
<div className="mb-6 flex items-center justify-between gap-4 border-b border-fd-border">
|
|
175
|
+
<div role="tablist" aria-label="Views of this document" className="flex gap-1">
|
|
176
|
+
{views.map((view, index) => {
|
|
177
|
+
const selected = view.id === active;
|
|
178
|
+
return (
|
|
179
|
+
<button
|
|
180
|
+
key={view.id}
|
|
181
|
+
ref={(node) => {
|
|
182
|
+
if (node) tabRefs.current.set(view.id, node);
|
|
183
|
+
}}
|
|
184
|
+
type="button"
|
|
185
|
+
role="tab"
|
|
186
|
+
id={tabId(view.id)}
|
|
187
|
+
aria-selected={selected}
|
|
188
|
+
aria-controls={panelId(view.id)}
|
|
189
|
+
// The visible minutes belong to the SELECTED view only, so each
|
|
190
|
+
// tab carries its own in its accessible name — otherwise a
|
|
191
|
+
// screen-reader user could not compare them without switching.
|
|
192
|
+
{...(view.minutes === undefined
|
|
193
|
+
? {}
|
|
194
|
+
: {
|
|
195
|
+
"aria-label": `${view.label}, ${view.minutes} minute${view.minutes === 1 ? "" : "s"} to read`,
|
|
196
|
+
})}
|
|
197
|
+
// Roving tabIndex: one stop for the whole strip, arrows move
|
|
198
|
+
// within it. Every tab being a tab stop is the common mistake and
|
|
199
|
+
// makes a keyboard reader walk the chrome before the prose.
|
|
200
|
+
tabIndex={selected ? 0 : -1}
|
|
201
|
+
onClick={() => select(view.id)}
|
|
202
|
+
onKeyDown={(event) => onKeyDown(event, index)}
|
|
203
|
+
className={[
|
|
204
|
+
"-mb-px flex items-center gap-2 border-b-2 px-3 py-2 font-mono text-xs tracking-wide transition-colors",
|
|
205
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fd-primary",
|
|
206
|
+
selected
|
|
207
|
+
? "border-fd-primary text-fd-foreground"
|
|
208
|
+
: "border-transparent text-fd-muted-foreground hover:text-fd-foreground",
|
|
209
|
+
].join(" ")}
|
|
210
|
+
>
|
|
211
|
+
{view.icon}
|
|
212
|
+
{view.label}
|
|
213
|
+
</button>
|
|
214
|
+
);
|
|
215
|
+
})}
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
{activeMinutes === undefined ? null : (
|
|
219
|
+
<span className="flex shrink-0 items-center gap-2 pb-2 text-sm text-fd-muted-foreground">
|
|
220
|
+
<Clock aria-hidden className="size-3.5 shrink-0" />
|
|
221
|
+
<span>{activeMinutes} min read</span>
|
|
222
|
+
</span>
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
{views.map((view) => (
|
|
227
|
+
<div
|
|
228
|
+
key={view.id}
|
|
229
|
+
role="tabpanel"
|
|
230
|
+
id={panelId(view.id)}
|
|
231
|
+
aria-labelledby={tabId(view.id)}
|
|
232
|
+
hidden={view.id !== active}
|
|
233
|
+
// Not tabbable itself; its contents are. A tabpanel of prose with
|
|
234
|
+
// tabIndex={0} adds a stop that announces the whole document.
|
|
235
|
+
>
|
|
236
|
+
{view.panel}
|
|
237
|
+
</div>
|
|
238
|
+
))}
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The end-of-document region: what a reader DOES with a document once they
|
|
5
|
+
* have read it.
|
|
6
|
+
*
|
|
7
|
+
* The deck lives here, and the quiz will sit beside it. Deliberately not a tab:
|
|
8
|
+
* a study aid is used AFTER the document, and a tab would hide the document
|
|
9
|
+
* while you used it. Deliberately one region rather than each aid finding its
|
|
10
|
+
* own spot on the page, so a second aid is a child here and not a new layout
|
|
11
|
+
* argument.
|
|
12
|
+
*
|
|
13
|
+
* Renders NOTHING when the document has no aids — no heading, no rule, no
|
|
14
|
+
* empty region (specs/ksor/study-attachments C3).
|
|
15
|
+
*/
|
|
16
|
+
export function StudyAids({ children }: { readonly children: ReactNode }): ReactElement | null {
|
|
17
|
+
const present = Array.isArray(children) ? children.some((c) => c) : Boolean(children);
|
|
18
|
+
if (!present) return null;
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<section
|
|
22
|
+
aria-label="Study aids"
|
|
23
|
+
// `not-prose`: this is site chrome around the record, not the record's
|
|
24
|
+
// prose, so the document type ramp must not reach into it.
|
|
25
|
+
className="not-prose mt-14 flex flex-col gap-14"
|
|
26
|
+
>
|
|
27
|
+
{children}
|
|
28
|
+
</section>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One aid's header: its own name in the record's serif, an accent bar, and the
|
|
34
|
+
* hairline that separates it from the document above.
|
|
35
|
+
*
|
|
36
|
+
* Shared so the deck and the quiz cannot drift into two different headings.
|
|
37
|
+
*/
|
|
38
|
+
export function StudyAidHeader({
|
|
39
|
+
title,
|
|
40
|
+
description,
|
|
41
|
+
}: {
|
|
42
|
+
readonly title: string;
|
|
43
|
+
readonly description?: string | undefined;
|
|
44
|
+
}): ReactElement {
|
|
45
|
+
return (
|
|
46
|
+
<header className="mb-8">
|
|
47
|
+
<h2 className="font-(family-name:--font-display) text-2xl font-semibold tracking-tight text-fd-foreground">
|
|
48
|
+
{title}
|
|
49
|
+
</h2>
|
|
50
|
+
{/* A short accent bar over a full-width hairline: the rule says "a new
|
|
51
|
+
region starts here" and the bar says which one, without a second
|
|
52
|
+
colour or a boxed container. */}
|
|
53
|
+
<div className="mt-3 h-px w-full bg-fd-border">
|
|
54
|
+
<div className="h-[3px] w-24 -translate-y-px bg-fd-primary" />
|
|
55
|
+
</div>
|
|
56
|
+
{description === undefined ? null : (
|
|
57
|
+
<p className="mt-4 text-sm text-fd-muted-foreground">{description}</p>
|
|
58
|
+
)}
|
|
59
|
+
</header>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Generated by `pnpm dlx shadcn@latest add card`. Yours to edit.
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
|
|
6
|
+
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
|
7
|
+
return (
|
|
8
|
+
<div
|
|
9
|
+
data-slot="card"
|
|
10
|
+
className={cn(
|
|
11
|
+
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
|
12
|
+
className,
|
|
13
|
+
)}
|
|
14
|
+
{...props}
|
|
15
|
+
/>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
20
|
+
return (
|
|
21
|
+
<div
|
|
22
|
+
data-slot="card-header"
|
|
23
|
+
className={cn(
|
|
24
|
+
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
|
25
|
+
className,
|
|
26
|
+
)}
|
|
27
|
+
{...props}
|
|
28
|
+
/>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
data-slot="card-title"
|
|
36
|
+
className={cn("leading-none font-semibold", className)}
|
|
37
|
+
{...props}
|
|
38
|
+
/>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
|
43
|
+
return (
|
|
44
|
+
<div
|
|
45
|
+
data-slot="card-description"
|
|
46
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
47
|
+
{...props}
|
|
48
|
+
/>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
data-slot="card-action"
|
|
56
|
+
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
|
57
|
+
{...props}
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
63
|
+
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
67
|
+
return (
|
|
68
|
+
<div
|
|
69
|
+
data-slot="card-footer"
|
|
70
|
+
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
|
71
|
+
{...props}
|
|
72
|
+
/>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Generated by `pnpm dlx shadcn@latest add dropdown-menu`. Yours to edit.
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
|
6
|
+
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
|
7
|
+
|
|
8
|
+
import { cn } from "@/lib/utils";
|
|
9
|
+
|
|
10
|
+
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
|
11
|
+
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function DropdownMenuPortal({
|
|
15
|
+
...props
|
|
16
|
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
|
17
|
+
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function DropdownMenuTrigger({
|
|
21
|
+
...props
|
|
22
|
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
|
23
|
+
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function DropdownMenuContent({
|
|
27
|
+
className,
|
|
28
|
+
sideOffset = 4,
|
|
29
|
+
...props
|
|
30
|
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
|
31
|
+
return (
|
|
32
|
+
<DropdownMenuPrimitive.Portal>
|
|
33
|
+
<DropdownMenuPrimitive.Content
|
|
34
|
+
data-slot="dropdown-menu-content"
|
|
35
|
+
sideOffset={sideOffset}
|
|
36
|
+
className={cn(
|
|
37
|
+
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
|
38
|
+
className,
|
|
39
|
+
)}
|
|
40
|
+
{...props}
|
|
41
|
+
/>
|
|
42
|
+
</DropdownMenuPrimitive.Portal>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
|
47
|
+
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function DropdownMenuItem({
|
|
51
|
+
className,
|
|
52
|
+
inset,
|
|
53
|
+
variant = "default",
|
|
54
|
+
...props
|
|
55
|
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
|
56
|
+
inset?: boolean;
|
|
57
|
+
variant?: "default" | "destructive";
|
|
58
|
+
}) {
|
|
59
|
+
return (
|
|
60
|
+
<DropdownMenuPrimitive.Item
|
|
61
|
+
data-slot="dropdown-menu-item"
|
|
62
|
+
data-inset={inset}
|
|
63
|
+
data-variant={variant}
|
|
64
|
+
className={cn(
|
|
65
|
+
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
|
|
66
|
+
className,
|
|
67
|
+
)}
|
|
68
|
+
{...props}
|
|
69
|
+
/>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function DropdownMenuCheckboxItem({
|
|
74
|
+
className,
|
|
75
|
+
children,
|
|
76
|
+
checked,
|
|
77
|
+
...props
|
|
78
|
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
|
79
|
+
return (
|
|
80
|
+
<DropdownMenuPrimitive.CheckboxItem
|
|
81
|
+
data-slot="dropdown-menu-checkbox-item"
|
|
82
|
+
className={cn(
|
|
83
|
+
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus: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",
|
|
84
|
+
className,
|
|
85
|
+
)}
|
|
86
|
+
checked={checked}
|
|
87
|
+
{...props}
|
|
88
|
+
>
|
|
89
|
+
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
|
90
|
+
<DropdownMenuPrimitive.ItemIndicator>
|
|
91
|
+
<CheckIcon className="size-4" />
|
|
92
|
+
</DropdownMenuPrimitive.ItemIndicator>
|
|
93
|
+
</span>
|
|
94
|
+
{children}
|
|
95
|
+
</DropdownMenuPrimitive.CheckboxItem>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function DropdownMenuRadioGroup({
|
|
100
|
+
...props
|
|
101
|
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
|
102
|
+
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function DropdownMenuRadioItem({
|
|
106
|
+
className,
|
|
107
|
+
children,
|
|
108
|
+
...props
|
|
109
|
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
|
110
|
+
return (
|
|
111
|
+
<DropdownMenuPrimitive.RadioItem
|
|
112
|
+
data-slot="dropdown-menu-radio-item"
|
|
113
|
+
className={cn(
|
|
114
|
+
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus: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",
|
|
115
|
+
className,
|
|
116
|
+
)}
|
|
117
|
+
{...props}
|
|
118
|
+
>
|
|
119
|
+
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
|
120
|
+
<DropdownMenuPrimitive.ItemIndicator>
|
|
121
|
+
<CircleIcon className="size-2 fill-current" />
|
|
122
|
+
</DropdownMenuPrimitive.ItemIndicator>
|
|
123
|
+
</span>
|
|
124
|
+
{children}
|
|
125
|
+
</DropdownMenuPrimitive.RadioItem>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function DropdownMenuLabel({
|
|
130
|
+
className,
|
|
131
|
+
inset,
|
|
132
|
+
...props
|
|
133
|
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
|
134
|
+
inset?: boolean;
|
|
135
|
+
}) {
|
|
136
|
+
return (
|
|
137
|
+
<DropdownMenuPrimitive.Label
|
|
138
|
+
data-slot="dropdown-menu-label"
|
|
139
|
+
data-inset={inset}
|
|
140
|
+
className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
|
|
141
|
+
{...props}
|
|
142
|
+
/>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function DropdownMenuSeparator({
|
|
147
|
+
className,
|
|
148
|
+
...props
|
|
149
|
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
|
150
|
+
return (
|
|
151
|
+
<DropdownMenuPrimitive.Separator
|
|
152
|
+
data-slot="dropdown-menu-separator"
|
|
153
|
+
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
|
154
|
+
{...props}
|
|
155
|
+
/>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
|
160
|
+
return (
|
|
161
|
+
<span
|
|
162
|
+
data-slot="dropdown-menu-shortcut"
|
|
163
|
+
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
|
|
164
|
+
{...props}
|
|
165
|
+
/>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
|
170
|
+
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function DropdownMenuSubTrigger({
|
|
174
|
+
className,
|
|
175
|
+
inset,
|
|
176
|
+
children,
|
|
177
|
+
...props
|
|
178
|
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
|
179
|
+
inset?: boolean;
|
|
180
|
+
}) {
|
|
181
|
+
return (
|
|
182
|
+
<DropdownMenuPrimitive.SubTrigger
|
|
183
|
+
data-slot="dropdown-menu-sub-trigger"
|
|
184
|
+
data-inset={inset}
|
|
185
|
+
className={cn(
|
|
186
|
+
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
|
187
|
+
className,
|
|
188
|
+
)}
|
|
189
|
+
{...props}
|
|
190
|
+
>
|
|
191
|
+
{children}
|
|
192
|
+
<ChevronRightIcon className="ml-auto size-4" />
|
|
193
|
+
</DropdownMenuPrimitive.SubTrigger>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function DropdownMenuSubContent({
|
|
198
|
+
className,
|
|
199
|
+
...props
|
|
200
|
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
|
201
|
+
return (
|
|
202
|
+
<DropdownMenuPrimitive.SubContent
|
|
203
|
+
data-slot="dropdown-menu-sub-content"
|
|
204
|
+
className={cn(
|
|
205
|
+
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
|
206
|
+
className,
|
|
207
|
+
)}
|
|
208
|
+
{...props}
|
|
209
|
+
/>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export {
|
|
214
|
+
DropdownMenu,
|
|
215
|
+
DropdownMenuPortal,
|
|
216
|
+
DropdownMenuTrigger,
|
|
217
|
+
DropdownMenuContent,
|
|
218
|
+
DropdownMenuGroup,
|
|
219
|
+
DropdownMenuLabel,
|
|
220
|
+
DropdownMenuItem,
|
|
221
|
+
DropdownMenuCheckboxItem,
|
|
222
|
+
DropdownMenuRadioGroup,
|
|
223
|
+
DropdownMenuRadioItem,
|
|
224
|
+
DropdownMenuSeparator,
|
|
225
|
+
DropdownMenuShortcut,
|
|
226
|
+
DropdownMenuSub,
|
|
227
|
+
DropdownMenuSubTrigger,
|
|
228
|
+
DropdownMenuSubContent,
|
|
229
|
+
};
|