첫 커밋: 로컬 프로젝트 업로드

This commit is contained in:
2026-06-10 15:51:34 +09:00
commit 6a8dbeb2e9
1211 changed files with 312864 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
export type CommonAuditLog = {
event_id: string;
timestamp: string;
user_id: string;
event_type: string;
status: string;
ip_address: string;
user_agent: string;
device_id?: string;
details?: string;
};
export type AuditDetails = {
request_id?: string;
method?: string;
path?: string;
status?: number;
latency_ms?: number;
error?: string;
tenant_id?: string;
actor_id?: string;
action?: string;
target?: string;
target_id?: string;
before?: unknown;
after?: unknown;
};
export function parseAuditDetails(details?: string): AuditDetails {
if (!details) {
return {};
}
try {
const parsed = JSON.parse(details);
if (parsed && typeof parsed === "object") {
return parsed as AuditDetails;
}
} catch {}
return {};
}
export function formatAuditValue(value: unknown) {
if (value === null || value === undefined || value === "") {
return "-";
}
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
export function formatAuditDateParts(value: string) {
if (!value) {
return { date: "-", time: "-" };
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return { date: value, time: "-" };
}
return {
date: parsed.toISOString().slice(0, 10),
time: parsed.toLocaleTimeString("ko-KR", { hour12: false }),
};
}
export function resolveAuditActor(
log: Pick<CommonAuditLog, "user_id">,
details: AuditDetails,
) {
return log.user_id || details.actor_id || "-";
}
export function resolveAuditAction(
log: Pick<CommonAuditLog, "event_type">,
details: AuditDetails,
) {
if (details.action) {
return details.action;
}
if (details.method && details.path) {
return `${details.method} ${details.path}`;
}
return log.event_type;
}
export function resolveAuditTarget(details: AuditDetails) {
return details.target || details.target_id || "-";
}

View File

@@ -0,0 +1,87 @@
export const DEFAULT_OIDC_SCOPE = "openid offline_access profile email";
export const DEFAULT_OIDC_REDIRECT_PATH = "/auth/callback";
export type CommonOidcConfigOptions<TUserStore = unknown> = {
authority: string;
clientId: string;
origin?: string;
redirectPath?: string;
scope?: string;
automaticSilentRenew?: boolean;
userStore: TUserStore;
};
export type LoginRedirectGuardParams = {
pathname: string;
isRedirecting: boolean;
loginPath?: string;
callbackPath?: string;
};
type CommonOidcRuntimeConfig<TUserStore> = {
authority: string;
client_id: string;
redirect_uri: string;
response_type: "code";
scope: string;
post_logout_redirect_uri: string;
popup_redirect_uri: string;
userStore: TUserStore;
automaticSilentRenew: boolean;
};
export function buildCommonOidcRuntimeConfig<TUserStore>({
authority,
clientId,
origin = window.location.origin,
redirectPath = DEFAULT_OIDC_REDIRECT_PATH,
scope = DEFAULT_OIDC_SCOPE,
automaticSilentRenew = false,
userStore,
}: CommonOidcConfigOptions<TUserStore>): CommonOidcRuntimeConfig<TUserStore> {
const callbackUrl = `${origin}${redirectPath}`;
return {
authority,
client_id: clientId,
redirect_uri: callbackUrl,
response_type: "code",
scope,
post_logout_redirect_uri: origin,
popup_redirect_uri: callbackUrl,
userStore,
automaticSilentRenew,
};
}
export function buildCommonUserManagerSettings<
TConfig extends {
authority?: string;
client_id?: string;
redirect_uri?: string;
},
>(config: TConfig) {
return {
...config,
authority: config.authority || "",
client_id: config.client_id || "",
redirect_uri: config.redirect_uri || "",
};
}
export function shouldStartLoginRedirect({
pathname,
isRedirecting,
loginPath = "/login",
callbackPath = DEFAULT_OIDC_REDIRECT_PATH,
}: LoginRedirectGuardParams) {
if (isRedirecting) {
return false;
}
if (pathname === loginPath || pathname.startsWith(callbackPath)) {
return false;
}
return true;
}

View File

@@ -0,0 +1,148 @@
import { act } from "react-dom/test-utils";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CommonAuditLog } from "../../audit";
import { AuditLogTable } from "./AuditLogTable";
const roots: Root[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
vi.restoreAllMocks();
document.body.innerHTML = "";
});
function renderTable(props: Parameters<typeof AuditLogTable>[0]) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
act(() => {
root.render(<AuditLogTable {...props} />);
});
return { container };
}
const logs: CommonAuditLog[] = [
{
event_id: "evt-1",
timestamp: "2026-05-28T06:07:18.000Z",
user_id: "user-1",
event_type: "CLIENT_UPDATE",
status: "success",
ip_address: "127.0.0.1",
user_agent: "Vitest",
device_id: "device-1",
details: JSON.stringify({
request_id: "req-1",
method: "POST",
path: "/api/v1/clients",
latency_ms: 120,
tenant_id: "tenant-1",
actor_id: "user-1",
action: "업데이트",
target_id: "client-a",
before: { status: "inactive" },
after: { status: "active" },
}),
},
];
describe("AuditLogTable", () => {
it("renders loading and empty states", () => {
const { container: loadingContainer } = renderTable({
logs: [],
t: (key, fallback) => fallback ?? key,
loading: true,
hasNextPage: false,
isFetchingNextPage: false,
onLoadMore: vi.fn(),
});
expect(loadingContainer.textContent).toContain("Loading audit logs...");
const { container: emptyContainer } = renderTable({
logs: [],
t: (key, fallback) => fallback ?? key,
loading: false,
hasNextPage: false,
isFetchingNextPage: false,
onLoadMore: vi.fn(),
});
expect(emptyContainer.textContent).toContain("No audit logs found.");
expect(emptyContainer.textContent).toContain("End of audit feed");
});
it("renders rows, expands details, copies fields, and loads more", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
const onLoadMore = vi.fn();
const { container } = renderTable({
logs,
t: (key, fallback, vars) => {
let text = fallback ?? key;
for (const [name, value] of Object.entries(vars ?? {})) {
text = text.replaceAll(`{{${name}}}`, String(value));
}
return text;
},
loading: false,
hasNextPage: true,
isFetchingNextPage: false,
onLoadMore,
});
expect(container.textContent).toContain("user-1");
expect(container.textContent).toContain("업데이트");
expect(container.textContent).toContain("client-a");
expect(container.textContent).toContain("success");
const buttons = Array.from(container.querySelectorAll("button"));
const actorCopyButton = buttons.find(
(button) => button.getAttribute("aria-label") === "Copy User ID",
);
const targetCopyButton = buttons.find(
(button) => button.getAttribute("aria-label") === "Copy Client ID",
);
const expandButton = buttons.find(
(button) => !button.getAttribute("aria-label") && !button.textContent,
);
const loadMoreButton = buttons.find(
(button) => button.textContent === "Load more",
);
expect(actorCopyButton).toBeTruthy();
expect(targetCopyButton).toBeTruthy();
expect(expandButton).toBeTruthy();
expect(loadMoreButton).toBeTruthy();
await act(async () => {
actorCopyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
targetCopyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(writeText).toHaveBeenCalledWith("user-1");
expect(writeText).toHaveBeenCalledWith("client-a");
expect(container.textContent).toContain("Request ID · req-1");
expect(container.textContent).toContain("Actor");
expect(container.textContent).toContain("Result");
await act(async () => {
loadMoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onLoadMore).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,409 @@
import { ChevronDown, ChevronUp, Copy } from "lucide-react";
import * as React from "react";
import {
getCommonBadgeClasses,
type CommonBadgeVariant,
} from "../../../ui/badge";
import { getCommonButtonClasses } from "../../../ui/button";
import {
commonStickyTableHeaderClass,
commonTableBodyClass,
commonTableCellClass,
commonTableClass,
commonTableHeadClass,
commonTableHeaderClass,
commonTableRowClass,
commonTableShellClass,
commonTableViewportClass,
commonTableWrapperClass,
} from "../../../ui/table";
import type { CommonAuditLog } from "../../audit";
import {
formatAuditDateParts,
formatAuditValue,
parseAuditDetails,
resolveAuditAction,
resolveAuditActor,
resolveAuditTarget,
} from "../../audit";
type AuditTranslate = (
key: string,
fallback: string,
vars?: Record<string, string | number>,
) => string;
type AuditLogTableProps = {
logs: CommonAuditLog[];
t: AuditTranslate;
loading: boolean;
hasNextPage: boolean;
isFetchingNextPage: boolean;
onLoadMore: () => void;
className?: string;
};
function cx(...classNames: Array<string | false | null | undefined>) {
return classNames.filter(Boolean).join(" ");
}
function statusVariant(status: string): CommonBadgeVariant {
switch (status.toLowerCase()) {
case "success":
case "ok":
return "success";
case "failure":
case "error":
case "blocked":
return "destructive";
case "pending":
case "warning":
return "warning";
default:
return "default";
}
}
export function AuditLogTable({
logs,
t,
loading,
hasNextPage,
isFetchingNextPage,
onLoadMore,
className,
}: AuditLogTableProps) {
const [expandedRows, setExpandedRows] = React.useState<
Record<string, boolean>
>({});
const handleCopy = (value: string) => {
if (!value) {
return;
}
navigator.clipboard.writeText(value);
};
return (
<div className={cx(commonTableShellClass, className)}>
<div className={cx(commonTableViewportClass, "flex-1")}>
<div className={commonTableWrapperClass}>
<Table className={commonTableClass}>
<TableHeader className={commonTableHeaderClass}>
<TableRow className={cx(commonTableRowClass, commonStickyTableHeaderClass)}>
<TableHead className={cx(commonTableHeadClass, "w-[190px]")}>
{t("ui.common.audit.table.time", "Time")}
</TableHead>
<TableHead className={cx(commonTableHeadClass, "w-[180px]")}>
{t("ui.common.audit.table.user_id", "User ID")}
</TableHead>
<TableHead className={cx(commonTableHeadClass, "w-[180px]")}>
{t("ui.common.audit.table.action", "Action")}
</TableHead>
<TableHead className={cx(commonTableHeadClass, "w-[260px]")}>
{t("ui.common.audit.table.client_id", "Client ID")}
</TableHead>
<TableHead className={cx(commonTableHeadClass, "w-[120px]")}>
{t("ui.common.audit.table.status", "Status")}
</TableHead>
<TableHead className={cx(commonTableHeadClass, "w-[80px]")} />
</TableRow>
</TableHeader>
<TableBody className={commonTableBodyClass}>
{logs.map((log, index) => {
const details = parseAuditDetails(log.details);
const actorLabel = resolveAuditActor(log, details);
const actionLabel = resolveAuditAction(log, details);
const targetLabel = resolveAuditTarget(details);
const rowKey = `${log.event_id}-${log.timestamp}-${index}`;
const expanded = Boolean(expandedRows[rowKey]);
const { date, time } = formatAuditDateParts(log.timestamp);
return (
<React.Fragment key={rowKey}>
<TableRow className={cx(commonTableRowClass, "bg-card/40")}>
<TableCell className={cx(commonTableCellClass, "text-xs text-muted-foreground")}>
<div className="space-y-1">
<div>{date}</div>
<div>{time}</div>
</div>
</TableCell>
<TableCell className={commonTableCellClass}>
<div className="flex items-center gap-2">
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
{actorLabel}
</code>
{actorLabel !== "-" ? (
<button
type="button"
className={cx(
getCommonButtonClasses({
variant: "ghost",
size: "icon",
}),
"h-7 w-7 text-muted-foreground hover:text-primary",
)}
aria-label={t(
"ui.common.audit.copy.actor_id",
"Copy User ID",
)}
onClick={() => handleCopy(actorLabel)}
>
<Copy className="h-3 w-3" />
</button>
) : null}
</div>
</TableCell>
<TableCell className={cx(commonTableCellClass, "text-xs text-muted-foreground")}>
<div className="font-semibold text-foreground">
{actionLabel}
</div>
</TableCell>
<TableCell className={cx(commonTableCellClass, "text-xs text-muted-foreground")}>
<div className="flex items-center gap-2">
<span className="break-all">{targetLabel}</span>
{targetLabel !== "-" ? (
<button
type="button"
className={cx(
getCommonButtonClasses({
variant: "ghost",
size: "icon",
}),
"h-7 w-7 text-muted-foreground hover:text-primary",
)}
aria-label={t(
"ui.common.audit.copy.target",
"Copy Client ID",
)}
onClick={() => handleCopy(targetLabel)}
>
<Copy className="h-3 w-3" />
</button>
) : null}
</div>
</TableCell>
<TableCell className={commonTableCellClass}>
<span
className={getCommonBadgeClasses({
variant: statusVariant(log.status),
})}
>
{log.status}
</span>
</TableCell>
<TableCell className={cx(commonTableCellClass, "text-right")}>
<button
type="button"
className={getCommonButtonClasses({
variant: "ghost",
size: "sm",
})}
onClick={() =>
setExpandedRows((prev) => ({
...prev,
[rowKey]: !expanded,
}))
}
>
{expanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
</TableCell>
</TableRow>
{expanded && (
<TableRow className={cx(commonTableRowClass, "bg-card/20")}>
<TableCell colSpan={6} className={cx(commonTableCellClass, "text-xs")}>
<div className="grid gap-4 text-muted-foreground md:grid-cols-3">
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.common.audit.details.request", "Request")}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.request_id",
"Request ID · {{value}}",
{ value: formatAuditValue(details.request_id) },
)}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.event_id",
"Event ID · {{value}}",
{ value: formatAuditValue(log.event_id) },
)}
</div>
<div>
{t("ui.common.audit.details.ip", "IP · {{value}}", {
value: formatAuditValue(log.ip_address),
})}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.method",
"Method · {{value}}",
{ value: formatAuditValue(details.method) },
)}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.path",
"Path · {{value}}",
{ value: formatAuditValue(details.path) },
)}
</div>
<div>
{t(
"ui.common.audit.details.latency",
"Latency · {{value}}",
{
value:
details.latency_ms !== undefined
? `${details.latency_ms}ms`
: "-",
},
)}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.common.audit.details.actor", "Actor")}
</div>
<div>
{t(
"ui.common.audit.details.actor_id",
"User ID · {{value}}",
{ value: actorLabel },
)}
</div>
<div>
{t(
"ui.common.audit.details.tenant",
"Tenant · {{value}}",
{ value: formatAuditValue(details.tenant_id) },
)}
</div>
<div>
{t(
"ui.common.audit.details.device",
"Device · {{value}}",
{ value: formatAuditValue(log.device_id) },
)}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.target",
"Client ID · {{value}}",
{ value: targetLabel },
)}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.common.audit.details.result", "Result")}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.error",
"Error · {{value}}",
{ value: formatAuditValue(details.error) },
)}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.before",
"Before · {{value}}",
{ value: formatAuditValue(details.before) },
)}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.after",
"After · {{value}}",
{ value: formatAuditValue(details.after) },
)}
</div>
</div>
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
{logs.length === 0 && !loading && (
<TableRow className={commonTableRowClass}>
<TableCell
colSpan={6}
className={cx(
commonTableCellClass,
"text-center text-muted-foreground py-8",
)}
>
{t("msg.common.audit.empty", "No audit logs found.")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<div className="p-4 border-t text-center flex-shrink-0 bg-background/50 backdrop-blur-sm z-10">
{hasNextPage ? (
<div className="flex flex-col items-center gap-2">
{isFetchingNextPage && (
<span className="text-xs text-muted-foreground animate-pulse">
{t("msg.common.loading", "Loading more...")}
</span>
)}
<button
type="button"
className={getCommonButtonClasses({
variant: "outline",
size: "sm",
})}
onClick={onLoadMore}
disabled={isFetchingNextPage}
>
{isFetchingNextPage
? t("msg.common.loading", "Loading...")
: t("ui.common.audit.load_more", "Load more")}
</button>
</div>
) : logs.length > 0 ? (
<span className="text-xs text-muted-foreground">
{t("msg.common.audit.end", "End of audit feed")}
</span>
) : null}
</div>
</div>
);
}
// Internal table components for cleaner implementation
function Table({ className, children, style }: { className?: string, children: React.ReactNode, style?: React.CSSProperties }) {
return <table className={className} style={style}>{children}</table>;
}
function TableHeader({ className, children }: { className?: string, children: React.ReactNode }) {
return <thead className={className}>{children}</thead>;
}
function TableBody({ className, children }: { className?: string, children: React.ReactNode }) {
return <tbody className={className}>{children}</tbody>;
}
function TableRow({ className, children }: { className?: string, children: React.ReactNode }) {
return <tr className={className}>{children}</tr>;
}
function TableHead({ className, children }: { className?: string, children?: React.ReactNode }) {
return <th className={className}>{children}</th>;
}
function TableCell({ className, children, colSpan }: { className?: string, children: React.ReactNode, colSpan?: number }) {
return <td className={className} colSpan={colSpan}>{children}</td>;
}

View File

@@ -0,0 +1 @@
export * from "./AuditLogTable";

View File

@@ -0,0 +1,14 @@
export function OverviewAxisNotes({
xAxisLabel,
yAxisLabel,
}: {
xAxisLabel: string;
yAxisLabel: string;
}) {
return (
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>{xAxisLabel}</span>
<span>{yAxisLabel}</span>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
export function OverviewMetric({
icon,
label,
value,
}: {
icon: ReactNode;
label: string;
value: string;
}) {
return (
<span className="inline-flex items-center gap-2 whitespace-nowrap text-sm">
<span className="text-muted-foreground">{icon}</span>
<span className="text-muted-foreground">{label}</span>
<span className="font-semibold tabular-nums">{value}</span>
</span>
);
}

View File

@@ -0,0 +1,50 @@
import type { ReactNode } from "react";
type OverviewSelectionChipOption = {
id: string;
label: ReactNode;
};
export function OverviewSelectionChips({
allLabel,
options,
selectedIds,
onSelectAll,
onToggle,
}: {
allLabel: string;
options: OverviewSelectionChipOption[];
selectedIds: string[];
onSelectAll: () => void;
onToggle: (id: string) => void;
}) {
const isAllSelected = selectedIds.length === 0;
return (
<div className="flex flex-wrap gap-2 rounded-xl border border-border/60 bg-card/60 p-3">
<label className="inline-flex items-center gap-2 rounded-full border border-border/60 px-3 py-1.5 text-xs">
<input
type="checkbox"
checked={isAllSelected}
onChange={onSelectAll}
className="h-3.5 w-3.5"
/>
<span>{allLabel}</span>
</label>
{options.map((option) => (
<label
key={option.id}
className="inline-flex items-center gap-2 rounded-full border border-border/60 px-3 py-1.5 text-xs"
>
<input
type="checkbox"
checked={selectedIds.includes(option.id)}
onChange={() => onToggle(option.id)}
className="h-3.5 w-3.5"
/>
<span>{option.label}</span>
</label>
))}
</div>
);
}

View File

@@ -0,0 +1,3 @@
export { OverviewAxisNotes } from "./OverviewAxisNotes";
export { OverviewMetric } from "./OverviewMetric";
export { OverviewSelectionChips } from "./OverviewSelectionChips";

View File

@@ -0,0 +1,68 @@
import type { ElementType, HTMLAttributes, ReactNode } from "react";
function cx(...classNames: Array<string | false | null | undefined>) {
return classNames.filter(Boolean).join(" ");
}
type PageHeaderProps = Omit<HTMLAttributes<HTMLElement>, "title"> & {
actions?: ReactNode;
icon?: ReactNode;
as?: ElementType;
description?: ReactNode;
eyebrow?: ReactNode;
sticky?: boolean;
title: ReactNode;
titleAs?: ElementType;
};
export function PageHeader({
actions,
as,
className,
description,
eyebrow,
icon,
sticky = false,
title,
titleAs,
...props
}: PageHeaderProps) {
const Root = as ?? "header";
const Title = titleAs ?? "h1";
return (
<Root
className={cx(
"flex flex-wrap items-start justify-between gap-4",
sticky &&
"sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pt-4 pb-2 backdrop-blur",
className,
)}
{...props}
>
<div className="flex min-w-0 items-start gap-3">
{icon ? (
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
{icon}
</div>
) : null}
<div className="space-y-2">
{eyebrow ? (
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
{eyebrow}
</p>
) : null}
<Title className="text-3xl font-semibold tracking-tight">
{title}
</Title>
{description ? (
<p className="text-sm text-muted-foreground">{description}</p>
) : null}
</div>
</div>
{actions ? (
<div className="flex flex-wrap items-center gap-2">{actions}</div>
) : null}
</Root>
);
}

View File

@@ -0,0 +1 @@
export * from "./PageHeader";

View File

@@ -0,0 +1,170 @@
import type { ReactNode, ThHTMLAttributes } from "react";
import {
commonStickyTableHeaderClass,
commonTableHeadClass,
} from "../../../ui/table";
import type { SortConfig } from "../../utils";
export const sortableTableHeadBaseClassName = commonTableHeadClass;
export const sortableTableHeaderClassName = commonStickyTableHeaderClass;
function SortAscendingIcon() {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m12 5-5 5" />
<path d="m12 5 5 5" />
<path d="M12 19V5" />
</svg>
);
}
function SortDescendingIcon() {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m12 19-5-5" />
<path d="m12 19 5-5" />
<path d="M12 5v14" />
</svg>
);
}
function SortIdleIcon() {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="ml-1 h-3.5 w-3.5 opacity-50"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m7 15 5 5 5-5" />
<path d="m7 9 5-5 5 5" />
</svg>
);
}
type SortableTableHeadAlign = "left" | "center" | "right";
function alignClassName(align: SortableTableHeadAlign) {
switch (align) {
case "center":
return "text-center";
case "right":
return "text-right";
default:
return "text-left";
}
}
function buttonAlignClassName(align: SortableTableHeadAlign) {
switch (align) {
case "center":
return "justify-center";
case "right":
return "justify-end";
default:
return "justify-start";
}
}
function sortAriaValue(
isActive: boolean,
direction: "asc" | "desc" | null,
): ThHTMLAttributes<HTMLTableCellElement>["aria-sort"] {
if (!isActive || direction === null) {
return "none";
}
return direction === "asc" ? "ascending" : "descending";
}
type SortableTableHeadProps<Key extends string> = Omit<
ThHTMLAttributes<HTMLTableCellElement>,
"children"
> & {
align?: SortableTableHeadAlign;
contentClassName?: string;
disabled?: boolean;
label: ReactNode;
onSort: (key: Key) => void;
sortConfig: SortConfig<Key> | null;
sortKey: Key;
};
export function SortableTableHead<Key extends string>({
align = "left",
className = "",
contentClassName = "",
disabled = false,
label,
onSort,
sortConfig,
sortKey,
...props
}: SortableTableHeadProps<Key>) {
const isActive = sortConfig?.key === sortKey;
const direction = isActive ? (sortConfig?.direction ?? null) : null;
return (
<th
aria-sort={sortAriaValue(isActive, direction)}
className={[
sortableTableHeadBaseClassName,
alignClassName(align),
disabled ? "" : "transition-colors hover:bg-muted/50",
className,
]
.filter(Boolean)
.join(" ")}
{...props}
>
<button
type="button"
onClick={() => onSort(sortKey)}
disabled={disabled}
className={[
"flex w-full items-center font-inherit",
buttonAlignClassName(align),
disabled ? "cursor-default opacity-70" : "cursor-pointer",
contentClassName,
]
.filter(Boolean)
.join(" ")}
>
<span>{label}</span>
{direction === "asc" ? (
<span className="ml-1 inline-flex">
<SortAscendingIcon />
</span>
) : direction === "desc" ? (
<span className="ml-1 inline-flex">
<SortDescendingIcon />
</span>
) : (
<SortIdleIcon />
)}
</button>
</th>
);
}

