@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.
Files changed (38) hide show
  1. package/README.md +282 -0
  2. package/package.json +53 -0
  3. package/src/AdminRoot.tsx +34 -0
  4. package/src/adapters/EverystackAdapter.ts +218 -0
  5. package/src/adapters/types.ts +78 -0
  6. package/src/components/AvatarFieldInput.tsx +100 -0
  7. package/src/components/ImageDisplay.tsx +57 -0
  8. package/src/components/ImageFieldInput.tsx +140 -0
  9. package/src/components/dashboard/Brush.tsx +139 -0
  10. package/src/components/dashboard/DimensionBars.tsx +67 -0
  11. package/src/components/dashboard/DimensionDonut.tsx +98 -0
  12. package/src/components/dashboard/EventTable.tsx +248 -0
  13. package/src/components/dashboard/StatDials.tsx +67 -0
  14. package/src/components/dashboard/TimeHistogram.tsx +158 -0
  15. package/src/context.ts +75 -0
  16. package/src/hooks/useAdminConfig.ts +22 -0
  17. package/src/hooks/useAdminData.ts +132 -0
  18. package/src/hooks/useAggregates.ts +290 -0
  19. package/src/hooks/useRpc.ts +124 -0
  20. package/src/hooks/useStorageConfig.ts +16 -0
  21. package/src/index.ts +138 -0
  22. package/src/presets/twitter.ts +267 -0
  23. package/src/providers/AdminConfigProvider.tsx +18 -0
  24. package/src/providers/DataAdapterProvider.tsx +18 -0
  25. package/src/rpc/resource-dashboard-rpc.ts +416 -0
  26. package/src/screens/AdminDashboard.tsx +217 -0
  27. package/src/screens/AdminLayout.tsx +97 -0
  28. package/src/screens/ResourceCreate.tsx +243 -0
  29. package/src/screens/ResourceDashboard.tsx +278 -0
  30. package/src/screens/ResourceEdit.tsx +283 -0
  31. package/src/screens/ResourceList.tsx +153 -0
  32. package/src/screens/ResourceShow.tsx +171 -0
  33. package/src/screens/index.ts +14 -0
  34. package/src/types.ts +358 -0
  35. package/src/utils/naming.ts +105 -0
  36. package/src/utils/pick-image-web.ts +81 -0
  37. package/src/utils/resolve-image-url.ts +43 -0
  38. 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
+ }