feat: resolve all 22 items in Features and Bugs.txt and complete UI polish
This commit is contained in:
+387
-16
@@ -1,25 +1,241 @@
|
||||
import { create } from "zustand";
|
||||
import { applyFontSettings, applyTheme, type ThemeId } from "@pubscript/ui";
|
||||
import { applyDensity, applyFontSettings, applyTheme, type ThemeId } from "@pubscript/ui";
|
||||
import { discoverLocalProviders, type ProviderKind, type ThinkingEffort } from "@pubscript/ai";
|
||||
import { createUlid } from "@pubscript/core";
|
||||
|
||||
export type Density = "compact" | "comfortable";
|
||||
|
||||
/** Body typeface for the writing column. All are system stacks (no web fonts). */
|
||||
export type FontFamily = "serif" | "sans" | "mono";
|
||||
/** Body typeface identifier for the writing column. */
|
||||
export type FontFamily =
|
||||
| "serif"
|
||||
| "eb-garamond"
|
||||
| "cormorant"
|
||||
| "crimson"
|
||||
| "libre-baskerville"
|
||||
| "baskerville"
|
||||
| "palatino"
|
||||
| "book-antiqua"
|
||||
| "source-serif"
|
||||
| "merriweather"
|
||||
| "lora"
|
||||
| "literata"
|
||||
| "spectral"
|
||||
| "pt-serif"
|
||||
| "ibm-plex-serif"
|
||||
| "times"
|
||||
| "cambria"
|
||||
| "playfair"
|
||||
| "cinzel"
|
||||
| "sans"
|
||||
| "inter"
|
||||
| "ibm-plex-sans"
|
||||
| "open-sans"
|
||||
| "nunito"
|
||||
| "space-grotesk"
|
||||
| "gill-sans"
|
||||
| "optima"
|
||||
| "trebuchet"
|
||||
| "verdana"
|
||||
| "mono"
|
||||
| "jetbrains-mono"
|
||||
| "fira-code"
|
||||
| "ibm-plex-mono"
|
||||
| "courier-new";
|
||||
|
||||
export const FONT_FAMILIES: { value: FontFamily; label: string; css: string }[] = [
|
||||
{ value: "serif", label: "Georgia Serif", css: 'Georgia, "Times New Roman", serif' },
|
||||
{ value: "sans", label: "System Sans", css: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif' },
|
||||
{ value: "mono", label: "Mono", css: '"SFMono-Regular", ui-monospace, "Cascadia Code", Consolas, monospace' },
|
||||
export interface FontOption {
|
||||
value: FontFamily;
|
||||
label: string;
|
||||
category: "Serif" | "Sans-Serif" | "Monospace" | "Display";
|
||||
css: string;
|
||||
}
|
||||
|
||||
export const FONT_FAMILIES: FontOption[] = [
|
||||
// Classic & Literary Serifs
|
||||
{ value: "serif", label: "Georgia", category: "Serif", css: 'Georgia, "Times New Roman", serif' },
|
||||
{ value: "eb-garamond", label: "EB Garamond", category: "Serif", css: '"EB Garamond", Garamond, Georgia, serif' },
|
||||
{ value: "cormorant", label: "Cormorant Garamond", category: "Serif", css: '"Cormorant Garamond", Garamond, Georgia, serif' },
|
||||
{ value: "crimson", label: "Crimson Text", category: "Serif", css: '"Crimson Text", Georgia, serif' },
|
||||
{ value: "libre-baskerville", label: "Libre Baskerville", category: "Serif", css: '"Libre Baskerville", Baskerville, Georgia, serif' },
|
||||
{ value: "baskerville", label: "Baskerville", category: "Serif", css: 'Baskerville, "Baskerville Old Face", "Hoefler Text", Garamond, serif' },
|
||||
{ value: "palatino", label: "Palatino Linotype", category: "Serif", css: '"Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif' },
|
||||
{ value: "book-antiqua", label: "Book Antiqua", category: "Serif", css: '"Book Antiqua", Palatino, Georgia, serif' },
|
||||
{ value: "source-serif", label: "Source Serif 4", category: "Serif", css: '"Source Serif 4", Charter, Georgia, serif' },
|
||||
{ value: "literata", label: "Literata", category: "Serif", css: '"Literata", Georgia, serif' },
|
||||
{ value: "lora", label: "Lora", category: "Serif", css: '"Lora", Georgia, serif' },
|
||||
{ value: "merriweather", label: "Merriweather", category: "Serif", css: '"Merriweather", Georgia, serif' },
|
||||
{ value: "spectral", label: "Spectral", category: "Serif", css: '"Spectral", Georgia, serif' },
|
||||
{ value: "pt-serif", label: "PT Serif", category: "Serif", css: '"PT Serif", Georgia, serif' },
|
||||
{ value: "ibm-plex-serif", label: "IBM Plex Serif", category: "Serif", css: '"IBM Plex Serif", Georgia, serif' },
|
||||
{ value: "cambria", label: "Cambria", category: "Serif", css: 'Cambria, Georgia, serif' },
|
||||
{ value: "times", label: "Times New Roman", category: "Serif", css: '"Times New Roman", Times, serif' },
|
||||
|
||||
// Display Serifs
|
||||
{ value: "playfair", label: "Playfair Display", category: "Display", css: '"Playfair Display", Georgia, serif' },
|
||||
{ value: "cinzel", label: "Cinzel", category: "Display", css: '"Cinzel", Georgia, serif' },
|
||||
|
||||
// Sans-Serif
|
||||
{ value: "sans", label: "System Sans", category: "Sans-Serif", css: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif' },
|
||||
{ value: "inter", label: "Inter", category: "Sans-Serif", css: '"Inter", -apple-system, BlinkMacSystemFont, sans-serif' },
|
||||
{ value: "ibm-plex-sans", label: "IBM Plex Sans", category: "Sans-Serif", css: '"IBM Plex Sans", -apple-system, sans-serif' },
|
||||
{ value: "open-sans", label: "Open Sans", category: "Sans-Serif", css: '"Open Sans", -apple-system, sans-serif' },
|
||||
{ value: "nunito", label: "Nunito", category: "Sans-Serif", css: '"Nunito", -apple-system, sans-serif' },
|
||||
{ value: "space-grotesk", label: "Space Grotesk", category: "Sans-Serif", css: '"Space Grotesk", -apple-system, sans-serif' },
|
||||
{ value: "gill-sans", label: "Gill Sans", category: "Sans-Serif", css: '"Gill Sans", "Gill Sans MT", Calibri, sans-serif' },
|
||||
{ value: "optima", label: "Optima", category: "Sans-Serif", css: 'Optima, Candara, "Noto Sans", sans-serif' },
|
||||
{ value: "trebuchet", label: "Trebuchet MS", category: "Sans-Serif", css: '"Trebuchet MS", Helvetica, sans-serif' },
|
||||
{ value: "verdana", label: "Verdana", category: "Sans-Serif", css: 'Verdana, Geneva, sans-serif' },
|
||||
|
||||
// Monospace & Manuscript
|
||||
{ value: "courier-new", label: "Courier New (Manuscript)", category: "Monospace", css: '"Courier New", Courier, monospace' },
|
||||
{ value: "jetbrains-mono", label: "JetBrains Mono", category: "Monospace", css: '"JetBrains Mono", monospace' },
|
||||
{ value: "fira-code", label: "Fira Code", category: "Monospace", css: '"Fira Code", monospace' },
|
||||
{ value: "ibm-plex-mono", label: "IBM Plex Mono", category: "Monospace", css: '"IBM Plex Mono", monospace' },
|
||||
{ value: "mono", label: "System Monospace", category: "Monospace", css: '"SFMono-Regular", ui-monospace, "Cascadia Code", Consolas, monospace' },
|
||||
];
|
||||
|
||||
/** Resolve a stored font-family enum to its CSS font-family string. */
|
||||
export function fontFamilyCss(value: FontFamily): string {
|
||||
const found = FONT_FAMILIES.find((f) => f.value === value);
|
||||
return found?.css ?? FONT_FAMILIES.at(0)?.css ?? "";
|
||||
return found?.css ?? FONT_FAMILIES[0]!.css;
|
||||
}
|
||||
|
||||
export type GrammarProvider = "local" | "languagetool";
|
||||
|
||||
export interface AIProviderSetting {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: ProviderKind;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
defaultModel: string;
|
||||
models: string[];
|
||||
}
|
||||
|
||||
export interface AIAgentConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
role: "editor" | "reader" | "publisher" | "co-author" | "custom";
|
||||
temperature: number;
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
thinkingEnabled: boolean;
|
||||
thinkingEffort: ThinkingEffort;
|
||||
systemPrompt: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_AI_PROVIDERS: AIProviderSetting[] = [
|
||||
{
|
||||
id: "ollama-local",
|
||||
name: "Ollama (Local)",
|
||||
kind: "openai-compatible",
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
apiKey: "",
|
||||
defaultModel: "llama3.2",
|
||||
models: ["llama3.2", "qwen2.5:7b", "mistral", "deepseek-r1:8b", "gemma2:9b"],
|
||||
},
|
||||
{
|
||||
id: "llamacpp-local",
|
||||
name: "llama.cpp / LM Studio (Local)",
|
||||
kind: "openai-compatible",
|
||||
baseUrl: "http://localhost:1234/v1",
|
||||
apiKey: "",
|
||||
defaultModel: "local-model",
|
||||
models: ["local-model", "qwen2.5-coder", "llama-3.1-8b-instruct"],
|
||||
},
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
kind: "openai-compatible",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: "",
|
||||
defaultModel: "gpt-4o",
|
||||
models: ["gpt-4o", "gpt-4o-mini", "o3-mini"],
|
||||
},
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic Claude",
|
||||
kind: "anthropic",
|
||||
baseUrl: "https://api.anthropic.com/v1",
|
||||
apiKey: "",
|
||||
defaultModel: "claude-3-7-sonnet-latest",
|
||||
models: ["claude-3-7-sonnet-latest", "claude-3-5-haiku-latest"],
|
||||
},
|
||||
{
|
||||
id: "google",
|
||||
name: "Google Gemini",
|
||||
kind: "google",
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
||||
apiKey: "",
|
||||
defaultModel: "gemini-2.5-pro",
|
||||
models: ["gemini-2.5-pro", "gemini-2.5-flash"],
|
||||
},
|
||||
{
|
||||
id: "openrouter",
|
||||
name: "OpenRouter",
|
||||
kind: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
apiKey: "",
|
||||
defaultModel: "anthropic/claude-3.7-sonnet",
|
||||
models: [
|
||||
"anthropic/claude-3.7-sonnet",
|
||||
"openai/gpt-4o",
|
||||
"google/gemini-2.5-pro",
|
||||
"deepseek/deepseek-r1",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_AI_AGENTS: AIAgentConfig[] = [
|
||||
{
|
||||
id: "editor",
|
||||
name: "Editor",
|
||||
role: "editor",
|
||||
temperature: 0.3,
|
||||
contextWindow: 32768,
|
||||
maxTokens: 2048,
|
||||
thinkingEnabled: false,
|
||||
thinkingEffort: "medium",
|
||||
systemPrompt:
|
||||
"You are the PubScript Line Editor. Tighten prose, eliminate filter words, sharpen dialogue tags, improve pacing, flag weak openings or closings, and verify consistency against character and world sheets. Always preserve the author's distinct voice and explain your rationale.",
|
||||
},
|
||||
{
|
||||
id: "reader",
|
||||
name: "Reader",
|
||||
role: "reader",
|
||||
temperature: 0.6,
|
||||
contextWindow: 32768,
|
||||
maxTokens: 2048,
|
||||
thinkingEnabled: false,
|
||||
thinkingEffort: "medium",
|
||||
systemPrompt:
|
||||
"You are an honest, attentive first-time reader. Report what felt vivid, where your attention drifted, what was confusing, what was telegraphed too early, and what emotional beats paid off. Speak as an engaged reader rather than using dry technical jargon.",
|
||||
},
|
||||
{
|
||||
id: "publisher",
|
||||
name: "Publisher",
|
||||
role: "publisher",
|
||||
temperature: 0.5,
|
||||
contextWindow: 32768,
|
||||
maxTokens: 2048,
|
||||
thinkingEnabled: false,
|
||||
thinkingEffort: "medium",
|
||||
systemPrompt:
|
||||
"You are a literary Publisher and developmental strategist. Evaluate commercial hook, genre pacing, comparable titles (comps), blurb/pitch angles, audience expectations, and structural stakes across acts.",
|
||||
},
|
||||
{
|
||||
id: "co-author",
|
||||
name: "Co-Author",
|
||||
role: "co-author",
|
||||
temperature: 0.8,
|
||||
contextWindow: 65536,
|
||||
maxTokens: 4096,
|
||||
thinkingEnabled: true,
|
||||
thinkingEffort: "medium",
|
||||
systemPrompt:
|
||||
"You are a collaborative Co-Author with full knowledge of the manuscript's characters, world-building, and plot beats. Brainstorm plot turns, draft scenes or dialogue in the author's established voice, and expand world lore—always as adoptable suggestions that respect the story's canon.",
|
||||
},
|
||||
];
|
||||
|
||||
export interface AppSettings {
|
||||
theme: ThemeId;
|
||||
accentColor: string;
|
||||
@@ -32,13 +248,21 @@ export interface AppSettings {
|
||||
wordCountGoal: number;
|
||||
autoSave: boolean;
|
||||
focusMode: boolean;
|
||||
typewriterMode: boolean;
|
||||
// Navigation & AI Sidebars
|
||||
navCollapsed: boolean;
|
||||
aiSidebarOpen: boolean;
|
||||
// Spell & grammar (Chunk #7)
|
||||
enableSpellcheck: boolean;
|
||||
enableGrammarCheck: boolean;
|
||||
spellcheckLanguage: string;
|
||||
grammarProvider: GrammarProvider;
|
||||
grammarApiKey: string;
|
||||
// AI Providers & Agents
|
||||
aiProviders: AIProviderSetting[];
|
||||
activeProviderId: string;
|
||||
activeModel: string;
|
||||
aiAgents: AIAgentConfig[];
|
||||
activeAgentId: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
@@ -46,20 +270,27 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
||||
accentColor: "#7c8bf5",
|
||||
density: "comfortable",
|
||||
fontFamily: "serif",
|
||||
fontSize: 15,
|
||||
fontSize: 16,
|
||||
leading: 1.7,
|
||||
measure: 720,
|
||||
showLineNumbers: true,
|
||||
showLineNumbers: false,
|
||||
wordCountGoal: 80_000,
|
||||
autoSave: true,
|
||||
focusMode: false,
|
||||
typewriterMode: false,
|
||||
navCollapsed: false,
|
||||
aiSidebarOpen: false,
|
||||
// Spell & grammar (Chunk #7)
|
||||
enableSpellcheck: true,
|
||||
enableGrammarCheck: false,
|
||||
spellcheckLanguage: "en_US",
|
||||
grammarProvider: "local",
|
||||
grammarApiKey: "",
|
||||
// AI Providers & Agents
|
||||
aiProviders: DEFAULT_AI_PROVIDERS,
|
||||
activeProviderId: DEFAULT_AI_PROVIDERS[0]!.id,
|
||||
activeModel: DEFAULT_AI_PROVIDERS[0]!.defaultModel,
|
||||
aiAgents: DEFAULT_AI_AGENTS,
|
||||
activeAgentId: DEFAULT_AI_AGENTS[0]!.id,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "pubscript.settings.v1";
|
||||
@@ -69,7 +300,18 @@ function loadSettings(): AppSettings {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return { ...DEFAULT_SETTINGS };
|
||||
const parsed = JSON.parse(raw) as Partial<AppSettings>;
|
||||
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
...parsed,
|
||||
aiProviders:
|
||||
Array.isArray(parsed.aiProviders) && parsed.aiProviders.length > 0
|
||||
? parsed.aiProviders
|
||||
: DEFAULT_AI_PROVIDERS,
|
||||
aiAgents:
|
||||
Array.isArray(parsed.aiAgents) && parsed.aiAgents.length > 0
|
||||
? parsed.aiAgents
|
||||
: DEFAULT_AI_AGENTS,
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
@@ -78,13 +320,20 @@ function loadSettings(): AppSettings {
|
||||
export interface SettingsStore extends AppSettings {
|
||||
set: (patch: Partial<AppSettings>) => void;
|
||||
setTheme: (theme: ThemeId) => void;
|
||||
updateProvider: (id: string, patch: Partial<AIProviderSetting>) => void;
|
||||
addProvider: (provider: Omit<AIProviderSetting, "id">) => AIProviderSetting;
|
||||
deleteProvider: (id: string) => void;
|
||||
updateAgent: (id: string, patch: Partial<AIAgentConfig>) => void;
|
||||
addAgent: (agent: Omit<AIAgentConfig, "id">) => AIAgentConfig;
|
||||
deleteAgent: (id: string) => void;
|
||||
probeProviderModels: (providerId?: string) => Promise<string[]>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useSettings = create<SettingsStore>((set, get) => {
|
||||
const initial = loadSettings();
|
||||
// Apply the persisted theme + font on boot.
|
||||
applyTheme(initial.theme);
|
||||
applyDensity(initial.density);
|
||||
applyFontSettings({
|
||||
size: initial.fontSize,
|
||||
leading: initial.leading,
|
||||
@@ -96,7 +345,17 @@ export const useSettings = create<SettingsStore>((set, get) => {
|
||||
...initial,
|
||||
set: (patch) => {
|
||||
const next = { ...get(), ...patch };
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (patch.theme != null) {
|
||||
applyTheme(next.theme);
|
||||
}
|
||||
if (patch.density != null) {
|
||||
applyDensity(next.density);
|
||||
}
|
||||
if (
|
||||
patch.fontSize != null ||
|
||||
patch.leading != null ||
|
||||
@@ -116,9 +375,121 @@ export const useSettings = create<SettingsStore>((set, get) => {
|
||||
applyTheme(theme);
|
||||
get().set({ theme });
|
||||
},
|
||||
updateProvider: (id, patch) => {
|
||||
const nextProviders = get().aiProviders.map((p) =>
|
||||
p.id === id ? { ...p, ...patch } : p,
|
||||
);
|
||||
get().set({ aiProviders: nextProviders });
|
||||
},
|
||||
addProvider: (input) => {
|
||||
const created: AIProviderSetting = {
|
||||
...input,
|
||||
id: `prov-${createUlid().slice(-8).toLowerCase()}`,
|
||||
};
|
||||
const nextProviders = [...get().aiProviders, created];
|
||||
get().set({
|
||||
aiProviders: nextProviders,
|
||||
activeProviderId: created.id,
|
||||
activeModel: created.defaultModel,
|
||||
});
|
||||
return created;
|
||||
},
|
||||
deleteProvider: (id) => {
|
||||
const remaining = get().aiProviders.filter((p) => p.id !== id);
|
||||
if (remaining.length === 0) return;
|
||||
const nextActive =
|
||||
get().activeProviderId === id ? remaining[0]! : remaining.find((p) => p.id === get().activeProviderId) ?? remaining[0]!;
|
||||
get().set({
|
||||
aiProviders: remaining,
|
||||
activeProviderId: nextActive.id,
|
||||
activeModel: nextActive.defaultModel,
|
||||
});
|
||||
},
|
||||
updateAgent: (id, patch) => {
|
||||
const nextAgents = get().aiAgents.map((a) =>
|
||||
a.id === id ? { ...a, ...patch } : a,
|
||||
);
|
||||
get().set({ aiAgents: nextAgents });
|
||||
},
|
||||
addAgent: (input) => {
|
||||
const created: AIAgentConfig = {
|
||||
...input,
|
||||
id: `agent-${createUlid().slice(-8).toLowerCase()}`,
|
||||
};
|
||||
const nextAgents = [...get().aiAgents, created];
|
||||
get().set({ aiAgents: nextAgents, activeAgentId: created.id });
|
||||
return created;
|
||||
},
|
||||
deleteAgent: (id) => {
|
||||
const remaining = get().aiAgents.filter((a) => a.id !== id);
|
||||
if (remaining.length === 0) return;
|
||||
const nextActiveId =
|
||||
get().activeAgentId === id ? remaining[0]!.id : get().activeAgentId;
|
||||
get().set({ aiAgents: remaining, activeAgentId: nextActiveId });
|
||||
},
|
||||
probeProviderModels: async (providerId) => {
|
||||
const state = get();
|
||||
const targetId = providerId ?? state.activeProviderId;
|
||||
const provider = state.aiProviders.find((p) => p.id === targetId);
|
||||
if (!provider) return [];
|
||||
|
||||
const foundModels = new Set<string>(provider.models);
|
||||
try {
|
||||
const base = provider.baseUrl.replace(/\/+$/, "");
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 1800);
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (provider.apiKey) {
|
||||
headers.Authorization = `Bearer ${provider.apiKey}`;
|
||||
}
|
||||
const res = await fetch(`${base}/models`, {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = (await res.json()) as { data?: Array<{ id?: string }> };
|
||||
for (const item of json.data ?? []) {
|
||||
if (typeof item.id === "string" && item.id.trim()) {
|
||||
foundModels.add(item.id.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
} catch {
|
||||
/* endpoint unreachable */
|
||||
}
|
||||
|
||||
try {
|
||||
const localDetected = await discoverLocalProviders({ timeoutMs: 800 });
|
||||
for (const det of localDetected) {
|
||||
for (const m of det.models) {
|
||||
if (m.id) foundModels.add(m.id);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
const list = [...foundModels];
|
||||
if (list.length > 0) {
|
||||
get().updateProvider(targetId, {
|
||||
models: list,
|
||||
defaultModel: provider.defaultModel || list[0]!,
|
||||
});
|
||||
}
|
||||
return list;
|
||||
},
|
||||
reset: () => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
applyTheme(DEFAULT_SETTINGS.theme);
|
||||
applyDensity(DEFAULT_SETTINGS.density);
|
||||
applyFontSettings({
|
||||
size: DEFAULT_SETTINGS.fontSize,
|
||||
leading: DEFAULT_SETTINGS.leading,
|
||||
|
||||
Reference in New Issue
Block a user