mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Widget playground mobile mode
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
Docker Emulator Test / docker (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Test / docker (push) Has been cancelled
Runs E2E API Tests / build (22.x) (push) Has been cancelled
Runs E2E API Tests with external source of truth / build (22.x) (push) Has been cancelled
Lint & build / lint_and_build (latest) (push) Has been cancelled
Dev Environment Test / restart-dev-and-test (push) Has been cancelled
Run setup tests / setup-tests (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
Docker Emulator Test / docker (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Test / docker (push) Has been cancelled
Runs E2E API Tests / build (22.x) (push) Has been cancelled
Runs E2E API Tests with external source of truth / build (22.x) (push) Has been cancelled
Lint & build / lint_and_build (latest) (push) Has been cancelled
Dev Environment Test / restart-dev-and-test (push) Has been cancelled
Run setup tests / setup-tests (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
This commit is contained in:
parent
c93086c379
commit
fe5db59c30
@ -12,3 +12,5 @@ NEXT_PUBLIC_STACK_SVIX_SERVER_URL=# For prod, leave it empty. For local developm
|
||||
NEXT_PUBLIC_STACK_HEAD_TAGS='[{ "tagName": "script", "attributes": {}, "innerHTML": "// insert head tags here" }]'
|
||||
STACK_DEVELOPMENT_TRANSLATION_LOCALE=# enter the locale to use for the translation provider here, for example: de-DE. Only works during development, not in production. Optional, by default don't translate
|
||||
NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS='["internal"]'
|
||||
|
||||
NEXT_PUBLIC_STACK_DEBUGGER_ON_ASSERTION_ERROR=# set to true to open the debugger on assertion errors (set to true in .env.development)
|
||||
|
||||
@ -6,3 +6,5 @@ STACK_SECRET_SERVER_KEY=this-secret-server-key-is-for-local-development-only
|
||||
|
||||
NEXT_PUBLIC_STACK_SVIX_SERVER_URL=http://localhost:8113
|
||||
STACK_ARTIFICIAL_DEVELOPMENT_DELAY_MS=50
|
||||
|
||||
NEXT_PUBLIC_STACK_DEBUGGER_ON_ASSERTION_ERROR=true
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { PacificaCard } from '@/components/pacifica/card';
|
||||
import { DndContext, pointerWithin, useDraggable, useDroppable } from '@dnd-kit/core';
|
||||
import useResizeObserver from '@react-hook/resize-observer';
|
||||
import { range } from '@stackframe/stack-shared/dist/utils/arrays';
|
||||
import { StackAssertionError, errorToNiceString, throwErr } from '@stackframe/stack-shared/dist/utils/errors';
|
||||
import { bundleJavaScript } from '@stackframe/stack-shared/dist/utils/esbuild';
|
||||
@ -16,7 +18,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { FaBorderNone, FaPen, FaPlus, FaTrash } from 'react-icons/fa';
|
||||
import * as jsxRuntime from 'react/jsx-runtime';
|
||||
import { PageLayout } from "../page-layout";
|
||||
import { useAdminApp } from '../use-admin-app';
|
||||
|
||||
type SerializedWidget = {
|
||||
version: 1,
|
||||
@ -51,11 +52,15 @@ async function compileWidgetSource(source: string): Promise<Result<string, strin
|
||||
function createErrorWidget(id: string, errorMessage: string): Widget<any, any> {
|
||||
return {
|
||||
id,
|
||||
MainComponent: () => <Card style={{ inset: '0', position: 'absolute', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ fontSize: '16px', fontWeight: 'bold', color: 'red', fontFamily: 'monospace', whiteSpace: 'pre-wrap' }}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
</Card>,
|
||||
MainComponent: () => (
|
||||
<PacificaCard
|
||||
style={{ inset: '0', position: 'absolute', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
<div style={{ fontSize: '16px', fontWeight: 'bold', color: 'red', fontFamily: 'monospace', whiteSpace: 'pre-wrap' }}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
</PacificaCard>
|
||||
),
|
||||
defaultSettings: null as any,
|
||||
defaultState: null as any,
|
||||
};
|
||||
@ -90,7 +95,7 @@ async function deserializeWidget(serializedWidget: SerializedWidget): Promise<Wi
|
||||
|
||||
type Widget<Settings, State> = {
|
||||
id: string,
|
||||
MainComponent: React.ComponentType<{ settings: Settings, state: State, stateRef: ReadonlyRef<State>, setState: (updater: (state: State) => State) => void, widthInGridUnits: number, heightInGridUnits: number }>,
|
||||
MainComponent: React.ComponentType<{ settings: Settings, state: State, stateRef: ReadonlyRef<State>, setState: (updater: (state: State) => State) => void, widthInGridUnits: number, heightInGridUnits: number, isMobileMode: boolean }>,
|
||||
SettingsComponent?: React.ComponentType<{ settings: Settings, setSettings: (updater: (settings: Settings) => Settings) => void }>,
|
||||
defaultSettings: Settings,
|
||||
defaultState: State,
|
||||
@ -153,13 +158,15 @@ class WidgetInstanceGrid {
|
||||
const width = options.width ?? 24;
|
||||
const height = options.height ?? "auto";
|
||||
|
||||
const nonEmptyElements = widgetInstances.map((instance, index) => ({
|
||||
instance,
|
||||
x: (index * WidgetInstanceGrid.DEFAULT_ELEMENT_WIDTH) % width,
|
||||
y: Math.floor(index / Math.floor(width / WidgetInstanceGrid.DEFAULT_ELEMENT_WIDTH)) * WidgetInstanceGrid.DEFAULT_ELEMENT_HEIGHT,
|
||||
width: WidgetInstanceGrid.DEFAULT_ELEMENT_WIDTH,
|
||||
height: WidgetInstanceGrid.DEFAULT_ELEMENT_HEIGHT,
|
||||
}));
|
||||
const nonEmptyElements = widgetInstances
|
||||
.map((instance, index) => ({
|
||||
instance,
|
||||
x: (index * WidgetInstanceGrid.DEFAULT_ELEMENT_WIDTH) % width,
|
||||
y: Math.floor(index / Math.floor(width / WidgetInstanceGrid.DEFAULT_ELEMENT_WIDTH)) * WidgetInstanceGrid.DEFAULT_ELEMENT_HEIGHT,
|
||||
width: WidgetInstanceGrid.DEFAULT_ELEMENT_WIDTH,
|
||||
height: WidgetInstanceGrid.DEFAULT_ELEMENT_HEIGHT,
|
||||
}))
|
||||
.sort((a, b) => Math.sign(a.x - b.x) + 0.1 * Math.sign(a.y - b.y));
|
||||
|
||||
// Do some sanity checks to prevent bugs early
|
||||
for (const element of nonEmptyElements) {
|
||||
@ -470,8 +477,8 @@ class WidgetInstanceGrid {
|
||||
edgesDelta.bottom !== 0 ? this.clampResize(x, y, { ...edgesDelta, bottom: decr(edgesDelta.bottom) }) : null,
|
||||
edgesDelta.right !== 0 ? this.clampResize(x, y, { ...edgesDelta, right: decr(edgesDelta.right) }) : null,
|
||||
].filter(isNotNull);
|
||||
let maxScore = -1;
|
||||
let bestCandidate: { top: number, left: number, bottom: number, right: number } | null = null;
|
||||
let maxScore = 0;
|
||||
let bestCandidate: { top: number, left: number, bottom: number, right: number } = { top: 0, left: 0, bottom: 0, right: 0 };
|
||||
for (const candidate of candidates) {
|
||||
const score = Math.abs(candidate.top) + Math.abs(candidate.left) + Math.abs(candidate.bottom) + Math.abs(candidate.right);
|
||||
if (score > maxScore) {
|
||||
@ -479,9 +486,6 @@ class WidgetInstanceGrid {
|
||||
bestCandidate = candidate;
|
||||
}
|
||||
}
|
||||
if (!bestCandidate) {
|
||||
throw new StackAssertionError(`No candidate found for ${cacheKey}`);
|
||||
}
|
||||
this._clampResizeCache.set(cacheKey, bestCandidate);
|
||||
}
|
||||
}
|
||||
@ -554,7 +558,7 @@ class WidgetInstanceGrid {
|
||||
const widgets: Widget<any, any>[] = [
|
||||
{
|
||||
id: "$sub-grid",
|
||||
MainComponent: ({ widthInGridUnits, heightInGridUnits, state, stateRef, setState }) => {
|
||||
MainComponent: ({ widthInGridUnits, heightInGridUnits, state, stateRef, setState, isMobileMode }) => {
|
||||
const widgetGridRef = mapRef(stateRef, (state) => WidgetInstanceGrid.fromSerialized(state.serializedGrid));
|
||||
const [color] = useState("#" + Math.floor(Math.random() * 16777215).toString(16) + "22");
|
||||
|
||||
@ -575,6 +579,7 @@ const widgets: Widget<any, any>[] = [
|
||||
return (
|
||||
<div style={{ backgroundColor: color, padding: '16px' }}>
|
||||
<SwappableWidgetInstanceGrid
|
||||
isMobileMode={isMobileMode}
|
||||
gridRef={widgetGridRef}
|
||||
setGrid={setWidgetGrid}
|
||||
/>
|
||||
@ -615,11 +620,13 @@ const widgets: Widget<any, any>[] = [
|
||||
|
||||
export const defaultSettings = {name: "world"};
|
||||
`);
|
||||
const stackAdminApp = useAdminApp();
|
||||
const [compilationResult, setCompilationResult] = useState<Result<string, string> | null>(null);
|
||||
|
||||
return (
|
||||
<Card style={{ inset: '0', position: 'absolute' }}>
|
||||
<PacificaCard
|
||||
title="Widget builder"
|
||||
subtitle="This is a subtitle"
|
||||
>
|
||||
<textarea value={source} onChange={(e) => setSource(e.target.value)} style={{ width: '100%', height: '35%', fontFamily: "monospace" }} />
|
||||
<Button onClick={async () => {
|
||||
const result = await compileWidgetSource(source);
|
||||
@ -644,7 +651,7 @@ const widgets: Widget<any, any>[] = [
|
||||
{compilationResult.error}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</PacificaCard>
|
||||
);
|
||||
},
|
||||
defaultSettings: {},
|
||||
@ -652,12 +659,18 @@ const widgets: Widget<any, any>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const widgetInstances: WidgetInstance<any>[] = [];
|
||||
|
||||
const gridGapPixels = 32;
|
||||
const mobileModeWidgetHeight = 384;
|
||||
const mobileModeCutoffWidth = 768;
|
||||
|
||||
export default function PageClient() {
|
||||
const [widgetGridRef, setWidgetGrid] = useInstantState(WidgetInstanceGrid.fromWidgetInstances(widgetInstances));
|
||||
const [widgetGridRef, setWidgetGrid] = useInstantState(WidgetInstanceGrid.fromWidgetInstances(widgets.map((w, i) => ({
|
||||
id: "initial" + i,
|
||||
settingsOrUndefined: undefined,
|
||||
stateOrUndefined: undefined,
|
||||
widget: w,
|
||||
}))));
|
||||
const [isAltDown, setIsAltDown] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@ -685,7 +698,7 @@ export default function PageClient() {
|
||||
fillWidth
|
||||
>
|
||||
<SwappableWidgetInstanceGridContext.Provider value={{ isEditing: isAltDown }}>
|
||||
<SwappableWidgetInstanceGrid gridRef={widgetGridRef} setGrid={setWidgetGrid} />
|
||||
<SwappableWidgetInstanceGrid gridRef={widgetGridRef} setGrid={setWidgetGrid} isMobileMode="auto" />
|
||||
</SwappableWidgetInstanceGridContext.Provider>
|
||||
</PageLayout>
|
||||
);
|
||||
@ -697,12 +710,24 @@ const SwappableWidgetInstanceGridContext = React.createContext<{
|
||||
isEditing: false,
|
||||
});
|
||||
|
||||
function SwappableWidgetInstanceGrid(props: { gridRef: ReadonlyRef<WidgetInstanceGrid>, setGrid: (grid: WidgetInstanceGrid) => void }) {
|
||||
function SwappableWidgetInstanceGrid(props: { gridRef: ReadonlyRef<WidgetInstanceGrid>, setGrid: (grid: WidgetInstanceGrid) => void, isMobileMode: boolean | "auto" }) {
|
||||
const [overElementPosition, setOverElementPosition] = useState<[number, number] | null>(null);
|
||||
const [hoverSwap, setHoverSwap] = useState<[string, [number, number, number, number, number, number]] | null>(null);
|
||||
const [activeWidgetId, setActiveWidgetId] = useState<string | null>(null);
|
||||
const gridContainerRef = useRef<HTMLDivElement>(null);
|
||||
const context = React.use(SwappableWidgetInstanceGridContext);
|
||||
const [isMobileModeIfAuto, setMobileModeIfAuto] = useState<boolean>(false);
|
||||
|
||||
useResizeObserver(gridContainerRef, (entry, observer) => {
|
||||
const shouldBeMobileMode = entry.contentRect.width < mobileModeCutoffWidth;
|
||||
if (isMobileModeIfAuto !== shouldBeMobileMode) {
|
||||
setMobileModeIfAuto(shouldBeMobileMode);
|
||||
}
|
||||
});
|
||||
|
||||
const isMobileMode = props.isMobileMode === "auto" ? isMobileModeIfAuto : props.isMobileMode;
|
||||
|
||||
let hasAlreadyRenderedEmpty = false;
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
@ -771,21 +796,35 @@ function SwappableWidgetInstanceGrid(props: { gridRef: ReadonlyRef<WidgetInstanc
|
||||
<div
|
||||
ref={gridContainerRef}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${props.gridRef.current.width}, 1fr)`,
|
||||
gridTemplateRows: `repeat(${props.gridRef.current.height}, 1fr)`,
|
||||
...isMobileMode ? {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
} : {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${props.gridRef.current.width}, 1fr)`,
|
||||
gridTemplateRows: `repeat(${props.gridRef.current.height}, 1fr)`,
|
||||
},
|
||||
gap: gridGapPixels,
|
||||
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
overflow: 'none',
|
||||
}}
|
||||
>
|
||||
{range(props.gridRef.current.height).map((y) => (
|
||||
{!isMobileMode && range(props.gridRef.current.height).map((y) => (
|
||||
<div key={y} style={{ height: '16px', gridColumn: `1 / ${props.gridRef.current.width + 1}`, gridRow: `${y + 1} / ${y + 2}` }} />
|
||||
))}
|
||||
{[...props.gridRef.current].map(({ instance, x, y, width, height }) => {
|
||||
const isHoverSwap = !!hoverSwap && !!instance && (hoverSwap[0] === instance.id);
|
||||
|
||||
if (isMobileMode && !instance) {
|
||||
if (hasAlreadyRenderedEmpty) return null;
|
||||
hasAlreadyRenderedEmpty = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<Droppable
|
||||
isMobileMode={isMobileMode}
|
||||
key={instance?.id ?? JSON.stringify({ x, y })}
|
||||
isEmpty={!instance}
|
||||
isOver={overElementPosition?.[0] === x && overElementPosition[1] === y}
|
||||
@ -808,6 +847,7 @@ function SwappableWidgetInstanceGrid(props: { gridRef: ReadonlyRef<WidgetInstanc
|
||||
width: isHoverSwap ? `${hoverSwap[1][2]}px` : (hoverSwap && activeWidgetId === instance.id ? `${hoverSwap[1][4]}px` : undefined),
|
||||
height: isHoverSwap ? `${hoverSwap[1][3]}px` : (hoverSwap && activeWidgetId === instance.id ? `${hoverSwap[1][5]}px` : undefined),
|
||||
}}
|
||||
isMobileMode={isMobileMode}
|
||||
onDeleteWidget={async () => {
|
||||
props.setGrid(props.gridRef.current.withRemoved(x, y));
|
||||
}}
|
||||
@ -853,7 +893,7 @@ function SwappableWidgetInstanceGrid(props: { gridRef: ReadonlyRef<WidgetInstanc
|
||||
);
|
||||
}
|
||||
|
||||
function Droppable(props: { isOver: boolean, children: React.ReactNode, style?: React.CSSProperties, x: number, y: number, width: number, height: number, isEmpty: boolean, grid: WidgetInstanceGrid, onAddWidget: () => void }) {
|
||||
function Droppable(props: { isMobileMode: boolean, isOver: boolean, children: React.ReactNode, style?: React.CSSProperties, x: number, y: number, width: number, height: number, isEmpty: boolean, grid: WidgetInstanceGrid, onAddWidget: () => void }) {
|
||||
const { setNodeRef, active } = useDroppable({
|
||||
id: JSON.stringify([props.x, props.y]),
|
||||
});
|
||||
@ -870,18 +910,19 @@ function Droppable(props: { isOver: boolean, children: React.ReactNode, style?:
|
||||
borderRadius: '8px',
|
||||
gridColumn: `${props.x + 1} / span ${props.width}`,
|
||||
gridRow: `${props.y + 1} / span ${props.height}`,
|
||||
minHeight: props.isMobileMode ? mobileModeWidgetHeight : undefined,
|
||||
...props.style,
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes stack-animation-fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
@keyframes stack-animation-fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
{shouldRenderAddWidget && (<>
|
||||
<div
|
||||
@ -918,6 +959,7 @@ function Draggable(props: {
|
||||
height: number,
|
||||
activeWidgetId: string | null,
|
||||
isEditing: boolean,
|
||||
isMobileMode: boolean,
|
||||
onDeleteWidget: () => Promise<void>,
|
||||
settings: any,
|
||||
setSettings: (settings: any) => Promise<void>,
|
||||
@ -1047,9 +1089,8 @@ function Draggable(props: {
|
||||
ref={setNodeRef}
|
||||
className="stack-recursive-backface-hidden"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
minWidth: '100%',
|
||||
minHeight: '100%',
|
||||
display: 'flex',
|
||||
|
||||
zIndex: isDragging ? 100000 : undefined,
|
||||
@ -1067,8 +1108,14 @@ function Draggable(props: {
|
||||
<div
|
||||
className={cn(isDragging && 'bg-white dark:bg-black border-black/20 dark:border-white/20')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
...props.isMobileMode ? {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
} : {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
},
|
||||
flexGrow: 1,
|
||||
alignSelf: 'stretch',
|
||||
boxShadow: isEditing ? '0 0 32px 0 #8882' : '0 0 0 0 transparent',
|
||||
@ -1087,15 +1134,24 @@ function Draggable(props: {
|
||||
isDeleting ? 'scale(0.8)' : '',
|
||||
].filter(Boolean).join(' '),
|
||||
opacity: isDeleting ? 0 : 1,
|
||||
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-pacifica-children-flex-grow
|
||||
data-pacifica-children-min-width-0
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
}}
|
||||
>
|
||||
<SwappableWidgetInstanceGridContext.Provider value={{ isEditing: isEditingSubGrid }}>
|
||||
<props.widgetInstance.widget.MainComponent
|
||||
settings={props.widgetInstance.settingsOrUndefined}
|
||||
isMobileMode={props.isMobileMode}
|
||||
state={props.state}
|
||||
stateRef={props.stateRef}
|
||||
setState={props.setState}
|
||||
@ -1113,15 +1169,17 @@ function Draggable(props: {
|
||||
transition: 'opacity 0.2s ease',
|
||||
// note: Safari has a weird display glitch with transparent background images when animating opacity in a parent element, so we just don't render it while deleting
|
||||
backgroundImage: !isDeleting ? 'radial-gradient(circle at top, #ffffff08, #ffffff02), radial-gradient(circle at top right, #ffffff04, transparent, transparent)' : undefined,
|
||||
borderRadius: 'inherit',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
inert
|
||||
className={cn(isEditing && !isDragging && "bg-white/20 dark:bg-black/20")}
|
||||
className={cn(isEditing && !isDragging && "bg-white/50 dark:bg-black/50")}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backdropFilter: isEditing && !isDragging ? 'blur(4px)' : 'none',
|
||||
backdropFilter: isEditing && !isDragging ? 'drop-shadow(0 0 2px) blur(4px)' : 'none',
|
||||
borderRadius: 'inherit',
|
||||
}}
|
||||
/>
|
||||
{!isDragging && (
|
||||
@ -1182,7 +1240,7 @@ function Draggable(props: {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{[-1, 0, 1].flatMap(x => [-1, 0, 1].map(y => (x !== 0 || y !== 0) && (
|
||||
{!props.isMobileMode && [-1, 0, 1].flatMap(x => [-1, 0, 1].map(y => (x !== 0 || y !== 0) && (
|
||||
<ResizeHandle
|
||||
key={`${x},${y}`}
|
||||
widgetInstance={props.widgetInstance}
|
||||
|
||||
@ -183,3 +183,28 @@ body:has(.show-site-loading-indicator) .site-loading-indicator {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Pacifica styles */
|
||||
[data-pacifica-surface] {
|
||||
backdrop-filter: contrast(40%) blur(24px);
|
||||
background-color: hsl(var(--background), 0.2);
|
||||
color: hsl(var(--card-foreground));
|
||||
}
|
||||
|
||||
.dark [data-pacifica-surface] {
|
||||
background-image: radial-gradient(circle at top, #ffffff0d, #ffffff04), radial-gradient(circle at top right, #ffffff04, transparent, transparent);
|
||||
}
|
||||
|
||||
[data-pacifica-border] {
|
||||
border: 2px solid rgba(127, 127, 127, 0.2);
|
||||
box-shadow: 0 0 3px 3px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
:where([data-pacifica-children-flex-grow] > *) {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
:where([data-pacifica-children-min-width-0] > *) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
131
apps/dashboard/src/components/pacifica/card.tsx
Normal file
131
apps/dashboard/src/components/pacifica/card.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
import { componentWrapper, forwardRefIfNeeded } from "@stackframe/stack-shared/dist/utils/react";
|
||||
|
||||
import { filterUndefined } from "@stackframe/stack-shared/dist/utils/objects";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { PacificaSurface } from "./surface";
|
||||
|
||||
const PacificaCard = componentWrapper<
|
||||
typeof PacificaSurface,
|
||||
{
|
||||
title?: React.ReactNode,
|
||||
subtitle?: React.ReactNode,
|
||||
header?: React.ReactNode,
|
||||
footer?: React.ReactNode,
|
||||
innerProps?: Omit<React.ComponentPropsWithRef<"div">, "children">,
|
||||
}
|
||||
>("PacificaCard", ({ className, title, subtitle, header, footer, children, innerProps, ...props }, ref) => {
|
||||
const fullHeader = (title || subtitle || header || footer) && <>
|
||||
{header}
|
||||
{title && (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight capitalize", className)}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{subtitle && (
|
||||
<h4
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
>
|
||||
{subtitle}
|
||||
</h4>
|
||||
)}
|
||||
</>;
|
||||
return (
|
||||
<PacificaSurface
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl",
|
||||
className
|
||||
)}
|
||||
{...filterUndefined(props)}
|
||||
>
|
||||
<div
|
||||
className="p-6 overflow-y-auto rounded-[inherit] flex-grow-1"
|
||||
data-pacifica-border
|
||||
>
|
||||
{fullHeader && (
|
||||
<div className="flex flex-col space-y-0.5 pb-4">
|
||||
{fullHeader}
|
||||
</div>
|
||||
)}
|
||||
<div {...innerProps}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</PacificaSurface>
|
||||
);
|
||||
});
|
||||
PacificaCard.displayName = "PacificaCard";
|
||||
|
||||
const PacificaCardHeader = forwardRefIfNeeded<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6 pb-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
PacificaCardHeader.displayName = "PacificaCardHeader";
|
||||
|
||||
const PacificaCardTitle = forwardRefIfNeeded<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight capitalize", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
PacificaCardTitle.displayName = "PacificaCardTitle";
|
||||
|
||||
const PacificaCardDescription = forwardRefIfNeeded<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
PacificaCardDescription.displayName = "PacificaCardDescription";
|
||||
|
||||
const PacificaCardContent = forwardRefIfNeeded<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6", className)} {...props} />
|
||||
));
|
||||
PacificaCardContent.displayName = "PacificaCardContent";
|
||||
|
||||
const PacificaCardSubtitle = forwardRefIfNeeded<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h4
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground font-bold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
const PacificaCardFooter = forwardRefIfNeeded<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
PacificaCardFooter.displayName = "PacificaCardFooter";
|
||||
|
||||
export { PacificaCard, PacificaCardContent, PacificaCardDescription, PacificaCardFooter, PacificaCardHeader, PacificaCardSubtitle, PacificaCardTitle };
|
||||
|
||||
26
apps/dashboard/src/components/pacifica/surface.tsx
Normal file
26
apps/dashboard/src/components/pacifica/surface.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { componentWrapper } from "@stackframe/stack-shared/dist/utils/react";
|
||||
import { cn } from "../../../../../packages/stack-ui/dist/lib/utils";
|
||||
|
||||
export const PacificaSurface = componentWrapper<
|
||||
"div",
|
||||
{}
|
||||
>("PacificaSurface", ({ children, className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-pacifica-children-flex-grow
|
||||
className={cn("relative flex flex-col stretch min-h-0", className)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 rounded-[inherit]"
|
||||
style={{
|
||||
zIndex: -9999999,
|
||||
}}
|
||||
inert
|
||||
data-pacifica-surface
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@ -75,6 +75,10 @@ export class StackAssertionError extends Error {
|
||||
},
|
||||
enumerable: false,
|
||||
});
|
||||
|
||||
if (process.env.NEXT_PUBLIC_STACK_DEBUGGER_ON_ASSERTION_ERROR === "true") {
|
||||
debugger;
|
||||
}
|
||||
}
|
||||
}
|
||||
StackAssertionError.prototype.name = "StackAssertionError";
|
||||
|
||||
@ -3,6 +3,17 @@ import { isBrowserLike } from "./env";
|
||||
import { neverResolve } from "./promises";
|
||||
import { deindent } from "./strings";
|
||||
|
||||
export function componentWrapper<
|
||||
C extends React.ComponentType<any> | keyof React.JSX.IntrinsicElements,
|
||||
ExtraProps extends {} = {}
|
||||
>(displayName: string, render: React.ForwardRefRenderFunction<RefFromComponent<C>, React.ComponentPropsWithRef<C> & ExtraProps>) {
|
||||
const Component = forwardRefIfNeeded(render);
|
||||
Component.displayName = displayName;
|
||||
return Component;
|
||||
}
|
||||
type RefFromComponent<C extends React.ComponentType<any> | keyof React.JSX.IntrinsicElements> = NonNullable<RefFromComponentDistCond<React.ComponentPropsWithRef<C>["ref"]>>;
|
||||
type RefFromComponentDistCond<A> = A extends React.RefObject<infer T> ? T : never; // distributive conditional type; see https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types
|
||||
|
||||
export function forwardRefIfNeeded<T, P = {}>(render: React.ForwardRefRenderFunction<T, P>): React.FC<P & { ref?: React.Ref<T> }> {
|
||||
// TODO: when we drop support for react 18, remove this
|
||||
|
||||
@ -14,38 +25,6 @@ export function forwardRefIfNeeded<T, P = {}>(render: React.ForwardRefRenderFunc
|
||||
return ((props: P) => render(props, (props as any).ref)) as any;
|
||||
}
|
||||
}
|
||||
import.meta.vitest?.test("forwardRefIfNeeded", ({ expect }) => {
|
||||
// Mock React.version and React.forwardRef
|
||||
const originalVersion = React.version;
|
||||
const originalForwardRef = React.forwardRef;
|
||||
|
||||
try {
|
||||
// Test with React version < 19
|
||||
Object.defineProperty(React, 'version', { value: '18.2.0', writable: true });
|
||||
|
||||
// Create a render function
|
||||
const renderFn = (props: any, ref: any) => null;
|
||||
|
||||
// Call forwardRefIfNeeded
|
||||
const result = forwardRefIfNeeded(renderFn);
|
||||
|
||||
// Verify the function returns something
|
||||
expect(result).toBeDefined();
|
||||
|
||||
// Test with React version >= 19
|
||||
Object.defineProperty(React, 'version', { value: '19.0.0', writable: true });
|
||||
|
||||
// Call forwardRefIfNeeded again with React 19
|
||||
const result19 = forwardRefIfNeeded(renderFn);
|
||||
|
||||
// Verify the function returns something
|
||||
expect(result19).toBeDefined();
|
||||
} finally {
|
||||
// Restore original values
|
||||
Object.defineProperty(React, 'version', { value: originalVersion });
|
||||
React.forwardRef = originalForwardRef;
|
||||
}
|
||||
});
|
||||
|
||||
export function getNodeText(node: React.ReactNode): string {
|
||||
if (["number", "string"].includes(typeof node)) {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors';
|
||||
import { writeFileSyncIfChanged } from '@stackframe/stack-shared/dist/utils/fs';
|
||||
import { replaceAll } from '@stackframe/stack-shared/dist/utils/strings';
|
||||
import autoprefixer from 'autoprefixer';
|
||||
@ -6,8 +7,7 @@ import * as path from 'path';
|
||||
import postcss from 'postcss';
|
||||
import postcssNested from 'postcss-nested';
|
||||
|
||||
const sentinel = '--stack-sentinel--';
|
||||
const scopeName = 'stack-scope'
|
||||
const scopeSelector = ':where(.stack-scope)';
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
@ -23,26 +23,26 @@ async function main() {
|
||||
let content = fs.readFileSync(inputPath, 'utf8');
|
||||
|
||||
// set the scope and sentinel, sentinel is used for same level selectors later
|
||||
content = `.${scopeName}, .${sentinel} {\n${content}\n}`;
|
||||
content = `${scopeSelector}, .--stack-sentinel-- {\n${content}\n}`;
|
||||
|
||||
// use postcss to nest the scope
|
||||
content = await postcss([autoprefixer, postcssNested]).process(content, { from: undefined }).css;
|
||||
|
||||
// swap the case like .scope img to img.scope
|
||||
content = content.replace(/(\.--stack-sentinel--\s)([*a-zA-Z0-9\-]+)([^,{\n]*)/g, `$2.${scopeName}$3`)
|
||||
content = content.replace(/(\.--stack-sentinel--\s)([*a-zA-Z0-9\-]+)([^,{\n]*)/g, `$2${scopeSelector}$3`)
|
||||
|
||||
// swap the case like .scope [data-foo="bar"] to [data-foo="bar"] .scope
|
||||
content = content.replace(/(\.--stack-sentinel--\s)(\[.*?\])([^,{\n]*)/g, `$2 .${scopeName}$3`)
|
||||
content = content.replace(/(\.--stack-sentinel--\s)(\[.*?\])([^,{\n]*)/g, `$2 ${scopeSelector}$3`)
|
||||
|
||||
// replace the remaining sentinels
|
||||
content = replaceAll(content, sentinel + ' ', scopeName);
|
||||
content = replaceAll(content, '.--stack-sentinel-- ', scopeSelector);
|
||||
|
||||
// remove all :root
|
||||
content = replaceAll(content, ':root', '');
|
||||
|
||||
// double check that all sentinels were replaced
|
||||
if (content.includes(sentinel)) {
|
||||
throw new Error('Sentinel not replaced');
|
||||
if (content.includes('--stack-sentinel--')) {
|
||||
throw new StackAssertionError('Sentinel not replaced', { content });
|
||||
}
|
||||
|
||||
// output css file for debugging
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@stackframe/stack-ui';
|
||||
import { KeyRound } from 'lucide-react';
|
||||
import { useId } from 'react';
|
||||
import { useStackApp } from '..';
|
||||
import { useTranslation } from '../lib/translations';
|
||||
import { KeyRound } from 'lucide-react';
|
||||
|
||||
|
||||
export function PasskeyButton({
|
||||
|
||||
@ -66,12 +66,13 @@ function convertColorsToCSS(theme: Theme) {
|
||||
}
|
||||
|
||||
return deindent`
|
||||
.stack-scope {
|
||||
${colorsToCSSVars(colors.light)}
|
||||
}
|
||||
html:has(head > [data-stack-theme="dark"]) .stack-scope {
|
||||
${colorsToCSSVars(colors.dark)}
|
||||
}`;
|
||||
.stack-scope {
|
||||
${colorsToCSSVars(colors.light)}
|
||||
}
|
||||
html:has(head > [data-stack-theme="dark"]) .stack-scope {
|
||||
${colorsToCSSVars(colors.dark)}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user