View File

@@ -0,0 +1 @@
export * from "./SortableTableHead";

View File

@@ -0,0 +1,11 @@
export { createTomlTranslator } from "./loader";
export {
DEFAULT_LOCALE,
LOCALE_STORAGE_KEY,
type Locale,
SUPPORTED_LOCALES,
type TomlObject,
type TomlValue,
type TranslatorInput,
type TranslatorOptions,
} from "./types";

View File

@@ -0,0 +1,182 @@
import {
DEFAULT_LOCALE,
LOCALE_STORAGE_KEY,
type Locale,
SUPPORTED_LOCALES,
type TomlObject,
type TomlValue,
type TranslatorInput,
type TranslatorOptions,
} from "./types";
function mergeTomlObjects(base: TomlObject, override: TomlObject): TomlObject {
const result: TomlObject = { ...base };
for (const [key, value] of Object.entries(override)) {
const currentValue = result[key];
if (
typeof currentValue === "object" &&
currentValue !== null &&
typeof value === "object" &&
value !== null
) {
result[key] = mergeTomlObjects(currentValue as TomlObject, value);
continue;
}
result[key] = value;
}
return result;
}
function isSupportedLocale(value: string): value is Locale {
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
function parseToml(raw: string): TomlObject {
const lines = raw.split(/\r?\n/);
const root: TomlObject = {};
let currentPath: string[] = [];
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
continue;
}
if (line.startsWith("[") && line.endsWith("]")) {
const sectionName = line.slice(1, -1).trim();
currentPath = sectionName
? sectionName
.split(".")
.map((part) => part.trim())
.filter(Boolean)
: [];
continue;
}
const eqIndex = line.indexOf("=");
if (eqIndex === -1) {
continue;
}
const key = line.slice(0, eqIndex).trim();
const valueRaw = line.slice(eqIndex + 1).trim();
if (!key) {
continue;
}
let value = valueRaw;
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
let cursor: TomlObject = root;
for (const section of currentPath) {
if (!cursor[section] || typeof cursor[section] === "string") {
cursor[section] = {};
}
cursor = cursor[section] as TomlObject;
}
cursor[key] = value;
}
return root;
}
function getValue(target: TomlObject, key: string): string | undefined {
const parts = key.split(".");
let cursor: TomlValue = target;
for (const part of parts) {
if (typeof cursor !== "object" || cursor === null) {
return undefined;
}
cursor = (cursor as TomlObject)[part];
if (cursor === undefined) {
return undefined;
}
}
return typeof cursor === "string" ? cursor : undefined;
}
function detectLocale(): Locale {
if (typeof window === "undefined") {
return DEFAULT_LOCALE;
}
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored && isSupportedLocale(stored)) {
return stored;
}
const pathLocale = window.location.pathname.split("/")[1];
if (pathLocale && isSupportedLocale(pathLocale)) {
return pathLocale;
}
const browserLang = window.navigator.language.toLowerCase();
if (browserLang.startsWith("ko")) {
return "ko";
}
return DEFAULT_LOCALE;
}
function formatTemplate(
template: string,
vars?: Record<string, string | number>,
options?: TranslatorOptions,
): string {
const normalizedTemplate = options?.normalizeEscapedNewlines
? template.replace(/\\n/g, "\n")
: template;
if (!vars) {
return normalizedTemplate;
}
return normalizedTemplate.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => {
const value = vars[key];
if (value === undefined || value === null) {
return match;
}
return String(value);
});
}
export function createTomlTranslator(
input: TranslatorInput,
options?: TranslatorOptions,
) {
const translations: Record<Locale, TomlObject> = {
ko: input.ko
.map((raw) => parseToml(raw))
.reduce<TomlObject>(
(merged, current) => mergeTomlObjects(merged, current),
{},
),
en: input.en
.map((raw) => parseToml(raw))
.reduce<TomlObject>(
(merged, current) => mergeTomlObjects(merged, current),
{},
),
};
return function t(
key: string,
fallback?: string,
vars?: Record<string, string | number>,
): string {
const locale = detectLocale();
const value = getValue(translations[locale], key);
if (value && value.length > 0) {
return formatTemplate(value, vars, options);
}
return formatTemplate(fallback ?? key, vars, options);
};
}

