nicolium: add pleroma config management

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2026-04-06 18:48:01 +00:00
parent 36b379f040
commit 9e74136140
12 changed files with 1843 additions and 6 deletions

View File

@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React, { memo, useMemo } from 'react';
import Checkbox from './checkbox';
@@ -78,7 +78,7 @@ const FormGroup: React.FC<IFormGroup> = (props) => {
}
return (
<div>
<div className='⁂-form-group'>
{labelText && (
<label
htmlFor={formFieldId}
@@ -114,4 +114,6 @@ const FormGroup: React.FC<IFormGroup> = (props) => {
);
};
export { FormGroup as default };
const MemoizedFormGroup = memo(FormGroup);
export { MemoizedFormGroup as default };

View File

@@ -195,6 +195,26 @@
"admin.links.pending_reports": "{count, plural, one {{formattedCount} pending report} other {{formattedCount} pending reports}}",
"admin.links.pending_users": "{count, plural, one {{formattedCount} pending user} other {{formattedCount} pending users}}",
"admin.moderation_log.empty_message": "You have not performed any moderation actions yet. When you do, a history will be shown here.",
"admin.pleroma_config.complex_hint": "Edit this value as JSON.",
"admin.pleroma_config.empty": "No configuration options available.",
"admin.pleroma_config.empty_search": "No settings match the current filters.",
"admin.pleroma_config.json_invalid": "Invalid JSON",
"admin.pleroma_config.need_reboot": "Some configuration changes require a server restart to take effect.",
"admin.pleroma_config.no_changes": "No changes yet",
"admin.pleroma_config.placeholder_value": "value",
"admin.pleroma_config.placeholder_value.kv_key": "key",
"admin.pleroma_config.placeholder_value.kv_value": "value",
"admin.pleroma_config.placeholder_value.left": "left",
"admin.pleroma_config.placeholder_value.right": "right",
"admin.pleroma_config.reset": "Reset",
"admin.pleroma_config.save": "Save",
"admin.pleroma_config.save_failed": "Failed to save configuration",
"admin.pleroma_config.saved": "Configuration saved",
"admin.pleroma_config.search": "Search settings",
"admin.pleroma_config.search_placeholder": "Search by label, key, group or description",
"admin.pleroma_config.suggestions": "Suggestions",
"admin.pleroma_config.tuple_hint": "Values are parsed as JSON when possible, otherwise kept as strings.",
"admin.pleroma_config.value_type": "Value type",
"admin.relays.add.fail": "Failed to follow the instance relay",
"admin.relays.add.success": "Instance relay followed",
"admin.relays.deleted": "Relay unfollowed",
@@ -454,6 +474,7 @@
"column.admin.edit_domain": "Edit domain",
"column.admin.edit_rule": "Edit rule",
"column.admin.moderation_log": "Moderation log",
"column.admin.pleroma_config": "Pleroma configuration",
"column.admin.relays": "Instance relays",
"column.admin.reports": "Reports",
"column.admin.reports.clear_filter": "Clear filter",

View File

@@ -0,0 +1,183 @@
import React, { memo, useEffect, useMemo, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import Accordion from '@/components/ui/accordion';
import Form from '@/components/ui/form';
import FormActions from '@/components/ui/form-actions';
import { ConfigValueEditor } from './config-value-editor';
import { getDescriptionValue, getDescriptionValueSignature } from './utils';
import type { PleromaConfigDescription } from 'pl-api';
interface IConfigGroupEditor {
description: PleromaConfigDescription;
currentValue: unknown;
currentValueSignature: string;
onSave: (description: PleromaConfigDescription, value: unknown) => void;
isPending: boolean;
}
const ConfigGroupEditor = memo(
({
description,
currentValue,
currentValueSignature: _currentValueSignature,
onSave,
isPending,
}: IConfigGroupEditor) => {
const [draftValue, setDraftValue] = useState(currentValue);
const [hasError, setHasError] = useState(false);
useEffect(() => {
setDraftValue(currentValue);
setHasError(false);
}, [currentValue]);
const isDirty = useMemo(
() => JSON.stringify(draftValue) !== JSON.stringify(currentValue),
[currentValue, draftValue],
);
if (!description.group) return null;
return (
<Form
onSubmit={(event) => {
event.preventDefault();
onSave(description, draftValue);
}}
>
<fieldset className='⁂-admin-config__fieldset' disabled={isPending}>
<ConfigValueEditor
node={description}
value={draftValue}
onChange={setDraftValue}
onValidityChange={setHasError}
/>
</fieldset>
<FormActions>
{!isDirty ? (
<p className='⁂-admin-config__feedback'>
<FormattedMessage
id='admin.pleroma_config.no_changes'
defaultMessage='No changes yet'
/>
</p>
) : null}
<button
type='button'
disabled={isPending || !isDirty}
className='⁂-admin-config__reset-button'
onClick={() => {
setDraftValue(currentValue);
setHasError(false);
}}
>
<FormattedMessage id='admin.pleroma_config.reset' defaultMessage='Reset' />
</button>
<button
type='submit'
disabled={isPending || hasError || !isDirty}
className='⁂-admin-config__submit-button'
>
<FormattedMessage id='admin.pleroma_config.save' defaultMessage='Save' />
</button>
</FormActions>
</Form>
);
},
(prevProps, nextProps) =>
prevProps.description === nextProps.description &&
prevProps.currentValueSignature === nextProps.currentValueSignature &&
prevProps.onSave === nextProps.onSave &&
prevProps.isPending === nextProps.isPending,
);
ConfigGroupEditor.displayName = 'ConfigGroupEditor';
interface IConfigSection {
group: string;
descriptions: PleromaConfigDescription[];
configValueMap: Map<string, unknown>;
onSave: (description: PleromaConfigDescription, value: unknown) => void;
isPending: boolean;
}
const ConfigSection = memo(
({ group, descriptions, configValueMap, onSave, isPending }: IConfigSection) => {
const [expandedKey, setExpandedKey] = useState<string | null>(null);
return (
<section className='⁂-admin-config__section'>
<div className='⁂-admin-config__section-header'>
<h2 className='⁂-admin-config__section-title'>{group}</h2>
</div>
<div className='⁂-admin-config__accordion-list'>
{descriptions.map((description) => {
const accordionKey =
description.key ?? description.label ?? `${group}-${description.type}`;
const currentValue = getDescriptionValue(description, configValueMap);
return (
<Accordion
key={accordionKey}
expanded={expandedKey === accordionKey}
onToggle={(open) => setExpandedKey(open ? accordionKey : null)}
headline={
<div className='⁂-admin-config__accordion-headline'>
<p className='⁂-admin-config__headline-title'>
{description.label ?? description.key}
</p>
{description.description ? (
<p className='⁂-admin-config__meta'>{description.description}</p>
) : null}
</div>
}
>
{expandedKey === accordionKey && (
<ConfigGroupEditor
description={description}
currentValue={currentValue}
currentValueSignature={getDescriptionValueSignature(
description,
configValueMap,
)}
onSave={onSave}
isPending={isPending}
/>
)}
</Accordion>
);
})}
</div>
</section>
);
},
(prevProps, nextProps) => {
if (
prevProps.group !== nextProps.group ||
prevProps.descriptions !== nextProps.descriptions ||
prevProps.onSave !== nextProps.onSave ||
prevProps.isPending !== nextProps.isPending
) {
return false;
}
return prevProps.descriptions.every((description) => {
return (
getDescriptionValueSignature(description, prevProps.configValueMap) ===
getDescriptionValueSignature(description, nextProps.configValueMap)
);
});
},
);
ConfigSection.displayName = 'ConfigSection';
export { ConfigSection };

View File

@@ -0,0 +1,770 @@
import isEqual from 'lodash/isEqual';
import React, { memo, useEffect, useMemo, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import FormGroup from '@/components/ui/form-group';
import Input from '@/components/ui/input';
import Select from '@/components/ui/select';
import Streamfield from '@/components/ui/streamfield';
import Textarea from '@/components/ui/textarea';
import Toggle from '@/components/ui/toggle';
import {
createDynamicEntry,
createTupleEntry,
getDefaultValueForType,
getDynamicEntriesValue,
getPrimitiveTypeName,
getSuggestionValues,
getTupleSuggestions,
getTypeLabel,
getUnionOptions,
isContainerDescriptor,
isStringOrImageType,
isValueCompatibleWithType,
mapToTupleArray,
normalizeDynamicEntries,
normalizePrimitiveListEntries,
normalizeTupleEntries,
numericTypes,
parsePrimitiveListEntries,
parseLooseValue,
stringifyValue,
textualTypes,
tupleArrayToMap,
} from './utils';
import type { ConfigDescriptionNode, ConfigType, DynamicEntry, TupleEntry } from './utils';
import type { StreamfieldComponent } from '@/components/ui/streamfield';
const messages = defineMessages({
placeholderValue: {
id: 'admin.pleroma_config.placeholder_value',
defaultMessage: 'value',
},
placeholderValueLeft: {
id: 'admin.pleroma_config.placeholder_value.left',
defaultMessage: 'left',
},
placeholderValueRight: {
id: 'admin.pleroma_config.placeholder_value.right',
defaultMessage: 'right',
},
placeholderValueKVKey: {
id: 'admin.pleroma_config.placeholder_value.kv_key',
defaultMessage: 'key',
},
placeholderValueKVValue: {
id: 'admin.pleroma_config.placeholder_value.kv_value',
defaultMessage: 'value',
},
});
interface ISuggestions {
suggestions: unknown[];
onSelect: (value: unknown) => void;
}
interface IConfigValueEditor {
node: ConfigDescriptionNode;
value: unknown;
onChange: (value: unknown) => void;
onValidityChange?: (value: boolean) => void;
}
const Suggestions = memo(({ suggestions, onSelect }: ISuggestions) => {
if (!suggestions.length) return null;
return (
<div className='⁂-admin-config__suggestions'>
<p className='⁂-admin-config__suggestions-label'>
<FormattedMessage id='admin.pleroma_config.suggestions' defaultMessage='Suggestions' />
</p>
{suggestions.map((suggestion) => (
<button
key={`${typeof suggestion}-${stringifyValue(suggestion)}`}
type='button'
className='⁂-admin-config__suggestion-button'
onClick={() => onSelect(suggestion)}
>
{typeof suggestion === 'string' ? suggestion : stringifyValue(suggestion)}
</button>
))}
</div>
);
});
Suggestions.displayName = 'Suggestions';
const PrimitiveValueEditor = memo(({ node, value, onChange }: IConfigValueEditor) => {
const typeName = getPrimitiveTypeName(node.type) ?? 'string';
const suggestions = getSuggestionValues(node);
const [textValue, setTextValue] = useState(
typeof value === 'string' || typeof value === 'number' ? String(value) : '',
);
useEffect(() => {
if (typeName === 'boolean') return;
setTextValue(typeof value === 'string' || typeof value === 'number' ? String(value) : '');
}, [typeName, value]);
if (typeName === 'boolean') {
return (
<div className='⁂-admin-config__toggle-field'>
<Toggle checked={value === true} onChange={(event) => onChange(event.target.checked)} />
</div>
);
}
if (numericTypes.has(typeName)) {
return (
<>
<Input
type='number'
value={textValue}
onChange={(event) => {
setTextValue(event.target.value);
if (!event.target.value.length) {
onChange(getDefaultValueForType(typeName));
return;
}
const parsed = Number(event.target.value);
if (!Number.isNaN(parsed)) onChange(parsed);
}}
/>
<Suggestions
suggestions={suggestions}
onSelect={(suggestion) => {
const nextValue =
typeof suggestion === 'number' ? suggestion : Number.parseFloat(String(suggestion));
if (Number.isNaN(nextValue)) return;
setTextValue(String(nextValue));
onChange(nextValue);
}}
/>
</>
);
}
return (
<>
<Input
type='text'
value={textValue}
placeholder={typeName === 'atom' ? ':value' : undefined}
onChange={(event) => {
setTextValue(event.target.value);
onChange(event.target.value);
}}
/>
<Suggestions
suggestions={suggestions}
onSelect={(suggestion) => {
const nextValue = String(suggestion);
setTextValue(nextValue);
onChange(nextValue);
}}
/>
</>
);
});
PrimitiveValueEditor.displayName = 'PrimitiveValueEditor';
const JsonValueEditor = memo(({ value, onChange, onValidityChange }: IConfigValueEditor) => {
const [jsonText, setJsonText] = useState(stringifyValue(value));
const [jsonError, setJsonError] = useState(false);
useEffect(() => {
setJsonText(stringifyValue(value));
setJsonError(false);
onValidityChange?.(false);
}, [onValidityChange, value]);
return (
<div className='⁂-admin-config__editor-stack'>
<Textarea
isCodeEditor
value={jsonText}
hasError={jsonError}
onChange={(event) => {
const nextText = event.target.value;
setJsonText(nextText);
if (!nextText.trim().length) {
setJsonError(false);
onValidityChange?.(false);
onChange(null);
return;
}
try {
onChange(JSON.parse(nextText));
setJsonError(false);
onValidityChange?.(false);
} catch {
setJsonError(true);
onValidityChange?.(true);
}
}}
/>
{jsonError ? (
<p className='⁂-admin-config__feedback ⁂-admin-config__feedback--danger'>
<FormattedMessage id='admin.pleroma_config.json_invalid' defaultMessage='Invalid JSON' />
</p>
) : null}
</div>
);
});
JsonValueEditor.displayName = 'JsonValueEditor';
const PrimitiveListStreamfieldInput: StreamfieldComponent<string> = memo(({ value, onChange }) => {
const intl = useIntl();
return (
<Input
type='text'
outerClassName='⁂-admin-config__streamfield-row__input'
value={value}
placeholder={intl.formatMessage(messages.placeholderValue)}
onChange={(event) => onChange(event.target.value)}
/>
);
});
PrimitiveListStreamfieldInput.displayName = 'PrimitiveListStreamfieldInput';
const PrimitiveListEditor = memo(
({ node, itemType, value, onChange }: IConfigValueEditor & { itemType: ConfigType }) => {
const suggestions = getSuggestionValues(node);
const typeName = typeof itemType === 'string' ? itemType : 'string';
const [entries, setEntries] = useState<string[]>(() => normalizePrimitiveListEntries(value));
useEffect(() => {
if (isEqual(parsePrimitiveListEntries(entries, typeName), value)) return;
setEntries(normalizePrimitiveListEntries(value));
}, [typeName, value]);
const syncEntries = (nextEntries: string[]) => {
setEntries(nextEntries);
const nextValue = parsePrimitiveListEntries(nextEntries, typeName);
if (isEqual(nextValue, value)) return;
onChange(nextValue);
};
return (
<div className='⁂-admin-config__editor-stack'>
<Streamfield
values={entries}
onChange={syncEntries}
onAddItem={() => syncEntries([...entries, ''])}
onRemoveItem={(index) =>
syncEntries(entries.filter((_, currentIndex) => currentIndex !== index))
}
component={PrimitiveListStreamfieldInput}
/>
<Suggestions
suggestions={suggestions}
onSelect={(suggestion) => {
syncEntries([...entries, stringifyValue(suggestion)]);
}}
/>
</div>
);
},
);
PrimitiveListEditor.displayName = 'PrimitiveListEditor';
const DropdownValueEditor = memo(({ node, value, onChange }: IConfigValueEditor) => {
const suggestions = getSuggestionValues(node).map(String);
const currentValue = typeof value === 'string' ? value : String(value ?? suggestions[0] ?? '');
const options = suggestions.includes(currentValue) ? suggestions : [currentValue, ...suggestions];
return (
<Select value={currentValue} onChange={(event) => onChange(event.target.value)}>
{options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</Select>
);
});
DropdownValueEditor.displayName = 'DropdownValueEditor';
const TupleStreamfieldInput: StreamfieldComponent<TupleEntry> = memo(({ value, onChange }) => {
const intl = useIntl();
return (
<div className='⁂-admin-config__streamfield-row'>
<Input
type='text'
outerClassName='⁂-admin-config__streamfield-row__input'
value={value.left}
placeholder={intl.formatMessage(messages.placeholderValueLeft)}
onChange={(event) => onChange({ ...value, left: event.target.value })}
/>
<Input
type='text'
outerClassName='⁂-admin-config__streamfield-row__input'
value={value.right}
placeholder={intl.formatMessage(messages.placeholderValueRight)}
onChange={(event) => onChange({ ...value, right: event.target.value })}
/>
</div>
);
});
TupleStreamfieldInput.displayName = 'TupleStreamfieldInput';
const DynamicStreamfieldInput: StreamfieldComponent<DynamicEntry> = memo(({ value, onChange }) => {
const intl = useIntl();
return (
<div className='⁂-admin-config__streamfield-row ⁂-admin-config__streamfield-row--dynamic'>
<Input
type='text'
outerClassName='⁂-admin-config__streamfield-row__input'
value={value.key}
placeholder={intl.formatMessage(messages.placeholderValueKVKey)}
onChange={(event) => onChange({ ...value, key: event.target.value })}
/>
<Textarea
isCodeEditor
rows={1}
autoGrow
value={value.value}
placeholder={intl.formatMessage(messages.placeholderValueKVValue)}
onChange={(event) => onChange({ ...value, value: event.target.value })}
/>
</div>
);
});
DynamicStreamfieldInput.displayName = 'DynamicStreamfieldInput';
const TupleEditor = memo(
({ node, value, onChange, isList }: IConfigValueEditor & { isList: boolean }) => {
const [entries, setEntries] = useState<TupleEntry[]>(() =>
normalizeTupleEntries(value, isList),
);
const suggestions = getTupleSuggestions(node);
useEffect(() => {
setEntries(normalizeTupleEntries(value, isList));
}, [isList, value]);
const syncEntries = (nextEntries: TupleEntry[]) => {
setEntries(nextEntries);
const tuples = nextEntries.map((entry) => ({
tuple: [parseLooseValue(entry.left), parseLooseValue(entry.right)] as [unknown, unknown],
}));
onChange(isList ? tuples : (tuples[0] ?? { tuple: ['', ''] }));
};
return (
<div className='⁂-admin-config__editor-stack'>
<p className='⁂-admin-config__feedback'>
<FormattedMessage
id='admin.pleroma_config.tuple_hint'
defaultMessage='Values are parsed as JSON when possible, otherwise kept as strings.'
/>
</p>
<Streamfield
values={entries}
onChange={syncEntries}
onAddItem={() => syncEntries([...entries, createTupleEntry()])}
onRemoveItem={(index) => {
const nextEntries = entries.filter((_, currentIndex) => currentIndex !== index);
syncEntries(nextEntries.length ? nextEntries : [createTupleEntry()]);
}}
component={TupleStreamfieldInput}
/>
<Suggestions
suggestions={suggestions}
onSelect={(suggestion) => {
if (!Array.isArray(suggestion) || suggestion.length !== 2) return;
const nextEntry = createTupleEntry(
stringifyValue(suggestion[0]),
stringifyValue(suggestion[1]),
);
syncEntries(isList ? [...entries, nextEntry] : [nextEntry]);
}}
/>
</div>
);
},
);
TupleEditor.displayName = 'TupleEditor';
const DynamicEntriesEditor = memo(
({ node, value, onChange, isKeyword }: IConfigValueEditor & { isKeyword: boolean }) => {
const [entries, setEntries] = useState<DynamicEntry[]>(() =>
normalizeDynamicEntries(value, isKeyword),
);
const suggestions = getSuggestionValues(node);
useEffect(() => {
if (isEqual(getDynamicEntriesValue(entries, isKeyword), value)) return;
setEntries(normalizeDynamicEntries(value, isKeyword));
}, [isKeyword, value]);
const syncEntries = (nextEntries: DynamicEntry[]) => {
setEntries(nextEntries);
const nextValue = getDynamicEntriesValue(nextEntries, isKeyword);
if (isEqual(nextValue, value)) return;
onChange(nextValue);
};
return (
<div className='⁂-admin-config__editor-stack'>
<Streamfield
values={entries}
onChange={syncEntries}
onAddItem={() => syncEntries([...entries, createDynamicEntry()])}
onRemoveItem={(index) => {
const nextEntries = entries.filter((_, currentIndex) => currentIndex !== index);
syncEntries(nextEntries.length ? nextEntries : [createDynamicEntry()]);
}}
component={DynamicStreamfieldInput}
/>
<Suggestions
suggestions={suggestions}
onSelect={(suggestion) => {
if (!Array.isArray(suggestion) || suggestion.length !== 2) return;
syncEntries([
...entries,
createDynamicEntry(String(suggestion[0]), stringifyValue(suggestion[1])),
]);
}}
/>
</div>
);
},
);
DynamicEntriesEditor.displayName = 'DynamicEntriesEditor';
const GroupChildrenEditor = memo(
({ node, value, onChange, onValidityChange }: IConfigValueEditor) => {
const childValues = useMemo(() => tupleArrayToMap(value), [value]);
const [invalidChildren, setInvalidChildren] = useState<Record<string, boolean>>({});
const hasInvalidChildren = useMemo(
() => Object.values(invalidChildren).some(Boolean),
[invalidChildren],
);
useEffect(() => {
setInvalidChildren({});
}, [node, value]);
useEffect(() => {
onValidityChange?.(hasInvalidChildren);
}, [hasInvalidChildren, onValidityChange]);
if (!node.children?.length) {
return (
<JsonValueEditor
node={node}
value={value}
onChange={onChange}
onValidityChange={onValidityChange}
/>
);
}
return (
<div className='⁂-admin-config__editor-stack'>
{node.children.map((child) => {
if (!child.key) return null;
const childKey = child.key;
return (
<FormGroup
key={[child.group].flat().join('|') + '|' + childKey}
labelText={child.label ?? childKey}
hintText={child.description}
>
<ConfigValueEditor
node={child}
value={childValues[childKey]}
onChange={(nextValue) =>
onChange(
mapToTupleArray({
...childValues,
[childKey]: nextValue,
}),
)
}
onValidityChange={(isInvalid) =>
setInvalidChildren((current) =>
current[childKey] === isInvalid
? current
: { ...current, [childKey]: isInvalid },
)
}
/>
</FormGroup>
);
})}
</div>
);
},
);
GroupChildrenEditor.displayName = 'GroupChildrenEditor';
const UnionValueEditor = memo(
({
node,
value,
onChange,
onValidityChange,
options,
}: IConfigValueEditor & { options: ConfigType[] }) => {
const intl = useIntl();
const [selectedType, setSelectedType] = useState<ConfigType>(() => {
return options.find((option) => isValueCompatibleWithType(value, option)) ?? options[0];
});
useEffect(() => {
setSelectedType(
options.find((option) => isValueCompatibleWithType(value, option)) ?? options[0],
);
}, [options, value]);
return (
<div className='⁂-admin-config__editor-stack'>
<FormGroup
labelText={intl.formatMessage({
id: 'admin.pleroma_config.value_type',
defaultMessage: 'Value type',
})}
>
<Select
value={JSON.stringify(selectedType)}
onChange={(event) => {
const nextType =
options.find((option) => JSON.stringify(option) === event.target.value) ??
options[0];
setSelectedType(nextType);
if (!isValueCompatibleWithType(value, nextType)) {
onChange(getDefaultValueForType(nextType, getSuggestionValues(node)));
}
}}
>
{options.map((option) => (
<option key={JSON.stringify(option)} value={JSON.stringify(option)}>
{getTypeLabel(option)}
</option>
))}
</Select>
</FormGroup>
<ConfigValueEditor
node={{ ...node, type: selectedType }}
value={value}
onChange={onChange}
onValidityChange={onValidityChange}
/>
</div>
);
},
);
UnionValueEditor.displayName = 'UnionValueEditor';
const ConfigValueEditor = memo(
({ node, value, onChange, onValidityChange }: IConfigValueEditor) => {
const unionOptions = getUnionOptions(node.type);
if (unionOptions) {
return (
<UnionValueEditor
node={node}
value={value}
onChange={onChange}
onValidityChange={onValidityChange}
options={unionOptions}
/>
);
}
if (node.type === 'group') {
return (
<GroupChildrenEditor
node={node}
value={value}
onChange={onChange}
onValidityChange={onValidityChange}
/>
);
}
if (typeof node.type === 'string' || isStringOrImageType(node.type)) {
if (node.type === 'tuple') {
return (
<TupleEditor
node={node}
value={value}
onChange={onChange}
onValidityChange={onValidityChange}
isList={false}
/>
);
}
if (node.type === 'keyword') {
return (
<DynamicEntriesEditor
node={node}
value={value}
onChange={onChange}
onValidityChange={onValidityChange}
isKeyword
/>
);
}
if (node.type === 'map') {
return (
<DynamicEntriesEditor
node={node}
value={value}
onChange={onChange}
onValidityChange={onValidityChange}
isKeyword={false}
/>
);
}
return (
<PrimitiveValueEditor
node={node}
value={value}
onChange={onChange}
onValidityChange={onValidityChange}
/>
);
}
if (!isContainerDescriptor(node.type)) {
return (
<div className='⁂-admin-config__editor-stack'>
<p className='⁂-admin-config__feedback'>
<FormattedMessage
id='admin.pleroma_config.complex_hint'
defaultMessage='Edit this value as JSON.'
/>
</p>
<JsonValueEditor
node={node}
value={value}
onChange={onChange}
onValidityChange={onValidityChange}
/>
</div>
);
}
const [container, subType] = node.type;
if (container === 'dropdown') {
return (
<DropdownValueEditor
node={node}
value={value}
onChange={onChange}
onValidityChange={onValidityChange}
/>
);
}
if (container === 'list') {
if (subType === 'tuple') {
return (
<TupleEditor
node={node}
value={value}
onChange={onChange}
onValidityChange={onValidityChange}
isList
/>
);
}
if (
typeof subType === 'string' &&
(textualTypes.has(subType) || numericTypes.has(subType) || subType === 'boolean')
) {
return (
<PrimitiveListEditor
node={node}
itemType={subType}
value={value}
onChange={onChange}
onValidityChange={onValidityChange}
/>
);
}
return (
<JsonValueEditor
node={node}
value={value}
onChange={onChange}
onValidityChange={onValidityChange}
/>
);
}
if (container === 'keyword') {
return (
<DynamicEntriesEditor
node={node}
value={value}
onChange={onChange}
onValidityChange={onValidityChange}
isKeyword
/>
);
}
return (
<DynamicEntriesEditor
node={node}
value={value}
onChange={onChange}
onValidityChange={onValidityChange}
isKeyword={false}
/>
);
},
);
ConfigValueEditor.displayName = 'ConfigValueEditor';
export { ConfigValueEditor };
export type { IConfigValueEditor };

View File

@@ -0,0 +1,431 @@
import type { PleromaConfigDescription, PleromaConfigDescriptionChild } from 'pl-api';
type ConfigDescriptionNode = PleromaConfigDescription | PleromaConfigDescriptionChild;
type ConfigType = ConfigDescriptionNode['type'];
type PrimitiveValue = boolean | number | string;
type TupleValue = { tuple: [unknown, unknown] };
type TupleEntry = { id: string; left: string; right: string };
type DynamicEntry = { id: string; key: string; value: string };
type ConfigEntry = { group: string; key: string; value: unknown };
const containerTypes = new Set(['list', 'dropdown', 'keyword', 'map']);
const textualTypes = new Set(['string', 'atom', 'module', 'charlist', 'image', 'image/png']);
const numericTypes = new Set(['integer', 'float']);
const annotationTypes = new Set(['image', 'image/png']);
const stringifyValue = (value: unknown): string => {
if (typeof value === 'undefined') return '';
if (typeof value === 'string') return value;
const json = JSON.stringify(value, null, 2);
return json ?? '';
};
const isTupleObject = (value: unknown): value is TupleValue =>
!!value &&
typeof value === 'object' &&
'tuple' in value &&
Array.isArray((value as { tuple?: unknown }).tuple) &&
(value as { tuple: unknown[] }).tuple.length === 2;
const tupleArrayToMap = (value: unknown): Record<string, unknown> => {
if (!Array.isArray(value)) return {};
return value.reduce<Record<string, unknown>>((result, item) => {
if (isTupleObject(item) && typeof item.tuple[0] === 'string') {
result[item.tuple[0]] = item.tuple[1];
}
return result;
}, {});
};
const mapToTupleArray = (value: Record<string, unknown>): TupleValue[] =>
Object.entries(value).map(([key, itemValue]) => ({
tuple: [key, itemValue],
}));
const parseLooseValue = (text: string): unknown => {
const trimmed = text.trim();
if (!trimmed.length) return '';
if (trimmed === 'true') return true;
if (trimmed === 'false') return false;
if (trimmed === 'null') return null;
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
try {
return JSON.parse(trimmed);
} catch {
return text;
}
};
const isContainerDescriptor = (type: ConfigType): type is Array<string | Array<string>> => {
if (!Array.isArray(type)) return false;
if (type.length !== 2) return false;
return typeof type[0] === 'string' && containerTypes.has(type[0]);
};
const getUnionOptions = (type: ConfigType): ConfigType[] | null => {
if (!Array.isArray(type) || !type.length) return null;
if (isContainerDescriptor(type)) return null;
const options = [...type].filter(
(item) => typeof item === 'string' || Array.isArray(item),
) as ConfigType[];
if (options.length <= 1) return null;
if (
options.length === 2 &&
options.some((item) => item === 'string') &&
options.some((item) => typeof item === 'string' && annotationTypes.has(item))
) {
return null;
}
return options;
};
const getPrimitiveTypeName = (type: ConfigType): string | null => {
if (typeof type === 'string') return type;
if (
Array.isArray(type) &&
type.length === 2 &&
type[0] === 'dropdown' &&
typeof type[1] === 'string'
) {
return type[1];
}
return null;
};
const isValueCompatibleWithType = (value: unknown, type: ConfigType): boolean => {
const unionOptions = getUnionOptions(type);
if (unionOptions) {
return unionOptions.some((option) => isValueCompatibleWithType(value, option));
}
if (typeof type === 'string') {
if (type === 'boolean') return typeof value === 'boolean';
if (numericTypes.has(type)) return typeof value === 'number';
if (textualTypes.has(type)) return typeof value === 'string';
if (type === 'tuple') return isTupleObject(value);
return true;
}
if (!isContainerDescriptor(type)) return true;
const [container, subType] = type;
if (container === 'dropdown') return isValueCompatibleWithType(value, subType);
if (container === 'list') return Array.isArray(value);
if (container === 'keyword')
return Array.isArray(value) && value.every((item) => isTupleObject(item));
if (container === 'map')
return typeof value === 'object' && value !== null && !Array.isArray(value);
return true;
};
const isStringOrImageType: (type: Exclude<ConfigType, string>) => boolean = (type) => {
return type.length === 2 && type[0] === 'string' && type[1] === 'image';
};
const getSuggestionValues = (node: ConfigDescriptionNode): unknown[] => {
const suggestions = (node as Record<string, unknown>).suggestions;
return Array.isArray(suggestions) ? suggestions : [];
};
const getDefaultValueForType = (type: ConfigType, suggestions: unknown[] = []): unknown => {
const unionOptions = getUnionOptions(type);
if (unionOptions) {
return getDefaultValueForType(unionOptions[0], suggestions);
}
if (typeof type === 'string') {
if (type === 'boolean') return false;
if (numericTypes.has(type)) return 0;
if (textualTypes.has(type)) return typeof suggestions[0] === 'string' ? suggestions[0] : '';
if (type === 'tuple') return { tuple: ['', ''] };
return typeof suggestions[0] !== 'undefined' ? suggestions[0] : null;
}
if (!isContainerDescriptor(type)) return null;
const [container, subType] = type;
if (container === 'dropdown') {
return typeof suggestions[0] !== 'undefined'
? suggestions[0]
: getDefaultValueForType(subType, suggestions);
}
if (container === 'list' || container === 'keyword') return [];
if (container === 'map') return {};
return null;
};
const getTypeLabel = (type: ConfigType): string => {
const unionOptions = getUnionOptions(type);
if (unionOptions) return unionOptions.map(getTypeLabel).join(' / ');
if (typeof type === 'string') {
switch (type) {
case 'image':
case 'image/png':
return 'image path';
default:
return type;
}
}
if (!isContainerDescriptor(type)) return JSON.stringify(type);
const [container, subType] = type;
if (container === 'dropdown') return `dropdown (${getTypeLabel(subType)})`;
if (container === 'list') return `list of ${getTypeLabel(subType)}`;
if (container === 'keyword') return `keyword of ${getTypeLabel(subType)}`;
if (container === 'map') return `map of ${getTypeLabel(subType)}`;
return JSON.stringify(type);
};
const getValueSignature = (value: unknown): string => stringifyValue(value);
const getTupleSuggestions = (node: ConfigDescriptionNode): Array<[unknown, unknown]> =>
getSuggestionValues(node)
.map((value) => {
if (Array.isArray(value) && value.length === 2)
return [value[0], value[1]] as [unknown, unknown];
if (isTupleObject(value)) return value.tuple;
return null;
})
.filter((value): value is [unknown, unknown] => value !== null);
const createTupleEntry = (left = '', right = ''): TupleEntry => ({
id: crypto.randomUUID(),
left,
right,
});
const createDynamicEntry = (key = '', value = ''): DynamicEntry => ({
id: crypto.randomUUID(),
key,
value,
});
const normalizeTupleEntries = (value: unknown, isList: boolean): TupleEntry[] => {
console.log('Normalizing tuple entries', { value, isList });
if (isList) {
if (!Array.isArray(value)) return [createTupleEntry()];
const entries = value
.map((item) => {
if (!isTupleObject(item)) return null;
return createTupleEntry(stringifyValue(item.tuple[0]), stringifyValue(item.tuple[1]));
})
.filter((item): item is TupleEntry => item !== null);
return entries.length ? entries : [createTupleEntry()];
}
if (isTupleObject(value)) {
return [createTupleEntry(stringifyValue(value.tuple[0]), stringifyValue(value.tuple[1]))];
}
return [createTupleEntry()];
};
const normalizeDynamicEntries = (value: unknown, isKeyword: boolean): DynamicEntry[] => {
console.log('Normalizing dynamic entries', { value, isKeyword });
if (isKeyword) {
if (!Array.isArray(value)) return [createDynamicEntry()];
const entries = value
.map((item) => {
if (!isTupleObject(item)) return null;
return createDynamicEntry(stringifyValue(item.tuple[0]), stringifyValue(item.tuple[1]));
})
.filter((item): item is DynamicEntry => item !== null);
return entries.length ? entries : [createDynamicEntry()];
}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return [createDynamicEntry()];
}
const entries = Object.entries(value).map(([key, itemValue]) =>
createDynamicEntry(key, stringifyValue(itemValue)),
);
return entries.length ? entries : [createDynamicEntry()];
};
const normalizePrimitiveListEntries = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
return value.map((item) => stringifyValue(item));
};
const parsePrimitiveListEntries = (entries: string[], typeName: string): PrimitiveValue[] => {
return entries
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry): PrimitiveValue => {
if (numericTypes.has(typeName)) return Number(entry);
if (typeName === 'boolean') return entry === 'true';
return entry;
});
};
const getDynamicEntriesValue = (entries: DynamicEntry[], isKeyword: boolean): unknown => {
const activeEntries = entries.filter((entry) => entry.key.trim().length);
if (isKeyword) {
return activeEntries.map((entry) => ({
tuple: [entry.key, parseLooseValue(entry.value)] as [unknown, unknown],
}));
}
return activeEntries.reduce<Record<string, unknown>>((result, entry) => {
result[entry.key] = parseLooseValue(entry.value);
return result;
}, {});
};
const getConfigEntryId = (group: string, key: string): string => `${group}\u0000${key}`;
const getConfigValueMap = (configs: ConfigEntry[]): Map<string, unknown> => {
return new Map(configs.map((entry) => [getConfigEntryId(entry.group, entry.key), entry.value]));
};
const getNodeGroup = (group: ConfigDescriptionNode['group']): string | null => {
if (typeof group === 'string') return group;
if (Array.isArray(group)) return group[0] ?? null;
return null;
};
const getFlatGroupValue = (
description: PleromaConfigDescription,
configValueMap: Map<string, unknown>,
): TupleValue[] => {
const childValues = description.children.reduce<Record<string, unknown>>((result, child) => {
if (!child.key) return result;
const childGroup = getNodeGroup(child.group) ?? description.group;
if (!childGroup) return result;
const childValue = configValueMap.get(getConfigEntryId(childGroup, child.key));
if (typeof childValue !== 'undefined') {
result[child.key] = childValue;
}
return result;
}, {});
return mapToTupleArray(childValues);
};
const getDescriptionValue = (
description: PleromaConfigDescription,
configValueMap: Map<string, unknown>,
): unknown => {
if (!description.group) return undefined;
if (description.key) {
return configValueMap.get(getConfigEntryId(description.group, description.key));
}
if (description.type === 'group') {
return getFlatGroupValue(description, configValueMap);
}
return undefined;
};
const getDescriptionValueSignature = (
description: PleromaConfigDescription,
configValueMap: Map<string, unknown>,
): string => getValueSignature(getDescriptionValue(description, configValueMap));
const getDescriptionUpdates = (
description: PleromaConfigDescription,
value: unknown,
): ConfigEntry[] => {
if (!description.group) return [];
if (description.key) {
return [{ group: description.group, key: description.key, value }];
}
if (description.type !== 'group') return [];
const childValues = tupleArrayToMap(value);
return description.children.flatMap((child) => {
if (!child.key || !Object.hasOwn(childValues, child.key)) return [];
const childGroup = getNodeGroup(child.group) ?? description.group;
if (!childGroup) return [];
return [{ group: childGroup, key: child.key, value: childValues[child.key] }];
});
};
const getDescriptionSearchText = (node: ConfigDescriptionNode): string => {
return [
getNodeGroup(node.group),
node.key,
node.label,
node.description,
...(node.children?.map(getDescriptionSearchText) ?? []),
]
.filter(Boolean)
.join(' ')
.toLowerCase();
};
export {
createDynamicEntry,
createTupleEntry,
getConfigValueMap,
getDefaultValueForType,
getDescriptionSearchText,
getDescriptionUpdates,
getDescriptionValue,
getDescriptionValueSignature,
getDynamicEntriesValue,
getPrimitiveTypeName,
getSuggestionValues,
getTupleSuggestions,
getTypeLabel,
getUnionOptions,
isContainerDescriptor,
isStringOrImageType,
isTupleObject,
isValueCompatibleWithType,
mapToTupleArray,
normalizeDynamicEntries,
normalizePrimitiveListEntries,
normalizeTupleEntries,
numericTypes,
parsePrimitiveListEntries,
parseLooseValue,
stringifyValue,
textualTypes,
tupleArrayToMap,
};
export type {
ConfigDescriptionNode,
ConfigEntry,
ConfigType,
DynamicEntry,
PrimitiveValue,
TupleEntry,
TupleValue,
};

View File

@@ -307,6 +307,18 @@ const Dashboard: React.FC = () => {
/>
)}
{features.pleromaAdminConfig && (
<ListItem
to='/nicolium/admin/pleroma-config'
label={
<FormattedMessage
id='column.admin.pleroma_config'
defaultMessage='Pleroma configuration'
/>
}
/>
)}
{features.domains && (
<ListItem
to='/nicolium/admin/domains'

View File

@@ -0,0 +1,209 @@
import React, { useCallback, useDeferredValue, useMemo, useState } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { EmptyMessage } from '@/components/empty-message';
import Column from '@/components/ui/column';
import FormGroup from '@/components/ui/form-group';
import Input from '@/components/ui/input';
import Spinner from '@/components/ui/spinner';
import Warning from '@/features/compose/components/warning';
import { ConfigSection } from '@/pages/dashboard/components/pleroma-config/config-section';
import {
getConfigValueMap,
getDescriptionSearchText,
getDescriptionUpdates,
} from '@/pages/dashboard/components/pleroma-config/utils';
import {
useAdminConfig,
useAdminConfigDescriptions,
useUpdateAdminConfig,
} from '@/queries/admin/use-config';
import toast from '@/toast';
import type { PleromaConfigDescription } from 'pl-api';
type IndexedDescription = {
description: PleromaConfigDescription;
searchText: string;
};
const PleromaConfigPage: React.FC = () => {
const intl = useIntl();
const { data: descriptions, isLoading: descriptionsLoading } = useAdminConfigDescriptions();
const { data: currentConfig, isLoading: currentConfigLoading } = useAdminConfig();
const { mutate: updateConfig, isPending } = useUpdateAdminConfig();
const [searchInput, setSearchInput] = useState('');
const deferredSearchInput = useDeferredValue(searchInput);
const normalizedSearchInput = deferredSearchInput.trim().toLowerCase();
const indexedDescriptions = useMemo<IndexedDescription[]>(() => {
if (!descriptions) return [];
return descriptions.map((description) => ({
description,
searchText: getDescriptionSearchText(description),
}));
}, [descriptions]);
const filteredDescriptions = useMemo(() => {
if (!indexedDescriptions.length) return [];
if (!normalizedSearchInput.length) {
return indexedDescriptions.map((entry) => entry.description);
}
return indexedDescriptions
.filter((entry) => entry.searchText.includes(normalizedSearchInput))
.map((entry) => entry.description);
}, [indexedDescriptions, normalizedSearchInput]);
const groupedEntries = useMemo(() => {
const grouped = filteredDescriptions.reduce<Record<string, PleromaConfigDescription[]>>(
(result, description) => {
if (!description.group) return result;
if (!result[description.group]) {
result[description.group] = [];
}
result[description.group].push(description);
return result;
},
{},
);
return Object.entries(grouped)
.map(
([group, groupDescriptions]) =>
[
group,
[...groupDescriptions].sort((left, right) =>
(left.label ?? left.key ?? '').localeCompare(right.label ?? right.key ?? ''),
),
] as const,
)
.sort(([left], [right]) => left.localeCompare(right));
}, [filteredDescriptions]);
const configValueMap = useMemo(
() => getConfigValueMap(currentConfig?.configs ?? []),
[currentConfig?.configs],
);
const handleSave = useCallback(
(description: PleromaConfigDescription, value: unknown) => {
const updates = getDescriptionUpdates(description, value);
if (!updates.length) return;
updateConfig(updates, {
onSuccess: () => {
toast.success(
intl.formatMessage({
id: 'admin.pleroma_config.saved',
defaultMessage: 'Configuration saved',
}),
);
},
onError: () => {
toast.error(
intl.formatMessage({
id: 'admin.pleroma_config.save_failed',
defaultMessage: 'Failed to save configuration',
}),
);
},
});
},
[intl, updateConfig],
);
const handleSearchChange = useCallback<React.ChangeEventHandler<HTMLInputElement>>((event) => {
setSearchInput(event.target.value);
}, []);
const isEmpty = !descriptionsLoading && !descriptions?.length;
const isFilteredEmpty =
!descriptionsLoading && !!descriptions?.length && !filteredDescriptions.length;
return (
<Column
label={intl.formatMessage({
id: 'column.admin.pleroma_config',
defaultMessage: 'Pleroma configuration',
})}
>
{descriptionsLoading || currentConfigLoading ? (
<Spinner />
) : (
<div className='⁂-admin-config'>
{currentConfig?.need_reboot ? (
<Warning
message={
<FormattedMessage
id='admin.pleroma_config.need_reboot'
defaultMessage='Some configuration changes require a server restart to take effect.'
/>
}
/>
) : null}
<div className='⁂-admin-config__filters'>
<FormGroup
labelText={intl.formatMessage({
id: 'admin.pleroma_config.search',
defaultMessage: 'Search settings',
})}
>
<Input
type='text'
value={searchInput}
placeholder={intl.formatMessage({
id: 'admin.pleroma_config.search_placeholder',
defaultMessage: 'Search by label, key, group or description',
})}
onChange={handleSearchChange}
/>
</FormGroup>
</div>
<div className='⁂-admin-config__sections'>
{groupedEntries.map(([group, groupDescriptions]) => (
<ConfigSection
key={group}
group={group}
descriptions={groupDescriptions}
configValueMap={configValueMap}
onSave={handleSave}
isPending={isPending}
/>
))}
</div>
{isEmpty ? (
<EmptyMessage
text={
<FormattedMessage
id='admin.pleroma_config.empty'
defaultMessage='No configuration options available.'
/>
}
/>
) : null}
{isFilteredEmpty ? (
<EmptyMessage
text={
<FormattedMessage
id='admin.pleroma_config.empty_search'
defaultMessage='No settings match the current filters.'
/>
}
/>
) : null}
</div>
)}
</Column>
);
};
export { PleromaConfigPage as default };

View File

@@ -1041,6 +1041,15 @@ export const adminRulesRoute = createRoute({
}),
});
export const adminPleromaConfigRoute = createRoute({
getParentRoute: () => layouts.admin,
path: '/nicolium/admin/pleroma-config',
component: lazy(() => import('@/pages/dashboard/pleroma-config')),
beforeLoad: requireAuthMiddleware(({ context: { features, isAdmin } }) => {
if (!isAdmin || !features.pleromaAdminConfig) throw notFound();
}),
});
// Info and other routes
export const serverInfoRoute = createRoute({
getParentRoute: () => layouts.empty,
@@ -1333,6 +1342,7 @@ const routeTree = rootRoute.addChildren([
adminAnnouncementsRoute,
adminDomainsRoute,
adminRulesRoute,
adminPleromaConfigRoute,
]),
layouts.chats.addChildren([
chatsRoute.addChildren([

View File

@@ -59,3 +59,202 @@
@include mixins.text($align: center);
}
}
.-admin-config {
display: flex;
flex-direction: column;
gap: 1.25rem;
&__filters {
display: grid;
gap: 1rem;
}
&__sections {
display: flex;
flex-direction: column;
gap: 1rem;
}
&__section {
display: flex;
flex-direction: column;
gap: 1rem;
.-form__actions {
position: sticky;
bottom: 0;
margin: -0.5rem -1rem;
padding: 0.5rem 1rem;
background-color: #fffe;
backdrop-filter: blur(12px) saturate(2);
.dark & {
background-color: rgb(var(--color-primary-900) / 0.9);
backdrop-filter: blur(12px);
}
@media (max-width: variables.$breakpoint-lg) {
bottom: 53px;
}
}
}
&__section-header {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
}
&__section-title,
&__headline-title {
@include mixins.text($weight: semibold);
margin: 0;
}
&__section-title {
@include mixins.text($size: lg, $weight: semibold);
}
.dark & .-accordion {
background-color: rgb(var(--color-primary-900));
}
&__feedback {
flex-grow: 1;
}
p.-admin-config__meta,
p.-admin-config__feedback {
@include mixins.text($size: sm, $theme: muted);
margin: 0;
}
p.-admin-config__feedback--danger {
@include mixins.text($size: sm, $theme: danger);
}
&__accordion-list,
&__editor-stack,
&__streamfield-row {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
&__accordion-headline {
display: flex;
flex-direction: column;
gap: 0.375rem;
align-items: flex-start;
width: 100%;
text-align: left;
}
&__fieldset {
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 0;
}
&__editor-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
&__toggle-field {
display: flex;
justify-content: flex-end;
width: 100%;
}
&__streamfield-row {
display: grid;
flex-grow: 1;
gap: 0.75rem;
align-items: start;
&__input {
flex-grow: 1;
width: 100%;
margin-top: 0;
}
@media (min-width: variables.$breakpoint-md) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
&__streamfield-row--dynamic {
@media (min-width: variables.$breakpoint-md) {
grid-template-columns: minmax(10rem, 0.75fr) minmax(0, 1.25fr);
}
}
&__suggestions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
.-form-group__content > & {
margin-top: 0.75rem;
}
}
p.-admin-config__suggestions-label {
@include mixins.text($size: xs, $theme: muted);
margin: 0;
}
&__suggestion-button {
@include mixins.button($theme: secondary, $size: sm);
border-radius: 0.75rem;
text-align: left;
}
&__submit-button {
@include mixins.button($theme: primary);
}
&__reset-button {
@include mixins.button($theme: secondary);
}
.-form-group:has(> .-form-group__content > .-admin-config__toggle-field) {
display: grid;
grid-template-areas:
'label toggle'
'hint toggle';
.-form-group__label {
grid-area: label;
}
.-form-group__hint {
grid-area: hint;
}
.-admin-config__toggle-field {
display: flex;
grid-area: toggle;
align-items: center;
}
.-form-group__content {
display: contents;
}
}
}

View File

@@ -430,7 +430,7 @@ a.⁂-list-item,
border-color: rgb(var(--color-gray-800));
color: rgb(var(--color-gray-100));
background-color: rgb(var(--color-gray-900));
outline: 2px solid rgb(var(--color-gray-800));
outline: 1px solid rgb(var(--color-gray-800));
}
.dark &:focus {

View File

@@ -66,7 +66,7 @@
border-color: rgb(var(--color-gray-800));
color: rgb(var(--color-gray-100));
background-color: rgb(var(--color-gray-900));
outline: 2px solid rgb(var(--color-gray-800));
outline: 1px solid rgb(var(--color-gray-800));
}
.dark &:focus {

View File

@@ -637,7 +637,7 @@
position: sticky;
bottom: 0.5rem;
@media (max-width: variables.$breakpoint-md) {
@media (max-width: variables.$breakpoint-lg) {
bottom: 4rem;
}
}