@eifi1/ui-kit 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -6
- package/dist/chart.d.ts +4 -1
- package/dist/components/amount-input.d.ts +4 -1
- package/dist/components/button-group.d.ts +4 -1
- package/dist/components/calculator.d.ts +4 -1
- package/dist/components/copy-button.d.ts +4 -1
- package/dist/components/facing-pair.d.ts +4 -1
- package/dist/components/file-button.d.ts +4 -1
- package/dist/components/file-dropzone.d.ts +4 -1
- package/dist/components/floating-panel.d.ts +4 -1
- package/dist/components/floating-panel.js +15 -1
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/form-actions.d.ts +4 -1
- package/dist/components/legal.d.ts +84 -0
- package/dist/components/legal.js +55 -0
- package/dist/components/legal.js.map +1 -0
- package/dist/components/money-field.d.ts +4 -1
- package/dist/components/number-field.d.ts +4 -1
- package/dist/components/number-input.d.ts +4 -1
- package/dist/components/numpad-sheet.d.ts +4 -1
- package/dist/components/series-chart.d.ts +4 -1
- package/dist/components/server-wake.d.ts +4 -2
- package/dist/components/server-wake.js +23 -2
- package/dist/components/server-wake.js.map +1 -1
- package/dist/components/settings-fields.d.ts +4 -1
- package/dist/components/share-card.d.ts +4 -1
- package/dist/components/text-link.d.ts +4 -1
- package/dist/components/time-input.d.ts +4 -1
- package/dist/components/translation-review-editor.d.ts +56 -0
- package/dist/components/translation-review-editor.js +159 -0
- package/dist/components/translation-review-editor.js.map +1 -0
- package/dist/components/translation-review-labels.d.ts +92 -0
- package/dist/components/translation-review-labels.js +67 -0
- package/dist/components/translation-review-labels.js.map +1 -0
- package/dist/components/translation-review-parts.d.ts +97 -0
- package/dist/components/translation-review-parts.js +163 -0
- package/dist/components/translation-review-parts.js.map +1 -0
- package/dist/components/translation-review.d.ts +94 -0
- package/dist/components/translation-review.js +344 -0
- package/dist/components/translation-review.js.map +1 -0
- package/dist/components/ui.d.ts +4 -1
- package/dist/hooks/use-file-drop.d.ts +4 -1
- package/dist/i18n/defaults.d.ts +4 -1
- package/dist/i18n/defaults.js +5 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +4 -1
- package/dist/i18n/german.js +51 -0
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +4 -1
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.d.ts +243 -0
- package/dist/i18n/languages.js +70 -0
- package/dist/i18n/languages.js.map +1 -0
- package/dist/i18n/locales/de-CH.d.ts +4 -1
- package/dist/i18n/locales/en.d.ts +4 -1
- package/dist/i18n/locales/en.js +8 -0
- package/dist/i18n/locales/en.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +4 -1
- package/dist/i18n/locales/es.js +83 -33
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +4 -1
- package/dist/i18n/locales/fr.js +50 -0
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +4 -1
- package/dist/i18n/locales/hu.js +51 -0
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +4 -1
- package/dist/i18n/locales/it.js +90 -40
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +4 -1
- package/dist/i18n/locales/zh.js +51 -0
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +102 -0
- package/dist/i18n/review.js +158 -0
- package/dist/i18n/review.js.map +1 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +64 -0
- package/dist/index.js.map +1 -1
- package/dist/{kit-labels-CPbsjlXr.d.ts → kit-labels-D1J2_jyD.d.ts} +7 -0
- package/dist/lib/floating-stack.d.ts +31 -0
- package/dist/lib/floating-stack.js +42 -0
- package/dist/lib/floating-stack.js.map +1 -0
- package/dist/lib/server-wake.d.ts +24 -8
- package/dist/lib/server-wake.js +6 -5
- package/dist/lib/server-wake.js.map +1 -1
- package/dist/lib/translation-review.d.ts +232 -0
- package/dist/lib/translation-review.js +194 -0
- package/dist/lib/translation-review.js.map +1 -0
- package/dist/rhf/fields.d.ts +4 -1
- package/dist/rhf/form.d.ts +4 -1
- package/dist/rhf.d.ts +4 -1
- package/dist/shell/app-shell.d.ts +4 -1
- package/dist/shell/top-bar-brand.d.ts +4 -1
- package/dist/shell.d.ts +4 -1
- package/dist/wizard/stepper-nav.d.ts +4 -1
- package/dist/wizard.d.ts +4 -1
- package/package.json +1 -1
- package/src/components/floating-panel.tsx +19 -1
- package/src/components/legal.tsx +159 -0
- package/src/components/server-wake.tsx +37 -5
- package/src/components/translation-review-editor.tsx +261 -0
- package/src/components/translation-review-labels.ts +152 -0
- package/src/components/translation-review-parts.tsx +269 -0
- package/src/components/translation-review.tsx +502 -0
- package/src/i18n/defaults.ts +4 -0
- package/src/i18n/german.ts +52 -0
- package/src/i18n/kit-labels.tsx +7 -0
- package/src/i18n/languages.ts +285 -0
- package/src/i18n/locales/en.ts +8 -0
- package/src/i18n/locales/es.ts +84 -33
- package/src/i18n/locales/fr.ts +51 -0
- package/src/i18n/locales/hu.ts +52 -0
- package/src/i18n/locales/it.ts +91 -40
- package/src/i18n/locales/zh.ts +52 -0
- package/src/i18n/review.ts +193 -0
- package/src/index.ts +63 -0
- package/src/lib/floating-stack.ts +66 -0
- package/src/lib/server-wake.ts +34 -13
- package/src/lib/translation-review.ts +465 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
2
|
import { ReactNode, ReactElement } from 'react';
|
|
3
|
-
import { f as ButtonVariant } from '../kit-labels-
|
|
3
|
+
import { f as ButtonVariant } from '../kit-labels-D1J2_jyD.js';
|
|
4
4
|
import { UseWizardReturn, WizardLabels } from './types.js';
|
|
5
5
|
import '../components/data-table-labels.js';
|
|
6
6
|
import '../components/mini-calendar.js';
|
|
@@ -52,6 +52,7 @@ import '../hooks/use-authed-src.js';
|
|
|
52
52
|
import '../components/image-grid.js';
|
|
53
53
|
import '../components/lightbox.js';
|
|
54
54
|
import '../components/write-lock.js';
|
|
55
|
+
import '../components/legal.js';
|
|
55
56
|
import '../components/account-chips.js';
|
|
56
57
|
import '../lib/format.js';
|
|
57
58
|
import '../data-table-3mi2R3so.js';
|
|
@@ -60,6 +61,8 @@ import '../components/swipeable-row.js';
|
|
|
60
61
|
import '../components/reauth-dialog.js';
|
|
61
62
|
import '../components/server-wake.js';
|
|
62
63
|
import '../lib/server-wake.js';
|
|
64
|
+
import '../components/translation-review-labels.js';
|
|
65
|
+
import '../lib/translation-review.js';
|
|
63
66
|
|
|
64
67
|
/**
|
|
65
68
|
* The chrome around a wizard: the step indicator, the step content, and the
|
package/dist/wizard.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export { WizardStep, WizardStepProps } from './wizard/wizard-step.js';
|
|
|
6
6
|
export { WizardSummary } from './wizard/wizard-summary.js';
|
|
7
7
|
export { StepperNav } from './wizard/stepper-nav.js';
|
|
8
8
|
import 'react';
|
|
9
|
-
import './kit-labels-
|
|
9
|
+
import './kit-labels-D1J2_jyD.js';
|
|
10
10
|
import './components/data-table-labels.js';
|
|
11
11
|
import './components/mini-calendar.js';
|
|
12
12
|
import './components/calendar-heatmap.js';
|
|
@@ -57,6 +57,7 @@ import './hooks/use-authed-src.js';
|
|
|
57
57
|
import './components/image-grid.js';
|
|
58
58
|
import './components/lightbox.js';
|
|
59
59
|
import './components/write-lock.js';
|
|
60
|
+
import './components/legal.js';
|
|
60
61
|
import './components/account-chips.js';
|
|
61
62
|
import './lib/format.js';
|
|
62
63
|
import './data-table-3mi2R3so.js';
|
|
@@ -65,3 +66,5 @@ import './components/swipeable-row.js';
|
|
|
65
66
|
import './components/reauth-dialog.js';
|
|
66
67
|
import './components/server-wake.js';
|
|
67
68
|
import './lib/server-wake.js';
|
|
69
|
+
import './components/translation-review-labels.js';
|
|
70
|
+
import './lib/translation-review.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eifi1/ui-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "App-agnostic shared design system: domain-free React components, theme and tokens.",
|
|
6
6
|
"_sideEffects": "The barrel (src/index.ts) re-exports EVERY component, including components/chart, which statically imports recharts. Without this field a bundler must assume each of those modules has side effects, so it cannot drop the unused ones: importing a Button from '@eifi1/ui-kit' dragged all 433KB of recharts into the consumer's ENTRY chunk, and Keksdose was preloading it on every page load, login included. Nothing in src/ imports for side effects (no bare imports, no CSS imports from JS, no module-level global writes) — the token stylesheet is its own './tokens.css' export — so this is safe. CSS is still listed so any future stylesheet survives. Re-checked when `flag-icons` left `dependencies` and tsup started emitting a `\"use client\"` banner: neither touches this. The flag stylesheet was never imported from JS in the first place (that was the finding), and a directive prologue is a string literal every bundler lifts out before it decides whether a module is inert.",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
3
|
import type {
|
|
4
4
|
AnchorHTMLAttributes,
|
|
@@ -15,7 +15,9 @@ import { X } from "lucide-react";
|
|
|
15
15
|
|
|
16
16
|
import { cn } from "../lib/cn";
|
|
17
17
|
import { dirOf, type Direction } from "../lib/direction";
|
|
18
|
+
import { registerFloating } from "../lib/floating-stack";
|
|
18
19
|
import { useKitLabels, useKitLink } from "../i18n/kit-labels";
|
|
20
|
+
import { assignRef } from "./choice-parts";
|
|
19
21
|
import { pickLinkRenderer } from "./text-link";
|
|
20
22
|
import { Tooltip, type TooltipSide } from "./tooltip";
|
|
21
23
|
|
|
@@ -172,6 +174,9 @@ export interface FloatingActionButtonProps extends Omit<ButtonHTMLAttributes<HTM
|
|
|
172
174
|
*
|
|
173
175
|
* Portalled, because `position: fixed` inside any transformed ancestor (an animated
|
|
174
176
|
* page transition) is fixed to that ancestor, not to the screen.
|
|
177
|
+
*
|
|
178
|
+
* While mounted it reports its box (`lib/floating-stack.ts`), so `ServerWakeNotice`
|
|
179
|
+
* stacks above it rather than over it.
|
|
175
180
|
*/
|
|
176
181
|
export function FloatingActionButton({
|
|
177
182
|
label,
|
|
@@ -189,18 +194,30 @@ export function FloatingActionButton({
|
|
|
189
194
|
style,
|
|
190
195
|
type = "button",
|
|
191
196
|
title,
|
|
197
|
+
ref,
|
|
192
198
|
...rest
|
|
193
199
|
}: FloatingActionButtonProps) {
|
|
194
200
|
const [marker, dir] = usePortalDir();
|
|
195
201
|
const toggle = pressed !== undefined;
|
|
196
202
|
const tip = tooltip === true ? label : tooltip === false || tooltip === "" ? undefined : tooltip;
|
|
197
203
|
const wrapped = tip !== undefined && tip !== null;
|
|
204
|
+
// The fixed element — the button, or the wrapper when there is a tooltip.
|
|
205
|
+
const [fixed, setFixed] = useState<HTMLElement | null>(null);
|
|
206
|
+
useEffect(() => (fixed ? registerFloating(fixed) : undefined), [fixed]);
|
|
207
|
+
const buttonRef = useCallback(
|
|
208
|
+
(node: HTMLButtonElement | null) => {
|
|
209
|
+
assignRef(ref, node);
|
|
210
|
+
if (!wrapped) setFixed(node);
|
|
211
|
+
},
|
|
212
|
+
[ref, wrapped],
|
|
213
|
+
);
|
|
198
214
|
// An empty title is "no tooltip", not an attribute with nothing in it. The kit
|
|
199
215
|
// tooltip replaces it outright.
|
|
200
216
|
const nativeTip = !wrapped && nativeTitle && title !== "" ? (title ?? label) : undefined;
|
|
201
217
|
const button = (
|
|
202
218
|
<button
|
|
203
219
|
{...rest}
|
|
220
|
+
ref={buttonRef}
|
|
204
221
|
type={type}
|
|
205
222
|
dir={dir}
|
|
206
223
|
aria-label={label}
|
|
@@ -248,6 +265,7 @@ export function FloatingActionButton({
|
|
|
248
265
|
<>
|
|
249
266
|
{wrapped ? (
|
|
250
267
|
<div
|
|
268
|
+
ref={setFixed}
|
|
251
269
|
dir={dir}
|
|
252
270
|
hidden={rest.hidden}
|
|
253
271
|
style={cornerStyle(corner, offset)}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { useKitLabels } from "../i18n/kit-labels";
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
import { AlertBanner } from "./alert-banner";
|
|
6
|
+
import { TextLink } from "./text-link";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The legal pages' shell — Imprint, Privacy Policy, Terms — for every app (Marcel,
|
|
10
|
+
* 2026-10-02: every app has them; docs/i18n-harmonization.md H10).
|
|
11
|
+
*
|
|
12
|
+
* keksdose and kastlan each carried the same shell, written twice: a back link, the
|
|
13
|
+
* title, a "closed beta, not yet reviewed by a lawyer" notice, sections of heading +
|
|
14
|
+
* running text, and kastlan's row of links to the three pages with the current one
|
|
15
|
+
* marked. Kurvenschmiede had none. The words stay in each app — they are the app's
|
|
16
|
+
* legal statements, reviewed under the `legal` area of the translation review — and
|
|
17
|
+
* only the shape moves here.
|
|
18
|
+
*
|
|
19
|
+
* A CONTENT COLUMN, not a page: keksdose shows its legal pages inside its public
|
|
20
|
+
* landing header and footer, kastlan inside `AuthLayout` (`width="wide" card={false}`,
|
|
21
|
+
* no title of its own). Both put this inside their chrome; a page-owning layout would
|
|
22
|
+
* have fought one of them.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// ── Labels ────────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export interface LegalLabels {
|
|
28
|
+
/** The accessible name of {@link LegalLinks}' `<nav>`. */
|
|
29
|
+
navLabel: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const DEFAULT_LEGAL_LABELS: LegalLabels = {
|
|
33
|
+
navLabel: "Legal",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// ── Links ─────────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export interface LegalLink {
|
|
39
|
+
href: string;
|
|
40
|
+
/** "Imprint", "Privacy Policy", "Terms" — the app's words. */
|
|
41
|
+
label: ReactNode;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface LegalLinksProps {
|
|
45
|
+
links: readonly LegalLink[];
|
|
46
|
+
/** The page being shown; its link is marked `aria-current="page"`. Usually the
|
|
47
|
+
* router's pathname. */
|
|
48
|
+
currentHref?: string;
|
|
49
|
+
/** `false` renders the links without the `<nav>` — inside `AuthLayout`'s `footer`,
|
|
50
|
+
* which wraps its footer in a `<nav>` named by `footerLabel` already. Default true. */
|
|
51
|
+
nav?: boolean;
|
|
52
|
+
labels?: Partial<LegalLabels>;
|
|
53
|
+
className?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The legal pages as a row of quiet links, the one you are on marked (kastlan's
|
|
57
|
+
* `LegalLinks`). Wraps on a phone. */
|
|
58
|
+
export function LegalLinks({ links, currentHref, nav = true, labels: labelsProp, className }: LegalLinksProps) {
|
|
59
|
+
const labels = useKitLabels("legal", DEFAULT_LEGAL_LABELS, labelsProp);
|
|
60
|
+
const items = links.map((link) => (
|
|
61
|
+
<TextLink key={link.href} href={link.href} tone="muted" current={link.href === currentHref}>
|
|
62
|
+
{link.label}
|
|
63
|
+
</TextLink>
|
|
64
|
+
));
|
|
65
|
+
if (!nav) return <>{items}</>;
|
|
66
|
+
return (
|
|
67
|
+
<nav
|
|
68
|
+
aria-label={labels.navLabel}
|
|
69
|
+
className={cn("flex flex-wrap gap-x-4 gap-y-1 text-xs", className)}
|
|
70
|
+
>
|
|
71
|
+
{items}
|
|
72
|
+
</nav>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Section ───────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export interface LegalSectionProps {
|
|
79
|
+
heading: ReactNode;
|
|
80
|
+
/** Running text. Line breaks in it are kept (`white-space: pre-line`), so a
|
|
81
|
+
* translation can hold an address or a list as plain lines. */
|
|
82
|
+
body?: string;
|
|
83
|
+
/** Anything richer than plain text — links, a list — after `body`. */
|
|
84
|
+
children?: ReactNode;
|
|
85
|
+
/** The section's `id`, for a link to it from elsewhere ("see §4"). */
|
|
86
|
+
id?: string;
|
|
87
|
+
/** The heading's element. Default `h2`, under the layout's `h1`. */
|
|
88
|
+
headingAs?: "h2" | "h3";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function LegalSection({ heading, body, children, id, headingAs: Heading = "h2" }: LegalSectionProps) {
|
|
92
|
+
return (
|
|
93
|
+
<section id={id} className="scroll-mt-20">
|
|
94
|
+
<Heading className="text-lg font-semibold text-[var(--text-primary)]">{heading}</Heading>
|
|
95
|
+
{body !== undefined && (
|
|
96
|
+
<p className="mt-2 whitespace-pre-line text-sm leading-relaxed text-[var(--text-secondary)]">{body}</p>
|
|
97
|
+
)}
|
|
98
|
+
{children !== undefined && (
|
|
99
|
+
<div className="mt-2 text-sm leading-relaxed text-[var(--text-secondary)]">{children}</div>
|
|
100
|
+
)}
|
|
101
|
+
</section>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Layout ────────────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
export interface LegalLayoutProps {
|
|
108
|
+
/** "Imprint", "Privacy Policy", "Terms". */
|
|
109
|
+
title: ReactNode;
|
|
110
|
+
/** Above the title — the app's "← Back" link (`<TextLink icon={ArrowLeft}>`), whose
|
|
111
|
+
* target differs per app (keksdose's start page, kastlan's sign-in). */
|
|
112
|
+
back?: ReactNode;
|
|
113
|
+
/**
|
|
114
|
+
* A notice under the title, as a warning banner with `role="note"` — page furniture,
|
|
115
|
+
* not an alert. Both apps say their legal texts await a lawyer's review; keksdose's
|
|
116
|
+
* privacy page says something more precise than the others (its technical sections
|
|
117
|
+
* describe running code), so the notice is per page, not per layout.
|
|
118
|
+
*/
|
|
119
|
+
notice?: ReactNode;
|
|
120
|
+
/** The sections — {@link LegalSection}s. */
|
|
121
|
+
children: ReactNode;
|
|
122
|
+
/** The three pages, linked at the end of the column with the current one marked.
|
|
123
|
+
* Leave out when the page's chrome already shows them (kastlan's AuthLayout footer). */
|
|
124
|
+
links?: readonly LegalLink[];
|
|
125
|
+
currentHref?: string;
|
|
126
|
+
/** Default `h1`: the page's heading. */
|
|
127
|
+
headingAs?: "h1" | "h2";
|
|
128
|
+
/** Extra classes on the column — `px-0 py-0` inside a chrome that pads already. */
|
|
129
|
+
className?: string;
|
|
130
|
+
labels?: Partial<LegalLabels>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function LegalLayout({
|
|
134
|
+
title,
|
|
135
|
+
back,
|
|
136
|
+
notice,
|
|
137
|
+
children,
|
|
138
|
+
links,
|
|
139
|
+
currentHref,
|
|
140
|
+
headingAs: Heading = "h1",
|
|
141
|
+
className,
|
|
142
|
+
labels,
|
|
143
|
+
}: LegalLayoutProps) {
|
|
144
|
+
return (
|
|
145
|
+
<article className={cn("mx-auto w-full max-w-3xl px-4 py-12", className)}>
|
|
146
|
+
{back !== undefined && <div className="mb-4 text-sm">{back}</div>}
|
|
147
|
+
<Heading className="text-3xl font-bold tracking-tight text-[var(--text-primary)]">{title}</Heading>
|
|
148
|
+
{notice !== undefined && notice !== null && (
|
|
149
|
+
<AlertBanner tone="warning" role="note" className="mt-4">
|
|
150
|
+
{notice}
|
|
151
|
+
</AlertBanner>
|
|
152
|
+
)}
|
|
153
|
+
<div className="mt-8 space-y-8">{children}</div>
|
|
154
|
+
{links !== undefined && links.length > 0 && (
|
|
155
|
+
<LegalLinks links={links} currentHref={currentHref} labels={labels} className="mt-12 border-t border-[var(--border)] pt-4" />
|
|
156
|
+
)}
|
|
157
|
+
</article>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { useSyncExternalStore } from "react";
|
|
2
|
-
import type { CSSProperties } from "react";
|
|
1
|
+
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
2
|
+
import type { CSSProperties, RefObject } from "react";
|
|
3
3
|
|
|
4
4
|
import { useKitLabels } from "../i18n/kit-labels";
|
|
5
5
|
import { cn } from "../lib/cn";
|
|
6
|
+
import { clearanceAbove, subscribeFloating } from "../lib/floating-stack";
|
|
6
7
|
import { serverWake } from "../lib/server-wake";
|
|
7
8
|
import type { ServerWakeStage, ServerWakeWatcher } from "../lib/server-wake";
|
|
8
9
|
import { AlertBanner } from "./alert-banner";
|
|
@@ -17,7 +18,8 @@ import { Spinner } from "./ui";
|
|
|
17
18
|
* Corner-anchored and non-blocking on purpose. The pages underneath already show their
|
|
18
19
|
* own skeletons; what was missing is the explanation, not another overlay. Start
|
|
19
20
|
* corner, above the phone's bottom nav, so it collides with neither the centred toasts
|
|
20
|
-
* nor the nav bar
|
|
21
|
+
* nor the nav bar — and above any FloatingActionButton it would otherwise cover
|
|
22
|
+
* (kastlan's offline pill shares the corner; on a phone the notice spans both).
|
|
21
23
|
*
|
|
22
24
|
* Mount it next to the router, not inside the app layout, so it also covers the login
|
|
23
25
|
* screen and the landing page — a cold start is at its most confusing exactly there,
|
|
@@ -67,12 +69,38 @@ export interface ServerWakeNoticeProps {
|
|
|
67
69
|
*/
|
|
68
70
|
navOffset?: string | number;
|
|
69
71
|
/** Extra classes on the fixed anchor — a different width or `start-*`. The bottom
|
|
70
|
-
* offset is an inline style, so move it vertically with {@link navOffset}.
|
|
72
|
+
* offset is an inline style, so move it vertically with {@link navOffset}. Either
|
|
73
|
+
* way it rises above a visible FloatingActionButton under it. */
|
|
71
74
|
className?: string;
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
const px = (v: string | number) => (typeof v === "number" ? `${v}px` : v);
|
|
75
78
|
|
|
79
|
+
/** Gap between the notice and a floating control it stacks above. */
|
|
80
|
+
const STACK_GAP = "0.5rem";
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* How far off the bottom the notice must sit to clear the FloatingActionButtons under
|
|
84
|
+
* it (`lib/floating-stack.ts`), re-measured when one appears, goes, resizes or the
|
|
85
|
+
* viewport does — or `null` while it is idle or nothing is under it. kastlan 0.18.
|
|
86
|
+
*/
|
|
87
|
+
function useFloatingClearance(anchor: RefObject<HTMLDivElement | null>, stage: ServerWakeStage): number | null {
|
|
88
|
+
const [clearance, setClearance] = useState<number | null>(null);
|
|
89
|
+
useLayoutEffect(() => {
|
|
90
|
+
if (stage === "idle") return;
|
|
91
|
+
const measure = () => setClearance(clearanceAbove(anchor.current));
|
|
92
|
+
measure();
|
|
93
|
+
const unsubscribe = subscribeFloating(measure);
|
|
94
|
+
window.addEventListener("resize", measure);
|
|
95
|
+
return () => {
|
|
96
|
+
unsubscribe();
|
|
97
|
+
window.removeEventListener("resize", measure);
|
|
98
|
+
};
|
|
99
|
+
}, [anchor, stage]);
|
|
100
|
+
// Idle, the last measurement is stale and nothing shows anyway.
|
|
101
|
+
return stage === "idle" ? null : clearance;
|
|
102
|
+
}
|
|
103
|
+
|
|
76
104
|
/**
|
|
77
105
|
* Renders nothing visible while idle. The LIVE REGION, though, is always mounted: a
|
|
78
106
|
* `role="status"` element inserted together with its text is announced unreliably
|
|
@@ -84,12 +112,16 @@ export function ServerWakeNotice({ watcher, appName, labels: labelsProp, navOffs
|
|
|
84
112
|
const labels = useKitLabels("serverWake", DEFAULT_SERVER_WAKE_LABELS, labelsProp);
|
|
85
113
|
const stage = useServerWakeStage(watcher);
|
|
86
114
|
const waking = stage === "waking";
|
|
115
|
+
const anchor = useRef<HTMLDivElement>(null);
|
|
116
|
+
const clearance = useFloatingClearance(anchor, stage);
|
|
117
|
+
const base = `calc(max(${navOffset === undefined ? "var(--app-nav-h, 0px)" : px(navOffset)}, env(safe-area-inset-bottom, 0px)) + 1rem)`;
|
|
87
118
|
const style: CSSProperties = {
|
|
88
|
-
bottom:
|
|
119
|
+
bottom: clearance === null ? base : `max(${base}, calc(${clearance}px + ${STACK_GAP}))`,
|
|
89
120
|
};
|
|
90
121
|
|
|
91
122
|
return (
|
|
92
123
|
<div
|
|
124
|
+
ref={anchor}
|
|
93
125
|
role="status"
|
|
94
126
|
aria-live="polite"
|
|
95
127
|
data-stage={stage}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { useKitLabels } from "../i18n/kit-labels";
|
|
5
|
+
import { cn } from "../lib/cn";
|
|
6
|
+
import { useKitFormat } from "../lib/format";
|
|
7
|
+
import { placeholderTokens, reviewWrite } from "../lib/translation-review";
|
|
8
|
+
import type {
|
|
9
|
+
TranslationReviewKey,
|
|
10
|
+
TranslationReviewWrite,
|
|
11
|
+
TranslationRow,
|
|
12
|
+
TranslationVerdict,
|
|
13
|
+
} from "../lib/translation-review";
|
|
14
|
+
import { AlertBanner } from "./alert-banner";
|
|
15
|
+
import { Caption, SectionLabel } from "./text";
|
|
16
|
+
import { DEFAULT_TRANSLATION_REVIEW_LABELS } from "./translation-review-labels";
|
|
17
|
+
import type { TranslationReviewLabels } from "./translation-review-labels";
|
|
18
|
+
import { Button, Textarea } from "./ui";
|
|
19
|
+
|
|
20
|
+
/** A callback may return a promise: resolve and the editor closes, reject and it stays
|
|
21
|
+
* open with the error. */
|
|
22
|
+
type MaybePromise = void | Promise<unknown>;
|
|
23
|
+
|
|
24
|
+
export interface TranslationReviewEditorProps {
|
|
25
|
+
row: TranslationRow;
|
|
26
|
+
/** The reference language's own name ("English", "Deutsch") — the app's choice of
|
|
27
|
+
* reference, so the app's word for it. */
|
|
28
|
+
referenceLabel: ReactNode;
|
|
29
|
+
/** The reviewed language's own name ("Français"). */
|
|
30
|
+
localeLabel: ReactNode;
|
|
31
|
+
/** Store a verdict. Left out (or `readOnly`): the editor shows the row and its last
|
|
32
|
+
* verdict, with no fields and no actions. */
|
|
33
|
+
onSave?: (write: TranslationReviewWrite) => MaybePromise;
|
|
34
|
+
/** Clear the verdict — back to unreviewed. Left out: no reset button. */
|
|
35
|
+
onClear?: (key: TranslationReviewKey) => MaybePromise;
|
|
36
|
+
/** The Cancel button — and called after a save or a reset resolves, so the panel
|
|
37
|
+
* that opened the editor closes it. */
|
|
38
|
+
onClose?: () => void;
|
|
39
|
+
/** Look, do not touch: a locale the viewer may read but not review. */
|
|
40
|
+
readOnly?: boolean;
|
|
41
|
+
/** How the verdict's date reads. Default: the kit's `formatDate`, medium, in the
|
|
42
|
+
* provider's locale. */
|
|
43
|
+
formatDate?: (iso: string) => string;
|
|
44
|
+
/** Turns a rejection into words. Default {@link TranslationReviewLabels.failed}. */
|
|
45
|
+
formatError?: (error: unknown) => ReactNode;
|
|
46
|
+
className?: string;
|
|
47
|
+
labels?: Partial<TranslationReviewLabels>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The wording a verdict was given on, under the line that says it changed. */
|
|
51
|
+
function Then({ label, text }: { label: string; text: string }) {
|
|
52
|
+
return (
|
|
53
|
+
<div className="mt-1.5 min-w-0">
|
|
54
|
+
<span className="text-xs font-medium">{label}</span>
|
|
55
|
+
<p className="whitespace-pre-line break-words text-[var(--text-secondary)]">{text}</p>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One string, opened: the reference beside the translation, what changed since the last
|
|
62
|
+
* verdict, what a machine can see is wrong (the placeholders), and the two things a
|
|
63
|
+
* reviewer can say about it — keksdose's and kastlan's `translation-review-editor.tsx`.
|
|
64
|
+
*
|
|
65
|
+
* "Better wording" opens on the CURRENT text rather than empty, because the usual
|
|
66
|
+
* correction is a word or an ending, and retyping a sentence to change one word is how
|
|
67
|
+
* new mistakes get in. It is sent only with "Needs a change" — approving says the text is
|
|
68
|
+
* fine as it stands. A "Needs a change" with neither a note nor a different wording tells
|
|
69
|
+
* whoever fixes it nothing, so it takes one of the two.
|
|
70
|
+
*
|
|
71
|
+
* A MISSING string (kastlan: the locale lacks the key) cannot be approved — there is
|
|
72
|
+
* nothing to approve. Its field starts empty and is the translation to add; the verdict
|
|
73
|
+
* is NEEDS_CHANGE on the empty text, which the export hands a developer as the string to
|
|
74
|
+
* add.
|
|
75
|
+
*
|
|
76
|
+
* Every action waits for its callback: the buttons hold while the promise is out, a
|
|
77
|
+
* resolution closes the editor through `onClose`, and a rejection keeps it open with what
|
|
78
|
+
* was typed and the error under the buttons.
|
|
79
|
+
*/
|
|
80
|
+
export function TranslationReviewEditor({
|
|
81
|
+
row,
|
|
82
|
+
referenceLabel,
|
|
83
|
+
localeLabel,
|
|
84
|
+
onSave,
|
|
85
|
+
onClear,
|
|
86
|
+
onClose,
|
|
87
|
+
readOnly = false,
|
|
88
|
+
formatDate: formatDateProp,
|
|
89
|
+
formatError,
|
|
90
|
+
className,
|
|
91
|
+
labels: labelsProp,
|
|
92
|
+
}: TranslationReviewEditorProps) {
|
|
93
|
+
const labels = useKitLabels("translationReview", DEFAULT_TRANSLATION_REVIEW_LABELS, labelsProp);
|
|
94
|
+
const { formatDate } = useKitFormat();
|
|
95
|
+
const review = row.review;
|
|
96
|
+
const missing = row.text === "";
|
|
97
|
+
const editable = !readOnly && onSave !== undefined;
|
|
98
|
+
const [suggestion, setSuggestion] = useState(review?.suggestion ?? row.text);
|
|
99
|
+
const [note, setNote] = useState(review?.note ?? "");
|
|
100
|
+
const [busy, setBusy] = useState<"approve" | "flag" | "clear" | null>(null);
|
|
101
|
+
const [failure, setFailure] = useState<ReactNode>(null);
|
|
102
|
+
|
|
103
|
+
const canFlag = note.trim() !== "" || suggestion.trim() !== row.text.trim();
|
|
104
|
+
const changed = row.status === "changed" ? review : null;
|
|
105
|
+
const date = (iso: string) => (formatDateProp ? formatDateProp(iso) : formatDate(iso, "medium"));
|
|
106
|
+
|
|
107
|
+
const run = async (kind: "approve" | "flag" | "clear", action: () => MaybePromise) => {
|
|
108
|
+
setBusy(kind);
|
|
109
|
+
setFailure(null);
|
|
110
|
+
try {
|
|
111
|
+
await action();
|
|
112
|
+
onClose?.();
|
|
113
|
+
} catch (caught) {
|
|
114
|
+
setFailure(formatError ? formatError(caught) : labels.failed);
|
|
115
|
+
} finally {
|
|
116
|
+
setBusy(null);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const save = (verdict: TranslationVerdict) => {
|
|
121
|
+
if (!onSave) return;
|
|
122
|
+
const trimmedNote = note.trim() || null;
|
|
123
|
+
// Approving says the text is right as it stands: there is no wording to suggest.
|
|
124
|
+
const sent = verdict === "APPROVED" ? null : suggestion.trim() || null;
|
|
125
|
+
void run(verdict === "APPROVED" ? "approve" : "flag", () =>
|
|
126
|
+
onSave(reviewWrite(row, verdict, trimmedNote, sent)),
|
|
127
|
+
);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div className={cn("min-w-0 space-y-3 text-sm", className)}>
|
|
132
|
+
<code className="block break-all text-xs text-[var(--text-muted)]">{row.key}</code>
|
|
133
|
+
|
|
134
|
+
<div className="grid gap-3 md:grid-cols-2">
|
|
135
|
+
<div className="min-w-0">
|
|
136
|
+
<SectionLabel as="span" size="xs">
|
|
137
|
+
{referenceLabel}
|
|
138
|
+
</SectionLabel>
|
|
139
|
+
<p className="mt-1 whitespace-pre-line break-words text-[var(--text-secondary)]">{row.reference}</p>
|
|
140
|
+
</div>
|
|
141
|
+
<div className="min-w-0">
|
|
142
|
+
<SectionLabel as="span" size="xs">
|
|
143
|
+
{localeLabel}
|
|
144
|
+
</SectionLabel>
|
|
145
|
+
{missing ? (
|
|
146
|
+
<p className="mt-1 italic text-[var(--text-muted)]">{labels.missingText}</p>
|
|
147
|
+
) : (
|
|
148
|
+
<p lang={row.locale} className="mt-1 whitespace-pre-line break-words">
|
|
149
|
+
{row.text}
|
|
150
|
+
</p>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
{changed && (
|
|
156
|
+
<AlertBanner tone="warning" variant="inline" size="sm" block live={false}>
|
|
157
|
+
<div className="min-w-0">
|
|
158
|
+
<p>{labels.changedSince}</p>
|
|
159
|
+
{changed.text !== row.text && <Then label={labels.reviewedWording} text={changed.text || "—"} />}
|
|
160
|
+
{changed.referenceText != null && changed.referenceText !== row.reference && (
|
|
161
|
+
<Then label={labels.reviewedReference} text={changed.referenceText} />
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
</AlertBanner>
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
{row.placeholderMismatch && (
|
|
168
|
+
<AlertBanner tone="danger" variant="inline" size="sm" block live={false}>
|
|
169
|
+
<span className="min-w-0 break-words">
|
|
170
|
+
{labels.placeholderMismatch(
|
|
171
|
+
placeholderTokens(row.reference).join(" ") || "—",
|
|
172
|
+
placeholderTokens(row.text).join(" ") || "—",
|
|
173
|
+
)}
|
|
174
|
+
</span>
|
|
175
|
+
</AlertBanner>
|
|
176
|
+
)}
|
|
177
|
+
|
|
178
|
+
{review && (
|
|
179
|
+
<Caption>
|
|
180
|
+
{(review.verdict === "APPROVED" ? labels.lastApproved : labels.lastFlagged)(
|
|
181
|
+
review.reviewerName ?? labels.erasedReviewer,
|
|
182
|
+
date(review.reviewedAt),
|
|
183
|
+
)}
|
|
184
|
+
</Caption>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
{/* Read-only: what the reviewer said, as text — the fields are for saying it. */}
|
|
188
|
+
{!editable && review?.verdict === "NEEDS_CHANGE" && (review.suggestion || review.note) && (
|
|
189
|
+
<div className="space-y-2">
|
|
190
|
+
{review.suggestion && <Then label={missing ? labels.translation : labels.suggestion} text={review.suggestion} />}
|
|
191
|
+
{review.note && <Then label={labels.note} text={review.note} />}
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
{editable && (
|
|
196
|
+
<>
|
|
197
|
+
<Textarea
|
|
198
|
+
label={missing ? labels.translation : labels.suggestion}
|
|
199
|
+
lang={row.locale}
|
|
200
|
+
rows={Math.min(8, Math.max(2, Math.ceil(Math.max(row.text.length, row.reference.length) / 80)))}
|
|
201
|
+
value={suggestion}
|
|
202
|
+
disabled={busy !== null}
|
|
203
|
+
onChange={(e) => setSuggestion(e.target.value)}
|
|
204
|
+
/>
|
|
205
|
+
<Textarea
|
|
206
|
+
label={labels.note}
|
|
207
|
+
rows={2}
|
|
208
|
+
value={note}
|
|
209
|
+
placeholder={labels.notePlaceholder}
|
|
210
|
+
disabled={busy !== null}
|
|
211
|
+
onChange={(e) => setNote(e.target.value)}
|
|
212
|
+
/>
|
|
213
|
+
<div className="flex flex-wrap gap-2">
|
|
214
|
+
{!missing && (
|
|
215
|
+
<Button
|
|
216
|
+
variant="primary"
|
|
217
|
+
commit
|
|
218
|
+
pending={busy === "approve"}
|
|
219
|
+
disabled={busy !== null}
|
|
220
|
+
onClick={() => save("APPROVED")}
|
|
221
|
+
>
|
|
222
|
+
{labels.approve}
|
|
223
|
+
</Button>
|
|
224
|
+
)}
|
|
225
|
+
<Button
|
|
226
|
+
variant={missing ? "primary" : "danger"}
|
|
227
|
+
commit
|
|
228
|
+
pending={busy === "flag"}
|
|
229
|
+
disabled={busy !== null || !canFlag}
|
|
230
|
+
onClick={() => save("NEEDS_CHANGE")}
|
|
231
|
+
>
|
|
232
|
+
{missing ? labels.suggest : labels.flag}
|
|
233
|
+
</Button>
|
|
234
|
+
{review && onClear && (
|
|
235
|
+
<Button
|
|
236
|
+
variant="ghost"
|
|
237
|
+
commit
|
|
238
|
+
pending={busy === "clear"}
|
|
239
|
+
disabled={busy !== null}
|
|
240
|
+
onClick={() => void run("clear", () => onClear({ locale: row.locale, key: row.key }))}
|
|
241
|
+
>
|
|
242
|
+
{labels.reset}
|
|
243
|
+
</Button>
|
|
244
|
+
)}
|
|
245
|
+
{onClose && (
|
|
246
|
+
<Button variant="ghost" disabled={busy !== null} onClick={onClose}>
|
|
247
|
+
{labels.cancel}
|
|
248
|
+
</Button>
|
|
249
|
+
)}
|
|
250
|
+
</div>
|
|
251
|
+
</>
|
|
252
|
+
)}
|
|
253
|
+
|
|
254
|
+
{failure && (
|
|
255
|
+
<AlertBanner tone="danger" size="sm" role="alert">
|
|
256
|
+
{failure}
|
|
257
|
+
</AlertBanner>
|
|
258
|
+
)}
|
|
259
|
+
</div>
|
|
260
|
+
);
|
|
261
|
+
}
|