harebear-ui 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -2
- package/dist/components/admin/app-nav.d.ts +4 -0
- package/dist/components/admin/app-nav.js +2 -2
- package/dist/components/admin/avatar-upload.d.ts +32 -0
- package/dist/components/admin/avatar-upload.js +121 -0
- package/dist/components/admin/category.d.ts +17 -0
- package/dist/components/admin/category.js +35 -0
- package/dist/components/admin/celebration.d.ts +7 -0
- package/dist/components/admin/celebration.js +61 -0
- package/dist/components/admin/color-swatch-picker.d.ts +15 -0
- package/dist/components/admin/color-swatch-picker.js +68 -0
- package/dist/components/admin/date-time-picker.d.ts +20 -0
- package/dist/components/admin/date-time-picker.js +97 -0
- package/dist/components/admin/detail-sheet.d.ts +18 -0
- package/dist/components/admin/detail-sheet.js +80 -0
- package/dist/components/admin/markdown-editor.d.ts +15 -0
- package/dist/components/admin/markdown-editor.js +42 -0
- package/dist/components/admin/user-chip.d.ts +34 -3
- package/dist/components/admin/user-chip.js +66 -7
- package/dist/components/admin/user-menu.d.ts +4 -1
- package/dist/components/admin/user-menu.js +2 -1
- package/dist/components/ai/ask-box.d.ts +14 -2
- package/dist/components/ai/ask-box.js +90 -54
- package/dist/components/ai/assistant.d.ts +13 -3
- package/dist/components/ai/assistant.js +35 -7
- package/dist/components/ai/code-block.d.ts +10 -3
- package/dist/components/ai/code-block.js +54 -5
- package/dist/components/ai/conversation-list.d.ts +13 -3
- package/dist/components/ai/conversation-list.js +15 -26
- package/dist/components/auth/auth.js +4 -6
- package/dist/components/data/agenda-row.d.ts +26 -0
- package/dist/components/data/agenda-row.js +62 -0
- package/dist/components/data/day-strip.d.ts +22 -0
- package/dist/components/data/day-strip.js +68 -0
- package/dist/components/data/sectioned-list.d.ts +21 -6
- package/dist/components/data/sectioned-list.js +44 -21
- package/dist/components/data/task-row.d.ts +3 -0
- package/dist/components/data/task-row.js +6 -14
- package/dist/components/ui/avatar.js +1 -1
- package/dist/components/ui/kbd.js +1 -1
- package/dist/components/ui/password-input.d.ts +8 -0
- package/dist/components/ui/password-input.js +32 -0
- package/dist/index.d.ts +15 -5
- package/dist/index.js +13 -3
- package/dist/lib/date.js +8 -0
- package/dist/styles.css +25 -0
- package/docs/COMPONENTS.md +164 -14
- package/docs/ROADMAP.md +12 -11
- package/llms.txt +8 -7
- package/package.json +1 -1
- package/skills/harebear-ui/SKILL.md +2 -1
package/AGENTS.md
CHANGED
|
@@ -26,7 +26,7 @@ src/
|
|
|
26
26
|
components/ui/ shadcn primitives (radix-nova style), restyled. Keep upstream structure.
|
|
27
27
|
components/theme/ ThemeProvider, ThemeToggle, ThemeScript.
|
|
28
28
|
components/admin/ Layout and dashboard composites (AppShell, AppNav, FocusLayout, SettingsSection, StatCard...).
|
|
29
|
-
components/data/ DataTable, TrendChart, Sparkline, BarList, StatusBars, TaskRow, SectionedList.
|
|
29
|
+
components/data/ DataTable, TrendChart, Sparkline, BarList, StatusBars, TaskRow, SectionedList, AgendaRow, DayStrip.
|
|
30
30
|
components/ai/ Chat composites (ChatThread, ChatMessage, PromptInput...) and the assistant layer (AssistantProvider, AssistantDock, AskBox, DictationButton, ActionReceipt).
|
|
31
31
|
components/auth/ AuthLayout, LoginForm, SignupForm (parameterized shadcn login/signup blocks).
|
|
32
32
|
components/layout/ Prop-driven layout and text (Stack, Text, Metric, ShowMore, Collapse).
|
|
@@ -50,7 +50,7 @@ test/ Vitest: API snapshot, docs coverage, token integrity, be
|
|
|
50
50
|
4. Floors: checks 24px, phone touch targets 44px, no text under 12px, icon-only buttons have `aria-label`.
|
|
51
51
|
5. `pnpm gen`, then add the component to `docs/COMPONENTS.md` (the docs test fails otherwise) and to the index in `llms.txt`. Use a heading like ``### `Name` `` or a table row with the name in backticks, so `npx harebear-ui docs Name` finds it.
|
|
52
52
|
6. Use it in a playground screen if it is visual, and look at it in light and dark mode and at 375px width.
|
|
53
|
-
7. Add a behavior test in `test/` if it has logic (`components.test.tsx`, `building-blocks.test.tsx`, `household.test.tsx` for the household and assistant layer). `pnpm test -u` to accept the new export in the snapshot.
|
|
53
|
+
7. Add a behavior test in `test/` if it has logic (`components.test.tsx`, `building-blocks.test.tsx`, `household.test.tsx` for the household and assistant layer, `people.test.tsx` (avatars, category colours), `forms.test.tsx` (form fields, DetailSheet, Prose tips)). `pnpm test -u` to accept the new export in the snapshot.
|
|
54
54
|
8. `pnpm check`.
|
|
55
55
|
|
|
56
56
|
## Looking at your work
|
|
@@ -6,6 +6,8 @@ export interface AppNavPlace extends Omit<AppNavItem, "badge"> {
|
|
|
6
6
|
count?: number;
|
|
7
7
|
/** Show in the phone tab bar. Default true; the first 5 that are not false are shown. */
|
|
8
8
|
tab?: boolean;
|
|
9
|
+
/** Show in the desktop sidebar. Default true; false makes a tab-bar-only place, e.g. Lists when the sidebar has a Lists group. */
|
|
10
|
+
sidebar?: boolean;
|
|
9
11
|
}
|
|
10
12
|
export interface AppNavGroup {
|
|
11
13
|
/** Group heading, e.g. "Lists". */
|
|
@@ -22,6 +24,8 @@ export interface AppNavAction {
|
|
|
22
24
|
label: string;
|
|
23
25
|
icon: React.ReactNode;
|
|
24
26
|
onSelect: () => void;
|
|
27
|
+
/** Show the button in the desktop sidebar. Default true; false keeps only the round phone button. */
|
|
28
|
+
sidebar?: boolean;
|
|
25
29
|
}
|
|
26
30
|
export interface AppNavProps {
|
|
27
31
|
/** Product mark at the top of the sidebar, usually <AppBrand />. */
|
|
@@ -28,7 +28,7 @@ function AppNav({ brand, places, group, footer, action, label = "Main", linkComp
|
|
|
28
28
|
defaultOpen,
|
|
29
29
|
width,
|
|
30
30
|
sidebarFooter: footer,
|
|
31
|
-
sidebarActions: action && /* @__PURE__ */ jsxs(Button, {
|
|
31
|
+
sidebarActions: action && action.sidebar !== false && /* @__PURE__ */ jsxs(Button, {
|
|
32
32
|
type: "button",
|
|
33
33
|
"aria-label": action.label,
|
|
34
34
|
onClick: action.onSelect,
|
|
@@ -44,7 +44,7 @@ function AppNav({ brand, places, group, footer, action, label = "Main", linkComp
|
|
|
44
44
|
className: "flex flex-col gap-5",
|
|
45
45
|
children: [/* @__PURE__ */ jsx(SidebarMenu, {
|
|
46
46
|
className: "gap-1 group-data-[collapsible=icon]:items-center",
|
|
47
|
-
children: places.map((place) => /* @__PURE__ */ jsx(PlaceItem, {
|
|
47
|
+
children: places.filter((p) => p.sidebar !== false).map((place) => /* @__PURE__ */ jsx(PlaceItem, {
|
|
48
48
|
place,
|
|
49
49
|
Link: linkComponent
|
|
50
50
|
}, place.label))
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { CategoryColor } from "./category.js";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
/**
|
|
4
|
+
* Crops an image to its centred square and scales it to `size` (default 256) pixels, as a JPEG. With `maxBytes`,
|
|
5
|
+
* steps the quality down until it fits, or rejects with "That photo is too big.".
|
|
6
|
+
*/
|
|
7
|
+
export declare function cropImageToSquare(file: Blob, { size, maxBytes }?: {
|
|
8
|
+
size?: number;
|
|
9
|
+
maxBytes?: number;
|
|
10
|
+
}): Promise<Blob>;
|
|
11
|
+
export interface AvatarUploadProps extends Omit<React.ComponentProps<"div">, "onChange" | "onError" | "color"> {
|
|
12
|
+
/** Whose photo; gives the initials shown without one. */
|
|
13
|
+
name: string;
|
|
14
|
+
/** Category colour behind the initials. */
|
|
15
|
+
color?: CategoryColor;
|
|
16
|
+
/** The current photo. */
|
|
17
|
+
avatarUrl?: string;
|
|
18
|
+
/** Called with a 256x256 JPEG after a photo is chosen, or `null` after Remove photo. */
|
|
19
|
+
onChange: (photo: Blob | null) => void;
|
|
20
|
+
/** Largest photo to hand back, in bytes; JPEG quality steps down to fit. */
|
|
21
|
+
maxBytes?: number;
|
|
22
|
+
/** Called when the file is not an image or cannot be made small enough. */
|
|
23
|
+
onError?: (error: Error) => void;
|
|
24
|
+
/** A line under the buttons. */
|
|
25
|
+
description?: React.ReactNode;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Profile photo picker: a large avatar, "Upload a photo" / "Change photo" and "Remove photo". The chosen image is
|
|
29
|
+
* cropped to a centred square in the browser and previewed right away.
|
|
30
|
+
*/
|
|
31
|
+
declare function AvatarUpload({ name, color, avatarUrl, onChange, maxBytes, onError, description, className, ...props }: AvatarUploadProps): React.JSX.Element;
|
|
32
|
+
export { AvatarUpload };
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { MemberAvatar } from "./user-chip.js";
|
|
3
|
+
import { Button } from "../ui/button.js";
|
|
4
|
+
import { cn } from "../../lib/utils.js";
|
|
5
|
+
import * as React from "react";
|
|
6
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
// src/components/admin/avatar-upload.tsx
|
|
8
|
+
/**
|
|
9
|
+
* Crops an image to its centred square and scales it to `size` (default 256) pixels, as a JPEG. With `maxBytes`,
|
|
10
|
+
* steps the quality down until it fits, or rejects with "That photo is too big.".
|
|
11
|
+
*/
|
|
12
|
+
async function cropImageToSquare(file, { size = 256, maxBytes } = {}) {
|
|
13
|
+
const image = await createImageBitmap(file).catch(() => {
|
|
14
|
+
throw new Error("Could not read that image.");
|
|
15
|
+
});
|
|
16
|
+
const side = Math.min(image.width, image.height);
|
|
17
|
+
const canvas = document.createElement("canvas");
|
|
18
|
+
canvas.width = size;
|
|
19
|
+
canvas.height = size;
|
|
20
|
+
const ctx = canvas.getContext("2d");
|
|
21
|
+
if (!ctx) throw new Error("Could not read that image.");
|
|
22
|
+
ctx.fillStyle = "#fff";
|
|
23
|
+
ctx.fillRect(0, 0, size, size);
|
|
24
|
+
ctx.imageSmoothingQuality = "high";
|
|
25
|
+
ctx.drawImage(image, (image.width - side) / 2, (image.height - side) / 2, side, side, 0, 0, size, size);
|
|
26
|
+
image.close();
|
|
27
|
+
for (const quality of [
|
|
28
|
+
.9,
|
|
29
|
+
.8,
|
|
30
|
+
.7,
|
|
31
|
+
.6,
|
|
32
|
+
.5
|
|
33
|
+
]) {
|
|
34
|
+
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", quality));
|
|
35
|
+
if (!blob) throw new Error("Could not read that image.");
|
|
36
|
+
if (!maxBytes || blob.size <= maxBytes) return blob;
|
|
37
|
+
}
|
|
38
|
+
throw new Error("That photo is too big.");
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Profile photo picker: a large avatar, "Upload a photo" / "Change photo" and "Remove photo". The chosen image is
|
|
42
|
+
* cropped to a centred square in the browser and previewed right away.
|
|
43
|
+
*/
|
|
44
|
+
function AvatarUpload({ name, color, avatarUrl, onChange, maxBytes, onError, description, className, ...props }) {
|
|
45
|
+
const input = React.useRef(null);
|
|
46
|
+
const [preview, setPreview] = React.useState();
|
|
47
|
+
const [busy, setBusy] = React.useState(false);
|
|
48
|
+
const shown = preview === void 0 ? avatarUrl : preview ?? void 0;
|
|
49
|
+
React.useEffect(() => {
|
|
50
|
+
if (!preview) return;
|
|
51
|
+
return () => URL.revokeObjectURL(preview);
|
|
52
|
+
}, [preview]);
|
|
53
|
+
React.useEffect(() => setPreview(void 0), [avatarUrl]);
|
|
54
|
+
async function choose(file) {
|
|
55
|
+
setBusy(true);
|
|
56
|
+
try {
|
|
57
|
+
const blob = await cropImageToSquare(file, { maxBytes });
|
|
58
|
+
setPreview(URL.createObjectURL(blob));
|
|
59
|
+
onChange(blob);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
onError?.(error);
|
|
62
|
+
} finally {
|
|
63
|
+
setBusy(false);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
67
|
+
"data-slot": "avatar-upload",
|
|
68
|
+
className: cn("flex flex-wrap items-center gap-5", className),
|
|
69
|
+
...props,
|
|
70
|
+
children: [/* @__PURE__ */ jsx(MemberAvatar, {
|
|
71
|
+
name,
|
|
72
|
+
avatarUrl: shown,
|
|
73
|
+
color,
|
|
74
|
+
size: "lg",
|
|
75
|
+
"aria-label": shown ? `${name}'s photo` : name,
|
|
76
|
+
className: "data-[size=lg]:size-18 *:data-[slot=avatar-fallback]:text-2xl"
|
|
77
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
78
|
+
className: "flex min-w-0 flex-[1_1_14rem] flex-col gap-2",
|
|
79
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
80
|
+
className: "flex flex-wrap gap-2",
|
|
81
|
+
children: [
|
|
82
|
+
/* @__PURE__ */ jsx("input", {
|
|
83
|
+
ref: input,
|
|
84
|
+
type: "file",
|
|
85
|
+
accept: "image/*",
|
|
86
|
+
hidden: true,
|
|
87
|
+
"data-slot": "avatar-upload-input",
|
|
88
|
+
onChange: (e) => {
|
|
89
|
+
const file = e.target.files?.[0];
|
|
90
|
+
e.target.value = "";
|
|
91
|
+
if (file) choose(file);
|
|
92
|
+
}
|
|
93
|
+
}),
|
|
94
|
+
/* @__PURE__ */ jsx(Button, {
|
|
95
|
+
type: "button",
|
|
96
|
+
variant: "outline",
|
|
97
|
+
className: "max-md:h-11",
|
|
98
|
+
loading: busy,
|
|
99
|
+
onClick: () => input.current?.click(),
|
|
100
|
+
children: shown ? "Change photo" : "Upload a photo"
|
|
101
|
+
}),
|
|
102
|
+
shown && /* @__PURE__ */ jsx(Button, {
|
|
103
|
+
type: "button",
|
|
104
|
+
variant: "ghost",
|
|
105
|
+
className: "max-md:h-11",
|
|
106
|
+
disabled: busy,
|
|
107
|
+
onClick: () => {
|
|
108
|
+
setPreview(null);
|
|
109
|
+
onChange(null);
|
|
110
|
+
},
|
|
111
|
+
children: "Remove photo"
|
|
112
|
+
})
|
|
113
|
+
]
|
|
114
|
+
}), description && /* @__PURE__ */ jsx("p", {
|
|
115
|
+
className: "text-[13px] text-muted-foreground",
|
|
116
|
+
children: description
|
|
117
|
+
})]
|
|
118
|
+
})]
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
export { AvatarUpload, cropImageToSquare };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
/** One of the six category colours (`category-1..6`) for members, lists and calendars. */
|
|
3
|
+
export type CategoryColor = 1 | 2 | 3 | 4 | 5 | 6;
|
|
4
|
+
/** `bg-category-N` per colour, as complete class names so Tailwind generates them. */
|
|
5
|
+
export declare const categoryBg: Record<CategoryColor, string>;
|
|
6
|
+
export interface CategoryDotProps extends Omit<React.ComponentProps<"span">, "color"> {
|
|
7
|
+
color: CategoryColor;
|
|
8
|
+
/** Accessible name when the colour means something on its own, e.g. whose event it is. Decorative without it. */
|
|
9
|
+
label?: string;
|
|
10
|
+
/** Adds a lock after the dot, for something only you can see. */
|
|
11
|
+
locked?: boolean;
|
|
12
|
+
/** Accessible name of the lock. Default "Only you". */
|
|
13
|
+
lockLabel?: string;
|
|
14
|
+
}
|
|
15
|
+
/** A small dot in a category colour, e.g. before a list name in a sidebar, with an optional lock for private ones. */
|
|
16
|
+
declare function CategoryDot({ color, label, locked, lockLabel, className, ...props }: CategoryDotProps): React.JSX.Element;
|
|
17
|
+
export { CategoryDot };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { cn } from "../../lib/utils.js";
|
|
3
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
import { LockIcon } from "lucide-react";
|
|
5
|
+
// src/components/admin/category.tsx
|
|
6
|
+
/** `bg-category-N` per colour, as complete class names so Tailwind generates them. */
|
|
7
|
+
const categoryBg = {
|
|
8
|
+
1: "bg-category-1",
|
|
9
|
+
2: "bg-category-2",
|
|
10
|
+
3: "bg-category-3",
|
|
11
|
+
4: "bg-category-4",
|
|
12
|
+
5: "bg-category-5",
|
|
13
|
+
6: "bg-category-6"
|
|
14
|
+
};
|
|
15
|
+
/** A small dot in a category colour, e.g. before a list name in a sidebar, with an optional lock for private ones. */
|
|
16
|
+
function CategoryDot({ color, label, locked, lockLabel = "Only you", className, ...props }) {
|
|
17
|
+
return /* @__PURE__ */ jsxs("span", {
|
|
18
|
+
"data-slot": "category-dot",
|
|
19
|
+
className: cn("inline-flex shrink-0 items-center gap-1.5", className),
|
|
20
|
+
...props,
|
|
21
|
+
children: [label ? /* @__PURE__ */ jsx("span", {
|
|
22
|
+
role: "img",
|
|
23
|
+
"aria-label": label,
|
|
24
|
+
className: cn("size-2.5 shrink-0 rounded-full", categoryBg[color])
|
|
25
|
+
}) : /* @__PURE__ */ jsx("span", {
|
|
26
|
+
"aria-hidden": "true",
|
|
27
|
+
className: cn("size-2.5 shrink-0 rounded-full", categoryBg[color])
|
|
28
|
+
}), locked && /* @__PURE__ */ jsx(LockIcon, {
|
|
29
|
+
role: "img",
|
|
30
|
+
"aria-label": lockLabel,
|
|
31
|
+
className: "size-3.5 text-muted-foreground"
|
|
32
|
+
})]
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export { CategoryDot, categoryBg };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
/** Plays one confetti burst on the mounted `<Celebration />`. Does nothing under prefers-reduced-motion. */
|
|
3
|
+
declare function celebrate(): void;
|
|
4
|
+
export type CelebrationProps = React.ComponentProps<"div">;
|
|
5
|
+
/** Confetti layer for `celebrate()`. Mount once near the root, like Toaster. Renders nothing between bursts. */
|
|
6
|
+
declare function Celebration({ className, ...props }: CelebrationProps): React.JSX.Element | null;
|
|
7
|
+
export { Celebration, celebrate };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { cn } from "../../lib/utils.js";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { jsx } from "react/jsx-runtime";
|
|
5
|
+
// src/components/admin/celebration.tsx
|
|
6
|
+
const DURATION_MS = 1600;
|
|
7
|
+
const COLORS = [
|
|
8
|
+
"bg-category-1",
|
|
9
|
+
"bg-category-2",
|
|
10
|
+
"bg-category-3",
|
|
11
|
+
"bg-category-4",
|
|
12
|
+
"bg-category-5",
|
|
13
|
+
"bg-category-6"
|
|
14
|
+
];
|
|
15
|
+
const PIECES = Array.from({ length: 36 }, (_, i) => {
|
|
16
|
+
const angle = i / 36 * Math.PI * 2 + i % 3 * .3;
|
|
17
|
+
const reach = 120 + i * 47 % 160;
|
|
18
|
+
return {
|
|
19
|
+
color: COLORS[i % COLORS.length],
|
|
20
|
+
style: {
|
|
21
|
+
"--hb-x": `${Math.round(Math.cos(angle) * reach)}px`,
|
|
22
|
+
"--hb-y": `${Math.round(Math.sin(angle) * reach * .8 - 120)}px`,
|
|
23
|
+
"--hb-r": `${i * 137 % 720 - 360}deg`,
|
|
24
|
+
animationDelay: `${i % 6 * 25}ms`
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
29
|
+
/** Plays one confetti burst on the mounted `<Celebration />`. Does nothing under prefers-reduced-motion. */
|
|
30
|
+
function celebrate() {
|
|
31
|
+
if (typeof window === "undefined" || window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
|
|
32
|
+
for (const listener of listeners) listener();
|
|
33
|
+
}
|
|
34
|
+
/** Confetti layer for `celebrate()`. Mount once near the root, like Toaster. Renders nothing between bursts. */
|
|
35
|
+
function Celebration({ className, ...props }) {
|
|
36
|
+
const [burst, setBurst] = React.useState(0);
|
|
37
|
+
React.useEffect(() => {
|
|
38
|
+
const listener = () => setBurst((b) => b + 1);
|
|
39
|
+
listeners.add(listener);
|
|
40
|
+
return () => {
|
|
41
|
+
listeners.delete(listener);
|
|
42
|
+
};
|
|
43
|
+
}, []);
|
|
44
|
+
React.useEffect(() => {
|
|
45
|
+
if (!burst) return;
|
|
46
|
+
const t = setTimeout(() => setBurst(0), DURATION_MS);
|
|
47
|
+
return () => clearTimeout(t);
|
|
48
|
+
}, [burst]);
|
|
49
|
+
if (!burst) return null;
|
|
50
|
+
return /* @__PURE__ */ jsx("div", {
|
|
51
|
+
"data-slot": "celebration",
|
|
52
|
+
"aria-hidden": "true",
|
|
53
|
+
className: cn("pointer-events-none fixed inset-0 z-[100] overflow-hidden motion-reduce:hidden", className),
|
|
54
|
+
...props,
|
|
55
|
+
children: PIECES.map((p, i) => /* @__PURE__ */ jsx("span", {
|
|
56
|
+
className: cn("absolute top-1/2 left-1/2 h-3 w-2 rounded-[2px] opacity-0 animate-confetti", p.color),
|
|
57
|
+
style: p.style
|
|
58
|
+
}, i))
|
|
59
|
+
}, burst);
|
|
60
|
+
}
|
|
61
|
+
export { Celebration, celebrate };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { CategoryColor } from "./category.js";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { RadioGroup } from "radix-ui";
|
|
4
|
+
export interface ColorSwatchPickerProps extends Omit<React.ComponentProps<typeof RadioGroup.Root>, "value" | "defaultValue" | "onValueChange" | "children"> {
|
|
5
|
+
value?: CategoryColor;
|
|
6
|
+
onValueChange?: (color: CategoryColor) => void;
|
|
7
|
+
/** Colours someone else already uses, as colour -> their name. Shown with their initial and disabled. */
|
|
8
|
+
taken?: Partial<Record<CategoryColor, string>>;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The six category colours as a radio group (arrow keys move between free colours). Name it with `aria-label` or
|
|
12
|
+
* `aria-labelledby`. 32px swatches with 44px touch targets; the chosen one shows a check and a ring.
|
|
13
|
+
*/
|
|
14
|
+
declare function ColorSwatchPicker({ value, onValueChange, taken, className, ...props }: ColorSwatchPickerProps): React.JSX.Element;
|
|
15
|
+
export { ColorSwatchPicker };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { categoryBg } from "./category.js";
|
|
3
|
+
import { cn } from "../../lib/utils.js";
|
|
4
|
+
import { jsx } from "react/jsx-runtime";
|
|
5
|
+
import { CheckIcon } from "lucide-react";
|
|
6
|
+
import { RadioGroup } from "radix-ui";
|
|
7
|
+
// src/components/admin/color-swatch-picker.tsx
|
|
8
|
+
const colors = [
|
|
9
|
+
{
|
|
10
|
+
value: 1,
|
|
11
|
+
name: "Blue"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
value: 2,
|
|
15
|
+
name: "Teal"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
value: 3,
|
|
19
|
+
name: "Violet"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
value: 4,
|
|
23
|
+
name: "Rose"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
value: 5,
|
|
27
|
+
name: "Slate"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
value: 6,
|
|
31
|
+
name: "Cyan"
|
|
32
|
+
}
|
|
33
|
+
];
|
|
34
|
+
/**
|
|
35
|
+
* The six category colours as a radio group (arrow keys move between free colours). Name it with `aria-label` or
|
|
36
|
+
* `aria-labelledby`. 32px swatches with 44px touch targets; the chosen one shows a check and a ring.
|
|
37
|
+
*/
|
|
38
|
+
function ColorSwatchPicker({ value, onValueChange, taken, className, ...props }) {
|
|
39
|
+
return /* @__PURE__ */ jsx(RadioGroup.Root, {
|
|
40
|
+
"data-slot": "color-swatch-picker",
|
|
41
|
+
value: value ? String(value) : "",
|
|
42
|
+
onValueChange: (v) => onValueChange?.(Number(v)),
|
|
43
|
+
className: cn("flex flex-wrap gap-3", className),
|
|
44
|
+
...props,
|
|
45
|
+
children: colors.map((c) => {
|
|
46
|
+
const by = taken?.[c.value];
|
|
47
|
+
return /* @__PURE__ */ jsx(RadioGroup.Item, {
|
|
48
|
+
value: String(c.value),
|
|
49
|
+
disabled: !!by,
|
|
50
|
+
"aria-label": by ? `${c.name}, taken by ${by}` : c.name,
|
|
51
|
+
title: by ? `${c.name}, taken by ${by}` : void 0,
|
|
52
|
+
className: cn("relative grid size-8 shrink-0 place-items-center rounded-full text-category-foreground outline-none after:absolute after:-inset-1.5 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary focus-visible:outline-solid disabled:cursor-not-allowed disabled:opacity-50 data-checked:ring-2 data-checked:ring-foreground data-checked:ring-offset-2 data-checked:ring-offset-card", categoryBg[c.value]),
|
|
53
|
+
children: by ? /* @__PURE__ */ jsx("span", {
|
|
54
|
+
"aria-hidden": "true",
|
|
55
|
+
className: "text-xs leading-none font-semibold",
|
|
56
|
+
children: by.trim()[0]?.toUpperCase()
|
|
57
|
+
}) : /* @__PURE__ */ jsx(RadioGroup.Indicator, {
|
|
58
|
+
className: "grid place-items-center",
|
|
59
|
+
children: /* @__PURE__ */ jsx(CheckIcon, {
|
|
60
|
+
className: "size-4",
|
|
61
|
+
strokeWidth: 3
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
}, c.value);
|
|
65
|
+
})
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
export { ColorSwatchPicker };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
/** A floating local date and optional time, e.g. `{ date: "2026-11-12", time: "09:30" }`. No time zone. */
|
|
3
|
+
export interface DateTimeValue {
|
|
4
|
+
/** `YYYY-MM-DD` */
|
|
5
|
+
date: string;
|
|
6
|
+
/** `HH:MM`, 24-hour. */
|
|
7
|
+
time?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface DateTimePickerProps extends Omit<React.ComponentProps<"button">, "value" | "onChange"> {
|
|
10
|
+
value: DateTimeValue | null;
|
|
11
|
+
/** Called with the new value, or null when cleared. */
|
|
12
|
+
onValueChange: (value: DateTimeValue | null) => void;
|
|
13
|
+
/** Show a time field under the calendar. */
|
|
14
|
+
withTime?: boolean;
|
|
15
|
+
/** Trigger text when there is no value. Default "Pick a date". */
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
}
|
|
18
|
+
/** Date field: a button that opens a Calendar in a Popover, with an optional time field and Clear. */
|
|
19
|
+
declare function DateTimePicker({ value, onValueChange, withTime, placeholder, className, ...props }: DateTimePickerProps): React.JSX.Element;
|
|
20
|
+
export { DateTimePicker };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Button } from "../ui/button.js";
|
|
3
|
+
import { Calendar } from "../ui/calendar.js";
|
|
4
|
+
import { Input } from "../ui/input.js";
|
|
5
|
+
import { Label } from "../ui/label.js";
|
|
6
|
+
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover.js";
|
|
7
|
+
import { parseLocalDate } from "../../lib/date.js";
|
|
8
|
+
import { cn } from "../../lib/utils.js";
|
|
9
|
+
import * as React from "react";
|
|
10
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
11
|
+
import { CalendarIcon } from "lucide-react";
|
|
12
|
+
// src/components/admin/date-time-picker.tsx
|
|
13
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
14
|
+
const toISODate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
15
|
+
function formatValue({ date, time }) {
|
|
16
|
+
const at = parseLocalDate(date, time);
|
|
17
|
+
const day = at.toLocaleDateString(void 0, {
|
|
18
|
+
weekday: "short",
|
|
19
|
+
day: "numeric",
|
|
20
|
+
month: "short",
|
|
21
|
+
year: "numeric"
|
|
22
|
+
});
|
|
23
|
+
return time ? `${day}, ${at.toLocaleTimeString(void 0, {
|
|
24
|
+
hour: "numeric",
|
|
25
|
+
minute: "2-digit"
|
|
26
|
+
})}` : day;
|
|
27
|
+
}
|
|
28
|
+
/** Date field: a button that opens a Calendar in a Popover, with an optional time field and Clear. */
|
|
29
|
+
function DateTimePicker({ value, onValueChange, withTime, placeholder = "Pick a date", className, ...props }) {
|
|
30
|
+
const [open, setOpen] = React.useState(false);
|
|
31
|
+
const timeId = React.useId();
|
|
32
|
+
const selected = value ? parseLocalDate(value.date) : void 0;
|
|
33
|
+
return /* @__PURE__ */ jsxs(Popover, {
|
|
34
|
+
open,
|
|
35
|
+
onOpenChange: setOpen,
|
|
36
|
+
children: [/* @__PURE__ */ jsx(PopoverTrigger, {
|
|
37
|
+
asChild: true,
|
|
38
|
+
children: /* @__PURE__ */ jsxs("button", {
|
|
39
|
+
type: "button",
|
|
40
|
+
"data-slot": "date-time-picker",
|
|
41
|
+
"data-empty": value ? void 0 : "",
|
|
42
|
+
className: cn("flex h-10 w-full min-w-0 items-center gap-2.5 rounded-md border border-transparent bg-muted px-3.5 text-left text-base transition-[border-color,box-shadow,background-color] outline-none focus-visible:border-primary focus-visible:bg-card focus-visible:ring-4 focus-visible:ring-primary/15 disabled:pointer-events-none disabled:opacity-50 data-empty:text-muted-foreground md:text-sm [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-muted-foreground", className),
|
|
43
|
+
...props,
|
|
44
|
+
children: [/* @__PURE__ */ jsx(CalendarIcon, { "aria-hidden": "true" }), /* @__PURE__ */ jsx("span", {
|
|
45
|
+
className: "truncate",
|
|
46
|
+
children: value ? formatValue(value) : placeholder
|
|
47
|
+
})]
|
|
48
|
+
})
|
|
49
|
+
}), /* @__PURE__ */ jsxs(PopoverContent, {
|
|
50
|
+
align: "start",
|
|
51
|
+
className: "w-auto gap-3 p-3",
|
|
52
|
+
children: [
|
|
53
|
+
/* @__PURE__ */ jsx(Calendar, {
|
|
54
|
+
mode: "single",
|
|
55
|
+
required: true,
|
|
56
|
+
selected,
|
|
57
|
+
defaultMonth: selected,
|
|
58
|
+
onSelect: (d) => {
|
|
59
|
+
onValueChange({
|
|
60
|
+
date: toISODate(d),
|
|
61
|
+
...value?.time ? { time: value.time } : {}
|
|
62
|
+
});
|
|
63
|
+
if (!withTime) setOpen(false);
|
|
64
|
+
},
|
|
65
|
+
className: "p-0 [--cell-size:--spacing(11)] md:[--cell-size:--spacing(9)]"
|
|
66
|
+
}),
|
|
67
|
+
withTime && /* @__PURE__ */ jsxs("div", {
|
|
68
|
+
className: "flex items-center gap-3",
|
|
69
|
+
children: [/* @__PURE__ */ jsx(Label, {
|
|
70
|
+
htmlFor: timeId,
|
|
71
|
+
className: "shrink-0",
|
|
72
|
+
children: "Time"
|
|
73
|
+
}), /* @__PURE__ */ jsx(Input, {
|
|
74
|
+
id: timeId,
|
|
75
|
+
type: "time",
|
|
76
|
+
disabled: !value,
|
|
77
|
+
value: value?.time ?? "",
|
|
78
|
+
onChange: (e) => value && onValueChange({
|
|
79
|
+
date: value.date,
|
|
80
|
+
...e.target.value ? { time: e.target.value } : {}
|
|
81
|
+
})
|
|
82
|
+
})]
|
|
83
|
+
}),
|
|
84
|
+
value && /* @__PURE__ */ jsx(Button, {
|
|
85
|
+
variant: "ghost",
|
|
86
|
+
className: "max-md:h-11",
|
|
87
|
+
onClick: () => {
|
|
88
|
+
onValueChange(null);
|
|
89
|
+
setOpen(false);
|
|
90
|
+
},
|
|
91
|
+
children: "Clear"
|
|
92
|
+
})
|
|
93
|
+
]
|
|
94
|
+
})]
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
export { DateTimePicker };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export interface DetailSheetProps {
|
|
3
|
+
open: boolean;
|
|
4
|
+
onOpenChange: (open: boolean) => void;
|
|
5
|
+
/** Heading and accessible name, e.g. "To-do". */
|
|
6
|
+
title: React.ReactNode;
|
|
7
|
+
description?: React.ReactNode;
|
|
8
|
+
/** Pinned under the scrolling body, e.g. a Delete button. */
|
|
9
|
+
footer?: React.ReactNode;
|
|
10
|
+
className?: string;
|
|
11
|
+
children: React.ReactNode;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A record's details: a floating side sheet from 768px up, a full-screen drawer on phones.
|
|
15
|
+
* Both have a close button and a scrolling body.
|
|
16
|
+
*/
|
|
17
|
+
declare function DetailSheet({ open, onOpenChange, title, description, footer, className, children }: DetailSheetProps): React.JSX.Element;
|
|
18
|
+
export { DetailSheet };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Button } from "../ui/button.js";
|
|
3
|
+
import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerTitle } from "../ui/drawer.js";
|
|
4
|
+
import { Sheet, SheetContent, SheetDescription, SheetTitle } from "../ui/sheet.js";
|
|
5
|
+
import { useMediaQuery } from "../../hooks/use-media-query.js";
|
|
6
|
+
import { cn } from "../../lib/utils.js";
|
|
7
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
import { XIcon } from "lucide-react";
|
|
9
|
+
// src/components/admin/detail-sheet.tsx
|
|
10
|
+
/**
|
|
11
|
+
* A record's details: a floating side sheet from 768px up, a full-screen drawer on phones.
|
|
12
|
+
* Both have a close button and a scrolling body.
|
|
13
|
+
*/
|
|
14
|
+
function DetailSheet({ open, onOpenChange, title, description, footer, className, children }) {
|
|
15
|
+
const desktop = useMediaQuery("(min-width: 768px)");
|
|
16
|
+
const noDescription = description ? {} : { "aria-describedby": void 0 };
|
|
17
|
+
const body = (padBottom) => /* @__PURE__ */ jsx("div", {
|
|
18
|
+
className: cn("min-h-0 flex-1 overflow-y-auto px-6 pb-6", !footer && padBottom),
|
|
19
|
+
children
|
|
20
|
+
});
|
|
21
|
+
const foot = (padBottom) => footer && /* @__PURE__ */ jsx("div", {
|
|
22
|
+
className: cn("flex flex-wrap items-center gap-2 border-t px-6 py-4", padBottom),
|
|
23
|
+
children: footer
|
|
24
|
+
});
|
|
25
|
+
if (desktop) return /* @__PURE__ */ jsx(Sheet, {
|
|
26
|
+
open,
|
|
27
|
+
onOpenChange,
|
|
28
|
+
children: /* @__PURE__ */ jsxs(SheetContent, {
|
|
29
|
+
"data-slot": "detail-sheet",
|
|
30
|
+
"data-layout": "sheet",
|
|
31
|
+
...noDescription,
|
|
32
|
+
className: cn("gap-0 data-[side=right]:inset-y-4 data-[side=right]:right-4 data-[side=right]:h-auto data-[side=right]:w-[min(420px,calc(100%-32px))] data-[side=right]:rounded-xl data-[side=right]:border data-[side=right]:sm:max-w-none", className),
|
|
33
|
+
children: [
|
|
34
|
+
/* @__PURE__ */ jsxs("div", {
|
|
35
|
+
className: "flex flex-col gap-1 p-6 pr-14",
|
|
36
|
+
children: [/* @__PURE__ */ jsx(SheetTitle, {
|
|
37
|
+
className: "text-lg font-semibold",
|
|
38
|
+
children: title
|
|
39
|
+
}), description && /* @__PURE__ */ jsx(SheetDescription, { children: description })]
|
|
40
|
+
}),
|
|
41
|
+
body(),
|
|
42
|
+
foot()
|
|
43
|
+
]
|
|
44
|
+
})
|
|
45
|
+
});
|
|
46
|
+
return /* @__PURE__ */ jsx(Drawer, {
|
|
47
|
+
open,
|
|
48
|
+
onOpenChange,
|
|
49
|
+
children: /* @__PURE__ */ jsxs(DrawerContent, {
|
|
50
|
+
"data-slot": "detail-sheet",
|
|
51
|
+
"data-layout": "drawer",
|
|
52
|
+
...noDescription,
|
|
53
|
+
className: cn("h-dvh data-[vaul-drawer-direction=bottom]:mt-0 data-[vaul-drawer-direction=bottom]:max-h-none data-[vaul-drawer-direction=bottom]:rounded-none data-[vaul-drawer-direction=bottom]:border-t-0", className),
|
|
54
|
+
children: [
|
|
55
|
+
/* @__PURE__ */ jsxs("div", {
|
|
56
|
+
className: "flex items-start gap-2 px-6 pt-[max(0.5rem,env(safe-area-inset-top))] pb-4",
|
|
57
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
58
|
+
className: "flex min-w-0 flex-1 flex-col gap-1 pt-2.5",
|
|
59
|
+
children: [/* @__PURE__ */ jsx(DrawerTitle, {
|
|
60
|
+
className: "text-lg font-semibold",
|
|
61
|
+
children: title
|
|
62
|
+
}), description && /* @__PURE__ */ jsx(DrawerDescription, { children: description })]
|
|
63
|
+
}), /* @__PURE__ */ jsx(DrawerClose, {
|
|
64
|
+
asChild: true,
|
|
65
|
+
children: /* @__PURE__ */ jsx(Button, {
|
|
66
|
+
variant: "ghost",
|
|
67
|
+
size: "icon-lg",
|
|
68
|
+
"aria-label": "Close",
|
|
69
|
+
className: "-mr-2.5",
|
|
70
|
+
children: /* @__PURE__ */ jsx(XIcon, {})
|
|
71
|
+
})
|
|
72
|
+
})]
|
|
73
|
+
}),
|
|
74
|
+
body("pb-[max(1.5rem,env(safe-area-inset-bottom))]"),
|
|
75
|
+
foot("pb-[max(1rem,env(safe-area-inset-bottom))]")
|
|
76
|
+
]
|
|
77
|
+
})
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
export { DetailSheet };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export interface MarkdownEditorProps extends Omit<React.ComponentProps<"textarea">, "value" | "defaultValue" | "onChange"> {
|
|
3
|
+
value: string;
|
|
4
|
+
onValueChange: (value: string) => void;
|
|
5
|
+
/**
|
|
6
|
+
* Turns the Markdown into elements for the Preview tab; the result is wrapped in Prose.
|
|
7
|
+
* With react-markdown: `(md) => <Markdown components={{ blockquote: ProseBlockquote }}>{md}</Markdown>`.
|
|
8
|
+
*/
|
|
9
|
+
renderPreview: (markdown: string) => React.ReactNode;
|
|
10
|
+
/** Classes for the wrapping Tabs. `className` goes to the textarea. */
|
|
11
|
+
wrapperClassName?: string;
|
|
12
|
+
}
|
|
13
|
+
/** Markdown field with Write and Preview tabs. Other props go to the Textarea (id, placeholder, aria-*). */
|
|
14
|
+
declare function MarkdownEditor({ value, onValueChange, renderPreview, className, wrapperClassName, ...props }: MarkdownEditorProps): React.JSX.Element;
|
|
15
|
+
export { MarkdownEditor };
|