@drakkar.software/octospaces-ui 0.1.0 → 0.2.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/dist/index.d.ts +132 -1
- package/dist/index.js +309 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/discover/DiscoverList.tsx +92 -0
- package/src/discover/DiscoverRow.tsx +96 -0
- package/src/discover/DiscoverScreen.tsx +255 -0
- package/src/discover/filter.test.ts +72 -0
- package/src/discover/filter.ts +24 -0
- package/src/discover/index.ts +8 -0
- package/src/discover/types.ts +22 -0
- package/src/index.ts +12 -0
- package/tsconfig.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -206,4 +206,135 @@ declare function focusRingStyle(palette: Palette, width?: number): {
|
|
|
206
206
|
/** Map a semantic status name to its palette color. */
|
|
207
207
|
declare function statusColor(palette: Palette, status: 'success' | 'warning' | 'danger' | 'info' | string, muted?: boolean): string;
|
|
208
208
|
|
|
209
|
-
|
|
209
|
+
/**
|
|
210
|
+
* Shared entry type for the generic Discover surface.
|
|
211
|
+
*
|
|
212
|
+
* Structurally compatible with `PublicObjectDirEntry` from
|
|
213
|
+
* `@drakkar.software/octospaces-sdk` (which adds the same fields). Apps pass
|
|
214
|
+
* `readObjectDirectory()` results directly to `loadEntries`; no runtime
|
|
215
|
+
* conversion needed.
|
|
216
|
+
*/
|
|
217
|
+
interface DiscoverEntry {
|
|
218
|
+
/** The space this object belongs to. */
|
|
219
|
+
spaceId: string;
|
|
220
|
+
/** The object's node id. */
|
|
221
|
+
id: string;
|
|
222
|
+
/** Display title (empty string when the server stripped it). */
|
|
223
|
+
title: string;
|
|
224
|
+
/** The node type (e.g. `'page'`, `'board'`, `'task'`). */
|
|
225
|
+
type: string;
|
|
226
|
+
/** Optional emoji short-code or unicode character shown before the title. */
|
|
227
|
+
emoji?: string;
|
|
228
|
+
/** Server-side last-updated timestamp (epoch ms). */
|
|
229
|
+
updatedAt?: number;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Case-insensitive substring filter over a `DiscoverEntry[]`.
|
|
234
|
+
*
|
|
235
|
+
* Returns the original array reference unchanged when `query` is blank so the
|
|
236
|
+
* caller can skip a re-render. Pure function — no side effects.
|
|
237
|
+
*/
|
|
238
|
+
declare function filterDiscoverEntries(entries: DiscoverEntry[], query: string): DiscoverEntry[];
|
|
239
|
+
/**
|
|
240
|
+
* Sort discover entries by updatedAt descending (most recent first).
|
|
241
|
+
* Entries without an `updatedAt` field sort last. Pure function.
|
|
242
|
+
*/
|
|
243
|
+
declare function sortDiscoverEntries(entries: DiscoverEntry[]): DiscoverEntry[];
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* A single row in the Discover list — shows the object's emoji/icon, title,
|
|
247
|
+
* and type. All app-specific behaviour is injected via props:
|
|
248
|
+
* - `renderIcon` — render a type/emoji icon; receives the entry and must
|
|
249
|
+
* return a ReactNode (null for no icon).
|
|
250
|
+
* - `onOpen` — called when the row is pressed.
|
|
251
|
+
*
|
|
252
|
+
* Styled entirely from the injected {@link Theme} via `useOctoSpacesTheme()`.
|
|
253
|
+
*/
|
|
254
|
+
|
|
255
|
+
interface DiscoverRowProps {
|
|
256
|
+
entry: DiscoverEntry;
|
|
257
|
+
/** Render a leading icon for the entry. Return `null` to show nothing. */
|
|
258
|
+
renderIcon?: (entry: DiscoverEntry) => React.ReactNode;
|
|
259
|
+
/** Called when the user taps the row. */
|
|
260
|
+
onOpen: (entry: DiscoverEntry) => void;
|
|
261
|
+
}
|
|
262
|
+
declare function DiscoverRow({ entry, renderIcon, onOpen }: DiscoverRowProps): React.JSX.Element;
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* A themed FlatList wrapper that renders a list of {@link DiscoverEntry} rows.
|
|
266
|
+
*
|
|
267
|
+
* All app-specific behaviour is injected via props so this component has zero
|
|
268
|
+
* imports from any specific OctoSpaces app.
|
|
269
|
+
*/
|
|
270
|
+
|
|
271
|
+
interface DiscoverListProps {
|
|
272
|
+
entries: DiscoverEntry[];
|
|
273
|
+
/** Render a leading icon for each row — see {@link DiscoverRowProps.renderIcon}. */
|
|
274
|
+
renderIcon?: (entry: DiscoverEntry) => React.ReactNode;
|
|
275
|
+
/** Called when a row is tapped. */
|
|
276
|
+
onOpen: (entry: DiscoverEntry) => void;
|
|
277
|
+
/** Text shown when `entries` is empty (default: "No public objects found"). */
|
|
278
|
+
emptyMessage?: string;
|
|
279
|
+
/** Whether a pull-to-refresh is currently in progress. */
|
|
280
|
+
refreshing?: boolean;
|
|
281
|
+
/** Called when the user pulls to refresh. */
|
|
282
|
+
onRefresh?: () => void;
|
|
283
|
+
}
|
|
284
|
+
declare function DiscoverList({ entries, renderIcon, onOpen, emptyMessage, refreshing, onRefresh, }: DiscoverListProps): React.JSX.Element;
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Generic public-object discovery screen.
|
|
288
|
+
*
|
|
289
|
+
* Loads the world-readable public-object directory via `loadEntries`, renders a
|
|
290
|
+
* search bar, and delegates row rendering + tap behaviour to the injected props.
|
|
291
|
+
* No app-specific logic lives here — all customisation is via props:
|
|
292
|
+
*
|
|
293
|
+
* ```tsx
|
|
294
|
+
* <DiscoverScreen
|
|
295
|
+
* loadEntries={readObjectDirectory}
|
|
296
|
+
* renderIcon={(e) => <TypeIcon entry={e} />}
|
|
297
|
+
* onOpen={(e) => router.push({ pathname: routeForNode(e), params: { id: e.id, spaceId: e.spaceId } })}
|
|
298
|
+
* />
|
|
299
|
+
* ```
|
|
300
|
+
*
|
|
301
|
+
* State machine:
|
|
302
|
+
* idle → loading → (ready | error)
|
|
303
|
+
* Any pull of `loadEntries` updates the entries; errors show a retry button.
|
|
304
|
+
*/
|
|
305
|
+
|
|
306
|
+
interface DiscoverScreenProps {
|
|
307
|
+
/**
|
|
308
|
+
* Async function that resolves to the current public-object directory.
|
|
309
|
+
* Typically `readObjectDirectory` from `@drakkar.software/octospaces-sdk`.
|
|
310
|
+
* Called on mount and when `refresh()` is triggered.
|
|
311
|
+
*/
|
|
312
|
+
loadEntries: () => Promise<DiscoverEntry[]>;
|
|
313
|
+
/** Render a leading icon for each row. */
|
|
314
|
+
renderIcon?: (entry: DiscoverEntry) => React.ReactNode;
|
|
315
|
+
/** Called when the user taps a row — navigate to the object. */
|
|
316
|
+
onOpen: (entry: DiscoverEntry) => void;
|
|
317
|
+
/**
|
|
318
|
+
* Optional heading text shown above the search bar.
|
|
319
|
+
* @default "Discover"
|
|
320
|
+
*/
|
|
321
|
+
title?: string;
|
|
322
|
+
/**
|
|
323
|
+
* Text shown when the directory is empty after loading.
|
|
324
|
+
* @default "No public objects yet"
|
|
325
|
+
*/
|
|
326
|
+
emptyMessage?: string;
|
|
327
|
+
/**
|
|
328
|
+
* Text shown when the directory is empty due to an active search query.
|
|
329
|
+
* @default "No results for «query»"
|
|
330
|
+
*/
|
|
331
|
+
emptySearchMessage?: string;
|
|
332
|
+
/**
|
|
333
|
+
* Whether to show the inline search bar.
|
|
334
|
+
* @default true
|
|
335
|
+
*/
|
|
336
|
+
searchEnabled?: boolean;
|
|
337
|
+
}
|
|
338
|
+
declare function DiscoverScreen({ loadEntries, renderIcon, onOpen, title, emptyMessage, emptySearchMessage, searchEnabled, }: DiscoverScreenProps): React.JSX.Element;
|
|
339
|
+
|
|
340
|
+
export { type ColorScheme, type DiscoverEntry, DiscoverList, type DiscoverListProps, DiscoverRow, type DiscoverRowProps, DiscoverScreen, type DiscoverScreenProps, type Easing, type Fonts, type LabelTracking, type Layers, type Layout, type Motion, type MotionToken, OctoSpacesThemeProvider, type OctoSpacesThemeProviderProps, type Opacity, type Palette, type Radii, type ShadowToken, type Shadows, type Spacing, type Swatches, type Theme, type TypeScale, type Typography, avatarTint, filterDiscoverEntries, focusRingStyle, glowShadow, paperBorder, presenceColor, sortDiscoverEntries, statusColor, swatch, useOctoSpacesTheme, verificationColor };
|
package/dist/index.js
CHANGED
|
@@ -92,13 +92,322 @@ function statusColor(palette, status, muted = false) {
|
|
|
92
92
|
return palette.info;
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
|
+
|
|
96
|
+
// src/discover/filter.ts
|
|
97
|
+
function filterDiscoverEntries(entries, query) {
|
|
98
|
+
const q = query.trim().toLowerCase();
|
|
99
|
+
if (!q) return entries;
|
|
100
|
+
return entries.filter((e) => e.title.toLowerCase().includes(q));
|
|
101
|
+
}
|
|
102
|
+
function sortDiscoverEntries(entries) {
|
|
103
|
+
return [...entries].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/discover/DiscoverRow.tsx
|
|
107
|
+
import React2, { useCallback } from "react";
|
|
108
|
+
import { Pressable, Text, View } from "react-native";
|
|
109
|
+
function DiscoverRow({ entry, renderIcon, onOpen }) {
|
|
110
|
+
const theme = useOctoSpacesTheme();
|
|
111
|
+
const handlePress = useCallback(() => {
|
|
112
|
+
onOpen(entry);
|
|
113
|
+
}, [entry, onOpen]);
|
|
114
|
+
const icon = renderIcon ? renderIcon(entry) : null;
|
|
115
|
+
const displayEmoji = !icon && entry.emoji ? entry.emoji : null;
|
|
116
|
+
return /* @__PURE__ */ React2.createElement(
|
|
117
|
+
Pressable,
|
|
118
|
+
{
|
|
119
|
+
onPress: handlePress,
|
|
120
|
+
style: ({ pressed }) => ({
|
|
121
|
+
flexDirection: "row",
|
|
122
|
+
alignItems: "center",
|
|
123
|
+
paddingVertical: theme.spacing["3"] ?? 12,
|
|
124
|
+
paddingHorizontal: theme.spacing["4"] ?? 16,
|
|
125
|
+
backgroundColor: pressed ? theme.colors.surface ?? "#f5f5f5" : "transparent",
|
|
126
|
+
borderRadius: theme.radii["sm"] ?? 6
|
|
127
|
+
}),
|
|
128
|
+
accessibilityRole: "button",
|
|
129
|
+
accessibilityLabel: entry.title || "Untitled"
|
|
130
|
+
},
|
|
131
|
+
(icon || displayEmoji) && /* @__PURE__ */ React2.createElement(
|
|
132
|
+
View,
|
|
133
|
+
{
|
|
134
|
+
style: {
|
|
135
|
+
width: 28,
|
|
136
|
+
height: 28,
|
|
137
|
+
marginRight: theme.spacing["2"] ?? 8,
|
|
138
|
+
alignItems: "center",
|
|
139
|
+
justifyContent: "center",
|
|
140
|
+
flexShrink: 0
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
icon ?? /* @__PURE__ */ React2.createElement(Text, { style: { fontSize: 18, lineHeight: 24 } }, displayEmoji)
|
|
144
|
+
),
|
|
145
|
+
/* @__PURE__ */ React2.createElement(View, { style: { flex: 1, minWidth: 0 } }, /* @__PURE__ */ React2.createElement(
|
|
146
|
+
Text,
|
|
147
|
+
{
|
|
148
|
+
numberOfLines: 1,
|
|
149
|
+
style: {
|
|
150
|
+
fontSize: theme.type["body"]?.size ?? 15,
|
|
151
|
+
lineHeight: theme.type["body"]?.lineHeight ?? 22,
|
|
152
|
+
color: entry.title ? theme.colors.text : theme.colors.textTertiary,
|
|
153
|
+
fontFamily: theme.fonts["body"] ?? void 0
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
entry.title || "Untitled"
|
|
157
|
+
), /* @__PURE__ */ React2.createElement(
|
|
158
|
+
Text,
|
|
159
|
+
{
|
|
160
|
+
numberOfLines: 1,
|
|
161
|
+
style: {
|
|
162
|
+
fontSize: theme.type["caption"]?.size ?? 12,
|
|
163
|
+
lineHeight: theme.type["caption"]?.lineHeight ?? 18,
|
|
164
|
+
color: theme.colors.textSecondary,
|
|
165
|
+
marginTop: 1,
|
|
166
|
+
textTransform: "capitalize"
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
entry.type
|
|
170
|
+
))
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/discover/DiscoverList.tsx
|
|
175
|
+
import React3, { useCallback as useCallback2 } from "react";
|
|
176
|
+
import { FlatList, RefreshControl, Text as Text2, View as View2 } from "react-native";
|
|
177
|
+
function DiscoverList({
|
|
178
|
+
entries,
|
|
179
|
+
renderIcon,
|
|
180
|
+
onOpen,
|
|
181
|
+
emptyMessage = "No public objects found",
|
|
182
|
+
refreshing,
|
|
183
|
+
onRefresh
|
|
184
|
+
}) {
|
|
185
|
+
const theme = useOctoSpacesTheme();
|
|
186
|
+
const renderItem = useCallback2(
|
|
187
|
+
({ item }) => /* @__PURE__ */ React3.createElement(DiscoverRow, { entry: item, renderIcon, onOpen }),
|
|
188
|
+
[renderIcon, onOpen]
|
|
189
|
+
);
|
|
190
|
+
const keyExtractor = useCallback2(
|
|
191
|
+
(item) => `${item.spaceId}:${item.id}`,
|
|
192
|
+
[]
|
|
193
|
+
);
|
|
194
|
+
if (entries.length === 0) {
|
|
195
|
+
return /* @__PURE__ */ React3.createElement(
|
|
196
|
+
View2,
|
|
197
|
+
{
|
|
198
|
+
style: {
|
|
199
|
+
flex: 1,
|
|
200
|
+
alignItems: "center",
|
|
201
|
+
justifyContent: "center",
|
|
202
|
+
paddingHorizontal: theme.spacing["6"] ?? 24
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
/* @__PURE__ */ React3.createElement(
|
|
206
|
+
Text2,
|
|
207
|
+
{
|
|
208
|
+
style: {
|
|
209
|
+
fontSize: theme.type["body"]?.size ?? 15,
|
|
210
|
+
color: theme.colors.textSecondary,
|
|
211
|
+
textAlign: "center"
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
emptyMessage
|
|
215
|
+
)
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
return /* @__PURE__ */ React3.createElement(
|
|
219
|
+
FlatList,
|
|
220
|
+
{
|
|
221
|
+
data: entries,
|
|
222
|
+
renderItem,
|
|
223
|
+
keyExtractor,
|
|
224
|
+
contentContainerStyle: { paddingVertical: theme.spacing["1"] ?? 4 },
|
|
225
|
+
showsVerticalScrollIndicator: false,
|
|
226
|
+
removeClippedSubviews: true,
|
|
227
|
+
refreshControl: onRefresh ? /* @__PURE__ */ React3.createElement(
|
|
228
|
+
RefreshControl,
|
|
229
|
+
{
|
|
230
|
+
refreshing: refreshing ?? false,
|
|
231
|
+
onRefresh,
|
|
232
|
+
tintColor: theme.colors.primary
|
|
233
|
+
}
|
|
234
|
+
) : void 0
|
|
235
|
+
}
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// src/discover/DiscoverScreen.tsx
|
|
240
|
+
import React4, { useCallback as useCallback3, useEffect, useRef, useState } from "react";
|
|
241
|
+
import { ActivityIndicator, Pressable as Pressable2, Text as Text3, TextInput, View as View3 } from "react-native";
|
|
242
|
+
function DiscoverScreen({
|
|
243
|
+
loadEntries,
|
|
244
|
+
renderIcon,
|
|
245
|
+
onOpen,
|
|
246
|
+
title = "Discover",
|
|
247
|
+
emptyMessage = "No public objects yet",
|
|
248
|
+
emptySearchMessage,
|
|
249
|
+
searchEnabled = true
|
|
250
|
+
}) {
|
|
251
|
+
const theme = useOctoSpacesTheme();
|
|
252
|
+
const [state, setState] = useState({ status: "idle" });
|
|
253
|
+
const [query, setQuery] = useState("");
|
|
254
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
255
|
+
const cancelledRef = useRef(false);
|
|
256
|
+
const load = useCallback3(async () => {
|
|
257
|
+
setState({ status: "loading" });
|
|
258
|
+
try {
|
|
259
|
+
const raw = await loadEntries();
|
|
260
|
+
if (cancelledRef.current) return;
|
|
261
|
+
setState({ status: "ready", entries: sortDiscoverEntries(raw) });
|
|
262
|
+
} catch (err) {
|
|
263
|
+
if (cancelledRef.current) return;
|
|
264
|
+
setState({
|
|
265
|
+
status: "error",
|
|
266
|
+
message: err instanceof Error ? err.message : "Failed to load directory"
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}, [loadEntries]);
|
|
270
|
+
const handleRefresh = useCallback3(async () => {
|
|
271
|
+
setRefreshing(true);
|
|
272
|
+
try {
|
|
273
|
+
const raw = await loadEntries();
|
|
274
|
+
if (cancelledRef.current) return;
|
|
275
|
+
setState({ status: "ready", entries: sortDiscoverEntries(raw) });
|
|
276
|
+
} catch {
|
|
277
|
+
} finally {
|
|
278
|
+
if (!cancelledRef.current) setRefreshing(false);
|
|
279
|
+
}
|
|
280
|
+
}, [loadEntries]);
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
cancelledRef.current = false;
|
|
283
|
+
void load();
|
|
284
|
+
return () => {
|
|
285
|
+
cancelledRef.current = true;
|
|
286
|
+
};
|
|
287
|
+
}, [load]);
|
|
288
|
+
const allEntries = state.status === "ready" ? state.entries : [];
|
|
289
|
+
const visibleEntries = filterDiscoverEntries(allEntries, query);
|
|
290
|
+
const noSearchResults = !!query.trim() && visibleEntries.length === 0 && allEntries.length > 0;
|
|
291
|
+
const resolvedEmptyMessage = noSearchResults ? emptySearchMessage ?? `No results for "${query.trim()}"` : emptyMessage;
|
|
292
|
+
const sp2 = theme.spacing["2"] ?? 8;
|
|
293
|
+
const sp3 = theme.spacing["3"] ?? 12;
|
|
294
|
+
const sp4 = theme.spacing["4"] ?? 16;
|
|
295
|
+
const radMd = theme.radii["md"] ?? 8;
|
|
296
|
+
return /* @__PURE__ */ React4.createElement(View3, { style: { flex: 1, backgroundColor: theme.colors.background } }, /* @__PURE__ */ React4.createElement(
|
|
297
|
+
View3,
|
|
298
|
+
{
|
|
299
|
+
style: {
|
|
300
|
+
paddingHorizontal: sp4,
|
|
301
|
+
paddingTop: sp4,
|
|
302
|
+
paddingBottom: sp2
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
/* @__PURE__ */ React4.createElement(
|
|
306
|
+
Text3,
|
|
307
|
+
{
|
|
308
|
+
style: {
|
|
309
|
+
fontSize: theme.type["title2"]?.size ?? 22,
|
|
310
|
+
fontWeight: theme.type["title2"]?.weight ?? "700",
|
|
311
|
+
lineHeight: theme.type["title2"]?.lineHeight ?? 28,
|
|
312
|
+
color: theme.colors.text,
|
|
313
|
+
fontFamily: theme.fonts["heading"] ?? void 0,
|
|
314
|
+
marginBottom: sp3
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
title
|
|
318
|
+
),
|
|
319
|
+
searchEnabled && /* @__PURE__ */ React4.createElement(
|
|
320
|
+
View3,
|
|
321
|
+
{
|
|
322
|
+
style: {
|
|
323
|
+
flexDirection: "row",
|
|
324
|
+
alignItems: "center",
|
|
325
|
+
backgroundColor: theme.colors.surfaceInput ?? theme.colors.surface,
|
|
326
|
+
borderRadius: radMd,
|
|
327
|
+
borderWidth: 1,
|
|
328
|
+
borderColor: theme.colors.borderSubtle,
|
|
329
|
+
paddingHorizontal: sp3,
|
|
330
|
+
height: 40
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
/* @__PURE__ */ React4.createElement(
|
|
334
|
+
TextInput,
|
|
335
|
+
{
|
|
336
|
+
placeholder: "Search\u2026",
|
|
337
|
+
placeholderTextColor: theme.colors.textTertiary,
|
|
338
|
+
value: query,
|
|
339
|
+
onChangeText: setQuery,
|
|
340
|
+
style: {
|
|
341
|
+
flex: 1,
|
|
342
|
+
fontSize: theme.type["body"]?.size ?? 15,
|
|
343
|
+
color: theme.colors.text,
|
|
344
|
+
fontFamily: theme.fonts["body"] ?? void 0
|
|
345
|
+
},
|
|
346
|
+
returnKeyType: "search",
|
|
347
|
+
clearButtonMode: "while-editing",
|
|
348
|
+
accessibilityLabel: "Search discover"
|
|
349
|
+
}
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
), state.status === "loading" ? /* @__PURE__ */ React4.createElement(View3, { style: { flex: 1, alignItems: "center", justifyContent: "center" } }, /* @__PURE__ */ React4.createElement(ActivityIndicator, { color: theme.colors.primary })) : state.status === "error" ? /* @__PURE__ */ React4.createElement(
|
|
353
|
+
View3,
|
|
354
|
+
{
|
|
355
|
+
style: {
|
|
356
|
+
flex: 1,
|
|
357
|
+
alignItems: "center",
|
|
358
|
+
justifyContent: "center",
|
|
359
|
+
paddingHorizontal: sp4
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
/* @__PURE__ */ React4.createElement(
|
|
363
|
+
Text3,
|
|
364
|
+
{
|
|
365
|
+
style: {
|
|
366
|
+
color: theme.colors.textSecondary,
|
|
367
|
+
fontSize: theme.type["body"]?.size ?? 15,
|
|
368
|
+
textAlign: "center",
|
|
369
|
+
marginBottom: sp3
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
state.message
|
|
373
|
+
),
|
|
374
|
+
/* @__PURE__ */ React4.createElement(
|
|
375
|
+
Pressable2,
|
|
376
|
+
{
|
|
377
|
+
onPress: load,
|
|
378
|
+
style: {
|
|
379
|
+
paddingHorizontal: sp4,
|
|
380
|
+
paddingVertical: sp2,
|
|
381
|
+
backgroundColor: theme.colors.primary,
|
|
382
|
+
borderRadius: radMd
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
/* @__PURE__ */ React4.createElement(Text3, { style: { color: theme.colors.textOnPrimary, fontWeight: "600" } }, "Retry")
|
|
386
|
+
)
|
|
387
|
+
) : /* @__PURE__ */ React4.createElement(
|
|
388
|
+
DiscoverList,
|
|
389
|
+
{
|
|
390
|
+
entries: visibleEntries,
|
|
391
|
+
renderIcon,
|
|
392
|
+
onOpen,
|
|
393
|
+
emptyMessage: resolvedEmptyMessage,
|
|
394
|
+
refreshing,
|
|
395
|
+
onRefresh: handleRefresh
|
|
396
|
+
}
|
|
397
|
+
));
|
|
398
|
+
}
|
|
95
399
|
export {
|
|
400
|
+
DiscoverList,
|
|
401
|
+
DiscoverRow,
|
|
402
|
+
DiscoverScreen,
|
|
96
403
|
OctoSpacesThemeProvider,
|
|
97
404
|
avatarTint,
|
|
405
|
+
filterDiscoverEntries,
|
|
98
406
|
focusRingStyle,
|
|
99
407
|
glowShadow,
|
|
100
408
|
paperBorder,
|
|
101
409
|
presenceColor,
|
|
410
|
+
sortDiscoverEntries,
|
|
102
411
|
statusColor,
|
|
103
412
|
swatch,
|
|
104
413
|
useOctoSpacesTheme,
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/theme/provider.tsx","../src/theme/helpers.ts"],"sourcesContent":["/**\n * Theme injection plumbing — provider + hook.\n *\n * The package carries ZERO theme values. The host app builds a concrete {@link Theme}\n * and wraps its tree in `<OctoSpacesThemeProvider theme={resolvedTheme}>`.\n * All primitives then call `useOctoSpacesTheme()` to read the active theme.\n */\nimport React, { createContext, useContext } from 'react';\nimport type { Theme } from './types.js';\n\nconst ThemeContext = createContext<Theme | null>(null);\n\nexport interface OctoSpacesThemeProviderProps {\n theme: Theme;\n children: React.ReactNode;\n}\n\n/**\n * Wrap your root component with this provider to inject the resolved Theme into\n * every primitive from `@drakkar.software/octospaces-ui`.\n *\n * @example\n * ```tsx\n * import { OctoSpacesThemeProvider } from '@drakkar.software/octospaces-ui';\n * import { resolvedTheme } from '@/theme'; // your app's theme\n *\n * export default function App() {\n * return (\n * <OctoSpacesThemeProvider theme={resolvedTheme}>\n * <RootNavigator />\n * </OctoSpacesThemeProvider>\n * );\n * }\n * ```\n */\nexport function OctoSpacesThemeProvider({ theme, children }: OctoSpacesThemeProviderProps) {\n return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;\n}\n\n/**\n * Read the active theme. Throws if called outside an `<OctoSpacesThemeProvider>` —\n * this is intentional: a missing provider means primitives have no colors/spacing,\n * so a hard failure with a clear message is better than a silent rendering bug.\n */\nexport function useOctoSpacesTheme(): Theme {\n const theme = useContext(ThemeContext);\n if (!theme) {\n throw new Error(\n '[octospaces-ui] useOctoSpacesTheme() called outside of <OctoSpacesThemeProvider>. ' +\n 'Wrap your root component with <OctoSpacesThemeProvider theme={…}>.',\n );\n }\n return theme;\n}\n","/**\n * Pure palette-helper functions over a {@link Palette}. No theme values live here —\n * only functions that DERIVE from the injected palette (or from passed-in colors).\n *\n * Import these from `@drakkar.software/octospaces-ui` (re-exported by `src/index.ts`).\n */\nimport type { Palette, ShadowToken, Theme } from './types.js';\n\n// ── Presence ──────────────────────────────────────────────────────────────────\n\n/** Map a presence status string to the corresponding palette color. */\nexport function presenceColor(\n palette: Palette,\n status: 'online' | 'away' | 'busy' | 'offline' | string,\n): string {\n switch (status) {\n case 'online': return palette.presenceOnline;\n case 'away': return palette.presenceAway;\n case 'busy': return palette.presenceBusy;\n default: return palette.presenceOffline;\n }\n}\n\n// ── Verification ──────────────────────────────────────────────────────────────\n\n/** Map a verification level to the corresponding palette color. */\nexport function verificationColor(\n palette: Palette,\n level: 'verified' | 'partial' | 'none' | string,\n): string {\n switch (level) {\n case 'verified': return palette.verificationVerified;\n case 'partial': return palette.verificationPartial;\n default: return palette.verificationNone;\n }\n}\n\n// ── Avatar ────────────────────────────────────────────────────────────────────\n\nconst AVATAR_TINT_KEYS = [\n 'primary', 'success', 'warning', 'danger', 'info',\n] as const;\n\n/** Stable avatar background tint derived from a userId string. */\nexport function avatarTint(palette: Palette, userId: string): string {\n let hash = 0;\n for (let i = 0; i < userId.length; i++) hash = (hash * 31 + userId.charCodeAt(i)) | 0;\n const key = AVATAR_TINT_KEYS[Math.abs(hash) % AVATAR_TINT_KEYS.length];\n return (palette as unknown as Record<string, string>)[key] ?? palette.primary;\n}\n\n// ── Swatch ────────────────────────────────────────────────────────────────────\n\n/** Look up a named swatch; falls back to `palette.primary` if absent. */\nexport function swatch(theme: Theme, name: string): string {\n return theme.swatches[name] ?? theme.colors.primary;\n}\n\n// ── Borders ───────────────────────────────────────────────────────────────────\n\n/** Derive a `borderColor` value for a \"paper\" (elevated surface) border. */\nexport function paperBorder(palette: Palette): string {\n return palette.borderSubtle;\n}\n\n// ── Shadows ───────────────────────────────────────────────────────────────────\n\n/** Build a glow shadow token from a base color (used for focus rings, highlights). */\nexport function glowShadow(color: string, radius = 8, opacity = 0.4): ShadowToken {\n return {\n shadowColor: color,\n shadowOffset: { width: 0, height: 0 },\n shadowOpacity: opacity,\n shadowRadius: radius,\n elevation: 4,\n };\n}\n\n// ── Focus ring ────────────────────────────────────────────────────────────────\n\n/** Style object for a keyboard-focus indicator (web + React Native). */\nexport function focusRingStyle(\n palette: Palette,\n width = 2,\n): {\n borderWidth: number;\n borderColor: string;\n borderStyle: 'solid';\n} {\n return { borderWidth: width, borderColor: palette.focus, borderStyle: 'solid' };\n}\n\n// ── Status color ──────────────────────────────────────────────────────────────\n\n/** Map a semantic status name to its palette color. */\nexport function statusColor(\n palette: Palette,\n status: 'success' | 'warning' | 'danger' | 'info' | string,\n muted = false,\n): string {\n if (muted) {\n switch (status) {\n case 'success': return palette.successMuted;\n case 'warning': return palette.warningMuted;\n case 'danger': return palette.dangerMuted;\n default: return palette.infoMuted;\n }\n }\n switch (status) {\n case 'success': return palette.success;\n case 'warning': return palette.warning;\n case 'danger': return palette.danger;\n default: return palette.info;\n }\n}\n"],"mappings":";AAOA,OAAO,SAAS,eAAe,kBAAkB;AAGjD,IAAM,eAAe,cAA4B,IAAI;AAyB9C,SAAS,wBAAwB,EAAE,OAAO,SAAS,GAAiC;AACzF,SAAO,oCAAC,aAAa,UAAb,EAAsB,OAAO,SAAQ,QAAS;AACxD;AAOO,SAAS,qBAA4B;AAC1C,QAAM,QAAQ,WAAW,YAAY;AACrC,MAAI,CAAC,OAAO;AACV,UAAM,IAAI;AAAA,MACR;AAAA,IAEF;AAAA,EACF;AACA,SAAO;AACT;;;AC1CO,SAAS,cACd,SACA,QACQ;AACR,UAAQ,QAAQ;AAAA,IACd,KAAK;AAAU,aAAO,QAAQ;AAAA,IAC9B,KAAK;AAAU,aAAO,QAAQ;AAAA,IAC9B,KAAK;AAAU,aAAO,QAAQ;AAAA,IAC9B;AAAe,aAAO,QAAQ;AAAA,EAChC;AACF;AAKO,SAAS,kBACd,SACA,OACQ;AACR,UAAQ,OAAO;AAAA,IACb,KAAK;AAAY,aAAO,QAAQ;AAAA,IAChC,KAAK;AAAY,aAAO,QAAQ;AAAA,IAChC;AAAiB,aAAO,QAAQ;AAAA,EAClC;AACF;AAIA,IAAM,mBAAmB;AAAA,EACvB;AAAA,EAAW;AAAA,EAAW;AAAA,EAAW;AAAA,EAAU;AAC7C;AAGO,SAAS,WAAW,SAAkB,QAAwB;AACnE,MAAI,OAAO;AACX,WAAS,IAAI,GAAG,IAAI,OAAO,QAAQ,IAAK,QAAQ,OAAO,KAAK,OAAO,WAAW,CAAC,IAAK;AACpF,QAAM,MAAM,iBAAiB,KAAK,IAAI,IAAI,IAAI,iBAAiB,MAAM;AACrE,SAAQ,QAA8C,GAAG,KAAK,QAAQ;AACxE;AAKO,SAAS,OAAO,OAAc,MAAsB;AACzD,SAAO,MAAM,SAAS,IAAI,KAAK,MAAM,OAAO;AAC9C;AAKO,SAAS,YAAY,SAA0B;AACpD,SAAO,QAAQ;AACjB;AAKO,SAAS,WAAW,OAAe,SAAS,GAAG,UAAU,KAAkB;AAChF,SAAO;AAAA,IACL,aAAa;AAAA,IACb,cAAc,EAAE,OAAO,GAAG,QAAQ,EAAE;AAAA,IACpC,eAAe;AAAA,IACf,cAAc;AAAA,IACd,WAAW;AAAA,EACb;AACF;AAKO,SAAS,eACd,SACA,QAAQ,GAKR;AACA,SAAO,EAAE,aAAa,OAAO,aAAa,QAAQ,OAAO,aAAa,QAAQ;AAChF;AAKO,SAAS,YACd,SACA,QACA,QAAQ,OACA;AACR,MAAI,OAAO;AACT,YAAQ,QAAQ;AAAA,MACd,KAAK;AAAW,eAAO,QAAQ;AAAA,MAC/B,KAAK;AAAW,eAAO,QAAQ;AAAA,MAC/B,KAAK;AAAW,eAAO,QAAQ;AAAA,MAC/B;AAAgB,eAAO,QAAQ;AAAA,IACjC;AAAA,EACF;AACA,UAAQ,QAAQ;AAAA,IACd,KAAK;AAAW,aAAO,QAAQ;AAAA,IAC/B,KAAK;AAAW,aAAO,QAAQ;AAAA,IAC/B,KAAK;AAAW,aAAO,QAAQ;AAAA,IAC/B;AAAgB,aAAO,QAAQ;AAAA,EACjC;AACF;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/theme/provider.tsx","../src/theme/helpers.ts","../src/discover/filter.ts","../src/discover/DiscoverRow.tsx","../src/discover/DiscoverList.tsx","../src/discover/DiscoverScreen.tsx"],"sourcesContent":["/**\n * Theme injection plumbing — provider + hook.\n *\n * The package carries ZERO theme values. The host app builds a concrete {@link Theme}\n * and wraps its tree in `<OctoSpacesThemeProvider theme={resolvedTheme}>`.\n * All primitives then call `useOctoSpacesTheme()` to read the active theme.\n */\nimport React, { createContext, useContext } from 'react';\nimport type { Theme } from './types.js';\n\nconst ThemeContext = createContext<Theme | null>(null);\n\nexport interface OctoSpacesThemeProviderProps {\n theme: Theme;\n children: React.ReactNode;\n}\n\n/**\n * Wrap your root component with this provider to inject the resolved Theme into\n * every primitive from `@drakkar.software/octospaces-ui`.\n *\n * @example\n * ```tsx\n * import { OctoSpacesThemeProvider } from '@drakkar.software/octospaces-ui';\n * import { resolvedTheme } from '@/theme'; // your app's theme\n *\n * export default function App() {\n * return (\n * <OctoSpacesThemeProvider theme={resolvedTheme}>\n * <RootNavigator />\n * </OctoSpacesThemeProvider>\n * );\n * }\n * ```\n */\nexport function OctoSpacesThemeProvider({ theme, children }: OctoSpacesThemeProviderProps) {\n return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;\n}\n\n/**\n * Read the active theme. Throws if called outside an `<OctoSpacesThemeProvider>` —\n * this is intentional: a missing provider means primitives have no colors/spacing,\n * so a hard failure with a clear message is better than a silent rendering bug.\n */\nexport function useOctoSpacesTheme(): Theme {\n const theme = useContext(ThemeContext);\n if (!theme) {\n throw new Error(\n '[octospaces-ui] useOctoSpacesTheme() called outside of <OctoSpacesThemeProvider>. ' +\n 'Wrap your root component with <OctoSpacesThemeProvider theme={…}>.',\n );\n }\n return theme;\n}\n","/**\n * Pure palette-helper functions over a {@link Palette}. No theme values live here —\n * only functions that DERIVE from the injected palette (or from passed-in colors).\n *\n * Import these from `@drakkar.software/octospaces-ui` (re-exported by `src/index.ts`).\n */\nimport type { Palette, ShadowToken, Theme } from './types.js';\n\n// ── Presence ──────────────────────────────────────────────────────────────────\n\n/** Map a presence status string to the corresponding palette color. */\nexport function presenceColor(\n palette: Palette,\n status: 'online' | 'away' | 'busy' | 'offline' | string,\n): string {\n switch (status) {\n case 'online': return palette.presenceOnline;\n case 'away': return palette.presenceAway;\n case 'busy': return palette.presenceBusy;\n default: return palette.presenceOffline;\n }\n}\n\n// ── Verification ──────────────────────────────────────────────────────────────\n\n/** Map a verification level to the corresponding palette color. */\nexport function verificationColor(\n palette: Palette,\n level: 'verified' | 'partial' | 'none' | string,\n): string {\n switch (level) {\n case 'verified': return palette.verificationVerified;\n case 'partial': return palette.verificationPartial;\n default: return palette.verificationNone;\n }\n}\n\n// ── Avatar ────────────────────────────────────────────────────────────────────\n\nconst AVATAR_TINT_KEYS = [\n 'primary', 'success', 'warning', 'danger', 'info',\n] as const;\n\n/** Stable avatar background tint derived from a userId string. */\nexport function avatarTint(palette: Palette, userId: string): string {\n let hash = 0;\n for (let i = 0; i < userId.length; i++) hash = (hash * 31 + userId.charCodeAt(i)) | 0;\n const key = AVATAR_TINT_KEYS[Math.abs(hash) % AVATAR_TINT_KEYS.length];\n return (palette as unknown as Record<string, string>)[key] ?? palette.primary;\n}\n\n// ── Swatch ────────────────────────────────────────────────────────────────────\n\n/** Look up a named swatch; falls back to `palette.primary` if absent. */\nexport function swatch(theme: Theme, name: string): string {\n return theme.swatches[name] ?? theme.colors.primary;\n}\n\n// ── Borders ───────────────────────────────────────────────────────────────────\n\n/** Derive a `borderColor` value for a \"paper\" (elevated surface) border. */\nexport function paperBorder(palette: Palette): string {\n return palette.borderSubtle;\n}\n\n// ── Shadows ───────────────────────────────────────────────────────────────────\n\n/** Build a glow shadow token from a base color (used for focus rings, highlights). */\nexport function glowShadow(color: string, radius = 8, opacity = 0.4): ShadowToken {\n return {\n shadowColor: color,\n shadowOffset: { width: 0, height: 0 },\n shadowOpacity: opacity,\n shadowRadius: radius,\n elevation: 4,\n };\n}\n\n// ── Focus ring ────────────────────────────────────────────────────────────────\n\n/** Style object for a keyboard-focus indicator (web + React Native). */\nexport function focusRingStyle(\n palette: Palette,\n width = 2,\n): {\n borderWidth: number;\n borderColor: string;\n borderStyle: 'solid';\n} {\n return { borderWidth: width, borderColor: palette.focus, borderStyle: 'solid' };\n}\n\n// ── Status color ──────────────────────────────────────────────────────────────\n\n/** Map a semantic status name to its palette color. */\nexport function statusColor(\n palette: Palette,\n status: 'success' | 'warning' | 'danger' | 'info' | string,\n muted = false,\n): string {\n if (muted) {\n switch (status) {\n case 'success': return palette.successMuted;\n case 'warning': return palette.warningMuted;\n case 'danger': return palette.dangerMuted;\n default: return palette.infoMuted;\n }\n }\n switch (status) {\n case 'success': return palette.success;\n case 'warning': return palette.warning;\n case 'danger': return palette.danger;\n default: return palette.info;\n }\n}\n","import type { DiscoverEntry } from './types.js';\n\n/**\n * Case-insensitive substring filter over a `DiscoverEntry[]`.\n *\n * Returns the original array reference unchanged when `query` is blank so the\n * caller can skip a re-render. Pure function — no side effects.\n */\nexport function filterDiscoverEntries(\n entries: DiscoverEntry[],\n query: string,\n): DiscoverEntry[] {\n const q = query.trim().toLowerCase();\n if (!q) return entries;\n return entries.filter((e) => e.title.toLowerCase().includes(q));\n}\n\n/**\n * Sort discover entries by updatedAt descending (most recent first).\n * Entries without an `updatedAt` field sort last. Pure function.\n */\nexport function sortDiscoverEntries(entries: DiscoverEntry[]): DiscoverEntry[] {\n return [...entries].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));\n}\n","/**\n * A single row in the Discover list — shows the object's emoji/icon, title,\n * and type. All app-specific behaviour is injected via props:\n * - `renderIcon` — render a type/emoji icon; receives the entry and must\n * return a ReactNode (null for no icon).\n * - `onOpen` — called when the row is pressed.\n *\n * Styled entirely from the injected {@link Theme} via `useOctoSpacesTheme()`.\n */\nimport React, { useCallback } from 'react';\nimport { Pressable, Text, View } from 'react-native';\n\nimport { useOctoSpacesTheme } from '../theme/provider.js';\nimport type { DiscoverEntry } from './types.js';\n\nexport interface DiscoverRowProps {\n entry: DiscoverEntry;\n /** Render a leading icon for the entry. Return `null` to show nothing. */\n renderIcon?: (entry: DiscoverEntry) => React.ReactNode;\n /** Called when the user taps the row. */\n onOpen: (entry: DiscoverEntry) => void;\n}\n\nexport function DiscoverRow({ entry, renderIcon, onOpen }: DiscoverRowProps) {\n const theme = useOctoSpacesTheme();\n\n const handlePress = useCallback(() => {\n onOpen(entry);\n }, [entry, onOpen]);\n\n const icon = renderIcon ? renderIcon(entry) : null;\n const displayEmoji = !icon && entry.emoji ? entry.emoji : null;\n\n return (\n <Pressable\n onPress={handlePress}\n style={({ pressed }) => ({\n flexDirection: 'row',\n alignItems: 'center',\n paddingVertical: (theme.spacing['3'] as number) ?? 12,\n paddingHorizontal: (theme.spacing['4'] as number) ?? 16,\n backgroundColor: pressed\n ? (theme.colors.surface ?? '#f5f5f5')\n : 'transparent',\n borderRadius: (theme.radii['sm'] as number) ?? 6,\n })}\n accessibilityRole=\"button\"\n accessibilityLabel={entry.title || 'Untitled'}\n >\n {/* Leading icon / emoji */}\n {(icon || displayEmoji) && (\n <View\n style={{\n width: 28,\n height: 28,\n marginRight: (theme.spacing['2'] as number) ?? 8,\n alignItems: 'center',\n justifyContent: 'center',\n flexShrink: 0,\n }}\n >\n {icon ?? (\n <Text style={{ fontSize: 18, lineHeight: 24 }}>{displayEmoji}</Text>\n )}\n </View>\n )}\n\n {/* Title + type subtitle */}\n <View style={{ flex: 1, minWidth: 0 }}>\n <Text\n numberOfLines={1}\n style={{\n fontSize: (theme.type['body']?.size ?? 15),\n lineHeight: (theme.type['body']?.lineHeight ?? 22),\n color: entry.title ? theme.colors.text : theme.colors.textTertiary,\n fontFamily: theme.fonts['body'] ?? undefined,\n }}\n >\n {entry.title || 'Untitled'}\n </Text>\n <Text\n numberOfLines={1}\n style={{\n fontSize: (theme.type['caption']?.size ?? 12),\n lineHeight: (theme.type['caption']?.lineHeight ?? 18),\n color: theme.colors.textSecondary,\n marginTop: 1,\n textTransform: 'capitalize',\n }}\n >\n {entry.type}\n </Text>\n </View>\n </Pressable>\n );\n}\n","/**\n * A themed FlatList wrapper that renders a list of {@link DiscoverEntry} rows.\n *\n * All app-specific behaviour is injected via props so this component has zero\n * imports from any specific OctoSpaces app.\n */\nimport React, { useCallback } from 'react';\nimport { FlatList, RefreshControl, Text, View } from 'react-native';\n\nimport { useOctoSpacesTheme } from '../theme/provider.js';\nimport { DiscoverRow } from './DiscoverRow.js';\nimport type { DiscoverEntry } from './types.js';\n\nexport interface DiscoverListProps {\n entries: DiscoverEntry[];\n /** Render a leading icon for each row — see {@link DiscoverRowProps.renderIcon}. */\n renderIcon?: (entry: DiscoverEntry) => React.ReactNode;\n /** Called when a row is tapped. */\n onOpen: (entry: DiscoverEntry) => void;\n /** Text shown when `entries` is empty (default: \"No public objects found\"). */\n emptyMessage?: string;\n /** Whether a pull-to-refresh is currently in progress. */\n refreshing?: boolean;\n /** Called when the user pulls to refresh. */\n onRefresh?: () => void;\n}\n\nexport function DiscoverList({\n entries,\n renderIcon,\n onOpen,\n emptyMessage = 'No public objects found',\n refreshing,\n onRefresh,\n}: DiscoverListProps) {\n const theme = useOctoSpacesTheme();\n\n const renderItem = useCallback(\n ({ item }: { item: DiscoverEntry }) => (\n <DiscoverRow entry={item} renderIcon={renderIcon} onOpen={onOpen} />\n ),\n [renderIcon, onOpen],\n );\n\n const keyExtractor = useCallback(\n (item: DiscoverEntry) => `${item.spaceId}:${item.id}`,\n [],\n );\n\n if (entries.length === 0) {\n return (\n <View\n style={{\n flex: 1,\n alignItems: 'center',\n justifyContent: 'center',\n paddingHorizontal: (theme.spacing['6'] as number) ?? 24,\n }}\n >\n <Text\n style={{\n fontSize: theme.type['body']?.size ?? 15,\n color: theme.colors.textSecondary,\n textAlign: 'center',\n }}\n >\n {emptyMessage}\n </Text>\n </View>\n );\n }\n\n return (\n <FlatList\n data={entries}\n renderItem={renderItem}\n keyExtractor={keyExtractor}\n contentContainerStyle={{ paddingVertical: (theme.spacing['1'] as number) ?? 4 }}\n showsVerticalScrollIndicator={false}\n removeClippedSubviews\n refreshControl={\n onRefresh ? (\n <RefreshControl\n refreshing={refreshing ?? false}\n onRefresh={onRefresh}\n tintColor={theme.colors.primary}\n />\n ) : undefined\n }\n />\n );\n}\n","/**\n * Generic public-object discovery screen.\n *\n * Loads the world-readable public-object directory via `loadEntries`, renders a\n * search bar, and delegates row rendering + tap behaviour to the injected props.\n * No app-specific logic lives here — all customisation is via props:\n *\n * ```tsx\n * <DiscoverScreen\n * loadEntries={readObjectDirectory}\n * renderIcon={(e) => <TypeIcon entry={e} />}\n * onOpen={(e) => router.push({ pathname: routeForNode(e), params: { id: e.id, spaceId: e.spaceId } })}\n * />\n * ```\n *\n * State machine:\n * idle → loading → (ready | error)\n * Any pull of `loadEntries` updates the entries; errors show a retry button.\n */\nimport React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';\nimport { ActivityIndicator, Pressable, Text, TextInput, View, type TextStyle } from 'react-native';\n\nimport { useOctoSpacesTheme } from '../theme/provider.js';\nimport { DiscoverList } from './DiscoverList.js';\nimport { filterDiscoverEntries, sortDiscoverEntries } from './filter.js';\nimport type { DiscoverEntry } from './types.js';\n\nexport interface DiscoverScreenProps {\n /**\n * Async function that resolves to the current public-object directory.\n * Typically `readObjectDirectory` from `@drakkar.software/octospaces-sdk`.\n * Called on mount and when `refresh()` is triggered.\n */\n loadEntries: () => Promise<DiscoverEntry[]>;\n /** Render a leading icon for each row. */\n renderIcon?: (entry: DiscoverEntry) => React.ReactNode;\n /** Called when the user taps a row — navigate to the object. */\n onOpen: (entry: DiscoverEntry) => void;\n /**\n * Optional heading text shown above the search bar.\n * @default \"Discover\"\n */\n title?: string;\n /**\n * Text shown when the directory is empty after loading.\n * @default \"No public objects yet\"\n */\n emptyMessage?: string;\n /**\n * Text shown when the directory is empty due to an active search query.\n * @default \"No results for «query»\"\n */\n emptySearchMessage?: string;\n /**\n * Whether to show the inline search bar.\n * @default true\n */\n searchEnabled?: boolean;\n}\n\ntype State =\n | { status: 'idle' }\n | { status: 'loading' }\n | { status: 'ready'; entries: DiscoverEntry[] }\n | { status: 'error'; message: string };\n\nexport function DiscoverScreen({\n loadEntries,\n renderIcon,\n onOpen,\n title = 'Discover',\n emptyMessage = 'No public objects yet',\n emptySearchMessage,\n searchEnabled = true,\n}: DiscoverScreenProps) {\n const theme = useOctoSpacesTheme();\n const [state, setState] = useState<State>({ status: 'idle' });\n const [query, setQuery] = useState('');\n const [refreshing, setRefreshing] = useState(false);\n const cancelledRef = useRef(false);\n\n const load = useCallback(async () => {\n setState({ status: 'loading' });\n try {\n const raw = await loadEntries();\n if (cancelledRef.current) return;\n setState({ status: 'ready', entries: sortDiscoverEntries(raw) });\n } catch (err) {\n if (cancelledRef.current) return;\n setState({\n status: 'error',\n message: err instanceof Error ? err.message : 'Failed to load directory',\n });\n }\n }, [loadEntries]);\n\n /** Pull-to-refresh: re-fetches without blanking the existing list. */\n const handleRefresh = useCallback(async () => {\n setRefreshing(true);\n try {\n const raw = await loadEntries();\n if (cancelledRef.current) return;\n setState({ status: 'ready', entries: sortDiscoverEntries(raw) });\n } catch {\n // keep the existing list on refresh failure; the retry button remains for error state\n } finally {\n if (!cancelledRef.current) setRefreshing(false);\n }\n }, [loadEntries]);\n\n useEffect(() => {\n cancelledRef.current = false;\n void load();\n return () => {\n cancelledRef.current = true;\n };\n }, [load]);\n\n // ── Derived list ─────────────────────────────────────────────────────────\n const allEntries = state.status === 'ready' ? state.entries : [];\n const visibleEntries = filterDiscoverEntries(allEntries, query);\n const noSearchResults = !!query.trim() && visibleEntries.length === 0 && allEntries.length > 0;\n const resolvedEmptyMessage = noSearchResults\n ? (emptySearchMessage ?? `No results for \"${query.trim()}\"`)\n : emptyMessage;\n\n // ── Palette shortcuts ─────────────────────────────────────────────────────\n const sp2 = (theme.spacing['2'] as number) ?? 8;\n const sp3 = (theme.spacing['3'] as number) ?? 12;\n const sp4 = (theme.spacing['4'] as number) ?? 16;\n const radMd = (theme.radii['md'] as number) ?? 8;\n\n return (\n <View style={{ flex: 1, backgroundColor: theme.colors.background }}>\n {/* Header */}\n <View\n style={{\n paddingHorizontal: sp4,\n paddingTop: sp4,\n paddingBottom: sp2,\n }}\n >\n <Text\n style={{\n fontSize: theme.type['title2']?.size ?? 22,\n fontWeight: (theme.type['title2']?.weight as TextStyle['fontWeight']) ?? '700',\n lineHeight: theme.type['title2']?.lineHeight ?? 28,\n color: theme.colors.text,\n fontFamily: theme.fonts['heading'] ?? undefined,\n marginBottom: sp3,\n }}\n >\n {title}\n </Text>\n\n {/* Search bar */}\n {searchEnabled && (\n <View\n style={{\n flexDirection: 'row',\n alignItems: 'center',\n backgroundColor: theme.colors.surfaceInput ?? theme.colors.surface,\n borderRadius: radMd,\n borderWidth: 1,\n borderColor: theme.colors.borderSubtle,\n paddingHorizontal: sp3,\n height: 40,\n }}\n >\n <TextInput\n placeholder=\"Search…\"\n placeholderTextColor={theme.colors.textTertiary}\n value={query}\n onChangeText={setQuery}\n style={{\n flex: 1,\n fontSize: theme.type['body']?.size ?? 15,\n color: theme.colors.text,\n fontFamily: theme.fonts['body'] ?? undefined,\n }}\n returnKeyType=\"search\"\n clearButtonMode=\"while-editing\"\n accessibilityLabel=\"Search discover\"\n />\n </View>\n )}\n </View>\n\n {/* Body */}\n {state.status === 'loading' ? (\n <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>\n <ActivityIndicator color={theme.colors.primary} />\n </View>\n ) : state.status === 'error' ? (\n <View\n style={{\n flex: 1,\n alignItems: 'center',\n justifyContent: 'center',\n paddingHorizontal: sp4,\n }}\n >\n <Text\n style={{\n color: theme.colors.textSecondary,\n fontSize: theme.type['body']?.size ?? 15,\n textAlign: 'center',\n marginBottom: sp3,\n }}\n >\n {state.message}\n </Text>\n <Pressable\n onPress={load}\n style={{\n paddingHorizontal: sp4,\n paddingVertical: sp2,\n backgroundColor: theme.colors.primary,\n borderRadius: radMd,\n }}\n >\n <Text style={{ color: theme.colors.textOnPrimary, fontWeight: '600' }}>\n Retry\n </Text>\n </Pressable>\n </View>\n ) : (\n <DiscoverList\n entries={visibleEntries}\n renderIcon={renderIcon}\n onOpen={onOpen}\n emptyMessage={resolvedEmptyMessage}\n refreshing={refreshing}\n onRefresh={handleRefresh}\n />\n )}\n </View>\n );\n}\n"],"mappings":"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","names":["React","React","React","useCallback","Text","View","useCallback","React","View","Text","React","useCallback","Pressable","Text","View","useCallback","React","View","Text","Pressable"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakkar.software/octospaces-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Shared headless-themed UI primitives for OctoSpaces apps. Theme values are injected by the host app; this package ships only types + plumbing + primitives.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A themed FlatList wrapper that renders a list of {@link DiscoverEntry} rows.
|
|
3
|
+
*
|
|
4
|
+
* All app-specific behaviour is injected via props so this component has zero
|
|
5
|
+
* imports from any specific OctoSpaces app.
|
|
6
|
+
*/
|
|
7
|
+
import React, { useCallback } from 'react';
|
|
8
|
+
import { FlatList, RefreshControl, Text, View } from 'react-native';
|
|
9
|
+
|
|
10
|
+
import { useOctoSpacesTheme } from '../theme/provider.js';
|
|
11
|
+
import { DiscoverRow } from './DiscoverRow.js';
|
|
12
|
+
import type { DiscoverEntry } from './types.js';
|
|
13
|
+
|
|
14
|
+
export interface DiscoverListProps {
|
|
15
|
+
entries: DiscoverEntry[];
|
|
16
|
+
/** Render a leading icon for each row — see {@link DiscoverRowProps.renderIcon}. */
|
|
17
|
+
renderIcon?: (entry: DiscoverEntry) => React.ReactNode;
|
|
18
|
+
/** Called when a row is tapped. */
|
|
19
|
+
onOpen: (entry: DiscoverEntry) => void;
|
|
20
|
+
/** Text shown when `entries` is empty (default: "No public objects found"). */
|
|
21
|
+
emptyMessage?: string;
|
|
22
|
+
/** Whether a pull-to-refresh is currently in progress. */
|
|
23
|
+
refreshing?: boolean;
|
|
24
|
+
/** Called when the user pulls to refresh. */
|
|
25
|
+
onRefresh?: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function DiscoverList({
|
|
29
|
+
entries,
|
|
30
|
+
renderIcon,
|
|
31
|
+
onOpen,
|
|
32
|
+
emptyMessage = 'No public objects found',
|
|
33
|
+
refreshing,
|
|
34
|
+
onRefresh,
|
|
35
|
+
}: DiscoverListProps) {
|
|
36
|
+
const theme = useOctoSpacesTheme();
|
|
37
|
+
|
|
38
|
+
const renderItem = useCallback(
|
|
39
|
+
({ item }: { item: DiscoverEntry }) => (
|
|
40
|
+
<DiscoverRow entry={item} renderIcon={renderIcon} onOpen={onOpen} />
|
|
41
|
+
),
|
|
42
|
+
[renderIcon, onOpen],
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const keyExtractor = useCallback(
|
|
46
|
+
(item: DiscoverEntry) => `${item.spaceId}:${item.id}`,
|
|
47
|
+
[],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (entries.length === 0) {
|
|
51
|
+
return (
|
|
52
|
+
<View
|
|
53
|
+
style={{
|
|
54
|
+
flex: 1,
|
|
55
|
+
alignItems: 'center',
|
|
56
|
+
justifyContent: 'center',
|
|
57
|
+
paddingHorizontal: (theme.spacing['6'] as number) ?? 24,
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
<Text
|
|
61
|
+
style={{
|
|
62
|
+
fontSize: theme.type['body']?.size ?? 15,
|
|
63
|
+
color: theme.colors.textSecondary,
|
|
64
|
+
textAlign: 'center',
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
{emptyMessage}
|
|
68
|
+
</Text>
|
|
69
|
+
</View>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<FlatList
|
|
75
|
+
data={entries}
|
|
76
|
+
renderItem={renderItem}
|
|
77
|
+
keyExtractor={keyExtractor}
|
|
78
|
+
contentContainerStyle={{ paddingVertical: (theme.spacing['1'] as number) ?? 4 }}
|
|
79
|
+
showsVerticalScrollIndicator={false}
|
|
80
|
+
removeClippedSubviews
|
|
81
|
+
refreshControl={
|
|
82
|
+
onRefresh ? (
|
|
83
|
+
<RefreshControl
|
|
84
|
+
refreshing={refreshing ?? false}
|
|
85
|
+
onRefresh={onRefresh}
|
|
86
|
+
tintColor={theme.colors.primary}
|
|
87
|
+
/>
|
|
88
|
+
) : undefined
|
|
89
|
+
}
|
|
90
|
+
/>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single row in the Discover list — shows the object's emoji/icon, title,
|
|
3
|
+
* and type. All app-specific behaviour is injected via props:
|
|
4
|
+
* - `renderIcon` — render a type/emoji icon; receives the entry and must
|
|
5
|
+
* return a ReactNode (null for no icon).
|
|
6
|
+
* - `onOpen` — called when the row is pressed.
|
|
7
|
+
*
|
|
8
|
+
* Styled entirely from the injected {@link Theme} via `useOctoSpacesTheme()`.
|
|
9
|
+
*/
|
|
10
|
+
import React, { useCallback } from 'react';
|
|
11
|
+
import { Pressable, Text, View } from 'react-native';
|
|
12
|
+
|
|
13
|
+
import { useOctoSpacesTheme } from '../theme/provider.js';
|
|
14
|
+
import type { DiscoverEntry } from './types.js';
|
|
15
|
+
|
|
16
|
+
export interface DiscoverRowProps {
|
|
17
|
+
entry: DiscoverEntry;
|
|
18
|
+
/** Render a leading icon for the entry. Return `null` to show nothing. */
|
|
19
|
+
renderIcon?: (entry: DiscoverEntry) => React.ReactNode;
|
|
20
|
+
/** Called when the user taps the row. */
|
|
21
|
+
onOpen: (entry: DiscoverEntry) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function DiscoverRow({ entry, renderIcon, onOpen }: DiscoverRowProps) {
|
|
25
|
+
const theme = useOctoSpacesTheme();
|
|
26
|
+
|
|
27
|
+
const handlePress = useCallback(() => {
|
|
28
|
+
onOpen(entry);
|
|
29
|
+
}, [entry, onOpen]);
|
|
30
|
+
|
|
31
|
+
const icon = renderIcon ? renderIcon(entry) : null;
|
|
32
|
+
const displayEmoji = !icon && entry.emoji ? entry.emoji : null;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<Pressable
|
|
36
|
+
onPress={handlePress}
|
|
37
|
+
style={({ pressed }) => ({
|
|
38
|
+
flexDirection: 'row',
|
|
39
|
+
alignItems: 'center',
|
|
40
|
+
paddingVertical: (theme.spacing['3'] as number) ?? 12,
|
|
41
|
+
paddingHorizontal: (theme.spacing['4'] as number) ?? 16,
|
|
42
|
+
backgroundColor: pressed
|
|
43
|
+
? (theme.colors.surface ?? '#f5f5f5')
|
|
44
|
+
: 'transparent',
|
|
45
|
+
borderRadius: (theme.radii['sm'] as number) ?? 6,
|
|
46
|
+
})}
|
|
47
|
+
accessibilityRole="button"
|
|
48
|
+
accessibilityLabel={entry.title || 'Untitled'}
|
|
49
|
+
>
|
|
50
|
+
{/* Leading icon / emoji */}
|
|
51
|
+
{(icon || displayEmoji) && (
|
|
52
|
+
<View
|
|
53
|
+
style={{
|
|
54
|
+
width: 28,
|
|
55
|
+
height: 28,
|
|
56
|
+
marginRight: (theme.spacing['2'] as number) ?? 8,
|
|
57
|
+
alignItems: 'center',
|
|
58
|
+
justifyContent: 'center',
|
|
59
|
+
flexShrink: 0,
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
{icon ?? (
|
|
63
|
+
<Text style={{ fontSize: 18, lineHeight: 24 }}>{displayEmoji}</Text>
|
|
64
|
+
)}
|
|
65
|
+
</View>
|
|
66
|
+
)}
|
|
67
|
+
|
|
68
|
+
{/* Title + type subtitle */}
|
|
69
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
70
|
+
<Text
|
|
71
|
+
numberOfLines={1}
|
|
72
|
+
style={{
|
|
73
|
+
fontSize: (theme.type['body']?.size ?? 15),
|
|
74
|
+
lineHeight: (theme.type['body']?.lineHeight ?? 22),
|
|
75
|
+
color: entry.title ? theme.colors.text : theme.colors.textTertiary,
|
|
76
|
+
fontFamily: theme.fonts['body'] ?? undefined,
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
79
|
+
{entry.title || 'Untitled'}
|
|
80
|
+
</Text>
|
|
81
|
+
<Text
|
|
82
|
+
numberOfLines={1}
|
|
83
|
+
style={{
|
|
84
|
+
fontSize: (theme.type['caption']?.size ?? 12),
|
|
85
|
+
lineHeight: (theme.type['caption']?.lineHeight ?? 18),
|
|
86
|
+
color: theme.colors.textSecondary,
|
|
87
|
+
marginTop: 1,
|
|
88
|
+
textTransform: 'capitalize',
|
|
89
|
+
}}
|
|
90
|
+
>
|
|
91
|
+
{entry.type}
|
|
92
|
+
</Text>
|
|
93
|
+
</View>
|
|
94
|
+
</Pressable>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic public-object discovery screen.
|
|
3
|
+
*
|
|
4
|
+
* Loads the world-readable public-object directory via `loadEntries`, renders a
|
|
5
|
+
* search bar, and delegates row rendering + tap behaviour to the injected props.
|
|
6
|
+
* No app-specific logic lives here — all customisation is via props:
|
|
7
|
+
*
|
|
8
|
+
* ```tsx
|
|
9
|
+
* <DiscoverScreen
|
|
10
|
+
* loadEntries={readObjectDirectory}
|
|
11
|
+
* renderIcon={(e) => <TypeIcon entry={e} />}
|
|
12
|
+
* onOpen={(e) => router.push({ pathname: routeForNode(e), params: { id: e.id, spaceId: e.spaceId } })}
|
|
13
|
+
* />
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* State machine:
|
|
17
|
+
* idle → loading → (ready | error)
|
|
18
|
+
* Any pull of `loadEntries` updates the entries; errors show a retry button.
|
|
19
|
+
*/
|
|
20
|
+
import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
|
21
|
+
import { ActivityIndicator, Pressable, Text, TextInput, View, type TextStyle } from 'react-native';
|
|
22
|
+
|
|
23
|
+
import { useOctoSpacesTheme } from '../theme/provider.js';
|
|
24
|
+
import { DiscoverList } from './DiscoverList.js';
|
|
25
|
+
import { filterDiscoverEntries, sortDiscoverEntries } from './filter.js';
|
|
26
|
+
import type { DiscoverEntry } from './types.js';
|
|
27
|
+
|
|
28
|
+
export interface DiscoverScreenProps {
|
|
29
|
+
/**
|
|
30
|
+
* Async function that resolves to the current public-object directory.
|
|
31
|
+
* Typically `readObjectDirectory` from `@drakkar.software/octospaces-sdk`.
|
|
32
|
+
* Called on mount and when `refresh()` is triggered.
|
|
33
|
+
*/
|
|
34
|
+
loadEntries: () => Promise<DiscoverEntry[]>;
|
|
35
|
+
/** Render a leading icon for each row. */
|
|
36
|
+
renderIcon?: (entry: DiscoverEntry) => React.ReactNode;
|
|
37
|
+
/** Called when the user taps a row — navigate to the object. */
|
|
38
|
+
onOpen: (entry: DiscoverEntry) => void;
|
|
39
|
+
/**
|
|
40
|
+
* Optional heading text shown above the search bar.
|
|
41
|
+
* @default "Discover"
|
|
42
|
+
*/
|
|
43
|
+
title?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Text shown when the directory is empty after loading.
|
|
46
|
+
* @default "No public objects yet"
|
|
47
|
+
*/
|
|
48
|
+
emptyMessage?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Text shown when the directory is empty due to an active search query.
|
|
51
|
+
* @default "No results for «query»"
|
|
52
|
+
*/
|
|
53
|
+
emptySearchMessage?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Whether to show the inline search bar.
|
|
56
|
+
* @default true
|
|
57
|
+
*/
|
|
58
|
+
searchEnabled?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Optional ref whose `.current` is set to a `reload()` function once mounted.
|
|
61
|
+
* Lets a host (e.g. a tab screen) trigger a soft-refresh on focus without
|
|
62
|
+
* blanking the existing list — identical to pull-to-refresh behaviour.
|
|
63
|
+
*
|
|
64
|
+
* ```tsx
|
|
65
|
+
* const reloadRef = useRef<() => void>(null);
|
|
66
|
+
* useFocusEffect(useCallback(() => { reloadRef.current?.(); }, []));
|
|
67
|
+
* <DiscoverScreen reloadRef={reloadRef} ... />
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
reloadRef?: React.RefObject<(() => void) | null>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type State =
|
|
74
|
+
| { status: 'idle' }
|
|
75
|
+
| { status: 'loading' }
|
|
76
|
+
| { status: 'ready'; entries: DiscoverEntry[] }
|
|
77
|
+
| { status: 'error'; message: string };
|
|
78
|
+
|
|
79
|
+
export function DiscoverScreen({
|
|
80
|
+
loadEntries,
|
|
81
|
+
renderIcon,
|
|
82
|
+
onOpen,
|
|
83
|
+
title = 'Discover',
|
|
84
|
+
emptyMessage = 'No public objects yet',
|
|
85
|
+
emptySearchMessage,
|
|
86
|
+
searchEnabled = true,
|
|
87
|
+
reloadRef,
|
|
88
|
+
}: DiscoverScreenProps) {
|
|
89
|
+
const theme = useOctoSpacesTheme();
|
|
90
|
+
const [state, setState] = useState<State>({ status: 'idle' });
|
|
91
|
+
const [query, setQuery] = useState('');
|
|
92
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
93
|
+
const cancelledRef = useRef(false);
|
|
94
|
+
|
|
95
|
+
const load = useCallback(async () => {
|
|
96
|
+
setState({ status: 'loading' });
|
|
97
|
+
try {
|
|
98
|
+
const raw = await loadEntries();
|
|
99
|
+
if (cancelledRef.current) return;
|
|
100
|
+
setState({ status: 'ready', entries: sortDiscoverEntries(raw) });
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if (cancelledRef.current) return;
|
|
103
|
+
setState({
|
|
104
|
+
status: 'error',
|
|
105
|
+
message: err instanceof Error ? err.message : 'Failed to load directory',
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}, [loadEntries]);
|
|
109
|
+
|
|
110
|
+
/** Pull-to-refresh: re-fetches without blanking the existing list. */
|
|
111
|
+
const handleRefresh = useCallback(async () => {
|
|
112
|
+
setRefreshing(true);
|
|
113
|
+
try {
|
|
114
|
+
const raw = await loadEntries();
|
|
115
|
+
if (cancelledRef.current) return;
|
|
116
|
+
setState({ status: 'ready', entries: sortDiscoverEntries(raw) });
|
|
117
|
+
} catch {
|
|
118
|
+
// keep the existing list on refresh failure; the retry button remains for error state
|
|
119
|
+
} finally {
|
|
120
|
+
if (!cancelledRef.current) setRefreshing(false);
|
|
121
|
+
}
|
|
122
|
+
}, [loadEntries]);
|
|
123
|
+
|
|
124
|
+
// Expose handleRefresh via reloadRef so a host can trigger a soft reload on focus.
|
|
125
|
+
useImperativeHandle(reloadRef, () => handleRefresh, [handleRefresh]);
|
|
126
|
+
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
cancelledRef.current = false;
|
|
129
|
+
void load();
|
|
130
|
+
return () => {
|
|
131
|
+
cancelledRef.current = true;
|
|
132
|
+
};
|
|
133
|
+
}, [load]);
|
|
134
|
+
|
|
135
|
+
// ── Derived list ─────────────────────────────────────────────────────────
|
|
136
|
+
const allEntries = state.status === 'ready' ? state.entries : [];
|
|
137
|
+
const visibleEntries = filterDiscoverEntries(allEntries, query);
|
|
138
|
+
const noSearchResults = !!query.trim() && visibleEntries.length === 0 && allEntries.length > 0;
|
|
139
|
+
const resolvedEmptyMessage = noSearchResults
|
|
140
|
+
? (emptySearchMessage ?? `No results for "${query.trim()}"`)
|
|
141
|
+
: emptyMessage;
|
|
142
|
+
|
|
143
|
+
// ── Palette shortcuts ─────────────────────────────────────────────────────
|
|
144
|
+
const sp2 = (theme.spacing['2'] as number) ?? 8;
|
|
145
|
+
const sp3 = (theme.spacing['3'] as number) ?? 12;
|
|
146
|
+
const sp4 = (theme.spacing['4'] as number) ?? 16;
|
|
147
|
+
const radMd = (theme.radii['md'] as number) ?? 8;
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<View style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
|
151
|
+
{/* Header */}
|
|
152
|
+
<View
|
|
153
|
+
style={{
|
|
154
|
+
paddingHorizontal: sp4,
|
|
155
|
+
paddingTop: sp4,
|
|
156
|
+
paddingBottom: sp2,
|
|
157
|
+
}}
|
|
158
|
+
>
|
|
159
|
+
<Text
|
|
160
|
+
style={{
|
|
161
|
+
fontSize: theme.type['title2']?.size ?? 22,
|
|
162
|
+
fontWeight: (theme.type['title2']?.weight as TextStyle['fontWeight']) ?? '700',
|
|
163
|
+
lineHeight: theme.type['title2']?.lineHeight ?? 28,
|
|
164
|
+
color: theme.colors.text,
|
|
165
|
+
fontFamily: theme.fonts['heading'] ?? undefined,
|
|
166
|
+
marginBottom: sp3,
|
|
167
|
+
}}
|
|
168
|
+
>
|
|
169
|
+
{title}
|
|
170
|
+
</Text>
|
|
171
|
+
|
|
172
|
+
{/* Search bar */}
|
|
173
|
+
{searchEnabled && (
|
|
174
|
+
<View
|
|
175
|
+
style={{
|
|
176
|
+
flexDirection: 'row',
|
|
177
|
+
alignItems: 'center',
|
|
178
|
+
backgroundColor: theme.colors.surfaceInput ?? theme.colors.surface,
|
|
179
|
+
borderRadius: radMd,
|
|
180
|
+
borderWidth: 1,
|
|
181
|
+
borderColor: theme.colors.borderSubtle,
|
|
182
|
+
paddingHorizontal: sp3,
|
|
183
|
+
height: 40,
|
|
184
|
+
}}
|
|
185
|
+
>
|
|
186
|
+
<TextInput
|
|
187
|
+
placeholder="Search…"
|
|
188
|
+
placeholderTextColor={theme.colors.textTertiary}
|
|
189
|
+
value={query}
|
|
190
|
+
onChangeText={setQuery}
|
|
191
|
+
style={{
|
|
192
|
+
flex: 1,
|
|
193
|
+
fontSize: theme.type['body']?.size ?? 15,
|
|
194
|
+
color: theme.colors.text,
|
|
195
|
+
fontFamily: theme.fonts['body'] ?? undefined,
|
|
196
|
+
}}
|
|
197
|
+
returnKeyType="search"
|
|
198
|
+
clearButtonMode="while-editing"
|
|
199
|
+
accessibilityLabel="Search discover"
|
|
200
|
+
/>
|
|
201
|
+
</View>
|
|
202
|
+
)}
|
|
203
|
+
</View>
|
|
204
|
+
|
|
205
|
+
{/* Body */}
|
|
206
|
+
{state.status === 'loading' ? (
|
|
207
|
+
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
|
208
|
+
<ActivityIndicator color={theme.colors.primary} />
|
|
209
|
+
</View>
|
|
210
|
+
) : state.status === 'error' ? (
|
|
211
|
+
<View
|
|
212
|
+
style={{
|
|
213
|
+
flex: 1,
|
|
214
|
+
alignItems: 'center',
|
|
215
|
+
justifyContent: 'center',
|
|
216
|
+
paddingHorizontal: sp4,
|
|
217
|
+
}}
|
|
218
|
+
>
|
|
219
|
+
<Text
|
|
220
|
+
style={{
|
|
221
|
+
color: theme.colors.textSecondary,
|
|
222
|
+
fontSize: theme.type['body']?.size ?? 15,
|
|
223
|
+
textAlign: 'center',
|
|
224
|
+
marginBottom: sp3,
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
{state.message}
|
|
228
|
+
</Text>
|
|
229
|
+
<Pressable
|
|
230
|
+
onPress={load}
|
|
231
|
+
style={{
|
|
232
|
+
paddingHorizontal: sp4,
|
|
233
|
+
paddingVertical: sp2,
|
|
234
|
+
backgroundColor: theme.colors.primary,
|
|
235
|
+
borderRadius: radMd,
|
|
236
|
+
}}
|
|
237
|
+
>
|
|
238
|
+
<Text style={{ color: theme.colors.textOnPrimary, fontWeight: '600' }}>
|
|
239
|
+
Retry
|
|
240
|
+
</Text>
|
|
241
|
+
</Pressable>
|
|
242
|
+
</View>
|
|
243
|
+
) : (
|
|
244
|
+
<DiscoverList
|
|
245
|
+
entries={visibleEntries}
|
|
246
|
+
renderIcon={renderIcon}
|
|
247
|
+
onOpen={onOpen}
|
|
248
|
+
emptyMessage={resolvedEmptyMessage}
|
|
249
|
+
refreshing={refreshing}
|
|
250
|
+
onRefresh={handleRefresh}
|
|
251
|
+
/>
|
|
252
|
+
)}
|
|
253
|
+
</View>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { filterDiscoverEntries, sortDiscoverEntries } from './filter.js';
|
|
3
|
+
import type { DiscoverEntry } from './types.js';
|
|
4
|
+
|
|
5
|
+
const entry = (id: string, title: string, updatedAt = 0): DiscoverEntry => ({
|
|
6
|
+
id,
|
|
7
|
+
spaceId: 'sp-1',
|
|
8
|
+
title,
|
|
9
|
+
type: 'page',
|
|
10
|
+
updatedAt,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// ── filterDiscoverEntries ─────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
describe('filterDiscoverEntries', () => {
|
|
16
|
+
it('returns original reference for empty query', () => {
|
|
17
|
+
const arr = [entry('a', 'Alpha')];
|
|
18
|
+
expect(filterDiscoverEntries(arr, '')).toBe(arr);
|
|
19
|
+
expect(filterDiscoverEntries(arr, ' ')).toBe(arr);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('filters case-insensitively by substring', () => {
|
|
23
|
+
const arr = [entry('a', 'Alpha Page'), entry('b', 'Beta Board'), entry('c', 'alpha task')];
|
|
24
|
+
const result = filterDiscoverEntries(arr, 'alpha');
|
|
25
|
+
expect(result).toHaveLength(2);
|
|
26
|
+
expect(result.map((e) => e.id)).toEqual(['a', 'c']);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns empty array when no matches', () => {
|
|
30
|
+
const arr = [entry('a', 'Hello'), entry('b', 'World')];
|
|
31
|
+
expect(filterDiscoverEntries(arr, 'xyz')).toHaveLength(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('matches mid-word substrings', () => {
|
|
35
|
+
const arr = [entry('a', 'Product Design'), entry('b', 'Roadmap')];
|
|
36
|
+
expect(filterDiscoverEntries(arr, 'oduct')).toHaveLength(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('handles empty entries array', () => {
|
|
40
|
+
expect(filterDiscoverEntries([], 'query')).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// ── sortDiscoverEntries ───────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
describe('sortDiscoverEntries', () => {
|
|
47
|
+
it('sorts descending by updatedAt', () => {
|
|
48
|
+
const arr = [entry('a', 'Old', 100), entry('b', 'New', 999), entry('c', 'Mid', 500)];
|
|
49
|
+
const sorted = sortDiscoverEntries(arr);
|
|
50
|
+
expect(sorted.map((e) => e.id)).toEqual(['b', 'c', 'a']);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('puts entries without updatedAt last', () => {
|
|
54
|
+
const arr: DiscoverEntry[] = [
|
|
55
|
+
{ id: 'a', spaceId: 'sp', title: 'A', type: 'page' },
|
|
56
|
+
entry('b', 'B', 100),
|
|
57
|
+
];
|
|
58
|
+
const sorted = sortDiscoverEntries(arr);
|
|
59
|
+
expect(sorted[0].id).toBe('b');
|
|
60
|
+
expect(sorted[1].id).toBe('a');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('does not mutate the original array', () => {
|
|
64
|
+
const arr = [entry('a', 'A', 200), entry('b', 'B', 100)];
|
|
65
|
+
sortDiscoverEntries(arr);
|
|
66
|
+
expect(arr[0].id).toBe('a');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('handles empty array', () => {
|
|
70
|
+
expect(sortDiscoverEntries([])).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { DiscoverEntry } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Case-insensitive substring filter over a `DiscoverEntry[]`.
|
|
5
|
+
*
|
|
6
|
+
* Returns the original array reference unchanged when `query` is blank so the
|
|
7
|
+
* caller can skip a re-render. Pure function — no side effects.
|
|
8
|
+
*/
|
|
9
|
+
export function filterDiscoverEntries(
|
|
10
|
+
entries: DiscoverEntry[],
|
|
11
|
+
query: string,
|
|
12
|
+
): DiscoverEntry[] {
|
|
13
|
+
const q = query.trim().toLowerCase();
|
|
14
|
+
if (!q) return entries;
|
|
15
|
+
return entries.filter((e) => e.title.toLowerCase().includes(q));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Sort discover entries by updatedAt descending (most recent first).
|
|
20
|
+
* Entries without an `updatedAt` field sort last. Pure function.
|
|
21
|
+
*/
|
|
22
|
+
export function sortDiscoverEntries(entries: DiscoverEntry[]): DiscoverEntry[] {
|
|
23
|
+
return [...entries].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
|
24
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type { DiscoverEntry } from './types.js';
|
|
2
|
+
export { filterDiscoverEntries, sortDiscoverEntries } from './filter.js';
|
|
3
|
+
export type { DiscoverRowProps } from './DiscoverRow.js';
|
|
4
|
+
export { DiscoverRow } from './DiscoverRow.js';
|
|
5
|
+
export type { DiscoverListProps } from './DiscoverList.js';
|
|
6
|
+
export { DiscoverList } from './DiscoverList.js';
|
|
7
|
+
export type { DiscoverScreenProps } from './DiscoverScreen.js';
|
|
8
|
+
export { DiscoverScreen } from './DiscoverScreen.js';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared entry type for the generic Discover surface.
|
|
3
|
+
*
|
|
4
|
+
* Structurally compatible with `PublicObjectDirEntry` from
|
|
5
|
+
* `@drakkar.software/octospaces-sdk` (which adds the same fields). Apps pass
|
|
6
|
+
* `readObjectDirectory()` results directly to `loadEntries`; no runtime
|
|
7
|
+
* conversion needed.
|
|
8
|
+
*/
|
|
9
|
+
export interface DiscoverEntry {
|
|
10
|
+
/** The space this object belongs to. */
|
|
11
|
+
spaceId: string;
|
|
12
|
+
/** The object's node id. */
|
|
13
|
+
id: string;
|
|
14
|
+
/** Display title (empty string when the server stripped it). */
|
|
15
|
+
title: string;
|
|
16
|
+
/** The node type (e.g. `'page'`, `'board'`, `'task'`). */
|
|
17
|
+
type: string;
|
|
18
|
+
/** Optional emoji short-code or unicode character shown before the title. */
|
|
19
|
+
emoji?: string;
|
|
20
|
+
/** Server-side last-updated timestamp (epoch ms). */
|
|
21
|
+
updatedAt?: number;
|
|
22
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -37,3 +37,15 @@ export {
|
|
|
37
37
|
focusRingStyle,
|
|
38
38
|
statusColor,
|
|
39
39
|
} from './theme/helpers.js';
|
|
40
|
+
|
|
41
|
+
// Discover surface — generic themed components for browsing public-object directories.
|
|
42
|
+
// Components are headless: they read the injected Theme via useOctoSpacesTheme() and
|
|
43
|
+
// delegate all app-specific behaviour (icon rendering, navigation) to props.
|
|
44
|
+
export type { DiscoverEntry } from './discover/types.js';
|
|
45
|
+
export { filterDiscoverEntries, sortDiscoverEntries } from './discover/filter.js';
|
|
46
|
+
export type { DiscoverRowProps } from './discover/DiscoverRow.js';
|
|
47
|
+
export { DiscoverRow } from './discover/DiscoverRow.js';
|
|
48
|
+
export type { DiscoverListProps } from './discover/DiscoverList.js';
|
|
49
|
+
export { DiscoverList } from './discover/DiscoverList.js';
|
|
50
|
+
export type { DiscoverScreenProps } from './discover/DiscoverScreen.js';
|
|
51
|
+
export { DiscoverScreen } from './discover/DiscoverScreen.js';
|
package/tsconfig.json
CHANGED