Files
genieacs/ui/tailwind-utility-components.ts
Zaid Humaimidi 4a191afdec feat(ui): redesign UI with Tailwind CSS, CodeMirror 6, and new layout
- Replace all hand-written CSS with Tailwind utility classes
- Upgrade CodeMirror 5 to CodeMirror 6 (scoped @codemirror/* packages)
- Redesign layout with responsive sidebar navigation using JSX/Mithril
- Flatten admin routes (e.g. /admin/presets -> /presets)
- Add "remember me" login option with 2-week persistent cookie
- Add delete confirmation dialogs across admin pages
- Upgrade esbuild from 0.21 to 0.27 and update browser targets
- Add Inter and Roboto Mono variable fonts with subset script
2026-03-12 14:55:10 -07:00

201 lines
4.6 KiB
TypeScript

import m, {
ChildArrayOrPrimitive,
ClosureComponent,
mount,
Vnode,
VnodeDOM,
} from "mithril";
import { ICONS_SVG } from "../build/assets.ts";
export const portal: ClosureComponent<void> = () => {
let rootElement: HTMLElement;
let children: ChildArrayOrPrimitive;
return {
oncreate: (vnode) => {
children = vnode.children;
rootElement = document.createElement("div");
document.body.appendChild(rootElement);
mount(rootElement, { view: () => children });
},
onupdate: (vnode) => {
children = vnode.children;
},
onremove: () => {
if (document.body.contains(rootElement)) {
mount(rootElement, null);
document.body.removeChild(rootElement);
}
},
view: () => {
return null;
},
};
};
interface DialogAttrs {
onClose?: () => void;
as: string;
class?: string;
}
export const dialog: ClosureComponent<DialogAttrs> = () => {
return {
view(vnode) {
return m(
portal,
m(
vnode.attrs.as,
{ class: vnode.attrs.class, role: "dialog", "aria-modal": "true" },
vnode.children,
),
);
},
};
};
interface DialogOverlayAttrs {
class?: string;
}
export const dialogOverlay: ClosureComponent<DialogOverlayAttrs> = () => {
return {
view(vnode) {
return m(
"div",
{ class: vnode.attrs.class, "aria-hidden": "true" },
vnode.children,
);
},
};
};
let transitionShow = false;
let transitionTimeout = 0;
interface TransitionRootAttrs {
show: boolean;
duration: number;
}
export const transitionRoot: ClosureComponent<TransitionRootAttrs> = (
initialVnode,
) => {
let show = initialVnode.attrs.show;
let transitionTimestamp = 0;
let timeout: ReturnType<typeof setTimeout>;
return {
view(vnode: Vnode<TransitionRootAttrs>) {
if (show !== vnode.attrs.show) {
const now = Date.now();
transitionTimestamp = now;
show = vnode.attrs.show;
clearTimeout(timeout);
timeout = setTimeout(() => m.redraw(), vnode.attrs.duration);
}
transitionShow = show;
transitionTimeout = Math.max(
0,
vnode.attrs.duration - (Date.now() - transitionTimestamp),
);
if (!transitionShow && !transitionTimeout) return null;
return vnode.children;
},
};
};
interface TransitionChildAttrs {
enter: string;
enterFrom: string;
enterTo: string;
leave: string;
leaveFrom: string;
leaveTo: string;
}
export const transitionChild: ClosureComponent<TransitionChildAttrs> = () => {
let show: boolean;
let timeout: number;
let firstFrame: boolean;
function updateCssClasses(vnode: VnodeDOM<TransitionChildAttrs>): void {
const dom = vnode.dom as HTMLElement;
const enter = vnode.attrs.enter.split(" ");
const enterFrom = vnode.attrs.enterFrom.split(" ");
const enterTo = vnode.attrs.enterTo.split(" ");
const leave = vnode.attrs.leave.split(" ");
const leaveFrom = vnode.attrs.leaveFrom.split(" ");
const leaveTo = vnode.attrs.leaveTo.split(" ");
if (show) {
dom.classList.remove(...leave, ...leaveFrom, ...leaveTo);
if (firstFrame) {
dom.classList.add(...enterFrom);
void dom.getBoundingClientRect();
dom.classList.remove(...enterFrom);
}
if (timeout) dom.classList.add(...enter);
else dom.classList.remove(...enter);
dom.classList.add(...enterTo);
} else {
dom.classList.remove(...enter, ...enterFrom, ...enterTo);
if (firstFrame) {
dom.classList.add(...leaveFrom);
void dom.getBoundingClientRect();
dom.classList.remove(...leaveFrom);
}
if (timeout) dom.classList.add(...leave);
else dom.classList.remove(...leave);
dom.classList.add(...leaveTo);
}
}
return {
view(vnode: Vnode<TransitionChildAttrs>) {
firstFrame = show !== transitionShow;
show = transitionShow;
timeout = transitionTimeout;
return vnode.children;
},
oncreate(vnode: VnodeDOM<TransitionChildAttrs>) {
updateCssClasses(vnode);
},
onupdate(vnode: VnodeDOM<TransitionChildAttrs>) {
updateCssClasses(vnode);
},
};
};
interface IconAttrs {
name: string;
class?: string;
}
export const icon: ClosureComponent<IconAttrs> = () => {
return {
view(vnode) {
return m(
`svg`,
{
xmlns: "http://www.w3.org/2000/svg",
fill: "none",
stroke: "currentColor",
"stroke-width": "2",
class: vnode.attrs.class,
"aria-hidden": "true",
},
m("use", { href: `${ICONS_SVG}#icon-${vnode.attrs.name}` }),
);
},
};
};