sid-ui 0.0.0-stage → 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 +54 -2
- package/package.json +51 -4
- package/src/brand/BrandFooter.tsx +45 -0
- package/src/brand/BrandHeading.tsx +24 -0
- package/src/brand/BrandLockup.tsx +47 -0
- package/src/brand/JoinedLogo.tsx +67 -0
- package/src/brand/JoinedMark.tsx +76 -0
- package/src/brand/geometry.ts +26 -0
- package/src/brand/index.ts +11 -0
- package/src/brand/name.ts +5 -0
- package/src/brand/paint.tsx +56 -0
- package/src/components/Action.tsx +44 -0
- package/src/components/AddressSelector.tsx +99 -0
- package/src/components/BarChart.tsx +132 -0
- package/src/components/Calendar.tsx +284 -0
- package/src/components/CalendarAgenda.tsx +87 -0
- package/src/components/CalendarMonth.tsx +223 -0
- package/src/components/CalendarSchedule.tsx +210 -0
- package/src/components/Card.tsx +80 -0
- package/src/components/Charts.tsx +64 -0
- package/src/components/Chat.tsx +35 -0
- package/src/components/CitySelector.tsx +81 -0
- package/src/components/Clock.tsx +196 -0
- package/src/components/CodeInput.tsx +183 -0
- package/src/components/Container.tsx +20 -0
- package/src/components/Content.tsx +50 -0
- package/src/components/Data.tsx +43 -0
- package/src/components/DataInput.tsx +107 -0
- package/src/components/DateField.tsx +162 -0
- package/src/components/DonutChart.tsx +123 -0
- package/src/components/Drawer.tsx +110 -0
- package/src/components/Feedback.tsx +37 -0
- package/src/components/FileUploader.tsx +377 -0
- package/src/components/FunnelChart.tsx +83 -0
- package/src/components/Glyph.tsx +330 -0
- package/src/components/GoogleSignIn.tsx +67 -0
- package/src/components/GridSystem.tsx +131 -0
- package/src/components/HeatmapCalendar.tsx +167 -0
- package/src/components/KanbanBoard.tsx +421 -0
- package/src/components/Kpi.tsx +61 -0
- package/src/components/LayoutPrimitives.tsx +86 -0
- package/src/components/Lists.tsx +19 -0
- package/src/components/LocationSelector.tsx +49 -0
- package/src/components/MessageList.tsx +80 -0
- package/src/components/Nav.tsx +69 -0
- package/src/components/Navigation.tsx +52 -0
- package/src/components/Notification.tsx +115 -0
- package/src/components/NotificationTrigger.tsx +248 -0
- package/src/components/Overlay.tsx +50 -0
- package/src/components/Page.tsx +143 -0
- package/src/components/PageTabs.tsx +28 -0
- package/src/components/PickerShell.tsx +138 -0
- package/src/components/PillNav.tsx +194 -0
- package/src/components/Primitives.tsx +30 -0
- package/src/components/Rating.tsx +78 -0
- package/src/components/Responsive.tsx +128 -0
- package/src/components/ResponsiveFrame.tsx +122 -0
- package/src/components/Segment.tsx +190 -0
- package/src/components/Sparkline.tsx +52 -0
- package/src/components/StateSelector.tsx +39 -0
- package/src/components/Sticky.tsx +95 -0
- package/src/components/Table.tsx +344 -0
- package/src/components/ThemeToggle.tsx +48 -0
- package/src/components/Tile.tsx +23 -0
- package/src/components/TimeColumns.tsx +155 -0
- package/src/components/TimeDial.tsx +260 -0
- package/src/components/TimeField.tsx +196 -0
- package/src/components/TimeInput.tsx +213 -0
- package/src/components/TimeSlots.tsx +83 -0
- package/src/components/Timeline.tsx +111 -0
- package/src/components/TokenDemo.tsx +378 -0
- package/src/components/Tree.tsx +283 -0
- package/src/components/TrendChart.tsx +211 -0
- package/src/components/breakpoints.ts +42 -0
- package/src/components/calendarTypes.ts +26 -0
- package/src/components/chartMath.ts +125 -0
- package/src/components/chartParts.tsx +116 -0
- package/src/components/codeInputRules.ts +58 -0
- package/src/components/date.ts +87 -0
- package/src/components/hooks.ts +53 -0
- package/src/components/index.ts +195 -0
- package/src/components/kanban.ts +78 -0
- package/src/components/motion.ts +78 -0
- package/src/components/places-search.tsx +73 -0
- package/src/components/places.ts +209 -0
- package/src/components/size.ts +3 -0
- package/src/components/time.ts +83 -0
- package/src/components/useFileDrop.ts +104 -0
- package/src/places/geoapify.ts +129 -0
- package/src/styles/astryx.css +5 -0
- package/src/styles/components/brand.css +98 -0
- package/src/styles/components/calendar.css +680 -0
- package/src/styles/components/charts.css +659 -0
- package/src/styles/components/code-input.css +233 -0
- package/src/styles/components/google.css +11 -0
- package/src/styles/components/message-list.css +153 -0
- package/src/styles/components/notification.css +350 -0
- package/src/styles/components/picker.css +114 -0
- package/src/styles/components/pill-nav.css +179 -0
- package/src/styles/components/responsive.css +419 -0
- package/src/styles/components/sticky.css +23 -0
- package/src/styles/components/table.css +267 -0
- package/src/styles/components/time.css +526 -0
- package/src/styles/components/timeline.css +426 -0
- package/src/styles/components/tree.css +269 -0
- package/src/styles/components.css +423 -0
- package/src/styles/joined.css +4 -0
- package/src/styles/theme.css +79 -0
- package/src/styles/tokens.css +338 -0
- package/src/theme/JoinedProvider.tsx +50 -0
- package/src/theme/index.ts +3 -0
- package/src/theme/joined.d.ts +11 -0
- package/src/theme/joined.js +569 -0
- package/src/theme/joined.theme.ts +74 -0
- package/src/theme/joined.variants.d.ts +16 -0
- package/src/theme/theme.css +786 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Link } from "./Action";
|
|
4
|
+
import { Text } from "./Primitives";
|
|
5
|
+
|
|
6
|
+
import type { SearchableItem, SearchSource } from "./DataInput";
|
|
7
|
+
import type { Address } from "./places";
|
|
8
|
+
|
|
9
|
+
/** Wait until a few characters are typed, then ask Geoapify once. */
|
|
10
|
+
export const PLACES_MIN_QUERY = 3;
|
|
11
|
+
export const PLACES_DEBOUNCE_MS = 300;
|
|
12
|
+
export const PLACES_PATH = "/api/places";
|
|
13
|
+
export const GEOAPIFY_ATTRIBUTION_HREF = "https://www.geoapify.com/";
|
|
14
|
+
|
|
15
|
+
export type PlaceHit = Address & {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type PlaceItem = SearchableItem<Address>;
|
|
21
|
+
|
|
22
|
+
/** Geoapify autocomplete, proxied by the app so the API key stays on the server. */
|
|
23
|
+
export function placeSearch(
|
|
24
|
+
kind: "city" | "address" | "country" | "location",
|
|
25
|
+
): SearchSource<PlaceItem> {
|
|
26
|
+
let controller: AbortController | null = null;
|
|
27
|
+
return {
|
|
28
|
+
bootstrap: () => [],
|
|
29
|
+
cancel() {
|
|
30
|
+
controller?.abort();
|
|
31
|
+
},
|
|
32
|
+
async search(query) {
|
|
33
|
+
controller?.abort();
|
|
34
|
+
controller = new AbortController();
|
|
35
|
+
const signal = controller.signal;
|
|
36
|
+
const params = new URLSearchParams({ kind, text: query });
|
|
37
|
+
try {
|
|
38
|
+
const response = await fetch(`${PLACES_PATH}?${params}`, { signal });
|
|
39
|
+
if (!response.ok) return [];
|
|
40
|
+
const body = (await response.json()) as { results?: PlaceHit[] };
|
|
41
|
+
return (body.results ?? []).map((hit) => ({
|
|
42
|
+
id: hit.id,
|
|
43
|
+
label: hit.label,
|
|
44
|
+
auxiliaryData: {
|
|
45
|
+
line1: hit.line1,
|
|
46
|
+
city: hit.city,
|
|
47
|
+
state: hit.state,
|
|
48
|
+
postalCode: hit.postalCode,
|
|
49
|
+
country: hit.country,
|
|
50
|
+
},
|
|
51
|
+
}));
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function placeItem(label: string, address?: Address): PlaceItem | null {
|
|
61
|
+
const text = label.trim();
|
|
62
|
+
if (!text) return null;
|
|
63
|
+
return { id: text, label: text, auxiliaryData: address };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Free Geoapify plans require a visible credit. */
|
|
67
|
+
export function PlacesCredit() {
|
|
68
|
+
return (
|
|
69
|
+
<Text type="supporting" color="secondary">
|
|
70
|
+
Address data © <Link href={GEOAPIFY_ATTRIBUTION_HREF}>Geoapify</Link>
|
|
71
|
+
</Text>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/** US places shared by the city, state, and address selectors. */
|
|
2
|
+
|
|
3
|
+
export type State = {
|
|
4
|
+
name: string;
|
|
5
|
+
abbreviation: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type City = {
|
|
9
|
+
name: string;
|
|
10
|
+
state: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type Address = {
|
|
14
|
+
line1: string;
|
|
15
|
+
city: string;
|
|
16
|
+
state: string;
|
|
17
|
+
postalCode: string;
|
|
18
|
+
country: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const LOCATION_SEPARATOR = " · ";
|
|
22
|
+
|
|
23
|
+
export const US_STATES: State[] = [
|
|
24
|
+
{ name: "Alabama", abbreviation: "AL" },
|
|
25
|
+
{ name: "Alaska", abbreviation: "AK" },
|
|
26
|
+
{ name: "Arizona", abbreviation: "AZ" },
|
|
27
|
+
{ name: "Arkansas", abbreviation: "AR" },
|
|
28
|
+
{ name: "California", abbreviation: "CA" },
|
|
29
|
+
{ name: "Colorado", abbreviation: "CO" },
|
|
30
|
+
{ name: "Connecticut", abbreviation: "CT" },
|
|
31
|
+
{ name: "Delaware", abbreviation: "DE" },
|
|
32
|
+
{ name: "District of Columbia", abbreviation: "DC" },
|
|
33
|
+
{ name: "Florida", abbreviation: "FL" },
|
|
34
|
+
{ name: "Georgia", abbreviation: "GA" },
|
|
35
|
+
{ name: "Hawaii", abbreviation: "HI" },
|
|
36
|
+
{ name: "Idaho", abbreviation: "ID" },
|
|
37
|
+
{ name: "Illinois", abbreviation: "IL" },
|
|
38
|
+
{ name: "Indiana", abbreviation: "IN" },
|
|
39
|
+
{ name: "Iowa", abbreviation: "IA" },
|
|
40
|
+
{ name: "Kansas", abbreviation: "KS" },
|
|
41
|
+
{ name: "Kentucky", abbreviation: "KY" },
|
|
42
|
+
{ name: "Louisiana", abbreviation: "LA" },
|
|
43
|
+
{ name: "Maine", abbreviation: "ME" },
|
|
44
|
+
{ name: "Maryland", abbreviation: "MD" },
|
|
45
|
+
{ name: "Massachusetts", abbreviation: "MA" },
|
|
46
|
+
{ name: "Michigan", abbreviation: "MI" },
|
|
47
|
+
{ name: "Minnesota", abbreviation: "MN" },
|
|
48
|
+
{ name: "Mississippi", abbreviation: "MS" },
|
|
49
|
+
{ name: "Missouri", abbreviation: "MO" },
|
|
50
|
+
{ name: "Montana", abbreviation: "MT" },
|
|
51
|
+
{ name: "Nebraska", abbreviation: "NE" },
|
|
52
|
+
{ name: "Nevada", abbreviation: "NV" },
|
|
53
|
+
{ name: "New Hampshire", abbreviation: "NH" },
|
|
54
|
+
{ name: "New Jersey", abbreviation: "NJ" },
|
|
55
|
+
{ name: "New Mexico", abbreviation: "NM" },
|
|
56
|
+
{ name: "New York", abbreviation: "NY" },
|
|
57
|
+
{ name: "North Carolina", abbreviation: "NC" },
|
|
58
|
+
{ name: "North Dakota", abbreviation: "ND" },
|
|
59
|
+
{ name: "Ohio", abbreviation: "OH" },
|
|
60
|
+
{ name: "Oklahoma", abbreviation: "OK" },
|
|
61
|
+
{ name: "Oregon", abbreviation: "OR" },
|
|
62
|
+
{ name: "Pennsylvania", abbreviation: "PA" },
|
|
63
|
+
{ name: "Rhode Island", abbreviation: "RI" },
|
|
64
|
+
{ name: "South Carolina", abbreviation: "SC" },
|
|
65
|
+
{ name: "South Dakota", abbreviation: "SD" },
|
|
66
|
+
{ name: "Tennessee", abbreviation: "TN" },
|
|
67
|
+
{ name: "Texas", abbreviation: "TX" },
|
|
68
|
+
{ name: "Utah", abbreviation: "UT" },
|
|
69
|
+
{ name: "Vermont", abbreviation: "VT" },
|
|
70
|
+
{ name: "Virginia", abbreviation: "VA" },
|
|
71
|
+
{ name: "Washington", abbreviation: "WA" },
|
|
72
|
+
{ name: "West Virginia", abbreviation: "WV" },
|
|
73
|
+
{ name: "Wisconsin", abbreviation: "WI" },
|
|
74
|
+
{ name: "Wyoming", abbreviation: "WY" },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
export const US_CITIES: City[] = [
|
|
78
|
+
{ name: "New York", state: "NY" },
|
|
79
|
+
{ name: "Los Angeles", state: "CA" },
|
|
80
|
+
{ name: "Chicago", state: "IL" },
|
|
81
|
+
{ name: "Houston", state: "TX" },
|
|
82
|
+
{ name: "Phoenix", state: "AZ" },
|
|
83
|
+
{ name: "Philadelphia", state: "PA" },
|
|
84
|
+
{ name: "San Antonio", state: "TX" },
|
|
85
|
+
{ name: "San Diego", state: "CA" },
|
|
86
|
+
{ name: "Dallas", state: "TX" },
|
|
87
|
+
{ name: "Austin", state: "TX" },
|
|
88
|
+
{ name: "San Jose", state: "CA" },
|
|
89
|
+
{ name: "Fort Worth", state: "TX" },
|
|
90
|
+
{ name: "Jacksonville", state: "FL" },
|
|
91
|
+
{ name: "Columbus", state: "OH" },
|
|
92
|
+
{ name: "Charlotte", state: "NC" },
|
|
93
|
+
{ name: "Indianapolis", state: "IN" },
|
|
94
|
+
{ name: "San Francisco", state: "CA" },
|
|
95
|
+
{ name: "Seattle", state: "WA" },
|
|
96
|
+
{ name: "Denver", state: "CO" },
|
|
97
|
+
{ name: "Washington", state: "DC" },
|
|
98
|
+
{ name: "Nashville", state: "TN" },
|
|
99
|
+
{ name: "Oklahoma City", state: "OK" },
|
|
100
|
+
{ name: "Boston", state: "MA" },
|
|
101
|
+
{ name: "Portland", state: "OR" },
|
|
102
|
+
{ name: "Las Vegas", state: "NV" },
|
|
103
|
+
{ name: "Detroit", state: "MI" },
|
|
104
|
+
{ name: "Memphis", state: "TN" },
|
|
105
|
+
{ name: "Louisville", state: "KY" },
|
|
106
|
+
{ name: "Baltimore", state: "MD" },
|
|
107
|
+
{ name: "Milwaukee", state: "WI" },
|
|
108
|
+
{ name: "Albuquerque", state: "NM" },
|
|
109
|
+
{ name: "Tucson", state: "AZ" },
|
|
110
|
+
{ name: "Fresno", state: "CA" },
|
|
111
|
+
{ name: "Sacramento", state: "CA" },
|
|
112
|
+
{ name: "Atlanta", state: "GA" },
|
|
113
|
+
{ name: "Miami", state: "FL" },
|
|
114
|
+
{ name: "Minneapolis", state: "MN" },
|
|
115
|
+
{ name: "Cleveland", state: "OH" },
|
|
116
|
+
{ name: "New Orleans", state: "LA" },
|
|
117
|
+
{ name: "Tampa", state: "FL" },
|
|
118
|
+
{ name: "Pittsburgh", state: "PA" },
|
|
119
|
+
{ name: "Cincinnati", state: "OH" },
|
|
120
|
+
{ name: "Orlando", state: "FL" },
|
|
121
|
+
{ name: "St. Louis", state: "MO" },
|
|
122
|
+
{ name: "Kansas City", state: "MO" },
|
|
123
|
+
{ name: "Raleigh", state: "NC" },
|
|
124
|
+
{ name: "Omaha", state: "NE" },
|
|
125
|
+
{ name: "Oakland", state: "CA" },
|
|
126
|
+
{ name: "Honolulu", state: "HI" },
|
|
127
|
+
{ name: "Anchorage", state: "AK" },
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
export function cityLabel(city: City) {
|
|
131
|
+
return city.name;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function joinLocations(cities: string[]) {
|
|
135
|
+
return cities
|
|
136
|
+
.map((city) => city.trim())
|
|
137
|
+
.filter(Boolean)
|
|
138
|
+
.join(LOCATION_SEPARATOR);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function splitLocations(value: string) {
|
|
142
|
+
return value
|
|
143
|
+
.split("·")
|
|
144
|
+
.map((city) => city.trim())
|
|
145
|
+
.filter(Boolean);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function formatAddress(address: Address) {
|
|
149
|
+
const cityState = [address.city.trim(), address.state.trim()].filter(Boolean).join(", ");
|
|
150
|
+
const place = [cityState, address.postalCode.trim()].filter(Boolean).join(" ");
|
|
151
|
+
return [address.line1.trim(), place, address.country.trim()].filter(Boolean).join(", ");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const US_STATE_TOKEN = /^([A-Za-z .]+?)(?:\s+(\d{5}(?:-\d{4})?))?$/;
|
|
155
|
+
/** A trailing postal code has a digit and no lowercase: "75001", "SW1A 1AA", "M5V 2T6". */
|
|
156
|
+
const TRAILING_POSTAL = /^(.*?\S)\s+((?=[A-Z0-9 -]*\d)[A-Z0-9][A-Z0-9 -]{1,9})$/;
|
|
157
|
+
|
|
158
|
+
/** "FL", "fl", or "Florida" → "FL"; anything else → null. */
|
|
159
|
+
function usStateCode(value: string) {
|
|
160
|
+
const text = value.trim().toLowerCase();
|
|
161
|
+
const state = US_STATES.find(
|
|
162
|
+
(item) => item.abbreviation.toLowerCase() === text || item.name.toLowerCase() === text,
|
|
163
|
+
);
|
|
164
|
+
return state?.abbreviation ?? null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Reads back what formatAddress wrote — "street, City, ST 12345, Country" — with any part
|
|
169
|
+
* missing: "Miami, FL, United States", "Seattle, WA 98101", "1 Rue X, Paris 75001, France".
|
|
170
|
+
*/
|
|
171
|
+
export function parseAddress(value: string): Address {
|
|
172
|
+
const parts = value
|
|
173
|
+
.split(",")
|
|
174
|
+
.map((part) => part.trim())
|
|
175
|
+
.filter(Boolean);
|
|
176
|
+
const address = emptyAddress();
|
|
177
|
+
if (parts.length === 0) return address;
|
|
178
|
+
|
|
179
|
+
// A US state, with or without a ZIP, sits right after the city.
|
|
180
|
+
for (let index = parts.length - 1; index >= 1; index -= 1) {
|
|
181
|
+
const match = parts[index].match(US_STATE_TOKEN);
|
|
182
|
+
const code = match ? usStateCode(match[1]) : null;
|
|
183
|
+
if (!match || !code) continue;
|
|
184
|
+
return {
|
|
185
|
+
line1: parts.slice(0, index - 1).join(", "),
|
|
186
|
+
city: parts[index - 1],
|
|
187
|
+
state: code,
|
|
188
|
+
postalCode: match[2] ?? "",
|
|
189
|
+
country: parts.slice(index + 1).join(", "),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Elsewhere: street…, City [postal], Country.
|
|
194
|
+
if (parts.length === 1) return { ...address, line1: parts[0] };
|
|
195
|
+
const country = parts[parts.length - 1];
|
|
196
|
+
const place = parts[parts.length - 2];
|
|
197
|
+
const postal = place.match(TRAILING_POSTAL);
|
|
198
|
+
return {
|
|
199
|
+
line1: parts.slice(0, -2).join(", "),
|
|
200
|
+
city: postal ? postal[1] : place,
|
|
201
|
+
state: "",
|
|
202
|
+
postalCode: postal ? postal[2] : "",
|
|
203
|
+
country,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function emptyAddress(): Address {
|
|
208
|
+
return { line1: "", city: "", state: "", postalCode: "", country: "" };
|
|
209
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** Time values travel as 24-hour strings: "HH:mm" or "HH:mm:ss". "" means empty. */
|
|
2
|
+
|
|
3
|
+
export type HourCycle = "12h" | "24h";
|
|
4
|
+
export type MinuteStep = 1 | 5 | 10 | 15 | 30;
|
|
5
|
+
export type Meridiem = "AM" | "PM";
|
|
6
|
+
export type TimeUnit = "hour" | "minute" | "second";
|
|
7
|
+
|
|
8
|
+
export const MERIDIEMS = ["AM", "PM"] as const;
|
|
9
|
+
export const HOURS_PER_DAY = 24;
|
|
10
|
+
export const HOURS_PER_HALF = 12;
|
|
11
|
+
export const MINUTES_PER_HOUR = 60;
|
|
12
|
+
export const SECONDS_PER_MINUTE = 60;
|
|
13
|
+
|
|
14
|
+
export interface TimeParts {
|
|
15
|
+
h: number;
|
|
16
|
+
m: number;
|
|
17
|
+
s: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function pad2(n: number) {
|
|
21
|
+
return String(n).padStart(2, "0");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function wrap(n: number, span: number) {
|
|
25
|
+
return ((n % span) + span) % span;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseTime(value: string): TimeParts | null {
|
|
29
|
+
if (!value) return null;
|
|
30
|
+
const [h = "0", m = "0", s = "0"] = value.split(":");
|
|
31
|
+
const parts = { h: Number(h), m: Number(m), s: Number(s) };
|
|
32
|
+
return Number.isFinite(parts.h) && Number.isFinite(parts.m) && Number.isFinite(parts.s)
|
|
33
|
+
? parts
|
|
34
|
+
: null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function formatTime({ h, m, s }: TimeParts, withSeconds: boolean) {
|
|
38
|
+
const base = `${pad2(wrap(h, HOURS_PER_DAY))}:${pad2(wrap(m, MINUTES_PER_HOUR))}`;
|
|
39
|
+
return withSeconds ? `${base}:${pad2(wrap(s, SECONDS_PER_MINUTE))}` : base;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function to12(h: number) {
|
|
43
|
+
const hour = h % HOURS_PER_HALF;
|
|
44
|
+
return hour === 0 ? HOURS_PER_HALF : hour;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function meridiemOf(h: number): Meridiem {
|
|
48
|
+
return h >= HOURS_PER_HALF ? "PM" : "AM";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 12-hour clock hour + meridiem → 0–23. */
|
|
52
|
+
export function from12(hour12: number, meridiem: Meridiem) {
|
|
53
|
+
return (hour12 % HOURS_PER_HALF) + (meridiem === "PM" ? HOURS_PER_HALF : 0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Human label for a value: "9:30 AM" or "21:30". */
|
|
57
|
+
export function displayTime(value: string, hourCycle: HourCycle, withSeconds = false) {
|
|
58
|
+
const parts = parseTime(value);
|
|
59
|
+
if (!parts) return "";
|
|
60
|
+
const tail = `${pad2(parts.m)}${withSeconds ? `:${pad2(parts.s)}` : ""}`;
|
|
61
|
+
return hourCycle === "12h"
|
|
62
|
+
? `${to12(parts.h)}:${tail} ${meridiemOf(parts.h)}`
|
|
63
|
+
: `${pad2(parts.h)}:${tail}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function range(from: number, to: number, step = 1) {
|
|
67
|
+
const out: number[] = [];
|
|
68
|
+
for (let n = from; n <= to; n += step) out.push(n);
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Minutes since midnight. */
|
|
73
|
+
export function minutesOf(value: string) {
|
|
74
|
+
const parts = parseTime(value);
|
|
75
|
+
return parts ? parts.h * MINUTES_PER_HOUR + parts.m : 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function fromMinutes(total: number) {
|
|
79
|
+
return formatTime(
|
|
80
|
+
{ h: Math.floor(total / MINUTES_PER_HOUR), m: total % MINUTES_PER_HOUR, s: 0 },
|
|
81
|
+
false,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef, useState, type ClipboardEvent, type DragEvent } from "react";
|
|
4
|
+
|
|
5
|
+
export interface UseFileDropOptions {
|
|
6
|
+
onFiles: (files: File[]) => void;
|
|
7
|
+
isDisabled?: boolean;
|
|
8
|
+
/** Also accept files pasted from the clipboard while focus is inside the target. */
|
|
9
|
+
hasPaste?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface FileDropProps {
|
|
13
|
+
onDragEnter: (event: DragEvent<HTMLElement>) => void;
|
|
14
|
+
onDragOver: (event: DragEvent<HTMLElement>) => void;
|
|
15
|
+
onDragLeave: (event: DragEvent<HTMLElement>) => void;
|
|
16
|
+
onDrop: (event: DragEvent<HTMLElement>) => void;
|
|
17
|
+
onPaste?: (event: ClipboardEvent<HTMLElement>) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function carriesFiles(event: DragEvent<HTMLElement>) {
|
|
21
|
+
return Array.from(event.dataTransfer?.types ?? []).includes("Files");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Turns any element into a file drop target. Spread `dropProps` on it and use
|
|
26
|
+
* `isDragging` to show a drop state. Nested children don't flicker the state.
|
|
27
|
+
*/
|
|
28
|
+
export function useFileDrop({ onFiles, isDisabled = false, hasPaste = false }: UseFileDropOptions) {
|
|
29
|
+
const [isDragging, setDragging] = useState(false);
|
|
30
|
+
const depth = useRef(0);
|
|
31
|
+
|
|
32
|
+
const reset = useCallback(() => {
|
|
33
|
+
depth.current = 0;
|
|
34
|
+
setDragging(false);
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
const dropProps: FileDropProps = {
|
|
38
|
+
onDragEnter: (event) => {
|
|
39
|
+
if (isDisabled || !carriesFiles(event)) return;
|
|
40
|
+
event.preventDefault();
|
|
41
|
+
depth.current += 1;
|
|
42
|
+
setDragging(true);
|
|
43
|
+
},
|
|
44
|
+
onDragOver: (event) => {
|
|
45
|
+
if (isDisabled || !carriesFiles(event)) return;
|
|
46
|
+
event.preventDefault();
|
|
47
|
+
event.dataTransfer.dropEffect = "copy";
|
|
48
|
+
},
|
|
49
|
+
onDragLeave: () => {
|
|
50
|
+
if (isDisabled) return;
|
|
51
|
+
depth.current = Math.max(0, depth.current - 1);
|
|
52
|
+
if (depth.current === 0) setDragging(false);
|
|
53
|
+
},
|
|
54
|
+
onDrop: (event) => {
|
|
55
|
+
if (isDisabled) return;
|
|
56
|
+
event.preventDefault();
|
|
57
|
+
reset();
|
|
58
|
+
const files: File[] = Array.from(event.dataTransfer.files as ArrayLike<File>);
|
|
59
|
+
if (files.length) onFiles(files);
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
if (hasPaste) {
|
|
64
|
+
dropProps.onPaste = (event) => {
|
|
65
|
+
if (isDisabled) return;
|
|
66
|
+
const files: File[] = Array.from(event.clipboardData.files as ArrayLike<File>);
|
|
67
|
+
if (!files.length) return;
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
onFiles(files);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { isDragging, dropProps };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const KB = 1024;
|
|
77
|
+
const UNITS = ["B", "KB", "MB", "GB"];
|
|
78
|
+
|
|
79
|
+
/** 1536 → "1.5 KB". */
|
|
80
|
+
export function formatBytes(bytes: number) {
|
|
81
|
+
let value = bytes;
|
|
82
|
+
let unit = 0;
|
|
83
|
+
while (value >= KB && unit < UNITS.length - 1) {
|
|
84
|
+
value /= KB;
|
|
85
|
+
unit += 1;
|
|
86
|
+
}
|
|
87
|
+
return `${unit === 0 ? value : value.toFixed(value < 10 ? 1 : 0)} ${UNITS[unit]}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Matches a file against an `accept` string such as ".pdf,image/*". */
|
|
91
|
+
export function matchesAccept(file: File, accept?: string) {
|
|
92
|
+
if (!accept) return true;
|
|
93
|
+
const name = file.name.toLowerCase();
|
|
94
|
+
const type = file.type.toLowerCase();
|
|
95
|
+
return accept
|
|
96
|
+
.split(",")
|
|
97
|
+
.map((token) => token.trim().toLowerCase())
|
|
98
|
+
.filter(Boolean)
|
|
99
|
+
.some((token) => {
|
|
100
|
+
if (token.startsWith(".")) return name.endsWith(token);
|
|
101
|
+
if (token.endsWith("/*")) return type.startsWith(token.slice(0, -1));
|
|
102
|
+
return type === token;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
const GEOAPIFY_AUTOCOMPLETE = "https://api.geoapify.com/v1/geocode/autocomplete";
|
|
2
|
+
const PLACES_LIMIT = 8;
|
|
3
|
+
|
|
4
|
+
/** Wait until a few characters are typed before calling Geoapify. */
|
|
5
|
+
export const PLACES_MIN_TEXT = 3;
|
|
6
|
+
|
|
7
|
+
export type PlaceKind = "city" | "address" | "country" | "location";
|
|
8
|
+
|
|
9
|
+
export type PlaceResult = {
|
|
10
|
+
id: string;
|
|
11
|
+
label: string;
|
|
12
|
+
line1: string;
|
|
13
|
+
city: string;
|
|
14
|
+
state: string;
|
|
15
|
+
postalCode: string;
|
|
16
|
+
country: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type GeoResult = {
|
|
20
|
+
place_id?: string;
|
|
21
|
+
formatted?: string;
|
|
22
|
+
address_line1?: string;
|
|
23
|
+
city?: string;
|
|
24
|
+
state?: string;
|
|
25
|
+
state_code?: string;
|
|
26
|
+
postcode?: string;
|
|
27
|
+
country?: string;
|
|
28
|
+
housenumber?: string;
|
|
29
|
+
street?: string;
|
|
30
|
+
result_type?: string;
|
|
31
|
+
lat?: number;
|
|
32
|
+
lon?: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Next.js route body. Pass `process.env.GEOAPIFY_API_KEY` so the key stays on the server. */
|
|
36
|
+
export async function placesResponse(
|
|
37
|
+
request: Request,
|
|
38
|
+
apiKey: string | undefined,
|
|
39
|
+
): Promise<Response> {
|
|
40
|
+
const key = apiKey?.trim();
|
|
41
|
+
if (!key) {
|
|
42
|
+
return Response.json(
|
|
43
|
+
{ error: "Address search is not configured", results: [] },
|
|
44
|
+
{ status: 503 },
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const url = new URL(request.url);
|
|
48
|
+
const kind = placeKind(url.searchParams.get("kind"));
|
|
49
|
+
const text = url.searchParams.get("text")?.trim() ?? "";
|
|
50
|
+
try {
|
|
51
|
+
const results = await searchPlaces(key, kind, text);
|
|
52
|
+
return Response.json({ results });
|
|
53
|
+
} catch {
|
|
54
|
+
return Response.json({ error: "Address search failed", results: [] }, { status: 502 });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Geoapify autocomplete. The API key stays on the server that calls this. */
|
|
59
|
+
export async function searchPlaces(
|
|
60
|
+
apiKey: string,
|
|
61
|
+
kind: PlaceKind,
|
|
62
|
+
text: string,
|
|
63
|
+
): Promise<PlaceResult[]> {
|
|
64
|
+
const query = text.trim();
|
|
65
|
+
if (query.length < PLACES_MIN_TEXT) return [];
|
|
66
|
+
|
|
67
|
+
const upstream = new URL(GEOAPIFY_AUTOCOMPLETE);
|
|
68
|
+
upstream.searchParams.set("text", query);
|
|
69
|
+
upstream.searchParams.set("format", "json");
|
|
70
|
+
upstream.searchParams.set("limit", String(PLACES_LIMIT));
|
|
71
|
+
upstream.searchParams.set("apiKey", apiKey);
|
|
72
|
+
if (kind === "city" || kind === "country") upstream.searchParams.set("type", kind);
|
|
73
|
+
|
|
74
|
+
const response = await fetch(upstream, { cache: "no-store" });
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
throw new Error("Address search failed");
|
|
77
|
+
}
|
|
78
|
+
const body = (await response.json()) as { results?: GeoResult[] };
|
|
79
|
+
return (body.results ?? [])
|
|
80
|
+
.filter((hit) => kind !== "location" || isRegion(hit))
|
|
81
|
+
.map((hit) => toPlace(hit, kind))
|
|
82
|
+
.filter((hit) => hit.label.length > 0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function placeKind(value: string | null): PlaceKind {
|
|
86
|
+
if (value === "city" || value === "country" || value === "location") return value;
|
|
87
|
+
return "address";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function regionType(hit: GeoResult) {
|
|
91
|
+
return (hit.result_type ?? "").trim().toLowerCase();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isRegion(hit: GeoResult) {
|
|
95
|
+
const type = regionType(hit);
|
|
96
|
+
return type === "city" || type === "state" || type === "country";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function toPlace(hit: GeoResult, kind: PlaceKind): PlaceResult {
|
|
100
|
+
const city = hit.city?.trim() ?? "";
|
|
101
|
+
const state = (hit.state_code || hit.state || "").trim();
|
|
102
|
+
const street = [hit.housenumber, hit.street].filter(Boolean).join(" ").trim();
|
|
103
|
+
const line1 = street || hit.address_line1?.trim() || "";
|
|
104
|
+
const country = hit.country?.trim() || "";
|
|
105
|
+
const type = regionType(hit);
|
|
106
|
+
const label =
|
|
107
|
+
kind === "city"
|
|
108
|
+
? [city, state].filter(Boolean).join(", ")
|
|
109
|
+
: kind === "country"
|
|
110
|
+
? country || hit.formatted?.trim() || ""
|
|
111
|
+
: kind === "location"
|
|
112
|
+
? locationLabel(hit, type, city, state, country)
|
|
113
|
+
: hit.formatted?.trim() || [line1, city, state].filter(Boolean).join(", ");
|
|
114
|
+
return {
|
|
115
|
+
id: hit.place_id || `${hit.lat ?? ""},${hit.lon ?? ""},${label}`,
|
|
116
|
+
label,
|
|
117
|
+
line1: kind === "city" || kind === "country" || kind === "location" ? "" : line1,
|
|
118
|
+
city: kind === "country" ? "" : city,
|
|
119
|
+
state: kind === "country" ? "" : state,
|
|
120
|
+
postalCode: kind === "country" || kind === "location" ? "" : (hit.postcode?.trim() ?? ""),
|
|
121
|
+
country: country || (kind === "country" ? label : ""),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function locationLabel(hit: GeoResult, type: string, city: string, state: string, country: string) {
|
|
126
|
+
if (type === "country") return country || hit.formatted?.trim() || "";
|
|
127
|
+
if (type === "state") return [hit.state?.trim() || state, country].filter(Boolean).join(", ");
|
|
128
|
+
return [city, state].filter(Boolean).join(", ") || hit.formatted?.trim() || "";
|
|
129
|
+
}
|