@pablozaiden/webapp 0.2.0 → 0.2.2
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 +1 -0
- package/docs/cli.md +45 -0
- package/docs/deployment.md +3 -0
- package/docs/getting-started.md +14 -0
- package/docs/server.md +50 -0
- package/docs/settings.md +4 -0
- package/docs/sidebar.md +1 -1
- package/docs/ui-guidelines.md +3 -1
- package/package.json +8 -3
- package/src/cli/api-command.ts +119 -0
- package/src/cli/credentials.ts +67 -0
- package/src/cli/device-auth.ts +179 -0
- package/src/cli/index.ts +4 -0
- package/src/cli/runtime.ts +51 -0
- package/src/server/auth/passkeys.ts +3 -3
- package/src/server/create-web-app-server.ts +200 -63
- package/src/server/index.ts +1 -0
- package/src/server/route-catalog.ts +147 -0
- package/src/server/routes.ts +12 -2
- package/src/web/WebAppRoot.tsx +112 -10
- package/src/web/api-client.ts +64 -0
- package/src/web/index.ts +1 -0
- package/src/web/realtime/useRealtime.ts +2 -2
- package/src/web/styles.css +66 -0
package/src/web/WebAppRoot.tsx
CHANGED
|
@@ -40,6 +40,7 @@ export interface WebAppRootProps {
|
|
|
40
40
|
appName: string;
|
|
41
41
|
homeRoute: WebAppRoute;
|
|
42
42
|
sidebar: {
|
|
43
|
+
search?: boolean;
|
|
43
44
|
topActions?: SidebarAction[];
|
|
44
45
|
pinning?: false | {
|
|
45
46
|
sectionTitle?: string;
|
|
@@ -487,6 +488,7 @@ function UserManagement({ config }: { config: WebAppConfigResponse }) {
|
|
|
487
488
|
const [username, setUsername] = useState("");
|
|
488
489
|
const [role, setRole] = useState<WebAppUserRole>("user");
|
|
489
490
|
const [setupLink, setSetupLink] = useState<string>();
|
|
491
|
+
const [userToDelete, setUserToDelete] = useState<WebAppUserSummary>();
|
|
490
492
|
const [error, setError] = useState<string>();
|
|
491
493
|
|
|
492
494
|
const refreshUsers = useCallback(async () => {
|
|
@@ -535,6 +537,7 @@ function UserManagement({ config }: { config: WebAppConfigResponse }) {
|
|
|
535
537
|
try {
|
|
536
538
|
setError(undefined);
|
|
537
539
|
await json(`/api/users/${encodeURIComponent(user.id)}`, { method: "DELETE" });
|
|
540
|
+
setUserToDelete(undefined);
|
|
538
541
|
await refreshUsers();
|
|
539
542
|
} catch (err) {
|
|
540
543
|
setError(err instanceof Error ? err.message : String(err));
|
|
@@ -575,23 +578,73 @@ function UserManagement({ config }: { config: WebAppConfigResponse }) {
|
|
|
575
578
|
</select>
|
|
576
579
|
) : <Badge variant="success">Owner</Badge>}
|
|
577
580
|
{user.role !== "owner" ? <Button type="button" onClick={() => void resetUser(user)}>Reset</Button> : null}
|
|
578
|
-
<Button type="button" variant="danger" disabled={user.role === "owner"} onClick={() =>
|
|
581
|
+
<Button type="button" variant="danger" disabled={user.role === "owner"} onClick={() => setUserToDelete(user)}>Delete</Button>
|
|
579
582
|
</div>
|
|
580
583
|
</div>
|
|
581
584
|
))}
|
|
582
585
|
</div>
|
|
586
|
+
<ConfirmDialog
|
|
587
|
+
open={Boolean(userToDelete)}
|
|
588
|
+
title="Delete user?"
|
|
589
|
+
message={userToDelete ? `This permanently deletes "${userToDelete.username}" and revokes their setup links, API keys, passkeys and device sessions.` : ""}
|
|
590
|
+
confirmLabel="Delete user"
|
|
591
|
+
danger
|
|
592
|
+
onCancel={() => setUserToDelete(undefined)}
|
|
593
|
+
onConfirm={() => userToDelete && void deleteUser(userToDelete)}
|
|
594
|
+
/>
|
|
583
595
|
</FormSection>
|
|
584
596
|
);
|
|
585
597
|
}
|
|
586
598
|
|
|
599
|
+
const KILL_SERVER_COUNTDOWN_SECONDS = 15;
|
|
600
|
+
|
|
601
|
+
function computeProgressPercent(countdown: number, total: number): number {
|
|
602
|
+
return total <= 0 ? 0 : (countdown / total) * 100;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function useCountdownReload(active: boolean, onComplete: () => void, durationSeconds = KILL_SERVER_COUNTDOWN_SECONDS): { countdown: number; progressPercent: number } {
|
|
606
|
+
const [countdown, setCountdown] = useState(durationSeconds);
|
|
607
|
+
|
|
608
|
+
useEffect(() => {
|
|
609
|
+
if (!active) {
|
|
610
|
+
setCountdown(durationSeconds);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
setCountdown(durationSeconds);
|
|
615
|
+
const interval = window.setInterval(() => {
|
|
616
|
+
setCountdown((previous) => {
|
|
617
|
+
const next = previous - 1;
|
|
618
|
+
if (next <= 0) {
|
|
619
|
+
window.clearInterval(interval);
|
|
620
|
+
onComplete();
|
|
621
|
+
return 0;
|
|
622
|
+
}
|
|
623
|
+
return next;
|
|
624
|
+
});
|
|
625
|
+
}, 1000);
|
|
626
|
+
|
|
627
|
+
return () => window.clearInterval(interval);
|
|
628
|
+
}, [active, durationSeconds, onComplete]);
|
|
629
|
+
|
|
630
|
+
return {
|
|
631
|
+
countdown,
|
|
632
|
+
progressPercent: computeProgressPercent(countdown, durationSeconds),
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
587
636
|
function SettingsView({ config, refresh, customSections, theme, setTheme }: { config: WebAppConfigResponse; refresh: () => Promise<void>; customSections: SettingsSection[]; theme: ThemePreference; setTheme: (theme: ThemePreference) => void }) {
|
|
588
637
|
const [apiKeys, setApiKeys] = useState<ApiKeySummary[]>([]);
|
|
589
638
|
const [authSessions, setAuthSessions] = useState<AuthSessionSummary[]>([]);
|
|
590
639
|
const [createdToken, setCreatedToken] = useState<string>();
|
|
591
640
|
const [apiKeyToDelete, setApiKeyToDelete] = useState<ApiKeySummary>();
|
|
641
|
+
const [authSessionToRevoke, setAuthSessionToRevoke] = useState<AuthSessionSummary>();
|
|
592
642
|
const [confirmDeletePasskey, setConfirmDeletePasskey] = useState(false);
|
|
643
|
+
const [confirmKillServer, setConfirmKillServer] = useState(false);
|
|
593
644
|
const [killRequested, setKillRequested] = useState(false);
|
|
594
645
|
const [error, setError] = useState<string>();
|
|
646
|
+
const reloadPage = useCallback(() => window.location.reload(), []);
|
|
647
|
+
const { countdown, progressPercent } = useCountdownReload(killRequested, reloadPage);
|
|
595
648
|
|
|
596
649
|
const refreshApiKeys = useCallback(async () => {
|
|
597
650
|
if (config.apiKeys.enabled) {
|
|
@@ -648,10 +701,25 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
|
|
|
648
701
|
await refresh();
|
|
649
702
|
}
|
|
650
703
|
|
|
704
|
+
async function revokeAuthSession(session: AuthSessionSummary) {
|
|
705
|
+
try {
|
|
706
|
+
setError(undefined);
|
|
707
|
+
await json(`/api/auth/sessions/${session.id}`, { method: "DELETE" });
|
|
708
|
+
setAuthSessionToRevoke(undefined);
|
|
709
|
+
await refreshAuthSessions();
|
|
710
|
+
} catch (err) {
|
|
711
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
651
715
|
async function killServer() {
|
|
716
|
+
setError(undefined);
|
|
717
|
+
setConfirmKillServer(false);
|
|
718
|
+
const response = await fetch("/api/server/kill", { method: "POST" });
|
|
719
|
+
if (!response.ok) {
|
|
720
|
+
throw new Error("Failed to kill server. Please try again.");
|
|
721
|
+
}
|
|
652
722
|
setKillRequested(true);
|
|
653
|
-
await fetch("/api/server/kill", { method: "POST" });
|
|
654
|
-
setTimeout(() => window.location.reload(), 3500);
|
|
655
723
|
}
|
|
656
724
|
|
|
657
725
|
return (
|
|
@@ -729,7 +797,7 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
|
|
|
729
797
|
{authSessions.length ? authSessions.map((session) => (
|
|
730
798
|
<div className="wapp-list-row" key={session.id}>
|
|
731
799
|
<span><strong>{session.clientId}</strong><small>{session.scope} · {session.active ? "active" : "inactive"} · {session.updatedAt}</small></span>
|
|
732
|
-
<Button type="button" variant="danger" disabled={!session.active} onClick={() =>
|
|
800
|
+
<Button type="button" variant="danger" disabled={!session.active} onClick={() => setAuthSessionToRevoke(session)}>Revoke</Button>
|
|
733
801
|
</div>
|
|
734
802
|
)) : <EmptyState title="No device sessions" />}
|
|
735
803
|
</div>
|
|
@@ -741,8 +809,23 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
|
|
|
741
809
|
|
|
742
810
|
{config.currentUser?.isAdmin ? (
|
|
743
811
|
<FormSection title="Server operations">
|
|
744
|
-
|
|
745
|
-
|
|
812
|
+
<div className="wapp-settings-row">
|
|
813
|
+
<div>
|
|
814
|
+
<strong>Kill server</strong>
|
|
815
|
+
<p>Stop the server process. If your deployment restarts it automatically, the app will come back after a moment.</p>
|
|
816
|
+
{killRequested ? (
|
|
817
|
+
<div className="wapp-shutdown-countdown" aria-live="polite">
|
|
818
|
+
<div className="wapp-shutdown-message">Server is shutting down... Reloading in {countdown}s</div>
|
|
819
|
+
<div className="wapp-shutdown-progress" aria-hidden="true">
|
|
820
|
+
<div className="wapp-shutdown-progress-bar" style={{ width: `${progressPercent}%` }} />
|
|
821
|
+
</div>
|
|
822
|
+
</div>
|
|
823
|
+
) : null}
|
|
824
|
+
</div>
|
|
825
|
+
<div className="wapp-row-actions">
|
|
826
|
+
<Button type="button" variant="danger" disabled={killRequested} onClick={() => setConfirmKillServer(true)}>Kill server</Button>
|
|
827
|
+
</div>
|
|
828
|
+
</div>
|
|
746
829
|
</FormSection>
|
|
747
830
|
) : null}
|
|
748
831
|
|
|
@@ -765,6 +848,24 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
|
|
|
765
848
|
onConfirm={() => apiKeyToDelete && void deleteKey(apiKeyToDelete.id)}
|
|
766
849
|
/>
|
|
767
850
|
<ConfirmDialog open={confirmDeletePasskey} title="Delete passkey?" message="This removes the configured passkey and invalidates browser sessions." confirmLabel="Delete passkey" danger onCancel={() => setConfirmDeletePasskey(false)} onConfirm={() => void deleteConfiguredPasskey()} />
|
|
851
|
+
<ConfirmDialog
|
|
852
|
+
open={Boolean(authSessionToRevoke)}
|
|
853
|
+
title="Revoke device session?"
|
|
854
|
+
message={authSessionToRevoke ? `This revokes the active "${authSessionToRevoke.clientId}" refresh-token session.` : ""}
|
|
855
|
+
confirmLabel="Revoke session"
|
|
856
|
+
danger
|
|
857
|
+
onCancel={() => setAuthSessionToRevoke(undefined)}
|
|
858
|
+
onConfirm={() => authSessionToRevoke && void revokeAuthSession(authSessionToRevoke)}
|
|
859
|
+
/>
|
|
860
|
+
<ConfirmDialog
|
|
861
|
+
open={confirmKillServer}
|
|
862
|
+
title="Kill server?"
|
|
863
|
+
message="Are you sure you want to kill the server?"
|
|
864
|
+
confirmLabel="Kill server"
|
|
865
|
+
danger
|
|
866
|
+
onCancel={() => setConfirmKillServer(false)}
|
|
867
|
+
onConfirm={() => void killServer().catch((err) => setError(String(err)))}
|
|
868
|
+
/>
|
|
768
869
|
</div>
|
|
769
870
|
);
|
|
770
871
|
}
|
|
@@ -776,10 +877,11 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, settin
|
|
|
776
877
|
const [search, setSearch] = useState("");
|
|
777
878
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
778
879
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
880
|
+
const sidebarSearchEnabled = sidebar.search !== false;
|
|
779
881
|
const pinningEnabled = sidebar.pinning !== false;
|
|
780
882
|
const sidebarPins = useSidebarPins(appName, sidebar.pinning ? sidebar.pinning.storageKey : undefined);
|
|
781
883
|
const baseNodes = useMemo(() => sidebar.getNodes({ search: "" }), [sidebar]);
|
|
782
|
-
const filteredNodes = useMemo(() => sidebar.getNodes({ search }), [sidebar, search]);
|
|
884
|
+
const filteredNodes = useMemo(() => sidebar.getNodes({ search: sidebarSearchEnabled ? search : "" }), [sidebar, search, sidebarSearchEnabled]);
|
|
783
885
|
const allPinnableItems = useMemo(() => flattenSidebarItems(baseNodes).filter((node) => node.pinnable && node.route), [baseNodes]);
|
|
784
886
|
const currentPins = useMemo(() => {
|
|
785
887
|
const byId = new Map(allPinnableItems.map((node) => [node.pinId ?? node.id, node]));
|
|
@@ -807,7 +909,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, settin
|
|
|
807
909
|
}), [pinningActionFor]);
|
|
808
910
|
const nodes = useMemo(() => {
|
|
809
911
|
const augmented = augmentPinningActions(filteredNodes);
|
|
810
|
-
if (!pinningEnabled || search.trim() || currentPins.length === 0) return augmented;
|
|
912
|
+
if (!pinningEnabled || (sidebarSearchEnabled && search.trim()) || currentPins.length === 0) return augmented;
|
|
811
913
|
const augmentedByPinId = new Map(flattenSidebarItems(augmentPinningActions(baseNodes)).map((node) => [node.pinId ?? node.id, node]));
|
|
812
914
|
const pinnedChildren = currentPins.map((pin) => ({
|
|
813
915
|
...(augmentedByPinId.get(pin.id) ?? {
|
|
@@ -827,7 +929,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, settin
|
|
|
827
929
|
{ type: "section" as const, id: "framework:pinned", title: sidebar.pinning ? sidebar.pinning.sectionTitle ?? "Pinned" : "Pinned", children: pinnedChildren },
|
|
828
930
|
...augmented,
|
|
829
931
|
];
|
|
830
|
-
}, [augmentPinningActions, baseNodes, currentPins, filteredNodes, pinningEnabled, search, sidebar.pinning]);
|
|
932
|
+
}, [augmentPinningActions, baseNodes, currentPins, filteredNodes, pinningEnabled, search, sidebar.pinning, sidebarSearchEnabled]);
|
|
831
933
|
|
|
832
934
|
useEffect(() => {
|
|
833
935
|
if (!config?.currentUser) return;
|
|
@@ -888,7 +990,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, settin
|
|
|
888
990
|
</div>
|
|
889
991
|
</div>
|
|
890
992
|
<div className="wapp-sidebar-scroll">
|
|
891
|
-
<label className="wapp-search"><span className="sr-only">Search</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search" /></label>
|
|
993
|
+
{sidebarSearchEnabled ? <label className="wapp-search"><span className="sr-only">Search</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search" /></label> : null}
|
|
892
994
|
<SidebarTree nodes={nodes} route={route} navigate={(next) => { navigate(next); setSidebarOpen(false); }} />
|
|
893
995
|
</div>
|
|
894
996
|
<div className="wapp-sidebar-footer">v{effectiveVersion}<button type="button" aria-label="Reload" onClick={() => window.location.reload()}><Icon name="refresh" /></button></div>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export class WebAppApiError extends Error {
|
|
2
|
+
status: number;
|
|
3
|
+
error?: string;
|
|
4
|
+
details?: unknown;
|
|
5
|
+
|
|
6
|
+
constructor(message: string, status: number, error?: string, details?: unknown) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "WebAppApiError";
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.error = error;
|
|
11
|
+
this.details = details;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type AuthRequiredListener = () => void;
|
|
16
|
+
|
|
17
|
+
const authRequiredListeners = new Set<AuthRequiredListener>();
|
|
18
|
+
|
|
19
|
+
export function onAuthRequired(listener: AuthRequiredListener): () => void {
|
|
20
|
+
authRequiredListeners.add(listener);
|
|
21
|
+
return () => authRequiredListeners.delete(listener);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function emitAuthRequired(): void {
|
|
25
|
+
for (const listener of authRequiredListeners) {
|
|
26
|
+
listener();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function appPath(path: string): string {
|
|
31
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) return path;
|
|
32
|
+
const base = document.querySelector("base")?.getAttribute("href") ?? "/";
|
|
33
|
+
return new URL(path.replace(/^\/+/, ""), new URL(base, window.location.href)).toString();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function appWebSocketUrl(path = "/api/ws"): string {
|
|
37
|
+
const url = new URL(appPath(path));
|
|
38
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
39
|
+
return url.toString();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function appFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
43
|
+
const response = await fetch(typeof input === "string" ? appPath(input) : input, {
|
|
44
|
+
...init,
|
|
45
|
+
credentials: init?.credentials ?? "same-origin",
|
|
46
|
+
headers: {
|
|
47
|
+
accept: "application/json",
|
|
48
|
+
...init?.headers,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
if (response.headers.get("x-webapp-passkey-required") === "true" || response.headers.get("x-passkey-auth-required") === "true") {
|
|
52
|
+
emitAuthRequired();
|
|
53
|
+
}
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
let body: { error?: string; message?: string; details?: unknown } | undefined;
|
|
56
|
+
try {
|
|
57
|
+
body = await response.clone().json() as typeof body;
|
|
58
|
+
} catch {
|
|
59
|
+
body = undefined;
|
|
60
|
+
}
|
|
61
|
+
throw new WebAppApiError(body?.message ?? `Request failed with status ${response.status}`, response.status, body?.error, body?.details);
|
|
62
|
+
}
|
|
63
|
+
return response;
|
|
64
|
+
}
|
package/src/web/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { appWebSocketUrl } from "../api-client";
|
|
2
3
|
|
|
3
4
|
export type RealtimeStatus = "connecting" | "open" | "closed";
|
|
4
5
|
export type RealtimeAction = "created" | "updated" | "changed" | "deleted";
|
|
@@ -71,8 +72,7 @@ export function useRealtime<TEvent>({
|
|
|
71
72
|
for (const [key, value] of Object.entries(filters ?? {})) {
|
|
72
73
|
if (value) params.set(key, value);
|
|
73
74
|
}
|
|
74
|
-
|
|
75
|
-
socket = new WebSocket(`${protocol}//${window.location.host}${path}${params.size ? `?${params.toString()}` : ""}`);
|
|
75
|
+
socket = new WebSocket(appWebSocketUrl(`${path}${params.size ? `?${params.toString()}` : ""}`));
|
|
76
76
|
setStatus("connecting");
|
|
77
77
|
socket.onopen = () => {
|
|
78
78
|
retryRef.current = 0;
|
package/src/web/styles.css
CHANGED
|
@@ -725,6 +725,7 @@ textarea::placeholder {
|
|
|
725
725
|
font-size: 0.875rem;
|
|
726
726
|
font-weight: 600;
|
|
727
727
|
padding: 0.5rem 0.875rem;
|
|
728
|
+
white-space: nowrap;
|
|
728
729
|
transition: background 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease;
|
|
729
730
|
}
|
|
730
731
|
|
|
@@ -978,6 +979,10 @@ textarea::placeholder {
|
|
|
978
979
|
gap: 0.5rem;
|
|
979
980
|
}
|
|
980
981
|
|
|
982
|
+
.wapp-settings .wapp-row-actions {
|
|
983
|
+
flex: 0 0 auto;
|
|
984
|
+
}
|
|
985
|
+
|
|
981
986
|
.wapp-danger-zone {
|
|
982
987
|
display: flex;
|
|
983
988
|
align-items: flex-start;
|
|
@@ -1064,6 +1069,45 @@ textarea::placeholder {
|
|
|
1064
1069
|
color: var(--wapp-muted);
|
|
1065
1070
|
}
|
|
1066
1071
|
|
|
1072
|
+
.wapp-shutdown-countdown {
|
|
1073
|
+
display: grid;
|
|
1074
|
+
gap: 0.5rem;
|
|
1075
|
+
margin-top: 0.75rem;
|
|
1076
|
+
font-weight: 600;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
.wapp-shutdown-message {
|
|
1080
|
+
color: #dc2626;
|
|
1081
|
+
font-size: 0.875rem;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
:root.dark .wapp-shutdown-message {
|
|
1085
|
+
color: #f87171;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
.wapp-shutdown-progress {
|
|
1089
|
+
width: 100%;
|
|
1090
|
+
height: 0.375rem;
|
|
1091
|
+
overflow: hidden;
|
|
1092
|
+
border-radius: 999px;
|
|
1093
|
+
background: #fecaca;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
:root.dark .wapp-shutdown-progress {
|
|
1097
|
+
background: #7f1d1d;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
.wapp-shutdown-progress-bar {
|
|
1101
|
+
height: 100%;
|
|
1102
|
+
border-radius: inherit;
|
|
1103
|
+
background: #ef4444;
|
|
1104
|
+
transition: width 1s linear;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
:root.dark .wapp-shutdown-progress-bar {
|
|
1108
|
+
background: #f87171;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1067
1111
|
.wapp-segmented {
|
|
1068
1112
|
display: flex;
|
|
1069
1113
|
width: 100%;
|
|
@@ -1117,6 +1161,10 @@ textarea::placeholder {
|
|
|
1117
1161
|
padding: 0.75rem;
|
|
1118
1162
|
}
|
|
1119
1163
|
|
|
1164
|
+
.wapp-list-row > span {
|
|
1165
|
+
min-width: 0;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1120
1168
|
.wapp-grid-2 {
|
|
1121
1169
|
display: grid;
|
|
1122
1170
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
@@ -1356,3 +1404,21 @@ textarea::placeholder {
|
|
|
1356
1404
|
margin-top: 0.75rem;
|
|
1357
1405
|
}
|
|
1358
1406
|
}
|
|
1407
|
+
|
|
1408
|
+
@media (max-width: 640px) {
|
|
1409
|
+
.wapp-settings .wapp-list-row {
|
|
1410
|
+
display: grid;
|
|
1411
|
+
grid-template-columns: minmax(0, 1fr);
|
|
1412
|
+
align-items: start;
|
|
1413
|
+
gap: 0.75rem;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
.wapp-settings .wapp-list-row .wapp-row-actions {
|
|
1417
|
+
justify-content: flex-start;
|
|
1418
|
+
margin-top: 0;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
.wapp-settings .wapp-row-actions .wapp-inline-select {
|
|
1422
|
+
max-width: 100%;
|
|
1423
|
+
}
|
|
1424
|
+
}
|