gantry-web 0.3.5 → 0.4.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/src/router.tsx CHANGED
@@ -1,160 +1,180 @@
1
- // Tiny pathname router shared by createApp, Link and useRoute. No
2
- // dependency, history-API based: navigate() pushes, back/forward work,
3
- // and every subscriber re-renders on change.
4
-
5
- import {
6
- useSyncExternalStore,
7
- type AnchorHTMLAttributes,
8
- type ReactNode,
9
- } from "react";
10
- import { getShell } from "./bridge";
11
-
12
- // Subscriptions go through a window-scoped event, NOT a module-local
13
- // set: if bundling ever instantiates this module twice (it happened -
14
- // Vite served excluded node_modules source under ?v=hash and bare URLs
15
- // at once), a local set splits the subscribers and navigation silently
16
- // stops re-rendering half the app. The window survives any number of
17
- // module copies; the route itself already lives in location.pathname.
18
- const NAV_EVENT = "gantry:navigate";
19
-
20
- function notify() {
21
- window.dispatchEvent(new Event(NAV_EVENT));
22
- }
23
-
24
- if (typeof window !== "undefined") {
25
- window.addEventListener("popstate", notify);
26
- }
27
-
28
- /** navigate switches pages without a full reload. */
29
- export function navigate(path: string): void {
30
- if (location.pathname !== path) {
31
- history.pushState(null, "", path);
32
- }
33
- notify();
34
- }
35
-
36
- /** redirect replaces the current URL (no history entry) - used when a
37
- * route no longer exists and the app falls back to another page. */
38
- export function redirect(path: string): void {
39
- if (location.pathname !== path) {
40
- history.replaceState(null, "", path);
41
- }
42
- notify();
43
- }
44
-
45
- /** goBack navigates back through the page history (popstate re-renders). */
46
- export function goBack(): void {
47
- history.back();
48
- }
49
-
50
- /** goForward navigates forward through the page history. */
51
- export function goForward(): void {
52
- history.forward();
53
- }
54
-
55
- function subscribe(fn: () => void): () => void {
56
- window.addEventListener(NAV_EVENT, fn);
57
- return () => window.removeEventListener(NAV_EVENT, fn);
58
- }
59
-
60
- /** useRoute returns the current pathname; re-renders on navigation. */
61
- export function useRoute(): string {
62
- return useSyncExternalStore(subscribe, () => location.pathname);
63
- }
64
-
65
- /** isActive: exact match, or prefix match when exact is false. */
66
- export function isActive(path: string, to: string, exact = true): boolean {
67
- const clean = path !== "/" && path.endsWith("/") ? path.slice(0, -1) : path;
68
- if (exact || to === "/") return clean === to;
69
- return clean === to || clean.startsWith(to + "/");
70
- }
71
-
72
- export interface LinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
73
- /** Route to navigate to, e.g. "/settings". */
74
- to: string;
75
- /** Extra class applied while the link's route is current. */
76
- activeClassName?: string;
77
- /** Match route prefixes too ("/docs" active on "/docs/intro"). */
78
- matchPrefix?: boolean;
79
- children?: ReactNode;
80
- }
81
-
82
- /**
83
- * Link navigates between pages and knows when it is the current one:
84
- * it sets data-active="true"/"false" (style with
85
- * [data-active="true"] in css, or data-[active=true]: variants in
86
- * Tailwind), aria-current="page", and appends activeClassName when
87
- * given.
88
- */
89
- export interface ExternalLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
90
- /** The external URL to open. */
91
- href: string;
92
- prefix?: string;
93
- children?: ReactNode;
94
- }
95
-
96
- /**
97
- * ExternalLink opens a URL in the user's DEFAULT BROWSER instead of
98
- * navigating the app's webview, and (in the native window) renders
99
- * without an href so no URL-preview bubble ever appears. In a plain
100
- * browser tab it falls back to a normal new-tab link.
101
- */
102
- export function ExternalLink({ href, prefix, children, onClick, ...rest }: ExternalLinkProps) {
103
- const shell = getShell(prefix);
104
- if (!shell.available) {
105
- return (
106
- <a href={href} target="_blank" rel="noreferrer" onClick={onClick} {...rest}>
107
- {children}
108
- </a>
109
- );
110
- }
111
- return (
112
- <a
113
- role="link"
114
- tabIndex={0}
115
- onClick={(e) => {
116
- onClick?.(e);
117
- if (!e.defaultPrevented) shell.openExternal(href);
118
- }}
119
- onKeyDown={(e) => {
120
- if (e.key === "Enter") shell.openExternal(href);
121
- }}
122
- {...rest}
123
- >
124
- {children}
125
- </a>
126
- );
127
- }
128
-
129
- export function Link({ to, activeClassName, matchPrefix, className, children, onClick, ...rest }: LinkProps) {
130
- const path = useRoute();
131
- const active = isActive(path, to, !matchPrefix);
132
- const cls = [className, active ? activeClassName : undefined].filter(Boolean).join(" ");
133
- // Deliberately NO href: navigation is client-side anyway, and an
134
- // href makes the webview show the URL preview bubble in the bottom
135
- // corner on hover - a browser artifact that looks wrong in a
136
- // desktop app. role/tabIndex/keyboard keep it accessible.
137
- return (
138
- <a
139
- role="link"
140
- tabIndex={0}
141
- className={cls || undefined}
142
- data-active={active ? "true" : "false"}
143
- aria-current={active ? "page" : undefined}
144
- onClick={(e) => {
145
- onClick?.(e);
146
- if (e.defaultPrevented || e.button !== 0) return;
147
- navigate(to);
148
- }}
149
- onKeyDown={(e) => {
150
- if (e.key === "Enter" || e.key === " ") {
151
- e.preventDefault();
152
- navigate(to);
153
- }
154
- }}
155
- {...rest}
156
- >
157
- {children}
158
- </a>
159
- );
160
- }
1
+ // Tiny pathname router shared by createApp, Link and useRoute. No
2
+ // dependency, history-API based: navigate() pushes, back/forward work,
3
+ // and every subscriber re-renders on change.
4
+
5
+ import {
6
+ createContext,
7
+ useContext,
8
+ useSyncExternalStore,
9
+ type AnchorHTMLAttributes,
10
+ type ReactNode,
11
+ } from "react";
12
+ import { getShell } from "./bridge";
13
+
14
+ /** Params captured from a dynamic route: a string for a [id] segment, a
15
+ * string[] for a [...slug] catch-all. */
16
+ export type RouteParams = Record<string, string | string[]>;
17
+
18
+ // The active page's captured params, provided by createApp around the
19
+ // page. Empty for static routes.
20
+ export const ParamsContext = createContext<RouteParams>({});
21
+
22
+ /**
23
+ * useParams returns the params captured from the current dynamic route.
24
+ * For pages/examples/page1/[id] on /examples/page1/7 it is { id: "7" };
25
+ * for a [...slug] catch-all the value is a string[]. Empty on static
26
+ * pages. Type it with the shape you expect: useParams<{ id: string }>().
27
+ */
28
+ export function useParams<T extends RouteParams = RouteParams>(): T {
29
+ return useContext(ParamsContext) as T;
30
+ }
31
+
32
+ // Subscriptions go through a window-scoped event, NOT a module-local
33
+ // set: if bundling ever instantiates this module twice (it happened -
34
+ // Vite served excluded node_modules source under ?v=hash and bare URLs
35
+ // at once), a local set splits the subscribers and navigation silently
36
+ // stops re-rendering half the app. The window survives any number of
37
+ // module copies; the route itself already lives in location.pathname.
38
+ const NAV_EVENT = "gantry:navigate";
39
+
40
+ function notify() {
41
+ window.dispatchEvent(new Event(NAV_EVENT));
42
+ }
43
+
44
+ if (typeof window !== "undefined") {
45
+ window.addEventListener("popstate", notify);
46
+ }
47
+
48
+ /** navigate switches pages without a full reload. */
49
+ export function navigate(path: string): void {
50
+ if (location.pathname !== path) {
51
+ history.pushState(null, "", path);
52
+ }
53
+ notify();
54
+ }
55
+
56
+ /** redirect replaces the current URL (no history entry) - used when a
57
+ * route no longer exists and the app falls back to another page. */
58
+ export function redirect(path: string): void {
59
+ if (location.pathname !== path) {
60
+ history.replaceState(null, "", path);
61
+ }
62
+ notify();
63
+ }
64
+
65
+ /** goBack navigates back through the page history (popstate re-renders). */
66
+ export function goBack(): void {
67
+ history.back();
68
+ }
69
+
70
+ /** goForward navigates forward through the page history. */
71
+ export function goForward(): void {
72
+ history.forward();
73
+ }
74
+
75
+ function subscribe(fn: () => void): () => void {
76
+ window.addEventListener(NAV_EVENT, fn);
77
+ return () => window.removeEventListener(NAV_EVENT, fn);
78
+ }
79
+
80
+ /** useRoute returns the current pathname; re-renders on navigation. */
81
+ export function useRoute(): string {
82
+ return useSyncExternalStore(subscribe, () => location.pathname);
83
+ }
84
+
85
+ /** isActive: exact match, or prefix match when exact is false. */
86
+ export function isActive(path: string, to: string, exact = true): boolean {
87
+ const clean = path !== "/" && path.endsWith("/") ? path.slice(0, -1) : path;
88
+ if (exact || to === "/") return clean === to;
89
+ return clean === to || clean.startsWith(to + "/");
90
+ }
91
+
92
+ export interface LinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
93
+ /** Route to navigate to, e.g. "/settings". */
94
+ to: string;
95
+ /** Extra class applied while the link's route is current. */
96
+ activeClassName?: string;
97
+ /** Match route prefixes too ("/docs" active on "/docs/intro"). */
98
+ matchPrefix?: boolean;
99
+ children?: ReactNode;
100
+ }
101
+
102
+ /**
103
+ * Link navigates between pages and knows when it is the current one:
104
+ * it sets data-active="true"/"false" (style with
105
+ * [data-active="true"] in css, or data-[active=true]: variants in
106
+ * Tailwind), aria-current="page", and appends activeClassName when
107
+ * given.
108
+ */
109
+ export interface ExternalLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
110
+ /** The external URL to open. */
111
+ href: string;
112
+ prefix?: string;
113
+ children?: ReactNode;
114
+ }
115
+
116
+ /**
117
+ * ExternalLink opens a URL in the user's DEFAULT BROWSER instead of
118
+ * navigating the app's webview, and (in the native window) renders
119
+ * without an href so no URL-preview bubble ever appears. In a plain
120
+ * browser tab it falls back to a normal new-tab link.
121
+ */
122
+ export function ExternalLink({ href, prefix, children, onClick, ...rest }: ExternalLinkProps) {
123
+ const shell = getShell(prefix);
124
+ if (!shell.available) {
125
+ return (
126
+ <a href={href} target="_blank" rel="noreferrer" onClick={onClick} {...rest}>
127
+ {children}
128
+ </a>
129
+ );
130
+ }
131
+ return (
132
+ <a
133
+ role="link"
134
+ tabIndex={0}
135
+ onClick={(e) => {
136
+ onClick?.(e);
137
+ if (!e.defaultPrevented) shell.openExternal(href);
138
+ }}
139
+ onKeyDown={(e) => {
140
+ if (e.key === "Enter") shell.openExternal(href);
141
+ }}
142
+ {...rest}
143
+ >
144
+ {children}
145
+ </a>
146
+ );
147
+ }
148
+
149
+ export function Link({ to, activeClassName, matchPrefix, className, children, onClick, ...rest }: LinkProps) {
150
+ const path = useRoute();
151
+ const active = isActive(path, to, !matchPrefix);
152
+ const cls = [className, active ? activeClassName : undefined].filter(Boolean).join(" ");
153
+ // Deliberately NO href: navigation is client-side anyway, and an
154
+ // href makes the webview show the URL preview bubble in the bottom
155
+ // corner on hover - a browser artifact that looks wrong in a
156
+ // desktop app. role/tabIndex/keyboard keep it accessible.
157
+ return (
158
+ <a
159
+ role="link"
160
+ tabIndex={0}
161
+ className={cls || undefined}
162
+ data-active={active ? "true" : "false"}
163
+ aria-current={active ? "page" : undefined}
164
+ onClick={(e) => {
165
+ onClick?.(e);
166
+ if (e.defaultPrevented || e.button !== 0) return;
167
+ navigate(to);
168
+ }}
169
+ onKeyDown={(e) => {
170
+ if (e.key === "Enter" || e.key === " ") {
171
+ e.preventDefault();
172
+ navigate(to);
173
+ }
174
+ }}
175
+ {...rest}
176
+ >
177
+ {children}
178
+ </a>
179
+ );
180
+ }
package/src/service.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import { callGo, connect } from "./socket";
2
+ import { callGo, connect, GantryCallError } from "./socket";
3
3
 
