Add import/export functionality
This commit is contained in:
+265
-27
@@ -51,32 +51,8 @@ const checkSchema = z.object({
|
|||||||
message: z.string().trim().min(1, "Add feedback text for this check.")
|
message: z.string().trim().min(1, "Add feedback text for this check.")
|
||||||
});
|
});
|
||||||
|
|
||||||
const taskSchema = z.object({
|
function validateChecks(checks: z.infer<typeof checkSchema>[], context: z.RefinementCtx) {
|
||||||
title: z.string().trim().min(1, "Add a task title."),
|
checks.forEach((check, index) => {
|
||||||
descriptionMarkdown: z.string().default(""),
|
|
||||||
groupId: z.string().trim().min(1, "Choose a day group for this task."),
|
|
||||||
order: z.coerce.number({ invalid_type_error: "Enter a valid task order." }).int("Task order must be a whole number.").default(0),
|
|
||||||
visible: z.boolean().default(true),
|
|
||||||
starterHtml: z.string().default(""),
|
|
||||||
starterCss: z.string().default(""),
|
|
||||||
starterJs: z.string().default(""),
|
|
||||||
referenceHtml: z.string().default(""),
|
|
||||||
referenceCss: z.string().default(""),
|
|
||||||
referenceJs: z.string().default(""),
|
|
||||||
visualThreshold: z
|
|
||||||
.coerce.number({ invalid_type_error: "Enter a valid visual threshold." })
|
|
||||||
.min(0, "Visual threshold must be between 0 and 1.")
|
|
||||||
.max(1, "Visual threshold must be between 0 and 1.")
|
|
||||||
.default(0.85),
|
|
||||||
passingScore: z
|
|
||||||
.coerce.number({ invalid_type_error: "Enter a valid passing score." })
|
|
||||||
.min(0, "Passing score must be between 0 and 1.")
|
|
||||||
.max(1, "Passing score must be between 0 and 1.")
|
|
||||||
.default(0.8),
|
|
||||||
checks: z.array(checkSchema).default([]),
|
|
||||||
regenerateExpected: z.boolean().default(true)
|
|
||||||
}).superRefine((task, context) => {
|
|
||||||
task.checks.forEach((check, index) => {
|
|
||||||
const label = `Check ${index + 1}`;
|
const label = `Check ${index + 1}`;
|
||||||
const needsSelector = check.type !== "visual-match";
|
const needsSelector = check.type !== "visual-match";
|
||||||
const needsValue = ["text-contains", "attribute-equals", "css-property"].includes(check.type);
|
const needsValue = ["text-contains", "attribute-equals", "css-property"].includes(check.type);
|
||||||
@@ -129,6 +105,79 @@ const taskSchema = z.object({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskSchema = z.object({
|
||||||
|
title: z.string().trim().min(1, "Add a task title."),
|
||||||
|
descriptionMarkdown: z.string().default(""),
|
||||||
|
groupId: z.string().trim().min(1, "Choose a day group for this task."),
|
||||||
|
order: z.coerce.number({ invalid_type_error: "Enter a valid task order." }).int("Task order must be a whole number.").default(0),
|
||||||
|
visible: z.boolean().default(true),
|
||||||
|
starterHtml: z.string().default(""),
|
||||||
|
starterCss: z.string().default(""),
|
||||||
|
starterJs: z.string().default(""),
|
||||||
|
referenceHtml: z.string().default(""),
|
||||||
|
referenceCss: z.string().default(""),
|
||||||
|
referenceJs: z.string().default(""),
|
||||||
|
visualThreshold: z
|
||||||
|
.coerce.number({ invalid_type_error: "Enter a valid visual threshold." })
|
||||||
|
.min(0, "Visual threshold must be between 0 and 1.")
|
||||||
|
.max(1, "Visual threshold must be between 0 and 1.")
|
||||||
|
.default(0.85),
|
||||||
|
passingScore: z
|
||||||
|
.coerce.number({ invalid_type_error: "Enter a valid passing score." })
|
||||||
|
.min(0, "Passing score must be between 0 and 1.")
|
||||||
|
.max(1, "Passing score must be between 0 and 1.")
|
||||||
|
.default(0.8),
|
||||||
|
checks: z.array(checkSchema).default([]),
|
||||||
|
regenerateExpected: z.boolean().default(true)
|
||||||
|
}).superRefine((task, context) => {
|
||||||
|
validateChecks(task.checks, context);
|
||||||
|
});
|
||||||
|
|
||||||
|
const importTaskSchema = z.object({
|
||||||
|
id: z.string().trim().optional(),
|
||||||
|
title: z.string().trim().min(1, "Add a task title."),
|
||||||
|
descriptionMarkdown: z.string().default(""),
|
||||||
|
order: z.coerce.number({ invalid_type_error: "Enter a valid task order." }).int("Task order must be a whole number.").default(0),
|
||||||
|
visible: z.boolean().default(true),
|
||||||
|
starterHtml: z.string().default(""),
|
||||||
|
starterCss: z.string().default(""),
|
||||||
|
starterJs: z.string().default(""),
|
||||||
|
referenceHtml: z.string().default(""),
|
||||||
|
referenceCss: z.string().default(""),
|
||||||
|
referenceJs: z.string().default(""),
|
||||||
|
visualThreshold: z
|
||||||
|
.coerce.number({ invalid_type_error: "Enter a valid visual threshold." })
|
||||||
|
.min(0, "Visual threshold must be between 0 and 1.")
|
||||||
|
.max(1, "Visual threshold must be between 0 and 1.")
|
||||||
|
.default(0.85),
|
||||||
|
passingScore: z
|
||||||
|
.coerce.number({ invalid_type_error: "Enter a valid passing score." })
|
||||||
|
.min(0, "Passing score must be between 0 and 1.")
|
||||||
|
.max(1, "Passing score must be between 0 and 1.")
|
||||||
|
.default(0.8),
|
||||||
|
checks: z.array(checkSchema).default([])
|
||||||
|
}).superRefine((task, context) => {
|
||||||
|
validateChecks(task.checks, context);
|
||||||
|
});
|
||||||
|
|
||||||
|
const importAssetSchema = z.object({
|
||||||
|
path: z.string().trim().min(1),
|
||||||
|
contentType: z.string().trim().optional(),
|
||||||
|
dataBase64: z.string().min(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
const importExportSchema = z.object({
|
||||||
|
format: z.literal("frontend-judge-task-export").optional(),
|
||||||
|
version: z.number().optional(),
|
||||||
|
groups: z.array(z.object({
|
||||||
|
id: z.string().trim().optional(),
|
||||||
|
title: z.string().trim().min(1, "Add a group title."),
|
||||||
|
order: z.coerce.number().int().default(0),
|
||||||
|
tasks: z.array(importTaskSchema).default([])
|
||||||
|
})).default([]),
|
||||||
|
assets: z.array(importAssetSchema).default([])
|
||||||
});
|
});
|
||||||
|
|
||||||
const submissionSchema = z.object({
|
const submissionSchema = z.object({
|
||||||
@@ -221,6 +270,80 @@ function imageExtension(contentType: string) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function contentTypeForAssetPath(assetPath: string) {
|
||||||
|
const extension = path.extname(assetPath).toLocaleLowerCase("en-US");
|
||||||
|
if (extension === ".png") return "image/png";
|
||||||
|
if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
|
||||||
|
if (extension === ".webp") return "image/webp";
|
||||||
|
if (extension === ".gif") return "image/gif";
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExportAssetPath(assetPath: string) {
|
||||||
|
const normalized = assetPath.replace(/^\/+/, "");
|
||||||
|
return /^generated\/assets\/[A-Za-z0-9._-]+\.(png|jpe?g|webp|gif)$/i.test(normalized) ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdownAssetPaths(markdown: string) {
|
||||||
|
const paths = new Set<string>();
|
||||||
|
const assetPattern = /(?:^|[("'`\s])\/?(generated\/assets\/[A-Za-z0-9._-]+\.(?:png|jpe?g|webp|gif))/gi;
|
||||||
|
for (const match of markdown.matchAll(assetPattern)) {
|
||||||
|
const assetPath = normalizeExportAssetPath(match[1]);
|
||||||
|
if (assetPath) {
|
||||||
|
paths.add(assetPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueImportId(id: string | undefined, used: Set<string>) {
|
||||||
|
if (!id || !/^[A-Za-z0-9_-]{1,128}$/.test(id) || used.has(id)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
used.add(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportMarkdownAssets(markdowns: string[]) {
|
||||||
|
const paths = new Set<string>();
|
||||||
|
for (const markdown of markdowns) {
|
||||||
|
for (const assetPath of markdownAssetPaths(markdown)) {
|
||||||
|
paths.add(assetPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assets = [];
|
||||||
|
for (const assetPath of [...paths].sort()) {
|
||||||
|
try {
|
||||||
|
const absolutePath = path.join(config.dataDir, assetPath);
|
||||||
|
const data = await fs.readFile(absolutePath);
|
||||||
|
assets.push({
|
||||||
|
path: assetPath,
|
||||||
|
contentType: contentTypeForAssetPath(assetPath),
|
||||||
|
dataBase64: data.toString("base64")
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Missing markdown images should not make the entire task export unusable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return assets;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importMarkdownAssets(assets: z.infer<typeof importAssetSchema>[]) {
|
||||||
|
for (const asset of assets) {
|
||||||
|
const assetPath = normalizeExportAssetPath(asset.path);
|
||||||
|
if (!assetPath) {
|
||||||
|
throw new Error(`Unsupported asset path: ${asset.path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolutePath = path.join(config.dataDir, assetPath);
|
||||||
|
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||||
|
await fs.writeFile(absolutePath, Buffer.from(asset.dataBase64, "base64"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveAsset(buffer: Buffer, contentType: string) {
|
async function saveAsset(buffer: Buffer, contentType: string) {
|
||||||
const extension = imageExtension(contentType);
|
const extension = imageExtension(contentType);
|
||||||
if (!extension) {
|
if (!extension) {
|
||||||
@@ -323,7 +446,7 @@ export async function createApp() {
|
|||||||
crossOriginEmbedderPolicy: false
|
crossOriginEmbedderPolicy: false
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
app.use(express.json({ limit: "2mb" }));
|
app.use(express.json({ limit: "25mb" }));
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use("/generated", express.static(config.generatedDir));
|
app.use("/generated", express.static(config.generatedDir));
|
||||||
|
|
||||||
@@ -482,6 +605,121 @@ export async function createApp() {
|
|||||||
res.json(groups.map((group) => ({ ...group, tasks: group.tasks.map(taskWithChecks) })));
|
res.json(groups.map((group) => ({ ...group, tasks: group.tasks.map(taskWithChecks) })));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/api/admin/export", requireAdmin, async (_req, res) => {
|
||||||
|
const groups = await prisma.dayGroup.findMany({
|
||||||
|
orderBy: [{ order: "asc" }, { createdAt: "asc" }],
|
||||||
|
include: { tasks: { orderBy: [{ order: "asc" }, { createdAt: "asc" }] } }
|
||||||
|
});
|
||||||
|
const markdowns = groups.flatMap((group) => group.tasks.map((task) => task.descriptionMarkdown));
|
||||||
|
const exportData = {
|
||||||
|
format: "frontend-judge-task-export",
|
||||||
|
version: 1,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
groups: groups.map((group) => ({
|
||||||
|
id: group.id,
|
||||||
|
title: group.title,
|
||||||
|
order: group.order,
|
||||||
|
tasks: group.tasks.map((task) => ({
|
||||||
|
id: task.id,
|
||||||
|
title: task.title,
|
||||||
|
descriptionMarkdown: task.descriptionMarkdown,
|
||||||
|
order: task.order,
|
||||||
|
visible: task.visible,
|
||||||
|
starterHtml: task.starterHtml,
|
||||||
|
starterCss: task.starterCss,
|
||||||
|
starterJs: task.starterJs,
|
||||||
|
referenceHtml: task.referenceHtml,
|
||||||
|
referenceCss: task.referenceCss,
|
||||||
|
referenceJs: task.referenceJs,
|
||||||
|
visualThreshold: task.visualThreshold,
|
||||||
|
passingScore: task.passingScore,
|
||||||
|
checks: parseChecks(task.checksJson)
|
||||||
|
}))
|
||||||
|
})),
|
||||||
|
assets: await exportMarkdownAssets(markdowns)
|
||||||
|
};
|
||||||
|
|
||||||
|
res.setHeader("Content-Disposition", `attachment; filename="frontend-judge-tasks-${new Date().toISOString().slice(0, 10)}.json"`);
|
||||||
|
res.json(exportData);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/admin/import", requireAdmin, async (req, res) => {
|
||||||
|
const parsed = importExportSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json(validationError(parsed.error, "Please choose a valid task export file."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
try {
|
||||||
|
await importMarkdownAssets(parsed.data.assets);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: error instanceof Error ? error.message : "Could not import markdown assets." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupIds = new Set<string>();
|
||||||
|
const taskIds = new Set<string>();
|
||||||
|
const createdTasks = await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.dayGroup.deleteMany();
|
||||||
|
const tasks = [];
|
||||||
|
|
||||||
|
for (const group of parsed.data.groups) {
|
||||||
|
const createdGroup = await tx.dayGroup.create({
|
||||||
|
data: {
|
||||||
|
...(uniqueImportId(group.id, groupIds) ? { id: group.id } : {}),
|
||||||
|
title: group.title,
|
||||||
|
order: group.order
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const task of group.tasks) {
|
||||||
|
const taskId = uniqueImportId(task.id, taskIds);
|
||||||
|
const createdTask = await tx.task.create({
|
||||||
|
data: {
|
||||||
|
...(taskId ? { id: taskId } : {}),
|
||||||
|
title: task.title,
|
||||||
|
descriptionMarkdown: task.descriptionMarkdown,
|
||||||
|
groupId: createdGroup.id,
|
||||||
|
order: task.order,
|
||||||
|
visible: task.visible,
|
||||||
|
starterHtml: task.starterHtml,
|
||||||
|
starterCss: task.starterCss,
|
||||||
|
starterJs: task.starterJs,
|
||||||
|
referenceHtml: task.referenceHtml,
|
||||||
|
referenceCss: task.referenceCss,
|
||||||
|
referenceJs: task.referenceJs,
|
||||||
|
visualThreshold: task.visualThreshold,
|
||||||
|
passingScore: task.passingScore,
|
||||||
|
checksJson: JSON.stringify(task.checks)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tasks.push(createdTask);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tasks;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const task of createdTasks) {
|
||||||
|
try {
|
||||||
|
const expectedScreenshotPath = await maybeGenerateExpected(task);
|
||||||
|
if (expectedScreenshotPath) {
|
||||||
|
await prisma.task.update({ where: { id: task.id }, data: { expectedScreenshotPath } });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
warnings.push(`Could not regenerate the expected screenshot for "${task.title}".`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
groups: parsed.data.groups.length,
|
||||||
|
tasks: createdTasks.length,
|
||||||
|
warnings
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/api/admin/groups", requireAdmin, async (req, res) => {
|
app.post("/api/admin/groups", requireAdmin, async (req, res) => {
|
||||||
const parsed = z.object({ title: z.string().min(1), order: z.coerce.number().int().default(0) }).safeParse(req.body);
|
const parsed = z.object({ title: z.string().min(1), order: z.coerce.number().int().default(0) }).safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { javascript } from "@codemirror/lang-javascript";
|
|||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
FileText,
|
FileText,
|
||||||
History,
|
History,
|
||||||
@@ -698,6 +699,8 @@ function AdminApp({ refreshAuth }: { refreshAuth: () => void }) {
|
|||||||
if (!selectedTask.groupId && groupData[0]) {
|
if (!selectedTask.groupId && groupData[0]) {
|
||||||
setSelectedTask((task) => ({ ...task, groupId: groupData[0].id }));
|
setSelectedTask((task) => ({ ...task, groupId: groupData[0].id }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { groupData, submissionData };
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -802,6 +805,53 @@ function AdminApp({ refreshAuth }: { refreshAuth: () => void }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function exportTasks() {
|
||||||
|
setError("");
|
||||||
|
setMessage("");
|
||||||
|
const response = await fetch("/api/admin/export");
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
|
throw new ApiError(typeof payload.error === "string" ? payload.error : "Could not export tasks.", collectErrorDetails(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const disposition = response.headers.get("content-disposition") ?? "";
|
||||||
|
const fileNameMatch = disposition.match(/filename="([^"]+)"/);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileNameMatch?.[1] ?? `frontend-judge-tasks-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
document.body.append(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
setMessage("Task export downloaded.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importTasks(file: File) {
|
||||||
|
setError("");
|
||||||
|
setMessage("");
|
||||||
|
const shouldImport = window.confirm("Importing will replace all current groups, tasks, and submissions. Continue?");
|
||||||
|
if (!shouldImport) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(await file.text()) as unknown;
|
||||||
|
const result = await api<{ groups: number; tasks: number; warnings: string[] }>("/api/admin/import", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(parsed)
|
||||||
|
});
|
||||||
|
const { groupData } = await loadAdminData();
|
||||||
|
setSelectedTask({ ...emptyTask, groupId: groupData[0]?.id ?? "" });
|
||||||
|
setSelectedSubmission(null);
|
||||||
|
setMessage(
|
||||||
|
[
|
||||||
|
`Imported ${result.groups} group(s) and ${result.tasks} task(s).`,
|
||||||
|
...(result.warnings ?? [])
|
||||||
|
].join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="app-shell">
|
<main className="app-shell">
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
@@ -809,6 +859,22 @@ function AdminApp({ refreshAuth }: { refreshAuth: () => void }) {
|
|||||||
<h1>Admin dashboard</h1>
|
<h1>Admin dashboard</h1>
|
||||||
<p>Create day groups, tasks, checks, and reference screenshots.</p>
|
<p>Create day groups, tasks, checks, and reference screenshots.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="topbar-actions">
|
||||||
|
<button className="secondary" onClick={() => exportTasks().catch((err) => setError(errorMessage(err, "Could not export tasks.")))}>
|
||||||
|
<Download size={17} /> Export
|
||||||
|
</button>
|
||||||
|
<label className="upload-button">
|
||||||
|
<Upload size={17} /> Import
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
event.target.value = "";
|
||||||
|
if (file) importTasks(file).catch((err) => setError(errorMessage(err, "Could not import tasks.")));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
className="ghost"
|
className="ghost"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -819,6 +885,7 @@ function AdminApp({ refreshAuth }: { refreshAuth: () => void }) {
|
|||||||
>
|
>
|
||||||
<LogOut size={18} />
|
<LogOut size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="admin-layout">
|
<div className="admin-layout">
|
||||||
|
|||||||
@@ -102,6 +102,14 @@ p {
|
|||||||
color: #5b6b79;
|
color: #5b6b79;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.student-layout,
|
.student-layout,
|
||||||
.admin-layout {
|
.admin-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -645,6 +653,7 @@ p {
|
|||||||
.success {
|
.success {
|
||||||
color: #167142;
|
color: #167142;
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cm-editor {
|
.cm-editor {
|
||||||
|
|||||||
Reference in New Issue
Block a user