uifolio 0.5.3 → 0.5.4
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/README.md +27 -233
- package/cli/uifolio.mjs +252 -53
- package/package.json +2 -1
- package/src/components/ui/audio-player.tsx +7 -7
- package/src/components/ui/color-picker.tsx +6 -6
- package/src/components/ui/command-menu.tsx +4 -4
- package/src/components/ui/file-upload.tsx +2 -2
- package/src/components/ui/icon-picker.tsx +4 -4
- package/src/components/ui/rich-text-editor.tsx +12 -13
- package/src/components/ui/stories.tsx +2 -2
- package/src/components/ui/table.tsx +164 -0
package/README.md
CHANGED
|
@@ -2,46 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/uifolio)
|
|
4
4
|
[](https://www.npmjs.com/package/uifolio)
|
|
5
|
-
[](https://www.npmjs.com/package/uifolio)
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
The `uifolio` npm package contains the CLI, the 14 components and their 9 helpers — nothing else.
|
|
10
|
-
The demo site (Vite, animations, playgrounds) lives in the project repository and is not part
|
|
11
|
-
of the package.
|
|
12
|
-
|
|
13
|
-
## Getting started
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
npm install
|
|
17
|
-
npm run dev # demo server (http://localhost:5173)
|
|
18
|
-
npm run build # typecheck + production build
|
|
19
|
-
npm run preview # serve the build
|
|
20
|
-
npm run lint # oxlint
|
|
21
|
-
npm test # CLI tests (copy, color, npm manifest)
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
Routes (hash):
|
|
25
|
-
|
|
26
|
-
- `#/` — home page (UIFolio identity)
|
|
27
|
-
- `#/docs` — introduction, installation, theme
|
|
28
|
-
- `#/components` — documented catalog
|
|
29
|
-
- `#/components/video` — VideoPlayer playground
|
|
30
|
-
- `#/components/calendar`, `/time`, `/audio`, `/stories`, `/editor`, `/upload`,
|
|
31
|
-
`/shortcuts`, `/panels`, `/choice`, `/color-picker`, `/icon-picker`
|
|
32
|
-
- `#/animations/otp` — animated OTP demo
|
|
33
|
-
- `#/animations/horizontal-accordion`, `/storytelling`, `/stacking`,
|
|
34
|
-
`/scroll-text`, `/image-crossfade` — scroll animations and crossfades
|
|
35
|
-
|
|
36
|
-
**Light/dark** theme: button in the header, choice persisted in
|
|
37
|
-
`localStorage (uifolio-theme)`, initialized before the first paint (no flash),
|
|
38
|
-
default = system preference.
|
|
39
|
-
|
|
40
|
-
**Global customization**: the right panel (or the accordion on mobile) sets the
|
|
41
|
-
`--primary` color and the `--radius` for every component. The advanced picker offers
|
|
42
|
-
saturation/lightness, hue, alpha, eyedropper, RGB channels, presets and HEX/RGB/HSL/RGBA
|
|
43
|
-
formats. The choice is persisted in `localStorage (uifolio:design)` and reused in the
|
|
44
|
-
copied CLI commands (`--color`, `--radius`).
|
|
6
|
+
A small, theme-friendly UI registry (components live in the target project, copied and edited) with a shadcn-like CLI (`uifolio add`).
|
|
45
7
|
|
|
46
8
|
## Using the components in another project (CLI)
|
|
47
9
|
|
|
@@ -69,21 +31,19 @@ node cli/uifolio.mjs add file-upload --alias "#lib" --dry-run # simulate
|
|
|
69
31
|
| `uifolio init` | writes `uifolio.json` (dir, lib, alias) in the target project |
|
|
70
32
|
| `uifolio add <names...>` | copies the component **+ transitive dependencies** (helpers `@/lib/*`) |
|
|
71
33
|
|
|
72
|
-
Options: `--cwd <dir>`, `--dir <path>` (default `src/components/ui`),
|
|
73
|
-
`--lib <path>` (default `src/lib`), `--alias <alias>` (default `@`),
|
|
74
|
-
`--page <path>` (created if missing), `--interactive` (force the questions),
|
|
75
|
-
`--force` (overwrite), `--dry-run` (simulate), `--color <hex>` and `--radius <px>`.
|
|
34
|
+
Options: `--cwd <dir>`, `--dir <path>` (default `src/components/ui`), `--lib <path>` (default `src/lib`), `--alias <alias>` (default `@`), `--page <path>` (created if missing), `--interactive` (force the questions), `--force` (overwrite), `--dry-run` (simulate), `--color <hex>` and `--radius <px>`.
|
|
76
35
|
|
|
77
|
-
`add` does not stop at the copy.
|
|
78
|
-
components
|
|
36
|
+
`add` does not stop at the copy. The components always land in
|
|
37
|
+
`src/components/ui` (override with `--dir`) and the page imports them through
|
|
38
|
+
the `@/` alias. Without `--page`, the CLI asks where to use them:
|
|
79
39
|
|
|
80
|
-
1. *Do you already have a file ready to receive the component (
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
40
|
+
1. *Do you already have a file ready to receive the component (…component…) (y/N) ?* `yes` → *In which file should the component (…component…) be copied (e.g. src/pages/calendar.tsx) ?*: the import and a ready-to-render example are added without duplication, and the file is created when missing;
|
|
41
|
+
2. `no` → *Would you like to create a new folder and an import file (Y/n) ?* `yes` → *Please specify the new path (e.g. src/pages/calendar.tsx) ?*: the folder and the file are created; `no` → install only, no file is touched.
|
|
42
|
+
|
|
43
|
+
The example is a small component (`<CalendarExample />` plus its definition)
|
|
44
|
+
that carries the data and props each component needs (items for `Stories` and
|
|
45
|
+
`CommandMenu`, a source for the players, children for the resizable panels…),
|
|
46
|
+
so the component is visible immediately instead of an empty `<Component />`.
|
|
87
47
|
|
|
88
48
|
Answering `-` to a path question installs the component without creating or
|
|
89
49
|
modifying any page.
|
|
@@ -103,65 +63,17 @@ At the end, the CLI prints the detected npm dependencies, for example:
|
|
|
103
63
|
npm install clsx tailwind-merge lucide-react hls.js
|
|
104
64
|
```
|
|
105
65
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
- CSS tokens: `:root` (light) and `.dark` (dark) in `src/index.css`,
|
|
109
|
-
wired to Tailwind through `@theme inline` + `@custom-variant dark`.
|
|
110
|
-
- `useTheme()` (`src/lib/use-theme.ts`): `{ theme, setTheme, toggle }`,
|
|
111
|
-
persistence in `localStorage('uifolio-theme')`.
|
|
112
|
-
- `<ThemeToggle />` (`src/components/theme-toggle.tsx`): Sun/Moon button.
|
|
113
|
-
- No fixed color anywhere in the components: everything goes through
|
|
114
|
-
`background`, `foreground`, `card`, `muted`, `border`, `primary`, `accent`, `ring`.
|
|
115
|
-
|
|
116
|
-
## Real upload
|
|
117
|
-
|
|
118
|
-
`FileUpload` knows nothing about your backend: it calls
|
|
119
|
-
`uploader(file, onProgress) => Promise<url>` and shows progress, success and a link
|
|
120
|
-
to the returned public URL.
|
|
121
|
-
|
|
122
|
-
The demo uses `createUploader()` (`src/lib/uploader.ts`):
|
|
66
|
+
### Preview parity
|
|
123
67
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
68
|
+
The docs preview and the installed component render identically because the page
|
|
69
|
+
written by the CLI ships a demo of the same shape as the docs. If a component
|
|
70
|
+
needs data to show anything (for example Stories, CommandMenu, FileUpload,
|
|
71
|
+
AudioPlayer, VideoPlayer or the resizable panels), the page is created with that
|
|
72
|
+
data, not an empty instance.
|
|
128
73
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
`/uploads/<key>`;
|
|
133
|
-
- **S3-compatible mode** (AWS S3, Cloudflare R2, MinIO): when `S3_ENDPOINT`,
|
|
134
|
-
`S3_BUCKET`, `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY` are set, the server
|
|
135
|
-
returns a **SigV4** signed PUT URL (implemented with `node:crypto`, no SDK)
|
|
136
|
-
and the browser uploads straight to the bucket.
|
|
137
|
-
|
|
138
|
-
Configuration in `.env` — see `.env.example`.
|
|
139
|
-
|
|
140
|
-
Plugging in your own uploader:
|
|
141
|
-
|
|
142
|
-
```tsx
|
|
143
|
-
const upload = createUploader({ signEndpoint: '/api/upload/sign' })
|
|
144
|
-
|
|
145
|
-
// or your own backend:
|
|
146
|
-
const upload = async (file: File, onProgress: (v: number) => void) => {
|
|
147
|
-
const form = new FormData()
|
|
148
|
-
form.append('file', file)
|
|
149
|
-
const res = await fetch('/api/your-endpoint', { method: 'POST', body: form })
|
|
150
|
-
const { url } = await res.json()
|
|
151
|
-
return url
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
<FileUpload accept="image/*" uploader={upload} onChange={setFiles} />
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
## Stack
|
|
158
|
-
|
|
159
|
-
- Vite 8 · React 19 · TypeScript
|
|
160
|
-
- Tailwind CSS 4 (CSS tokens, no JS config)
|
|
161
|
-
- `lucide-react` for icons, `clsx` + `tailwind-merge` for `cn()`
|
|
162
|
-
- `hls.js` imported **dynamically**, only when a `.m3u8` stream is played
|
|
163
|
-
- No other dependency: editor, calendar, time picker, panels, palette,
|
|
164
|
-
OTP and SigV4 signing are home-made
|
|
74
|
+
If the docs entry for a component is missing, the detail page does not show a blank
|
|
75
|
+
placeholder. It falls back to the preview as-is (or a minimal coherent state) and
|
|
76
|
+
only marks the installation as complete when that is relevant.
|
|
165
77
|
|
|
166
78
|
## Components
|
|
167
79
|
|
|
@@ -178,142 +90,24 @@ const upload = async (file: File, onProgress: (v: number) => void) => {
|
|
|
178
90
|
| ResizablePanels | `src/components/ui/resizable.tsx` | horizontal/vertical groups, handles, min-size, keyboard |
|
|
179
91
|
| OtpInput | `src/components/ui/otp-input.tsx` | 4 fields, paste, rotate + success check |
|
|
180
92
|
| ColorPicker | `src/components/ui/color-picker.tsx` | advanced panel, saturation/lightness, hue, alpha, eyedropper, RGB channels, presets, HEX/RGB/HSL/RGBA |
|
|
93
|
+
| Table | `src/components/ui/table.tsx` | typed data table, uniform `px-4` cells and a wider gap before Actions, selection and row actions |
|
|
181
94
|
|
|
182
95
|
Copyable helpers (the CLI follows them automatically): `src/lib/utils.ts`,
|
|
183
96
|
`src/lib/video.ts`, `src/lib/vtt.ts`, `src/lib/date.ts`, `src/lib/uploader.ts`, `src/lib/color.ts`,
|
|
184
97
|
`src/lib/use-theme.ts`, `src/lib/use-hotkeys.ts`, `src/lib/use-reduced-motion.ts`.
|
|
185
98
|
|
|
186
|
-
## VideoPlayer
|
|
187
|
-
|
|
188
|
-
```tsx
|
|
189
|
-
<VideoPlayer
|
|
190
|
-
sources={file ?? '/video.mp4'}
|
|
191
|
-
aspectRatio="16/9"
|
|
192
|
-
fit="cover"
|
|
193
|
-
controls="full" // full | minimal | none
|
|
194
|
-
accent="#facc15"
|
|
195
|
-
autoPlay
|
|
196
|
-
muted
|
|
197
|
-
loop
|
|
198
|
-
lazy
|
|
199
|
-
subtitles={[
|
|
200
|
-
{ src: '/fr.vtt', label: 'French', lang: 'fr', default: true },
|
|
201
|
-
{ src: '/en.vtt', label: 'English', lang: 'en' },
|
|
202
|
-
]}
|
|
203
|
-
subtitleClassName="bottom-20"
|
|
204
|
-
overlay={(state) => <MyBadge playing={state.playing} />}
|
|
205
|
-
/>
|
|
206
|
-
```
|
|
207
|
-
|
|
208
|
-
- Sources: direct URL, `File`/`Blob`, quality list (`{ src, quality }`), `.m3u8`,
|
|
209
|
-
YouTube and Vimeo.
|
|
210
|
-
- For embeds, the iframe is only injected on click (facade + thumbnail) and native
|
|
211
|
-
controls are hidden: the component bar drives the platform player
|
|
212
|
-
(progress, time, volume, speed, play/pause) through `postMessage`.
|
|
213
|
-
- `ref` exposes `play`, `pause`, `seek`, `mute`, `setVolume`, `setPlaybackRate`,
|
|
214
|
-
`requestFullscreen`, `getVideo`.
|
|
215
|
-
- The progress bar and the volume follow the global `--radius` (handles
|
|
216
|
-
included), like the rest of the catalog.
|
|
217
|
-
- Optimizations: lazy loading, pause off-screen, controlled `preload`, `hls.js` in a separate
|
|
218
|
-
chunk, revoked object URLs, quality switch without losing the position.
|
|
219
|
-
- Subtitles: tolerant VTT parsing, file or URL tracks, free styling through
|
|
220
|
-
`subtitleClassName`, CC menu.
|
|
221
|
-
|
|
222
|
-
## Calendar / TimePicker
|
|
223
|
-
|
|
224
|
-
```tsx
|
|
225
|
-
const [date, setDate] = useState<CalendarValue>(null)
|
|
226
|
-
<Calendar mode="single" value={date} onChange={setDate} min={min} max={max} />
|
|
227
|
-
<Calendar mode="range" value={range} onChange={setRange} />
|
|
228
|
-
<Calendar disabled={(day) => day.getDay() === 0} />
|
|
229
|
-
|
|
230
|
-
<TimePicker value={time} onChange={setTime} withSeconds step={15} hour12 />
|
|
231
|
-
```
|
|
232
|
-
|
|
233
|
-
Manual input accepts `dd/mm/yyyy`, `dd-mm-yyyy` and `yyyy-mm-dd`.
|
|
234
|
-
|
|
235
|
-
## Audio / Stories
|
|
236
|
-
|
|
237
|
-
```tsx
|
|
238
|
-
<AudioPlayer src="/audio.wav" title="Title" cover="/cover.svg" accent="#facc15" />
|
|
239
|
-
<AudioPlayer variant="compact" src="/audio.wav" title="Title" />
|
|
240
|
-
|
|
241
|
-
<Stories items={stories} open={open} onOpenChange={setOpen} duration={5000} />
|
|
242
|
-
```
|
|
243
|
-
|
|
244
|
-
A story can be an image, a video or a text slide with its own background and duration.
|
|
245
|
-
|
|
246
|
-
## Editor / Upload / Shortcuts / Panels / OTP
|
|
247
|
-
|
|
248
|
-
```tsx
|
|
249
|
-
<RichTextEditor value={html} onChange={setHtml} toolbar />
|
|
250
|
-
|
|
251
|
-
<FileUpload accept="image/*,video/*" maxFiles={3} uploader={upload} onChange={setFiles} />
|
|
252
|
-
|
|
253
|
-
<CommandMenu items={items} open={open} onOpenChange={setOpen} />
|
|
254
|
-
useHotkeys({ 'mod+k': () => setOpen(true) })
|
|
255
|
-
|
|
256
|
-
<ResizablePanelGroup direction="horizontal">
|
|
257
|
-
<ResizablePanel defaultSize={34} minSize={20}>…</ResizablePanel>
|
|
258
|
-
<ResizableHandle />
|
|
259
|
-
<ResizablePanel defaultSize={66} minSize={25}>…</ResizablePanel>
|
|
260
|
-
</ResizablePanelGroup>
|
|
261
|
-
|
|
262
|
-
<OtpInput length={4} value={code} onChange={setCode} onComplete={validate} />
|
|
263
|
-
```
|
|
264
|
-
|
|
265
|
-
The OTP aligns 4 to 8 fields, forms a circle on validation and then shows a check.
|
|
266
|
-
The animation respects `prefers-reduced-motion`.
|
|
267
|
-
|
|
268
|
-
## Animations
|
|
269
|
-
|
|
270
|
-
Every animation has its own documented page: `#/animations/otp` for the OTP, then
|
|
271
|
-
`#/animations/horizontal-accordion` and `#/animations/storytelling` for the
|
|
272
|
-
horizontal cards, and `#/animations/stacking` for the cards that stack on scroll with
|
|
273
|
-
a text color driven by progress. Colors follow `--primary`. The number of cards is set
|
|
274
|
-
from 4 to 8 right in each preview. These stand-alone demos are not installable
|
|
275
|
-
through the CLI.
|
|
276
|
-
|
|
277
99
|
## Publishing the package
|
|
278
100
|
|
|
279
|
-
The published package only contains the CLI, the components and the helpers (`files`): no
|
|
280
|
-
runtime dependency, React and Tailwind stay in `devDependencies` so they are not installed
|
|
281
|
-
by people running `npx uifolio`.
|
|
282
|
-
|
|
283
101
|
```bash
|
|
284
|
-
npm
|
|
285
|
-
npm profile enable-2fa auth-and-writes # npm refuses to publish without 2FA
|
|
286
|
-
npm publish --otp 123456 # prepublishOnly checks cli/uifolio.mjs
|
|
102
|
+
npm publish
|
|
287
103
|
```
|
|
288
104
|
|
|
289
|
-
|
|
290
|
-
these tokens for publishing: 2FA + `--otp` is the safe path.
|
|
291
|
-
|
|
292
|
-
Then `npx uifolio@latest add <component>` and `pnpm dlx uifolio@latest add <component>`
|
|
293
|
-
work.
|
|
105
|
+
Automatic publishing is wired in `.github/workflows/publish.yml` and runs on tag push.
|
|
294
106
|
|
|
295
107
|
### Automatic publishing (GitHub Actions)
|
|
296
108
|
|
|
297
|
-
|
|
298
|
-
matches the `package.json` version, installs with `npm ci`, runs `oxlint`,
|
|
299
|
-
`npm pack --dry-run` and `npm test`, then publishes through **trusted publishing** (OIDC, no
|
|
300
|
-
stored token). A second job then creates the tag's **GitHub Release** with notes generated
|
|
301
|
-
from the commits.
|
|
302
|
-
|
|
303
|
-
```bash
|
|
304
|
-
npm version patch # 0.3.0 → 0.3.1, commit + tag v0.3.1
|
|
305
|
-
git push --follow-tags # triggers the workflow
|
|
306
|
-
```
|
|
307
|
-
|
|
308
|
-
Prerequisites, once:
|
|
109
|
+
The workflow publishes on tag `v*`. Run `git tag vX.Y.Z && git push --tags` to publish.
|
|
309
110
|
|
|
310
|
-
|
|
311
|
-
2. npmjs.com → `uifolio` → Settings → **Trusted publishing** → GitHub Actions:
|
|
312
|
-
organization `Barronakn`, repository `UIfolio`, workflow `publish.yml`,
|
|
313
|
-
“npm publish” action allowed;
|
|
314
|
-
3. a new configuration must complete its first publication **within 2 days**,
|
|
315
|
-
otherwise it expires and has to be recreated.
|
|
111
|
+
More info: [README on GitHub](https://github.com/Barronakn/UIfolio#readme).
|
|
316
112
|
|
|
317
|
-
|
|
318
|
-
package with Settings → Publishing access → “Require two-factor authentication and
|
|
319
|
-
disallow tokens”.
|
|
113
|
+
License: [MIT](https://opensource.org/license/mit).
|
package/cli/uifolio.mjs
CHANGED
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
* --force overwrite existing files
|
|
23
23
|
* --dry-run print what would be copied without writing anything
|
|
24
24
|
*
|
|
25
|
-
* Without --
|
|
26
|
-
*
|
|
25
|
+
* Without --page, the CLI asks interactively (terminal) where to use them.
|
|
26
|
+
* The components always land in src/components/ui unless --dir overrides it.
|
|
27
27
|
*
|
|
28
28
|
* Examples:
|
|
29
29
|
* node cli/uifolio.mjs add calendar otp --cwd ../my-project
|
|
@@ -164,10 +164,11 @@ function externalDeps(source) {
|
|
|
164
164
|
return [...deps]
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
/** Real exported name of the file (otp-input → OtpInput). */
|
|
167
|
+
/** Real exported name of the file (otp-input → OtpInput, icon-picker → IconPicker). */
|
|
168
168
|
function componentExportName(source, fallback) {
|
|
169
|
-
const
|
|
170
|
-
|
|
169
|
+
const exported = [...source.matchAll(/export\s+function\s+([A-Z][A-Za-z0-9_]*)/g)].map((match) => match[1])
|
|
170
|
+
const pascal = fallback.replace(/(^|-)([a-z0-9])/g, (_, __, char) => char.toUpperCase())
|
|
171
|
+
return exported.find((name) => name === pascal) ?? exported[0] ?? fallback
|
|
171
172
|
}
|
|
172
173
|
|
|
173
174
|
/** .tsx files under src/pages of the target project, sorted. */
|
|
@@ -222,8 +223,9 @@ function createPrompter(flags) {
|
|
|
222
223
|
})
|
|
223
224
|
|
|
224
225
|
async function ask(question, fallback) {
|
|
226
|
+
const text = question.trim().replace(/\?+$/, '')
|
|
225
227
|
const suffix = fallback ? ` (${fallback})` : ''
|
|
226
|
-
process.stdout.write(`\n \x1b[36m
|
|
228
|
+
process.stdout.write(`\n \x1b[36m${text}${suffix} ?\x1b[0m `)
|
|
227
229
|
|
|
228
230
|
let answer
|
|
229
231
|
if (queued.length > 0) answer = queued.shift()
|
|
@@ -243,7 +245,7 @@ function createPrompter(flags) {
|
|
|
243
245
|
ask,
|
|
244
246
|
/** y / yes / n / no (French values tolerated as fallback). */
|
|
245
247
|
async confirm(question, fallback = false) {
|
|
246
|
-
const answer = await ask(
|
|
248
|
+
const answer = await ask(question, fallback ? 'Y/n' : 'y/N')
|
|
247
249
|
const value = String(answer ?? '')
|
|
248
250
|
.trim()
|
|
249
251
|
.toLowerCase()
|
|
@@ -463,10 +465,179 @@ function writeTheme({ cwd, accent, radius, dryRun }) {
|
|
|
463
465
|
/* Usage du composant dans une page */
|
|
464
466
|
/* -------------------------------------------------------------------------- */
|
|
465
467
|
|
|
466
|
-
/**
|
|
467
|
-
function
|
|
468
|
-
const
|
|
469
|
-
|
|
468
|
+
/** Import specifier through the project alias: @/components/ui/calendar. */
|
|
469
|
+
function aliasImport(cwd, componentsDir, name, alias) {
|
|
470
|
+
const relativePath = relative(resolve(cwd), join(resolve(cwd, componentsDir), name))
|
|
471
|
+
.replace(/\\/g, '/')
|
|
472
|
+
.replace(/^src\//, '')
|
|
473
|
+
return `${alias}/${relativePath}`
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Example generated with the page: an instance that renders right away.
|
|
478
|
+
* A bare `<Component />` shows nothing for dialogs, players, radio groups or
|
|
479
|
+
* the animated wrappers that require children, so these examples carry the
|
|
480
|
+
* props and data each one needs.
|
|
481
|
+
*/
|
|
482
|
+
const USAGE_EXAMPLES = {
|
|
483
|
+
'audio-player': {
|
|
484
|
+
body: ` return (
|
|
485
|
+
<AudioPlayer
|
|
486
|
+
src="/sample.wav"
|
|
487
|
+
title="Test signal"
|
|
488
|
+
subtitle="Generated loop · 8 s · WAV"
|
|
489
|
+
accent="#10B981"
|
|
490
|
+
/>
|
|
491
|
+
)
|
|
492
|
+
`,
|
|
493
|
+
},
|
|
494
|
+
'video-player': {
|
|
495
|
+
body: ` return <VideoPlayer sources="/sample-720.mp4" aspectRatio="16/9" muted loop />
|
|
496
|
+
`,
|
|
497
|
+
},
|
|
498
|
+
'file-upload': {
|
|
499
|
+
body: ` return <FileUpload accept="image/*,video/*" maxSizeMb={25} maxFiles={3} />
|
|
500
|
+
`,
|
|
501
|
+
},
|
|
502
|
+
choice: {
|
|
503
|
+
imports: ["import { useState } from 'react'"],
|
|
504
|
+
body: ` const [checked, setChecked] = useState(true)
|
|
505
|
+
|
|
506
|
+
return (
|
|
507
|
+
<Choice
|
|
508
|
+
checked={checked}
|
|
509
|
+
onChange={setChecked}
|
|
510
|
+
label="Email alerts"
|
|
511
|
+
description="Get a summary every week."
|
|
512
|
+
/>
|
|
513
|
+
)
|
|
514
|
+
`,
|
|
515
|
+
},
|
|
516
|
+
'command-menu': {
|
|
517
|
+
imports: ["import { useState } from 'react'"],
|
|
518
|
+
body: ` const [open, setOpen] = useState(false)
|
|
519
|
+
|
|
520
|
+
return (
|
|
521
|
+
<div className="space-y-3">
|
|
522
|
+
<button
|
|
523
|
+
type="button"
|
|
524
|
+
onClick={() => setOpen(true)}
|
|
525
|
+
className="rounded-full bg-primary px-4 py-2 text-xs font-medium text-primary-foreground"
|
|
526
|
+
>
|
|
527
|
+
Open the palette
|
|
528
|
+
</button>
|
|
529
|
+
<CommandMenu
|
|
530
|
+
open={open}
|
|
531
|
+
onOpenChange={setOpen}
|
|
532
|
+
items={[
|
|
533
|
+
{ id: 'home', label: 'Home', group: 'Navigation', action: () => {} },
|
|
534
|
+
{ id: 'components', label: 'Components', group: 'Navigation', action: () => {} },
|
|
535
|
+
]}
|
|
536
|
+
/>
|
|
537
|
+
</div>
|
|
538
|
+
)
|
|
539
|
+
`,
|
|
540
|
+
},
|
|
541
|
+
stories: {
|
|
542
|
+
imports: ["import { useState } from 'react'"],
|
|
543
|
+
types: ['StoryItem'],
|
|
544
|
+
body: ` const [open, setOpen] = useState(false)
|
|
545
|
+
const items: StoryItem[] = [
|
|
546
|
+
{ type: 'image', src: '/story-1.svg', title: 'Dawn', duration: 5000 },
|
|
547
|
+
{ type: 'text', title: 'Text only', text: 'A story can be plain text.', duration: 5000 },
|
|
548
|
+
]
|
|
549
|
+
|
|
550
|
+
return (
|
|
551
|
+
<div className="space-y-3">
|
|
552
|
+
<button
|
|
553
|
+
type="button"
|
|
554
|
+
onClick={() => setOpen(true)}
|
|
555
|
+
className="rounded-full bg-primary px-4 py-2 text-xs font-medium text-primary-foreground"
|
|
556
|
+
>
|
|
557
|
+
Open the stories
|
|
558
|
+
</button>
|
|
559
|
+
<Stories items={items} open={open} onOpenChange={setOpen} />
|
|
560
|
+
</div>
|
|
561
|
+
)
|
|
562
|
+
`,
|
|
563
|
+
},
|
|
564
|
+
resizable: {
|
|
565
|
+
members: ['ResizablePanel', 'ResizableHandle'],
|
|
566
|
+
body: ` return (
|
|
567
|
+
<ResizablePanelGroup className="h-64 rounded-xl border">
|
|
568
|
+
<ResizablePanel className="p-4">Left panel</ResizablePanel>
|
|
569
|
+
<ResizableHandle />
|
|
570
|
+
<ResizablePanel className="p-4">Right panel</ResizablePanel>
|
|
571
|
+
</ResizablePanelGroup>
|
|
572
|
+
)
|
|
573
|
+
`,
|
|
574
|
+
},
|
|
575
|
+
motion: {
|
|
576
|
+
body: ` return (
|
|
577
|
+
<FadeIn>
|
|
578
|
+
<p className="text-sm">Fade in on mount.</p>
|
|
579
|
+
</FadeIn>
|
|
580
|
+
)
|
|
581
|
+
`,
|
|
582
|
+
},
|
|
583
|
+
table: {
|
|
584
|
+
imports: ["import { useState } from 'react'"],
|
|
585
|
+
members: ['type TableColumn'],
|
|
586
|
+
body: ` const [selected, setSelected] = useState<string[]>([])
|
|
587
|
+
type Row = { id: string; name: string; status: string; total: number }
|
|
588
|
+
const rows: Row[] = [
|
|
589
|
+
{ id: 'finanx', name: 'Finanx', status: 'Active', total: 0 },
|
|
590
|
+
{ id: 'facebook', name: 'Facebook', status: 'Active', total: 408221304 },
|
|
591
|
+
]
|
|
592
|
+
const columns: TableColumn<Row>[] = [
|
|
593
|
+
{ key: 'name', header: 'Name', cell: (row) => row.name },
|
|
594
|
+
{ key: 'status', header: 'Status', cell: (row) => row.status },
|
|
595
|
+
{ key: 'total', header: 'Total received', cell: (row) => row.total },
|
|
596
|
+
]
|
|
597
|
+
|
|
598
|
+
return (
|
|
599
|
+
<Table
|
|
600
|
+
rows={rows}
|
|
601
|
+
columns={columns}
|
|
602
|
+
rowKey={(row) => row.id}
|
|
603
|
+
selectable
|
|
604
|
+
selectedKeys={selected}
|
|
605
|
+
onSelectionChange={setSelected}
|
|
606
|
+
/>
|
|
607
|
+
)
|
|
608
|
+
`,
|
|
609
|
+
},
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Example component written next to the page, plus the imports it needs. */
|
|
613
|
+
function exampleFor({ name, exportName }) {
|
|
614
|
+
const spec = USAGE_EXAMPLES[name]
|
|
615
|
+
const component = `${exportName}Example`
|
|
616
|
+
if (!spec) {
|
|
617
|
+
return {
|
|
618
|
+
component,
|
|
619
|
+
imports: [],
|
|
620
|
+
types: [],
|
|
621
|
+
members: [],
|
|
622
|
+
code: `function ${component}() {\n return <${exportName} />\n}\n`,
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return {
|
|
626
|
+
component,
|
|
627
|
+
imports: spec.imports ?? [],
|
|
628
|
+
types: spec.types ?? [],
|
|
629
|
+
members: spec.members ?? [],
|
|
630
|
+
code: `function ${component}() {\n${spec.body}}\n`,
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Appends the example components that are not in the file yet. */
|
|
635
|
+
function addExamples(source, examples) {
|
|
636
|
+
const missing = examples.filter(
|
|
637
|
+
({ component }) => !new RegExp(`function\\s+${component}\\s*\\(`).test(source),
|
|
638
|
+
)
|
|
639
|
+
if (missing.length === 0) return source
|
|
640
|
+
return `${source.trimEnd()}\n\n${missing.map(({ code }) => code.trimEnd()).join('\n\n')}\n`
|
|
470
641
|
}
|
|
471
642
|
|
|
472
643
|
/** Inserts the imports after the last import of the file. */
|
|
@@ -563,33 +734,49 @@ function addJsxUsage(source, exportNames) {
|
|
|
563
734
|
}
|
|
564
735
|
|
|
565
736
|
/**
|
|
566
|
-
* Writes (or completes) the chosen file: imports from the real folder of
|
|
567
|
-
*
|
|
737
|
+
* Writes (or completes) the chosen file: `@/` imports from the real folder of
|
|
738
|
+
* the components, a working example (`<CalendarExample />`) and its definition.
|
|
568
739
|
*/
|
|
569
|
-
function writeUsage({ cwd, target, componentsDir, components, dryRun }) {
|
|
740
|
+
function writeUsage({ cwd, target, componentsDir, components, alias, dryRun }) {
|
|
570
741
|
if (existsSync(target) && statSync(target).isDirectory()) {
|
|
571
742
|
fail(`${relative(cwd, target)} is a folder: give a file (e.g. src/pages/${components[0].name}.tsx).`)
|
|
572
743
|
}
|
|
573
744
|
const created = !existsSync(target)
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
|
|
745
|
+
const entries = components.map((component) => ({
|
|
746
|
+
exportName: component.exportName,
|
|
747
|
+
specifier: aliasImport(cwd, componentsDir, component.name, alias),
|
|
748
|
+
example: exampleFor(component),
|
|
749
|
+
}))
|
|
577
750
|
const label = relative(cwd, target)
|
|
578
|
-
|
|
751
|
+
|
|
752
|
+
const imports = new Set()
|
|
753
|
+
for (const { exportName, specifier, example } of entries) {
|
|
754
|
+
for (const line of example.imports) imports.add(line)
|
|
755
|
+
imports.add(`import { ${[exportName, ...example.members].join(', ')} } from '${specifier}'`)
|
|
756
|
+
if (example.types.length > 0) {
|
|
757
|
+
imports.add(`import type { ${example.types.join(', ')} } from '${specifier}'`)
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const orderedImports = [...imports].sort(
|
|
762
|
+
(left, right) => Number(left.includes(`'${alias}/`)) - Number(right.includes(`'${alias}/`)),
|
|
763
|
+
)
|
|
579
764
|
|
|
580
765
|
if (created) {
|
|
581
766
|
const body = [
|
|
582
|
-
|
|
583
|
-
'',
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
767
|
+
orderedImports.join('\n'),
|
|
768
|
+
entries.map(({ example }) => example.code.trimEnd()).join('\n\n'),
|
|
769
|
+
[
|
|
770
|
+
`export default function ${entries[0].exportName}Page() {`,
|
|
771
|
+
' return (',
|
|
772
|
+
' <main className="space-y-6 p-6">',
|
|
773
|
+
...entries.map(({ example }) => ` <${example.component} />`),
|
|
774
|
+
' </main>',
|
|
775
|
+
' )',
|
|
776
|
+
'}',
|
|
777
|
+
].join('\n'),
|
|
591
778
|
'',
|
|
592
|
-
].join('\n')
|
|
779
|
+
].join('\n\n')
|
|
593
780
|
if (!dryRun) {
|
|
594
781
|
try {
|
|
595
782
|
mkdirSync(dirname(target), { recursive: true })
|
|
@@ -601,8 +788,13 @@ function writeUsage({ cwd, target, componentsDir, components, dryRun }) {
|
|
|
601
788
|
ok(`${dryRun ? '[dry-run] ' : ''}page created: ${label}`)
|
|
602
789
|
} else {
|
|
603
790
|
const source = readFileSync(target, 'utf8')
|
|
604
|
-
const withImports = addImports(source,
|
|
605
|
-
const
|
|
791
|
+
const withImports = addImports(source, [...imports])
|
|
792
|
+
const names = entries.map(({ example }) => example.component)
|
|
793
|
+
const { source: withUsage, added } = addJsxUsage(withImports, names)
|
|
794
|
+
const updated = addExamples(
|
|
795
|
+
withUsage,
|
|
796
|
+
entries.map(({ example }) => example),
|
|
797
|
+
)
|
|
606
798
|
if (!dryRun) {
|
|
607
799
|
try {
|
|
608
800
|
writeFileSync(target, updated, 'utf8')
|
|
@@ -617,7 +809,7 @@ function writeUsage({ cwd, target, componentsDir, components, dryRun }) {
|
|
|
617
809
|
}
|
|
618
810
|
}
|
|
619
811
|
|
|
620
|
-
for (const
|
|
812
|
+
for (const { example } of entries) info(`· <${example.component} />`)
|
|
621
813
|
}
|
|
622
814
|
|
|
623
815
|
/* -------------------------------------------------------------------------- */
|
|
@@ -672,20 +864,26 @@ async function chooseUsageTarget({ prompter, cwd, names, exportNames }) {
|
|
|
672
864
|
if (candidates.length > 0) info(`existing files: ${candidates.join(', ')}`)
|
|
673
865
|
|
|
674
866
|
const ready = await prompter.confirm(
|
|
675
|
-
`Do you already have a file ready to receive the component (${label})
|
|
867
|
+
`Do you already have a file ready to receive the component (${label})`,
|
|
676
868
|
false,
|
|
677
869
|
)
|
|
678
870
|
|
|
679
871
|
let answer
|
|
680
872
|
if (ready) {
|
|
681
|
-
answer = await prompter.ask(
|
|
873
|
+
answer = await prompter.ask(
|
|
874
|
+
`In which file should the component (${label}) be copied`,
|
|
875
|
+
pathFormat,
|
|
876
|
+
)
|
|
682
877
|
} else {
|
|
683
|
-
const create = await prompter.confirm(
|
|
878
|
+
const create = await prompter.confirm(
|
|
879
|
+
'Would you like to create a new folder and an import file',
|
|
880
|
+
true,
|
|
881
|
+
)
|
|
684
882
|
if (!create) {
|
|
685
883
|
info('the component is installed but not used in any file.')
|
|
686
884
|
return null
|
|
687
885
|
}
|
|
688
|
-
answer = await prompter.ask(
|
|
886
|
+
answer = await prompter.ask('Please specify the new path', pathFormat)
|
|
689
887
|
}
|
|
690
888
|
|
|
691
889
|
// “ - ”: installation only, like the “no” answer.
|
|
@@ -730,20 +928,10 @@ async function commandAdd({ positional, flags }) {
|
|
|
730
928
|
|
|
731
929
|
const prompter = createPrompter(flags)
|
|
732
930
|
try {
|
|
733
|
-
// 1. Dossier d'import des composants.
|
|
734
|
-
|
|
931
|
+
// 1. Dossier d'import des composants : toujours src/components/ui.
|
|
932
|
+
const componentsDir = normalizeComponentsDir(
|
|
735
933
|
typeof flags.dir === 'string' ? flags.dir : config.dir ?? DEFAULT_COMPONENTS_DIR,
|
|
736
934
|
)
|
|
737
|
-
if (prompter && typeof flags.dir !== 'string') {
|
|
738
|
-
// A file path was already accepted here (mkdir relative to the root).
|
|
739
|
-
for (let attempts = 0; ; attempts += 1) {
|
|
740
|
-
const fallback = attempts === 0 ? componentsDir : DEFAULT_COMPONENTS_DIR
|
|
741
|
-
const answer = await prompter.ask('In which folder should the components be copied?', fallback)
|
|
742
|
-
componentsDir = normalizeComponentsDir(answer ?? DEFAULT_COMPONENTS_DIR)
|
|
743
|
-
if (!looksLikeFile(componentsDir) || attempts >= 2) break
|
|
744
|
-
info(`"${componentsDir}" is a file, not a folder: give a folder, e.g. ${DEFAULT_COMPONENTS_DIR}.`)
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
935
|
if (looksLikeFile(componentsDir)) {
|
|
748
936
|
fail(`"${componentsDir}" is a file: give a folder (e.g. ${DEFAULT_COMPONENTS_DIR}).`)
|
|
749
937
|
}
|
|
@@ -807,7 +995,16 @@ async function commandAdd({ positional, flags }) {
|
|
|
807
995
|
exportNames: components.map((component) => component.exportName),
|
|
808
996
|
})
|
|
809
997
|
}
|
|
810
|
-
if (pageTarget)
|
|
998
|
+
if (pageTarget) {
|
|
999
|
+
writeUsage({
|
|
1000
|
+
cwd,
|
|
1001
|
+
target: pageTarget,
|
|
1002
|
+
componentsDir,
|
|
1003
|
+
components,
|
|
1004
|
+
alias: targetAlias,
|
|
1005
|
+
dryRun,
|
|
1006
|
+
})
|
|
1007
|
+
}
|
|
811
1008
|
|
|
812
1009
|
if (written.size > 0 || dryRun) {
|
|
813
1010
|
console.log('\n Detected npm dependencies:')
|
|
@@ -847,11 +1044,13 @@ Options:
|
|
|
847
1044
|
--force overwrite existing files
|
|
848
1045
|
--dry-run simulate without writing
|
|
849
1046
|
|
|
850
|
-
Without --
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1047
|
+
Without --page, the CLI asks whether a file is already ready to receive the
|
|
1048
|
+
components: they are added to that file, to a new folder + file, or installed
|
|
1049
|
+
only. Files go to src/components/ui (override with --dir) and the page imports
|
|
1050
|
+
them through the alias (@/components/ui/...). The page also gets a working
|
|
1051
|
+
example, with the data and props each component needs, so it shows immediately.
|
|
1052
|
+
Colors go through a Tailwind CSS 4 theme (uifolio-theme.css, @theme), so classes
|
|
1053
|
+
like bg-primary and text-muted-foreground stay in the code.
|
|
855
1054
|
|
|
856
1055
|
Examples:
|
|
857
1056
|
node cli/uifolio.mjs add video-player calendar otp --cwd ../my-project
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uifolio",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"description": "CLI that copies React components (calendar, video/audio players, OTP, editor, upload) and their helpers into your project, with Tailwind CSS 4 tokens.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"file-upload",
|
|
30
30
|
"rich-text-editor",
|
|
31
31
|
"command-menu",
|
|
32
|
+
"data-table",
|
|
32
33
|
"resizable-panels",
|
|
33
34
|
"time-picker",
|
|
34
35
|
"cadence",
|
|
@@ -136,7 +136,7 @@ export function AudioPlayer({
|
|
|
136
136
|
<button
|
|
137
137
|
type="button"
|
|
138
138
|
onClick={() => void togglePlay()}
|
|
139
|
-
aria-label={playing ? '
|
|
139
|
+
aria-label={playing ? 'Pause' : 'Play'}
|
|
140
140
|
className="grid size-9 shrink-0 place-items-center rounded-full bg-[var(--ap-accent)] text-black shadow-sm transition hover:brightness-110"
|
|
141
141
|
>
|
|
142
142
|
{playing ? (
|
|
@@ -157,7 +157,7 @@ export function AudioPlayer({
|
|
|
157
157
|
step={0.05}
|
|
158
158
|
value={Math.min(currentTime, duration || 0)}
|
|
159
159
|
onChange={(event) => seek(Number(event.target.value))}
|
|
160
|
-
aria-label="
|
|
160
|
+
aria-label="Playback position"
|
|
161
161
|
className="h-1 w-full min-w-0 cursor-pointer appearance-none rounded-full"
|
|
162
162
|
style={{
|
|
163
163
|
accentColor: accent,
|
|
@@ -176,7 +176,7 @@ export function AudioPlayer({
|
|
|
176
176
|
<button
|
|
177
177
|
type="button"
|
|
178
178
|
onClick={() => void togglePlay()}
|
|
179
|
-
aria-label={playing ? '
|
|
179
|
+
aria-label={playing ? 'Pause' : 'Play'}
|
|
180
180
|
className="grid size-8 shrink-0 place-items-center rounded-full bg-[var(--ap-accent)] text-black transition hover:brightness-110"
|
|
181
181
|
>
|
|
182
182
|
{playing ? (
|
|
@@ -195,7 +195,7 @@ export function AudioPlayer({
|
|
|
195
195
|
step={0.05}
|
|
196
196
|
value={Math.min(currentTime, duration || 0)}
|
|
197
197
|
onChange={(event) => seek(Number(event.target.value))}
|
|
198
|
-
aria-label="
|
|
198
|
+
aria-label="Playback position"
|
|
199
199
|
className="mt-1.5 h-1 w-full cursor-pointer appearance-none rounded-full"
|
|
200
200
|
style={{
|
|
201
201
|
accentColor: accent,
|
|
@@ -214,7 +214,7 @@ export function AudioPlayer({
|
|
|
214
214
|
<button
|
|
215
215
|
type="button"
|
|
216
216
|
onClick={() => setMuted((value) => !value)}
|
|
217
|
-
aria-label={muted ? '
|
|
217
|
+
aria-label={muted ? 'Unmute' : 'Mute'}
|
|
218
218
|
className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
|
219
219
|
>
|
|
220
220
|
{effectiveVolume === 0 ? (
|
|
@@ -246,12 +246,12 @@ export function AudioPlayer({
|
|
|
246
246
|
<button
|
|
247
247
|
type="button"
|
|
248
248
|
onClick={cycleRate}
|
|
249
|
-
aria-label={`
|
|
249
|
+
aria-label={`Speed ${rate}×`}
|
|
250
250
|
className="ml-auto rounded-md px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
|
251
251
|
>
|
|
252
252
|
{rate}×
|
|
253
253
|
</button>
|
|
254
|
-
{error && <span className="text-[10px] text-rose-400">Source
|
|
254
|
+
{error && <span className="text-[10px] text-rose-400">Source unavailable</span>}
|
|
255
255
|
</div>
|
|
256
256
|
</div>
|
|
257
257
|
)
|
|
@@ -313,7 +313,7 @@ export function ColorPicker({
|
|
|
313
313
|
|
|
314
314
|
<div className="relative min-w-0 flex-1">
|
|
315
315
|
<span className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 font-mono text-[9px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
316
|
-
{
|
|
316
|
+
{label ?? 'Color'}
|
|
317
317
|
</span>
|
|
318
318
|
<input
|
|
319
319
|
id={inputId}
|
|
@@ -382,7 +382,7 @@ export function ColorPicker({
|
|
|
382
382
|
maxHeight: 'min(620px, calc(100dvh - 20px))',
|
|
383
383
|
visibility: position ? 'visible' : 'hidden',
|
|
384
384
|
}}
|
|
385
|
-
className="z-
|
|
385
|
+
className="z-100 overflow-y-auto rounded-md border bg-card text-card-foreground shadow-2xl outline-none"
|
|
386
386
|
>
|
|
387
387
|
<div className="flex items-start justify-between gap-3 border-b px-3.5 py-3">
|
|
388
388
|
<div className="min-w-0">
|
|
@@ -444,7 +444,7 @@ export function ColorPicker({
|
|
|
444
444
|
|
|
445
445
|
<div className="space-y-2">
|
|
446
446
|
<div className="flex items-center justify-between text-[9px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
|
447
|
-
<span>
|
|
447
|
+
<span>Hue</span>
|
|
448
448
|
<span className="font-mono">{Math.round(hue)}°</span>
|
|
449
449
|
</div>
|
|
450
450
|
<input
|
|
@@ -458,7 +458,7 @@ export function ColorPicker({
|
|
|
458
458
|
setDraft(null)
|
|
459
459
|
emitHsv({ ...hsva, h: Number(event.target.value) })
|
|
460
460
|
}}
|
|
461
|
-
aria-label="
|
|
461
|
+
aria-label="Hue"
|
|
462
462
|
className="h-3 w-full cursor-pointer appearance-none rounded-full outline-none transition focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-white [&::-moz-range-thumb]:bg-transparent [&::-moz-range-thumb]:shadow-md [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white [&::-webkit-slider-thumb]:bg-transparent [&::-webkit-slider-thumb]:shadow-md"
|
|
463
463
|
style={{
|
|
464
464
|
background:
|
|
@@ -484,7 +484,7 @@ export function ColorPicker({
|
|
|
484
484
|
setDraft(null)
|
|
485
485
|
emit({ ...current, a: Number(event.target.value) / 100 })
|
|
486
486
|
}}
|
|
487
|
-
aria-label="
|
|
487
|
+
aria-label="Transparency"
|
|
488
488
|
className="h-3 w-full cursor-pointer appearance-none rounded-full border border-border outline-none transition focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-white [&::-moz-range-thumb]:bg-transparent [&::-moz-range-thumb]:shadow-md [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white [&::-webkit-slider-thumb]:bg-transparent [&::-webkit-slider-thumb]:shadow-md"
|
|
489
489
|
style={{
|
|
490
490
|
backgroundColor: 'transparent',
|
|
@@ -589,7 +589,7 @@ export function ColorPicker({
|
|
|
589
589
|
emit(rgba)
|
|
590
590
|
}}
|
|
591
591
|
title={raw}
|
|
592
|
-
aria-label={`
|
|
592
|
+
aria-label={`Color ${raw}`}
|
|
593
593
|
aria-pressed={selected}
|
|
594
594
|
className={cn(
|
|
595
595
|
'relative grid size-5 place-items-center overflow-hidden rounded-sm border transition hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed',
|
|
@@ -94,7 +94,7 @@ export function CommandMenu({
|
|
|
94
94
|
|
|
95
95
|
return (
|
|
96
96
|
<div
|
|
97
|
-
className="fixed inset-0 z-
|
|
97
|
+
className="fixed inset-0 z-70 flex items-start justify-center bg-black/65 px-4 pt-[12vh] backdrop-blur-sm"
|
|
98
98
|
onPointerDown={(event) => {
|
|
99
99
|
if (event.target === event.currentTarget) setOpen(false)
|
|
100
100
|
}}
|
|
@@ -102,7 +102,7 @@ export function CommandMenu({
|
|
|
102
102
|
<div
|
|
103
103
|
role="dialog"
|
|
104
104
|
aria-modal="true"
|
|
105
|
-
aria-label="
|
|
105
|
+
aria-label="Command palette"
|
|
106
106
|
className={cn(
|
|
107
107
|
'w-full max-w-lg overflow-hidden rounded-sm border bg-card shadow-2xl shadow-black/50',
|
|
108
108
|
className,
|
|
@@ -211,8 +211,8 @@ export function CommandMenu({
|
|
|
211
211
|
|
|
212
212
|
<div className="flex items-center justify-between border-t px-3.5 py-2 text-[10px] text-muted-foreground">
|
|
213
213
|
<span className="flex items-center gap-3">
|
|
214
|
-
<span>↑↓
|
|
215
|
-
<span>↵
|
|
214
|
+
<span>↑↓ to navigate</span>
|
|
215
|
+
<span>↵ to open</span>
|
|
216
216
|
</span>
|
|
217
217
|
<span className="font-mono">ctrl / ⌘ + k</span>
|
|
218
218
|
</div>
|
|
@@ -243,7 +243,7 @@ export function FileUpload({
|
|
|
243
243
|
<button
|
|
244
244
|
type="button"
|
|
245
245
|
onClick={() => setRejection(null)}
|
|
246
|
-
aria-label="
|
|
246
|
+
aria-label="Hide error"
|
|
247
247
|
className="text-rose-300/70 transition hover:text-rose-200"
|
|
248
248
|
>
|
|
249
249
|
<X className="size-3.5" />
|
|
@@ -322,7 +322,7 @@ export function FileUpload({
|
|
|
322
322
|
<button
|
|
323
323
|
type="button"
|
|
324
324
|
onClick={() => remove(item.id)}
|
|
325
|
-
aria-label={`
|
|
325
|
+
aria-label={`Remove ${item.file.name}`}
|
|
326
326
|
className="grid size-7 place-items-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
|
327
327
|
>
|
|
328
328
|
<X className="size-3.5" />
|
|
@@ -245,7 +245,7 @@ export function IconPicker({
|
|
|
245
245
|
|
|
246
246
|
return (
|
|
247
247
|
<div className={cn('relative w-full max-w-md', className)}>
|
|
248
|
-
{/*
|
|
248
|
+
{/* Main button */}
|
|
249
249
|
<button
|
|
250
250
|
type="button"
|
|
251
251
|
onClick={() => setOpen((v) => !v)}
|
|
@@ -287,7 +287,7 @@ export function IconPicker({
|
|
|
287
287
|
</button>
|
|
288
288
|
</div>
|
|
289
289
|
|
|
290
|
-
{/*
|
|
290
|
+
{/* Search bar */}
|
|
291
291
|
<div className="border-b border-border p-3">
|
|
292
292
|
<div className="relative">
|
|
293
293
|
<Search
|
|
@@ -298,7 +298,7 @@ export function IconPicker({
|
|
|
298
298
|
type="text"
|
|
299
299
|
value={search}
|
|
300
300
|
onChange={(e) => setSearch(e.target.value)}
|
|
301
|
-
placeholder="
|
|
301
|
+
placeholder="Search: shirt, shopping, home..."
|
|
302
302
|
autoFocus
|
|
303
303
|
className="h-10 w-full rounded-lg border border-border bg-muted/50 pl-9 pr-3 text-sm text-foreground outline-none transition placeholder:text-muted-foreground focus:border-primary/60 focus:bg-card"
|
|
304
304
|
/>
|
|
@@ -352,7 +352,7 @@ export function IconPicker({
|
|
|
352
352
|
No icon found
|
|
353
353
|
</p>
|
|
354
354
|
<p className="mt-1 text-xs text-muted-foreground/70">
|
|
355
|
-
|
|
355
|
+
Try another term
|
|
356
356
|
</p>
|
|
357
357
|
</div>
|
|
358
358
|
)}
|
|
@@ -38,7 +38,7 @@ export function RichTextEditor({
|
|
|
38
38
|
value,
|
|
39
39
|
defaultValue = '',
|
|
40
40
|
onChange,
|
|
41
|
-
placeholder = '
|
|
41
|
+
placeholder = 'Write something…',
|
|
42
42
|
readOnly = false,
|
|
43
43
|
toolbar = true,
|
|
44
44
|
minHeight = 220,
|
|
@@ -115,7 +115,7 @@ export function RichTextEditor({
|
|
|
115
115
|
editor.focus()
|
|
116
116
|
return
|
|
117
117
|
}
|
|
118
|
-
const input = window.prompt('
|
|
118
|
+
const input = window.prompt('Enter link URL', 'https://')
|
|
119
119
|
if (!input) return
|
|
120
120
|
const url = /^(https?:|mailto:|tel:)/i.test(input) ? input : `https://${input}`
|
|
121
121
|
exec('createLink', url)
|
|
@@ -136,10 +136,10 @@ export function RichTextEditor({
|
|
|
136
136
|
>
|
|
137
137
|
{toolbar && (
|
|
138
138
|
<div className="flex flex-wrap items-center gap-0.5 border-b bg-muted/30 p-1.5">
|
|
139
|
-
<ToolButton label="
|
|
139
|
+
<ToolButton label="Bold" active={active.bold} onClick={() => exec('bold')}>
|
|
140
140
|
<Bold className="size-3.5" />
|
|
141
141
|
</ToolButton>
|
|
142
|
-
<ToolButton label="
|
|
142
|
+
<ToolButton label="Italic" active={active.italic} onClick={() => exec('italic')}>
|
|
143
143
|
<Italic className="size-3.5" />
|
|
144
144
|
</ToolButton>
|
|
145
145
|
<ToolButton
|
|
@@ -159,17 +159,17 @@ export function RichTextEditor({
|
|
|
159
159
|
|
|
160
160
|
<Divider />
|
|
161
161
|
|
|
162
|
-
<ToolButton label="
|
|
162
|
+
<ToolButton label="Heading 2" active={active.h2} onClick={() => toggleBlock('h2')}>
|
|
163
163
|
<Heading2 className="size-3.5" />
|
|
164
164
|
</ToolButton>
|
|
165
|
-
<ToolButton label="
|
|
165
|
+
<ToolButton label="Heading 3" active={active.h3} onClick={() => toggleBlock('h3')}>
|
|
166
166
|
<Heading3 className="size-3.5" />
|
|
167
167
|
</ToolButton>
|
|
168
|
-
<ToolButton label="
|
|
168
|
+
<ToolButton label="Paragraph" onClick={() => toggleBlock('p')}>
|
|
169
169
|
<Pilcrow className="size-3.5" />
|
|
170
170
|
</ToolButton>
|
|
171
171
|
<ToolButton
|
|
172
|
-
label="
|
|
172
|
+
label="Quote"
|
|
173
173
|
active={active.blockquote}
|
|
174
174
|
onClick={() => toggleBlock('blockquote')}
|
|
175
175
|
>
|
|
@@ -195,24 +195,23 @@ export function RichTextEditor({
|
|
|
195
195
|
>
|
|
196
196
|
<ListOrdered className="size-3.5" />
|
|
197
197
|
</ToolButton>
|
|
198
|
-
<ToolButton label="
|
|
198
|
+
<ToolButton label="Link" onClick={addLink}>
|
|
199
199
|
<Link2 className="size-3.5" />
|
|
200
200
|
</ToolButton>
|
|
201
201
|
|
|
202
202
|
<Divider />
|
|
203
203
|
|
|
204
|
-
<ToolButton label="
|
|
204
|
+
<ToolButton label="Undo" onClick={() => exec('undo')}>
|
|
205
205
|
<Undo2 className="size-3.5" />
|
|
206
206
|
</ToolButton>
|
|
207
207
|
<ToolButton label="Redo" onClick={() => exec('redo')}>
|
|
208
208
|
<Redo2 className="size-3.5" />
|
|
209
209
|
</ToolButton>
|
|
210
|
-
<ToolButton label="
|
|
210
|
+
<ToolButton label="Remove formatting" onClick={() => exec('removeFormat')}>
|
|
211
211
|
<RemoveFormatting className="size-3.5" />
|
|
212
212
|
</ToolButton>
|
|
213
213
|
|
|
214
|
-
<span className="ml-auto px-1.5 text-[10px] tabular-nums text-muted-foreground">
|
|
215
|
-
{characters} car.
|
|
214
|
+
<span className="ml-auto px-1.5 text-[10px] tabular-nums text-muted-foreground">{characters} chars
|
|
216
215
|
</span>
|
|
217
216
|
</div>
|
|
218
217
|
)}
|
|
@@ -166,7 +166,7 @@ export function Stories({
|
|
|
166
166
|
aria-modal="true"
|
|
167
167
|
aria-label="Stories"
|
|
168
168
|
className={cn(
|
|
169
|
-
'fixed inset-0 z-
|
|
169
|
+
'fixed inset-0 z-80 flex items-center justify-center bg-black/95 p-0 backdrop-blur-sm sm:p-6',
|
|
170
170
|
className,
|
|
171
171
|
)}
|
|
172
172
|
>
|
|
@@ -210,7 +210,7 @@ export function Stories({
|
|
|
210
210
|
type="button"
|
|
211
211
|
onClick={close}
|
|
212
212
|
onPointerDown={(event) => event.stopPropagation()}
|
|
213
|
-
aria-label="
|
|
213
|
+
aria-label="Close stories"
|
|
214
214
|
className="grid size-8 place-items-center rounded-full bg-black/40 text-white/80 ring-1 ring-white/15 transition hover:bg-black/60 hover:text-white"
|
|
215
215
|
>
|
|
216
216
|
<X className="size-4" />
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
import { cn } from '@/lib/utils'
|
|
5
|
+
import { useControllableValue } from '@/lib/use-controllable'
|
|
6
|
+
|
|
7
|
+
export interface TableColumn<T> {
|
|
8
|
+
/** Stable identifier, also used as the React key of the column. */
|
|
9
|
+
key: string
|
|
10
|
+
header: ReactNode
|
|
11
|
+
cell: (row: T) => ReactNode
|
|
12
|
+
/** Applied to the header and body cells: width, nowrap… */
|
|
13
|
+
className?: string
|
|
14
|
+
align?: 'left' | 'center' | 'right'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TableProps<T> {
|
|
18
|
+
columns: ReadonlyArray<TableColumn<T>>
|
|
19
|
+
rows: ReadonlyArray<T>
|
|
20
|
+
rowKey: (row: T) => string | number
|
|
21
|
+
selectable?: boolean
|
|
22
|
+
selectedKeys?: string[]
|
|
23
|
+
defaultSelectedKeys?: string[]
|
|
24
|
+
onSelectionChange?: (keys: string[]) => void
|
|
25
|
+
actions?: (row: T) => ReactNode
|
|
26
|
+
actionsHeader?: ReactNode
|
|
27
|
+
onRowClick?: (row: T) => void
|
|
28
|
+
emptyText?: string
|
|
29
|
+
className?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function alignClass(align: TableColumn<unknown>['align']): string {
|
|
33
|
+
if (align === 'center') return 'text-center'
|
|
34
|
+
if (align === 'right') return 'text-right'
|
|
35
|
+
return 'text-left'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Data table with a shadcn-like API: typed columns, optional selection and a
|
|
40
|
+
* dedicated actions column. Every cell on the left shares the same `px-4`
|
|
41
|
+
* padding; the actions column keeps a wider `pl-10` gap with the last column.
|
|
42
|
+
*/
|
|
43
|
+
export function Table<T>({
|
|
44
|
+
columns,
|
|
45
|
+
rows,
|
|
46
|
+
rowKey,
|
|
47
|
+
selectable = false,
|
|
48
|
+
selectedKeys,
|
|
49
|
+
defaultSelectedKeys = [],
|
|
50
|
+
onSelectionChange,
|
|
51
|
+
actions,
|
|
52
|
+
actionsHeader = 'Actions',
|
|
53
|
+
onRowClick,
|
|
54
|
+
emptyText = 'No data.',
|
|
55
|
+
className,
|
|
56
|
+
}: Readonly<TableProps<T>>) {
|
|
57
|
+
const [selection, setSelection] = useControllableValue<string[]>(
|
|
58
|
+
selectedKeys,
|
|
59
|
+
defaultSelectedKeys,
|
|
60
|
+
onSelectionChange,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const toggle = (key: string, checked: boolean) => {
|
|
64
|
+
setSelection((current) =>
|
|
65
|
+
checked
|
|
66
|
+
? current.includes(key)
|
|
67
|
+
? current
|
|
68
|
+
: [...current, key]
|
|
69
|
+
: current.filter((entry) => entry !== key),
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const columnsCount = columns.length + (actions ? 1 : 0)
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className={cn('w-full overflow-x-auto rounded-sm border bg-card', className)}>
|
|
77
|
+
<table className="w-full min-w-[860px] border-collapse text-left text-xs">
|
|
78
|
+
<thead>
|
|
79
|
+
<tr className="border-b bg-muted/50">
|
|
80
|
+
{columns.map((column) => (
|
|
81
|
+
<th
|
|
82
|
+
key={column.key}
|
|
83
|
+
scope="col"
|
|
84
|
+
className={cn(
|
|
85
|
+
'whitespace-nowrap px-4 py-3.5 text-[11px] font-medium text-muted-foreground',
|
|
86
|
+
alignClass(column.align),
|
|
87
|
+
column.className,
|
|
88
|
+
)}
|
|
89
|
+
>
|
|
90
|
+
{column.header}
|
|
91
|
+
</th>
|
|
92
|
+
))}
|
|
93
|
+
{actions && (
|
|
94
|
+
<th
|
|
95
|
+
scope="col"
|
|
96
|
+
className="whitespace-nowrap py-3.5 pl-10 pr-4 text-right text-[11px] font-medium text-muted-foreground"
|
|
97
|
+
>
|
|
98
|
+
{actionsHeader}
|
|
99
|
+
</th>
|
|
100
|
+
)}
|
|
101
|
+
</tr>
|
|
102
|
+
</thead>
|
|
103
|
+
<tbody>
|
|
104
|
+
{rows.length === 0 ? (
|
|
105
|
+
<tr>
|
|
106
|
+
<td colSpan={columnsCount} className="px-4 py-10 text-center text-muted-foreground">
|
|
107
|
+
{emptyText}
|
|
108
|
+
</td>
|
|
109
|
+
</tr>
|
|
110
|
+
) : (
|
|
111
|
+
rows.map((row) => {
|
|
112
|
+
const key = String(rowKey(row))
|
|
113
|
+
const selected = selection.includes(key)
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<tr
|
|
117
|
+
key={key}
|
|
118
|
+
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
|
119
|
+
className={cn(
|
|
120
|
+
'border-b transition last:border-b-0',
|
|
121
|
+
onRowClick && 'cursor-pointer',
|
|
122
|
+
selected ? 'bg-primary/5' : 'hover:bg-muted/30',
|
|
123
|
+
)}
|
|
124
|
+
>
|
|
125
|
+
{columns.map((column, index) => (
|
|
126
|
+
<td
|
|
127
|
+
key={column.key}
|
|
128
|
+
className={cn(
|
|
129
|
+
'px-4 py-3.5 align-middle',
|
|
130
|
+
alignClass(column.align),
|
|
131
|
+
column.className,
|
|
132
|
+
)}
|
|
133
|
+
>
|
|
134
|
+
{selectable && index === 0 ? (
|
|
135
|
+
<div className="flex items-center gap-3">
|
|
136
|
+
<input
|
|
137
|
+
type="checkbox"
|
|
138
|
+
checked={selected}
|
|
139
|
+
onChange={(event) => toggle(key, event.target.checked)}
|
|
140
|
+
onClick={(event) => event.stopPropagation()}
|
|
141
|
+
aria-label={`Select row ${key}`}
|
|
142
|
+
className="size-4 shrink-0 cursor-pointer rounded-sm border-border accent-primary"
|
|
143
|
+
/>
|
|
144
|
+
{column.cell(row)}
|
|
145
|
+
</div>
|
|
146
|
+
) : (
|
|
147
|
+
column.cell(row)
|
|
148
|
+
)}
|
|
149
|
+
</td>
|
|
150
|
+
))}
|
|
151
|
+
{actions && (
|
|
152
|
+
<td className="whitespace-nowrap py-3.5 pl-10 pr-4">
|
|
153
|
+
<div className="flex items-center justify-end gap-1.5">{actions(row)}</div>
|
|
154
|
+
</td>
|
|
155
|
+
)}
|
|
156
|
+
</tr>
|
|
157
|
+
)
|
|
158
|
+
})
|
|
159
|
+
)}
|
|
160
|
+
</tbody>
|
|
161
|
+
</table>
|
|
162
|
+
</div>
|
|
163
|
+
)
|
|
164
|
+
}
|