Initial commit
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Good Grief Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@goodgrief/admin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@goodgrief/cue-engine": "file:../../packages/cue-engine",
|
||||
"@goodgrief/effects": "file:../../packages/effects",
|
||||
"@goodgrief/render-engine": "file:../../packages/render-engine",
|
||||
"@goodgrief/shared-types": "file:../../packages/shared-types",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.3",
|
||||
"@types/react-dom": "^19.1.3",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { CueTransition } from "@goodgrief/shared-types";
|
||||
import { SceneViewport } from "../features/live/SceneViewport";
|
||||
import { readProgramOutputState, subscribeProgramOutput, type ProgramOutputState } from "../features/live/output-sync";
|
||||
import "./output.css";
|
||||
|
||||
const enterFullscreen = async () => {
|
||||
if (document.fullscreenElement) {
|
||||
await document.exitFullscreen();
|
||||
return;
|
||||
}
|
||||
|
||||
await document.documentElement.requestFullscreen();
|
||||
};
|
||||
|
||||
export const ProgramOutputApp = () => {
|
||||
const [outputState, setOutputState] = useState<ProgramOutputState | null>(() => readProgramOutputState());
|
||||
const [overlayVisible, setOverlayVisible] = useState(true);
|
||||
const [overlayDismissed, setOverlayDismissed] = useState(false);
|
||||
const hideTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const clearHideTimer = useCallback(() => {
|
||||
if (hideTimeoutRef.current !== null) {
|
||||
window.clearTimeout(hideTimeoutRef.current);
|
||||
hideTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleHide = useCallback(
|
||||
(delayMs: number) => {
|
||||
clearHideTimer();
|
||||
hideTimeoutRef.current = window.setTimeout(() => {
|
||||
setOverlayVisible(false);
|
||||
}, delayMs);
|
||||
},
|
||||
[clearHideTimer]
|
||||
);
|
||||
|
||||
const showOverlay = useCallback(
|
||||
(delayMs = 1800) => {
|
||||
setOverlayVisible(true);
|
||||
scheduleHide(delayMs);
|
||||
},
|
||||
[scheduleHide]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.add("mode-output");
|
||||
document.body.classList.add("mode-output");
|
||||
|
||||
return () => {
|
||||
clearHideTimer();
|
||||
document.documentElement.classList.remove("mode-output");
|
||||
document.body.classList.remove("mode-output");
|
||||
};
|
||||
}, [clearHideTimer]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeProgramOutput((payload) =>
|
||||
setOutputState((current) => {
|
||||
if (!current || payload.outputRevision > current.outputRevision) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
return current;
|
||||
})
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setOverlayDismissed(false);
|
||||
showOverlay(2800);
|
||||
}, [outputState?.updatedAt, showOverlay]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key.toLowerCase() === "f") {
|
||||
void enterFullscreen();
|
||||
}
|
||||
if (event.key.toLowerCase() === "i") {
|
||||
setOverlayDismissed((current) => {
|
||||
const next = !current;
|
||||
if (next) {
|
||||
clearHideTimer();
|
||||
setOverlayVisible(false);
|
||||
} else {
|
||||
showOverlay(2800);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleWindowBlur = () => {
|
||||
clearHideTimer();
|
||||
setOverlayVisible(false);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("blur", handleWindowBlur);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("blur", handleWindowBlur);
|
||||
};
|
||||
}, [clearHideTimer, showOverlay]);
|
||||
|
||||
const handlePointerMove = () => {
|
||||
if (overlayDismissed) {
|
||||
return;
|
||||
}
|
||||
showOverlay();
|
||||
};
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
clearHideTimer();
|
||||
setOverlayVisible(false);
|
||||
};
|
||||
|
||||
const transition: CueTransition | null = outputState?.transition ?? null;
|
||||
|
||||
return (
|
||||
<main className="output-shell" onMouseMove={handlePointerMove} onMouseLeave={handlePointerLeave}>
|
||||
<SceneViewport
|
||||
presentation={outputState?.presentation ?? null}
|
||||
blackout={outputState?.blackout ?? false}
|
||||
transition={transition}
|
||||
activationKey={`${outputState?.presentationHash ?? "program-empty"}:${outputState?.outputRevision ?? 0}:${outputState?.blackout ? "blackout" : "live"}`}
|
||||
/>
|
||||
<div className={`output-overlay ${overlayVisible ? "output-overlay--visible" : ""}`}>
|
||||
<div>
|
||||
<p>Program Output</p>
|
||||
<strong>{outputState?.presentation?.label ?? "Awaiting cue"}</strong>
|
||||
<span>{outputState?.blackout ? "Blackout active" : "Move this window to the projector display."}</span>
|
||||
</div>
|
||||
<div className="output-overlay__actions">
|
||||
<button onClick={() => void enterFullscreen()}>{document.fullscreenElement ? "Exit fullscreen" : "Fullscreen"}</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setOverlayDismissed(true);
|
||||
clearHideTimer();
|
||||
setOverlayVisible(false);
|
||||
}}
|
||||
>
|
||||
Hide info
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,111 @@
|
||||
html.mode-output {
|
||||
--type-3xs: 0.58rem;
|
||||
--type-2xs: 0.62rem;
|
||||
--type-xs: 0.68rem;
|
||||
--type-sm: 0.74rem;
|
||||
--type-md: 0.8rem;
|
||||
--type-base: 0.84rem;
|
||||
--type-lg: 0.92rem;
|
||||
--type-xl: 1.1rem;
|
||||
}
|
||||
|
||||
html.mode-output,
|
||||
body.mode-output,
|
||||
body.mode-output #root {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
body.mode-output .output-shell {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
body.mode-output .output-shell .surface-viewport {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
body.mode-output .output-shell .surface-viewport__canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
body.mode-output .output-overlay {
|
||||
position: fixed;
|
||||
left: 24px;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: end;
|
||||
padding: 16px 18px;
|
||||
border-radius: 18px;
|
||||
background: rgba(8, 12, 16, 0.78);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: #f5f2ea;
|
||||
backdrop-filter: blur(18px);
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
pointer-events: none;
|
||||
transition: opacity 220ms ease, transform 220ms ease;
|
||||
}
|
||||
|
||||
body.mode-output .output-overlay--visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
body.mode-output .output-overlay p,
|
||||
body.mode-output .output-overlay strong,
|
||||
body.mode-output .output-overlay span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
body.mode-output .output-overlay p {
|
||||
margin: 0 0 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: var(--type-sm);
|
||||
color: #aeb7c0;
|
||||
}
|
||||
|
||||
body.mode-output .output-overlay strong {
|
||||
font-size: var(--type-xl);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
body.mode-output .output-overlay span {
|
||||
color: #aeb7c0;
|
||||
font-size: var(--type-md);
|
||||
}
|
||||
|
||||
body.mode-output .output-overlay__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
body.mode-output .output-overlay__actions button {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 999px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(22, 28, 34, 0.92);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: var(--type-sm);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { RenderSurface as RenderSurfaceType, SurfacePresentation } from "@goodgrief/render-engine";
|
||||
import type { CueTransition } from "@goodgrief/shared-types";
|
||||
import "./viewport.css";
|
||||
|
||||
interface SceneViewportProps {
|
||||
presentation: SurfacePresentation | null;
|
||||
blackout?: boolean;
|
||||
transition?: CueTransition | null;
|
||||
activationKey?: string;
|
||||
}
|
||||
|
||||
const defaultTransition: CueTransition = {
|
||||
style: "cut",
|
||||
durationMs: 0
|
||||
};
|
||||
|
||||
export const SceneViewport = ({ presentation, blackout = false, transition, activationKey }: SceneViewportProps) => {
|
||||
const frameRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const surfaceRef = useRef<RenderSurfaceType | null>(null);
|
||||
const presentationRef = useRef<SurfacePresentation | null>(presentation);
|
||||
const activationRef = useRef<string | undefined>(activationKey);
|
||||
const transitionRef = useRef<CueTransition | null | undefined>(transition);
|
||||
const blackoutRef = useRef(blackout);
|
||||
|
||||
presentationRef.current = presentation;
|
||||
activationRef.current = activationKey;
|
||||
transitionRef.current = transition;
|
||||
blackoutRef.current = blackout;
|
||||
|
||||
useEffect(() => {
|
||||
const frame = frameRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!frame || !canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let observer: ResizeObserver | null = null;
|
||||
|
||||
void import("@goodgrief/render-engine").then(({ RenderSurface, defaultScenePlugins }) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const surface = new RenderSurface(canvas);
|
||||
surface.registerMany(defaultScenePlugins);
|
||||
surface.setBlackout(blackoutRef.current);
|
||||
surfaceRef.current = surface;
|
||||
|
||||
const resize = () => {
|
||||
const { width, height } = frame.getBoundingClientRect();
|
||||
surface.setSize(Math.max(1, width), Math.max(1, height));
|
||||
};
|
||||
|
||||
resize();
|
||||
observer = new ResizeObserver(() => resize());
|
||||
observer.observe(frame);
|
||||
|
||||
const initialPresentation = presentationRef.current;
|
||||
if (initialPresentation) {
|
||||
void surface.activate(initialPresentation, defaultTransition);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
observer?.disconnect();
|
||||
surfaceRef.current?.dispose();
|
||||
surfaceRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
surfaceRef.current?.setBlackout(blackout);
|
||||
}, [blackout]);
|
||||
|
||||
useEffect(() => {
|
||||
const surface = surfaceRef.current;
|
||||
if (!surface) {
|
||||
return;
|
||||
}
|
||||
|
||||
void surface.activate(presentationRef.current, transitionRef.current ?? defaultTransition);
|
||||
}, [activationKey]);
|
||||
|
||||
return (
|
||||
<div ref={frameRef} className="surface-viewport">
|
||||
<canvas ref={canvasRef} className="surface-viewport__canvas" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import type {
|
||||
Cue,
|
||||
CueGeneratePayload,
|
||||
CueMovePayload,
|
||||
CueUpsertPayload,
|
||||
ModerationActionPayload,
|
||||
RepositoryState
|
||||
} from "@goodgrief/shared-types";
|
||||
|
||||
const postVoid = async (url: string, body?: unknown) => {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: body
|
||||
? {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
: undefined,
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed for ${url}.`);
|
||||
}
|
||||
};
|
||||
|
||||
const requestJson = async <T>(url: string, init?: RequestInit) => {
|
||||
const response = await fetch(url, init);
|
||||
if (!response.ok) {
|
||||
let message = `Request failed for ${url}.`;
|
||||
try {
|
||||
const payload = (await response.json()) as { message?: string };
|
||||
if (payload.message) {
|
||||
message = payload.message;
|
||||
}
|
||||
} catch {
|
||||
// ignore non-json errors
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
};
|
||||
|
||||
export const loadState = async (): Promise<RepositoryState> => {
|
||||
const response = await fetch("/api/state");
|
||||
if (!response.ok) {
|
||||
throw new Error("Could not load admin state.");
|
||||
}
|
||||
return (await response.json()) as RepositoryState;
|
||||
};
|
||||
|
||||
export const rescanLibrary = async (): Promise<RepositoryState> =>
|
||||
requestJson<RepositoryState>("/api/library/rescan", {
|
||||
method: "POST"
|
||||
});
|
||||
|
||||
export const moderateAsset = async (assetId: string, payload: ModerationActionPayload) => {
|
||||
await postVoid(`/api/assets/${assetId}/moderation`, payload);
|
||||
};
|
||||
|
||||
export const fireCue = async (cueId: string) => {
|
||||
await postVoid(`/api/cues/${cueId}/fire`);
|
||||
};
|
||||
|
||||
export const activateSafeCue = async (cueId: string) => {
|
||||
await postVoid(`/api/cues/${cueId}/safe`);
|
||||
};
|
||||
|
||||
export const createCue = async (payload: CueUpsertPayload) =>
|
||||
requestJson<Cue>("/api/cues", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
export const updateCue = async (cueId: string, payload: CueUpsertPayload) =>
|
||||
requestJson<Cue>(`/api/cues/${cueId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
export const moveCue = async (cueId: string, payload: CueMovePayload) => {
|
||||
await postVoid(`/api/cues/${cueId}/move`, payload);
|
||||
};
|
||||
|
||||
export const generateCue = async (payload: CueGeneratePayload) =>
|
||||
requestJson<CueUpsertPayload>("/api/cues/generate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
export const createAdminUpload = async (payload: FormData) =>
|
||||
requestJson<{ submission?: { id: string }; assetId: string }>("/api/admin/uploads", {
|
||||
method: "POST",
|
||||
body: payload
|
||||
});
|
||||
|
||||
export const deleteCue = async (cueId: string) => {
|
||||
const response = await fetch(`/api/cues/${cueId}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed for /api/cues/${cueId}.`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { SurfacePresentation } from "@goodgrief/render-engine";
|
||||
import { flattenSceneParams, type CueTransition } from "@goodgrief/shared-types";
|
||||
|
||||
export interface ProgramOutputState {
|
||||
presentation: SurfacePresentation | null;
|
||||
blackout: boolean;
|
||||
transition: CueTransition | null;
|
||||
presentationHash: string;
|
||||
outputRevision: number;
|
||||
updatedAt: string;
|
||||
takenAt: string;
|
||||
}
|
||||
|
||||
const storageKey = "goodgrief:program-output";
|
||||
const channelName = "goodgrief-program-output";
|
||||
|
||||
const canUseWindow = () => typeof window !== "undefined";
|
||||
|
||||
const createChannel = () =>
|
||||
canUseWindow() && "BroadcastChannel" in window ? new BroadcastChannel(channelName) : null;
|
||||
|
||||
const hashString = (input: string) => {
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
hash ^= input.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0).toString(16);
|
||||
};
|
||||
|
||||
export const createPresentationHash = (
|
||||
presentation: SurfacePresentation | null,
|
||||
blackout: boolean,
|
||||
transition: CueTransition | null
|
||||
) =>
|
||||
hashString(
|
||||
JSON.stringify({
|
||||
blackout,
|
||||
transition: transition
|
||||
? {
|
||||
style: transition.style,
|
||||
durationMs: transition.durationMs
|
||||
}
|
||||
: null,
|
||||
presentation: presentation
|
||||
? {
|
||||
cueId: presentation.cue?.id ?? null,
|
||||
definitionId: presentation.definition.id,
|
||||
effectPresetId: presentation.effectPresetId ?? null,
|
||||
modeKey: presentation.modeKey ?? null,
|
||||
assetIds: presentation.assets.map((asset) => asset.id),
|
||||
textFragments: presentation.textFragments ?? [],
|
||||
anchorCaption: presentation.anchorCaption ?? null,
|
||||
params: presentation.params ? flattenSceneParams(presentation.params) : null,
|
||||
label: presentation.label ?? null
|
||||
}
|
||||
: null
|
||||
})
|
||||
);
|
||||
|
||||
export const readProgramOutputState = (): ProgramOutputState | null => {
|
||||
if (!canUseWindow()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = window.localStorage.getItem(storageKey);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<ProgramOutputState>;
|
||||
return {
|
||||
presentation: parsed.presentation ?? null,
|
||||
blackout: parsed.blackout ?? false,
|
||||
transition: parsed.transition ?? null,
|
||||
presentationHash:
|
||||
parsed.presentationHash ?? createPresentationHash(parsed.presentation ?? null, parsed.blackout ?? false, parsed.transition ?? null),
|
||||
outputRevision: parsed.outputRevision ?? 0,
|
||||
updatedAt: parsed.updatedAt ?? new Date(0).toISOString(),
|
||||
takenAt: parsed.takenAt ?? parsed.updatedAt ?? new Date(0).toISOString()
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const writeProgramOutputState = (payload: ProgramOutputState) => {
|
||||
if (!canUseWindow()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(payload));
|
||||
const channel = createChannel();
|
||||
channel?.postMessage(payload);
|
||||
channel?.close();
|
||||
};
|
||||
|
||||
export const subscribeProgramOutput = (onMessage: (payload: ProgramOutputState) => void) => {
|
||||
if (!canUseWindow()) {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
const channel = createChannel();
|
||||
const storageHandler = (event: StorageEvent) => {
|
||||
if (event.key !== storageKey || !event.newValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
onMessage(JSON.parse(event.newValue) as ProgramOutputState);
|
||||
} catch {
|
||||
// ignore malformed storage state
|
||||
}
|
||||
};
|
||||
|
||||
channel?.addEventListener("message", (event) => {
|
||||
onMessage(event.data as ProgramOutputState);
|
||||
});
|
||||
window.addEventListener("storage", storageHandler);
|
||||
|
||||
return () => {
|
||||
channel?.close();
|
||||
window.removeEventListener("storage", storageHandler);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
.surface-viewport {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(244, 225, 208, 0.08);
|
||||
background:
|
||||
radial-gradient(circle at center, rgba(143, 170, 190, 0.08), transparent 52%),
|
||||
linear-gradient(180deg, rgba(9, 11, 14, 0.94), rgba(4, 5, 8, 0.98));
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.surface-viewport::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent 22%, transparent 78%, rgba(255, 255, 255, 0.02)),
|
||||
radial-gradient(circle at center, transparent 62%, rgba(0, 0, 0, 0.14));
|
||||
}
|
||||
|
||||
.surface-viewport__canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./app/App";
|
||||
import { ProgramOutputApp } from "./app/ProgramOutputApp";
|
||||
|
||||
const mode = new URLSearchParams(window.location.search).get("mode");
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
{mode === "output" ? <ProgramOutputApp /> : <App />}
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [
|
||||
"src",
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
const apiProxyTarget = process.env.VITE_API_PROXY_TARGET ?? "http://localhost:4300";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 4200,
|
||||
proxy: {
|
||||
"/api": apiProxyTarget,
|
||||
"/uploads": apiProxyTarget
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user