@etamong-playground/ui 0.34.2
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/LICENSE +21 -0
- package/README.md +1302 -0
- package/dist/helpers.cjs +137 -0
- package/dist/helpers.d.cts +96 -0
- package/dist/helpers.d.ts +96 -0
- package/dist/helpers.js +118 -0
- package/dist/index.cjs +3684 -0
- package/dist/index.d.cts +1800 -0
- package/dist/index.d.ts +1800 -0
- package/dist/index.js +3585 -0
- package/dist/styles.css +2312 -0
- package/dist/testing.cjs +185 -0
- package/dist/testing.d.cts +166 -0
- package/dist/testing.d.ts +166 -0
- package/dist/testing.js +173 -0
- package/package.json +75 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,1800 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode, AnchorHTMLAttributes, MouseEvent } from 'react';
|
|
3
|
+
|
|
4
|
+
/** A single command-palette entry. */
|
|
5
|
+
interface CommandItem {
|
|
6
|
+
/** Stable unique id (used as the React key). */
|
|
7
|
+
id: string;
|
|
8
|
+
/** Visible label in the active locale. */
|
|
9
|
+
label: string;
|
|
10
|
+
/** Right-aligned secondary text (e.g. a parent group name). */
|
|
11
|
+
sublabel?: string;
|
|
12
|
+
/**
|
|
13
|
+
* The string cmdk filters on. To make search work across languages, build
|
|
14
|
+
* this with `crossLocaleKeywords` so it contains the label in every locale.
|
|
15
|
+
* Defaults to `label` when omitted.
|
|
16
|
+
*/
|
|
17
|
+
keywords?: string;
|
|
18
|
+
/** Leading icon — any node (lucide, inline SVG). Icon library stays the app's choice. */
|
|
19
|
+
icon?: ReactNode;
|
|
20
|
+
/** Navigation target; selecting calls `onNavigate(href)`. */
|
|
21
|
+
href?: string;
|
|
22
|
+
/** Action callback; selecting invokes it. Takes precedence over `href`. */
|
|
23
|
+
onSelect?: () => void;
|
|
24
|
+
/** Hidden unless the palette is rendered with `isAdmin`. */
|
|
25
|
+
adminOnly?: boolean;
|
|
26
|
+
}
|
|
27
|
+
/** An ordered, headed group of items. */
|
|
28
|
+
interface CommandSection {
|
|
29
|
+
id: string;
|
|
30
|
+
heading: string;
|
|
31
|
+
items: CommandItem[];
|
|
32
|
+
/** Keep the group mounted even with no text match (e.g. a "search for …" row). */
|
|
33
|
+
forceMount?: boolean;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A bottom, always-mounted "search for …" action that receives the live query.
|
|
37
|
+
* Selecting one typically pushes to a search/list route carrying the text, so an
|
|
38
|
+
* unmatched query still becomes a real search.
|
|
39
|
+
*/
|
|
40
|
+
interface CommandSearchAction {
|
|
41
|
+
id: string;
|
|
42
|
+
label: string;
|
|
43
|
+
keywords?: string;
|
|
44
|
+
icon?: ReactNode;
|
|
45
|
+
/** Called with the current input text on select. */
|
|
46
|
+
run: (query: string) => void;
|
|
47
|
+
}
|
|
48
|
+
/** Overridable UI strings (defaults are English). */
|
|
49
|
+
interface CommandPaletteLabels {
|
|
50
|
+
placeholder: string;
|
|
51
|
+
noResults: string;
|
|
52
|
+
/** Heading for the always-mounted search-actions group. */
|
|
53
|
+
searchHeading: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface CommandPaletteProps {
|
|
57
|
+
/** Ordered groups rendered top-to-bottom. */
|
|
58
|
+
sections: CommandSection[];
|
|
59
|
+
/** Always-mounted "search for …" actions, rendered last; receive the live query. */
|
|
60
|
+
searchActions?: CommandSearchAction[];
|
|
61
|
+
/** Called with an item's `href` on select (e.g. `router.push`). */
|
|
62
|
+
onNavigate?: (href: string) => void;
|
|
63
|
+
/** When false, items marked `adminOnly` are filtered out. Default false. */
|
|
64
|
+
isAdmin?: boolean;
|
|
65
|
+
/** Override the input placeholder / empty-state text. */
|
|
66
|
+
labels?: Partial<CommandPaletteLabels>;
|
|
67
|
+
/** Also open on "/" (ignored inside inputs). Default true. */
|
|
68
|
+
openOnSlash?: boolean;
|
|
69
|
+
/** Controlled open state. Omit to let the palette own it (⌘K / "/" / event). */
|
|
70
|
+
open?: boolean;
|
|
71
|
+
onOpenChange?: (open: boolean) => void;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* A configurable ⌘K command palette built on cmdk and styled from the
|
|
75
|
+
* @etamong-playground/ui tokens (import "@etamong-playground/ui/styles.css"). Mount it once,
|
|
76
|
+
* globally, when the user is authenticated.
|
|
77
|
+
*
|
|
78
|
+
* Opens on ⌘K / Ctrl+K, on "/" (unless typing), and on the
|
|
79
|
+
* `command-palette:open` DOM event. Search filters on each item's `keywords`
|
|
80
|
+
* (build them with `crossLocaleKeywords` for cross-language matching).
|
|
81
|
+
*/
|
|
82
|
+
declare function CommandPalette({ sections, searchActions, onNavigate, isAdmin, labels, openOnSlash, open: controlledOpen, onOpenChange, }: CommandPaletteProps): react.JSX.Element;
|
|
83
|
+
|
|
84
|
+
interface CommandPaletteTriggerProps {
|
|
85
|
+
/** Visible placeholder-style label, e.g. "Search…". */
|
|
86
|
+
label?: string;
|
|
87
|
+
/** Extra class merged with `etu-palette-trigger`. */
|
|
88
|
+
className?: string;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* A search-box-styled button that opens the command palette — so users discover
|
|
92
|
+
* it exists. Shows a magnifier + label + the ⌘K / Ctrl+K hint and dispatches the
|
|
93
|
+
* `command-palette:open` event. Styled from @etamong-playground/ui tokens; drop it in a
|
|
94
|
+
* sidebar/header.
|
|
95
|
+
*/
|
|
96
|
+
declare function CommandPaletteTrigger({ label, className }: CommandPaletteTriggerProps): react.JSX.Element;
|
|
97
|
+
|
|
98
|
+
interface GoToRoute {
|
|
99
|
+
/** Second key after the "g" prefix (e.g. "s" for `g s`). */
|
|
100
|
+
key: string;
|
|
101
|
+
/** Destination passed to `onNavigate`. */
|
|
102
|
+
href: string;
|
|
103
|
+
/** Skipped unless `isAdmin`. */
|
|
104
|
+
adminOnly?: boolean;
|
|
105
|
+
}
|
|
106
|
+
interface GoToOptions {
|
|
107
|
+
isAdmin?: boolean;
|
|
108
|
+
/** How long the "g" prefix stays armed, ms. Default 1500. */
|
|
109
|
+
timeoutMs?: number;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Two-key "go-to" navigation: press `g`, then a letter within the timeout, to
|
|
113
|
+
* jump. Korean-IME-safe (resolves the logical key via `shortcutKey`/`e.code`),
|
|
114
|
+
* ignores text-entry targets, modifier combos, and key repeats.
|
|
115
|
+
*
|
|
116
|
+
* Returns the armed prefix ("g") or null — render it as a small indicator.
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* const pending = useGoToShortcuts(
|
|
120
|
+
* [{ key: "h", href: "/" }, { key: "s", href: "/schedules" }],
|
|
121
|
+
* (href) => router.push(href),
|
|
122
|
+
* { isAdmin },
|
|
123
|
+
* );
|
|
124
|
+
*/
|
|
125
|
+
declare function useGoToShortcuts(routes: GoToRoute[], onNavigate: (href: string) => void, options?: GoToOptions): string | null;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Concatenate one label across every locale dictionary so cmdk search matches
|
|
129
|
+
* regardless of the active language — a Korean user typing an English term (or
|
|
130
|
+
* vice-versa) still finds the item. This is mandatory in a ko-first ecosystem.
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* import ko from "./locales/ko";
|
|
134
|
+
* import en from "./locales/en";
|
|
135
|
+
* const dicts = [ko, en];
|
|
136
|
+
* crossLocaleKeywords(dicts, (d) => d.nav.schedules) // "일정 Schedules"
|
|
137
|
+
*/
|
|
138
|
+
declare function crossLocaleKeywords<D>(dicts: readonly D[], getter: (dict: D) => string): string;
|
|
139
|
+
/** True when the keyboard event originates from a text-entry control. */
|
|
140
|
+
declare function isInputTarget(e: KeyboardEvent): boolean;
|
|
141
|
+
/**
|
|
142
|
+
* Map physical key codes to logical keys so shortcuts survive a Korean IME:
|
|
143
|
+
* when an IME is active `e.key` is a Hangul syllable, not the Latin letter, so
|
|
144
|
+
* single-letter shortcuts must fall back to `e.code`.
|
|
145
|
+
*/
|
|
146
|
+
declare const CODE_TO_KEY: Readonly<Record<string, string>>;
|
|
147
|
+
/**
|
|
148
|
+
* Resolve the logical shortcut key for an event. Prefers `e.key` when it is
|
|
149
|
+
* already an ASCII shortcut (`/`, `?`, or a–z); otherwise falls back to the
|
|
150
|
+
* physical-code map (covers IME output).
|
|
151
|
+
*/
|
|
152
|
+
declare function shortcutKey(e: KeyboardEvent): string;
|
|
153
|
+
/** The custom DOM event any UI affordance can dispatch to open the palette. */
|
|
154
|
+
declare const COMMAND_PALETTE_OPEN_EVENT = "command-palette:open";
|
|
155
|
+
/** Dispatch `command-palette:open` so a button/menu can open the palette. */
|
|
156
|
+
declare function openCommandPalette(): void;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Theme helpers for the `[data-theme]` mechanism that styles.css implements.
|
|
160
|
+
*
|
|
161
|
+
* `data-theme` must be set on <html> BEFORE first paint, or the page flashes
|
|
162
|
+
* the default theme before flipping to the user's choice. Inject
|
|
163
|
+
* `noFlashThemeScript` synchronously in <head> — for Next, in a <script
|
|
164
|
+
* dangerouslySetInnerHTML>; for a Vite app, inline in index.html.
|
|
165
|
+
*
|
|
166
|
+
* Resolution order (matches the fleet rule in
|
|
167
|
+
* planning/wiki/concepts/theme-system-dark-fallback.md):
|
|
168
|
+
*
|
|
169
|
+
* 1. saved user choice in localStorage
|
|
170
|
+
* 2. OS preference via `prefers-color-scheme`
|
|
171
|
+
* 3. dark — the fleet-wide fallback when we can't tell
|
|
172
|
+
*
|
|
173
|
+
* Prior to v0.28 the fallback was "light"; the fleet now treats dark as
|
|
174
|
+
* the audience-of-record default (most surfaces are dark; designs assume
|
|
175
|
+
* dark first).
|
|
176
|
+
*/
|
|
177
|
+
type Theme = "light" | "dark";
|
|
178
|
+
/**
|
|
179
|
+
* A self-contained <head> snippet (no deps) that sets `data-theme` from the
|
|
180
|
+
* saved choice, falling back to the OS preference, then dark. Pass the same
|
|
181
|
+
* `appKey` you pass to `getTheme`/`setTheme`.
|
|
182
|
+
*/
|
|
183
|
+
declare function noFlashThemeScript(appKey: string): string;
|
|
184
|
+
/** Read the active theme (saved choice → OS preference → dark). */
|
|
185
|
+
declare function getTheme(appKey: string): Theme;
|
|
186
|
+
/** Persist and apply a theme to <html>. */
|
|
187
|
+
declare function setTheme(appKey: string, theme: Theme): void;
|
|
188
|
+
|
|
189
|
+
type ToastKind = "ok" | "err" | "info";
|
|
190
|
+
interface ToastItem {
|
|
191
|
+
id: number;
|
|
192
|
+
message: ReactNode;
|
|
193
|
+
kind: ToastKind;
|
|
194
|
+
}
|
|
195
|
+
/** Show a transient toast. Returns the id (so callers can dismiss early). */
|
|
196
|
+
declare function toast(message: ReactNode, kind?: ToastKind, durationMs?: number): number;
|
|
197
|
+
/** Dismiss a toast by id. */
|
|
198
|
+
declare function dismissToast(id: number): void;
|
|
199
|
+
/**
|
|
200
|
+
* Mount once at the app root (alongside the router/shell). Renders the toast
|
|
201
|
+
* queue bottom-center, styled from the @etamong-playground/ui tokens
|
|
202
|
+
* (import "@etamong-playground/ui/styles.css").
|
|
203
|
+
*/
|
|
204
|
+
declare function Toaster(): react.JSX.Element | null;
|
|
205
|
+
|
|
206
|
+
interface BaseReq {
|
|
207
|
+
title?: ReactNode;
|
|
208
|
+
body?: ReactNode;
|
|
209
|
+
confirmLabel?: string;
|
|
210
|
+
cancelLabel?: string;
|
|
211
|
+
}
|
|
212
|
+
interface ConfirmReq extends BaseReq {
|
|
213
|
+
kind: "confirm";
|
|
214
|
+
danger?: boolean;
|
|
215
|
+
resolve: (ok: boolean) => void;
|
|
216
|
+
}
|
|
217
|
+
interface PromptReq extends BaseReq {
|
|
218
|
+
kind: "prompt";
|
|
219
|
+
placeholder?: string;
|
|
220
|
+
defaultValue?: string;
|
|
221
|
+
resolve: (value: string | null) => void;
|
|
222
|
+
}
|
|
223
|
+
/** Promise-based confirm — replaces window.confirm(). Resolves true/false. */
|
|
224
|
+
declare function uiConfirm(opts: Omit<ConfirmReq, "kind" | "resolve">): Promise<boolean>;
|
|
225
|
+
/** Promise-based prompt — replaces window.prompt(). Resolves the text or null. */
|
|
226
|
+
declare function uiPrompt(opts: Omit<PromptReq, "kind" | "resolve">): Promise<string | null>;
|
|
227
|
+
/**
|
|
228
|
+
* Mount once at the app root. Renders the pending uiConfirm/uiPrompt dialog with
|
|
229
|
+
* Escape (cancel), Enter (confirm), and backdrop-click (cancel) handling. Styled
|
|
230
|
+
* from the @etamong-playground/ui tokens (import "@etamong-playground/ui/styles.css").
|
|
231
|
+
*/
|
|
232
|
+
declare function DialogHost(): react.JSX.Element | null;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Build-version + deploy-time badge. Apps bake the deployed commit SHA and the
|
|
236
|
+
* build timestamp into their frontend at build time (CI build-arg →
|
|
237
|
+
* `VITE_BUILD_SHA`/`NEXT_PUBLIC_BUILD_SHA` etc.) and render this in a
|
|
238
|
+
* backoffice/console footer so operators can see *what* is live and *when* it
|
|
239
|
+
* shipped. Styled from the @etamong-playground/ui tokens.
|
|
240
|
+
*/
|
|
241
|
+
interface DeployInfoProps {
|
|
242
|
+
/** Deployed commit SHA (full or short). Displayed shortened to 7 chars. */
|
|
243
|
+
version?: string;
|
|
244
|
+
/** ISO-8601 build/deploy timestamp. Rendered relative; absolute in the tooltip. */
|
|
245
|
+
builtAt?: string;
|
|
246
|
+
/** Leading label. Default "deployed". */
|
|
247
|
+
label?: string;
|
|
248
|
+
/** If set, the version renders as a link (e.g. the commit URL). */
|
|
249
|
+
href?: string;
|
|
250
|
+
/** Extra class merged with `etu-deploy-info`. */
|
|
251
|
+
className?: string;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Renders e.g. `deployed a1b2c3d · 2 days ago`. Returns null when neither a
|
|
255
|
+
* version nor a timestamp is available (e.g. a local dev build), so callers can
|
|
256
|
+
* mount it unconditionally.
|
|
257
|
+
*/
|
|
258
|
+
declare function DeployInfo({ version, builtAt, label, href, className }: DeployInfoProps): react.JSX.Element | null;
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* PWA install affordance — a small dismissable mobile banner that shows the
|
|
262
|
+
* right thing per platform:
|
|
263
|
+
*
|
|
264
|
+
* - Chrome / Android: captures `beforeinstallprompt`, shows an "Install"
|
|
265
|
+
* button that fires the native prompt.
|
|
266
|
+
* - iOS Safari: no programmatic install — show a short "Share → Add to Home
|
|
267
|
+
* Screen" hint instead.
|
|
268
|
+
* - Anywhere already installed (`display-mode: standalone`): renders nothing.
|
|
269
|
+
*
|
|
270
|
+
* The banner is mobile-only by default (hides on `min-width: 768px`); for a
|
|
271
|
+
* desktop affordance, ship a small inline button or call `useInstallPrompt`
|
|
272
|
+
* yourself.
|
|
273
|
+
*
|
|
274
|
+
* Per-app: drop `<InstallBanner />` once near the root (same boundary as
|
|
275
|
+
* `<Toaster />`). Renders nothing in unsupported environments, so it's safe to
|
|
276
|
+
* mount unconditionally.
|
|
277
|
+
*/
|
|
278
|
+
interface InstallBannerProps {
|
|
279
|
+
/** Banner body text on supported (Chrome/Android) platforms. */
|
|
280
|
+
label?: string;
|
|
281
|
+
/** Banner body text on iOS Safari (where no programmatic prompt is possible). */
|
|
282
|
+
iosHint?: string;
|
|
283
|
+
/** Install button label (Chrome/Android only). */
|
|
284
|
+
installLabel?: string;
|
|
285
|
+
/** Aria-label for the close button. */
|
|
286
|
+
dismissLabel?: string;
|
|
287
|
+
/** Custom icon node rendered at the start of the banner. */
|
|
288
|
+
icon?: React.ReactNode;
|
|
289
|
+
/** Cooldown between re-shows after a dismiss, in ms. Default 3 days. */
|
|
290
|
+
cooldownMs?: number;
|
|
291
|
+
/** Stop offering after this many dismisses. Default 3. */
|
|
292
|
+
maxDismiss?: number;
|
|
293
|
+
/** localStorage key for persistence. Pick a per-app value to avoid clashes. */
|
|
294
|
+
storageKey?: string;
|
|
295
|
+
/** Extra class merged with `etu-install-banner`. */
|
|
296
|
+
className?: string;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Lower-level hook for apps that want to render their own UI.
|
|
300
|
+
*
|
|
301
|
+
* - `canPrompt` — Chrome/Android has fired `beforeinstallprompt`; call `promptInstall()` to show it.
|
|
302
|
+
* - `isIOS` — render iOS guidance text.
|
|
303
|
+
* - `isStandalone` — the app is already installed; render nothing.
|
|
304
|
+
*/
|
|
305
|
+
declare function useInstallPrompt(): {
|
|
306
|
+
canPrompt: boolean;
|
|
307
|
+
promptInstall: () => Promise<"accepted" | "dismissed" | "unsupported">;
|
|
308
|
+
isIOS: boolean;
|
|
309
|
+
isStandalone: boolean;
|
|
310
|
+
};
|
|
311
|
+
declare function InstallBanner({ label, iosHint, installLabel, dismissLabel, icon, cooldownMs, maxDismiss, storageKey, className, }: InstallBannerProps): react.JSX.Element | null;
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Polls the per-host status feed and returns the parsed banner state.
|
|
315
|
+
* Lower-level than `<StatusBanner>` — use this when you want to render your
|
|
316
|
+
* own UI (e.g. a header pill, a notifications page entry) instead of the
|
|
317
|
+
* default banner strip.
|
|
318
|
+
*/
|
|
319
|
+
type Severity = "outage" | "degraded" | "maintenance";
|
|
320
|
+
interface StatusBannerData {
|
|
321
|
+
enabled: boolean;
|
|
322
|
+
severity: Severity | null;
|
|
323
|
+
message_ko: string;
|
|
324
|
+
message_en: string;
|
|
325
|
+
eta_iso: string | null;
|
|
326
|
+
retry_after_seconds: number | null;
|
|
327
|
+
tags: string[];
|
|
328
|
+
updated_at: string | null;
|
|
329
|
+
}
|
|
330
|
+
interface UseStatusBannerOptions {
|
|
331
|
+
/** Endpoint to poll. Default `/.well-known/maintenance.json` (same-origin). */
|
|
332
|
+
endpoint?: string;
|
|
333
|
+
/** Poll interval in ms. Default 60_000 (the endpoint sends `max-age=30`, so
|
|
334
|
+
* a 60s tick guarantees the cached layer rotates between polls). */
|
|
335
|
+
pollMs?: number;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Returns the latest parsed status, or `null` while loading / on error.
|
|
339
|
+
* Pauses polling while the document is hidden (visibilitychange) and resumes
|
|
340
|
+
* with an immediate fetch when it returns to the foreground.
|
|
341
|
+
*/
|
|
342
|
+
declare function useStatusBanner(opts?: UseStatusBannerOptions): StatusBannerData | null;
|
|
343
|
+
|
|
344
|
+
interface StatusBannerProps {
|
|
345
|
+
/** Override the polling endpoint. Default `/.well-known/maintenance.json`
|
|
346
|
+
* (same-origin). Use a cross-origin URL only for an embedded view of
|
|
347
|
+
* another fleet host's status; the worker endpoint sends `cache-control: max-age=30`. */
|
|
348
|
+
endpoint?: string;
|
|
349
|
+
/** Override the JSON poll interval (ms). Default 60_000 (the endpoint caches
|
|
350
|
+
* for 30s, so two polls/minute is the floor that's useful). */
|
|
351
|
+
pollMs?: number;
|
|
352
|
+
/** Force-pick a language; default reads `document.documentElement.lang`. */
|
|
353
|
+
lang?: "ko" | "en";
|
|
354
|
+
/** Override class on the outer banner. Merged with `etu-status-banner`. */
|
|
355
|
+
className?: string;
|
|
356
|
+
/** Render the dismiss "x" button. Dismissal lasts for the session only;
|
|
357
|
+
* a fresh banner of a different severity reappears. Default true. */
|
|
358
|
+
dismissible?: boolean;
|
|
359
|
+
}
|
|
360
|
+
declare function StatusBanner({ endpoint, pollMs, lang, className, dismissible, }: StatusBannerProps): react.JSX.Element | null;
|
|
361
|
+
|
|
362
|
+
interface ErrorPageProps {
|
|
363
|
+
/** Headline (e.g. "문제가 발생했어요"). */
|
|
364
|
+
title?: string;
|
|
365
|
+
/** A user-friendly sentence — what happened + what to do. */
|
|
366
|
+
description?: string;
|
|
367
|
+
/**
|
|
368
|
+
* The 8-hex `ref` code from the backend / log (the `httperr` reference). Shown
|
|
369
|
+
* to the user so they can quote it in a report. Omit when there's no ref.
|
|
370
|
+
*/
|
|
371
|
+
refCode?: string;
|
|
372
|
+
/** Optional retry handler; renders a "다시 시도" button when set. */
|
|
373
|
+
onRetry?: () => void;
|
|
374
|
+
/** Optional home handler; renders a "홈으로" button when set. */
|
|
375
|
+
onHome?: () => void;
|
|
376
|
+
/**
|
|
377
|
+
* Override the labels on the buttons / ref line. Defaults are Korean.
|
|
378
|
+
*/
|
|
379
|
+
labels?: Partial<{
|
|
380
|
+
retry: string;
|
|
381
|
+
home: string;
|
|
382
|
+
refLabel: string;
|
|
383
|
+
}>;
|
|
384
|
+
/** Custom icon node (defaults to a circle-alert glyph). */
|
|
385
|
+
icon?: ReactNode;
|
|
386
|
+
/** Extra class merged with `etu-error-page`. */
|
|
387
|
+
className?: string;
|
|
388
|
+
}
|
|
389
|
+
declare function ErrorPage({ title, description, refCode, onRetry, onHome, labels, icon, className, }: ErrorPageProps): react.JSX.Element;
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Router-agnostic in-page state hooks for the SPA navigation/state contract
|
|
393
|
+
* (planning concepts/spa-navigation-state).
|
|
394
|
+
*
|
|
395
|
+
* Two flavors:
|
|
396
|
+
*
|
|
397
|
+
* - `useRouteState(key, initial, opts?)` — backed by the URL query string.
|
|
398
|
+
* Use for state the user benefits from sharing or restoring after refresh:
|
|
399
|
+
* which tab is open, filter, sort, search term, expanded row id.
|
|
400
|
+
*
|
|
401
|
+
* - `useSessionState(key, initial, opts?)` — backed by `sessionStorage`,
|
|
402
|
+
* keyed by route. Use for truly local state you don't want in the URL:
|
|
403
|
+
* scroll position, cmdk query, unsubmitted form draft.
|
|
404
|
+
*
|
|
405
|
+
* Both hooks:
|
|
406
|
+
* - Hydrate from their backing store on mount.
|
|
407
|
+
* - Listen to `popstate` / `hashchange` so back/forward and direct URL
|
|
408
|
+
* edits stay in sync.
|
|
409
|
+
* - Are SSR-safe: on the server they just return `initial` until mount.
|
|
410
|
+
* - Don't assume a router; they read/write `window.history` directly.
|
|
411
|
+
*
|
|
412
|
+
* URL serialization defaults to `JSON.stringify` so booleans / numbers /
|
|
413
|
+
* arrays round-trip. Pass `{ serialize, deserialize }` for a cleaner
|
|
414
|
+
* representation when you want pretty URLs (e.g. tab names as plain
|
|
415
|
+
* strings instead of `"overview"` with the quotes).
|
|
416
|
+
*/
|
|
417
|
+
type Updater<T> = T | ((prev: T) => T);
|
|
418
|
+
interface UseRouteStateOptions<T> {
|
|
419
|
+
/** How to encode the value into the URL. Default: `JSON.stringify`. */
|
|
420
|
+
serialize?: (value: T) => string;
|
|
421
|
+
/** How to decode the URL value back. Default: `JSON.parse`. */
|
|
422
|
+
deserialize?: (raw: string) => T;
|
|
423
|
+
/**
|
|
424
|
+
* Replace the entry instead of pushing a new one. Useful for noisy state
|
|
425
|
+
* (search-as-you-type filter) that shouldn't fill the back history.
|
|
426
|
+
* Default: `true` (replace).
|
|
427
|
+
*/
|
|
428
|
+
replace?: boolean;
|
|
429
|
+
}
|
|
430
|
+
interface UseSessionStateOptions<T> {
|
|
431
|
+
serialize?: (value: T) => string;
|
|
432
|
+
deserialize?: (raw: string) => T;
|
|
433
|
+
/**
|
|
434
|
+
* Override the per-route scope. Default: `window.location.pathname +
|
|
435
|
+
* window.location.hash` so each in-app view gets its own slot. Pass a
|
|
436
|
+
* static string when you want the state to span routes.
|
|
437
|
+
*/
|
|
438
|
+
scope?: string;
|
|
439
|
+
}
|
|
440
|
+
declare function useRouteState<T>(key: string, initial: T, opts?: UseRouteStateOptions<T>): [T, (next: Updater<T>) => void];
|
|
441
|
+
declare function useSessionState<T>(key: string, initial: T, opts?: UseSessionStateOptions<T>): [T, (next: Updater<T>) => void];
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* In-app history stack for the SPA navigation contract Rule 2
|
|
445
|
+
* (planning concepts/spa-navigation-state): the browser back button — and
|
|
446
|
+
* any in-UI "back" button — should stay inside the app instead of bouncing
|
|
447
|
+
* the user out to whatever site they came from.
|
|
448
|
+
*
|
|
449
|
+
* How it works:
|
|
450
|
+
* - Every in-app navigation goes through `push(url)` / `replace(url)`,
|
|
451
|
+
* which writes a marker into `history.state` (`{ etuInApp: true,
|
|
452
|
+
* etuDepth: N }`).
|
|
453
|
+
* - `canGoBack` is true when the current entry has `etuDepth > 0` — i.e.
|
|
454
|
+
* there's at least one in-app entry behind us.
|
|
455
|
+
* - `goBack()` calls `history.back()` when `canGoBack`; otherwise it
|
|
456
|
+
* runs the `fallback` handler (e.g. router.push("/more")) so the UI
|
|
457
|
+
* button still does something sensible on a cold entry.
|
|
458
|
+
*
|
|
459
|
+
* Composes cleanly with `useRouteState`: that hook uses `replaceState`,
|
|
460
|
+
* so URL-synced in-page state (tab, filter) doesn't grow the back stack
|
|
461
|
+
* and doesn't confuse the depth counter.
|
|
462
|
+
*
|
|
463
|
+
* The hook is router-agnostic — it reads and writes `window.history`
|
|
464
|
+
* directly. Pair it with whatever you use for routing.
|
|
465
|
+
*
|
|
466
|
+
* Most consumers should just render `<BackButton fallback="/more" />` —
|
|
467
|
+
* BackButton mounts this hook internally so apps don't have to plumb the
|
|
468
|
+
* canGoBack/goBack split themselves. The standalone hook is exposed for
|
|
469
|
+
* apps that need the values somewhere besides the button (e.g. a swipe
|
|
470
|
+
* gesture, a keyboard shortcut, a custom layout).
|
|
471
|
+
*/
|
|
472
|
+
interface UseInAppBackResult {
|
|
473
|
+
/** True when there's at least one in-app entry behind the current one. */
|
|
474
|
+
canGoBack: boolean;
|
|
475
|
+
/**
|
|
476
|
+
* Go to the previous in-app view. When `canGoBack` is false, runs the
|
|
477
|
+
* `fallback` passed to the hook (or `onExit` for legacy callers; no-op
|
|
478
|
+
* if neither is set).
|
|
479
|
+
*/
|
|
480
|
+
goBack: () => void;
|
|
481
|
+
/** Push a new in-app entry (URL + depth increment). */
|
|
482
|
+
push: (url: string) => void;
|
|
483
|
+
/** Replace the current entry without growing the stack. */
|
|
484
|
+
replace: (url: string) => void;
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Where to go when the user clicks "back" but there's no in-app history
|
|
488
|
+
* behind them (cold entry from an external link, or after the browser
|
|
489
|
+
* dropped the state on reload).
|
|
490
|
+
*
|
|
491
|
+
* - **string** — treated as a URL. Calls `history.pushState(null, "", url)`
|
|
492
|
+
* and dispatches a synthetic `popstate` so hash/path routers re-render.
|
|
493
|
+
* Use for hash-routed apps and other vanilla setups.
|
|
494
|
+
* - **function** — called as-is. Use for Next.js / React Router etc:
|
|
495
|
+
* `fallback={() => router.push("/more")}`.
|
|
496
|
+
*/
|
|
497
|
+
type InAppBackFallback = string | (() => void);
|
|
498
|
+
interface UseInAppBackOptions {
|
|
499
|
+
/**
|
|
500
|
+
* Where to go when `canGoBack` is false. Accepts a URL string or a
|
|
501
|
+
* callback (most consumers want the callback when they already have a
|
|
502
|
+
* router instance).
|
|
503
|
+
*/
|
|
504
|
+
fallback?: InAppBackFallback;
|
|
505
|
+
/**
|
|
506
|
+
* @deprecated since v0.27.0 — use `fallback`. Kept for back-compat with
|
|
507
|
+
* v0.8.0–v0.26.0 callers; behaves identically when `fallback` is unset.
|
|
508
|
+
*/
|
|
509
|
+
onExit?: () => void;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Runs a fallback. String fallbacks pushState + fire popstate so the
|
|
513
|
+
* caller's router picks up the URL change without a full reload.
|
|
514
|
+
*/
|
|
515
|
+
declare function runInAppBackFallback(fallback: InAppBackFallback): void;
|
|
516
|
+
declare function useInAppBack(opts?: UseInAppBackOptions): UseInAppBackResult;
|
|
517
|
+
|
|
518
|
+
interface BackButtonProps {
|
|
519
|
+
/**
|
|
520
|
+
* From `useInAppBack().canGoBack`. Omit if you're using the canonical
|
|
521
|
+
* one-liner — BackButton mounts the hook internally.
|
|
522
|
+
*/
|
|
523
|
+
canGoBack?: boolean;
|
|
524
|
+
/**
|
|
525
|
+
* From `useInAppBack().goBack`. Omit if you're using the canonical
|
|
526
|
+
* one-liner — BackButton mounts the hook internally.
|
|
527
|
+
*/
|
|
528
|
+
goBack?: () => void;
|
|
529
|
+
/**
|
|
530
|
+
* Direct click handler — use when you don't want to thread the hook
|
|
531
|
+
* result through. If passed, takes precedence over `goBack`.
|
|
532
|
+
*/
|
|
533
|
+
onClick?: () => void;
|
|
534
|
+
/**
|
|
535
|
+
* Where to go when there's no in-app history behind us (cold entry).
|
|
536
|
+
* String → URL (pushState + popstate). Function → run as-is (typical
|
|
537
|
+
* for Next.js: `() => router.push("/more")`). Used when no explicit
|
|
538
|
+
* `goBack`/`onClick` is provided.
|
|
539
|
+
*/
|
|
540
|
+
fallback?: InAppBackFallback;
|
|
541
|
+
/** Default: "뒤로". */
|
|
542
|
+
label?: string;
|
|
543
|
+
/** Replace the default chevron-left glyph. */
|
|
544
|
+
icon?: ReactNode;
|
|
545
|
+
/** Extra class merged onto `etu-back-button`. */
|
|
546
|
+
className?: string;
|
|
547
|
+
/**
|
|
548
|
+
* Render the button even when there's nowhere to go. Useful when the
|
|
549
|
+
* caller wants a stable layout — the click is a no-op unless an
|
|
550
|
+
* `onClick` / `goBack` / `fallback` is also wired.
|
|
551
|
+
*/
|
|
552
|
+
alwaysShow?: boolean;
|
|
553
|
+
}
|
|
554
|
+
declare function BackButton({ canGoBack, goBack, onClick, fallback, label, icon, className, alwaysShow, }: BackButtonProps): react.JSX.Element | null;
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Tiny `fetch` wrapper with the etamong-lab house conventions baked in:
|
|
558
|
+
*
|
|
559
|
+
* - **httperr `ref` parsing** — non-2xx JSON bodies shaped like
|
|
560
|
+
* `{ error: string, ref: string }` (planning concepts/user-facing-
|
|
561
|
+
* error-messages, shared/libs/httperr) become an `HttpError` whose
|
|
562
|
+
* `ref` drops straight into `<ErrorPage refCode={err.ref}>`.
|
|
563
|
+
* - **OIDC sign-in on 401** — 401 responses trigger `onAuthError`,
|
|
564
|
+
* which by default redirects to `oauth2-proxy`'s sign-in flow with
|
|
565
|
+
* the current URL as `rd`.
|
|
566
|
+
* - **JSON in / JSON out by default** — request bodies that are plain
|
|
567
|
+
* objects get `Content-Type: application/json` + serialized; non-empty
|
|
568
|
+
* 2xx responses with a JSON content type are parsed.
|
|
569
|
+
*
|
|
570
|
+
* Designed to be the one fetch wrapper an etamong-lab app needs.
|
|
571
|
+
* Compose with route-state / error-page / toast on the consumer side.
|
|
572
|
+
*/
|
|
573
|
+
interface CreateFetchOptions {
|
|
574
|
+
/**
|
|
575
|
+
* Base URL prepended to relative paths. `"/api"` (default: empty —
|
|
576
|
+
* paths are used as-is).
|
|
577
|
+
*/
|
|
578
|
+
baseUrl?: string;
|
|
579
|
+
/**
|
|
580
|
+
* Called on a 401 response. Default: redirect the browser to
|
|
581
|
+
* `/oauth2/start?rd=<current url>`. Pass a no-op when the app handles
|
|
582
|
+
* auth elsewhere (e.g. inside its router).
|
|
583
|
+
*/
|
|
584
|
+
onAuthError?: () => void;
|
|
585
|
+
/**
|
|
586
|
+
* Called for every non-2xx response after the `HttpError` is built but
|
|
587
|
+
* before it's thrown. Use for telemetry / global toast. Doesn't affect
|
|
588
|
+
* the throw — the caller still gets the error.
|
|
589
|
+
*/
|
|
590
|
+
onError?: (err: HttpError) => void;
|
|
591
|
+
/**
|
|
592
|
+
* Extra headers to add to every request. Static object or a function
|
|
593
|
+
* (evaluated per request). Common use: an `Authorization` header for
|
|
594
|
+
* token-based callers (not browser sessions).
|
|
595
|
+
*/
|
|
596
|
+
headers?: HeadersInit | (() => HeadersInit);
|
|
597
|
+
/**
|
|
598
|
+
* Override the global `fetch` (for tests / SSR). Default: `globalThis.fetch`.
|
|
599
|
+
*/
|
|
600
|
+
fetchImpl?: typeof fetch;
|
|
601
|
+
}
|
|
602
|
+
interface HttpErrorBody {
|
|
603
|
+
/** Server-supplied user-facing message. */
|
|
604
|
+
error?: string;
|
|
605
|
+
/** 8-hex correlation id from the server log (httperr). */
|
|
606
|
+
ref?: string;
|
|
607
|
+
/** Any extra fields the server included. */
|
|
608
|
+
[extra: string]: unknown;
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Error thrown for every non-2xx response. The `ref` field is the join
|
|
612
|
+
* key into the server-side error log and is what `<ErrorPage>` shows.
|
|
613
|
+
*/
|
|
614
|
+
declare class HttpError extends Error {
|
|
615
|
+
status: number;
|
|
616
|
+
ref?: string;
|
|
617
|
+
body?: HttpErrorBody | string;
|
|
618
|
+
url: string;
|
|
619
|
+
constructor(status: number, url: string, body: HttpErrorBody | string | undefined);
|
|
620
|
+
}
|
|
621
|
+
interface RequestOptions {
|
|
622
|
+
/** Query-string params; values are stringified. */
|
|
623
|
+
query?: Record<string, string | number | boolean | null | undefined>;
|
|
624
|
+
/** Per-call header override (merged on top of the factory headers). */
|
|
625
|
+
headers?: HeadersInit;
|
|
626
|
+
/** Per-call body. Objects → JSON. `FormData` / `Blob` / strings pass through. */
|
|
627
|
+
body?: unknown;
|
|
628
|
+
/** Abort signal. */
|
|
629
|
+
signal?: AbortSignal;
|
|
630
|
+
/**
|
|
631
|
+
* Skip JSON parsing and return the raw `Response` instead. Useful for
|
|
632
|
+
* downloads / streaming.
|
|
633
|
+
*/
|
|
634
|
+
raw?: boolean;
|
|
635
|
+
}
|
|
636
|
+
interface FetchClient {
|
|
637
|
+
request<T = unknown>(method: string, path: string, opts?: RequestOptions): Promise<T>;
|
|
638
|
+
get<T = unknown>(path: string, opts?: RequestOptions): Promise<T>;
|
|
639
|
+
post<T = unknown>(path: string, body?: unknown, opts?: RequestOptions): Promise<T>;
|
|
640
|
+
put<T = unknown>(path: string, body?: unknown, opts?: RequestOptions): Promise<T>;
|
|
641
|
+
patch<T = unknown>(path: string, body?: unknown, opts?: RequestOptions): Promise<T>;
|
|
642
|
+
delete<T = unknown>(path: string, opts?: RequestOptions): Promise<T>;
|
|
643
|
+
}
|
|
644
|
+
declare function createFetch(opts?: CreateFetchOptions): FetchClient;
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* `useMe()` + OIDC sign-in/out URL helpers — the small auth-status surface
|
|
648
|
+
* every etamong-lab app reimplements.
|
|
649
|
+
*
|
|
650
|
+
* Pairs with `oauth2-proxy` (the fleet's standard auth proxy): the sign-in
|
|
651
|
+
* URL is `/oauth2/start?rd=<return-url>`, sign-out is `/oauth2/sign_out`.
|
|
652
|
+
*
|
|
653
|
+
* The `/me` endpoint and the exact shape are app-specific, so the hook is
|
|
654
|
+
* generic over the body type. Pass a `fetcher` (typically from
|
|
655
|
+
* `createFetch`) and a type — the hook handles the loading/error/refresh
|
|
656
|
+
* state machine + listens for an `etu:me-refresh` event so other
|
|
657
|
+
* components can ask for a re-fetch.
|
|
658
|
+
*/
|
|
659
|
+
interface BaseMe {
|
|
660
|
+
/** Always present — the authenticated identity. */
|
|
661
|
+
email: string;
|
|
662
|
+
/** OIDC `preferred_username` if the IdP supplies it. */
|
|
663
|
+
preferred_username?: string;
|
|
664
|
+
/** OIDC `name` claim — full display name. */
|
|
665
|
+
name?: string;
|
|
666
|
+
/** OIDC `picture` claim — URL of the user's profile picture. */
|
|
667
|
+
picture?: string;
|
|
668
|
+
/** Server-side admin flag (use this, not allowlist-by-email on the client). */
|
|
669
|
+
is_admin?: boolean;
|
|
670
|
+
/** Role claims from the IdP. */
|
|
671
|
+
roles?: string[];
|
|
672
|
+
}
|
|
673
|
+
interface UseMeOptions<T extends BaseMe> {
|
|
674
|
+
/**
|
|
675
|
+
* URL to fetch. Default: `/api/me`. Ignored when `fetcher` is set.
|
|
676
|
+
*/
|
|
677
|
+
endpoint?: string;
|
|
678
|
+
/**
|
|
679
|
+
* Custom fetcher — usually the `api.get<T>("/me")` from your
|
|
680
|
+
* `createFetch` client. Use this when the app's API is mounted under a
|
|
681
|
+
* non-default base path or needs custom headers.
|
|
682
|
+
*/
|
|
683
|
+
fetcher?: () => Promise<T>;
|
|
684
|
+
/**
|
|
685
|
+
* When `true` (default), 401 from the default fetcher counts as
|
|
686
|
+
* "anonymous" rather than an error — `me` becomes `null`, `error`
|
|
687
|
+
* stays `null`. Useful when the app has public surfaces. Ignored when
|
|
688
|
+
* a custom `fetcher` is set.
|
|
689
|
+
*/
|
|
690
|
+
treat401AsAnonymous?: boolean;
|
|
691
|
+
}
|
|
692
|
+
interface UseMeResult<T extends BaseMe> {
|
|
693
|
+
me: T | null;
|
|
694
|
+
loading: boolean;
|
|
695
|
+
error: Error | null;
|
|
696
|
+
/** Re-fetch /me. Also fires `etu:me-refresh` so other readers re-fetch too. */
|
|
697
|
+
refresh: () => void;
|
|
698
|
+
}
|
|
699
|
+
declare function useMe<T extends BaseMe = BaseMe>(opts?: UseMeOptions<T>): UseMeResult<T>;
|
|
700
|
+
/**
|
|
701
|
+
* Build the oauth2-proxy sign-in URL. Pass `rd` to override the
|
|
702
|
+
* post-sign-in redirect target (default: the current URL).
|
|
703
|
+
*/
|
|
704
|
+
declare function signInUrl(rd?: string): string;
|
|
705
|
+
/**
|
|
706
|
+
* Build the oauth2-proxy sign-out URL. Pass `rd` for the post-sign-out
|
|
707
|
+
* redirect (default: `/`).
|
|
708
|
+
*/
|
|
709
|
+
declare function signOutUrl(rd?: string): string;
|
|
710
|
+
/** Navigate to the sign-in URL. */
|
|
711
|
+
declare function signIn(rd?: string): void;
|
|
712
|
+
/** Navigate to the sign-out URL. */
|
|
713
|
+
declare function signOut(rd?: string): void;
|
|
714
|
+
|
|
715
|
+
interface EmptyStateProps {
|
|
716
|
+
/** Headline. */
|
|
717
|
+
title: string;
|
|
718
|
+
/** Optional one-line description. */
|
|
719
|
+
description?: ReactNode;
|
|
720
|
+
/** Optional CTA / footnote node (typically a button or a hint). */
|
|
721
|
+
action?: ReactNode;
|
|
722
|
+
/** Replace the default empty-box glyph. Pass `null` to omit. */
|
|
723
|
+
icon?: ReactNode;
|
|
724
|
+
/** Smaller padding + smaller type — for inline / sidebar usage. */
|
|
725
|
+
compact?: boolean;
|
|
726
|
+
/** Extra class merged with `etu-empty-state`. */
|
|
727
|
+
className?: string;
|
|
728
|
+
}
|
|
729
|
+
declare function EmptyState({ title, description, action, icon, compact, className, }: EmptyStateProps): react.JSX.Element;
|
|
730
|
+
|
|
731
|
+
interface UseClipboardOptions {
|
|
732
|
+
/** Default: 1500ms. How long `copied` stays true after a successful copy. */
|
|
733
|
+
resetMs?: number;
|
|
734
|
+
/** Toast text on success. Default: "복사됨". Pass `null` to suppress. */
|
|
735
|
+
toastOnSuccess?: string | null;
|
|
736
|
+
/** Toast text on failure. Default: "복사 실패". Pass `null` to suppress. */
|
|
737
|
+
toastOnError?: string | null;
|
|
738
|
+
}
|
|
739
|
+
interface UseClipboardResult {
|
|
740
|
+
copied: boolean;
|
|
741
|
+
copy: (value: string) => Promise<boolean>;
|
|
742
|
+
}
|
|
743
|
+
declare function useClipboard(opts?: UseClipboardOptions): UseClipboardResult;
|
|
744
|
+
interface CopyButtonProps extends UseClipboardOptions {
|
|
745
|
+
/** The string to copy on click. */
|
|
746
|
+
value: string;
|
|
747
|
+
/** Button label. Default: "복사" (switches to "복사됨" briefly after success). */
|
|
748
|
+
label?: string;
|
|
749
|
+
/** Label shown right after a successful copy. Default: "복사됨". */
|
|
750
|
+
successLabel?: string;
|
|
751
|
+
/** Replace the default copy glyph. Pass `null` to omit. */
|
|
752
|
+
icon?: ReactNode;
|
|
753
|
+
/** Extra class merged with `etu-copy-button`. */
|
|
754
|
+
className?: string;
|
|
755
|
+
/**
|
|
756
|
+
* Render only the icon (no label). Good for inline placement next to a
|
|
757
|
+
* value display.
|
|
758
|
+
*/
|
|
759
|
+
iconOnly?: boolean;
|
|
760
|
+
/** ARIA label when `iconOnly`. Default: "복사". */
|
|
761
|
+
ariaLabel?: string;
|
|
762
|
+
}
|
|
763
|
+
declare function CopyButton({ value, label, successLabel, icon, className, iconOnly, ariaLabel, resetMs, toastOnSuccess, toastOnError, }: CopyButtonProps): react.JSX.Element;
|
|
764
|
+
|
|
765
|
+
interface OpenInBrowserButtonProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "target" | "rel"> {
|
|
766
|
+
/** The URL to open. */
|
|
767
|
+
href: string;
|
|
768
|
+
/** Button label. Default: "브라우저에서 열기". */
|
|
769
|
+
label?: string;
|
|
770
|
+
/** Replace the default external-link glyph. Pass `null` to omit. */
|
|
771
|
+
icon?: ReactNode;
|
|
772
|
+
/**
|
|
773
|
+
* Render only the icon (no label). Good for inline placement next to a URL
|
|
774
|
+
* display or inside a toolbar.
|
|
775
|
+
*/
|
|
776
|
+
iconOnly?: boolean;
|
|
777
|
+
/** ARIA label when `iconOnly`. Default: same as `label`. */
|
|
778
|
+
ariaLabel?: string;
|
|
779
|
+
/**
|
|
780
|
+
* Visual variant. `ghost` (default) = transparent button with border,
|
|
781
|
+
* matches `<CopyButton>`. `primary` = filled accent.
|
|
782
|
+
*/
|
|
783
|
+
variant?: "ghost" | "primary";
|
|
784
|
+
/** Extra class merged with `etu-open-in-browser-button`. */
|
|
785
|
+
className?: string;
|
|
786
|
+
}
|
|
787
|
+
declare function OpenInBrowserButton({ href, label, icon, iconOnly, ariaLabel, variant, className, ...rest }: OpenInBrowserButtonProps): react.JSX.Element;
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Service-worker helpers for the etamong-lab PWA recipe (planning
|
|
791
|
+
* concepts/pwa-service-worker). Two pieces:
|
|
792
|
+
*
|
|
793
|
+
* - `registerServiceWorker(url, opts)` — client-side registration with
|
|
794
|
+
* the update flow baked in: aggressive `registration.update()` checks
|
|
795
|
+
* (on load + visibilitychange + a slow interval), a "새 버전" toast on
|
|
796
|
+
* a waiting SW, and an auto-reload on `controllerchange`.
|
|
797
|
+
*
|
|
798
|
+
* - `networkFirstSwSource({ version, navigateAllowlist })` — returns the
|
|
799
|
+
* canonical hand-rolled SW source as a string. Apps write it to
|
|
800
|
+
* `public/sw.js` (or generate it at build time). The recipe:
|
|
801
|
+
* • Never intercepts non-GET or `/api/*` (fresh auth + live state).
|
|
802
|
+
* • Navigation requests: **network-first** with a short timeout, cache
|
|
803
|
+
* as the offline fallback. So online users always see the latest
|
|
804
|
+
* deploy; offline still works.
|
|
805
|
+
* • Static assets: **network-first** with a short timeout too, cache
|
|
806
|
+
* as the offline fallback. Same bias toward "fresh wins".
|
|
807
|
+
* • Caches are versioned by `version`; on `activate` older versions
|
|
808
|
+
* are deleted, then `clients.claim()`.
|
|
809
|
+
* • The SW calls `skipWaiting()` immediately on `install` so updates
|
|
810
|
+
* propagate on the next nav.
|
|
811
|
+
*
|
|
812
|
+
* This is the "online-first" preset. Apps with a tighter cache budget
|
|
813
|
+
* or a hand-rolled scoped strategy (minccino's specific endpoints, the
|
|
814
|
+
* shortener single-segment-route guard) should stick with their
|
|
815
|
+
* bespoke SW.
|
|
816
|
+
*/
|
|
817
|
+
interface RegisterServiceWorkerOptions {
|
|
818
|
+
/**
|
|
819
|
+
* Show a "새 버전이 있어요" toast when a new SW finishes installing and
|
|
820
|
+
* is waiting. Clicking it activates the new SW and reloads. Default: true.
|
|
821
|
+
*/
|
|
822
|
+
notifyOnUpdate?: boolean;
|
|
823
|
+
/**
|
|
824
|
+
* Auto-activate the waiting SW + reload as soon as it's detected, with
|
|
825
|
+
* no user prompt. Use when you don't care about transient state loss.
|
|
826
|
+
* Default: false (the toast is the canonical path).
|
|
827
|
+
*/
|
|
828
|
+
autoReloadOnUpdate?: boolean;
|
|
829
|
+
/**
|
|
830
|
+
* Override the toast text. Default: "새 버전이 준비됐어요. 새로고침할까요?".
|
|
831
|
+
*/
|
|
832
|
+
updateToastText?: string;
|
|
833
|
+
/**
|
|
834
|
+
* Interval (ms) between background `registration.update()` calls — so
|
|
835
|
+
* long-lived installed tabs catch new deploys without a reload. Default:
|
|
836
|
+
* 2 minutes. Pass `0` to disable the interval (still updates on load
|
|
837
|
+
* + visibilitychange).
|
|
838
|
+
*/
|
|
839
|
+
updateIntervalMs?: number;
|
|
840
|
+
/**
|
|
841
|
+
* Service-worker `register()` options (typically `{ scope }`).
|
|
842
|
+
*/
|
|
843
|
+
registerOptions?: RegistrationOptions;
|
|
844
|
+
/**
|
|
845
|
+
* Called when the new SW activates and the page is about to reload.
|
|
846
|
+
* Last chance to persist transient state.
|
|
847
|
+
*/
|
|
848
|
+
onActivate?: () => void;
|
|
849
|
+
/**
|
|
850
|
+
* The current build identifier — typically the deploy SHA from
|
|
851
|
+
* `DeployInfo`. When supplied, a dev-mode assertion fires a
|
|
852
|
+
* `console.warn` if this changes across reloads but the SW file body
|
|
853
|
+
* is byte-for-byte identical. That signal means the SW source isn't
|
|
854
|
+
* stamped per build, so browsers never observe a new SW and never
|
|
855
|
+
* roll over to the new deploy — the canonical iOS PWA staleness
|
|
856
|
+
* trigger (see planning concepts/pwa-cache-and-ios-shell and the
|
|
857
|
+
* `viewport-fit-assertions` companion pattern).
|
|
858
|
+
*
|
|
859
|
+
* Dev-only — production short-circuits via `NODE_ENV === "production"`.
|
|
860
|
+
*/
|
|
861
|
+
currentBuild?: string;
|
|
862
|
+
}
|
|
863
|
+
interface ServiceWorkerHandle {
|
|
864
|
+
/** The underlying registration once it resolves. */
|
|
865
|
+
readonly registration: ServiceWorkerRegistration | null;
|
|
866
|
+
/** True while a waiting SW exists (update available). */
|
|
867
|
+
readonly hasUpdate: boolean;
|
|
868
|
+
/** Force the waiting SW to take over + reload. No-op if no waiting SW. */
|
|
869
|
+
applyUpdate: () => void;
|
|
870
|
+
/** Manually trigger `registration.update()`. */
|
|
871
|
+
checkForUpdate: () => Promise<void>;
|
|
872
|
+
/** Unregister this SW. Useful in tests / when toggling off. */
|
|
873
|
+
unregister: () => Promise<boolean>;
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Register a service worker with the etamong-lab update-flow conventions.
|
|
877
|
+
* Returns a handle for programmatic control. Safe to call from `useEffect`
|
|
878
|
+
* (no React dependency — works from any client-side bootstrap).
|
|
879
|
+
*/
|
|
880
|
+
declare function registerServiceWorker(url: string, opts?: RegisterServiceWorkerOptions): ServiceWorkerHandle;
|
|
881
|
+
interface NetworkFirstSwOptions {
|
|
882
|
+
/**
|
|
883
|
+
* Cache version suffix. Use a build SHA so caches roll over on every
|
|
884
|
+
* deploy. Required.
|
|
885
|
+
*/
|
|
886
|
+
version: string;
|
|
887
|
+
/**
|
|
888
|
+
* Network timeout (ms) before the SW falls back to the cached
|
|
889
|
+
* navigation/asset. Default: 3000.
|
|
890
|
+
*/
|
|
891
|
+
networkTimeoutMs?: number;
|
|
892
|
+
/**
|
|
893
|
+
* Extra URL prefixes the SW should leave alone (in addition to the
|
|
894
|
+
* always-skipped `/api/`). Use for OIDC callbacks, webhook routes,
|
|
895
|
+
* single-segment apiserver routes. Default: `[]`.
|
|
896
|
+
*
|
|
897
|
+
* Each entry is a prefix match against `url.pathname`.
|
|
898
|
+
*/
|
|
899
|
+
passThroughPrefixes?: string[];
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Returns the canonical "online-first" SW source as a string. Write it to
|
|
903
|
+
* `public/sw.js` at build time (most apps already have a build step that
|
|
904
|
+
* inlines a `SW_VERSION` constant):
|
|
905
|
+
*
|
|
906
|
+
* import { networkFirstSwSource } from "@etamong-playground/ui";
|
|
907
|
+
* await fs.writeFile(
|
|
908
|
+
* "public/sw.js",
|
|
909
|
+
* networkFirstSwSource({ version: process.env.BUILD_SHA }),
|
|
910
|
+
* );
|
|
911
|
+
*
|
|
912
|
+
* Or serve it dynamically from the app's own backend at `/sw.js`.
|
|
913
|
+
*
|
|
914
|
+
* The recipe:
|
|
915
|
+
* - Never intercepts non-GET or anything under `/api/` (or the
|
|
916
|
+
* `passThroughPrefixes`) — auth & live state always hit the network.
|
|
917
|
+
* - Navigation requests: network-first with `networkTimeoutMs`, cache
|
|
918
|
+
* fallback for offline. So a new deploy lands the moment the next
|
|
919
|
+
* navigation succeeds.
|
|
920
|
+
* - Static assets: network-first with the same timeout, cache fallback.
|
|
921
|
+
* - On `activate`, all caches not matching `version` are deleted.
|
|
922
|
+
* - `skipWaiting()` on install + `clients.claim()` on activate so the
|
|
923
|
+
* new SW takes over the next time the page navigates.
|
|
924
|
+
*/
|
|
925
|
+
declare function networkFirstSwSource(opts: NetworkFirstSwOptions): string;
|
|
926
|
+
|
|
927
|
+
interface AdminCheckInput<T extends BaseMe = BaseMe> {
|
|
928
|
+
/** The current identity (from `useMe`). `null` = anonymous. */
|
|
929
|
+
me: T | null | undefined;
|
|
930
|
+
/** App-managed allowlist (case-insensitive on the local part / domain). */
|
|
931
|
+
emails?: string[];
|
|
932
|
+
/** Pass if `me.roles` intersects this set. */
|
|
933
|
+
roles?: string[];
|
|
934
|
+
/** Last-resort custom check (e.g. flags like `can_create_apps`). */
|
|
935
|
+
predicate?: (me: T) => boolean;
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* Returns `true` when *any* of the configured signals says the user is
|
|
939
|
+
* allowed in. With nothing configured, only `me.is_admin` counts.
|
|
940
|
+
*/
|
|
941
|
+
declare function isAdminLike<T extends BaseMe>(input: AdminCheckInput<T>): boolean;
|
|
942
|
+
interface AdminGateProps<T extends BaseMe = BaseMe> extends AdminCheckInput<T> {
|
|
943
|
+
/** Rendered when the user is allowed in. */
|
|
944
|
+
children: ReactNode;
|
|
945
|
+
/**
|
|
946
|
+
* Rendered when the user is NOT allowed in. Default: `null` (nothing).
|
|
947
|
+
* Pass a friendly "권한이 없어요" surface or a redirect-trigger here.
|
|
948
|
+
*/
|
|
949
|
+
fallback?: ReactNode;
|
|
950
|
+
}
|
|
951
|
+
declare function AdminGate<T extends BaseMe = BaseMe>(props: AdminGateProps<T>): react.JSX.Element;
|
|
952
|
+
interface AdminBadgeProps {
|
|
953
|
+
/** Default: "관리자 전용". */
|
|
954
|
+
label?: string;
|
|
955
|
+
/** Extra class merged with `etu-admin-badge`. */
|
|
956
|
+
className?: string;
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Small inline badge that marks a page / section as admin-only. Pair with
|
|
960
|
+
* `AdminGate` so users without access never see it in the first place,
|
|
961
|
+
* but admins always know the surface they're on.
|
|
962
|
+
*/
|
|
963
|
+
declare function AdminBadge({ label, className }: AdminBadgeProps): react.JSX.Element;
|
|
964
|
+
interface BackofficeLayoutProps {
|
|
965
|
+
/** Page title. */
|
|
966
|
+
title: ReactNode;
|
|
967
|
+
/** Optional subtitle line below the title. */
|
|
968
|
+
description?: ReactNode;
|
|
969
|
+
/** Right-side actions (buttons, search, filter). */
|
|
970
|
+
actions?: ReactNode;
|
|
971
|
+
/** Override the "관리자 전용" badge — pass `null` to hide it entirely. */
|
|
972
|
+
badge?: ReactNode | null;
|
|
973
|
+
/** Page body. */
|
|
974
|
+
children: ReactNode;
|
|
975
|
+
/** Extra class merged with `etu-backoffice`. */
|
|
976
|
+
className?: string;
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Standard page-head layout for a backoffice route: title + AdminBadge +
|
|
980
|
+
* optional actions, then the page body.
|
|
981
|
+
*/
|
|
982
|
+
declare function BackofficeLayout({ title, description, actions, badge, children, className, }: BackofficeLayoutProps): react.JSX.Element;
|
|
983
|
+
|
|
984
|
+
interface AppInfoLink {
|
|
985
|
+
label: string;
|
|
986
|
+
href: string;
|
|
987
|
+
/** Default: opens in a new tab when `href` is absolute. */
|
|
988
|
+
external?: boolean;
|
|
989
|
+
}
|
|
990
|
+
interface AppInfoSectionProps {
|
|
991
|
+
/** App display name (e.g. "schedule-manager", "🎪 Festplan"). */
|
|
992
|
+
name?: ReactNode;
|
|
993
|
+
/** Optional one-liner under the name. */
|
|
994
|
+
description?: ReactNode;
|
|
995
|
+
/** Optional logo / icon node to the left of the name. */
|
|
996
|
+
icon?: ReactNode;
|
|
997
|
+
/**
|
|
998
|
+
* Semver / release version (`package.json` `version` — e.g. "1.4.2").
|
|
999
|
+
* Distinct from the commit SHA, which goes through `version`.
|
|
1000
|
+
*/
|
|
1001
|
+
appVersion?: string;
|
|
1002
|
+
/** Deployed commit SHA — forwarded to `<DeployInfo>`. */
|
|
1003
|
+
version?: string;
|
|
1004
|
+
/** Build timestamp (ISO 8601) — forwarded to `<DeployInfo>`. */
|
|
1005
|
+
builtAt?: string;
|
|
1006
|
+
/**
|
|
1007
|
+
* Extra link rows ("도움말", "이용약관", "개인정보처리방침"). External
|
|
1008
|
+
* links open in a new tab.
|
|
1009
|
+
*/
|
|
1010
|
+
links?: AppInfoLink[];
|
|
1011
|
+
/**
|
|
1012
|
+
* Free-form rows under the standard fields. Use for app-specific
|
|
1013
|
+
* meta (plan name, quota, owner email, …).
|
|
1014
|
+
*/
|
|
1015
|
+
children?: ReactNode;
|
|
1016
|
+
/** Section heading. Default: "앱 정보". Pass `null` to omit. */
|
|
1017
|
+
heading?: ReactNode | null;
|
|
1018
|
+
/** Extra class merged with `etu-app-info`. */
|
|
1019
|
+
className?: string;
|
|
1020
|
+
}
|
|
1021
|
+
declare function AppInfoSection({ name, description, icon, appVersion, version, builtAt, links, children, heading, className, }: AppInfoSectionProps): react.JSX.Element;
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Time-format helpers — the small piece every etamong-lab app reimplements.
|
|
1025
|
+
*
|
|
1026
|
+
* Two surfaces:
|
|
1027
|
+
*
|
|
1028
|
+
* - `formatRelTime(when, now?)` — "3 minutes ago" / "3분 전" via the
|
|
1029
|
+
* browser's `Intl.RelativeTimeFormat`. Locale comes from the
|
|
1030
|
+
* document by default; pass `locale` to force.
|
|
1031
|
+
* - `formatAbsTime(when, opts?)` — absolute formatting via
|
|
1032
|
+
* `Intl.DateTimeFormat`. Defaults to a KST (`Asia/Seoul`) Korean
|
|
1033
|
+
* rendering — the fleet's de-facto wall clock.
|
|
1034
|
+
*
|
|
1035
|
+
* - `<RelTime when />` — small React component that auto-refreshes the
|
|
1036
|
+
* relative label on a timer (every 30 s under a minute, every minute
|
|
1037
|
+
* under an hour, then every 10 minutes). Title attribute always shows
|
|
1038
|
+
* the absolute time so hovering reveals the exact timestamp.
|
|
1039
|
+
*
|
|
1040
|
+
* `when` accepts a `Date`, an ISO string, or an epoch milliseconds
|
|
1041
|
+
* number. Invalid inputs render to empty strings rather than throwing —
|
|
1042
|
+
* makes the helpers safe to use directly on partial data.
|
|
1043
|
+
*/
|
|
1044
|
+
type TimeLike = Date | string | number;
|
|
1045
|
+
interface FormatRelTimeOptions {
|
|
1046
|
+
/** Locale tag (e.g. "ko", "en"). Default: browser default. */
|
|
1047
|
+
locale?: string | string[];
|
|
1048
|
+
/**
|
|
1049
|
+
* `Intl.RelativeTimeFormat` numeric mode. Default: `"auto"` (gives
|
|
1050
|
+
* "yesterday" / "어제" instead of "1 day ago").
|
|
1051
|
+
*/
|
|
1052
|
+
numeric?: "auto" | "always";
|
|
1053
|
+
/** Reference time for "now". Default: `Date.now()`. */
|
|
1054
|
+
now?: TimeLike;
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Returns e.g. `"3분 전"` / `"in 2 hours"`. Empty string for invalid
|
|
1058
|
+
* inputs.
|
|
1059
|
+
*/
|
|
1060
|
+
declare function formatRelTime(when: TimeLike, opts?: FormatRelTimeOptions): string;
|
|
1061
|
+
interface FormatAbsTimeOptions {
|
|
1062
|
+
/** Locale tag. Default: `"ko-KR"`. */
|
|
1063
|
+
locale?: string | string[];
|
|
1064
|
+
/** Time zone. Default: `"Asia/Seoul"` (KST — the fleet's wall clock). */
|
|
1065
|
+
timeZone?: string;
|
|
1066
|
+
/** `Intl.DateTimeFormat` style preset. Default: `"datetime"`. */
|
|
1067
|
+
style?: "date" | "time" | "datetime" | "datetime-seconds";
|
|
1068
|
+
/** Custom `Intl.DateTimeFormat` options — overrides `style`. */
|
|
1069
|
+
formatOptions?: Intl.DateTimeFormatOptions;
|
|
1070
|
+
/** Wrap the result so it's tagged with the timezone (e.g. `… KST`). */
|
|
1071
|
+
withZoneSuffix?: boolean;
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Returns e.g. `"2026. 06. 13. 12:34"` in KST. Empty string for invalid
|
|
1075
|
+
* inputs.
|
|
1076
|
+
*/
|
|
1077
|
+
declare function formatAbsTime(when: TimeLike, opts?: FormatAbsTimeOptions): string;
|
|
1078
|
+
interface RelTimeProps {
|
|
1079
|
+
/** The timestamp. */
|
|
1080
|
+
when: TimeLike;
|
|
1081
|
+
/** Forwarded to `formatRelTime`. */
|
|
1082
|
+
locale?: string | string[];
|
|
1083
|
+
/** Forwarded to `formatRelTime`. */
|
|
1084
|
+
numeric?: "auto" | "always";
|
|
1085
|
+
/**
|
|
1086
|
+
* Absolute-time options for the `title` attribute. Defaults to KST
|
|
1087
|
+
* datetime with the zone suffix.
|
|
1088
|
+
*/
|
|
1089
|
+
absoluteOptions?: FormatAbsTimeOptions;
|
|
1090
|
+
/** Render as a different tag. Default: `<time>`. */
|
|
1091
|
+
as?: "time" | "span";
|
|
1092
|
+
/** Extra class. */
|
|
1093
|
+
className?: string;
|
|
1094
|
+
}
|
|
1095
|
+
/**
|
|
1096
|
+
* Auto-refreshing relative-time label. Renders as a `<time>` element
|
|
1097
|
+
* with `dateTime` + a `title` showing the absolute time.
|
|
1098
|
+
*/
|
|
1099
|
+
declare function RelTime({ when, locale, numeric, absoluteOptions, as, className, }: RelTimeProps): react.JSX.Element | null;
|
|
1100
|
+
|
|
1101
|
+
interface AvatarProps {
|
|
1102
|
+
/** Picture URL — typically `me.picture`. */
|
|
1103
|
+
src?: string;
|
|
1104
|
+
/** Initial / fallback when no picture is available — typically `me.preferred_username || me.email`. */
|
|
1105
|
+
fallback?: string;
|
|
1106
|
+
/** px size of the rendered circle. Default: 32. */
|
|
1107
|
+
size?: number;
|
|
1108
|
+
/** Extra class merged with `etu-avatar`. */
|
|
1109
|
+
className?: string;
|
|
1110
|
+
/** ARIA label — defaults to "프로필". */
|
|
1111
|
+
alt?: string;
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* Round avatar — renders the picture if `src` is given, otherwise an
|
|
1115
|
+
* initial letter on a token-colored circle. Use stand-alone or inside
|
|
1116
|
+
* `<UserMenu>`.
|
|
1117
|
+
*/
|
|
1118
|
+
declare function Avatar({ src, fallback, size, className, alt }: AvatarProps): react.JSX.Element;
|
|
1119
|
+
interface UserMenuProps<T extends BaseMe = BaseMe> {
|
|
1120
|
+
/** Current identity. `null` shows the signed-out affordance. */
|
|
1121
|
+
me: T | null | undefined;
|
|
1122
|
+
/** Avatar size. Default: 32. */
|
|
1123
|
+
avatarSize?: number;
|
|
1124
|
+
/**
|
|
1125
|
+
* URL of the "내 정보" page. Default: `/me`. Pass `null` to hide the row.
|
|
1126
|
+
*/
|
|
1127
|
+
myInfoHref?: string | null;
|
|
1128
|
+
/** Override the "내 정보" label. */
|
|
1129
|
+
myInfoLabel?: string;
|
|
1130
|
+
/**
|
|
1131
|
+
* Logout handler. Default: `signOut()` (navigates to
|
|
1132
|
+
* `/oauth2/sign_out?rd=/`). Pass a custom handler for apps that have
|
|
1133
|
+
* their own logout POST (e.g. minccino's `/api/auth/logout`).
|
|
1134
|
+
*/
|
|
1135
|
+
onSignOut?: () => void;
|
|
1136
|
+
/** Override the "로그아웃" label. */
|
|
1137
|
+
signOutLabel?: string;
|
|
1138
|
+
/**
|
|
1139
|
+
* Rendered in place of the avatar when `me` is `null`. Default: a
|
|
1140
|
+
* "로그인" link pointing at `signInUrl()`.
|
|
1141
|
+
*/
|
|
1142
|
+
signedOutAction?: ReactNode;
|
|
1143
|
+
/**
|
|
1144
|
+
* Extra rows above 내 정보 / 로그아웃. Use for app-specific quick links
|
|
1145
|
+
* (e.g. "내 사이트", "결제"). Pass an array of `{ label, href? | onClick? }`.
|
|
1146
|
+
*/
|
|
1147
|
+
extraItems?: UserMenuItem[];
|
|
1148
|
+
/** Extra class merged with `etu-user-menu`. */
|
|
1149
|
+
className?: string;
|
|
1150
|
+
/**
|
|
1151
|
+
* Render an admin badge inside the dropdown when `me.is_admin` is true.
|
|
1152
|
+
* Default: true.
|
|
1153
|
+
*/
|
|
1154
|
+
showAdminBadge?: boolean;
|
|
1155
|
+
/**
|
|
1156
|
+
* Preferred placement of the dropdown relative to the trigger.
|
|
1157
|
+
* Default: `"bottom-right"`. Use `"top-right"` when the trigger sits at the
|
|
1158
|
+
* bottom of a sidebar foot (pages / festplan layouts) so the menu opens
|
|
1159
|
+
* *upward* instead of pointing at the viewport edge.
|
|
1160
|
+
*
|
|
1161
|
+
* The horizontal half controls the dropdown's right/left alignment; the
|
|
1162
|
+
* vertical half controls open-up vs open-down. The component auto-flips
|
|
1163
|
+
* either axis on open when the requested side doesn't fit — so a layout
|
|
1164
|
+
* that becomes a horizontal topbar on mobile won't strand the menu above
|
|
1165
|
+
* the viewport.
|
|
1166
|
+
*/
|
|
1167
|
+
placement?: "bottom-right" | "bottom-left" | "top-right" | "top-left";
|
|
1168
|
+
}
|
|
1169
|
+
interface UserMenuItem {
|
|
1170
|
+
label: ReactNode;
|
|
1171
|
+
href?: string;
|
|
1172
|
+
onClick?: () => void;
|
|
1173
|
+
/** Open `href` in a new tab. Default: false. */
|
|
1174
|
+
external?: boolean;
|
|
1175
|
+
}
|
|
1176
|
+
declare function UserMenu<T extends BaseMe = BaseMe>({ me, avatarSize, myInfoHref, myInfoLabel, onSignOut, signOutLabel, signedOutAction, extraItems, className, showAdminBadge, placement, }: UserMenuProps<T>): react.JSX.Element;
|
|
1177
|
+
|
|
1178
|
+
interface MobileTabBarItem {
|
|
1179
|
+
/** Stable key used for React reconciliation. */
|
|
1180
|
+
id: string;
|
|
1181
|
+
/** Short label shown under the icon. Keep ~5 chars to fit 5 tabs on a 360px viewport. */
|
|
1182
|
+
label: ReactNode;
|
|
1183
|
+
/** Icon node — typically a lucide-react `<Home size={24} />` or an inline SVG. */
|
|
1184
|
+
icon: ReactNode;
|
|
1185
|
+
/** Whether this tab is the current one. The caller computes this from its route. */
|
|
1186
|
+
active?: boolean;
|
|
1187
|
+
/** Click handler. Use this for SPA-style in-app navigation. */
|
|
1188
|
+
onClick?: () => void;
|
|
1189
|
+
/** Link target. Renders an `<a>` instead of a `<button>`. */
|
|
1190
|
+
href?: string;
|
|
1191
|
+
}
|
|
1192
|
+
interface MobileTabBarProps {
|
|
1193
|
+
items: MobileTabBarItem[];
|
|
1194
|
+
/** ARIA label for the nav landmark. Default: "주요 메뉴". */
|
|
1195
|
+
ariaLabel?: string;
|
|
1196
|
+
/** Extra class merged with `etu-mobile-tab-bar`. */
|
|
1197
|
+
className?: string;
|
|
1198
|
+
}
|
|
1199
|
+
declare function MobileTabBar({ items, ariaLabel, className }: MobileTabBarProps): react.JSX.Element;
|
|
1200
|
+
|
|
1201
|
+
interface NotificationBellItem {
|
|
1202
|
+
id: string;
|
|
1203
|
+
/** Rendered as a single row in the panel. */
|
|
1204
|
+
content: ReactNode;
|
|
1205
|
+
}
|
|
1206
|
+
interface NotificationBellProps {
|
|
1207
|
+
items: NotificationBellItem[];
|
|
1208
|
+
/**
|
|
1209
|
+
* Override the badge count. Defaults to `items.length`. Pass when the
|
|
1210
|
+
* server-side pending count is higher than what's currently rendered
|
|
1211
|
+
* (paginated list, partial fetch).
|
|
1212
|
+
*/
|
|
1213
|
+
count?: number;
|
|
1214
|
+
/** Called when the panel opens — use to refresh `items`. */
|
|
1215
|
+
onOpen?: () => void;
|
|
1216
|
+
/** Aria label for the trigger button. Default: `"알림"`. */
|
|
1217
|
+
ariaLabel?: string;
|
|
1218
|
+
/** Title shown at the top of the panel. Default: `"알림"`. */
|
|
1219
|
+
title?: ReactNode;
|
|
1220
|
+
/** Empty-state body when `items` is empty. Default: `"새 알림이 없습니다."`. */
|
|
1221
|
+
emptyMessage?: ReactNode;
|
|
1222
|
+
/** Optional footer — typically a "View all" link or "Mark all read". */
|
|
1223
|
+
footer?: ReactNode;
|
|
1224
|
+
/** Custom trigger icon — defaults to a bell SVG. */
|
|
1225
|
+
icon?: ReactNode;
|
|
1226
|
+
/** Extra class merged with `etu-notif-bell`. */
|
|
1227
|
+
className?: string;
|
|
1228
|
+
/**
|
|
1229
|
+
* Preferred placement of the desktop dropdown. Default: `"bottom-right"`.
|
|
1230
|
+
* Auto-flips on open when the requested side doesn't fit, same contract
|
|
1231
|
+
* as `<UserMenu>`. Ignored on mobile (always bottom sheet).
|
|
1232
|
+
*/
|
|
1233
|
+
placement?: "bottom-right" | "bottom-left" | "top-right" | "top-left";
|
|
1234
|
+
}
|
|
1235
|
+
declare function NotificationBell({ items, count, onOpen, ariaLabel, title, emptyMessage, footer, icon, className, placement, }: NotificationBellProps): react.JSX.Element;
|
|
1236
|
+
|
|
1237
|
+
interface SidebarItem {
|
|
1238
|
+
/** Stable key used for React reconciliation. */
|
|
1239
|
+
id: string;
|
|
1240
|
+
/** Display label. */
|
|
1241
|
+
label: ReactNode;
|
|
1242
|
+
/** Icon node — typically a lucide-react icon. */
|
|
1243
|
+
icon?: ReactNode;
|
|
1244
|
+
/** Whether this item is the current route. Caller computes from its router. */
|
|
1245
|
+
active?: boolean;
|
|
1246
|
+
/** Click handler for SPA-style navigation. */
|
|
1247
|
+
onClick?: () => void;
|
|
1248
|
+
/** Link target. Renders an `<a>` instead of a `<button>`. */
|
|
1249
|
+
href?: string;
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Captioned secondary subsection — used by large apps whose `/more` content
|
|
1253
|
+
* grows past ~6 rows and benefits from concern-based grouping
|
|
1254
|
+
* (`OPERATE / INVENTORY / GOVERNANCE`, …). Each group renders as its own
|
|
1255
|
+
* `<nav>` with an optional caption header above the items. Within a section
|
|
1256
|
+
* items are still frequency-ordered.
|
|
1257
|
+
*/
|
|
1258
|
+
interface SidebarSecondarySection {
|
|
1259
|
+
/** Stable key used for React reconciliation. Falls back to array index. */
|
|
1260
|
+
id?: string;
|
|
1261
|
+
/** Caption text shown above the section's items. Omit for a header-less group. */
|
|
1262
|
+
caption?: ReactNode;
|
|
1263
|
+
/** Items in this section — same row markup as the flat secondary list. */
|
|
1264
|
+
items: SidebarItem[];
|
|
1265
|
+
}
|
|
1266
|
+
interface SidebarProps {
|
|
1267
|
+
/** App display name shown in the header (e.g. "schedule-manager", "🎪 Festplan"). */
|
|
1268
|
+
appName?: ReactNode;
|
|
1269
|
+
/** Optional logo / icon node to the left of the name. */
|
|
1270
|
+
appIcon?: ReactNode;
|
|
1271
|
+
/** Optional element rendered under the app name (org switcher, plan badge, …). */
|
|
1272
|
+
appHeaderExtra?: ReactNode;
|
|
1273
|
+
/**
|
|
1274
|
+
* Primary destinations — mirror the same array that feeds
|
|
1275
|
+
* `<MobileTabBar items={primary.slice(0, 4).concat([moreTab])} />`.
|
|
1276
|
+
*/
|
|
1277
|
+
primary: SidebarItem[];
|
|
1278
|
+
/**
|
|
1279
|
+
* Secondary destinations as a flat list — mirror the array that feeds the
|
|
1280
|
+
* `/more` page (Settings, Admin, …). Suitable for small apps. Pass an empty
|
|
1281
|
+
* array if the app has no secondary nav. When `secondarySections` is also
|
|
1282
|
+
* supplied, `secondarySections` wins and this prop is ignored (a dev-only
|
|
1283
|
+
* `console.warn` is emitted so consumers notice the override).
|
|
1284
|
+
*/
|
|
1285
|
+
secondary?: SidebarItem[];
|
|
1286
|
+
/**
|
|
1287
|
+
* Secondary destinations grouped into captioned subsections — for large apps
|
|
1288
|
+
* whose `/more` grows past ~6 rows. Each group is concern-based
|
|
1289
|
+
* (`OPERATE / INVENTORY / GOVERNANCE`, …) and renders as a separate `<nav>`
|
|
1290
|
+
* with an optional caption header. Within a section items are still
|
|
1291
|
+
* frequency-ordered. Overrides `secondary` when both are supplied.
|
|
1292
|
+
* The same array shape drives the mobile `/more` drill-down rows; see
|
|
1293
|
+
* `planning/wiki/concepts/sidebar-composition.md` for the contract.
|
|
1294
|
+
*/
|
|
1295
|
+
secondarySections?: SidebarSecondarySection[];
|
|
1296
|
+
/**
|
|
1297
|
+
* Caption above the flat secondary list. Default: "더보기". Pass `null` to
|
|
1298
|
+
* omit. Applies only when `secondary` is rendered — `secondarySections`
|
|
1299
|
+
* carry their own captions per group.
|
|
1300
|
+
*/
|
|
1301
|
+
secondaryCaption?: ReactNode | null;
|
|
1302
|
+
/**
|
|
1303
|
+
* Footer node — identity + Logout + build info. The convention is to
|
|
1304
|
+
* render an inline `<AppInfoSection>`-style identity row plus a
|
|
1305
|
+
* `[Logout]` button; `<DeployInfo>` may go inline at the very bottom.
|
|
1306
|
+
* Pass `null` for an anonymous shell (login routes, public-only hosts).
|
|
1307
|
+
*/
|
|
1308
|
+
footer?: ReactNode;
|
|
1309
|
+
/** ARIA label for the nav landmark. Default: "주 메뉴". */
|
|
1310
|
+
ariaLabel?: string;
|
|
1311
|
+
/** Extra class merged with `etu-sidebar`. */
|
|
1312
|
+
className?: string;
|
|
1313
|
+
/**
|
|
1314
|
+
* Behavior at the tablet tier (720–1023px). Default: `"rail"`.
|
|
1315
|
+
* - "rail" icon-only ~64px rail
|
|
1316
|
+
* - "drawer" hidden until `open` is true; mount `<SidebarToggle>` in
|
|
1317
|
+
* your app bar to flip it
|
|
1318
|
+
* - "full" v0.27 behavior — full 240px sidebar at all ≥720px widths
|
|
1319
|
+
* Has no effect at the mobile (<720) or desktop (≥1024) tier.
|
|
1320
|
+
*/
|
|
1321
|
+
tabletMode?: "rail" | "drawer" | "full";
|
|
1322
|
+
/**
|
|
1323
|
+
* Drawer open state (drawer mode only). Controlled — pair with
|
|
1324
|
+
* `onOpenChange`. Auto-flips to `false` when the route changes (the
|
|
1325
|
+
* parent component is expected to call `onOpenChange(false)` after
|
|
1326
|
+
* navigation; or use the built-in `<SidebarToggle>` helper which wires
|
|
1327
|
+
* this up). Ignored unless `tabletMode === "drawer"`.
|
|
1328
|
+
*/
|
|
1329
|
+
open?: boolean;
|
|
1330
|
+
onOpenChange?: (open: boolean) => void;
|
|
1331
|
+
}
|
|
1332
|
+
declare function Sidebar({ appName, appIcon, appHeaderExtra, primary, secondary, secondarySections, secondaryCaption, footer, ariaLabel, className, tabletMode, open, onOpenChange, }: SidebarProps): react.JSX.Element;
|
|
1333
|
+
interface SidebarToggleProps {
|
|
1334
|
+
/** Whether the drawer is currently open. */
|
|
1335
|
+
open: boolean;
|
|
1336
|
+
onOpenChange: (open: boolean) => void;
|
|
1337
|
+
/** ARIA label. Default: "메뉴 열기" / "메뉴 닫기" via `labelOpen`/`labelClose`. */
|
|
1338
|
+
labelOpen?: string;
|
|
1339
|
+
labelClose?: string;
|
|
1340
|
+
/** Extra class merged with `etu-sidebar-toggle`. */
|
|
1341
|
+
className?: string;
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Hamburger button that flips the `<Sidebar tabletMode="drawer">` open
|
|
1345
|
+
* state. Visible only at the tablet tier (≥720 and <1024). Mobile users
|
|
1346
|
+
* use `<MobileTabBar>`; desktop users see the full sidebar already.
|
|
1347
|
+
*/
|
|
1348
|
+
declare function SidebarToggle({ open, onOpenChange, labelOpen, labelClose, className, }: SidebarToggleProps): react.JSX.Element;
|
|
1349
|
+
/**
|
|
1350
|
+
* State hook for the drawer mode. Persists open/closed in sessionStorage
|
|
1351
|
+
* (not localStorage — drawer state is per-session, not a preference) and
|
|
1352
|
+
* auto-closes when `routeKey` changes (pass your current path/hash).
|
|
1353
|
+
*/
|
|
1354
|
+
declare function useSidebarDrawer(appKey: string, routeKey?: string): [boolean, (open: boolean) => void];
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* React-free i18n primitives — safe to import from `helpers.ts`, server
|
|
1358
|
+
* runtimes, or the index.html-adjacent script that emits the no-flash
|
|
1359
|
+
* snippet. The React provider/hooks live in `i18n.tsx` and re-export
|
|
1360
|
+
* everything from here.
|
|
1361
|
+
*/
|
|
1362
|
+
type Locale = "ko" | "en";
|
|
1363
|
+
declare const SUPPORTED_LOCALES: Locale[];
|
|
1364
|
+
type Messages = Record<string, string>;
|
|
1365
|
+
type MessageBundle = Record<Locale, Messages>;
|
|
1366
|
+
/** Read the active locale (saved choice → system → "en"). */
|
|
1367
|
+
declare function getLocale(appKey: string): Locale;
|
|
1368
|
+
/** Persist and apply a locale to <html lang>. */
|
|
1369
|
+
declare function setLocale(appKey: string, locale: Locale): void;
|
|
1370
|
+
/**
|
|
1371
|
+
* <head> snippet (no deps) that sets <html lang> from the saved choice,
|
|
1372
|
+
* falling back to system languages, then "en".
|
|
1373
|
+
*/
|
|
1374
|
+
declare function noFlashLocaleScript(appKey: string): string;
|
|
1375
|
+
/** Render `{name}` placeholders. Unknown placeholders pass through. */
|
|
1376
|
+
declare function interpolate(template: string, vars?: Record<string, string | number>): string;
|
|
1377
|
+
|
|
1378
|
+
interface I18nContextValue {
|
|
1379
|
+
locale: Locale;
|
|
1380
|
+
setLocale: (next: Locale) => void;
|
|
1381
|
+
t: (key: string, vars?: Record<string, string | number>) => string;
|
|
1382
|
+
}
|
|
1383
|
+
interface I18nProviderProps {
|
|
1384
|
+
/** Per-app namespace key — same one you pass to `<ThemeProvider>`. */
|
|
1385
|
+
appKey: string;
|
|
1386
|
+
/** Localized messages keyed by locale. EN entries act as the fallback. */
|
|
1387
|
+
messages: MessageBundle;
|
|
1388
|
+
children: ReactNode;
|
|
1389
|
+
}
|
|
1390
|
+
declare function I18nProvider({ appKey, messages, children }: I18nProviderProps): react.JSX.Element;
|
|
1391
|
+
/** Returns the active `t(key, vars?)` translator + current locale + setter. */
|
|
1392
|
+
declare function useI18n(): I18nContextValue;
|
|
1393
|
+
/** Convenience: just the translator. Equivalent to `useI18n().t`. */
|
|
1394
|
+
declare function useT(): I18nContextValue["t"];
|
|
1395
|
+
/** Convenience: just `[locale, setLocale]`. */
|
|
1396
|
+
declare function useLocale(): [Locale, (next: Locale) => void];
|
|
1397
|
+
|
|
1398
|
+
/**
|
|
1399
|
+
* React-free viewport primitives — safe to import from `helpers.ts`.
|
|
1400
|
+
* React provider/hook lives in `viewport.tsx`.
|
|
1401
|
+
*/
|
|
1402
|
+
type ViewportTier = "mobile" | "tablet" | "desktop";
|
|
1403
|
+
declare const TABLET_MIN = 720;
|
|
1404
|
+
declare const DESKTOP_MIN = 1024;
|
|
1405
|
+
/** SSR-safe — returns "desktop" on the server. */
|
|
1406
|
+
declare function getViewport(): ViewportTier;
|
|
1407
|
+
declare const noFlashViewportScript: string;
|
|
1408
|
+
|
|
1409
|
+
declare function ViewportProvider({ children }: {
|
|
1410
|
+
children: ReactNode;
|
|
1411
|
+
}): react.JSX.Element;
|
|
1412
|
+
declare function useViewport(): ViewportTier;
|
|
1413
|
+
|
|
1414
|
+
interface NavigationBarProps {
|
|
1415
|
+
title: string;
|
|
1416
|
+
back?: boolean | string | (() => void);
|
|
1417
|
+
/** Default: "뒤로". */
|
|
1418
|
+
backLabel?: string;
|
|
1419
|
+
leading?: ReactNode;
|
|
1420
|
+
trailing?: ReactNode;
|
|
1421
|
+
/** Default: true. When true, sticks to top + applies safe-area-inset-top. */
|
|
1422
|
+
sticky?: boolean;
|
|
1423
|
+
/**
|
|
1424
|
+
* Default: true. When false, the sticky bar omits `padding-top:
|
|
1425
|
+
* env(safe-area-inset-top)` — for apps that already have a global top
|
|
1426
|
+
* chrome bar (brand/avatar/bell row) above the per-page nav. Avoids
|
|
1427
|
+
* double safe-area stacking that pushed iOS-PWA page titles below the
|
|
1428
|
+
* visible viewport on notched devices.
|
|
1429
|
+
*/
|
|
1430
|
+
safeAreaTop?: boolean;
|
|
1431
|
+
/** Default: false. When true, omits the bottom hairline border. */
|
|
1432
|
+
borderless?: boolean;
|
|
1433
|
+
/**
|
|
1434
|
+
* Default: true. Bar starts at 0.8 opacity and strengthens to 1 with a
|
|
1435
|
+
* heavier shadow after the page scrolls past 24px. Pure-CSS via the
|
|
1436
|
+
* `etu-navbar--scrolled` class toggled on a scroll listener.
|
|
1437
|
+
*/
|
|
1438
|
+
fadeOnScroll?: boolean;
|
|
1439
|
+
/** ARIA label for the inner <nav>. Default: title. */
|
|
1440
|
+
ariaLabel?: string;
|
|
1441
|
+
/** Extra class merged onto `etu-navbar`. */
|
|
1442
|
+
className?: string;
|
|
1443
|
+
}
|
|
1444
|
+
declare function NavigationBar({ title, back, backLabel, leading, trailing, sticky, safeAreaTop, borderless, fadeOnScroll, ariaLabel, className, }: NavigationBarProps): react.JSX.Element;
|
|
1445
|
+
|
|
1446
|
+
/**
|
|
1447
|
+
* Tag the document with PWA / iOS-PWA classes and harden the iOS standalone
|
|
1448
|
+
* shell against the two recurring complaints:
|
|
1449
|
+
*
|
|
1450
|
+
* - Korean body text "shrinks" when the app is launched from the home screen
|
|
1451
|
+
* because iOS's text-size-adjust kicks in without the Safari toolbar. The
|
|
1452
|
+
* `@etamong-playground/ui/styles.css` reset locks this declaratively; this helper
|
|
1453
|
+
* re-applies the lock via an explicit inline style, defensively, for hosts
|
|
1454
|
+
* that mount their own stylesheet after ours.
|
|
1455
|
+
* - Tapping a `<input>` whose font-size is below 16px triggers a zoom that
|
|
1456
|
+
* never zooms back out. Opt-in: set `<html data-etu-lock-zoom>` and we set
|
|
1457
|
+
* `maximum-scale=1` on the viewport meta in iOS PWA mode.
|
|
1458
|
+
*
|
|
1459
|
+
* Call once from app bootstrap (before or after first paint, both fine — it's
|
|
1460
|
+
* idempotent). No React; safe from any client-side entry.
|
|
1461
|
+
*/
|
|
1462
|
+
declare function installIOSPwaShell(): void;
|
|
1463
|
+
|
|
1464
|
+
/** Production legal hub host. Override via `manifestUrl` / `hubBaseUrl` props. */
|
|
1465
|
+
declare const LEGAL_HUB_BASE_URL = "https://legal.m.etamong.com";
|
|
1466
|
+
/** Default manifest endpoint. Override per-prop for tests / staging. */
|
|
1467
|
+
declare const LEGAL_MANIFEST_URL = "https://legal.m.etamong.com/api/public-manifest";
|
|
1468
|
+
/** Recognized L2 doc kinds. The hub may add more; unknown kinds render with a humanized label. */
|
|
1469
|
+
type LegalKind = "terms" | "privacy" | (string & {});
|
|
1470
|
+
interface LegalAvailability {
|
|
1471
|
+
/** Doc kinds the hub reports as published for this app, in fixed render order. */
|
|
1472
|
+
kinds: LegalKind[];
|
|
1473
|
+
/** "loaded" = fresh fetch; "stale" = cached over the soft TTL; "loading" = no value yet; "error" = no cached fallback either. */
|
|
1474
|
+
status: "loaded" | "stale" | "loading" | "error";
|
|
1475
|
+
/** Force a re-fetch (e.g. after the operator publishes a new doc). */
|
|
1476
|
+
refresh: () => void;
|
|
1477
|
+
}
|
|
1478
|
+
interface UseLegalAvailabilityOptions {
|
|
1479
|
+
/** Override the manifest URL — useful for tests or self-hosted hubs. */
|
|
1480
|
+
manifestUrl?: string;
|
|
1481
|
+
/** Disable the network fetch (e.g. SSR / Storybook). The hook returns `{kinds: [], status: "loading"}`. */
|
|
1482
|
+
disabled?: boolean;
|
|
1483
|
+
}
|
|
1484
|
+
declare function useLegalAvailability(appSlug: string, options?: UseLegalAvailabilityOptions): LegalAvailability;
|
|
1485
|
+
interface LegalRowProps {
|
|
1486
|
+
icon: ReactNode;
|
|
1487
|
+
label: ReactNode;
|
|
1488
|
+
/** `›` for in-app nav, `↗` for external. Default chosen from `external`. */
|
|
1489
|
+
trailing?: ReactNode;
|
|
1490
|
+
href: string;
|
|
1491
|
+
/** When set, called instead of the default link navigation. */
|
|
1492
|
+
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
|
|
1493
|
+
/** When true, opens the link in a new tab with `rel="noopener noreferrer"`. */
|
|
1494
|
+
external?: boolean;
|
|
1495
|
+
/**
|
|
1496
|
+
* When `false` (default), the row renders a top divider (matches "second-row-onwards"
|
|
1497
|
+
* inside a multi-row card). Set `true` for the first row of a card to suppress it.
|
|
1498
|
+
* `<LegalMenuItem>` and `<LegalPage>` set this automatically.
|
|
1499
|
+
*/
|
|
1500
|
+
isFirst?: boolean;
|
|
1501
|
+
}
|
|
1502
|
+
/** A single row inside an `.etu-legal-card`. Use when you want to compose your own
|
|
1503
|
+
* card with a `<LegalMenuItem>` and additional rows (e.g. 문의하기 mailto). */
|
|
1504
|
+
declare function LegalRow({ icon, label, trailing, href, onClick, external, isFirst, }: LegalRowProps): react.JSX.Element;
|
|
1505
|
+
interface LegalMenuItemProps {
|
|
1506
|
+
/** Hub `serviceId` — the codename slug, e.g. `alert-ops`, `xatu`. */
|
|
1507
|
+
appSlug: string;
|
|
1508
|
+
/** Route the in-app `/more/legal` page lives at. Default `/more/legal`. */
|
|
1509
|
+
to?: string;
|
|
1510
|
+
/** SPA navigation handler. Called instead of full-page navigation. */
|
|
1511
|
+
onNavigate?: (to: string) => void;
|
|
1512
|
+
/** Override the leading emoji / icon. Pass `null` to omit. */
|
|
1513
|
+
icon?: ReactNode | null;
|
|
1514
|
+
/** Override the row label. Default: `법률 정보`. */
|
|
1515
|
+
label?: ReactNode;
|
|
1516
|
+
/**
|
|
1517
|
+
* Set `true` when this is the first row inside its enclosing
|
|
1518
|
+
* `.etu-legal-card` (suppresses the top divider). Default `false` — the row
|
|
1519
|
+
* draws a top border so it sits cleanly under sibling rows like 문의하기.
|
|
1520
|
+
*/
|
|
1521
|
+
isFirst?: boolean;
|
|
1522
|
+
}
|
|
1523
|
+
/**
|
|
1524
|
+
* `법률 정보 ›` row. Renders only the row — the caller wraps it (and any
|
|
1525
|
+
* sibling rows like 문의하기) in an `.etu-legal-card` so the legal entry
|
|
1526
|
+
* shares the same visual card as adjacent contact/help rows (matches
|
|
1527
|
+
* planning concepts/mobile-more-page diagram).
|
|
1528
|
+
*/
|
|
1529
|
+
declare function LegalMenuItem({ appSlug: _appSlug, to, onNavigate, icon, label, isFirst, }: LegalMenuItemProps): react.JSX.Element;
|
|
1530
|
+
interface LegalPageProps {
|
|
1531
|
+
/** Hub `serviceId` — the codename slug, e.g. `alert-ops`, `xatu`. */
|
|
1532
|
+
appSlug: string;
|
|
1533
|
+
/** Override the L1 identity row label. Default: `로그인 정책`. */
|
|
1534
|
+
identityLabel?: ReactNode;
|
|
1535
|
+
/** Override the legal hub base URL (e.g. for staging). */
|
|
1536
|
+
hubBaseUrl?: string;
|
|
1537
|
+
/** Override per-kind row labels (extends the defaults: terms → 이용약관, privacy → 개인정보처리방침). */
|
|
1538
|
+
kindLabels?: Record<string, ReactNode>;
|
|
1539
|
+
/** Override anchor for a specific (kind) — by default `<appSlug>-<kind>`. */
|
|
1540
|
+
anchorOverride?: Partial<Record<string, string>>;
|
|
1541
|
+
/** Forwarded to `useLegalAvailability`. */
|
|
1542
|
+
manifestUrl?: string;
|
|
1543
|
+
/** Empty-state line shown when the manifest reports zero L2 kinds. */
|
|
1544
|
+
emptyMessage?: ReactNode;
|
|
1545
|
+
/** Extra class merged with the wrapping card. */
|
|
1546
|
+
className?: string;
|
|
1547
|
+
}
|
|
1548
|
+
declare function LegalPage({ appSlug, identityLabel, hubBaseUrl, kindLabels, anchorOverride, manifestUrl, emptyMessage, className, }: LegalPageProps): react.JSX.Element;
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* PolicyChangeBanner — a hub-driven amber banner that surfaces upcoming
|
|
1552
|
+
* legal-document changes for the current app. Fetches `/status.json` from
|
|
1553
|
+
* the legal hub, filters to this app's service ID, and renders a
|
|
1554
|
+
* dismissible full-width strip linking to the hub page.
|
|
1555
|
+
*
|
|
1556
|
+
* Renders null when nothing is pending, the hub is unreachable, or the
|
|
1557
|
+
* current pending set was already dismissed.
|
|
1558
|
+
*/
|
|
1559
|
+
interface PolicyChangeBannerProps {
|
|
1560
|
+
/** Hub `serviceId` — the codename slug, e.g. `"schedule-manager"`. */
|
|
1561
|
+
appSlug: string;
|
|
1562
|
+
/** Display language. Default `"ko"`. */
|
|
1563
|
+
locale?: "ko" | "en";
|
|
1564
|
+
/** Override the legal hub origin. Default `LEGAL_HUB_BASE_URL`. */
|
|
1565
|
+
hubBaseUrl?: string;
|
|
1566
|
+
/**
|
|
1567
|
+
* Router-agnostic link renderer. Default: `<a target="_blank" rel="noopener noreferrer">`.
|
|
1568
|
+
* Pass your framework's `<Link>` component when the hub is same-origin.
|
|
1569
|
+
*/
|
|
1570
|
+
renderLink?: (href: string, children: React.ReactNode) => React.ReactNode;
|
|
1571
|
+
/** Extra class merged with the outer banner element. */
|
|
1572
|
+
className?: string;
|
|
1573
|
+
}
|
|
1574
|
+
declare function PolicyChangeBanner({ appSlug, locale, hubBaseUrl, renderLink, className, }: PolicyChangeBannerProps): react.JSX.Element | null;
|
|
1575
|
+
|
|
1576
|
+
/**
|
|
1577
|
+
* `<DataTable>` — house-pattern tabular display per
|
|
1578
|
+
* `etamong-lab/planning` wiki concept `tabular-fit-and-nowrap.md`.
|
|
1579
|
+
*
|
|
1580
|
+
* At wide viewports renders a real `<table>`. Below the configured
|
|
1581
|
+
* breakpoint (default 720px), or whenever the carrier element is
|
|
1582
|
+
* narrower than that, the rows collapse to one stacked card each
|
|
1583
|
+
* (the column header becomes a label, the cell value the value).
|
|
1584
|
+
* Never `overflow-x: auto`. Every column stays visible at every
|
|
1585
|
+
* viewport.
|
|
1586
|
+
*
|
|
1587
|
+
* <DataTable
|
|
1588
|
+
* columns={[
|
|
1589
|
+
* { key: "name", label: "App", nowrap: true,
|
|
1590
|
+
* render: a => <a href={...}>{a.slug}</a> },
|
|
1591
|
+
* { key: "version", label: "Version", nowrap: true },
|
|
1592
|
+
* { key: "built", label: "Built", nowrap: true,
|
|
1593
|
+
* render: a => a.builtAt ?? "—" },
|
|
1594
|
+
* ]}
|
|
1595
|
+
* rows={apps}
|
|
1596
|
+
* rowKey={a => a.slug}
|
|
1597
|
+
* primaryColumn="name"
|
|
1598
|
+
* />
|
|
1599
|
+
*
|
|
1600
|
+
* `primaryColumn` (default = first column) is the row's identifying
|
|
1601
|
+
* field. In wide mode it's just the first cell; in card mode it's
|
|
1602
|
+
* the card header (rendered without the label).
|
|
1603
|
+
*/
|
|
1604
|
+
|
|
1605
|
+
interface DataTableColumn<R> {
|
|
1606
|
+
/** Stable key — used for React keys and the optional render fallback. */
|
|
1607
|
+
key: string;
|
|
1608
|
+
/** Header label / card-row label. */
|
|
1609
|
+
label: ReactNode;
|
|
1610
|
+
/** Apply `white-space: nowrap` to this column's `<td>` and to the value side of the stacked card. Default false. */
|
|
1611
|
+
nowrap?: boolean;
|
|
1612
|
+
/** Render the cell for this row. Default: read `row[key]` if it's a primitive. */
|
|
1613
|
+
render?: (row: R) => ReactNode;
|
|
1614
|
+
/** Hide the column entirely (wide and narrow). Useful for conditional surfaces. */
|
|
1615
|
+
hidden?: boolean;
|
|
1616
|
+
/** Hide in narrow card mode only (e.g. action buttons that live in a separate row). */
|
|
1617
|
+
hiddenNarrow?: boolean;
|
|
1618
|
+
/** Hide in wide table mode only — rare; for fields that only make sense in the card view. */
|
|
1619
|
+
hiddenWide?: boolean;
|
|
1620
|
+
/** Optional `<th>` width hint (CSS value). */
|
|
1621
|
+
width?: string;
|
|
1622
|
+
/** Optional alignment. Default left. */
|
|
1623
|
+
align?: "left" | "right" | "center";
|
|
1624
|
+
/** Extra class merged onto both `<th>` and `<td>` for this column. */
|
|
1625
|
+
className?: string;
|
|
1626
|
+
}
|
|
1627
|
+
interface DataTableProps<R> {
|
|
1628
|
+
columns: DataTableColumn<R>[];
|
|
1629
|
+
rows: R[];
|
|
1630
|
+
rowKey: (row: R, index: number) => string | number;
|
|
1631
|
+
/** Column key whose value is the row's primary identifier (appears as the card header). Defaults to the first non-hidden column. */
|
|
1632
|
+
primaryColumn?: string;
|
|
1633
|
+
/** Optional below-card action row (buttons). Hidden in wide mode unless rendered as a column. */
|
|
1634
|
+
rowActions?: (row: R) => ReactNode;
|
|
1635
|
+
/** Shown when `rows` is empty. */
|
|
1636
|
+
emptyState?: ReactNode;
|
|
1637
|
+
/** Extra class merged onto the outer wrapper. */
|
|
1638
|
+
className?: string;
|
|
1639
|
+
/** Force a mode. Default: container-query driven (`auto`). */
|
|
1640
|
+
mode?: "auto" | "wide" | "cards";
|
|
1641
|
+
}
|
|
1642
|
+
declare function DataTable<R>(props: DataTableProps<R>): ReactNode;
|
|
1643
|
+
|
|
1644
|
+
interface DocsHubSection {
|
|
1645
|
+
/** Stable key used in the left nav + as the section anchor. */
|
|
1646
|
+
id: string;
|
|
1647
|
+
/** Sidebar label. */
|
|
1648
|
+
label: ReactNode;
|
|
1649
|
+
/** Optional one-line summary shown under the section heading. */
|
|
1650
|
+
summary?: ReactNode;
|
|
1651
|
+
/** Body content — apps supply their own JSX (or rendered markdown). */
|
|
1652
|
+
content: ReactNode;
|
|
1653
|
+
}
|
|
1654
|
+
interface DocsHubSkill {
|
|
1655
|
+
/** Skill slug — used as the download filename `<name>.md`. */
|
|
1656
|
+
name: string;
|
|
1657
|
+
/** One-line description (skill frontmatter `description:`). */
|
|
1658
|
+
description: string;
|
|
1659
|
+
/** The skill body in markdown. */
|
|
1660
|
+
body: string;
|
|
1661
|
+
/**
|
|
1662
|
+
* Stable public URL serving the same skill markdown bytes as the
|
|
1663
|
+
* download button. Convention: `https://<app>.m.etamong.com/skill.md`.
|
|
1664
|
+
* When set, DocsHub shows a `curl … -o ~/.claude/skills/<slug>/SKILL.md`
|
|
1665
|
+
* one-liner as the primary install path (download stays as fallback).
|
|
1666
|
+
* The endpoint must be unauthenticated and return `text/markdown`.
|
|
1667
|
+
* See wiki/concepts/docs-hub.md.
|
|
1668
|
+
*/
|
|
1669
|
+
publicUrl?: string;
|
|
1670
|
+
/** Optional CTA label override. Default: "📥 Claude skill 받기". */
|
|
1671
|
+
buttonLabel?: ReactNode;
|
|
1672
|
+
/**
|
|
1673
|
+
* Override or hide the auto-appended "Claude skill 사용법" section.
|
|
1674
|
+
* - `undefined` (default): DocsHub appends a standard usage section
|
|
1675
|
+
* (Claude Code, Codex, plain LLM context).
|
|
1676
|
+
* - A custom `DocsHubSection`: use that instead.
|
|
1677
|
+
* - `null`: skip the auto-section entirely.
|
|
1678
|
+
*/
|
|
1679
|
+
usageSection?: DocsHubSection | null;
|
|
1680
|
+
}
|
|
1681
|
+
interface DocsHubProps {
|
|
1682
|
+
/** App display name shown in the page head (left of the skill button). */
|
|
1683
|
+
appName?: ReactNode;
|
|
1684
|
+
/** Optional sub-heading under `appName`. */
|
|
1685
|
+
description?: ReactNode;
|
|
1686
|
+
/** Section list. At least one section. */
|
|
1687
|
+
sections: DocsHubSection[];
|
|
1688
|
+
/** Skill artifact downloaded by the top-right button. Omit to hide it. */
|
|
1689
|
+
skill?: DocsHubSkill;
|
|
1690
|
+
/** Controlled active section. Pair with `onSectionChange`. */
|
|
1691
|
+
sectionId?: string;
|
|
1692
|
+
/** Active-section callback (controlled mode). */
|
|
1693
|
+
onSectionChange?: (id: string) => void;
|
|
1694
|
+
/** Initial section id (uncontrolled). Default: first section. */
|
|
1695
|
+
defaultSectionId?: string;
|
|
1696
|
+
/** Extra class merged with `etu-docs-hub`. */
|
|
1697
|
+
className?: string;
|
|
1698
|
+
}
|
|
1699
|
+
declare function DocsHub({ appName, description, sections, skill, sectionId, onSectionChange, defaultSectionId, className, }: DocsHubProps): react.JSX.Element;
|
|
1700
|
+
/** Exported for apps that want to render or share the skill markdown without
|
|
1701
|
+
* triggering a download (e.g., a "copy to clipboard" affordance). */
|
|
1702
|
+
declare function buildSkillMarkdownText(skill: DocsHubSkill): string;
|
|
1703
|
+
|
|
1704
|
+
/**
|
|
1705
|
+
* Fleet-auth primitives — implements the route contract documented at
|
|
1706
|
+
* `planning/wiki/concepts/fleet-auth.md` (planning#252).
|
|
1707
|
+
*
|
|
1708
|
+
* GET /auth/login?rd=<path> OIDC authorization
|
|
1709
|
+
* GET /auth/callback token exchange + session cookie
|
|
1710
|
+
* GET /auth/logout clear session
|
|
1711
|
+
* GET /api/me { sub, email, name, groups }
|
|
1712
|
+
*
|
|
1713
|
+
* Apps still on the older `oauth2-proxy` paths (`/oauth2/start`,
|
|
1714
|
+
* `/oauth2/sign_out`) use `useMe()` / `signInUrl()` / `signOutUrl()`
|
|
1715
|
+
* from `./useMe` directly — those keep their legacy default. This
|
|
1716
|
+
* module is the forward path that fleet apps standardise on.
|
|
1717
|
+
*/
|
|
1718
|
+
|
|
1719
|
+
declare const SHARE_CRAWLER_UA_SUBSTRINGS: readonly ["kakaotalk-scrap", "slackbot", "facebookexternalhit", "twitterbot", "discordbot", "linkedinbot", "telegrambot", "whatsapp", "line-poker"];
|
|
1720
|
+
/** Returns true if `ua` matches any advertised share-preview crawler. */
|
|
1721
|
+
declare function isShareCrawler(ua: string | null | undefined): boolean;
|
|
1722
|
+
/** Fleet-contract sign-in URL: `/auth/login?rd=<current or override>`. */
|
|
1723
|
+
declare function fleetLoginUrl(rd?: string): string;
|
|
1724
|
+
/** Fleet-contract sign-out URL: `/auth/logout?rd=<override or "/">`. */
|
|
1725
|
+
declare function fleetLogoutUrl(rd?: string): string;
|
|
1726
|
+
declare function fleetSignIn(rd?: string): void;
|
|
1727
|
+
declare function fleetSignOut(rd?: string): void;
|
|
1728
|
+
/**
|
|
1729
|
+
* Thin wrapper over `useMe()` with fleet defaults — `/api/me`, 401 treated
|
|
1730
|
+
* as anonymous. Returns the same shape plus `signIn`/`signOut` bound to
|
|
1731
|
+
* the fleet contract URLs.
|
|
1732
|
+
*/
|
|
1733
|
+
interface UseIdentityResult<T extends BaseMe> extends UseMeResult<T> {
|
|
1734
|
+
signIn: (rd?: string) => void;
|
|
1735
|
+
signOut: (rd?: string) => void;
|
|
1736
|
+
}
|
|
1737
|
+
declare function useIdentity<T extends BaseMe = BaseMe>(opts?: UseMeOptions<T>): UseIdentityResult<T>;
|
|
1738
|
+
/**
|
|
1739
|
+
* Renders `children` only for authenticated users. Anonymous browser
|
|
1740
|
+
* navigation triggers a `window.location` redirect to the fleet login.
|
|
1741
|
+
* Share-preview crawlers (UA match) and SSR/Next get `children`
|
|
1742
|
+
* unconditionally so the static `<meta og:*>` block remains readable.
|
|
1743
|
+
*/
|
|
1744
|
+
interface AuthGateProps {
|
|
1745
|
+
children: React.ReactNode;
|
|
1746
|
+
/** Override the post-login redirect target. Default = current location. */
|
|
1747
|
+
rd?: string;
|
|
1748
|
+
/** What to show while `/api/me` is in flight. Default = `null`. */
|
|
1749
|
+
loadingFallback?: React.ReactNode;
|
|
1750
|
+
/**
|
|
1751
|
+
* Pre-resolved User-Agent for SSR/Next environments. When supplied and
|
|
1752
|
+
* matching a known crawler, the gate is bypassed and `children` render
|
|
1753
|
+
* directly. In CSR `navigator.userAgent` is used.
|
|
1754
|
+
*/
|
|
1755
|
+
userAgent?: string;
|
|
1756
|
+
/** When true, skip the redirect (render `null` instead). */
|
|
1757
|
+
disableRedirect?: boolean;
|
|
1758
|
+
}
|
|
1759
|
+
declare function AuthGate({ children, rd, loadingFallback, userAgent, disableRedirect, }: AuthGateProps): JSX.Element | null;
|
|
1760
|
+
interface LoginButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onClick"> {
|
|
1761
|
+
/** Override the post-login redirect target. */
|
|
1762
|
+
rd?: string;
|
|
1763
|
+
/** Label override. Default: "Sign in". */
|
|
1764
|
+
label?: React.ReactNode;
|
|
1765
|
+
}
|
|
1766
|
+
declare function LoginButton({ rd, label, style, ...rest }: LoginButtonProps): JSX.Element;
|
|
1767
|
+
interface LogoutButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onClick"> {
|
|
1768
|
+
rd?: string;
|
|
1769
|
+
label?: React.ReactNode;
|
|
1770
|
+
}
|
|
1771
|
+
declare function LogoutButton({ rd, label, style, ...rest }: LogoutButtonProps): JSX.Element;
|
|
1772
|
+
interface SessionBadgeProps {
|
|
1773
|
+
/** Override the identity. By default, `useIdentity()` is used. */
|
|
1774
|
+
me?: BaseMe | null;
|
|
1775
|
+
/** Click target. Default: do nothing; consumers usually navigate to /more. */
|
|
1776
|
+
onClick?: () => void;
|
|
1777
|
+
/** Class hook for app-side overrides. */
|
|
1778
|
+
className?: string;
|
|
1779
|
+
}
|
|
1780
|
+
declare function SessionBadge({ me: overrideMe, onClick, className, }: SessionBadgeProps): JSX.Element | null;
|
|
1781
|
+
/**
|
|
1782
|
+
* Listens for `etu:session-expired` events and mounts a single
|
|
1783
|
+
* dialog directing the user to sign in again. Apps trigger it by
|
|
1784
|
+
* dispatching the event from their XHR error handler when /api/* returns
|
|
1785
|
+
* 401 after the initial `/api/me` succeeded.
|
|
1786
|
+
*/
|
|
1787
|
+
interface SessionExpiredDialogProps {
|
|
1788
|
+
title?: React.ReactNode;
|
|
1789
|
+
message?: React.ReactNode;
|
|
1790
|
+
signInLabel?: React.ReactNode;
|
|
1791
|
+
/** Optional class hook for the dialog root. */
|
|
1792
|
+
className?: string;
|
|
1793
|
+
}
|
|
1794
|
+
declare function SessionExpiredDialog({ title, message, signInLabel, className, }: SessionExpiredDialogProps): JSX.Element | null;
|
|
1795
|
+
/** Dispatch the `etu:session-expired` event. Call from your XHR layer. */
|
|
1796
|
+
declare function notifySessionExpired(): void;
|
|
1797
|
+
/** Dispatch the `etu:me-refresh` event so any mounted `useMe`/`useIdentity` re-fetches. */
|
|
1798
|
+
declare function refreshIdentity(): void;
|
|
1799
|
+
|
|
1800
|
+
export { AdminBadge, type AdminBadgeProps, type AdminCheckInput, AdminGate, type AdminGateProps, type AppInfoLink, AppInfoSection, type AppInfoSectionProps, AuthGate, type AuthGateProps, Avatar, type AvatarProps, BackButton, type BackButtonProps, BackofficeLayout, type BackofficeLayoutProps, type BaseMe, CODE_TO_KEY, COMMAND_PALETTE_OPEN_EVENT, type CommandItem, CommandPalette, type CommandPaletteLabels, type CommandPaletteProps, CommandPaletteTrigger, type CommandPaletteTriggerProps, type CommandSearchAction, type CommandSection, CopyButton, type CopyButtonProps, type CreateFetchOptions, DESKTOP_MIN, DataTable, type DataTableColumn, type DataTableProps, DeployInfo, type DeployInfoProps, DialogHost, DocsHub, type DocsHubProps, type DocsHubSection, type DocsHubSkill, EmptyState, type EmptyStateProps, ErrorPage, type ErrorPageProps, type FetchClient, type FormatAbsTimeOptions, type FormatRelTimeOptions, type GoToOptions, type GoToRoute, HttpError, type HttpErrorBody, I18nProvider, type I18nProviderProps, type InAppBackFallback, InstallBanner, type InstallBannerProps, LEGAL_HUB_BASE_URL, LEGAL_MANIFEST_URL, type LegalAvailability, type LegalKind, LegalMenuItem, type LegalMenuItemProps, LegalPage, type LegalPageProps, LegalRow, type LegalRowProps, type Locale, LoginButton, type LoginButtonProps, LogoutButton, type LogoutButtonProps, type MessageBundle, type Messages, MobileTabBar, type MobileTabBarItem, type MobileTabBarProps, NavigationBar, type NavigationBarProps, type NetworkFirstSwOptions, NotificationBell, type NotificationBellItem, type NotificationBellProps, OpenInBrowserButton, type OpenInBrowserButtonProps, PolicyChangeBanner, type PolicyChangeBannerProps, type RegisterServiceWorkerOptions, RelTime, type RelTimeProps, type RequestOptions, SHARE_CRAWLER_UA_SUBSTRINGS, SUPPORTED_LOCALES, type ServiceWorkerHandle, SessionBadge, type SessionBadgeProps, SessionExpiredDialog, type SessionExpiredDialogProps, Sidebar, type SidebarItem, type SidebarProps, type SidebarSecondarySection, SidebarToggle, type SidebarToggleProps, StatusBanner, type StatusBannerData, type StatusBannerProps, type Severity as StatusBannerSeverity, TABLET_MIN, type Theme, type TimeLike, type ToastItem, type ToastKind, Toaster, type UseClipboardOptions, type UseClipboardResult, type UseIdentityResult, type UseInAppBackOptions, type UseInAppBackResult, type UseLegalAvailabilityOptions, type UseMeOptions, type UseMeResult, type UseRouteStateOptions, type UseSessionStateOptions, type UseStatusBannerOptions, UserMenu, type UserMenuItem, type UserMenuProps, ViewportProvider, type ViewportTier, buildSkillMarkdownText, createFetch, crossLocaleKeywords, dismissToast, fleetLoginUrl, fleetLogoutUrl, fleetSignIn, fleetSignOut, formatAbsTime, formatRelTime, getLocale, getTheme, getViewport, installIOSPwaShell, interpolate, isAdminLike, isInputTarget, isShareCrawler, networkFirstSwSource, noFlashLocaleScript, noFlashThemeScript, noFlashViewportScript, notifySessionExpired, openCommandPalette, refreshIdentity, registerServiceWorker, runInAppBackFallback, setLocale, setTheme, shortcutKey, signIn, signInUrl, signOut, signOutUrl, toast, uiConfirm, uiPrompt, useClipboard, useGoToShortcuts, useI18n, useIdentity, useInAppBack, useInstallPrompt, useLegalAvailability, useLocale, useMe, useRouteState, useSessionState, useSidebarDrawer, useStatusBanner, useT, useViewport };
|