harebear-ui 0.8.1 → 0.10.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 +15 -1
- package/dist/components/admin/app-nav.js +28 -11
- 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/checklist-editor.d.ts +20 -0
- package/dist/components/admin/checklist-editor.js +69 -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/comment-thread.d.ts +26 -0
- package/dist/components/admin/comment-thread.js +68 -0
- package/dist/components/admin/copy-field.d.ts +3 -1
- package/dist/components/admin/copy-field.js +10 -5
- 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 +12 -0
- package/dist/components/admin/markdown-editor.js +46 -0
- package/dist/components/admin/page-header.d.ts +3 -1
- package/dist/components/admin/page-header.js +17 -9
- package/dist/components/admin/page.d.ts +16 -0
- package/dist/components/admin/page.js +42 -0
- package/dist/components/admin/prompt-dialog.d.ts +19 -0
- package/dist/components/admin/prompt-dialog.js +83 -0
- package/dist/components/admin/settings.d.ts +4 -2
- package/dist/components/admin/settings.js +37 -30
- package/dist/components/admin/time-zone-picker.d.ts +12 -0
- package/dist/components/admin/time-zone-picker.js +31 -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 +6 -1
- package/dist/components/admin/user-menu.js +22 -10
- 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 +36 -8
- package/dist/components/ai/code-block.d.ts +10 -3
- package/dist/components/ai/code-block.js +57 -8
- package/dist/components/ai/conversation-list.d.ts +13 -3
- package/dist/components/ai/conversation-list.js +15 -26
- package/dist/components/ai/markdown.d.ts +13 -0
- package/dist/components/ai/markdown.js +41 -0
- package/dist/components/auth/auth.d.ts +41 -5
- package/dist/components/auth/auth.js +167 -159
- 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 +27 -0
- package/dist/components/data/day-strip.js +78 -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/layout/collapse.js +1 -1
- package/dist/components/layout/text.d.ts +3 -3
- package/dist/components/layout/text.js +6 -1
- package/dist/components/ui/avatar.js +1 -1
- package/dist/components/ui/button.js +1 -1
- package/dist/components/ui/check-circle.js +1 -1
- package/dist/components/ui/code-block.js +2 -2
- package/dist/components/ui/combobox.js +1 -0
- package/dist/components/ui/input-group.js +1 -1
- package/dist/components/ui/input.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/components/ui/sonner.d.ts +2 -2
- package/dist/components/ui/sonner.js +1 -1
- package/dist/components/ui/toggle.js +1 -1
- package/dist/index.d.ts +23 -7
- package/dist/index.js +21 -5
- package/dist/lib/internal/date.js +8 -0
- package/dist/lib/{toast-clearance.js → internal/toast-clearance.js} +1 -1
- package/dist/styles.css +31 -1
- package/docs/COMPONENTS.md +302 -28
- package/docs/ROADMAP.md +13 -11
- package/llms.txt +10 -8
- package/package.json +3 -1
- package/skills/harebear-ui/SKILL.md +5 -3
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,10 +6,16 @@ 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". */
|
|
12
14
|
label: string;
|
|
15
|
+
/** Makes the heading a link, e.g. "/lists"; the chevron next to it still collapses the group. */
|
|
16
|
+
href?: string;
|
|
17
|
+
/** The heading link is the current page: aria-current and the active text style. */
|
|
18
|
+
active?: boolean;
|
|
13
19
|
items: AppNavPlace[];
|
|
14
20
|
/** Expanded at first. Default true. */
|
|
15
21
|
defaultOpen?: boolean;
|
|
@@ -22,6 +28,8 @@ export interface AppNavAction {
|
|
|
22
28
|
label: string;
|
|
23
29
|
icon: React.ReactNode;
|
|
24
30
|
onSelect: () => void;
|
|
31
|
+
/** Show the button in the desktop sidebar. Default true; false keeps only the round phone button. */
|
|
32
|
+
sidebar?: boolean;
|
|
25
33
|
}
|
|
26
34
|
export interface AppNavProps {
|
|
27
35
|
/** Product mark at the top of the sidebar, usually <AppBrand />. */
|
|
@@ -43,6 +51,12 @@ export interface AppNavProps {
|
|
|
43
51
|
defaultOpen?: boolean;
|
|
44
52
|
/** Sidebar width. Default "15.75rem". */
|
|
45
53
|
width?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Main column exactly fills the screen (minus the tab bar on phones, and the round action button's space when
|
|
56
|
+
* `action` is set) and does not scroll, for a chat or map. The
|
|
57
|
+
* child takes the height with `flex-1 min-h-0` (ChatPanel does) and scrolls inside itself.
|
|
58
|
+
*/
|
|
59
|
+
fill?: boolean;
|
|
46
60
|
/** Classes for the main column. */
|
|
47
61
|
className?: string;
|
|
48
62
|
children?: React.ReactNode;
|
|
@@ -53,5 +67,5 @@ export interface AppNavProps {
|
|
|
53
67
|
* and an optional round action button. The main column gets bottom padding and sets `--hb-bottom-inset` (and
|
|
54
68
|
* `--hb-fab-inset`) so an AssistantDock rendered inside sits above the tab bar and the action button.
|
|
55
69
|
*/
|
|
56
|
-
declare function AppNav({ brand, places, group, footer, action, label, linkComponent, collapsible, storageKey, defaultOpen, width, className, children }: AppNavProps): React.JSX.Element;
|
|
70
|
+
declare function AppNav({ brand, places, group, footer, action, label, linkComponent, collapsible, storageKey, defaultOpen, width, fill, className, children }: AppNavProps): React.JSX.Element;
|
|
57
71
|
export { AppNav };
|
|
@@ -4,7 +4,7 @@ import { Badge } from "../ui/badge.js";
|
|
|
4
4
|
import { Button } from "../ui/button.js";
|
|
5
5
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible.js";
|
|
6
6
|
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar.js";
|
|
7
|
-
import { useToastClearance } from "../../lib/toast-clearance.js";
|
|
7
|
+
import { useToastClearance } from "../../lib/internal/toast-clearance.js";
|
|
8
8
|
import { cn } from "../../lib/utils.js";
|
|
9
9
|
import * as React from "react";
|
|
10
10
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
@@ -17,7 +17,7 @@ const TAB_LIMIT = 5;
|
|
|
17
17
|
* and an optional round action button. The main column gets bottom padding and sets `--hb-bottom-inset` (and
|
|
18
18
|
* `--hb-fab-inset`) so an AssistantDock rendered inside sits above the tab bar and the action button.
|
|
19
19
|
*/
|
|
20
|
-
function AppNav({ brand, places, group, footer, action, label = "Main", linkComponent = "a", collapsible, storageKey, defaultOpen, width = "15.75rem", className, children }) {
|
|
20
|
+
function AppNav({ brand, places, group, footer, action, label = "Main", linkComponent = "a", collapsible, storageKey, defaultOpen, width = "15.75rem", fill, className, children }) {
|
|
21
21
|
const tabs = places.filter((p) => p.tab !== false).slice(0, TAB_LIMIT);
|
|
22
22
|
useToastClearance("--hb-toast-nav", "calc(4.5rem + env(safe-area-inset-bottom))", tabs.length > 0);
|
|
23
23
|
useToastClearance("--hb-toast-fab", "4.5rem", Boolean(action));
|
|
@@ -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))
|
|
@@ -53,7 +53,7 @@ function AppNav({ brand, places, group, footer, action, label = "Main", linkComp
|
|
|
53
53
|
Link: linkComponent
|
|
54
54
|
})]
|
|
55
55
|
}),
|
|
56
|
-
className: cn("max-md:pb-[calc(4.5rem+env(safe-area-inset-bottom))] max-md:[--hb-bottom-inset:calc(4.5rem+env(safe-area-inset-bottom))]", action && "max-md:[--hb-fab-inset:4.5rem]", className),
|
|
56
|
+
className: cn("max-md:pb-[calc(4.5rem+env(safe-area-inset-bottom))] max-md:[--hb-bottom-inset:calc(4.5rem+env(safe-area-inset-bottom))]", action && "max-md:pb-[calc(9rem+env(safe-area-inset-bottom))] max-md:[--hb-fab-inset:4.5rem]", fill && "h-dvh overflow-hidden", className),
|
|
57
57
|
children: [
|
|
58
58
|
children,
|
|
59
59
|
tabs.length > 0 && /* @__PURE__ */ jsx(TabBar, {
|
|
@@ -101,6 +101,10 @@ function PlaceItem({ place, Link }) {
|
|
|
101
101
|
}
|
|
102
102
|
function NavGroup({ group, Link }) {
|
|
103
103
|
const [open, setOpen] = React.useState(group.defaultOpen ?? true);
|
|
104
|
+
const chevron = /* @__PURE__ */ jsx(ChevronDownIcon, {
|
|
105
|
+
"aria-hidden": "true",
|
|
106
|
+
className: cn("size-3.5 transition-transform motion-reduce:transition-none", !open && "-rotate-90")
|
|
107
|
+
});
|
|
104
108
|
return /* @__PURE__ */ jsxs(Collapsible, {
|
|
105
109
|
open,
|
|
106
110
|
onOpenChange: setOpen,
|
|
@@ -108,15 +112,28 @@ function NavGroup({ group, Link }) {
|
|
|
108
112
|
className: "flex flex-col gap-1 group-data-[collapsible=icon]:hidden",
|
|
109
113
|
children: [/* @__PURE__ */ jsxs("div", {
|
|
110
114
|
className: "flex items-center gap-1 pr-1",
|
|
111
|
-
children: [/* @__PURE__ */ jsxs(
|
|
112
|
-
|
|
115
|
+
children: [group.href ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
116
|
+
/* @__PURE__ */ jsx(Link, {
|
|
117
|
+
href: group.href,
|
|
118
|
+
"aria-current": group.active ? "page" : void 0,
|
|
119
|
+
className: cn("flex h-8 min-w-0 items-center rounded-md px-3 text-xs font-medium text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:h-11", group.active && "text-foreground"),
|
|
120
|
+
children: /* @__PURE__ */ jsx("span", {
|
|
121
|
+
className: "truncate",
|
|
122
|
+
children: group.label
|
|
123
|
+
})
|
|
124
|
+
}),
|
|
125
|
+
/* @__PURE__ */ jsx(CollapsibleTrigger, {
|
|
126
|
+
"aria-label": `${open ? "Collapse" : "Expand"} ${group.label}`,
|
|
127
|
+
className: "-ml-1 grid size-8 shrink-0 place-items-center rounded-md text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:size-11",
|
|
128
|
+
children: chevron
|
|
129
|
+
}),
|
|
130
|
+
/* @__PURE__ */ jsx("span", { className: "flex-1" })
|
|
131
|
+
] }) : /* @__PURE__ */ jsxs(CollapsibleTrigger, {
|
|
132
|
+
className: "flex h-8 min-w-0 flex-1 items-center gap-1.5 rounded-md px-3 text-xs font-medium text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
|
|
113
133
|
children: [/* @__PURE__ */ jsx("span", {
|
|
114
134
|
className: "truncate",
|
|
115
135
|
children: group.label
|
|
116
|
-
}),
|
|
117
|
-
"aria-hidden": "true",
|
|
118
|
-
className: cn("size-3.5 transition-transform motion-reduce:transition-none", !open && "-rotate-90")
|
|
119
|
-
})]
|
|
136
|
+
}), chevron]
|
|
120
137
|
}), group.onAdd && /* @__PURE__ */ jsx(Button, {
|
|
121
138
|
type: "button",
|
|
122
139
|
variant: "ghost",
|
|
@@ -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,20 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export interface ChecklistItem {
|
|
3
|
+
id: string;
|
|
4
|
+
text: string;
|
|
5
|
+
done: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface ChecklistEditorProps extends Omit<React.ComponentProps<"div">, "children" | "onToggle"> {
|
|
8
|
+
items: ChecklistItem[];
|
|
9
|
+
onToggle: (id: string, done: boolean) => void;
|
|
10
|
+
onRemove: (id: string) => void;
|
|
11
|
+
/** Called with the trimmed text when the add field is submitted with Enter. The field then clears. */
|
|
12
|
+
onAdd: (text: string) => void;
|
|
13
|
+
/** Placeholder and accessible name of the add field. Default "Add an item". */
|
|
14
|
+
addPlaceholder?: string;
|
|
15
|
+
/** Progress line above the items, shown when there are any. Default "2 of 5 done". */
|
|
16
|
+
progressLabel?: (done: number, total: number) => React.ReactNode;
|
|
17
|
+
}
|
|
18
|
+
/** Steps inside a record (a to-do's checklist): tick, remove, add with Enter, and an "x of y done" line. */
|
|
19
|
+
declare function ChecklistEditor({ items, onToggle, onRemove, onAdd, addPlaceholder, progressLabel, className, ...props }: ChecklistEditorProps): React.JSX.Element;
|
|
20
|
+
export { ChecklistEditor };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Button } from "../ui/button.js";
|
|
3
|
+
import { CheckCircle } from "../ui/check-circle.js";
|
|
4
|
+
import { Input } from "../ui/input.js";
|
|
5
|
+
import { cn } from "../../lib/utils.js";
|
|
6
|
+
import * as React from "react";
|
|
7
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
import { XIcon } from "lucide-react";
|
|
9
|
+
// src/components/admin/checklist-editor.tsx
|
|
10
|
+
const defaultProgress = (done, total) => `${done} of ${total} done`;
|
|
11
|
+
/** Steps inside a record (a to-do's checklist): tick, remove, add with Enter, and an "x of y done" line. */
|
|
12
|
+
function ChecklistEditor({ items, onToggle, onRemove, onAdd, addPlaceholder = "Add an item", progressLabel = defaultProgress, className, ...props }) {
|
|
13
|
+
const [text, setText] = React.useState("");
|
|
14
|
+
const id = React.useId();
|
|
15
|
+
const done = items.filter((i) => i.done).length;
|
|
16
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
17
|
+
role: "group",
|
|
18
|
+
"data-slot": "checklist-editor",
|
|
19
|
+
className: cn("flex flex-col gap-2", className),
|
|
20
|
+
...props,
|
|
21
|
+
children: [items.length > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("p", {
|
|
22
|
+
className: "text-xs text-muted-foreground",
|
|
23
|
+
children: progressLabel(done, items.length)
|
|
24
|
+
}), /* @__PURE__ */ jsx("ul", {
|
|
25
|
+
className: "flex flex-col",
|
|
26
|
+
children: items.map((item) => /* @__PURE__ */ jsxs("li", {
|
|
27
|
+
className: "flex min-w-0 items-start gap-3 py-1.5 ps-2",
|
|
28
|
+
children: [
|
|
29
|
+
/* @__PURE__ */ jsx(CheckCircle, {
|
|
30
|
+
id: `${id}-${item.id}`,
|
|
31
|
+
className: "mt-2",
|
|
32
|
+
checked: item.done,
|
|
33
|
+
onCheckedChange: (v) => onToggle(item.id, v === true)
|
|
34
|
+
}),
|
|
35
|
+
/* @__PURE__ */ jsx("label", {
|
|
36
|
+
htmlFor: `${id}-${item.id}`,
|
|
37
|
+
className: cn("min-w-0 flex-1 py-2 text-[15px] leading-6 wrap-anywhere", item.done && "text-muted-foreground line-through decoration-muted-foreground/60"),
|
|
38
|
+
children: item.text
|
|
39
|
+
}),
|
|
40
|
+
/* @__PURE__ */ jsx(Button, {
|
|
41
|
+
type: "button",
|
|
42
|
+
variant: "ghost",
|
|
43
|
+
size: "icon",
|
|
44
|
+
className: "pointer-coarse:size-11",
|
|
45
|
+
"aria-label": `Remove ${item.text}`,
|
|
46
|
+
onClick: () => onRemove(item.id),
|
|
47
|
+
children: /* @__PURE__ */ jsx(XIcon, {})
|
|
48
|
+
})
|
|
49
|
+
]
|
|
50
|
+
}, item.id))
|
|
51
|
+
})] }), /* @__PURE__ */ jsx("form", {
|
|
52
|
+
onSubmit: (e) => {
|
|
53
|
+
e.preventDefault();
|
|
54
|
+
const value = text.trim();
|
|
55
|
+
if (!value) return;
|
|
56
|
+
onAdd(value);
|
|
57
|
+
setText("");
|
|
58
|
+
},
|
|
59
|
+
children: /* @__PURE__ */ jsx(Input, {
|
|
60
|
+
value: text,
|
|
61
|
+
onChange: (e) => setText(e.target.value),
|
|
62
|
+
placeholder: addPlaceholder,
|
|
63
|
+
"aria-label": addPlaceholder,
|
|
64
|
+
enterKeyHint: "done"
|
|
65
|
+
})
|
|
66
|
+
})]
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
export { ChecklistEditor };
|
|
@@ -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,26 @@
|
|
|
1
|
+
import { CategoryColor } from "./category.js";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
export interface CommentThreadComment {
|
|
4
|
+
id: string;
|
|
5
|
+
author: {
|
|
6
|
+
name: string;
|
|
7
|
+
color?: CategoryColor;
|
|
8
|
+
avatarUrl?: string;
|
|
9
|
+
};
|
|
10
|
+
/** Muted text after the name, e.g. "2 h ago" or "via HomeBot · 2 h ago". */
|
|
11
|
+
meta?: React.ReactNode;
|
|
12
|
+
/** Markdown, rendered with `Markdown`. */
|
|
13
|
+
body: string;
|
|
14
|
+
}
|
|
15
|
+
export interface CommentThreadProps extends Omit<React.ComponentProps<"div">, "children" | "onSubmit"> {
|
|
16
|
+
comments: CommentThreadComment[];
|
|
17
|
+
/** Called with the trimmed text. The box then clears. */
|
|
18
|
+
onSubmit: (text: string) => void;
|
|
19
|
+
/** Placeholder and accessible name of the box. Default "Add a comment". */
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
/** Default "Comment". */
|
|
22
|
+
submitLabel?: string;
|
|
23
|
+
}
|
|
24
|
+
/** Comments on a record, oldest first, with a box to add one. Bodies are Markdown. */
|
|
25
|
+
declare function CommentThread({ comments, onSubmit, placeholder, submitLabel, className, ...props }: CommentThreadProps): React.JSX.Element;
|
|
26
|
+
export { CommentThread };
|