Aiden added closer matching look
This commit is contained in:
@@ -38,6 +38,8 @@ The property request uses `READ_ACCESS` and omits `propertyName`, which asks Unr
|
||||
|
||||
Details payloads are cached in browser `localStorage` for five minutes per object path. When you reselect an object, cached properties render immediately and the app refreshes them from Unreal in the background.
|
||||
|
||||
Asset-looking property values also request `/remote/object/thumbnail` and cache thumbnail data in `localStorage`, so mesh/material fields can render closer to Unreal's Details panel.
|
||||
|
||||
## Run
|
||||
|
||||
Start Unreal Editor, enable the Remote Control API, and make sure it is listening on `127.0.0.1:30010`.
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import React from "react";
|
||||
import { ChevronDown, Search, Settings, SlidersHorizontal, X } from "lucide-react";
|
||||
import { Box, ChevronDown, Grid2X2, RotateCcw, Search, Settings, SlidersHorizontal, Star, X } from "lucide-react";
|
||||
import type { DetailProperty, ObjectDetails, TreeNode } from "../types";
|
||||
import { basename } from "../lib/remoteValues";
|
||||
import { formatDetailValue } from "../services/details";
|
||||
import {
|
||||
detailAssetPath,
|
||||
formatDetailValue,
|
||||
isBooleanProperty,
|
||||
isVectorLikeProperty,
|
||||
normalizeThumbnailPayload,
|
||||
vectorParts,
|
||||
} from "../services/details";
|
||||
import { readRemoteObjectThumbnail } from "../services/remoteControl";
|
||||
import { readCachedThumbnail, writeCachedThumbnail } from "../services/thumbnailCache";
|
||||
import { NodeIcon } from "./NodeIcon";
|
||||
|
||||
const emptyNode: TreeNode = {
|
||||
@@ -15,6 +24,80 @@ const emptyNode: TreeNode = {
|
||||
children: [],
|
||||
};
|
||||
|
||||
const categoryTabs = ["General", "Actor", "LOD", "Misc", "Physics", "Rendering", "Streaming", "All"];
|
||||
|
||||
function DetailValue({ property }: { property: DetailProperty }) {
|
||||
const assetPath = detailAssetPath(property);
|
||||
const [thumbnail, setThumbnail] = React.useState(() => (assetPath ? readCachedThumbnail(assetPath) : ""));
|
||||
|
||||
React.useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
if (!assetPath) {
|
||||
setThumbnail("");
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = readCachedThumbnail(assetPath);
|
||||
if (cached) {
|
||||
setThumbnail(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
readRemoteObjectThumbnail(assetPath)
|
||||
.then((payload) => {
|
||||
const dataUrl = normalizeThumbnailPayload(payload);
|
||||
if (!isCancelled && dataUrl) {
|
||||
setThumbnail(dataUrl);
|
||||
writeCachedThumbnail(assetPath, dataUrl);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isCancelled) {
|
||||
setThumbnail("");
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [assetPath]);
|
||||
|
||||
if (assetPath) {
|
||||
return (
|
||||
<div className="asset-value">
|
||||
<div className="asset-thumb">{thumbnail ? <img src={thumbnail} alt="" /> : <Box size={28} />}</div>
|
||||
<div className="asset-control">
|
||||
<span>{formatDetailValue(property.value)}</span>
|
||||
<ChevronDown size={16} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isVectorLikeProperty(property)) {
|
||||
return (
|
||||
<div className="vector-value">
|
||||
{vectorParts(property.value).map((part, index) => (
|
||||
<span className={`axis axis-${index}`} key={part.label}>
|
||||
{part.value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isBooleanProperty(property)) {
|
||||
return <span className={`checkbox-value ${property.value ? "checked" : ""}`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-value">
|
||||
<span>{formatDetailValue(property.value)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailsPanel({
|
||||
node,
|
||||
details,
|
||||
@@ -27,10 +110,13 @@ export function DetailsPanel({
|
||||
error: string | null;
|
||||
}) {
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [activeCategory, setActiveCategory] = React.useState("All");
|
||||
|
||||
const groupedProperties = React.useMemo(() => {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const visibleProperties = (details?.properties ?? []).filter((property) => {
|
||||
const matchesCategory = activeCategory === "All" || property.category.toLowerCase().includes(activeCategory.toLowerCase());
|
||||
if (!matchesCategory) return false;
|
||||
if (!lowerQuery) return true;
|
||||
return [property.displayName, property.name, property.type, property.category, formatDetailValue(property.value)].some((field) =>
|
||||
field.toLowerCase().includes(lowerQuery),
|
||||
@@ -43,7 +129,7 @@ export function DetailsPanel({
|
||||
groups[category].push(property);
|
||||
return groups;
|
||||
}, {});
|
||||
}, [details, query]);
|
||||
}, [activeCategory, details, query]);
|
||||
|
||||
return (
|
||||
<aside className="details-panel" aria-label="Details">
|
||||
@@ -67,16 +153,47 @@ export function DetailsPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="component-list">
|
||||
<div className="component-row selected">
|
||||
<NodeIcon node={node ?? emptyNode} />
|
||||
<span>{node ? `${node.label} (Instance)` : "No Selection"}</span>
|
||||
</div>
|
||||
<div className="component-row child">
|
||||
<Box size={18} />
|
||||
<span>{node?.type ? `${node.type}Component (${node.type}Component0)` : "Component"}</span>
|
||||
<a>Edit in C++</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="details-search-row">
|
||||
<label className="details-search">
|
||||
<Search size={18} />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search" />
|
||||
</label>
|
||||
<button className="details-tool" type="button" title="View Options">
|
||||
<Grid2X2 size={18} />
|
||||
</button>
|
||||
<button className="details-tool" type="button" title="Favorites">
|
||||
<Star size={18} />
|
||||
</button>
|
||||
<button className="details-tool" type="button" title="Settings">
|
||||
<Settings size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="category-tabs">
|
||||
{categoryTabs.map((category) => (
|
||||
<button
|
||||
className={activeCategory === category ? "active" : ""}
|
||||
key={category}
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(category)}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="details-categories">
|
||||
{isLoading ? (
|
||||
<div className="details-empty">Loading properties...</div>
|
||||
@@ -96,7 +213,12 @@ export function DetailsPanel({
|
||||
{properties.map((property) => (
|
||||
<div className="detail-row" key={property.name} title={property.description || property.type}>
|
||||
<span className="detail-name">{property.displayName}</span>
|
||||
<span className="detail-value">{formatDetailValue(property.value)}</span>
|
||||
<span className="detail-value">
|
||||
<DetailValue property={property} />
|
||||
<button className="reset-button row-reset" type="button" title="Reset to default">
|
||||
<RotateCcw size={15} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
@@ -109,3 +109,49 @@ export function formatDetailValue(value: unknown): string {
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function detailAssetPath(property: DetailProperty): string {
|
||||
const valuePath = extractObjectPath(property.value);
|
||||
if (valuePath.startsWith("/Game/") || valuePath.startsWith("/Engine/")) {
|
||||
return valuePath;
|
||||
}
|
||||
|
||||
if (typeof property.value === "string" && (property.value.startsWith("/Game/") || property.value.startsWith("/Engine/"))) {
|
||||
return property.value;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function isBooleanProperty(property: DetailProperty): boolean {
|
||||
return typeof property.value === "boolean" || property.type.toLowerCase().includes("bool");
|
||||
}
|
||||
|
||||
export function isVectorLikeProperty(property: DetailProperty): boolean {
|
||||
if (!property.value || typeof property.value !== "object" || Array.isArray(property.value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const record = asRecord(property.value);
|
||||
return ["X", "Y", "Z"].every((key) => key in record) || ["Roll", "Pitch", "Yaw"].every((key) => key in record);
|
||||
}
|
||||
|
||||
export function vectorParts(value: unknown): Array<{ label: string; value: string }> {
|
||||
const record = asRecord(value);
|
||||
const keys = ["X", "Y", "Z"].every((key) => key in record) ? ["X", "Y", "Z"] : ["Roll", "Pitch", "Yaw"];
|
||||
return keys.map((key) => ({ label: key, value: formatDetailValue(record[key]) }));
|
||||
}
|
||||
|
||||
export function normalizeThumbnailPayload(payload: unknown): string {
|
||||
if (typeof payload === "string") {
|
||||
return payload.startsWith("data:") ? payload : `data:image/png;base64,${payload}`;
|
||||
}
|
||||
|
||||
const record = asRecord(payload);
|
||||
const value = firstString(record, ["Thumbnail", "thumbnail", "Image", "image", "Data", "data", "Base64", "base64"]);
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return value.startsWith("data:") ? value : `data:image/png;base64,${value}`;
|
||||
}
|
||||
|
||||
@@ -223,3 +223,7 @@ export async function readRemoteObjectProperties(objectPath: string): Promise<un
|
||||
access: "READ_ACCESS",
|
||||
});
|
||||
}
|
||||
|
||||
export async function readRemoteObjectThumbnail(objectPath: string): Promise<unknown> {
|
||||
return callRemoteHttpRoute("/remote/object/thumbnail", "PUT", { objectPath });
|
||||
}
|
||||
|
||||
106
src/services/thumbnailCache.ts
Normal file
106
src/services/thumbnailCache.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
const CACHE_PREFIX = "unreal-outliner.thumbnail.v1.";
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
const MAX_CACHE_ENTRIES = 100;
|
||||
|
||||
type CachedThumbnail = {
|
||||
objectPath: string;
|
||||
savedAt: number;
|
||||
dataUrl: string;
|
||||
};
|
||||
|
||||
function cacheKey(objectPath: string): string {
|
||||
return `${CACHE_PREFIX}${objectPath}`;
|
||||
}
|
||||
|
||||
function isCachedThumbnail(value: unknown): value is CachedThumbnail {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as CachedThumbnail;
|
||||
return typeof candidate.objectPath === "string" && typeof candidate.savedAt === "number" && typeof candidate.dataUrl === "string";
|
||||
}
|
||||
|
||||
function storageAvailable(): boolean {
|
||||
try {
|
||||
const key = `${CACHE_PREFIX}probe`;
|
||||
window.localStorage.setItem(key, "1");
|
||||
window.localStorage.removeItem(key);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function readCachedThumbnail(objectPath: string): string {
|
||||
if (!storageAvailable()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(cacheKey(objectPath));
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!isCachedThumbnail(parsed) || parsed.objectPath !== objectPath) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (Date.now() - parsed.savedAt > CACHE_TTL_MS) {
|
||||
window.localStorage.removeItem(cacheKey(objectPath));
|
||||
return "";
|
||||
}
|
||||
|
||||
return parsed.dataUrl;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCachedThumbnail(objectPath: string, dataUrl: string): void {
|
||||
if (!storageAvailable() || !dataUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(cacheKey(objectPath), JSON.stringify({ objectPath, savedAt: Date.now(), dataUrl }));
|
||||
pruneThumbnailCache();
|
||||
} catch {
|
||||
pruneThumbnailCache();
|
||||
}
|
||||
}
|
||||
|
||||
function pruneThumbnailCache(): void {
|
||||
const entries: Array<{ key: string; savedAt: number }> = [];
|
||||
|
||||
for (let index = 0; index < window.localStorage.length; index += 1) {
|
||||
const key = window.localStorage.key(index);
|
||||
if (!key?.startsWith(CACHE_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(window.localStorage.getItem(key) ?? "");
|
||||
if (isCachedThumbnail(parsed)) {
|
||||
entries.push({ key, savedAt: parsed.savedAt });
|
||||
}
|
||||
} catch {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
const expiredBefore = Date.now() - CACHE_TTL_MS;
|
||||
for (const entry of entries) {
|
||||
if (entry.savedAt < expiredBefore) {
|
||||
window.localStorage.removeItem(entry.key);
|
||||
}
|
||||
}
|
||||
|
||||
entries
|
||||
.filter((entry) => entry.savedAt >= expiredBefore)
|
||||
.sort((a, b) => b.savedAt - a.savedAt)
|
||||
.slice(MAX_CACHE_ENTRIES)
|
||||
.forEach((entry) => window.localStorage.removeItem(entry.key));
|
||||
}
|
||||
243
src/styles.css
243
src/styles.css
@@ -281,7 +281,7 @@ input {
|
||||
.details-panel {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: 38px 58px 44px 1fr;
|
||||
grid-template-rows: 38px 48px 118px 44px 40px 1fr;
|
||||
border-top: 2px solid #0d0d0d;
|
||||
background: #202020;
|
||||
color: #cfcfcf;
|
||||
@@ -323,7 +323,7 @@ input {
|
||||
grid-template-columns: 28px 1fr;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 14px;
|
||||
padding: 7px 14px;
|
||||
border-bottom: 1px solid #171717;
|
||||
background: #242424;
|
||||
}
|
||||
@@ -339,7 +339,7 @@ input {
|
||||
|
||||
.details-object strong {
|
||||
color: #f1f1f1;
|
||||
font-size: 18px;
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -349,9 +349,56 @@ input {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.component-list {
|
||||
min-height: 0;
|
||||
padding: 6px 8px 8px;
|
||||
background: #181818;
|
||||
border: 1px solid #242424;
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.component-row {
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 0 10px;
|
||||
color: #d2d2d2;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.component-row.selected {
|
||||
background: #3d5770;
|
||||
color: #f1f1f1;
|
||||
}
|
||||
|
||||
.component-row.child {
|
||||
padding-left: 42px;
|
||||
background: #151515;
|
||||
color: #c9c9c9;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.component-row span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.component-row a {
|
||||
margin-left: auto;
|
||||
color: #d9d9d9;
|
||||
text-decoration: underline dotted;
|
||||
text-underline-offset: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.details-search-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 1fr) 34px;
|
||||
grid-template-columns: minmax(140px, 1fr) 34px 34px 34px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
@@ -396,6 +443,38 @@ input {
|
||||
background: #363636;
|
||||
}
|
||||
|
||||
.category-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 5px 12px;
|
||||
background: #262626;
|
||||
border-bottom: 1px solid #141414;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.category-tabs button {
|
||||
height: 29px;
|
||||
min-width: 84px;
|
||||
padding: 0 14px;
|
||||
color: #c7c7c7;
|
||||
background: #2c2c2c;
|
||||
border: 1px solid #151515;
|
||||
border-radius: 5px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.category-tabs button:hover {
|
||||
background: #343434;
|
||||
}
|
||||
|
||||
.category-tabs button.active {
|
||||
color: #ffffff;
|
||||
background: #0877d9;
|
||||
border-color: #0a5ea9;
|
||||
}
|
||||
|
||||
.details-categories {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
@@ -420,7 +499,7 @@ input {
|
||||
.detail-row {
|
||||
min-height: 40px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 0.45fr) minmax(220px, 0.55fr);
|
||||
grid-template-columns: minmax(180px, 0.48fr) minmax(220px, 0.52fr);
|
||||
border-bottom: 1px solid #171717;
|
||||
}
|
||||
|
||||
@@ -439,11 +518,165 @@ input {
|
||||
.detail-name {
|
||||
color: #c8c8c8;
|
||||
border-right: 1px solid #151515;
|
||||
background: #242424;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #e0e0e0;
|
||||
background: #242424;
|
||||
gap: 8px;
|
||||
padding: 5px 10px 5px 14px;
|
||||
}
|
||||
|
||||
.text-value {
|
||||
min-width: 0;
|
||||
width: min(380px, 100%);
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
color: #e5e5e5;
|
||||
background: #101010;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 0 0 1px #050505;
|
||||
}
|
||||
|
||||
.text-value span,
|
||||
.asset-control span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vector-value {
|
||||
min-width: 0;
|
||||
width: min(420px, 100%);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(64px, 1fr));
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.axis {
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 0 8px;
|
||||
color: #f0f0f0;
|
||||
background: #101010;
|
||||
border: 1px solid #333;
|
||||
border-left-width: 4px;
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 0 0 1px #050505;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.axis-0 {
|
||||
border-left-color: #df3b1f;
|
||||
}
|
||||
|
||||
.axis-1 {
|
||||
border-left-color: #76b900;
|
||||
}
|
||||
|
||||
.axis-2 {
|
||||
border-left-color: #0d7fe8;
|
||||
}
|
||||
|
||||
.checkbox-value {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid #353535;
|
||||
border-radius: 4px;
|
||||
background: #111;
|
||||
box-shadow: inset 0 0 0 1px #050505;
|
||||
}
|
||||
|
||||
.checkbox-value.checked::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 13px;
|
||||
height: 7px;
|
||||
margin: 7px 0 0 5px;
|
||||
border-left: 2px solid #d7d7d7;
|
||||
border-bottom: 2px solid #d7d7d7;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.asset-value {
|
||||
min-width: 0;
|
||||
width: min(430px, 100%);
|
||||
display: grid;
|
||||
grid-template-columns: 54px minmax(120px, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.asset-thumb {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #bdbdbd;
|
||||
background:
|
||||
linear-gradient(45deg, #1a1a1a 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #1a1a1a 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #1a1a1a 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #1a1a1a 75%),
|
||||
#101010;
|
||||
background-position:
|
||||
0 0,
|
||||
0 8px,
|
||||
8px -8px,
|
||||
-8px 0;
|
||||
background-size: 16px 16px;
|
||||
border: 1px solid #313131;
|
||||
border-radius: 5px;
|
||||
box-shadow: inset 0 -3px 0 #16bfc4;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.asset-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.asset-control {
|
||||
min-width: 0;
|
||||
height: 31px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 20px;
|
||||
align-items: center;
|
||||
padding: 0 8px 0 10px;
|
||||
color: #e5e5e5;
|
||||
background: #101010;
|
||||
border: 1px solid #333;
|
||||
border-radius: 5px;
|
||||
box-shadow: inset 0 0 0 1px #050505;
|
||||
}
|
||||
|
||||
.reset-button {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
color: #bcbcbc;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.reset-button:hover {
|
||||
background: #343434;
|
||||
}
|
||||
|
||||
.row-reset {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.details-empty {
|
||||
|
||||
Reference in New Issue
Block a user