View File

@@ -0,0 +1,20 @@
export const LOCALE_STORAGE_KEY = "locale";
export const DEFAULT_LOCALE = "ko";
export const SUPPORTED_LOCALES = ["ko", "en"] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
export type TomlValue = string | TomlObject;
export interface TomlObject {
[key: string]: TomlValue;
}
export interface TranslatorOptions {
normalizeEscapedNewlines?: boolean;
}
export interface TranslatorInput {
en: string[];
ko: string[];
}

View File

@@ -0,0 +1,85 @@
import type { CursorFetchRequest, CursorPageResponse } from "./cursorFetchCore";
import { fetchAllCursorPagesMainThread } from "./cursorFetchCore";
type CursorWorkerResponseMessage<TItem> =
| {
id: string;
ok: true;
response: CursorPageResponse<TItem>;
}
| {
id: string;
ok: false;
error: string;
};
function createRequestId() {
if (globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function shouldUseWorker(useWorker: boolean | undefined) {
if (useWorker === false || typeof Worker === "undefined") {
return false;
}
const maybeWindow = globalThis as typeof globalThis & {
window?: Window & typeof globalThis & { _IS_TEST_MODE?: boolean };
};
return maybeWindow.window?._IS_TEST_MODE !== true;
}
async function fetchAllCursorPagesInWorker<TItem>(
request: CursorFetchRequest,
): Promise<CursorPageResponse<TItem>> {
const worker = new Worker(
new URL("./cursorFetch.worker.ts", import.meta.url),
{
type: "module",
},
);
const id = createRequestId();
return new Promise((resolve, reject) => {
worker.onmessage = (
event: MessageEvent<CursorWorkerResponseMessage<TItem>>,
) => {
if (event.data.id !== id) {
return;
}
worker.terminate();
if (event.data.ok) {
resolve(event.data.response);
} else {
reject(new Error(event.data.error));
}
};
worker.onerror = (event) => {
worker.terminate();
reject(new Error(event.message || "Cursor worker failed"));
};
worker.postMessage({ id, request });
});
}
export async function fetchAllCursorPages<TItem>(
request: CursorFetchRequest & { useWorker?: boolean },
): Promise<CursorPageResponse<TItem>> {
if (shouldUseWorker(request.useWorker)) {
try {
return await fetchAllCursorPagesInWorker<TItem>(request);
} catch {
return fetchAllCursorPagesMainThread<TItem>(request);
}
}
return fetchAllCursorPagesMainThread<TItem>(request);
}
export type { CursorFetchRequest, CursorPageResponse } from "./cursorFetchCore";
export { fetchAllCursorPagesMainThread } from "./cursorFetchCore";

View File

@@ -0,0 +1,44 @@
import {
type CursorFetchRequest,
type CursorPageResponse,
fetchAllCursorPagesMainThread,
} from "./cursorFetchCore";
type CursorWorkerRequestMessage = {
id: string;
request: CursorFetchRequest;
};
type CursorWorkerResponseMessage<TItem> =
| {
id: string;
ok: true;
response: CursorPageResponse<TItem>;
}
| {
id: string;
ok: false;
error: string;
};
self.addEventListener(
"message",
async (event: MessageEvent<CursorWorkerRequestMessage>) => {
const { id, request } = event.data;
try {
const response = await fetchAllCursorPagesMainThread(request);
self.postMessage({
id,
ok: true,
response,
} satisfies CursorWorkerResponseMessage<unknown>);
} catch (error) {
self.postMessage({
id,
ok: false,
error: error instanceof Error ? error.message : String(error),
} satisfies CursorWorkerResponseMessage<unknown>);
}
},
);

View File

@@ -0,0 +1,107 @@
export type CursorPageResponse<TItem> = {
items: TItem[];
limit?: number;
offset?: number;
total?: number;
cursor?: string;
nextCursor?: string;
next_cursor?: string;
};
export type CursorFetchParams = Record<
string,
string | number | boolean | null | undefined
>;
export type CursorFetchRequest = {
baseUrl: string;
path: string;
pageSize?: number;
params?: CursorFetchParams;
headers?: Record<string, string>;
credentials?: RequestCredentials;
maxPages?: number;
};
function normalizeBaseUrl(baseUrl: string) {
const value = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
return new URL(value, globalThis.location?.origin ?? "http://localhost");
}
function buildCursorFetchUrl(
request: Required<Pick<CursorFetchRequest, "baseUrl" | "path">> &
Pick<CursorFetchRequest, "params">,
pageSize: number,
cursor: string | undefined,
) {
const path = request.path.replace(/^\/+/, "");
const url = new URL(path, normalizeBaseUrl(request.baseUrl));
for (const [key, value] of Object.entries(request.params ?? {})) {
if (value !== undefined && value !== null && value !== "") {
url.searchParams.set(key, String(value));
}
}
url.searchParams.set("limit", String(pageSize));
if (cursor) {
url.searchParams.set("cursor", cursor);
} else {
url.searchParams.delete("cursor");
}
return url;
}
function readNextCursor<TItem>(page: CursorPageResponse<TItem>) {
return page.nextCursor || page.next_cursor || undefined;
}
export async function fetchAllCursorPagesMainThread<TItem>({
pageSize = 100,
credentials = "same-origin",
maxPages = 1000,
...request
}: CursorFetchRequest): Promise<CursorPageResponse<TItem>> {
const items: TItem[] = [];
let cursor: string | undefined;
for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) {
const url = buildCursorFetchUrl(request, pageSize, cursor);
const response = await fetch(url, {
headers: request.headers,
credentials,
});
if (!response.ok) {
throw new Error(
`Cursor page request failed with status ${response.status}`,
);
}
const page = (await response.json()) as CursorPageResponse<TItem>;
items.push(...page.items);
const nextCursor = readNextCursor(page);
if (!nextCursor) {
return {
...page,
items,
limit: pageSize,
offset: 0,
total: items.length,
cursor,
nextCursor: undefined,
next_cursor: undefined,
};
}
if (nextCursor === cursor) {
throw new Error("Cursor page request returned the same next cursor");
}
cursor = nextCursor;
}
throw new Error(`Cursor page request exceeded ${maxPages} pages`);
}

View File

@@ -0,0 +1,6 @@
export {
type CursorFetchRequest,
type CursorPageResponse,
fetchAllCursorPages,
fetchAllCursorPagesMainThread,
} from "./cursorFetch";

View File

@@ -0,0 +1,7 @@
export const queryClientDefaultOptions = {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
retry: 1,
},
} as const;

