mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 07:48:14 +00:00
feat: de-uglify the ACL editor
This commit is contained in:
@@ -1,112 +1,73 @@
|
||||
import * as shopify from '@shopify/lang-jsonc';
|
||||
import { xcodeDark, xcodeLight } from '@uiw/codemirror-theme-xcode';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { BookCopy, CircleX } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Merge from 'react-codemirror-merge';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import Fallback from './fallback';
|
||||
import * as shopify from "@shopify/lang-jsonc";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { BookCopy, CircleX } from "lucide-react";
|
||||
import Merge from "react-codemirror-merge";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import { headplaneTheme } from "./theme";
|
||||
|
||||
interface EditorProps {
|
||||
isDisabled?: boolean;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
isDisabled?: boolean;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
// TODO: Remove ClientOnly
|
||||
export function Editor(props: EditorProps) {
|
||||
const [light, setLight] = useState(false);
|
||||
useEffect(() => {
|
||||
const theme = window.matchMedia('(prefers-color-scheme: light)');
|
||||
setLight(theme.matches);
|
||||
theme.addEventListener('change', (theme) => {
|
||||
setLight(theme.matches);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="overflow-y-scroll h-editor text-sm">
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<div className="flex flex-col items-center gap-2.5 py-8">
|
||||
<CircleX />
|
||||
<p className="text-lg font-semibold">Failed to load the editor.</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ClientOnly fallback={<Fallback acl={props.value} />}>
|
||||
{() => (
|
||||
<CodeMirror
|
||||
editable={!props.isDisabled}
|
||||
extensions={[shopify.jsonc()]} // Allow editing unless disabled
|
||||
height="100%" // Use readOnly if disabled
|
||||
onChange={(value) => props.onChange(value)}
|
||||
readOnly={props.isDisabled}
|
||||
style={{ height: '100%' }}
|
||||
theme={light ? xcodeLight : xcodeDark}
|
||||
value={props.value}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<div className="flex flex-col items-center gap-2.5 py-8">
|
||||
<CircleX />
|
||||
<p className="text-lg font-semibold">Failed to load the editor.</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CodeMirror
|
||||
editable={!props.isDisabled}
|
||||
extensions={[shopify.jsonc()]}
|
||||
minHeight="24rem"
|
||||
maxHeight="var(--height-editor)"
|
||||
onChange={(value) => props.onChange(value)}
|
||||
readOnly={props.isDisabled}
|
||||
theme={headplaneTheme}
|
||||
value={props.value}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DifferProps {
|
||||
left: string;
|
||||
right: string;
|
||||
left: string;
|
||||
right: string;
|
||||
}
|
||||
|
||||
export function Differ(props: DifferProps) {
|
||||
const [light, setLight] = useState(false);
|
||||
useEffect(() => {
|
||||
const theme = window.matchMedia('(prefers-color-scheme: light)');
|
||||
setLight(theme.matches);
|
||||
theme.addEventListener('change', (theme) => {
|
||||
setLight(theme.matches);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="text-sm">
|
||||
{props.left === props.right ? (
|
||||
<div className="flex flex-col items-center gap-2.5 py-8">
|
||||
<BookCopy />
|
||||
<p className="text-lg font-semibold">No changes</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-editor overflow-y-scroll">
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<div className="flex flex-col items-center gap-2.5 py-8">
|
||||
<CircleX />
|
||||
<p className="text-lg font-semibold">
|
||||
Failed to load the editor.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ClientOnly fallback={<Fallback acl={props.right} />}>
|
||||
{() => (
|
||||
<Merge orientation="a-b" theme={light ? xcodeLight : xcodeDark}>
|
||||
<Merge.Original
|
||||
extensions={[shopify.jsonc()]}
|
||||
readOnly
|
||||
value={props.left}
|
||||
/>
|
||||
<Merge.Modified
|
||||
extensions={[shopify.jsonc()]}
|
||||
readOnly
|
||||
value={props.right}
|
||||
/>
|
||||
</Merge>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="text-sm">
|
||||
{props.left === props.right ? (
|
||||
<div className="flex flex-col items-center gap-2.5 py-8">
|
||||
<BookCopy />
|
||||
<p className="text-lg font-semibold">No changes</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-editor">
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<div className="flex flex-col items-center gap-2.5 py-8">
|
||||
<CircleX />
|
||||
<p className="text-lg font-semibold">Failed to load the editor.</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Merge orientation="a-b" theme={headplaneTheme}>
|
||||
<Merge.Original extensions={[shopify.jsonc()]} readOnly value={props.left} />
|
||||
<Merge.Modified extensions={[shopify.jsonc()]} readOnly value={props.right} />
|
||||
</Merge>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,18 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
interface Props {
|
||||
readonly acl: string;
|
||||
}
|
||||
|
||||
export default function Fallback({ acl }: Props) {
|
||||
export default function Fallback() {
|
||||
return (
|
||||
<div className="h-editor relative flex w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full w-8 flex justify-center p-1",
|
||||
"border-r border-mist-400 dark:border-mist-800",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"h-5 w-5 animate-spin rounded-full",
|
||||
"border-mist-900 dark:border-mist-100",
|
||||
"border-2 border-t-transparent dark:border-t-transparent",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn("h-editor overflow-hidden rounded-md", "bg-[var(--cm-bg)] text-[var(--cm-fg)]")}
|
||||
>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-2 text-[var(--cm-gutter-fg)]">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
<p className="text-sm">Loading editor…</p>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
className={cn(
|
||||
"w-full h-editor font-mono resize-none text-sm",
|
||||
"bg-mist-50 dark:bg-mist-950 opacity-60",
|
||||
"pl-1 pt-1 leading-snug",
|
||||
)}
|
||||
readOnly
|
||||
value={acl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { tags as t } from "@lezer/highlight";
|
||||
|
||||
const editorTheme = EditorView.theme({
|
||||
"&": {
|
||||
backgroundColor: "var(--cm-bg)",
|
||||
color: "var(--cm-fg)",
|
||||
},
|
||||
"&.cm-editor.cm-focused": {
|
||||
outline: "none",
|
||||
},
|
||||
".cm-content": {
|
||||
caretColor: "var(--cm-caret)",
|
||||
fontFamily: "var(--font-mono, ui-monospace, monospace)",
|
||||
},
|
||||
"&.cm-editor .cm-scroller": {
|
||||
fontFamily: "var(--font-mono, ui-monospace, monospace)",
|
||||
},
|
||||
".cm-cursor, .cm-dropCursor": {
|
||||
borderLeftColor: "var(--cm-caret)",
|
||||
},
|
||||
"&.cm-focused .cm-selectionBackground, & .cm-line::selection, & .cm-selectionLayer .cm-selectionBackground, .cm-content ::selection":
|
||||
{
|
||||
background: "var(--cm-selection) !important",
|
||||
},
|
||||
"& .cm-selectionMatch": {
|
||||
backgroundColor: "var(--cm-selection-match)",
|
||||
},
|
||||
".cm-activeLine": {
|
||||
backgroundColor: "var(--cm-line-highlight)",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "var(--cm-gutter-bg)",
|
||||
color: "var(--cm-gutter-fg)",
|
||||
borderRight: "1px solid var(--cm-gutter-border)",
|
||||
},
|
||||
".cm-activeLineGutter": {
|
||||
backgroundColor: "var(--cm-line-highlight)",
|
||||
color: "var(--cm-gutter-fg-active)",
|
||||
},
|
||||
".cm-scroller": {
|
||||
scrollbarColor: "var(--cm-gutter-border) transparent",
|
||||
scrollbarWidth: "auto",
|
||||
},
|
||||
});
|
||||
|
||||
const highlightStyle = HighlightStyle.define([
|
||||
{ tag: [t.comment, t.quote], color: "var(--cm-comment)" },
|
||||
{ tag: [t.keyword], color: "var(--cm-keyword)", fontWeight: "bold" },
|
||||
{ tag: [t.string, t.meta], color: "var(--cm-string)" },
|
||||
{ tag: [t.typeName, t.typeOperator], color: "var(--cm-type)" },
|
||||
{ tag: [t.definition(t.variableName)], color: "var(--cm-definition)" },
|
||||
{ tag: [t.name], color: "var(--cm-name)" },
|
||||
{ tag: [t.variableName], color: "var(--cm-variable)" },
|
||||
{ tag: [t.propertyName], color: "var(--cm-property)" },
|
||||
{ tag: [t.atom, t.bool, t.special(t.variableName)], color: "var(--cm-atom)" },
|
||||
{ tag: [t.number], color: "var(--cm-number)" },
|
||||
{ tag: [t.regexp, t.link], color: "var(--cm-link)" },
|
||||
{ tag: [t.bracket], color: "var(--cm-bracket)" },
|
||||
]);
|
||||
|
||||
export const headplaneTheme = [editorTheme, syntaxHighlighting(highlightStyle)];
|
||||
@@ -108,12 +108,12 @@ export default function Page({ loaderData: { access, writable, policy } }: Route
|
||||
</TabsTab>
|
||||
</TabsList>
|
||||
<TabsPanel value="edit">
|
||||
<Suspense fallback={<Fallback acl={codePolicy} />}>
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<LazyEditor isDisabled={disabled} onChange={setCodePolicy} value={codePolicy} />
|
||||
</Suspense>
|
||||
</TabsPanel>
|
||||
<TabsPanel value="diff">
|
||||
<Suspense fallback={<Fallback acl={codePolicy} />}>
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<LazyDiffer left={policy} right={codePolicy} />
|
||||
</Suspense>
|
||||
</TabsPanel>
|
||||
|
||||
Reference in New Issue
Block a user