@pablozaiden/webapp 0.5.7 → 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/sidebar.md +2 -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 -1132
- 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 +78 -61
|
@@ -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
|
@@ -108,6 +108,7 @@ textarea::placeholder {
|
|
|
108
108
|
height: var(--wapp-viewport-height);
|
|
109
109
|
min-height: var(--wapp-viewport-height);
|
|
110
110
|
overflow: hidden;
|
|
111
|
+
overscroll-behavior-x: none;
|
|
111
112
|
background: var(--wapp-bg);
|
|
112
113
|
}
|
|
113
114
|
|
|
@@ -620,6 +621,15 @@ textarea::placeholder {
|
|
|
620
621
|
padding: 1.5rem 2rem max(2rem, var(--wapp-mobile-bottom-clearance));
|
|
621
622
|
}
|
|
622
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
|
+
|
|
623
633
|
.wapp-page,
|
|
624
634
|
.wapp-panel,
|
|
625
635
|
.wapp-modal-body {
|
|
@@ -1867,81 +1877,88 @@ textarea::placeholder {
|
|
|
1867
1877
|
display: none;
|
|
1868
1878
|
}
|
|
1869
1879
|
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
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
|
+
}
|
|
1874
1884
|
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
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
|
+
}
|
|
1886
1896
|
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1897
|
+
:root[data-wapp-mobile] .wapp-shell.sidebar-open .wapp-sidebar {
|
|
1898
|
+
transform: translateX(0);
|
|
1899
|
+
}
|
|
1890
1900
|
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1901
|
+
:root[data-wapp-mobile] .wapp-shell.sidebar-open .wapp-mobile-backdrop {
|
|
1902
|
+
display: block;
|
|
1903
|
+
}
|
|
1894
1904
|
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1905
|
+
:root[data-wapp-mobile] .wapp-main-header {
|
|
1906
|
+
padding: 0.875rem 1rem;
|
|
1907
|
+
}
|
|
1898
1908
|
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
}
|
|
1909
|
+
:root[data-wapp-mobile] .wapp-main {
|
|
1910
|
+
touch-action: pan-y;
|
|
1911
|
+
}
|
|
1903
1912
|
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1913
|
+
:root[data-wapp-mobile] .wapp-main-content {
|
|
1914
|
+
overflow: auto;
|
|
1915
|
+
overflow-x: hidden;
|
|
1916
|
+
}
|
|
1907
1917
|
|
|
1908
|
-
|
|
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
|
-
justify-content: flex-start;
|
|
1917
|
-
}
|
|
1922
|
+
:root[data-wapp-mobile] .wapp-page-full {
|
|
1923
|
+
padding: 0;
|
|
1924
|
+
}
|
|
1918
1925
|
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1926
|
+
:root[data-wapp-mobile] .wapp-panel-header,
|
|
1927
|
+
:root[data-wapp-mobile] .wapp-entity-header {
|
|
1928
|
+
flex-wrap: wrap;
|
|
1929
|
+
}
|
|
1922
1930
|
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
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
|
+
}
|
|
1928
1936
|
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1937
|
+
:root[data-wapp-mobile] .wapp-data-list-row {
|
|
1938
|
+
gap: 0.5rem;
|
|
1939
|
+
}
|
|
1932
1940
|
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
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
|
+
}
|
|
1936
1946
|
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1947
|
+
:root[data-wapp-mobile] .wapp-mobile-only {
|
|
1948
|
+
display: inline-flex;
|
|
1949
|
+
}
|
|
1940
1950
|
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1951
|
+
:root[data-wapp-mobile] .wapp-form-section {
|
|
1952
|
+
grid-template-columns: 1fr;
|
|
1953
|
+
}
|
|
1954
|
+
|
|
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;
|
|
1945
1962
|
}
|
|
1946
1963
|
|
|
1947
1964
|
@media (max-width: 640px) {
|