View File

@@ -0,0 +1,114 @@
export const DEFAULT_SESSION_RENEW_THRESHOLD_MS = 10 * 60 * 1000;
export const DEFAULT_SESSION_RENEW_THROTTLE_MS = 30 * 1000;
export const SESSION_EXPIRY_STORAGE_KEY = "baron_session_expiry_enabled";
type SessionExpiryReadableStorage = Pick<Storage, "getItem">;
type SessionExpiryWritableStorage = Pick<Storage, "setItem">;
export type SessionRenewDecisionParams = {
expiresAtSec?: number | null;
nowMs: number;
isEnabled: boolean;
isAuthenticated: boolean;
isLoading: boolean;
isRenewInFlight: boolean;
lastAttemptAtMs: number;
thresholdMs?: number;
throttleMs?: number;
};
export type SessionExpiryPreferenceParams = {
defaultEnabled?: boolean;
storage?: SessionExpiryReadableStorage | null;
};
export type DevelopmentSessionRedirectParams = {
appMode: string;
defaultEnabled?: boolean;
storage?: SessionExpiryReadableStorage | null;
};
function browserStorage() {
if (typeof window === "undefined") {
return null;
}
return window.localStorage;
}
export function readSessionExpiryEnabled({
defaultEnabled = true,
storage = browserStorage(),
}: SessionExpiryPreferenceParams = {}) {
const stored = storage?.getItem(SESSION_EXPIRY_STORAGE_KEY) ?? null;
return stored === null ? defaultEnabled : stored !== "false";
}
export function writeSessionExpiryEnabled(
isEnabled: boolean,
storage: SessionExpiryWritableStorage | null = browserStorage(),
) {
storage?.setItem(SESSION_EXPIRY_STORAGE_KEY, String(isEnabled));
}
export function shouldSuppressDevelopmentSessionRedirect({
appMode,
defaultEnabled = appMode !== "development",
storage = browserStorage(),
}: DevelopmentSessionRedirectParams) {
return (
appMode === "development" &&
!readSessionExpiryEnabled({ defaultEnabled, storage })
);
}
function hasRenewPreconditions({
isAuthenticated,
isLoading,
isRenewInFlight,
}: SessionRenewDecisionParams) {
return isAuthenticated && !isLoading && !isRenewInFlight;
}
function isRenewWindowOpen({
expiresAtSec,
nowMs,
lastAttemptAtMs,
thresholdMs = DEFAULT_SESSION_RENEW_THRESHOLD_MS,
throttleMs = DEFAULT_SESSION_RENEW_THROTTLE_MS,
}: SessionRenewDecisionParams) {
if (typeof expiresAtSec !== "number") {
return false;
}
const remainingMs = expiresAtSec * 1000 - nowMs;
if (remainingMs <= 0 || remainingMs > thresholdMs) {
return false;
}
if (nowMs - lastAttemptAtMs < throttleMs) {
return false;
}
return true;
}
export function shouldAttemptSlidingSessionRenew(
params: SessionRenewDecisionParams,
) {
if (!params.isEnabled || !hasRenewPreconditions(params)) {
return false;
}
return isRenewWindowOpen(params);
}
export function shouldAttemptUnlimitedSessionRenew(
params: SessionRenewDecisionParams,
) {
if (params.isEnabled || !hasRenewPreconditions(params)) {
return false;
}
return isRenewWindowOpen(params);
}

