mirror of
https://github.com/tale/headplane.git
synced 2026-08-12 06:46:51 +00:00
feat: de-uglify the ACL editor
This commit is contained in:
@@ -29,7 +29,7 @@ function TabList({ children, className }: { children: ReactNode; className?: str
|
||||
return (
|
||||
<BaseTabs.List
|
||||
className={cn(
|
||||
"flex items-center rounded-t-lg w-fit max-w-full",
|
||||
"flex items-center rounded-t-md w-fit max-w-full",
|
||||
"border-mist-200 dark:border-mist-800",
|
||||
"border-t border-x",
|
||||
className,
|
||||
@@ -56,7 +56,7 @@ function Tab({
|
||||
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1 z-10",
|
||||
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
|
||||
"border-r border-mist-200 dark:border-mist-800",
|
||||
"first:rounded-tl-lg last:rounded-tr-lg last:border-r-0",
|
||||
"first:rounded-tl-md last:rounded-tr-md last:border-r-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -76,7 +76,7 @@ function Panel({
|
||||
value={value}
|
||||
{...props}
|
||||
className={cn(
|
||||
"w-full overflow-clip rounded-b-lg rounded-r-lg",
|
||||
"w-full overflow-clip rounded-b-md rounded-r-md",
|
||||
"border border-mist-200 dark:border-mist-800",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+60
-10
@@ -61,11 +61,61 @@
|
||||
}
|
||||
}
|
||||
|
||||
.cm-merge-theme {
|
||||
height: 100% !important;
|
||||
:root {
|
||||
--cm-bg: var(--color-white);
|
||||
--cm-fg: var(--color-mist-900);
|
||||
--cm-caret: var(--color-mist-900);
|
||||
--cm-selection: var(--color-indigo-100);
|
||||
--cm-selection-match: var(--color-indigo-50);
|
||||
--cm-line-highlight: var(--color-mist-100);
|
||||
--cm-gutter-bg: var(--color-mist-50);
|
||||
--cm-gutter-fg: var(--color-mist-400);
|
||||
--cm-gutter-fg-active: var(--color-mist-700);
|
||||
--cm-gutter-border: var(--color-mist-200);
|
||||
--cm-comment: var(--color-mist-500);
|
||||
--cm-keyword: var(--color-purple-600);
|
||||
--cm-string: var(--color-emerald-700);
|
||||
--cm-type: var(--color-indigo-600);
|
||||
--cm-definition: var(--color-blue-600);
|
||||
--cm-name: var(--color-mist-800);
|
||||
--cm-variable: var(--color-cyan-700);
|
||||
--cm-property: var(--color-violet-600);
|
||||
--cm-atom: var(--color-orange-600);
|
||||
--cm-number: var(--color-orange-600);
|
||||
--cm-link: var(--color-blue-600);
|
||||
--cm-bracket: var(--color-mist-500);
|
||||
}
|
||||
|
||||
.cm-mergeView {
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--cm-bg: var(--color-mist-950);
|
||||
--cm-fg: var(--color-mist-100);
|
||||
--cm-caret: var(--color-mist-100);
|
||||
--cm-selection: var(--color-indigo-900);
|
||||
--cm-selection-match: var(--color-indigo-950);
|
||||
--cm-line-highlight: oklch(18% 0.006 224 / 0.5);
|
||||
--cm-gutter-bg: var(--color-mist-950);
|
||||
--cm-gutter-fg: var(--color-mist-500);
|
||||
--cm-gutter-fg-active: var(--color-mist-300);
|
||||
--cm-gutter-border: var(--color-mist-800);
|
||||
--cm-comment: var(--color-mist-400);
|
||||
--cm-keyword: var(--color-purple-400);
|
||||
--cm-string: var(--color-emerald-400);
|
||||
--cm-type: var(--color-indigo-400);
|
||||
--cm-definition: var(--color-blue-400);
|
||||
--cm-name: var(--color-mist-200);
|
||||
--cm-variable: var(--color-cyan-400);
|
||||
--cm-property: var(--color-violet-400);
|
||||
--cm-atom: var(--color-orange-400);
|
||||
--cm-number: var(--color-orange-400);
|
||||
--cm-link: var(--color-blue-400);
|
||||
--cm-bracket: var(--color-mist-400);
|
||||
}
|
||||
}
|
||||
|
||||
.cm-merge-theme,
|
||||
.cm-mergeView,
|
||||
.cm-mergeViewEditor {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
@@ -73,10 +123,6 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cm-mergeViewEditor {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.toast-viewport {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
@@ -177,8 +223,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Weirdest class name characters but ok */
|
||||
.cm-mergeView .ͼ1 .cm-scroller,
|
||||
.cm-mergeView .ͼ1 {
|
||||
.cm-mergeView .cm-editor,
|
||||
.cm-mergeView .cm-editor .cm-scroller {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.cm-mergeViewEditors {
|
||||
scrollbar-color: var(--cm-gutter-border) transparent;
|
||||
scrollbar-width: auto;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user