feat: 기능 추가 과정중
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
@@ -13,17 +13,31 @@ import {
|
||||
import {
|
||||
deriveAsyncState,
|
||||
type AsyncState,
|
||||
} from "../../../application/view-models/async-state.js";
|
||||
import type { ApiFailure } from "../../../contracts/errors.js";
|
||||
} from "../../../application/view-models/async-state.ts";
|
||||
import type { Result } from "../../../application/result.ts";
|
||||
import {
|
||||
createFailure,
|
||||
normalizeUnknownFailure,
|
||||
type AppFailure,
|
||||
} from "../../../contracts/errors.ts";
|
||||
import type { QueryInvalidationTopic } from "../../../contracts/query-invalidation.ts";
|
||||
import type {
|
||||
BoundMutation,
|
||||
BoundQuery,
|
||||
} from "../../../contracts/server-state.ts";
|
||||
import { runtimeIdentityToken } from "../../../contracts/query-keys.ts";
|
||||
import { useQueryInvalidationCoordinator } from "./query-invalidation-provider.tsx";
|
||||
import {
|
||||
createOptimisticLayerRuntime,
|
||||
type OptimisticLayerLease,
|
||||
} from "./optimistic-layer-runtime.ts";
|
||||
|
||||
export type ApplicationResult<Value> =
|
||||
| Readonly<{ ok: true; value: Value }>
|
||||
| Readonly<{ ok: false; error: ApiFailure }>;
|
||||
export type ApplicationResult<Value> = Result<Value>;
|
||||
|
||||
class ApplicationQueryError extends Error {
|
||||
readonly failure: ApiFailure;
|
||||
readonly failure: AppFailure;
|
||||
|
||||
constructor(failure: ApiFailure) {
|
||||
constructor(failure: AppFailure) {
|
||||
super(failure.kind);
|
||||
this.name = "ApplicationQueryError";
|
||||
this.failure = failure;
|
||||
@@ -31,31 +45,102 @@ class ApplicationQueryError extends Error {
|
||||
}
|
||||
|
||||
export function useApplicationQuery<Value>(
|
||||
options: Readonly<{
|
||||
queryKey: readonly unknown[];
|
||||
execute(context: Readonly<{ signal: AbortSignal }>): Promise<
|
||||
ApplicationResult<Value>
|
||||
>;
|
||||
enabled?: boolean;
|
||||
}>,
|
||||
options:
|
||||
| BoundQuery<Value>
|
||||
| Readonly<{
|
||||
queryKey: readonly unknown[];
|
||||
execute(context: Readonly<{ signal: AbortSignal }>): Promise<
|
||||
ApplicationResult<Value>
|
||||
>;
|
||||
enabled?: boolean;
|
||||
}>,
|
||||
): Readonly<{
|
||||
data: Value | undefined;
|
||||
state: AsyncState;
|
||||
retry(): Promise<void>;
|
||||
}> {
|
||||
const { queryKey, execute, enabled = true } = options;
|
||||
const { queryKey, execute } = options;
|
||||
const enabled = "enabled" in options ? options.enabled ?? true : true;
|
||||
const profile = "profile" in options ? options.profile : undefined;
|
||||
const scope = "scope" in options ? options.scope : undefined;
|
||||
const identity = "identity" in options ? options.identity : undefined;
|
||||
const queryDefinitionId =
|
||||
"definitionId" in options ? options.definitionId : "APPLICATION_QUERY";
|
||||
const [staleFailure, setStaleFailure] = useState(false);
|
||||
useEffect(() => {
|
||||
identity?.acquire();
|
||||
return () => identity?.release();
|
||||
}, [identity]);
|
||||
const query = useQuery<Value, ApplicationQueryError>({
|
||||
queryKey,
|
||||
enabled,
|
||||
retry: false,
|
||||
staleTime: profile?.staleTimeMs,
|
||||
gcTime: profile?.gcTimeMs,
|
||||
refetchOnMount: profile?.refetchOnMount,
|
||||
refetchOnWindowFocus: profile?.refetchOnFocus,
|
||||
refetchOnReconnect: profile?.refetchOnReconnect,
|
||||
queryFn: async ({ signal }) => {
|
||||
const result = await execute({ signal });
|
||||
if (result.ok) return result.value;
|
||||
if (signal.aborted || result.error.kind === "REQUEST_ABORTED") {
|
||||
throw new DOMException("Query cancelled", "AbortError");
|
||||
identity?.acquire();
|
||||
try {
|
||||
if (scope && !scope.isCurrent()) {
|
||||
throw new ApplicationQueryError(
|
||||
createFailure(
|
||||
"SCOPE_GENERATION_CHANGED",
|
||||
queryDefinitionId,
|
||||
0,
|
||||
{ code: "QUERY_SCOPE_STALE" },
|
||||
),
|
||||
);
|
||||
}
|
||||
const result = await execute({ signal });
|
||||
if (scope && !scope.isCurrent()) {
|
||||
throw new ApplicationQueryError(
|
||||
createFailure(
|
||||
"SCOPE_GENERATION_CHANGED",
|
||||
queryDefinitionId,
|
||||
0,
|
||||
{ code: "QUERY_SCOPE_CHANGED" },
|
||||
),
|
||||
);
|
||||
}
|
||||
if (result.ok) {
|
||||
if (
|
||||
profile &&
|
||||
!isAdmissibleResult(
|
||||
result.value,
|
||||
profile.maxResultItems,
|
||||
profile.maxEstimatedResultBytes,
|
||||
)
|
||||
) {
|
||||
throw new ApplicationQueryError(
|
||||
createFailure(
|
||||
"RESULT_LIMIT_EXCEEDED",
|
||||
"APPLICATION_QUERY",
|
||||
0,
|
||||
{ code: "RESULT_ADMISSION_LIMIT_EXCEEDED" },
|
||||
),
|
||||
);
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw new DOMException("Query cancelled", "AbortError");
|
||||
}
|
||||
throw new ApplicationQueryError(result.error);
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
throw new DOMException("Query cancelled", "AbortError");
|
||||
}
|
||||
if (error instanceof ApplicationQueryError) throw error;
|
||||
throw new ApplicationQueryError(
|
||||
normalizeUnknownFailure(error, {
|
||||
operationId: "APPLICATION_QUERY",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
identity?.release();
|
||||
}
|
||||
throw new ApplicationQueryError(result.error);
|
||||
},
|
||||
});
|
||||
const hasData = query.data !== undefined && query.data !== null;
|
||||
@@ -91,28 +176,61 @@ export function useApplicationQuery<Value>(
|
||||
}
|
||||
|
||||
export function useApplicationMutation<Input, Value>(
|
||||
options: Readonly<{
|
||||
execute(input: Input): Promise<ApplicationResult<Value>>;
|
||||
invalidate?: readonly (readonly unknown[])[];
|
||||
optimistic?: Readonly<{
|
||||
queryKey: readonly unknown[];
|
||||
update(previous: unknown, input: Input): unknown;
|
||||
}>;
|
||||
currentData?: unknown;
|
||||
}>,
|
||||
options:
|
||||
| BoundMutation<Input, Value>
|
||||
| Readonly<{
|
||||
execute(input: Input): Promise<ApplicationResult<Value>>;
|
||||
invalidate?: readonly QueryInvalidationTopic[];
|
||||
optimistic?: Readonly<{
|
||||
queryKey: readonly unknown[];
|
||||
update(previous: unknown, input: Input): unknown;
|
||||
}>;
|
||||
currentData?: unknown;
|
||||
}>,
|
||||
): Readonly<{
|
||||
state: AsyncState;
|
||||
submit(input: Input): Promise<ApplicationResult<Value>>;
|
||||
resolveConflict(): Promise<void>;
|
||||
}> {
|
||||
const queryClient = useQueryClient();
|
||||
const { execute, invalidate = [], optimistic, currentData } = options;
|
||||
const [conflict, setConflict] = useState<ApiFailure | null>(null);
|
||||
const inFlight = useRef<Promise<ApplicationResult<Value>> | null>(null);
|
||||
const invalidationCoordinator = useQueryInvalidationCoordinator();
|
||||
const { execute } = options;
|
||||
const invalidate = useMemo(
|
||||
() => options.invalidate ?? [],
|
||||
[options.invalidate],
|
||||
);
|
||||
const optimistic = "optimistic" in options ? options.optimistic : undefined;
|
||||
const currentData = "currentData" in options ? options.currentData : undefined;
|
||||
const definitionId =
|
||||
"definitionId" in options ? options.definitionId : "LEGACY_MUTATION";
|
||||
const duplicatePolicy =
|
||||
"duplicatePolicy" in options ? options.duplicatePolicy : "JOIN_IDENTICAL";
|
||||
const [conflict, setConflict] = useState<AppFailure | null>(null);
|
||||
const scope = "scope" in options ? options.scope : undefined;
|
||||
const mutation = useMutation<Value, ApplicationQueryError, Input>({
|
||||
retry: false,
|
||||
mutationFn: async (input) => {
|
||||
if (scope && !scope.isCurrent()) {
|
||||
throw new ApplicationQueryError(
|
||||
createFailure(
|
||||
"SCOPE_GENERATION_CHANGED",
|
||||
definitionId,
|
||||
0,
|
||||
{ code: "MUTATION_SCOPE_STALE" },
|
||||
),
|
||||
);
|
||||
}
|
||||
const result = await execute(input);
|
||||
if (scope && !scope.isCurrent()) {
|
||||
throw new ApplicationQueryError(
|
||||
createFailure(
|
||||
"SCOPE_GENERATION_CHANGED",
|
||||
definitionId,
|
||||
0,
|
||||
{ code: "MUTATION_SCOPE_CHANGED" },
|
||||
),
|
||||
);
|
||||
}
|
||||
if (result.ok) return result.value;
|
||||
throw new ApplicationQueryError(result.error);
|
||||
},
|
||||
@@ -120,55 +238,172 @@ export function useApplicationMutation<Input, Value>(
|
||||
|
||||
const submit = useCallback(
|
||||
(input: Input): Promise<ApplicationResult<Value>> => {
|
||||
if (inFlight.current) return inFlight.current;
|
||||
let identity: string;
|
||||
let identityLease: ReturnType<
|
||||
NonNullable<typeof scope>["identities"]["intern"]
|
||||
> | null = null;
|
||||
try {
|
||||
if (scope) {
|
||||
if (!scope.isCurrent()) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
error: createFailure(
|
||||
"SCOPE_GENERATION_CHANGED",
|
||||
definitionId,
|
||||
0,
|
||||
{ code: "MUTATION_SCOPE_STALE" },
|
||||
),
|
||||
});
|
||||
}
|
||||
identityLease = scope.identities.intern(input);
|
||||
identityLease.acquire();
|
||||
identity = `${scope.fingerprint}:${definitionId}:${identityLease.token}`;
|
||||
} else {
|
||||
identity = `${definitionId}:${runtimeIdentityToken(input)}`;
|
||||
}
|
||||
} catch (error) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
error: normalizeUnknownFailure(error, {
|
||||
operationId: definitionId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
const active = mutationExecutions(queryClient).get(identity) as
|
||||
| Promise<ApplicationResult<Value>>
|
||||
| undefined;
|
||||
if (active && duplicatePolicy === "JOIN_IDENTICAL") {
|
||||
identityLease?.release();
|
||||
return active;
|
||||
}
|
||||
if (active && duplicatePolicy === "REJECT_DUPLICATE") {
|
||||
identityLease?.release();
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
error: createFailure(
|
||||
"DUPLICATE_IN_FLIGHT",
|
||||
definitionId,
|
||||
0,
|
||||
{ code: "DUPLICATE_IN_FLIGHT" },
|
||||
),
|
||||
});
|
||||
}
|
||||
setConflict(null);
|
||||
mutation.reset();
|
||||
|
||||
const previous = optimistic
|
||||
? queryClient.getQueryData(optimistic.queryKey)
|
||||
: undefined;
|
||||
if (optimistic) {
|
||||
queryClient.setQueryData(
|
||||
optimistic.queryKey,
|
||||
optimistic.update(previous, input),
|
||||
);
|
||||
}
|
||||
|
||||
const pending = mutation
|
||||
.mutateAsync(input)
|
||||
.then(async (value) => {
|
||||
for (const queryKey of invalidate) {
|
||||
await queryClient.invalidateQueries({ queryKey, exact: false });
|
||||
}
|
||||
return { ok: true as const, value };
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const pending = (async (): Promise<ApplicationResult<Value>> => {
|
||||
const mutationLease =
|
||||
invalidate.length === 0
|
||||
? null
|
||||
: invalidationCoordinator?.beginMutation(invalidate);
|
||||
if (invalidate.length > 0 && !mutationLease) {
|
||||
throw new Error("Query invalidation coordinator is not installed.");
|
||||
}
|
||||
try {
|
||||
let previous: unknown;
|
||||
let hadPreviousData = false;
|
||||
let optimisticLayer: OptimisticLayerLease | null = null;
|
||||
if (optimistic) {
|
||||
queryClient.setQueryData(optimistic.queryKey, previous);
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: optimistic.queryKey,
|
||||
exact: true,
|
||||
});
|
||||
if (scope) {
|
||||
optimisticLayer = optimisticLayers(queryClient).begin(
|
||||
optimistic.queryKey,
|
||||
input,
|
||||
optimistic.update,
|
||||
scope,
|
||||
);
|
||||
} else {
|
||||
previous = queryClient.getQueryData(optimistic.queryKey);
|
||||
hadPreviousData = previous !== undefined;
|
||||
queryClient.setQueryData(
|
||||
optimistic.queryKey,
|
||||
optimistic.update(previous, input),
|
||||
);
|
||||
}
|
||||
}
|
||||
const failure =
|
||||
error instanceof ApplicationQueryError
|
||||
? error.failure
|
||||
: unexpectedMutationFailure();
|
||||
|
||||
let value: Value;
|
||||
try {
|
||||
value = await mutation.mutateAsync(input);
|
||||
} catch (error: unknown) {
|
||||
if (optimistic) {
|
||||
if (optimisticLayer) {
|
||||
optimisticLayer.rollback();
|
||||
} else if (hadPreviousData) {
|
||||
queryClient.setQueryData(optimistic.queryKey, previous);
|
||||
} else {
|
||||
queryClient.removeQueries({
|
||||
queryKey: optimistic.queryKey,
|
||||
exact: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
const failure =
|
||||
error instanceof ApplicationQueryError
|
||||
? error.failure
|
||||
: normalizeUnknownFailure(error, {
|
||||
operationId: "APPLICATION_MUTATION",
|
||||
});
|
||||
if (failure.kind === "CONFLICT") setConflict(failure);
|
||||
return { ok: false, error: failure };
|
||||
}
|
||||
|
||||
optimisticLayer?.commit();
|
||||
try {
|
||||
await invalidationCoordinator?.invalidate(invalidate);
|
||||
} catch {
|
||||
// Cache refresh remains best effort after the server has committed.
|
||||
}
|
||||
return { ok: true, value };
|
||||
} finally {
|
||||
try {
|
||||
await mutationLease?.release();
|
||||
} catch {
|
||||
// A cache coordination defect cannot change the committed command.
|
||||
}
|
||||
}
|
||||
})()
|
||||
.catch((error: unknown) => {
|
||||
const failure = normalizeUnknownFailure(error, {
|
||||
operationId: "APPLICATION_MUTATION",
|
||||
});
|
||||
if (failure.kind === "CONFLICT") setConflict(failure);
|
||||
return { ok: false as const, error: failure };
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight.current = null;
|
||||
identityLease?.release();
|
||||
if (mutationExecutions(queryClient).get(identity) === pending) {
|
||||
mutationExecutions(queryClient).delete(identity);
|
||||
}
|
||||
});
|
||||
inFlight.current = pending;
|
||||
if (duplicatePolicy !== "ALLOW_INDEPENDENT") {
|
||||
mutationExecutions(queryClient).set(identity, pending);
|
||||
}
|
||||
return pending;
|
||||
},
|
||||
[invalidate, mutation, optimistic, queryClient],
|
||||
[
|
||||
invalidate,
|
||||
invalidationCoordinator,
|
||||
mutation,
|
||||
optimistic,
|
||||
queryClient,
|
||||
definitionId,
|
||||
duplicatePolicy,
|
||||
scope,
|
||||
],
|
||||
);
|
||||
|
||||
const resolveConflict = useCallback(async () => {
|
||||
setConflict(null);
|
||||
mutation.reset();
|
||||
for (const queryKey of invalidate) {
|
||||
await queryClient.invalidateQueries({ queryKey, exact: false });
|
||||
if (invalidate.length > 0 && !invalidationCoordinator) {
|
||||
throw new Error("Query invalidation coordinator is not installed.");
|
||||
}
|
||||
}, [invalidate, mutation, queryClient]);
|
||||
await invalidationCoordinator?.invalidate(invalidate);
|
||||
}, [invalidate, invalidationCoordinator, mutation]);
|
||||
|
||||
return Object.freeze({
|
||||
state: deriveAsyncState({
|
||||
@@ -181,14 +416,65 @@ export function useApplicationMutation<Input, Value>(
|
||||
});
|
||||
}
|
||||
|
||||
function unexpectedMutationFailure(): ApiFailure {
|
||||
return {
|
||||
kind: "UNKNOWN_FAILURE",
|
||||
code: "UNKNOWN_FAILURE",
|
||||
retryable: false,
|
||||
operationId: "APPLICATION_MUTATION",
|
||||
attemptCount: 1,
|
||||
userMessageKey: "error.unknown_failure",
|
||||
action: "contact-support",
|
||||
};
|
||||
const RUNTIME_MUTATION_EXECUTIONS = new WeakMap<
|
||||
object,
|
||||
Map<string, Promise<ApplicationResult<unknown>>>
|
||||
>();
|
||||
|
||||
function mutationExecutions(
|
||||
owner: object,
|
||||
): Map<string, Promise<ApplicationResult<unknown>>> {
|
||||
const existing = RUNTIME_MUTATION_EXECUTIONS.get(owner);
|
||||
if (existing) return existing;
|
||||
const created = new Map<string, Promise<ApplicationResult<unknown>>>();
|
||||
RUNTIME_MUTATION_EXECUTIONS.set(owner, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
const OPTIMISTIC_LAYER_RUNTIMES = new WeakMap<
|
||||
object,
|
||||
ReturnType<typeof createOptimisticLayerRuntime>
|
||||
>();
|
||||
|
||||
function optimisticLayers(
|
||||
queryClient: Parameters<typeof createOptimisticLayerRuntime>[0],
|
||||
): ReturnType<typeof createOptimisticLayerRuntime> {
|
||||
const existing = OPTIMISTIC_LAYER_RUNTIMES.get(queryClient);
|
||||
if (existing) return existing;
|
||||
const created = createOptimisticLayerRuntime(queryClient);
|
||||
OPTIMISTIC_LAYER_RUNTIMES.set(queryClient, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function isAdmissibleResult(
|
||||
value: unknown,
|
||||
maxItems: number,
|
||||
maxBytes: number,
|
||||
): boolean {
|
||||
try {
|
||||
const seen = new WeakSet<object>();
|
||||
let items = 0;
|
||||
const visit = (candidate: unknown): boolean => {
|
||||
if (candidate === null || ["string", "number", "boolean"].includes(typeof candidate)) {
|
||||
return true;
|
||||
}
|
||||
if (!candidate || typeof candidate !== "object" || seen.has(candidate)) return false;
|
||||
seen.add(candidate);
|
||||
if (Array.isArray(candidate)) {
|
||||
items += candidate.length;
|
||||
return items <= maxItems && candidate.every(visit);
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(candidate);
|
||||
return (
|
||||
(prototype === Object.prototype || prototype === null) &&
|
||||
Object.values(candidate).every(visit)
|
||||
);
|
||||
};
|
||||
return (
|
||||
visit(value) &&
|
||||
new TextEncoder().encode(JSON.stringify(value)).byteLength <= maxBytes
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,8 @@ export {
|
||||
useApplicationMutation,
|
||||
useApplicationQuery,
|
||||
type ApplicationResult,
|
||||
} from "./application-query.js";
|
||||
} from "./application-query.ts";
|
||||
export {
|
||||
QueryInvalidationProvider,
|
||||
useQueryInvalidationCoordinator,
|
||||
} from "./query-invalidation-provider.tsx";
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { hashKey, type QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type { CacheScopeSnapshot } from "../../../contracts/server-state-scope.ts";
|
||||
|
||||
export type OptimisticLayerLease = Readonly<{
|
||||
commit(): void;
|
||||
rollback(): void;
|
||||
}>;
|
||||
|
||||
type Layer = {
|
||||
id: number;
|
||||
status: "pending" | "committed";
|
||||
apply(value: unknown): unknown;
|
||||
};
|
||||
|
||||
type EntryState = {
|
||||
queryKey: readonly unknown[];
|
||||
scope: CacheScopeSnapshot;
|
||||
base: unknown;
|
||||
layers: Layer[];
|
||||
};
|
||||
|
||||
export function createOptimisticLayerRuntime(queryClient: QueryClient) {
|
||||
const entries = new Map<string, EntryState>();
|
||||
let nextId = 1;
|
||||
let writing = false;
|
||||
|
||||
queryClient.getQueryCache().subscribe((event) => {
|
||||
if (
|
||||
writing ||
|
||||
event.type !== "updated" ||
|
||||
!entries.has(event.query.queryHash)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const entry = entries.get(event.query.queryHash);
|
||||
if (!entry) return;
|
||||
entry.base = event.query.state.data;
|
||||
project(event.query.queryHash, entry);
|
||||
});
|
||||
|
||||
function project(key: string, entry: EntryState): void {
|
||||
if (!entry.scope.isCurrent()) {
|
||||
entries.delete(key);
|
||||
queryClient.removeQueries({ queryKey: entry.queryKey, exact: true });
|
||||
return;
|
||||
}
|
||||
let value = entry.base;
|
||||
try {
|
||||
for (const layer of entry.layers) value = layer.apply(value);
|
||||
} catch {
|
||||
entries.delete(key);
|
||||
return;
|
||||
}
|
||||
writing = true;
|
||||
try {
|
||||
queryClient.setQueryData(entry.queryKey, value);
|
||||
} finally {
|
||||
writing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function collapse(key: string, entry: EntryState): void {
|
||||
while (entry.layers[0]?.status === "committed") {
|
||||
const committed = entry.layers.shift();
|
||||
if (!committed) break;
|
||||
entry.base = committed.apply(entry.base);
|
||||
}
|
||||
project(key, entry);
|
||||
if (entry.layers.length === 0) entries.delete(key);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
begin<Input>(
|
||||
queryKey: readonly unknown[],
|
||||
input: Input,
|
||||
update: (previous: unknown, input: Input) => unknown,
|
||||
scope: CacheScopeSnapshot,
|
||||
): OptimisticLayerLease | null {
|
||||
if (!scope.isCurrent()) return null;
|
||||
const current = queryClient.getQueryData(queryKey);
|
||||
if (current === undefined) return null;
|
||||
const key = hashKey(queryKey);
|
||||
let entry = entries.get(key);
|
||||
if (!entry) {
|
||||
entry = { queryKey, scope, base: current, layers: [] };
|
||||
entries.set(key, entry);
|
||||
} else if (entry.scope !== scope) {
|
||||
return null;
|
||||
}
|
||||
const layer: Layer = {
|
||||
id: nextId++,
|
||||
status: "pending",
|
||||
apply: (value) => update(value, input),
|
||||
};
|
||||
entry.layers.push(layer);
|
||||
project(key, entry);
|
||||
let settled = false;
|
||||
return Object.freeze({
|
||||
commit() {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
const selected = entry?.layers.find(
|
||||
(candidate) => candidate.id === layer.id,
|
||||
);
|
||||
if (!entry || !selected) return;
|
||||
selected.status = "committed";
|
||||
collapse(key, entry);
|
||||
},
|
||||
rollback() {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (!entry) return;
|
||||
entry.layers = entry.layers.filter(
|
||||
(candidate) => candidate.id !== layer.id,
|
||||
);
|
||||
collapse(key, entry);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
} from "react";
|
||||
|
||||
import type { QueryInvalidationCoordinator } from "../../../contracts/query-invalidation.ts";
|
||||
|
||||
const QueryInvalidationContext =
|
||||
createContext<QueryInvalidationCoordinator | null>(null);
|
||||
|
||||
export function QueryInvalidationProvider({
|
||||
coordinator,
|
||||
children,
|
||||
}: Readonly<{
|
||||
coordinator: QueryInvalidationCoordinator;
|
||||
children: ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<QueryInvalidationContext.Provider value={coordinator}>
|
||||
{children}
|
||||
</QueryInvalidationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useQueryInvalidationCoordinator():
|
||||
| QueryInvalidationCoordinator
|
||||
| null {
|
||||
return useContext(QueryInvalidationContext);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
|
||||
import type {
|
||||
CacheScopeSnapshot,
|
||||
ServerStateScopeRuntime,
|
||||
} from "../../../contracts/server-state-scope.ts";
|
||||
|
||||
const ServerStateScopeContext =
|
||||
createContext<ServerStateScopeRuntime | null>(null);
|
||||
|
||||
export function ServerStateScopeProvider({
|
||||
runtime,
|
||||
children,
|
||||
}: Readonly<{
|
||||
runtime: ServerStateScopeRuntime;
|
||||
children: ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<ServerStateScopeContext.Provider value={runtime}>
|
||||
{children}
|
||||
</ServerStateScopeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useServerStateScope(): CacheScopeSnapshot {
|
||||
const runtime = useContext(ServerStateScopeContext);
|
||||
if (!runtime) throw new Error("ServerStateScopeProvider is required");
|
||||
return useSyncExternalStore(
|
||||
runtime.subscribe,
|
||||
runtime.getSnapshot,
|
||||
runtime.getSnapshot,
|
||||
);
|
||||
}
|
||||
+11
-12
@@ -1,15 +1,14 @@
|
||||
import { formatMessage } from "../i18n/index.js";
|
||||
import { formatMessage } from "../i18n/index.ts";
|
||||
|
||||
export type BootErrorShellProps = Readonly<{
|
||||
kind?: string;
|
||||
code?: string;
|
||||
buildId?: string;
|
||||
configSchemaVersion?: string;
|
||||
releaseId?: string;
|
||||
supportReference: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* kind?: string,
|
||||
* code?: string,
|
||||
* buildId?: string,
|
||||
* configSchemaVersion?: string,
|
||||
* releaseId?: string,
|
||||
* supportReference: string
|
||||
* }} props
|
||||
*/
|
||||
export function BootErrorShell({
|
||||
kind = "BOOT_CONFIG_FAILURE",
|
||||
code = "BOOT_FAILED",
|
||||
@@ -17,7 +16,7 @@ export function BootErrorShell({
|
||||
configSchemaVersion,
|
||||
releaseId,
|
||||
supportReference,
|
||||
}) {
|
||||
}: BootErrorShellProps) {
|
||||
return (
|
||||
<main role="alert">
|
||||
<h1>{formatMessage("ko-KR", "boot.failure.title")}</h1>
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
type ErrorInfo,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useLocale } from "../i18n/index.js";
|
||||
import { useLocale } from "../i18n/index.ts";
|
||||
|
||||
type RecoveryResult =
|
||||
| Readonly<{ action: "reload-once"; releasePair: string }>
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { Component } from "react";
|
||||
import { formatMessage } from "../i18n/index.js";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* children: React.ReactNode,
|
||||
* boundaryName: string,
|
||||
* routeId: string,
|
||||
* buildId: string,
|
||||
* resetKey?: string,
|
||||
* onRenderFailure?: (report: import("../../application/ports/in/application-api.js").RenderFailureReport) => void,
|
||||
* fallback?: React.ReactNode
|
||||
* }} RenderBoundaryProps
|
||||
* @typedef {{ hasError: boolean }} RenderBoundaryState
|
||||
*/
|
||||
|
||||
/** @extends {Component<RenderBoundaryProps, RenderBoundaryState>} */
|
||||
export class RenderErrorBoundary extends Component {
|
||||
/** @param {RenderBoundaryProps} props */
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch() {
|
||||
try {
|
||||
this.props.onRenderFailure?.({
|
||||
routeId: this.props.routeId,
|
||||
buildId: this.props.buildId,
|
||||
boundaryName:
|
||||
/** @type {"route" | "feature"} */ (this.props.boundaryName),
|
||||
});
|
||||
} catch {
|
||||
// Diagnostics must never recurse into another render failure.
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {RenderBoundaryProps} previous */
|
||||
componentDidUpdate(previous) {
|
||||
if (
|
||||
this.state.hasError &&
|
||||
previous.resetKey !== this.props.resetKey
|
||||
) {
|
||||
this.setState({ hasError: false });
|
||||
}
|
||||
}
|
||||
|
||||
reset = () => {
|
||||
this.setState({ hasError: false });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
this.props.fallback ?? (
|
||||
<section role="alert">
|
||||
<p>{formatMessage("ko-KR", "error.render_failure")}</p>
|
||||
<button type="button" onClick={this.reset}>
|
||||
{formatMessage("ko-KR", "action.retry")}
|
||||
</button>
|
||||
</section>
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {Omit<RenderBoundaryProps, "boundaryName">} props */
|
||||
export function RouteBoundary(props) {
|
||||
return <RenderErrorBoundary {...props} boundaryName="route" />;
|
||||
}
|
||||
|
||||
/** @param {Omit<RenderBoundaryProps, "boundaryName">} props */
|
||||
export function FeatureBoundary(props) {
|
||||
return <RenderErrorBoundary {...props} boundaryName="feature" />;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Component, type ReactNode } from "react";
|
||||
|
||||
import type { RenderFailureReport } from "../../application/ports/in/application-api.ts";
|
||||
import { formatMessage } from "../i18n/index.ts";
|
||||
|
||||
export type RenderBoundaryProps = Readonly<{
|
||||
children: ReactNode;
|
||||
boundaryName: RenderFailureReport["boundaryName"];
|
||||
routeId: string;
|
||||
buildId: string;
|
||||
resetKey?: string;
|
||||
onRenderFailure?: (report: RenderFailureReport) => void;
|
||||
fallback?: ReactNode;
|
||||
}>;
|
||||
|
||||
type RenderBoundaryState = Readonly<{ hasError: boolean }>;
|
||||
|
||||
export class RenderErrorBoundary extends Component<
|
||||
RenderBoundaryProps,
|
||||
RenderBoundaryState
|
||||
> {
|
||||
state: RenderBoundaryState = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(): RenderBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(): void {
|
||||
try {
|
||||
this.props.onRenderFailure?.({
|
||||
routeId: this.props.routeId,
|
||||
buildId: this.props.buildId,
|
||||
boundaryName: this.props.boundaryName,
|
||||
});
|
||||
} catch {
|
||||
// Diagnostics must never recurse into another render failure.
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(previous: Readonly<RenderBoundaryProps>): void {
|
||||
if (this.state.hasError && previous.resetKey !== this.props.resetKey) {
|
||||
this.setState({ hasError: false });
|
||||
}
|
||||
}
|
||||
|
||||
reset = (): void => {
|
||||
this.setState({ hasError: false });
|
||||
};
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
this.props.fallback ?? (
|
||||
<section role="alert">
|
||||
<p>{formatMessage("ko-KR", "error.render_failure")}</p>
|
||||
<button type="button" onClick={this.reset}>
|
||||
{formatMessage("ko-KR", "action.retry")}
|
||||
</button>
|
||||
</section>
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export function RouteBoundary(
|
||||
props: Omit<RenderBoundaryProps, "boundaryName">,
|
||||
) {
|
||||
return <RenderErrorBoundary {...props} boundaryName="route" />;
|
||||
}
|
||||
|
||||
export function FeatureBoundary(
|
||||
props: Omit<RenderBoundaryProps, "boundaryName">,
|
||||
) {
|
||||
return <RenderErrorBoundary {...props} boundaryName="feature" />;
|
||||
}
|
||||
+42
-35
@@ -1,11 +1,12 @@
|
||||
import { useId } from "react";
|
||||
import { useId, type ReactNode } from "react";
|
||||
|
||||
import { errorMessage } from "./error-copy.js";
|
||||
import { Button } from "./ui/button.jsx";
|
||||
import { useLocale } from "../i18n/index.js";
|
||||
import type { AsyncState } from "../../application/view-models/async-state.ts";
|
||||
import type { AppFailure } from "../../contracts/errors.ts";
|
||||
import { useLocale } from "../i18n/index.ts";
|
||||
import { errorMessage } from "./error-copy.ts";
|
||||
import { Button } from "./ui/button.ts";
|
||||
|
||||
/** @param {{ label?: string }} props */
|
||||
export function LoadingSurface({ label }) {
|
||||
export function LoadingSurface({ label }: Readonly<{ label?: string }>) {
|
||||
const { message } = useLocale();
|
||||
const accessibleLabel = label ?? message("async.loading");
|
||||
return (
|
||||
@@ -22,18 +23,17 @@ export function LoadingSurface({ label }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* title?: string,
|
||||
* description?: string,
|
||||
* action?: React.ReactNode
|
||||
* }} props
|
||||
*/
|
||||
export type EmptySurfaceProps = Readonly<{
|
||||
title?: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}>;
|
||||
|
||||
export function EmptySurface({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}) {
|
||||
}: EmptySurfaceProps) {
|
||||
const { message } = useLocale();
|
||||
return (
|
||||
<section className="ui-empty state-surface" aria-live="polite">
|
||||
@@ -44,18 +44,22 @@ export function EmptySurface({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* userMessageKey: string,
|
||||
* action: "retry" | "reauth" | "navigate" | "reload-once" |
|
||||
* "contact-support" | "none",
|
||||
* onAction?: () => void
|
||||
* }} props
|
||||
*/
|
||||
export function TerminalErrorSurface({ userMessageKey, action, onAction }) {
|
||||
export type TerminalErrorSurfaceProps = Readonly<{
|
||||
userMessageKey: string;
|
||||
action: AppFailure["action"];
|
||||
onAction?: () => void;
|
||||
}>;
|
||||
|
||||
export function TerminalErrorSurface({
|
||||
userMessageKey,
|
||||
action,
|
||||
onAction,
|
||||
}: TerminalErrorSurfaceProps) {
|
||||
const { locale, message } = useLocale();
|
||||
const messageId = useId();
|
||||
const actionLabels = Object.freeze({
|
||||
const actionLabels: Readonly<
|
||||
Record<Exclude<AppFailure["action"], "none">, string>
|
||||
> = Object.freeze({
|
||||
retry: message("action.retry"),
|
||||
reauth: message("action.reauth"),
|
||||
navigate: message("action.navigateSafe"),
|
||||
@@ -77,22 +81,21 @@ export function TerminalErrorSurface({ userMessageKey, action, onAction }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* state: ReturnType<typeof import("../../application/view-models/async-state.js").deriveAsyncState>,
|
||||
* children?: React.ReactNode,
|
||||
* onAction?: () => void,
|
||||
* onRetry?: () => void,
|
||||
* onResolveConflict?: () => void
|
||||
* }} props
|
||||
*/
|
||||
export type AsyncSurfaceProps = Readonly<{
|
||||
state: AsyncState;
|
||||
children?: ReactNode;
|
||||
onAction?: () => void;
|
||||
onRetry?: () => void;
|
||||
onResolveConflict?: () => void;
|
||||
}>;
|
||||
|
||||
export function AsyncSurface({
|
||||
state,
|
||||
children,
|
||||
onAction,
|
||||
onRetry,
|
||||
onResolveConflict,
|
||||
}) {
|
||||
}: AsyncSurfaceProps) {
|
||||
const { message } = useLocale();
|
||||
if (state.base === "initial-loading") return <LoadingSurface />;
|
||||
if (state.base === "empty") return <EmptySurface />;
|
||||
@@ -101,7 +104,11 @@ export function AsyncSurface({
|
||||
<TerminalErrorSurface
|
||||
userMessageKey={state.failure.userMessageKey}
|
||||
action={state.failure.action}
|
||||
onAction={onRetry ?? onAction}
|
||||
onAction={
|
||||
state.failure.action === "retry"
|
||||
? onRetry ?? onAction
|
||||
: onAction
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { resolveMessage } from "../i18n/index.js";
|
||||
|
||||
/** @param {string} messageKey @param {string} [locale] */
|
||||
export function errorMessage(messageKey, locale = "ko-KR") {
|
||||
return resolveMessage(locale, messageKey);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { resolveMessage } from "../i18n/index.ts";
|
||||
|
||||
export function errorMessage(
|
||||
messageKey: string,
|
||||
locale: string = "ko-KR",
|
||||
): string {
|
||||
return resolveMessage(locale, messageKey);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* title: string,
|
||||
* description?: string,
|
||||
* eyebrow?: string
|
||||
* }} props
|
||||
*/
|
||||
export function PageHeader({ title, description, eyebrow }) {
|
||||
const headingRef = useRef(/** @type {HTMLHeadingElement | null} */ (null));
|
||||
|
||||
useEffect(() => {
|
||||
headingRef.current?.focus();
|
||||
}, [title]);
|
||||
|
||||
return (
|
||||
<header className="page-header">
|
||||
{eyebrow ? <p className="page-header__eyebrow">{eyebrow}</p> : null}
|
||||
<h1 ref={headingRef} tabIndex={-1} data-route-heading>
|
||||
{title}
|
||||
</h1>
|
||||
{description ? <p className="page-header__description">{description}</p> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export type PageHeaderProps = Readonly<{
|
||||
title: string;
|
||||
description?: string;
|
||||
eyebrow?: string;
|
||||
}>;
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
eyebrow,
|
||||
}: PageHeaderProps) {
|
||||
const headingRef = useRef<HTMLHeadingElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const activeElement = document.activeElement;
|
||||
const main = document.getElementById("main-content");
|
||||
const routeOwnsFocus =
|
||||
activeElement === null ||
|
||||
activeElement === document.body ||
|
||||
activeElement === document.documentElement ||
|
||||
activeElement === main;
|
||||
if (routeOwnsFocus) {
|
||||
headingRef.current?.focus();
|
||||
}
|
||||
}, [title]);
|
||||
|
||||
return (
|
||||
<header className="page-header">
|
||||
{eyebrow ? <p className="page-header__eyebrow">{eyebrow}</p> : null}
|
||||
<h1 ref={headingRef} tabIndex={-1} data-route-heading>
|
||||
{title}
|
||||
</h1>
|
||||
{description ? (
|
||||
<p className="page-header__description">{description}</p>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
+21
-19
@@ -1,16 +1,15 @@
|
||||
import { Button } from "./ui/button.jsx";
|
||||
import { useLocale } from "../i18n/index.js";
|
||||
import { useLocale } from "../i18n/index.ts";
|
||||
import { Button } from "./ui/button.ts";
|
||||
|
||||
type StateSurfaceProps = Readonly<{
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
tone?: "neutral" | "danger" | "warning";
|
||||
}>;
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* eyebrow: string,
|
||||
* title: string,
|
||||
* description: string,
|
||||
* actionLabel?: string,
|
||||
* onAction?: () => void,
|
||||
* tone?: "neutral" | "danger" | "warning"
|
||||
* }} props
|
||||
*/
|
||||
function StateSurface({
|
||||
eyebrow,
|
||||
title,
|
||||
@@ -18,7 +17,7 @@ function StateSurface({
|
||||
actionLabel,
|
||||
onAction,
|
||||
tone = "neutral",
|
||||
}) {
|
||||
}: StateSurfaceProps) {
|
||||
return (
|
||||
<section className={`state-surface state-surface--${tone}`}>
|
||||
<p className="state-surface__eyebrow">{eyebrow}</p>
|
||||
@@ -29,8 +28,9 @@ function StateSurface({
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {{ onSignIn?: () => void }} props */
|
||||
export function AuthRequiredSurface({ onSignIn }) {
|
||||
export function AuthRequiredSurface({
|
||||
onSignIn,
|
||||
}: Readonly<{ onSignIn?: () => void }>) {
|
||||
const { message } = useLocale();
|
||||
return (
|
||||
<StateSurface
|
||||
@@ -43,8 +43,9 @@ export function AuthRequiredSurface({ onSignIn }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {{ onNavigate?: () => void }} props */
|
||||
export function ForbiddenSurface({ onNavigate }) {
|
||||
export function ForbiddenSurface({
|
||||
onNavigate,
|
||||
}: Readonly<{ onNavigate?: () => void }>) {
|
||||
const { message } = useLocale();
|
||||
return (
|
||||
<StateSurface
|
||||
@@ -58,8 +59,9 @@ export function ForbiddenSurface({ onNavigate }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {{ onNavigate?: () => void }} props */
|
||||
export function NotFoundSurface({ onNavigate }) {
|
||||
export function NotFoundSurface({
|
||||
onNavigate,
|
||||
}: Readonly<{ onNavigate?: () => void }>) {
|
||||
const { message } = useLocale();
|
||||
return (
|
||||
<StateSurface
|
||||
@@ -1 +0,0 @@
|
||||
export { Alert } from "../../design-system/primitives/core.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { Alert, type AlertProps } from "../../design-system/primitives/core.tsx";
|
||||
@@ -1 +0,0 @@
|
||||
export { Badge } from "../../design-system/primitives/core.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { Badge, type BadgeProps } from "../../design-system/primitives/core.tsx";
|
||||
@@ -1 +0,0 @@
|
||||
export { Button } from "../../design-system/primitives/core.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
Button,
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
} from "../../design-system/primitives/core.tsx";
|
||||
@@ -1 +0,0 @@
|
||||
export { Card } from "../../design-system/primitives/core.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { Card, type CardProps } from "../../design-system/primitives/core.tsx";
|
||||
@@ -1 +0,0 @@
|
||||
export { Dialog } from "../../design-system/primitives/core.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { Dialog, type DialogProps } from "../../design-system/primitives/core.tsx";
|
||||
@@ -1 +0,0 @@
|
||||
export { TextField } from "../../design-system/primitives/core.js";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
TextField,
|
||||
type TextFieldProps,
|
||||
} from "../../design-system/primitives/core.tsx";
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
Tabs,
|
||||
TextArea,
|
||||
TextField,
|
||||
} from "./index.js";
|
||||
} from "./index.ts";
|
||||
|
||||
const meta = {
|
||||
title: "Platform/Design System",
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
SearchGlyph,
|
||||
SuccessGlyph,
|
||||
WarningGlyph,
|
||||
} from "./vendors/lucide.js";
|
||||
} from "./vendors/lucide.tsx";
|
||||
|
||||
export type SemanticIconProps = Readonly<{
|
||||
label?: string;
|
||||
|
||||
@@ -12,7 +12,7 @@ export {
|
||||
Spinner,
|
||||
TextField,
|
||||
VisuallyHidden,
|
||||
} from "./primitives/core.js";
|
||||
} from "./primitives/core.tsx";
|
||||
export type {
|
||||
AlertProps,
|
||||
BadgeProps,
|
||||
@@ -25,7 +25,7 @@ export type {
|
||||
LinkButtonProps,
|
||||
SpinnerProps,
|
||||
TextFieldProps,
|
||||
} from "./primitives/core.js";
|
||||
} from "./primitives/core.tsx";
|
||||
export {
|
||||
Checkbox,
|
||||
RadioGroup,
|
||||
@@ -33,7 +33,7 @@ export {
|
||||
Select,
|
||||
Switch,
|
||||
TextArea,
|
||||
} from "./primitives/forms.js";
|
||||
} from "./primitives/forms.tsx";
|
||||
export type {
|
||||
CheckboxProps,
|
||||
RadioGroupProps,
|
||||
@@ -43,16 +43,16 @@ export type {
|
||||
SelectProps,
|
||||
SwitchProps,
|
||||
TextAreaProps,
|
||||
} from "./primitives/forms.js";
|
||||
} from "./primitives/forms.tsx";
|
||||
export {
|
||||
ProgressBar,
|
||||
Separator,
|
||||
Skeleton,
|
||||
} from "./primitives/feedback.js";
|
||||
} from "./primitives/feedback.tsx";
|
||||
export type {
|
||||
ProgressBarProps,
|
||||
SkeletonProps,
|
||||
} from "./primitives/feedback.js";
|
||||
} from "./primitives/feedback.tsx";
|
||||
export {
|
||||
ConfirmationDialog,
|
||||
Drawer,
|
||||
@@ -61,23 +61,23 @@ export {
|
||||
ToastProvider,
|
||||
Tooltip,
|
||||
useToast,
|
||||
} from "./primitives/overlays.js";
|
||||
} from "./primitives/overlays.tsx";
|
||||
export type {
|
||||
DrawerProps,
|
||||
MenuItemDefinition,
|
||||
MenuProps,
|
||||
PopoverProps,
|
||||
TooltipProps,
|
||||
} from "./primitives/overlays.js";
|
||||
} from "./primitives/overlays.tsx";
|
||||
export {
|
||||
Breadcrumbs,
|
||||
Pagination,
|
||||
Tabs,
|
||||
} from "./primitives/navigation.js";
|
||||
} from "./primitives/navigation.tsx";
|
||||
export type {
|
||||
BreadcrumbItem,
|
||||
TabDefinition,
|
||||
} from "./primitives/navigation.js";
|
||||
} from "./primitives/navigation.tsx";
|
||||
export {
|
||||
CloseIcon,
|
||||
ErrorIcon,
|
||||
@@ -88,48 +88,48 @@ export {
|
||||
SearchIcon,
|
||||
SuccessIcon,
|
||||
WarningIcon,
|
||||
} from "./icons/semantic-icons.js";
|
||||
} from "./icons/semantic-icons.tsx";
|
||||
export type {
|
||||
SemanticIconProps,
|
||||
} from "./icons/semantic-icons.js";
|
||||
} from "./icons/semantic-icons.tsx";
|
||||
export {
|
||||
DESIGN_TOKEN_CONTRACT,
|
||||
REQUIRED_COMPONENT_TOKENS,
|
||||
REQUIRED_PRIMITIVE_TOKENS,
|
||||
REQUIRED_SEMANTIC_TOKENS,
|
||||
} from "./tokens/token-contract.js";
|
||||
} from "./tokens/token-contract.ts";
|
||||
export {
|
||||
AccessSurface,
|
||||
DataTable,
|
||||
DisclosureGroup,
|
||||
PaginationBar,
|
||||
SearchFilterToolbar,
|
||||
} from "./patterns/common-patterns.js";
|
||||
} from "./patterns/common-patterns.tsx";
|
||||
export type {
|
||||
AccessSurfaceProps,
|
||||
DataTableColumn,
|
||||
DisclosureDefinition,
|
||||
} from "./patterns/common-patterns.js";
|
||||
} from "./patterns/common-patterns.tsx";
|
||||
|
||||
export {
|
||||
AsyncSurface,
|
||||
EmptySurface,
|
||||
LoadingSurface,
|
||||
TerminalErrorSurface,
|
||||
} from "../components/async-surface.jsx";
|
||||
export { PageHeader } from "../components/page-header.jsx";
|
||||
} from "../components/async-surface.tsx";
|
||||
export { PageHeader } from "../components/page-header.tsx";
|
||||
export {
|
||||
AuthRequiredSurface,
|
||||
ForbiddenSurface,
|
||||
NotFoundSurface,
|
||||
} from "../components/state-surfaces.jsx";
|
||||
} from "../components/state-surfaces.tsx";
|
||||
export {
|
||||
DetailPage,
|
||||
CollectionPage,
|
||||
FormPage,
|
||||
StandardPage,
|
||||
StatusPage,
|
||||
} from "../templates/index.js";
|
||||
} from "../templates/index.ts";
|
||||
export {
|
||||
DirtyNavigationDialog,
|
||||
ErrorSummary,
|
||||
@@ -138,9 +138,9 @@ export {
|
||||
FormField,
|
||||
useAppForm,
|
||||
useDirtyNavigationGuard,
|
||||
} from "../forms/index.js";
|
||||
} from "../forms/index.ts";
|
||||
export type {
|
||||
PageActionDefinition,
|
||||
PageHeading,
|
||||
} from "../templates/page-templates.js";
|
||||
export type { FormResult } from "../forms/form-contracts.js";
|
||||
} from "../templates/page-templates.tsx";
|
||||
export type { FormResult } from "../forms/form-contracts.ts";
|
||||
|
||||
@@ -4,9 +4,9 @@ import {
|
||||
AuthRequiredSurface,
|
||||
ForbiddenSurface,
|
||||
NotFoundSurface,
|
||||
} from "../../components/state-surfaces.jsx";
|
||||
import { Button } from "../primitives/core.js";
|
||||
import { Pagination } from "../primitives/navigation.js";
|
||||
} from "../../components/state-surfaces.tsx";
|
||||
import { Button } from "../primitives/core.tsx";
|
||||
import { Pagination } from "../primitives/navigation.tsx";
|
||||
|
||||
export type DataTableColumn<Row> = Readonly<{
|
||||
id: string;
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { CloseIcon } from "../icons/semantic-icons.js";
|
||||
import { useLocale } from "../../i18n/index.js";
|
||||
import { CloseIcon } from "../icons/semantic-icons.tsx";
|
||||
import { useLocale } from "../../i18n/index.ts";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
||||
export type ButtonSize = "default" | "compact";
|
||||
@@ -322,6 +322,7 @@ export type DialogProps = Readonly<{
|
||||
children?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
className?: string;
|
||||
returnFocusRef?: React.RefObject<HTMLElement | null>;
|
||||
}>;
|
||||
|
||||
export const Dialog = forwardRef<HTMLDialogElement, DialogProps>(
|
||||
@@ -335,6 +336,7 @@ export const Dialog = forwardRef<HTMLDialogElement, DialogProps>(
|
||||
children,
|
||||
actions,
|
||||
className = "",
|
||||
returnFocusRef,
|
||||
},
|
||||
forwardedRef,
|
||||
) {
|
||||
@@ -354,9 +356,10 @@ export const Dialog = forwardRef<HTMLDialogElement, DialogProps>(
|
||||
|
||||
if (open) {
|
||||
previousFocusRef.current =
|
||||
document.activeElement instanceof HTMLElement
|
||||
returnFocusRef?.current ??
|
||||
(document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
: null);
|
||||
if (!dialog.open) {
|
||||
if (typeof dialog.showModal === "function") dialog.showModal();
|
||||
else dialog.setAttribute("open", "");
|
||||
@@ -382,12 +385,11 @@ export const Dialog = forwardRef<HTMLDialogElement, DialogProps>(
|
||||
previousFocus.focus();
|
||||
}
|
||||
};
|
||||
if (typeof globalThis.requestAnimationFrame === "function") {
|
||||
const frame = globalThis.requestAnimationFrame(restoreFocus);
|
||||
return () => globalThis.cancelAnimationFrame(frame);
|
||||
}
|
||||
queueMicrotask(restoreFocus);
|
||||
}, [open]);
|
||||
const timer = globalThis.setTimeout(restoreFocus, 0);
|
||||
return () => {
|
||||
globalThis.clearTimeout(timer);
|
||||
};
|
||||
}, [open, returnFocusRef]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
|
||||
@@ -6,10 +6,10 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { CloseIcon, SearchIcon } from "../icons/semantic-icons.js";
|
||||
import { Field, IconButton } from "./core.js";
|
||||
import type { TextFieldProps } from "./core.js";
|
||||
import { useLocale } from "../../i18n/index.js";
|
||||
import { CloseIcon, SearchIcon } from "../icons/semantic-icons.tsx";
|
||||
import { Field, IconButton } from "./core.tsx";
|
||||
import type { TextFieldProps } from "./core.tsx";
|
||||
import { useLocale } from "../../i18n/index.ts";
|
||||
|
||||
type FieldCopy = Readonly<{
|
||||
id?: string;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useId, useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "../../i18n/index.js";
|
||||
import { NextIcon, PreviousIcon } from "../icons/semantic-icons.js";
|
||||
import { IconButton, LinkButton } from "./core.js";
|
||||
import { useLocale } from "../../i18n/index.ts";
|
||||
import { NextIcon, PreviousIcon } from "../icons/semantic-icons.tsx";
|
||||
import { IconButton, LinkButton } from "./core.tsx";
|
||||
|
||||
export type BreadcrumbItem = Readonly<{
|
||||
label: string;
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { CloseIcon } from "../icons/semantic-icons.js";
|
||||
import { Button, Dialog, IconButton } from "./core.js";
|
||||
import { useLocale } from "../../i18n/index.js";
|
||||
import { CloseIcon } from "../icons/semantic-icons.tsx";
|
||||
import { Button, Dialog, IconButton } from "./core.tsx";
|
||||
import { useLocale } from "../../i18n/index.ts";
|
||||
|
||||
export type DrawerProps = Readonly<{
|
||||
open: boolean;
|
||||
@@ -19,6 +19,7 @@ export type DrawerProps = Readonly<{
|
||||
title: string;
|
||||
closeLabel?: string;
|
||||
placement?: "start" | "end";
|
||||
returnFocusRef?: React.RefObject<HTMLElement | null>;
|
||||
children: React.ReactNode;
|
||||
}>;
|
||||
|
||||
@@ -28,6 +29,7 @@ export function Drawer({
|
||||
title,
|
||||
closeLabel,
|
||||
placement = "start",
|
||||
returnFocusRef,
|
||||
children,
|
||||
}: DrawerProps) {
|
||||
return (
|
||||
@@ -36,6 +38,7 @@ export function Drawer({
|
||||
closeLabel={closeLabel}
|
||||
onClose={onClose}
|
||||
open={open}
|
||||
returnFocusRef={returnFocusRef}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
|
||||
+3
-4
@@ -1,8 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
import { PageHeader } from "../design-system/index.js";
|
||||
import { useSession } from "../providers/session-provider.jsx";
|
||||
import { PageHeader } from "../design-system/index.ts";
|
||||
import { useSession } from "../providers/session-provider.tsx";
|
||||
|
||||
export default function AuthExamplePage() {
|
||||
const location = useLocation();
|
||||
@@ -10,8 +10,7 @@ export default function AuthExamplePage() {
|
||||
const [pending, setPending] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
/** @param {() => Promise<unknown>} action */
|
||||
async function execute(action) {
|
||||
async function execute(action: () => Promise<unknown>): Promise<void> {
|
||||
setPending(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { deriveAsyncState } from "../../application/view-models/async-state.js";
|
||||
import { createFailure } from "../../contracts/errors.js";
|
||||
import { deriveAsyncState } from "../../application/view-models/async-state.ts";
|
||||
import { createFailure } from "../../contracts/errors.ts";
|
||||
import {
|
||||
AsyncSurface,
|
||||
EmptySurface,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
PageHeader,
|
||||
} from "../design-system/index.js";
|
||||
} from "../design-system/index.ts";
|
||||
|
||||
export default function StateGalleryPage() {
|
||||
const [lastAction, setLastAction] = useState(
|
||||
+3
-4
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, type FormEvent } from "react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
ToastProvider,
|
||||
Tooltip,
|
||||
useToast,
|
||||
} from "../design-system/index.js";
|
||||
} from "../design-system/index.ts";
|
||||
|
||||
const COLOR_TOKENS = Object.freeze([
|
||||
["Surface", "--color-surface"],
|
||||
@@ -58,8 +58,7 @@ function UiGalleryContent() {
|
||||
? "프로젝트 이름을 입력해 주세요."
|
||||
: undefined;
|
||||
|
||||
/** @param {React.FormEvent<HTMLFormElement>} event */
|
||||
function submitExample(event) {
|
||||
function submitExample(event: FormEvent<HTMLFormElement>): void {
|
||||
event.preventDefault();
|
||||
setFieldTouched(true);
|
||||
if (projectName.trim().length === 0) {
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useId, type FormHTMLAttributes, type ReactNode } from "react";
|
||||
|
||||
import { TextField } from "../components/ui/text-field.jsx";
|
||||
import { useLocale } from "../i18n/index.js";
|
||||
import { TextField } from "../components/ui/text-field.ts";
|
||||
import { useLocale } from "../i18n/index.ts";
|
||||
import type {
|
||||
FieldErrors,
|
||||
FieldName,
|
||||
FormValues,
|
||||
} from "./form-contracts.js";
|
||||
} from "./form-contracts.ts";
|
||||
|
||||
export function Form(
|
||||
props: FormHTMLAttributes<HTMLFormElement> & Readonly<{ pending?: boolean }>,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { ApiFailure } from "../../contracts/errors.js";
|
||||
import type { Result } from "../../application/result.ts";
|
||||
import type { AppFailure } from "../../contracts/errors.ts";
|
||||
import {
|
||||
formatMessage,
|
||||
type ParameterlessMessageKey,
|
||||
} from "../i18n/index.js";
|
||||
} from "../i18n/index.ts";
|
||||
|
||||
export type FormValues = Readonly<Record<string, unknown>>;
|
||||
export type FieldName<Values extends FormValues> = Extract<keyof Values, string>;
|
||||
@@ -10,9 +11,7 @@ export type FieldErrors<Values extends FormValues> = Readonly<
|
||||
Partial<Record<FieldName<Values>, string>>
|
||||
>;
|
||||
|
||||
export type FormResult<Value> =
|
||||
| Readonly<{ ok: true; value: Value }>
|
||||
| Readonly<{ ok: false; error: ApiFailure }>;
|
||||
export type FormResult<Value> = Result<Value>;
|
||||
|
||||
export type FormResultState =
|
||||
| "idle"
|
||||
@@ -50,7 +49,7 @@ export function validationMessage(
|
||||
}
|
||||
|
||||
export function mapValidationFailureToFields<Values extends FormValues>(
|
||||
failure: ApiFailure,
|
||||
failure: AppFailure,
|
||||
allowedFields: readonly FieldName<Values>[],
|
||||
message: MessageResolver = defaultMessage,
|
||||
): MappedValidationFailure<Values> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from "./form-components.js";
|
||||
export * from "./form-contracts.js";
|
||||
export * from "./use-app-form.js";
|
||||
export * from "./use-dirty-navigation-guard.js";
|
||||
export * from "./form-components.tsx";
|
||||
export * from "./form-contracts.ts";
|
||||
export * from "./use-app-form.ts";
|
||||
export * from "./use-dirty-navigation-guard.tsx";
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { ZodType, ZodIssue } from "zod";
|
||||
import {
|
||||
useLocale,
|
||||
type ParameterlessMessageKey,
|
||||
} from "../i18n/index.js";
|
||||
} from "../i18n/index.ts";
|
||||
|
||||
import {
|
||||
mapValidationFailureToFields,
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
type FormResult,
|
||||
type FormResultState,
|
||||
type FormValues,
|
||||
} from "./form-contracts.js";
|
||||
} from "./form-contracts.ts";
|
||||
|
||||
type AppFormOptions<
|
||||
Values extends FormValues,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback } from "react";
|
||||
import { useBeforeUnload, useBlocker } from "react-router-dom";
|
||||
|
||||
import { Button } from "../components/ui/button.jsx";
|
||||
import { Dialog } from "../components/ui/dialog.jsx";
|
||||
import { useLocale } from "../i18n/index.js";
|
||||
import { Button } from "../components/ui/button.ts";
|
||||
import { Dialog } from "../components/ui/dialog.ts";
|
||||
import { useLocale } from "../i18n/index.ts";
|
||||
|
||||
export function useDirtyNavigationGuard(when: boolean) {
|
||||
const blocker = useBlocker(when);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { INSTALLED_MESSAGE_CATALOGS } from "../../features/installed-feature-messages.js";
|
||||
import { INSTALLED_MESSAGE_CATALOGS } from "../../features/installed-feature-messages.ts";
|
||||
|
||||
const PLATFORM_KO_MESSAGES = {
|
||||
"common.unavailable": "요청한 문구를 표시할 수 없습니다.",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizeLocale, type SupportedLocale } from "./message-contract.js";
|
||||
import { normalizeLocale, type SupportedLocale } from "./message-contract.ts";
|
||||
|
||||
const FORMAT_FALLBACK = "—";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { LocaleProvider, useLocale } from "./locale-provider.js";
|
||||
export { LocaleProvider, useLocale } from "./locale-provider.tsx";
|
||||
export {
|
||||
catalogKeys,
|
||||
fallbackMessage,
|
||||
@@ -9,15 +9,15 @@ export {
|
||||
normalizeLocale,
|
||||
resolveMessage,
|
||||
SUPPORTED_LOCALES,
|
||||
} from "./message-contract.js";
|
||||
} from "./message-contract.ts";
|
||||
export type {
|
||||
MessageArguments,
|
||||
MessageParameters,
|
||||
ParameterlessMessageKey,
|
||||
SupportedLocale,
|
||||
TextDirection,
|
||||
} from "./message-contract.js";
|
||||
export type { MessageKey } from "./catalog.js";
|
||||
} from "./message-contract.ts";
|
||||
export type { MessageKey } from "./catalog.ts";
|
||||
export {
|
||||
FORMAT_FALLBACK,
|
||||
formatDate,
|
||||
@@ -26,4 +26,4 @@ export {
|
||||
formatRelativeTime,
|
||||
selectMessage,
|
||||
selectPlural,
|
||||
} from "./formatters.js";
|
||||
} from "./formatters.ts";
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
formatRelativeTime,
|
||||
selectMessage,
|
||||
selectPlural,
|
||||
} from "./formatters.js";
|
||||
} from "./formatters.ts";
|
||||
import {
|
||||
formatMessage,
|
||||
localeDirection,
|
||||
@@ -22,8 +22,8 @@ import {
|
||||
type MessageArguments,
|
||||
type SupportedLocale,
|
||||
type TextDirection,
|
||||
} from "./message-contract.js";
|
||||
import type { MessageKey } from "./catalog.js";
|
||||
} from "./message-contract.ts";
|
||||
import type { MessageKey } from "./catalog.ts";
|
||||
|
||||
type LocaleContextValue = Readonly<{
|
||||
locale: SupportedLocale;
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
KO_MESSAGES,
|
||||
MESSAGE_CATALOGS,
|
||||
type MessageKey,
|
||||
} from "./catalog.js";
|
||||
} from "./catalog.ts";
|
||||
|
||||
export const SUPPORTED_LOCALES = Object.freeze([
|
||||
"ko-KR",
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||
|
||||
import type { SessionState } from "../../application/ports/in/application-api.ts";
|
||||
import { normalizeColorSchemePreference } from "../../application/policies/color-scheme.ts";
|
||||
import {
|
||||
NAVIGATION_ROUTES,
|
||||
routePath,
|
||||
} from "../../features/installed-feature-contracts.js";
|
||||
} from "../../features/installed-feature-contracts.ts";
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
IconButton,
|
||||
MenuIcon,
|
||||
Select,
|
||||
} from "../design-system/index.js";
|
||||
import { useLocale } from "../i18n/index.js";
|
||||
import { useSession } from "../providers/session-provider.jsx";
|
||||
import { useTheme } from "../providers/theme-provider.jsx";
|
||||
} from "../design-system/index.ts";
|
||||
import {
|
||||
normalizeLocale,
|
||||
useLocale,
|
||||
type MessageKey,
|
||||
} from "../i18n/index.ts";
|
||||
import { useSession } from "../providers/session-provider.tsx";
|
||||
import { useTheme } from "../providers/theme-provider.tsx";
|
||||
|
||||
const SESSION_MESSAGE_KEYS = Object.freeze({
|
||||
authenticated: "shell.session.authenticated",
|
||||
unauthenticated: "shell.session.unauthenticated",
|
||||
"recovery-pending": "shell.session.recoveryPending",
|
||||
"integration-failed": "shell.session.integrationFailed",
|
||||
});
|
||||
} satisfies Readonly<Record<SessionState, MessageKey>>);
|
||||
|
||||
export function AppShell() {
|
||||
const location = useLocation();
|
||||
@@ -29,6 +35,7 @@ export function AppShell() {
|
||||
const { preference, setPreference } = useTheme();
|
||||
const { locale, setLocale, message } = useLocale();
|
||||
const [navigationOpen, setNavigationOpen] = useState(false);
|
||||
const navigationTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [sessionActionPending, setSessionActionPending] = useState(false);
|
||||
const [sessionActionFailed, setSessionActionFailed] = useState(false);
|
||||
|
||||
@@ -76,6 +83,7 @@ export function AppShell() {
|
||||
aria-controls="mobile-primary-navigation"
|
||||
aria-expanded={navigationOpen}
|
||||
onClick={() => setNavigationOpen((open) => !open)}
|
||||
ref={navigationTriggerRef}
|
||||
variant="secondary"
|
||||
>
|
||||
<MenuIcon />
|
||||
@@ -95,11 +103,7 @@ export function AppShell() {
|
||||
]}
|
||||
value={preference}
|
||||
onChange={(event) =>
|
||||
setPreference(
|
||||
/** @type {"system" | "light" | "dark"} */ (
|
||||
event.currentTarget.value
|
||||
),
|
||||
)
|
||||
setPreference(normalizeColorSchemePreference(event.currentTarget.value))
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
@@ -114,11 +118,7 @@ export function AppShell() {
|
||||
]}
|
||||
value={locale}
|
||||
onChange={(event) =>
|
||||
setLocale(
|
||||
/** @type {import("../i18n/index.js").SupportedLocale} */ (
|
||||
event.currentTarget.value
|
||||
),
|
||||
)
|
||||
setLocale(normalizeLocale(event.currentTarget.value))
|
||||
}
|
||||
/>
|
||||
<span className="session-status" data-state={sessionState}>
|
||||
@@ -152,6 +152,7 @@ export function AppShell() {
|
||||
closeLabel={message("shell.closeMenu")}
|
||||
onClose={() => setNavigationOpen(false)}
|
||||
open={navigationOpen}
|
||||
returnFocusRef={navigationTriggerRef}
|
||||
title={message("shell.menu")}
|
||||
>
|
||||
<PrimaryNavigation id="mobile-primary-navigation" />
|
||||
@@ -163,10 +164,7 @@ export function AppShell() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ id: string }} props
|
||||
*/
|
||||
function PrimaryNavigation({ id }) {
|
||||
function PrimaryNavigation({ id }: Readonly<{ id: string }>) {
|
||||
const { resolve, message } = useLocale();
|
||||
return (
|
||||
<nav id={id} aria-label={message("shell.primaryNavigation")}>
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { routePath } from "../../features/installed-feature-contracts.js";
|
||||
import { PageHeader } from "../design-system/index.js";
|
||||
import { useApplication } from "../providers/application-provider.js";
|
||||
import { routePath } from "../../features/installed-feature-contracts.ts";
|
||||
import { PageHeader } from "../design-system/index.ts";
|
||||
import { useApplication } from "../providers/application-provider.tsx";
|
||||
|
||||
const READINESS_ITEMS = Object.freeze([
|
||||
{
|
||||
@@ -22,11 +22,9 @@ const READINESS_ITEMS = Object.freeze([
|
||||
|
||||
export default function HomePage() {
|
||||
const { runtime } = useApplication();
|
||||
const [release, setRelease] = useState(
|
||||
/** @type {Awaited<ReturnType<typeof runtime.getReleaseSummary>> | null} */ (
|
||||
null
|
||||
),
|
||||
);
|
||||
const [release, setRelease] = useState<
|
||||
Awaited<ReturnType<typeof runtime.getReleaseSummary>> | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { routePath } from "../../features/installed-feature-contracts.js";
|
||||
import { PageHeader } from "../design-system/index.js";
|
||||
import { useLocale } from "../i18n/index.js";
|
||||
import { routePath } from "../../features/installed-feature-contracts.ts";
|
||||
import { PageHeader } from "../design-system/index.ts";
|
||||
import { useLocale } from "../i18n/index.ts";
|
||||
|
||||
export default function NotFoundPage() {
|
||||
const { message } = useLocale();
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
useContext,
|
||||
} from "react";
|
||||
|
||||
import type { ApplicationApi } from "../../application/create-application.js";
|
||||
import type { ApplicationApi } from "../../application/create-application.ts";
|
||||
|
||||
const ApplicationContext = createContext<ApplicationApi | null>(null);
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { createContext, useContext, useMemo, useSyncExternalStore } from "react";
|
||||
import { useApplication } from "./application-provider.js";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* sessionState: import("../../application/ports/in/application-api.js").SessionState,
|
||||
* beginSignIn: import("../../application/ports/in/application-api.js").ApplicationApi["session"]["beginSignIn"],
|
||||
* signOut: import("../../application/ports/in/application-api.js").ApplicationApi["session"]["signOut"],
|
||||
* recover: import("../../application/ports/in/application-api.js").ApplicationApi["session"]["recover"]
|
||||
* }} SessionContextValue
|
||||
*/
|
||||
|
||||
const SessionContext = createContext(
|
||||
/** @type {SessionContextValue | null} */ (null),
|
||||
);
|
||||
|
||||
/**
|
||||
* @param {{ children: React.ReactNode }} props
|
||||
*/
|
||||
export function SessionProvider({ children }) {
|
||||
const { session } = useApplication();
|
||||
const sessionState = useSyncExternalStore(
|
||||
session.subscribe,
|
||||
session.getSnapshot,
|
||||
session.getSnapshot,
|
||||
);
|
||||
const value = useMemo(
|
||||
() =>
|
||||
Object.freeze({
|
||||
sessionState,
|
||||
beginSignIn: session.beginSignIn,
|
||||
signOut: session.signOut,
|
||||
recover: session.recover,
|
||||
}),
|
||||
[session, sessionState],
|
||||
);
|
||||
|
||||
return (
|
||||
<SessionContext.Provider value={value}>{children}</SessionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSession() {
|
||||
const session = useContext(SessionContext);
|
||||
if (!session) {
|
||||
throw new Error("SessionProvider is required");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
useSyncExternalStore,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import type {
|
||||
ApplicationApi,
|
||||
SessionState,
|
||||
} from "../../application/ports/in/application-api.ts";
|
||||
import { useApplication } from "./application-provider.tsx";
|
||||
|
||||
export type SessionContextValue = Readonly<{
|
||||
sessionState: SessionState;
|
||||
beginSignIn: ApplicationApi["session"]["beginSignIn"];
|
||||
signOut: ApplicationApi["session"]["signOut"];
|
||||
recover: ApplicationApi["session"]["recover"];
|
||||
}>;
|
||||
|
||||
const SessionContext = createContext<SessionContextValue | null>(null);
|
||||
|
||||
export function SessionProvider({
|
||||
children,
|
||||
}: Readonly<{ children: ReactNode }>) {
|
||||
const { session } = useApplication();
|
||||
const sessionState = useSyncExternalStore(
|
||||
session.subscribe,
|
||||
session.getSnapshot,
|
||||
session.getSnapshot,
|
||||
);
|
||||
const value = useMemo<SessionContextValue>(
|
||||
() =>
|
||||
Object.freeze({
|
||||
sessionState,
|
||||
beginSignIn: session.beginSignIn,
|
||||
signOut: session.signOut,
|
||||
recover: session.recover,
|
||||
}),
|
||||
[session, sessionState],
|
||||
);
|
||||
|
||||
return (
|
||||
<SessionContext.Provider value={value}>{children}</SessionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSession(): SessionContextValue {
|
||||
const session = useContext(SessionContext);
|
||||
if (!session) {
|
||||
throw new Error("SessionProvider is required");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
+20
-22
@@ -5,37 +5,36 @@ import {
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import type { ColorSchemePreference } from "../../application/ports/in/application-api.ts";
|
||||
import {
|
||||
normalizeColorSchemePreference,
|
||||
resolveColorScheme,
|
||||
} from "../../application/policies/color-scheme.js";
|
||||
import { useApplication } from "./application-provider.js";
|
||||
} from "../../application/policies/color-scheme.ts";
|
||||
import { useApplication } from "./application-provider.tsx";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* preference: "system" | "light" | "dark",
|
||||
* resolvedTheme: "light" | "dark",
|
||||
* setPreference: (preference: "system" | "light" | "dark") => void
|
||||
* }} ThemeContextValue
|
||||
*/
|
||||
export type ThemeContextValue = Readonly<{
|
||||
preference: ColorSchemePreference;
|
||||
resolvedTheme: "light" | "dark";
|
||||
setPreference(preference: ColorSchemePreference): void;
|
||||
}>;
|
||||
|
||||
const ThemeContext = createContext(/** @type {ThemeContextValue | null} */ (null));
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
function systemPrefersDark() {
|
||||
function systemPrefersDark(): boolean {
|
||||
return (
|
||||
typeof window.matchMedia === "function" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ children: React.ReactNode }} props
|
||||
*/
|
||||
export function ThemeProvider({ children }) {
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
}: Readonly<{ children: ReactNode }>) {
|
||||
const { preferences } = useApplication();
|
||||
const [preference, updatePreference] = useState(
|
||||
const [preference, updatePreference] = useState<ColorSchemePreference>(
|
||||
preferences.getColorScheme,
|
||||
);
|
||||
const [darkSystemTheme, setDarkSystemTheme] = useState(systemPrefersDark);
|
||||
@@ -44,8 +43,8 @@ export function ThemeProvider({ children }) {
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== "function") return undefined;
|
||||
const query = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
/** @param {MediaQueryListEvent} event */
|
||||
const update = (event) => setDarkSystemTheme(event.matches);
|
||||
const update = (event: MediaQueryListEvent) =>
|
||||
setDarkSystemTheme(event.matches);
|
||||
setDarkSystemTheme(query.matches);
|
||||
query.addEventListener("change", update);
|
||||
return () => query.removeEventListener("change", update);
|
||||
@@ -57,13 +56,12 @@ export function ThemeProvider({ children }) {
|
||||
document.documentElement.style.colorScheme = resolvedTheme;
|
||||
}, [preference, resolvedTheme]);
|
||||
|
||||
const value = useMemo(
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() =>
|
||||
Object.freeze({
|
||||
preference,
|
||||
resolvedTheme,
|
||||
/** @param {"system" | "light" | "dark"} next */
|
||||
setPreference(next) {
|
||||
setPreference(next: ColorSchemePreference) {
|
||||
const normalized = normalizeColorSchemePreference(next);
|
||||
updatePreference(normalized);
|
||||
preferences.setColorScheme(normalized);
|
||||
@@ -75,7 +73,7 @@ export function ThemeProvider({ children }) {
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const theme = useContext(ThemeContext);
|
||||
if (!theme) throw new Error("ThemeProvider is required");
|
||||
return theme;
|
||||
@@ -1,8 +1,6 @@
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
Suspense,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
@@ -21,38 +19,31 @@ import {
|
||||
import {
|
||||
getRoute,
|
||||
ROUTE_REGISTRY,
|
||||
} from "../../features/installed-feature-contracts.js";
|
||||
import type { RouteDefinition } from "../../contracts/routes.js";
|
||||
} from "../../features/installed-feature-contracts.ts";
|
||||
import type { RouteDefinition } from "../../contracts/routes.ts";
|
||||
import {
|
||||
FeatureBoundary,
|
||||
RouteBoundary,
|
||||
} from "../boundaries/render-error-boundary.jsx";
|
||||
import { ChunkRecoveryBoundary } from "../boundaries/chunk-recovery-boundary.js";
|
||||
import { Button, PageHeader } from "../design-system/index.js";
|
||||
import { LocaleProvider, useLocale } from "../i18n/index.js";
|
||||
import { AppShell } from "../layouts/app-shell.jsx";
|
||||
import { useApplication } from "../providers/application-provider.js";
|
||||
import { SessionProvider, useSession } from "../providers/session-provider.jsx";
|
||||
import { ThemeProvider } from "../providers/theme-provider.jsx";
|
||||
} from "../boundaries/render-error-boundary.tsx";
|
||||
import { ChunkRecoveryBoundary } from "../boundaries/chunk-recovery-boundary.tsx";
|
||||
import { Button, PageHeader } from "../design-system/index.ts";
|
||||
import { LocaleProvider, useLocale } from "../i18n/index.ts";
|
||||
import { AppShell } from "../layouts/app-shell.tsx";
|
||||
import { useApplication } from "../providers/application-provider.tsx";
|
||||
import { SessionProvider, useSession } from "../providers/session-provider.tsx";
|
||||
import { ThemeProvider } from "../providers/theme-provider.tsx";
|
||||
import {
|
||||
createRedirectLoopGuard,
|
||||
decideRouteAccess,
|
||||
} from "./navigation-policy.js";
|
||||
} from "./navigation-policy.ts";
|
||||
import {
|
||||
buildRouteUrl,
|
||||
parseRouteInput,
|
||||
type ParsedRouteInput,
|
||||
type RouteId,
|
||||
} from "./route-codecs.js";
|
||||
import { ROUTE_RUNTIME } from "../../features/installed-feature-runtimes.js";
|
||||
|
||||
const RouteInputContext = createContext<ParsedRouteInput | null>(null);
|
||||
|
||||
export function useRouteInput(): ParsedRouteInput {
|
||||
const input = useContext(RouteInputContext);
|
||||
if (!input) throw new Error("Registered route input is required");
|
||||
return input;
|
||||
}
|
||||
} from "./route-codecs.ts";
|
||||
import { ROUTE_RUNTIME } from "../../features/installed-feature-runtimes.tsx";
|
||||
import type { ParsedRouteInput } from "./route-contract.ts";
|
||||
import { RouteInputProvider } from "./route-input.tsx";
|
||||
|
||||
function RouteLoadingSurface({ definition }: { definition: RouteDefinition }) {
|
||||
const { message, resolve } = useLocale();
|
||||
@@ -261,7 +252,7 @@ function RegisteredRoute({
|
||||
if (!parsed.success) return <InvalidRouteSurface code={parsed.code} />;
|
||||
|
||||
const content = (
|
||||
<RouteInputContext.Provider value={parsed.data}>
|
||||
<RouteInputProvider input={parsed.data}>
|
||||
<CanonicalRouteRedirect input={parsed.data} />
|
||||
<RouteLifecycle definition={definition} buildId={buildId} />
|
||||
<Suspense fallback={<RouteLoadingSurface definition={definition} />}>
|
||||
@@ -272,7 +263,7 @@ function RegisteredRoute({
|
||||
<runtime.Component />
|
||||
</ChunkRecoveryBoundary>
|
||||
</Suspense>
|
||||
</RouteInputContext.Provider>
|
||||
</RouteInputProvider>
|
||||
);
|
||||
const protectedContent =
|
||||
definition.access === "public" ? (
|
||||
|
||||
+19
-21
@@ -1,10 +1,17 @@
|
||||
import { getRoute } from "../../features/installed-feature-contracts.js";
|
||||
import type { SessionState } from "../../application/ports/in/application-api.ts";
|
||||
import { getRoute } from "../../features/installed-feature-contracts.ts";
|
||||
|
||||
/**
|
||||
* @param {string} routeId
|
||||
* @param {import("../../application/ports/in/application-api.js").SessionState} sessionState
|
||||
*/
|
||||
export function decideRouteAccess(routeId, sessionState) {
|
||||
export type RouteAccessDecision =
|
||||
| Readonly<{ allowed: true; action: "none" }>
|
||||
| Readonly<{
|
||||
allowed: false;
|
||||
action: "wait-for-session" | "show-sign-in";
|
||||
}>;
|
||||
|
||||
export function decideRouteAccess(
|
||||
routeId: string,
|
||||
sessionState: SessionState,
|
||||
): RouteAccessDecision {
|
||||
const route = getRoute(routeId);
|
||||
if (route.access === "public") return { allowed: true, action: "none" };
|
||||
if (sessionState === "authenticated") {
|
||||
@@ -16,34 +23,25 @@ export function decideRouteAccess(routeId, sessionState) {
|
||||
return { allowed: false, action: "show-sign-in" };
|
||||
}
|
||||
|
||||
/** @param {number} [maxHops] */
|
||||
export function createRedirectLoopGuard(maxHops = 5) {
|
||||
const visitedPairs = new Set();
|
||||
export function createRedirectLoopGuard(maxHops: number = 5) {
|
||||
const visitedPairs = new Set<string>();
|
||||
let hops = 0;
|
||||
|
||||
return Object.freeze({
|
||||
/**
|
||||
* @param {string} source
|
||||
* @param {string} target
|
||||
*/
|
||||
allow(source, target) {
|
||||
allow(source: string, target: string): boolean {
|
||||
const pair = `${source}->${target}`;
|
||||
if (
|
||||
source === target ||
|
||||
visitedPairs.has(pair) ||
|
||||
hops >= maxHops
|
||||
) {
|
||||
if (source === target || visitedPairs.has(pair) || hops >= maxHops) {
|
||||
return false;
|
||||
}
|
||||
visitedPairs.add(pair);
|
||||
hops += 1;
|
||||
return true;
|
||||
},
|
||||
reset() {
|
||||
reset(): void {
|
||||
visitedPairs.clear();
|
||||
hops = 0;
|
||||
},
|
||||
get hopCount() {
|
||||
get hopCount(): number {
|
||||
return hops;
|
||||
},
|
||||
});
|
||||
@@ -1,20 +1,12 @@
|
||||
import { getRoute, ROUTE_RUNTIME_CONTRACT } from "../../features/installed-feature-contracts.js";
|
||||
import { ROUTE_CODECS } from "../../features/installed-feature-runtimes.js";
|
||||
import { getRoute, ROUTE_RUNTIME_CONTRACT } from "../../features/installed-feature-contracts.ts";
|
||||
import { ROUTE_CODECS } from "../../features/installed-feature-runtimes.tsx";
|
||||
import type {
|
||||
ParsedRouteInput,
|
||||
RouteId,
|
||||
RouteInputResult,
|
||||
} from "./route-contract.ts";
|
||||
|
||||
export type RouteId = keyof typeof ROUTE_RUNTIME_CONTRACT;
|
||||
|
||||
export type ParsedRouteInput = Readonly<{
|
||||
routeId: RouteId;
|
||||
params: Readonly<Record<string, unknown>>;
|
||||
search: Readonly<Record<string, unknown>>;
|
||||
}>;
|
||||
|
||||
export type RouteInputResult =
|
||||
| Readonly<{ success: true; data: ParsedRouteInput }>
|
||||
| Readonly<{
|
||||
success: false;
|
||||
code: "ROUTE_PARAMS_INVALID" | "ROUTE_SEARCH_INVALID";
|
||||
}>;
|
||||
export type { ParsedRouteInput, RouteId, RouteInputResult };
|
||||
|
||||
function codecById(codecId: string) {
|
||||
const codec = ROUTE_CODECS[codecId as keyof typeof ROUTE_CODECS];
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ROUTE_RUNTIME_CONTRACT } from "../../features/installed-feature-contracts.ts";
|
||||
|
||||
export type RouteId = keyof typeof ROUTE_RUNTIME_CONTRACT;
|
||||
|
||||
export type ParsedRouteInput = Readonly<{
|
||||
routeId: RouteId;
|
||||
params: Readonly<Record<string, unknown>>;
|
||||
search: Readonly<Record<string, unknown>>;
|
||||
}>;
|
||||
|
||||
export type RouteInputResult =
|
||||
| Readonly<{ success: true; data: ParsedRouteInput }>
|
||||
| Readonly<{
|
||||
success: false;
|
||||
code: "ROUTE_PARAMS_INVALID" | "ROUTE_SEARCH_INVALID";
|
||||
}>;
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
} from "react";
|
||||
|
||||
import type { ParsedRouteInput } from "./route-contract.ts";
|
||||
|
||||
const RouteInputContext = createContext<ParsedRouteInput | null>(null);
|
||||
|
||||
export function RouteInputProvider({
|
||||
input,
|
||||
children,
|
||||
}: Readonly<{
|
||||
input: ParsedRouteInput;
|
||||
children: ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<RouteInputContext.Provider value={input}>
|
||||
{children}
|
||||
</RouteInputContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useRouteInput(): ParsedRouteInput {
|
||||
const input = useContext(RouteInputContext);
|
||||
if (!input) throw new Error("Registered route input is required");
|
||||
return input;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type LazyExoticComponent,
|
||||
} from "react";
|
||||
|
||||
import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../../contracts/route-runtime-contract.js";
|
||||
import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../../contracts/route-runtime-contract.ts";
|
||||
|
||||
type RouteModule = Readonly<{ default: ComponentType }>;
|
||||
type RouteRuntime = Readonly<{
|
||||
@@ -23,21 +23,21 @@ function runtime(
|
||||
}
|
||||
|
||||
export const PLATFORM_ROUTE_RUNTIME = {
|
||||
APP_HOME: runtime("APP_HOME", () => import("../pages/home-page.jsx")),
|
||||
APP_HOME: runtime("APP_HOME", () => import("../pages/home-page.tsx")),
|
||||
EXAMPLES_UI: runtime(
|
||||
"EXAMPLES_UI",
|
||||
() => import("../examples/ui-gallery-page.jsx"),
|
||||
() => import("../examples/ui-gallery-page.tsx"),
|
||||
),
|
||||
EXAMPLES_STATES: runtime(
|
||||
"EXAMPLES_STATES",
|
||||
() => import("../examples/state-gallery-page.jsx"),
|
||||
() => import("../examples/state-gallery-page.tsx"),
|
||||
),
|
||||
EXAMPLES_AUTH: runtime(
|
||||
"EXAMPLES_AUTH",
|
||||
() => import("../examples/auth-example-page.jsx"),
|
||||
() => import("../examples/auth-example-page.tsx"),
|
||||
),
|
||||
NOT_FOUND: runtime(
|
||||
"NOT_FOUND",
|
||||
() => import("../pages/not-found-page.jsx"),
|
||||
() => import("../pages/not-found-page.tsx"),
|
||||
),
|
||||
} satisfies Record<keyof typeof PLATFORM_ROUTE_RUNTIME_CONTRACT, RouteRuntime>;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
/**
|
||||
* Untrusted content is rendered as a React text node. HTML interpretation is
|
||||
* intentionally not offered by this template.
|
||||
*
|
||||
* @param {{ value: unknown }} props
|
||||
*/
|
||||
export function SafeText({ value }) {
|
||||
export function SafeText({ value }: Readonly<{ value: unknown }>) {
|
||||
return <span>{typeof value === "string" ? value : String(value ?? "")}</span>;
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export * from "./page-templates.js";
|
||||
export * from "./page-templates.tsx";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { PageHeader } from "../components/page-header.jsx";
|
||||
import { Button } from "../components/ui/button.jsx";
|
||||
import { useLocale } from "../i18n/index.js";
|
||||
import { PageHeader } from "../components/page-header.tsx";
|
||||
import { Button } from "../components/ui/button.ts";
|
||||
import { useLocale } from "../i18n/index.ts";
|
||||
|
||||
export type PageHeading = Readonly<{
|
||||
title: string;
|
||||
|
||||
Reference in New Issue
Block a user