View File

@@ -0,0 +1,8 @@
export function mergeClassNames(
mergeFn: (...classNames: string[]) => string,
classNames: string[],
) {
return mergeFn(...classNames);
}
export * from "./sort";

View File

@@ -0,0 +1,97 @@
export type SortDirection = "asc" | "desc";
export type SortConfig<Key extends string = string> = {
key: Key;
direction: SortDirection;
};
export type SortableValue = string | number | boolean | Date | null | undefined;
export type SortResolver<T> = (item: T) => SortableValue;
export type SortResolverMap<T, Key extends string = string> = Partial<
Record<Key, SortResolver<T>>
>;
function normalizeSortableValue(value: SortableValue) {
if (value instanceof Date) {
return value.getTime();
}
if (typeof value === "string") {
return value.toLocaleLowerCase();
}
if (typeof value === "boolean") {
return value ? 1 : 0;
}
return value;
}
export function compareNullableValues(
left: SortableValue,
right: SortableValue,
direction: SortDirection,
) {
if (left === right) {
return 0;
}
if (left === null || left === undefined) {
return 1;
}
if (right === null || right === undefined) {
return -1;
}
const normalizedLeft = normalizeSortableValue(left);
const normalizedRight = normalizeSortableValue(right);
if (normalizedLeft === normalizedRight) {
return 0;
}
if (normalizedLeft === null || normalizedLeft === undefined) {
return 1;
}
if (normalizedRight === null || normalizedRight === undefined) {
return -1;
}
const comparison = normalizedLeft < normalizedRight ? -1 : 1;
return direction === "asc" ? comparison : -comparison;
}
export function toggleSort<Key extends string>(
current: SortConfig<Key> | null,
key: Key,
): SortConfig<Key> {
if (current?.key === key && current.direction === "asc") {
return { key, direction: "desc" };
}
return { key, direction: "asc" };
}
export function sortItems<T, Key extends string = string>(
items: T[],
sortConfig: SortConfig<Key> | null,
resolverMap: SortResolverMap<T, Key> = {},
) {
if (!sortConfig) {
return [...items];
}
const resolveValue =
resolverMap[sortConfig.key] ??
((item: T) =>
(item as Record<string, SortableValue>)[sortConfig.key] ?? null);
return [...items].sort((left, right) =>
compareNullableValues(
resolveValue(left),
resolveValue(right),
sortConfig.direction,
),
);
}