관리자 빈 화면 진단
This commit is contained in:
parent
0324df7c4d
commit
50524f14ae
6 changed files with 556 additions and 12 deletions
|
|
@ -1,4 +1,13 @@
|
|||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Component,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ErrorInfo,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { AppShell } from "../components/shell/AppShell";
|
||||
import { Badge, Button, Dot, Icon, Kicker, ProgressBar } from "../components/ui";
|
||||
import {
|
||||
|
|
@ -31,6 +40,11 @@ type AccessTab = "roles" | "groups" | "matrix";
|
|||
type TicketStatusFilter = AdminSupportTicket["status"] | "all";
|
||||
type TicketCategoryFilter = AdminSupportTicket["category"] | "all";
|
||||
type TicketPriorityFilter = AdminSupportTicket["priority"] | "all";
|
||||
type AdminDataWarning = {
|
||||
key: string;
|
||||
message: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
const MAX_RENDERED_USERS = 40;
|
||||
const MAX_RESOLVED_TICKET_HISTORY = 6;
|
||||
|
|
@ -330,6 +344,201 @@ function parseCohorts(value: string): string[] {
|
|||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function stringField(
|
||||
source: Record<string, unknown>,
|
||||
field: string,
|
||||
fallback: string,
|
||||
warnings: AdminDataWarning[],
|
||||
label: string,
|
||||
): string {
|
||||
const value = source[field];
|
||||
if (typeof value === "string") return value;
|
||||
if (value == null) {
|
||||
warnings.push({
|
||||
key: `${label}.${field}`,
|
||||
message: `${label} 응답에서 ${field} 값이 비어 있어 기본값으로 표시합니다.`,
|
||||
detail: `${field}=null`,
|
||||
});
|
||||
return fallback;
|
||||
}
|
||||
warnings.push({
|
||||
key: `${label}.${field}`,
|
||||
message: `${label} 응답에서 ${field} 타입이 잘못되어 문자열로 변환했습니다.`,
|
||||
detail: `${field}=${typeof value}`,
|
||||
});
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function booleanField(
|
||||
source: Record<string, unknown>,
|
||||
field: string,
|
||||
fallback: boolean,
|
||||
warnings: AdminDataWarning[],
|
||||
label: string,
|
||||
): boolean {
|
||||
const value = source[field];
|
||||
if (typeof value === "boolean") return value;
|
||||
if (value == null) {
|
||||
warnings.push({
|
||||
key: `${label}.${field}`,
|
||||
message: `${label} 응답에서 ${field} 값이 비어 있어 ${fallback ? "true" : "false"}로 표시합니다.`,
|
||||
detail: `${field}=null`,
|
||||
});
|
||||
return fallback;
|
||||
}
|
||||
warnings.push({
|
||||
key: `${label}.${field}`,
|
||||
message: `${label} 응답에서 ${field} 타입이 잘못되어 bool 기본값으로 표시합니다.`,
|
||||
detail: `${field}=${typeof value}`,
|
||||
});
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function numberField(
|
||||
source: Record<string, unknown>,
|
||||
field: string,
|
||||
fallback: number,
|
||||
warnings: AdminDataWarning[],
|
||||
label: string,
|
||||
): number {
|
||||
const value = source[field];
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
warnings.push({
|
||||
key: `${label}.${field}`,
|
||||
message: `${label} 응답에서 ${field}가 문자열이라 숫자로 변환했습니다.`,
|
||||
detail: `${field}=string`,
|
||||
});
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
warnings.push({
|
||||
key: `${label}.${field}`,
|
||||
message: `${label} 응답에서 ${field} 숫자 값이 없어 ${fallback}으로 표시합니다.`,
|
||||
detail: `${field}=${value == null ? "null" : typeof value}`,
|
||||
});
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function cohortIdsField(
|
||||
source: Record<string, unknown>,
|
||||
warnings: AdminDataWarning[],
|
||||
label: string,
|
||||
): string[] {
|
||||
const value = source.cohort_ids;
|
||||
if (!Array.isArray(value)) {
|
||||
warnings.push({
|
||||
key: `${label}.cohort_ids`,
|
||||
message: `${label} 응답에서 cohort_ids가 배열이 아니라 빈 코호트로 표시합니다.`,
|
||||
detail: `cohort_ids=${value == null ? "null" : typeof value}`,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
const normalized = value.filter((item): item is string => typeof item === "string");
|
||||
if (normalized.length !== value.length) {
|
||||
warnings.push({
|
||||
key: `${label}.cohort_ids.items`,
|
||||
message: `${label} 응답에서 cohort_ids 내부에 문자열이 아닌 값이 있어 제외했습니다.`,
|
||||
detail: `kept=${normalized.length}, raw=${value.length}`,
|
||||
});
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function roleField(
|
||||
source: Record<string, unknown>,
|
||||
warnings: AdminDataWarning[],
|
||||
label: string,
|
||||
): AdminManagedUser["role"] {
|
||||
const value = source.role;
|
||||
if (value === "learner" || value === "teacher" || value === "admin") return value;
|
||||
warnings.push({
|
||||
key: `${label}.role`,
|
||||
message: `${label} 응답에서 role 값이 계약과 달라 학습자로 표시합니다.`,
|
||||
detail: `role=${String(value)}`,
|
||||
});
|
||||
return "learner";
|
||||
}
|
||||
|
||||
function accountStatusField(
|
||||
source: Record<string, unknown>,
|
||||
warnings: AdminDataWarning[],
|
||||
label: string,
|
||||
): AdminManagedUser["account_status"] {
|
||||
const value = source.account_status;
|
||||
if (value === "pending" || value === "approved" || value === "suspended") return value;
|
||||
warnings.push({
|
||||
key: `${label}.account_status`,
|
||||
message: `${label} 응답에서 account_status 값이 계약과 달라 승인 대기로 표시합니다.`,
|
||||
detail: `account_status=${String(value)}`,
|
||||
});
|
||||
return "pending";
|
||||
}
|
||||
|
||||
function userSourceField(
|
||||
source: Record<string, unknown>,
|
||||
parentSource: AdminUsersResponse["source"],
|
||||
): AdminManagedUser["source"] {
|
||||
const value = source.source;
|
||||
return value === "server_session_registry" || value === "database" ? value : parentSource;
|
||||
}
|
||||
|
||||
function normalizeAdminUsersResponse(rawResponse: AdminUsersResponse): {
|
||||
response: AdminUsersResponse;
|
||||
warnings: AdminDataWarning[];
|
||||
} {
|
||||
const warnings: AdminDataWarning[] = [];
|
||||
const root = asRecord(rawResponse);
|
||||
const source: AdminUsersResponse["source"] =
|
||||
root.source === "server_session_registry" ? "server_session_registry" : "database";
|
||||
const durable = typeof root.durable === "boolean" ? root.durable : source === "database";
|
||||
const rawUsers = Array.isArray(root.users) ? root.users : [];
|
||||
|
||||
if (!Array.isArray(root.users)) {
|
||||
warnings.push({
|
||||
key: "admin.users",
|
||||
message: "/admin/users 응답에서 users 배열이 없어 빈 목록으로 표시합니다.",
|
||||
detail: `users=${root.users == null ? "null" : typeof root.users}`,
|
||||
});
|
||||
}
|
||||
|
||||
const users = rawUsers.map((item, index): AdminManagedUser => {
|
||||
const sourceRecord = asRecord(item);
|
||||
const label = `users[${index}]`;
|
||||
const userId = stringField(sourceRecord, "user_id", `unknown-${index + 1}`, warnings, label);
|
||||
return {
|
||||
user_id: userId,
|
||||
email: stringField(sourceRecord, "email", userId, warnings, label),
|
||||
display_name: stringField(sourceRecord, "display_name", userId, warnings, label),
|
||||
role: roleField(sourceRecord, warnings, label),
|
||||
admin_access: booleanField(sourceRecord, "admin_access", false, warnings, label),
|
||||
super_admin: booleanField(sourceRecord, "super_admin", false, warnings, label),
|
||||
account_status: accountStatusField(sourceRecord, warnings, label),
|
||||
cohort_ids: cohortIdsField(sourceRecord, warnings, label),
|
||||
affiliation: stringField(sourceRecord, "affiliation", "", warnings, label),
|
||||
active_sessions: numberField(sourceRecord, "active_sessions", 0, warnings, label),
|
||||
created_at: numberField(sourceRecord, "created_at", 0, warnings, label),
|
||||
last_seen_at: numberField(sourceRecord, "last_seen_at", 0, warnings, label),
|
||||
source: userSourceField(sourceRecord, source),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
response: {
|
||||
source,
|
||||
durable,
|
||||
users,
|
||||
},
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function isOnline(seconds?: number | null): boolean {
|
||||
if (!seconds || seconds <= 0) return false;
|
||||
return Date.now() / 1000 - seconds < 15 * 60;
|
||||
|
|
@ -355,6 +564,187 @@ function PageHeader({ kicker, title, description, children }: PageHeaderProps) {
|
|||
);
|
||||
}
|
||||
|
||||
function runtimeAssetLabel(): string {
|
||||
if (typeof document === "undefined") return "unknown";
|
||||
const script = Array.from(document.scripts)
|
||||
.map((item) => item.getAttribute("src") ?? "")
|
||||
.find((src) => src.includes("/assets/index-") && src.endsWith(".js"));
|
||||
if (!script) return "unknown";
|
||||
return script.split("/").pop() ?? script;
|
||||
}
|
||||
|
||||
function AdminDiagnosticPanel({
|
||||
title,
|
||||
body,
|
||||
details,
|
||||
}: {
|
||||
title: string;
|
||||
body: string;
|
||||
details: Array<[string, string]>;
|
||||
}) {
|
||||
return (
|
||||
<section className="ad-diagnostic" role="alert">
|
||||
<div>
|
||||
<Kicker>화면 진단</Kicker>
|
||||
<h1>{title}</h1>
|
||||
<p>{body}</p>
|
||||
</div>
|
||||
<dl>
|
||||
{details.map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<dt>{key}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface AdminErrorBoundaryProps {
|
||||
section: AdminSection;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface AdminErrorBoundaryState {
|
||||
error: Error | null;
|
||||
errorInfo: ErrorInfo | null;
|
||||
}
|
||||
|
||||
class AdminErrorBoundary extends Component<AdminErrorBoundaryProps, AdminErrorBoundaryState> {
|
||||
state: AdminErrorBoundaryState = { error: null, errorInfo: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): AdminErrorBoundaryState {
|
||||
return { error, errorInfo: null };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
this.setState({ errorInfo });
|
||||
console.error("[admin-render-error]", error, errorInfo.componentStack);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: AdminErrorBoundaryProps) {
|
||||
if (prevProps.section !== this.props.section && this.state.error) {
|
||||
this.setState({ error: null, errorInfo: null });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<AdminDiagnosticPanel
|
||||
title="관리자 화면을 표시하지 못했습니다"
|
||||
body="렌더 중 예외가 발생했습니다. 빈 화면 대신 아래 진단값을 남깁니다."
|
||||
details={[
|
||||
["section", this.props.section],
|
||||
["path", typeof window === "undefined" ? "unknown" : window.location.pathname],
|
||||
["error", this.state.error.message || this.state.error.name],
|
||||
["asset", runtimeAssetLabel()],
|
||||
["componentStack", this.state.errorInfo?.componentStack?.trim() || "not captured"],
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function AdminBlankContentProbe({ section }: { section: AdminSection }) {
|
||||
const markerRef = useRef<HTMLSpanElement | null>(null);
|
||||
const [diagnostic, setDiagnostic] = useState<Array<[string, string]> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDiagnostic(null);
|
||||
const timer = window.setTimeout(() => {
|
||||
const root = markerRef.current?.closest(".ad-root") as HTMLElement | null;
|
||||
if (!root) return;
|
||||
const text = root.innerText.replace(/\s+/g, " ").trim();
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const visibleNodes = Array.from(
|
||||
root.querySelectorAll<HTMLElement>(
|
||||
"h1,h2,p,button,input,select,article,section,.ad-head,.ad-panel,.ad-section,.ad-users-note,.ad-status",
|
||||
),
|
||||
).filter((element) => {
|
||||
if (element.classList.contains("ad-diagnostic")) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
return (
|
||||
rect.width > 1 &&
|
||||
rect.height > 1 &&
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden" &&
|
||||
style.opacity !== "0"
|
||||
);
|
||||
});
|
||||
|
||||
if (text.length === 0 || rootRect.height < 12 || visibleNodes.length === 0) {
|
||||
setDiagnostic([
|
||||
["section", section],
|
||||
["path", window.location.pathname],
|
||||
["rootHeight", `${Math.round(rootRect.height)}px`],
|
||||
["visibleNodes", String(visibleNodes.length)],
|
||||
["scrollY", String(Math.round(window.scrollY))],
|
||||
["asset", runtimeAssetLabel()],
|
||||
]);
|
||||
}
|
||||
}, 800);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [section]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span ref={markerRef} className="ad-blank-probe" aria-hidden="true" />
|
||||
{diagnostic ? (
|
||||
<AdminDiagnosticPanel
|
||||
title="관리자 본문이 비어 있습니다"
|
||||
body="라우트는 열렸지만 본문 DOM이 표시 가능한 콘텐츠를 만들지 못했습니다."
|
||||
details={diagnostic}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminSectionRenderer({
|
||||
section,
|
||||
diagnostics,
|
||||
renderOverview,
|
||||
renderUsers,
|
||||
renderAccess,
|
||||
renderTickets,
|
||||
}: {
|
||||
section: AdminSection;
|
||||
diagnostics: ReactNode;
|
||||
renderOverview: () => ReactNode;
|
||||
renderUsers: () => ReactNode;
|
||||
renderAccess: () => ReactNode;
|
||||
renderTickets: () => ReactNode;
|
||||
}) {
|
||||
let content: ReactNode = null;
|
||||
if (section === "overview") content = renderOverview();
|
||||
if (section === "users") content = renderUsers();
|
||||
if (section === "access") content = renderAccess();
|
||||
if (section === "tickets") content = renderTickets();
|
||||
|
||||
return (
|
||||
<div className="ad-root" data-admin-section={section}>
|
||||
{diagnostics}
|
||||
{content ?? (
|
||||
<AdminDiagnosticPanel
|
||||
title="관리자 라우트 섹션을 찾지 못했습니다"
|
||||
body="라우터가 알 수 없는 관리자 섹션을 전달했습니다."
|
||||
details={[
|
||||
["section", section],
|
||||
["path", typeof window === "undefined" ? "unknown" : window.location.pathname],
|
||||
["asset", runtimeAssetLabel()],
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<AdminBlankContentProbe section={section} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AdminProps {
|
||||
section?: AdminSection;
|
||||
}
|
||||
|
|
@ -368,6 +758,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
const [usersData, setUsersData] = useState<AdminUsersResponse | null>(null);
|
||||
const [usersLoading, setUsersLoading] = useState(true);
|
||||
const [usersError, setUsersError] = useState<string | null>(null);
|
||||
const [usersDiagnostics, setUsersDiagnostics] = useState<AdminDataWarning[]>([]);
|
||||
const [userDrafts, setUserDrafts] = useState<Record<string, UserDraft>>({});
|
||||
const [savingUserId, setSavingUserId] = useState<string | null>(null);
|
||||
const [newUser, setNewUser] = useState<NewUserDraft>(EMPTY_NEW_USER);
|
||||
|
|
@ -413,10 +804,12 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
setUsersError(null);
|
||||
try {
|
||||
const next = await adminUsersApi.list();
|
||||
setUsersData(next);
|
||||
const { response: normalized, warnings } = normalizeAdminUsersResponse(next);
|
||||
setUsersData(normalized);
|
||||
setUsersDiagnostics(warnings);
|
||||
setUserDrafts((current) =>
|
||||
Object.fromEntries(
|
||||
next.users.map((user) => [
|
||||
normalized.users.map((user) => [
|
||||
user.user_id,
|
||||
current[user.user_id] ?? {
|
||||
display_name: user.display_name,
|
||||
|
|
@ -431,6 +824,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
);
|
||||
} catch (err) {
|
||||
setUsersError(err instanceof Error ? err.message : "사용자 목록을 불러오지 못했습니다.");
|
||||
setUsersDiagnostics([]);
|
||||
} finally {
|
||||
setUsersLoading(false);
|
||||
}
|
||||
|
|
@ -804,6 +1198,25 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
}, [userSearch, users]);
|
||||
const hasUserSearch = userSearch.trim().length > 0;
|
||||
const visibleUsers = filteredUsers.slice(0, MAX_RENDERED_USERS);
|
||||
const renderAdminDiagnostics = () =>
|
||||
usersDiagnostics.length > 0 ? (
|
||||
<AdminDiagnosticPanel
|
||||
title="관리자 데이터 진단"
|
||||
body="/admin/users 응답 일부가 화면 계약과 달라 기본값으로 보정했습니다. 빈 화면 대신 어떤 필드가 문제인지 표시합니다."
|
||||
details={[
|
||||
["section", section],
|
||||
["path", typeof window === "undefined" ? "unknown" : window.location.pathname],
|
||||
["asset", runtimeAssetLabel()],
|
||||
...usersDiagnostics.slice(0, 8).map((warning): [string, string] => [
|
||||
warning.key,
|
||||
`${warning.message} (${warning.detail})`,
|
||||
]),
|
||||
...(usersDiagnostics.length > 8
|
||||
? [["more", `${usersDiagnostics.length - 8}개 추가 진단 생략`] as [string, string]]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const renderOverview = () => (
|
||||
<>
|
||||
|
|
@ -1875,12 +2288,16 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
|
||||
return (
|
||||
<AppShell contextLabel="운영 콘솔" navRole="admin" wide>
|
||||
<div className="ad-root">
|
||||
{section === "overview" ? renderOverview() : null}
|
||||
{section === "users" ? renderUsers() : null}
|
||||
{section === "access" ? renderAccess() : null}
|
||||
{section === "tickets" ? renderTickets() : null}
|
||||
</div>
|
||||
<AdminErrorBoundary section={section}>
|
||||
<AdminSectionRenderer
|
||||
section={section}
|
||||
diagnostics={renderAdminDiagnostics()}
|
||||
renderOverview={renderOverview}
|
||||
renderUsers={renderUsers}
|
||||
renderAccess={renderAccess}
|
||||
renderTickets={renderTickets}
|
||||
/>
|
||||
</AdminErrorBoundary>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,83 @@
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ad-blank-probe {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
}
|
||||
|
||||
.ad-diagnostic {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
border: 1px solid color-mix(in srgb, var(--warn-solid) 42%, var(--hair));
|
||||
border-radius: var(--radius);
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--warn-tint) 48%, var(--bg-surface)), var(--bg-surface));
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.ad-diagnostic h1 {
|
||||
margin: 6px 0 0;
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-h2);
|
||||
line-height: 1.28;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.ad-diagnostic p {
|
||||
margin: 6px 0 0;
|
||||
max-width: 760px;
|
||||
color: var(--text-body);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.ad-diagnostic dl {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(130px, 0.28fr) minmax(0, 1fr);
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--hair);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.ad-diagnostic dl > div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(130px, 0.28fr) minmax(0, 1fr);
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ad-diagnostic dt,
|
||||
.ad-diagnostic dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 9px 11px;
|
||||
border-bottom: 1px solid var(--hair);
|
||||
color: var(--text-body);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ad-diagnostic dl > div:last-child dt,
|
||||
.ad-diagnostic dl > div:last-child dd {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.ad-diagnostic dt {
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-num);
|
||||
font-weight: 700;
|
||||
background: var(--bg-surface-2);
|
||||
}
|
||||
|
||||
.ad-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue