@everystack/admin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +282 -0
- package/package.json +53 -0
- package/src/AdminRoot.tsx +34 -0
- package/src/adapters/EverystackAdapter.ts +218 -0
- package/src/adapters/types.ts +78 -0
- package/src/components/AvatarFieldInput.tsx +100 -0
- package/src/components/ImageDisplay.tsx +57 -0
- package/src/components/ImageFieldInput.tsx +140 -0
- package/src/components/dashboard/Brush.tsx +139 -0
- package/src/components/dashboard/DimensionBars.tsx +67 -0
- package/src/components/dashboard/DimensionDonut.tsx +98 -0
- package/src/components/dashboard/EventTable.tsx +248 -0
- package/src/components/dashboard/StatDials.tsx +67 -0
- package/src/components/dashboard/TimeHistogram.tsx +158 -0
- package/src/context.ts +75 -0
- package/src/hooks/useAdminConfig.ts +22 -0
- package/src/hooks/useAdminData.ts +132 -0
- package/src/hooks/useAggregates.ts +290 -0
- package/src/hooks/useRpc.ts +124 -0
- package/src/hooks/useStorageConfig.ts +16 -0
- package/src/index.ts +138 -0
- package/src/presets/twitter.ts +267 -0
- package/src/providers/AdminConfigProvider.tsx +18 -0
- package/src/providers/DataAdapterProvider.tsx +18 -0
- package/src/rpc/resource-dashboard-rpc.ts +416 -0
- package/src/screens/AdminDashboard.tsx +217 -0
- package/src/screens/AdminLayout.tsx +97 -0
- package/src/screens/ResourceCreate.tsx +243 -0
- package/src/screens/ResourceDashboard.tsx +278 -0
- package/src/screens/ResourceEdit.tsx +283 -0
- package/src/screens/ResourceList.tsx +153 -0
- package/src/screens/ResourceShow.tsx +171 -0
- package/src/screens/index.ts +14 -0
- package/src/types.ts +358 -0
- package/src/utils/naming.ts +105 -0
- package/src/utils/pick-image-web.ts +81 -0
- package/src/utils/resolve-image-url.ts +43 -0
- package/src/utils/upload-file.ts +93 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AvatarFieldInput
|
|
3
|
+
*
|
|
4
|
+
* Edit/create form field renderer for `type: 'image', renderer: 'avatar'`.
|
|
5
|
+
* Composes AvatarUpload with the web file picker and presigned upload utility.
|
|
6
|
+
* Degrades to TextInput when storage is not configured.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React, { useState, useCallback, useRef } from 'react';
|
|
10
|
+
import { FormField, AvatarUpload, TextInput } from '@everystack/ui';
|
|
11
|
+
import { useStorageConfig } from '../hooks/useStorageConfig';
|
|
12
|
+
import { pickImageWeb } from '../utils/pick-image-web';
|
|
13
|
+
import { uploadFile } from '../utils/upload-file';
|
|
14
|
+
import { resolveImageUrl } from '../utils/resolve-image-url';
|
|
15
|
+
import type { FieldDefinition } from '../types';
|
|
16
|
+
|
|
17
|
+
interface AvatarFieldInputProps {
|
|
18
|
+
field: FieldDefinition;
|
|
19
|
+
value: unknown;
|
|
20
|
+
error?: string;
|
|
21
|
+
onChange: (value: unknown) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function AvatarFieldInput({ field, value, error, onChange }: AvatarFieldInputProps): React.ReactElement {
|
|
25
|
+
const storage = useStorageConfig();
|
|
26
|
+
const label = field.label ?? field.name;
|
|
27
|
+
const required = field.validation?.required;
|
|
28
|
+
|
|
29
|
+
const [previewUri, setPreviewUri] = useState<string | null>(null);
|
|
30
|
+
const [uploading, setUploading] = useState(false);
|
|
31
|
+
const [uploadProgress, setUploadProgress] = useState(0);
|
|
32
|
+
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
33
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
34
|
+
|
|
35
|
+
// No storage config — fall back to plain URL text input
|
|
36
|
+
if (!storage) {
|
|
37
|
+
return (
|
|
38
|
+
<FormField label={label} error={error} required={required}>
|
|
39
|
+
<TextInput
|
|
40
|
+
value={String(value ?? '')}
|
|
41
|
+
onChangeText={onChange}
|
|
42
|
+
keyboardType="url"
|
|
43
|
+
placeholder="Enter avatar URL"
|
|
44
|
+
/>
|
|
45
|
+
</FormField>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const currentUri = value
|
|
50
|
+
? resolveImageUrl(String(value), {
|
|
51
|
+
cdnUrl: storage.cdnUrl,
|
|
52
|
+
endpoint: storage.endpoint,
|
|
53
|
+
transform: { width: 128, height: 128, fit: 'cover' },
|
|
54
|
+
})
|
|
55
|
+
: undefined;
|
|
56
|
+
|
|
57
|
+
const handlePick = useCallback(async () => {
|
|
58
|
+
setUploadError(null);
|
|
59
|
+
const result = await pickImageWeb();
|
|
60
|
+
if (!result) return;
|
|
61
|
+
|
|
62
|
+
setPreviewUri(result.preview.uri);
|
|
63
|
+
|
|
64
|
+
setUploading(true);
|
|
65
|
+
setUploadProgress(0);
|
|
66
|
+
abortRef.current = new AbortController();
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const { key } = await uploadFile({
|
|
70
|
+
endpoint: storage.endpoint,
|
|
71
|
+
file: result.file,
|
|
72
|
+
getToken: storage.getToken,
|
|
73
|
+
onProgress: setUploadProgress,
|
|
74
|
+
signal: abortRef.current.signal,
|
|
75
|
+
});
|
|
76
|
+
onChange(key);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if ((err as Error).message !== 'Upload cancelled') {
|
|
79
|
+
setUploadError((err as Error).message);
|
|
80
|
+
setPreviewUri(null);
|
|
81
|
+
}
|
|
82
|
+
} finally {
|
|
83
|
+
setUploading(false);
|
|
84
|
+
abortRef.current = null;
|
|
85
|
+
}
|
|
86
|
+
}, [storage, onChange]);
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<FormField label={label} error={error || uploadError || undefined} required={required}>
|
|
90
|
+
<AvatarUpload
|
|
91
|
+
uri={currentUri}
|
|
92
|
+
previewUri={previewUri ?? undefined}
|
|
93
|
+
uploading={uploading}
|
|
94
|
+
uploadProgress={uploadProgress}
|
|
95
|
+
onPick={handlePick}
|
|
96
|
+
size="lg"
|
|
97
|
+
/>
|
|
98
|
+
</FormField>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ImageDisplay
|
|
3
|
+
*
|
|
4
|
+
* Read-only image display for ResourceShow and table cells.
|
|
5
|
+
* Resolves storage keys to CDN or endpoint URLs. Falls back to
|
|
6
|
+
* raw URL display for legacy data.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React from 'react';
|
|
10
|
+
import { View, Text, Image } from 'react-native';
|
|
11
|
+
import { Avatar } from '@everystack/ui';
|
|
12
|
+
import { useStorageConfig } from '../hooks/useStorageConfig';
|
|
13
|
+
import { resolveImageUrl } from '../utils/resolve-image-url';
|
|
14
|
+
import type { FieldDefinition } from '../types';
|
|
15
|
+
|
|
16
|
+
interface ImageDisplayProps {
|
|
17
|
+
field: FieldDefinition;
|
|
18
|
+
value: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ImageDisplay({ field, value }: ImageDisplayProps): React.ReactElement {
|
|
22
|
+
const storage = useStorageConfig();
|
|
23
|
+
|
|
24
|
+
if (value == null) {
|
|
25
|
+
return <Text className="text-sm text-gray-400">{'\u2014'}</Text>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const stringValue = String(value);
|
|
29
|
+
const uri = resolveImageUrl(stringValue, {
|
|
30
|
+
cdnUrl: storage?.cdnUrl,
|
|
31
|
+
endpoint: storage?.endpoint,
|
|
32
|
+
transform: field.renderer === 'avatar'
|
|
33
|
+
? { width: 128, height: 128, fit: 'cover' }
|
|
34
|
+
: { width: 400, height: 300, fit: 'cover' },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (field.renderer === 'avatar') {
|
|
38
|
+
return (
|
|
39
|
+
<View className="flex-row items-center gap-2">
|
|
40
|
+
<Avatar uri={uri} size="lg" />
|
|
41
|
+
</View>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<View>
|
|
47
|
+
<Image
|
|
48
|
+
source={{ uri }}
|
|
49
|
+
className="w-48 h-36 rounded-lg"
|
|
50
|
+
style={{ resizeMode: 'cover' }}
|
|
51
|
+
/>
|
|
52
|
+
<Text className="text-xs text-gray-500 mt-1" numberOfLines={1}>
|
|
53
|
+
{stringValue}
|
|
54
|
+
</Text>
|
|
55
|
+
</View>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ImageFieldInput
|
|
3
|
+
*
|
|
4
|
+
* Edit/create form field renderer for `type: 'image'` (default renderer).
|
|
5
|
+
* Composes ImageInput + UploadProgress with the web file picker and
|
|
6
|
+
* presigned upload utility. Degrades to TextInput when storage is not configured.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React, { useState, useCallback, useRef } from 'react';
|
|
10
|
+
import { View, Image } from 'react-native';
|
|
11
|
+
import { FormField, ImageInput, UploadProgress, TextInput, type PickResult } from '@everystack/ui';
|
|
12
|
+
import { useStorageConfig } from '../hooks/useStorageConfig';
|
|
13
|
+
import { pickImageWeb } from '../utils/pick-image-web';
|
|
14
|
+
import { uploadFile } from '../utils/upload-file';
|
|
15
|
+
import { resolveImageUrl } from '../utils/resolve-image-url';
|
|
16
|
+
import type { FieldDefinition } from '../types';
|
|
17
|
+
|
|
18
|
+
interface ImageFieldInputProps {
|
|
19
|
+
field: FieldDefinition;
|
|
20
|
+
value: unknown;
|
|
21
|
+
error?: string;
|
|
22
|
+
onChange: (value: unknown) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function ImageFieldInput({ field, value, error, onChange }: ImageFieldInputProps): React.ReactElement {
|
|
26
|
+
const storage = useStorageConfig();
|
|
27
|
+
const label = field.label ?? field.name;
|
|
28
|
+
const required = field.validation?.required;
|
|
29
|
+
|
|
30
|
+
const [preview, setPreview] = useState<PickResult | null>(null);
|
|
31
|
+
const [uploading, setUploading] = useState(false);
|
|
32
|
+
const [uploadProgress, setUploadProgress] = useState(0);
|
|
33
|
+
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
34
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
35
|
+
|
|
36
|
+
// No storage config — fall back to plain URL text input + static preview
|
|
37
|
+
if (!storage) {
|
|
38
|
+
return (
|
|
39
|
+
<FormField label={label} error={error} required={required}>
|
|
40
|
+
<TextInput
|
|
41
|
+
value={String(value ?? '')}
|
|
42
|
+
onChangeText={onChange}
|
|
43
|
+
keyboardType="url"
|
|
44
|
+
placeholder="Enter image URL"
|
|
45
|
+
/>
|
|
46
|
+
{value ? (
|
|
47
|
+
<Image
|
|
48
|
+
source={{ uri: String(value) }}
|
|
49
|
+
className="w-24 h-24 rounded-lg mt-2"
|
|
50
|
+
style={{ resizeMode: 'cover' }}
|
|
51
|
+
/>
|
|
52
|
+
) : null}
|
|
53
|
+
</FormField>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const displayUri = preview?.uri
|
|
58
|
+
?? (value ? resolveImageUrl(String(value), {
|
|
59
|
+
cdnUrl: storage.cdnUrl,
|
|
60
|
+
endpoint: storage.endpoint,
|
|
61
|
+
transform: { width: 400, height: 300, fit: 'cover' },
|
|
62
|
+
}) : undefined);
|
|
63
|
+
|
|
64
|
+
const handlePick = useCallback(async () => {
|
|
65
|
+
setUploadError(null);
|
|
66
|
+
const result = await pickImageWeb();
|
|
67
|
+
if (!result) return;
|
|
68
|
+
|
|
69
|
+
setPreview(result.preview);
|
|
70
|
+
|
|
71
|
+
// Start upload
|
|
72
|
+
setUploading(true);
|
|
73
|
+
setUploadProgress(0);
|
|
74
|
+
abortRef.current = new AbortController();
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const { key } = await uploadFile({
|
|
78
|
+
endpoint: storage.endpoint,
|
|
79
|
+
file: result.file,
|
|
80
|
+
getToken: storage.getToken,
|
|
81
|
+
onProgress: setUploadProgress,
|
|
82
|
+
signal: abortRef.current.signal,
|
|
83
|
+
});
|
|
84
|
+
onChange(key);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if ((err as Error).message !== 'Upload cancelled') {
|
|
87
|
+
setUploadError((err as Error).message);
|
|
88
|
+
}
|
|
89
|
+
} finally {
|
|
90
|
+
setUploading(false);
|
|
91
|
+
abortRef.current = null;
|
|
92
|
+
}
|
|
93
|
+
}, [storage, onChange]);
|
|
94
|
+
|
|
95
|
+
const handleClear = useCallback(() => {
|
|
96
|
+
abortRef.current?.abort();
|
|
97
|
+
setPreview(null);
|
|
98
|
+
setUploadProgress(0);
|
|
99
|
+
setUploadError(null);
|
|
100
|
+
setUploading(false);
|
|
101
|
+
onChange(null);
|
|
102
|
+
}, [onChange]);
|
|
103
|
+
|
|
104
|
+
const handleCancel = useCallback(() => {
|
|
105
|
+
abortRef.current?.abort();
|
|
106
|
+
setUploading(false);
|
|
107
|
+
setUploadProgress(0);
|
|
108
|
+
setPreview(null);
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<FormField label={label} error={error || uploadError || undefined} required={required}>
|
|
113
|
+
<ImageInput
|
|
114
|
+
value={displayUri ? { uri: displayUri } : null}
|
|
115
|
+
onPick={handlePick}
|
|
116
|
+
onClear={value || preview ? handleClear : undefined}
|
|
117
|
+
disabled={uploading}
|
|
118
|
+
placeholder="Click to select image"
|
|
119
|
+
/>
|
|
120
|
+
{uploading && (
|
|
121
|
+
<UploadProgress
|
|
122
|
+
percentage={uploadProgress}
|
|
123
|
+
status="uploading"
|
|
124
|
+
fileName={preview?.fileName}
|
|
125
|
+
fileSize={preview?.fileSize}
|
|
126
|
+
onCancel={handleCancel}
|
|
127
|
+
style={{ marginTop: 8 }}
|
|
128
|
+
/>
|
|
129
|
+
)}
|
|
130
|
+
{uploadError && !uploading && (
|
|
131
|
+
<UploadProgress
|
|
132
|
+
percentage={uploadProgress}
|
|
133
|
+
status="error"
|
|
134
|
+
error={uploadError}
|
|
135
|
+
style={{ marginTop: 8 }}
|
|
136
|
+
/>
|
|
137
|
+
)}
|
|
138
|
+
</FormField>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brush — Gesture-based selection overlay for chart brushing.
|
|
3
|
+
*
|
|
4
|
+
* Uses react-native PanResponder for cross-platform gesture tracking
|
|
5
|
+
* (no dependency on react-native-gesture-handler). Maps pixel
|
|
6
|
+
* coordinates to data domain values via a d3 scale's `.invert()` method.
|
|
7
|
+
*
|
|
8
|
+
* Render as a sibling overlaid on top of chart content inside a
|
|
9
|
+
* position: relative container.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import React, { useRef, useState } from 'react';
|
|
13
|
+
import { View, PanResponder } from 'react-native';
|
|
14
|
+
|
|
15
|
+
export interface BrushProps {
|
|
16
|
+
/** d3 scale with `.invert()` for mapping pixels → data domain. */
|
|
17
|
+
xScale: { invert: (px: number) => unknown };
|
|
18
|
+
/** Chart width in pixels. */
|
|
19
|
+
width: number;
|
|
20
|
+
/** Chart height in pixels. */
|
|
21
|
+
height: number;
|
|
22
|
+
/** Called when brush selection changes. null = cleared. */
|
|
23
|
+
onBrush: (extent: [unknown, unknown] | null) => void;
|
|
24
|
+
/** Called when brush is cleared (double-tap). */
|
|
25
|
+
onClear?: () => void;
|
|
26
|
+
/** Overlay color for the selection region. */
|
|
27
|
+
brushColor?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Clamp a value between min and max.
|
|
32
|
+
*/
|
|
33
|
+
export function clamp(value: number, min: number, max: number): number {
|
|
34
|
+
return Math.max(min, Math.min(max, value));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Compute brush extent from two pixel positions and a scale.
|
|
39
|
+
*
|
|
40
|
+
* Returns domain values in [min, max] order regardless of drag direction.
|
|
41
|
+
*/
|
|
42
|
+
export function computeBrushExtent(
|
|
43
|
+
startPx: number,
|
|
44
|
+
endPx: number,
|
|
45
|
+
width: number,
|
|
46
|
+
invert: (px: number) => unknown,
|
|
47
|
+
): [unknown, unknown] {
|
|
48
|
+
const minPx = clamp(Math.min(startPx, endPx), 0, width);
|
|
49
|
+
const maxPx = clamp(Math.max(startPx, endPx), 0, width);
|
|
50
|
+
return [invert(minPx), invert(maxPx)];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function Brush({
|
|
54
|
+
xScale,
|
|
55
|
+
width,
|
|
56
|
+
height,
|
|
57
|
+
onBrush,
|
|
58
|
+
onClear,
|
|
59
|
+
brushColor = 'rgba(99, 102, 241, 0.2)',
|
|
60
|
+
}: BrushProps): React.ReactElement {
|
|
61
|
+
const [selection, setSelection] = useState<{ left: number; width: number } | null>(null);
|
|
62
|
+
const startXRef = useRef(0);
|
|
63
|
+
const lastTapRef = useRef(0);
|
|
64
|
+
|
|
65
|
+
const panResponder = useRef(
|
|
66
|
+
PanResponder.create({
|
|
67
|
+
onStartShouldSetPanResponder: () => true,
|
|
68
|
+
onMoveShouldSetPanResponder: () => true,
|
|
69
|
+
|
|
70
|
+
onPanResponderGrant: (evt) => {
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
const x = evt.nativeEvent.locationX;
|
|
73
|
+
|
|
74
|
+
// Double-tap detection (< 300ms)
|
|
75
|
+
if (now - lastTapRef.current < 300) {
|
|
76
|
+
setSelection(null);
|
|
77
|
+
onBrush(null);
|
|
78
|
+
onClear?.();
|
|
79
|
+
lastTapRef.current = 0;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
lastTapRef.current = now;
|
|
83
|
+
startXRef.current = x;
|
|
84
|
+
setSelection({ left: x, width: 0 });
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
onPanResponderMove: (evt) => {
|
|
88
|
+
const currentX = evt.nativeEvent.locationX;
|
|
89
|
+
const startX = startXRef.current;
|
|
90
|
+
const left = clamp(Math.min(startX, currentX), 0, width);
|
|
91
|
+
const right = clamp(Math.max(startX, currentX), 0, width);
|
|
92
|
+
setSelection({ left, width: right - left });
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
onPanResponderRelease: (evt) => {
|
|
96
|
+
const endX = evt.nativeEvent.locationX;
|
|
97
|
+
const startX = startXRef.current;
|
|
98
|
+
|
|
99
|
+
// Minimum 4px drag to count as a brush (not just a tap)
|
|
100
|
+
if (Math.abs(endX - startX) < 4) {
|
|
101
|
+
setSelection(null);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const extent = computeBrushExtent(startX, endX, width, xScale.invert.bind(xScale));
|
|
106
|
+
onBrush(extent);
|
|
107
|
+
},
|
|
108
|
+
}),
|
|
109
|
+
).current;
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<View
|
|
113
|
+
style={{
|
|
114
|
+
position: 'absolute',
|
|
115
|
+
top: 0,
|
|
116
|
+
left: 0,
|
|
117
|
+
width,
|
|
118
|
+
height,
|
|
119
|
+
}}
|
|
120
|
+
{...panResponder.panHandlers}
|
|
121
|
+
>
|
|
122
|
+
{selection && selection.width > 0 && (
|
|
123
|
+
<View
|
|
124
|
+
style={{
|
|
125
|
+
position: 'absolute',
|
|
126
|
+
left: selection.left,
|
|
127
|
+
top: 0,
|
|
128
|
+
width: selection.width,
|
|
129
|
+
height,
|
|
130
|
+
backgroundColor: brushColor,
|
|
131
|
+
borderLeftWidth: 1,
|
|
132
|
+
borderRightWidth: 1,
|
|
133
|
+
borderColor: 'rgba(99, 102, 241, 0.5)',
|
|
134
|
+
}}
|
|
135
|
+
/>
|
|
136
|
+
)}
|
|
137
|
+
</View>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DimensionBars — Horizontal bar chart for any categorical dimension.
|
|
3
|
+
*
|
|
4
|
+
* Generalized from PlatformBars. Accepts title, data, colors, and filter state.
|
|
5
|
+
* Tapping a bar toggles that value in the parent's filter state.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React from 'react';
|
|
9
|
+
import { View, Text, Pressable } from 'react-native';
|
|
10
|
+
|
|
11
|
+
export interface DimensionBarsProps {
|
|
12
|
+
title: string;
|
|
13
|
+
data: { value: string; count: number }[];
|
|
14
|
+
colors?: Record<string, string>;
|
|
15
|
+
activeFilters?: string[];
|
|
16
|
+
onFilter: (value: string) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DEFAULT_COLOR = '#6366f1';
|
|
20
|
+
|
|
21
|
+
export function DimensionBars({ title, data, colors, activeFilters, onFilter }: DimensionBarsProps): React.ReactElement {
|
|
22
|
+
const sorted = [...data].sort((a, b) => b.count - a.count);
|
|
23
|
+
const maxValue = Math.max(...sorted.map(e => e.count), 1);
|
|
24
|
+
|
|
25
|
+
if (sorted.length === 0) {
|
|
26
|
+
return (
|
|
27
|
+
<View style={{ flex: 1 }}>
|
|
28
|
+
<Text style={{ fontSize: 12, fontWeight: '600', color: '#6b7280', marginBottom: 8 }}>
|
|
29
|
+
{title}
|
|
30
|
+
</Text>
|
|
31
|
+
<Text style={{ color: '#9ca3af', fontSize: 12 }}>No data</Text>
|
|
32
|
+
</View>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<View style={{ flex: 1 }}>
|
|
38
|
+
<Text style={{ fontSize: 12, fontWeight: '600', color: '#6b7280', marginBottom: 8 }}>
|
|
39
|
+
{title}
|
|
40
|
+
</Text>
|
|
41
|
+
{sorted.map((entry) => {
|
|
42
|
+
const color = colors?.[entry.value] ?? DEFAULT_COLOR;
|
|
43
|
+
const widthPercent = (entry.count / maxValue) * 100;
|
|
44
|
+
const isActive = activeFilters?.includes(entry.value);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<Pressable
|
|
48
|
+
key={entry.value}
|
|
49
|
+
onPress={() => onFilter(entry.value)}
|
|
50
|
+
style={{ marginBottom: 6, opacity: activeFilters && !isActive ? 0.4 : 1 }}
|
|
51
|
+
>
|
|
52
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 2 }}>
|
|
53
|
+
<Text style={{ fontSize: 11, color: '#374151', flex: 1 }}>{entry.value}</Text>
|
|
54
|
+
<Text style={{ fontSize: 11, color: '#6b7280', fontWeight: '500' }}>{entry.count}</Text>
|
|
55
|
+
</View>
|
|
56
|
+
<View style={{ height: 12, backgroundColor: '#f1f5f9', borderRadius: 6 }}>
|
|
57
|
+
<View style={{
|
|
58
|
+
height: 12, width: `${widthPercent}%`, backgroundColor: color,
|
|
59
|
+
borderRadius: 6, minWidth: 4,
|
|
60
|
+
}} />
|
|
61
|
+
</View>
|
|
62
|
+
</Pressable>
|
|
63
|
+
);
|
|
64
|
+
})}
|
|
65
|
+
</View>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DimensionDonut — Stacked bar showing distribution for any categorical dimension.
|
|
3
|
+
*
|
|
4
|
+
* Generalized from LevelDonut. Accepts title, data, colors, order, and filter state.
|
|
5
|
+
* Tapping a segment toggles that value in the parent's filter state.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React from 'react';
|
|
9
|
+
import { View, Text, Pressable } from 'react-native';
|
|
10
|
+
|
|
11
|
+
export interface DimensionDonutProps {
|
|
12
|
+
title: string;
|
|
13
|
+
data: { value: string; count: number }[];
|
|
14
|
+
colors?: Record<string, string>;
|
|
15
|
+
order?: string[];
|
|
16
|
+
activeFilters?: string[];
|
|
17
|
+
onFilter: (value: string) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_COLOR = '#6b7280';
|
|
21
|
+
|
|
22
|
+
export function DimensionDonut({ title, data, colors, order, activeFilters, onFilter }: DimensionDonutProps): React.ReactElement {
|
|
23
|
+
// Sort by order if provided, else by count
|
|
24
|
+
const sorted = order
|
|
25
|
+
? order
|
|
26
|
+
.map(val => {
|
|
27
|
+
const entry = data.find(e => e.value === val);
|
|
28
|
+
return { value: val, count: entry?.count ?? 0 };
|
|
29
|
+
})
|
|
30
|
+
.filter(e => e.count > 0)
|
|
31
|
+
: [...data].sort((a, b) => b.count - a.count);
|
|
32
|
+
|
|
33
|
+
const total = sorted.reduce((sum, e) => sum + e.count, 0);
|
|
34
|
+
|
|
35
|
+
if (sorted.length === 0) {
|
|
36
|
+
return (
|
|
37
|
+
<View style={{ flex: 1 }}>
|
|
38
|
+
<Text style={{ fontSize: 12, fontWeight: '600', color: '#6b7280', marginBottom: 8 }}>
|
|
39
|
+
{title}
|
|
40
|
+
</Text>
|
|
41
|
+
<Text style={{ color: '#9ca3af', fontSize: 12 }}>No data</Text>
|
|
42
|
+
</View>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<View style={{ flex: 1 }}>
|
|
48
|
+
<Text style={{ fontSize: 12, fontWeight: '600', color: '#6b7280', marginBottom: 8 }}>
|
|
49
|
+
{title}
|
|
50
|
+
</Text>
|
|
51
|
+
|
|
52
|
+
{/* Stacked bar */}
|
|
53
|
+
<View style={{
|
|
54
|
+
flexDirection: 'row', height: 24, borderRadius: 12, overflow: 'hidden',
|
|
55
|
+
marginBottom: 12,
|
|
56
|
+
}}>
|
|
57
|
+
{sorted.map((entry) => {
|
|
58
|
+
const color = colors?.[entry.value] ?? DEFAULT_COLOR;
|
|
59
|
+
const widthPercent = (entry.count / total) * 100;
|
|
60
|
+
const isActive = activeFilters?.includes(entry.value);
|
|
61
|
+
return (
|
|
62
|
+
<Pressable
|
|
63
|
+
key={entry.value}
|
|
64
|
+
onPress={() => onFilter(entry.value)}
|
|
65
|
+
style={{
|
|
66
|
+
width: `${widthPercent}%`,
|
|
67
|
+
backgroundColor: color,
|
|
68
|
+
minWidth: 4,
|
|
69
|
+
opacity: activeFilters && !isActive ? 0.4 : 1,
|
|
70
|
+
}}
|
|
71
|
+
/>
|
|
72
|
+
);
|
|
73
|
+
})}
|
|
74
|
+
</View>
|
|
75
|
+
|
|
76
|
+
{/* Legend */}
|
|
77
|
+
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 12 }}>
|
|
78
|
+
{sorted.map((entry) => {
|
|
79
|
+
const color = colors?.[entry.value] ?? DEFAULT_COLOR;
|
|
80
|
+
const percent = total > 0 ? ((entry.count / total) * 100).toFixed(1) : '0';
|
|
81
|
+
const isActive = activeFilters?.includes(entry.value);
|
|
82
|
+
return (
|
|
83
|
+
<Pressable
|
|
84
|
+
key={entry.value}
|
|
85
|
+
onPress={() => onFilter(entry.value)}
|
|
86
|
+
style={{ flexDirection: 'row', alignItems: 'center', opacity: activeFilters && !isActive ? 0.4 : 1 }}
|
|
87
|
+
>
|
|
88
|
+
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: color, marginRight: 4 }} />
|
|
89
|
+
<Text style={{ fontSize: 11, color: '#374151' }}>
|
|
90
|
+
{entry.value} ({percent}%)
|
|
91
|
+
</Text>
|
|
92
|
+
</Pressable>
|
|
93
|
+
);
|
|
94
|
+
})}
|
|
95
|
+
</View>
|
|
96
|
+
</View>
|
|
97
|
+
);
|
|
98
|
+
}
|