@pablozaiden/webapp 0.5.8 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -3
- package/docs/auth-validation.md +11 -0
- package/docs/auth.md +25 -0
- package/docs/cli.md +73 -3
- package/docs/deployment.md +84 -7
- package/docs/getting-started.md +69 -6
- package/docs/github-actions.md +39 -0
- package/docs/release.md +5 -5
- package/docs/server.md +34 -4
- package/docs/settings.md +5 -0
- package/docs/ui-guidelines.md +12 -4
- package/package.json +2 -4
- package/src/build/build-binary.ts +67 -24
- package/src/cli/api-command.ts +53 -16
- package/src/cli/credentials.ts +275 -4
- package/src/cli/device-auth.ts +83 -22
- package/src/cli/environment-auth.ts +57 -0
- package/src/cli/index.ts +1 -0
- package/src/package-resolution.ts +34 -0
- package/src/server/auth/device-auth.ts +2 -2
- package/src/server/auth/passkeys.ts +16 -16
- package/src/server/auth/request-origin.ts +97 -16
- package/src/server/authentication.ts +265 -0
- package/src/server/create-web-app-server.ts +85 -1391
- package/src/server/framework-endpoints.ts +451 -0
- package/src/server/public-route-dispatch.ts +85 -0
- package/src/server/request-schemas.ts +144 -0
- package/src/server/responses.ts +69 -3
- package/src/server/route-dispatch.ts +109 -0
- package/src/server/runtime-config.ts +67 -0
- package/src/server/same-origin.ts +1 -1
- package/src/server/server-lifecycle.ts +138 -0
- package/src/server/server-types.ts +87 -0
- package/src/server/web-document.ts +532 -0
- package/src/web/WebAppRoot.tsx +69 -1214
- package/src/web/api-client.ts +14 -4
- package/src/web/app-shell.tsx +198 -0
- package/src/web/auth-screens.tsx +186 -0
- package/src/web/components/index.tsx +10 -3
- package/src/web/index.ts +1 -0
- package/src/web/mobile-hooks.ts +194 -0
- package/src/web/mobile.ts +12 -0
- package/src/web/root-types.ts +61 -0
- package/src/web/routing.ts +61 -0
- package/src/web/settings/account-section.tsx +52 -0
- package/src/web/settings/resource-state.tsx +17 -0
- package/src/web/settings/security-section.tsx +119 -0
- package/src/web/settings/sessions-section.tsx +66 -0
- package/src/web/settings/settings-view.tsx +96 -0
- package/src/web/settings/shutdown-section.tsx +95 -0
- package/src/web/settings/user-management.tsx +123 -0
- package/src/web/settings-view.tsx +3 -0
- package/src/web/sidebar-state.ts +108 -0
- package/src/web/sidebar-tree.tsx +89 -0
- package/src/web/styles.css +76 -64
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import type { CreatedUserResponse, WebAppConfigResponse, WebAppUserRole, WebAppUserSummary } from "../../contracts";
|
|
3
|
+
import { appJson } from "../api-client";
|
|
4
|
+
import { Badge, Button, ConfirmDialog, EmptyState, FormSection, SelectField, TextField } from "../components";
|
|
5
|
+
import { useLiveQuery } from "../realtime/useRealtime";
|
|
6
|
+
import { ResourceState } from "./resource-state";
|
|
7
|
+
|
|
8
|
+
export function UserManagement({ config }: { config: WebAppConfigResponse }) {
|
|
9
|
+
const [username, setUsername] = useState("");
|
|
10
|
+
const [role, setRole] = useState<WebAppUserRole>("user");
|
|
11
|
+
const [setupLink, setSetupLink] = useState<string>();
|
|
12
|
+
const [userToDelete, setUserToDelete] = useState<WebAppUserSummary>();
|
|
13
|
+
const [error, setError] = useState<string>();
|
|
14
|
+
|
|
15
|
+
const loadUsers = useCallback(
|
|
16
|
+
() => config.userManagement.canManageUsers
|
|
17
|
+
? appJson<WebAppUserSummary[]>("/api/users")
|
|
18
|
+
: Promise.resolve([]),
|
|
19
|
+
[config.userManagement.canManageUsers],
|
|
20
|
+
);
|
|
21
|
+
const { data: users, error: usersLoadError, loading: usersLoading, refresh: refreshUsers } = useLiveQuery<WebAppUserSummary[]>({ load: loadUsers, realtime: false });
|
|
22
|
+
|
|
23
|
+
async function createUser() {
|
|
24
|
+
try {
|
|
25
|
+
setError(undefined);
|
|
26
|
+
const result = await appJson<CreatedUserResponse>("/api/users", { method: "POST", body: JSON.stringify({ username, role }) });
|
|
27
|
+
setUsername("");
|
|
28
|
+
setRole("user");
|
|
29
|
+
setSetupLink(result.setupLink.url);
|
|
30
|
+
await refreshUsers();
|
|
31
|
+
} catch (err) {
|
|
32
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function updateRole(user: WebAppUserSummary, nextRole: WebAppUserRole) {
|
|
37
|
+
try {
|
|
38
|
+
setError(undefined);
|
|
39
|
+
await appJson(`/api/users/${encodeURIComponent(user.id)}/role`, { method: "PATCH", body: JSON.stringify({ role: nextRole }) });
|
|
40
|
+
await refreshUsers();
|
|
41
|
+
} catch (err) {
|
|
42
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function resetUser(user: WebAppUserSummary) {
|
|
47
|
+
try {
|
|
48
|
+
setError(undefined);
|
|
49
|
+
const result = await appJson<CreatedUserResponse>(`/api/users/${encodeURIComponent(user.id)}/reset`, { method: "POST", body: "{}" });
|
|
50
|
+
setSetupLink(result.setupLink.url);
|
|
51
|
+
await refreshUsers();
|
|
52
|
+
} catch (err) {
|
|
53
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function deleteUser(user: WebAppUserSummary) {
|
|
58
|
+
try {
|
|
59
|
+
setError(undefined);
|
|
60
|
+
await appJson(`/api/users/${encodeURIComponent(user.id)}`, { method: "DELETE" });
|
|
61
|
+
setUserToDelete(undefined);
|
|
62
|
+
await refreshUsers();
|
|
63
|
+
} catch (err) {
|
|
64
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!config.userManagement.canManageUsers) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return (
|
|
72
|
+
<FormSection title="User management" description="Create users, reset passkeys, and manage admin access.">
|
|
73
|
+
{error ? <p className="wapp-error">{error}</p> : null}
|
|
74
|
+
<br />
|
|
75
|
+
<div className="wapp-settings-row stacked">
|
|
76
|
+
<div>
|
|
77
|
+
<TextField label="Username" value={username} onChange={(event) => setUsername(event.currentTarget.value)} placeholder="new-user" />
|
|
78
|
+
<br />
|
|
79
|
+
<SelectField label="Role" value={role} onChange={(event) => setRole(event.currentTarget.value as WebAppUserRole)}>
|
|
80
|
+
<option value="user">User</option>
|
|
81
|
+
<option value="admin">Admin</option>
|
|
82
|
+
</SelectField>
|
|
83
|
+
</div>
|
|
84
|
+
<br />
|
|
85
|
+
<div className="wapp-row-actions"><Button type="button" variant="primary" disabled={!username.trim()} onClick={() => void createUser()}>Create setup link</Button></div>
|
|
86
|
+
{setupLink ? <code className="wapp-token">{setupLink}</code> : null}
|
|
87
|
+
</div>
|
|
88
|
+
<br />
|
|
89
|
+
<ResourceState loading={usersLoading} error={usersLoadError} hasData={users !== undefined} refresh={refreshUsers} />
|
|
90
|
+
{users?.length ? (
|
|
91
|
+
<div className="wapp-list">
|
|
92
|
+
{users.map((user) => (
|
|
93
|
+
<div className="wapp-list-row" key={user.id}>
|
|
94
|
+
<span>
|
|
95
|
+
<strong>{user.username}</strong>
|
|
96
|
+
<small>{user.role} · passkey {user.passkeyConfigured ? "configured" : "pending"} · created {user.createdAt}</small>
|
|
97
|
+
</span>
|
|
98
|
+
<div className="wapp-row-actions">
|
|
99
|
+
{user.role !== "owner" ? (
|
|
100
|
+
<select className="wapp-inline-select" aria-label={`Role for ${user.username}`} value={user.role} onChange={(event) => void updateRole(user, event.currentTarget.value as WebAppUserRole)}>
|
|
101
|
+
<option value="user">User</option>
|
|
102
|
+
<option value="admin">Admin</option>
|
|
103
|
+
</select>
|
|
104
|
+
) : <Badge variant="success">Owner</Badge>}
|
|
105
|
+
{user.role !== "owner" ? <Button type="button" onClick={() => void resetUser(user)}>Reset</Button> : null}
|
|
106
|
+
<Button type="button" variant="danger" disabled={user.role === "owner"} onClick={() => setUserToDelete(user)}>Delete</Button>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
))}
|
|
110
|
+
</div>
|
|
111
|
+
) : users !== undefined && !usersLoadError ? <EmptyState title="No users" /> : null}
|
|
112
|
+
<ConfirmDialog
|
|
113
|
+
open={Boolean(userToDelete)}
|
|
114
|
+
title="Delete user?"
|
|
115
|
+
message={userToDelete ? `This permanently deletes "${userToDelete.username}" and revokes their setup links, API keys, passkeys and device sessions.` : ""}
|
|
116
|
+
confirmLabel="Delete user"
|
|
117
|
+
danger
|
|
118
|
+
onCancel={() => setUserToDelete(undefined)}
|
|
119
|
+
onConfirm={() => userToDelete && void deleteUser(userToDelete)}
|
|
120
|
+
/>
|
|
121
|
+
</FormSection>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import type { SidebarNode, WebAppRoute } from "./sidebar/types";
|
|
3
|
+
|
|
4
|
+
export type StoredSidebarPin = {
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
subtitle?: string;
|
|
8
|
+
badge?: string;
|
|
9
|
+
badgeVariant?: SidebarNode["badgeVariant"];
|
|
10
|
+
route: WebAppRoute;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type SidebarCollapsedState = Record<string, boolean>;
|
|
14
|
+
|
|
15
|
+
export function flattenSidebarItems(nodes: SidebarNode[]): SidebarNode[] {
|
|
16
|
+
return nodes.flatMap((node) => [
|
|
17
|
+
node,
|
|
18
|
+
...(node.children ? flattenSidebarItems(node.children) : []),
|
|
19
|
+
]).filter((node) => node.type === "item");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function pinStorageKey(appName: string, explicitKey?: string): string {
|
|
23
|
+
return explicitKey ?? `webapp.${appName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.sidebar.pins`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sidebarCollapsedStorageKey(appName: string): string {
|
|
27
|
+
return `webapp.${appName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.sidebar.collapsed`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isSidebarCollapsedState(value: unknown): value is SidebarCollapsedState {
|
|
31
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
return Object.values(value).every((entry) => typeof entry === "boolean");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function toStoredPin(node: SidebarNode): StoredSidebarPin | undefined {
|
|
38
|
+
if (!node.route) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
id: node.pinId ?? node.id,
|
|
43
|
+
title: node.title,
|
|
44
|
+
subtitle: node.subtitle,
|
|
45
|
+
badge: node.badge,
|
|
46
|
+
badgeVariant: node.badgeVariant,
|
|
47
|
+
route: node.route,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function useSidebarPins(appName: string, storageKey?: string) {
|
|
52
|
+
const key = pinStorageKey(appName, storageKey);
|
|
53
|
+
const [pins, setPins] = useState<StoredSidebarPin[]>(() => {
|
|
54
|
+
try {
|
|
55
|
+
const raw = localStorage.getItem(key);
|
|
56
|
+
return raw ? JSON.parse(raw) as StoredSidebarPin[] : [];
|
|
57
|
+
} catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
localStorage.setItem(key, JSON.stringify(pins));
|
|
64
|
+
}, [key, pins]);
|
|
65
|
+
|
|
66
|
+
const pinIds = useMemo(() => new Set(pins.map((pin) => pin.id)), [pins]);
|
|
67
|
+
const pin = useCallback((node: SidebarNode) => {
|
|
68
|
+
const stored = toStoredPin(node);
|
|
69
|
+
if (!stored) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
setPins((current) => [...current.filter((item) => item.id !== stored.id), stored]);
|
|
73
|
+
}, []);
|
|
74
|
+
const unpin = useCallback((id: string) => {
|
|
75
|
+
setPins((current) => current.filter((item) => item.id !== id));
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
return { pins, pinIds, pin, unpin };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function useSidebarCollapsedState(appName: string) {
|
|
82
|
+
const key = sidebarCollapsedStorageKey(appName);
|
|
83
|
+
const [collapsed, setCollapsed] = useState<SidebarCollapsedState>(() => {
|
|
84
|
+
try {
|
|
85
|
+
const raw = localStorage.getItem(key);
|
|
86
|
+
if (!raw) {
|
|
87
|
+
return {};
|
|
88
|
+
}
|
|
89
|
+
const parsed: unknown = JSON.parse(raw);
|
|
90
|
+
return isSidebarCollapsedState(parsed) ? parsed : {};
|
|
91
|
+
} catch {
|
|
92
|
+
return {};
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
localStorage.setItem(key, JSON.stringify(collapsed));
|
|
98
|
+
}, [key, collapsed]);
|
|
99
|
+
|
|
100
|
+
const toggleCollapsed = useCallback((id: string, isCollapsed: boolean) => {
|
|
101
|
+
setCollapsed((current) => {
|
|
102
|
+
const currentIsCollapsed = current[id] ?? isCollapsed;
|
|
103
|
+
return { ...current, [id]: !currentIsCollapsed };
|
|
104
|
+
});
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
return { collapsed, toggleCollapsed };
|
|
108
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Badge, ContextMenu, type ContextMenuPosition } from "./components";
|
|
3
|
+
import type { SidebarCollapsedState } from "./sidebar-state";
|
|
4
|
+
import type { ActionMenuItem, SidebarNode, WebAppRoute } from "./sidebar/types";
|
|
5
|
+
|
|
6
|
+
type SidebarTreeParentKind = "root" | "section" | "item";
|
|
7
|
+
|
|
8
|
+
export type SidebarTreeProps = {
|
|
9
|
+
nodes: SidebarNode[];
|
|
10
|
+
route: WebAppRoute;
|
|
11
|
+
navigate: (route: WebAppRoute) => void;
|
|
12
|
+
collapsed: SidebarCollapsedState;
|
|
13
|
+
toggleCollapsed: (id: string, isCollapsed: boolean) => void;
|
|
14
|
+
searchActive: boolean;
|
|
15
|
+
level?: number;
|
|
16
|
+
parentKind?: SidebarTreeParentKind;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function sidebarIndentStyle(level: number, parentKind: SidebarTreeParentKind): { marginLeft?: string } | undefined {
|
|
20
|
+
if (level <= 0) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
const baseIndentRem = level * 0.375;
|
|
24
|
+
const nestedSectionIndentRem = parentKind === "section" && level > 1 ? 0.875 : 0;
|
|
25
|
+
return { marginLeft: `${baseIndentRem + nestedSectionIndentRem}rem` };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function SidebarTree({ nodes, route, navigate, collapsed, toggleCollapsed, searchActive, level = 0, parentKind = "root" }: SidebarTreeProps) {
|
|
29
|
+
const [contextMenu, setContextMenu] = useState<{ position: ContextMenuPosition; items: ActionMenuItem[]; title: string } | null>(null);
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
{nodes.map((node) => {
|
|
33
|
+
const hasChildren = Boolean(node.children?.length);
|
|
34
|
+
const storedIsCollapsed = collapsed[node.id] ?? node.defaultCollapsed ?? false;
|
|
35
|
+
const isCollapsed = searchActive && hasChildren ? false : storedIsCollapsed;
|
|
36
|
+
const toggleAriaLabel = searchActive ? `Toggling unavailable during search for ${node.title}` : `${isCollapsed ? "Expand" : "Collapse"} ${node.title}`;
|
|
37
|
+
const toggleNodeCollapsed = () => {
|
|
38
|
+
if (!searchActive) {
|
|
39
|
+
toggleCollapsed(node.id, storedIsCollapsed);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
if (node.type === "section") {
|
|
43
|
+
return (
|
|
44
|
+
<section className={`wapp-sidebar-section ${level === 0 ? "top" : "nested"}`} key={node.id}>
|
|
45
|
+
<div className="wapp-sidebar-section-title" style={sidebarIndentStyle(level, parentKind)}>
|
|
46
|
+
{hasChildren ? (
|
|
47
|
+
<button type="button" aria-expanded={!isCollapsed} aria-label={toggleAriaLabel} disabled={searchActive} onClick={toggleNodeCollapsed}>
|
|
48
|
+
<span>{isCollapsed ? "▶" : "▼"}</span>{node.title}
|
|
49
|
+
</button>
|
|
50
|
+
) : (
|
|
51
|
+
<div className="wapp-sidebar-section-label">{node.title}</div>
|
|
52
|
+
)}
|
|
53
|
+
{node.action ? <button type="button" className="wapp-sidebar-action" title={node.action.title} aria-label={node.action.title} onClick={node.action.onAction ?? (() => node.action?.route && navigate(node.action.route))}>{node.action.label ?? "New"}</button> : null}
|
|
54
|
+
</div>
|
|
55
|
+
{!isCollapsed && hasChildren ? <SidebarTree nodes={node.children ?? []} route={route} navigate={navigate} collapsed={collapsed} toggleCollapsed={toggleCollapsed} searchActive={searchActive} level={level + 1} parentKind="section" /> : null}
|
|
56
|
+
{!isCollapsed && !hasChildren && level === 0 ? <div className="wapp-sidebar-empty">No items.</div> : null}
|
|
57
|
+
</section>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
const active = node.route?.view === route.view && Object.entries(node.route).every(([key, value]) => key === "view" || route[key] === value);
|
|
61
|
+
return (
|
|
62
|
+
<div className={`wapp-sidebar-item-wrap ${hasChildren ? "has-toggle" : ""}`} key={node.id} style={sidebarIndentStyle(level, parentKind)}>
|
|
63
|
+
{hasChildren ? <button type="button" className="wapp-tree-toggle" aria-expanded={!isCollapsed} aria-label={toggleAriaLabel} disabled={searchActive} onClick={toggleNodeCollapsed}>{isCollapsed ? "▶" : "▼"}</button> : null}
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
className={`wapp-sidebar-item ${active ? "active" : ""}`}
|
|
67
|
+
onClick={() => node.route && navigate(node.route)}
|
|
68
|
+
onContextMenu={(event) => {
|
|
69
|
+
if (!node.actions?.length) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
event.preventDefault();
|
|
73
|
+
setContextMenu({ position: { x: event.clientX, y: event.clientY }, items: node.actions, title: node.title });
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
<span>
|
|
77
|
+
<strong>{node.title}</strong>
|
|
78
|
+
{node.subtitle ? <small>{node.subtitle}</small> : null}
|
|
79
|
+
</span>
|
|
80
|
+
{node.badge ? <Badge variant={node.badgeVariant} className="wapp-sidebar-badge" title={node.badge} aria-label={node.badge}> </Badge> : null}
|
|
81
|
+
</button>
|
|
82
|
+
{!isCollapsed && node.children ? <div className="wapp-sidebar-children"><SidebarTree nodes={node.children} route={route} navigate={navigate} collapsed={collapsed} toggleCollapsed={toggleCollapsed} searchActive={searchActive} level={level + 1} parentKind="item" /></div> : null}
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
})}
|
|
86
|
+
<ContextMenu items={contextMenu?.items ?? []} position={contextMenu?.position ?? null} ariaLabel={contextMenu ? `Actions for ${contextMenu.title}` : "Actions"} onClose={() => setContextMenu(null)} />
|
|
87
|
+
</>
|
|
88
|
+
);
|
|
89
|
+
}
|
package/src/web/styles.css
CHANGED
|
@@ -621,6 +621,15 @@ textarea::placeholder {
|
|
|
621
621
|
padding: 1.5rem 2rem max(2rem, var(--wapp-mobile-bottom-clearance));
|
|
622
622
|
}
|
|
623
623
|
|
|
624
|
+
.wapp-page-full {
|
|
625
|
+
display: flex;
|
|
626
|
+
flex-direction: column;
|
|
627
|
+
flex: 1 1 auto;
|
|
628
|
+
min-height: 0;
|
|
629
|
+
overflow: hidden;
|
|
630
|
+
padding: 0;
|
|
631
|
+
}
|
|
632
|
+
|
|
624
633
|
.wapp-page,
|
|
625
634
|
.wapp-panel,
|
|
626
635
|
.wapp-modal-body {
|
|
@@ -1868,85 +1877,88 @@ textarea::placeholder {
|
|
|
1868
1877
|
display: none;
|
|
1869
1878
|
}
|
|
1870
1879
|
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1880
|
+
/* WebAppRoot owns the mobile state so CSS and browser behavior use one breakpoint. */
|
|
1881
|
+
:root[data-wapp-mobile] .wapp-shell {
|
|
1882
|
+
display: block;
|
|
1883
|
+
}
|
|
1875
1884
|
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1885
|
+
:root[data-wapp-mobile] .wapp-sidebar,
|
|
1886
|
+
:root[data-wapp-mobile] .wapp-shell.sidebar-collapsed .wapp-sidebar {
|
|
1887
|
+
position: fixed;
|
|
1888
|
+
inset: 0 auto 0 0;
|
|
1889
|
+
z-index: 60;
|
|
1890
|
+
width: min(20rem, 86vw);
|
|
1891
|
+
min-width: min(20rem, 86vw);
|
|
1892
|
+
transform: translateX(-100%);
|
|
1893
|
+
opacity: 1;
|
|
1894
|
+
pointer-events: auto;
|
|
1895
|
+
}
|
|
1887
1896
|
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1897
|
+
:root[data-wapp-mobile] .wapp-shell.sidebar-open .wapp-sidebar {
|
|
1898
|
+
transform: translateX(0);
|
|
1899
|
+
}
|
|
1891
1900
|
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1901
|
+
:root[data-wapp-mobile] .wapp-shell.sidebar-open .wapp-mobile-backdrop {
|
|
1902
|
+
display: block;
|
|
1903
|
+
}
|
|
1895
1904
|
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1905
|
+
:root[data-wapp-mobile] .wapp-main-header {
|
|
1906
|
+
padding: 0.875rem 1rem;
|
|
1907
|
+
}
|
|
1899
1908
|
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1909
|
+
:root[data-wapp-mobile] .wapp-main {
|
|
1910
|
+
touch-action: pan-y;
|
|
1911
|
+
}
|
|
1903
1912
|
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1913
|
+
:root[data-wapp-mobile] .wapp-main-content {
|
|
1914
|
+
overflow: auto;
|
|
1915
|
+
overflow-x: hidden;
|
|
1916
|
+
}
|
|
1908
1917
|
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1918
|
+
:root[data-wapp-mobile] .wapp-page {
|
|
1919
|
+
padding: 1rem 1rem max(1rem, var(--wapp-mobile-bottom-clearance));
|
|
1920
|
+
}
|
|
1912
1921
|
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
}
|
|
1922
|
+
:root[data-wapp-mobile] .wapp-page-full {
|
|
1923
|
+
padding: 0;
|
|
1924
|
+
}
|
|
1917
1925
|
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
}
|
|
1926
|
+
:root[data-wapp-mobile] .wapp-panel-header,
|
|
1927
|
+
:root[data-wapp-mobile] .wapp-entity-header {
|
|
1928
|
+
flex-wrap: wrap;
|
|
1929
|
+
}
|
|
1923
1930
|
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1931
|
+
:root[data-wapp-mobile] .wapp-panel-actions,
|
|
1932
|
+
:root[data-wapp-mobile] .wapp-entity-header-actions {
|
|
1933
|
+
width: 100%;
|
|
1934
|
+
justify-content: flex-start;
|
|
1935
|
+
}
|
|
1927
1936
|
|
|
1928
|
-
|
|
1929
|
-
.
|
|
1930
|
-
|
|
1931
|
-
min-width: 0;
|
|
1932
|
-
}
|
|
1937
|
+
:root[data-wapp-mobile] .wapp-data-list-row {
|
|
1938
|
+
gap: 0.5rem;
|
|
1939
|
+
}
|
|
1933
1940
|
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1941
|
+
:root[data-wapp-mobile] .wapp-data-list-row-meta,
|
|
1942
|
+
:root[data-wapp-mobile] .wapp-data-list-row-badge,
|
|
1943
|
+
:root[data-wapp-mobile] .wapp-data-list-row-actions {
|
|
1944
|
+
min-width: 0;
|
|
1945
|
+
}
|
|
1937
1946
|
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1947
|
+
:root[data-wapp-mobile] .wapp-mobile-only {
|
|
1948
|
+
display: inline-flex;
|
|
1949
|
+
}
|
|
1941
1950
|
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1951
|
+
:root[data-wapp-mobile] .wapp-form-section {
|
|
1952
|
+
grid-template-columns: 1fr;
|
|
1953
|
+
}
|
|
1945
1954
|
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1955
|
+
:root[data-wapp-mobile] .wapp-settings-row {
|
|
1956
|
+
display: block;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
:root[data-wapp-mobile] .wapp-row-actions {
|
|
1960
|
+
justify-content: flex-start;
|
|
1961
|
+
margin-top: 0.75rem;
|
|
1950
1962
|
}
|
|
1951
1963
|
|
|
1952
1964
|
@media (max-width: 640px) {
|