4
4
  export interface Service {
5
5
  /** call awaits a Go function registered on this service (ui.Calls). */
@@ -35,6 +35,8 @@ export function useService(name: string): Service {
35
35
  export interface CallResult<T> {
36
36
  data: T | undefined;
37
37
  error: string | null;
38
+ /** The gerr code of a failed call ("panic.call", "auth.expired", ...). */
39
+ code: string | null;
38
40
  loading: boolean;
39
41
  /** reload re-runs the call. */
40
42
  reload: () => void;
@@ -48,6 +50,7 @@ export interface CallResult<T> {
48
50
  export function useCall<T = unknown>(key: string, name: string, payload?: unknown): CallResult<T> {
49
51
  const [data, setData] = useState<T | undefined>(undefined);
50
52
  const [error, setError] = useState<string | null>(null);
53
+ const [code, setCode] = useState<string | null>(null);
51
54
  const [loading, setLoading] = useState(true);
52
55
  const [tick, setTick] = useState(0);
53
56
  const alive = useRef(true);
@@ -57,6 +60,7 @@ export function useCall<T = unknown>(key: string, name: string, payload?: unknow
57
60
  alive.current = true;
58
61
  setLoading(true);
59
62
  setError(null);
63
+ setCode(null);
60
64
  callGo(key, name, payload)
61
65
  .then((v) => {
62
66
  if (alive.current) {
@@ -67,6 +71,7 @@ export function useCall<T = unknown>(key: string, name: string, payload?: unknow
67
71
  .catch((e: Error) => {
68
72
  if (alive.current) {
69
73
  setError(e.message);
74
+ if (e instanceof GantryCallError && e.code) setCode(e.code);
70
75
  setLoading(false);
71
76
  }
72
77
  });
@@ -78,5 +83,5 @@ export function useCall<T = unknown>(key: string, name: string, payload?: unknow
78
83
  }, [key, name, payloadKey, tick]);
79
84
 
80
85
  const reload = useCallback(() => setTick((t) => t + 1), []);
81
- return { data, error, loading, reload };
86
+ return { data, error, code, loading, reload };
82
87
  }