@rightkit/workspace-ui 0.0.0-stage → 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.
Files changed (54) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE-APACHE +201 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +45 -2
  5. package/dist/client.d.ts +190 -0
  6. package/dist/client.js +2 -0
  7. package/dist/env.d.ts +59 -0
  8. package/dist/hooks/useDrive.d.ts +26 -0
  9. package/dist/hooks/useDrive.js +158 -0
  10. package/dist/host.d.ts +109 -0
  11. package/dist/host.js +33 -0
  12. package/dist/icons.d.ts +34 -0
  13. package/dist/icons.js +33 -0
  14. package/dist/index.d.ts +23 -0
  15. package/dist/index.js +24 -0
  16. package/dist/lib/avatar.d.ts +4 -0
  17. package/dist/lib/avatar.js +13 -0
  18. package/dist/lib/calendarUtils.d.ts +11 -0
  19. package/dist/lib/calendarUtils.js +42 -0
  20. package/dist/lib/vcard.d.ts +12 -0
  21. package/dist/lib/vcard.js +86 -0
  22. package/dist/views/CalendarEventFormModal.d.ts +10 -0
  23. package/dist/views/CalendarEventFormModal.js +19 -0
  24. package/dist/views/CalendarEventPopover.d.ts +10 -0
  25. package/dist/views/CalendarEventPopover.js +10 -0
  26. package/dist/views/CalendarGrids.d.ts +16 -0
  27. package/dist/views/CalendarGrids.js +106 -0
  28. package/dist/views/CalendarView.d.ts +3 -0
  29. package/dist/views/CalendarView.js +332 -0
  30. package/dist/views/ContactsView.d.ts +3 -0
  31. package/dist/views/ContactsView.js +227 -0
  32. package/dist/views/ContactsView.types.d.ts +8 -0
  33. package/dist/views/ContactsView.types.js +7 -0
  34. package/dist/views/DriveFileMenu.d.ts +14 -0
  35. package/dist/views/DriveFileMenu.js +32 -0
  36. package/dist/views/DrivePreviewModal.d.ts +9 -0
  37. package/dist/views/DrivePreviewModal.js +11 -0
  38. package/dist/views/DriveView.d.ts +3 -0
  39. package/dist/views/DriveView.js +185 -0
  40. package/dist/views/MeetView.d.ts +1 -0
  41. package/dist/views/MeetView.js +81 -0
  42. package/dist/views/TasksView.d.ts +3 -0
  43. package/dist/views/TasksView.js +156 -0
  44. package/dist/views/driveFileGlyph.d.ts +7 -0
  45. package/dist/views/driveFileGlyph.js +19 -0
  46. package/dist/views/nav/CalendarNav.d.ts +1 -0
  47. package/dist/views/nav/CalendarNav.js +93 -0
  48. package/dist/views/nav/ContactsNav.d.ts +1 -0
  49. package/dist/views/nav/ContactsNav.js +22 -0
  50. package/dist/views/nav/DriveNav.d.ts +1 -0
  51. package/dist/views/nav/DriveNav.js +44 -0
  52. package/dist/views/nav/TasksNav.d.ts +1 -0
  53. package/dist/views/nav/TasksNav.js +22 -0
  54. package/package.json +70 -3
@@ -0,0 +1,158 @@
1
+ import { useWorkspaceClient, useWorkspaceHost, useWorkspaceState } from "../host.js";
2
+ // Shared Drive logic for DriveNav (the contextual sidebar) and DriveView (the
3
+ // file grid). Reads/writes Drive state through the host state so both surfaces stay
4
+ // in sync (shelf selection in the nav re-lists the grid, etc.). Transient modal
5
+ // state (move/share pickers) stays local to DriveView since only it renders them.
6
+ export const FOLDER_MIME = "application/vnd.google-apps.folder";
7
+ export function useDrive() {
8
+ const { listDriveFiles, driveDownload, driveUpload, driveCreateFolder, driveRename, driveTrash } = useWorkspaceClient();
9
+ const showDialog = useWorkspaceHost().dialog;
10
+ const saveAndScan = useWorkspaceHost().saveDownload;
11
+ const linkIntoCompose = useWorkspaceHost().linkIntoCompose;
12
+ const activeAccount = useWorkspaceState((s) => s.activeAccount);
13
+ const setErr = useWorkspaceHost().reportError;
14
+ const showUndo = useWorkspaceHost().notify;
15
+ const driveType = useWorkspaceState((s) => s.driveType);
16
+ const setDriveType = useWorkspaceState((s) => s.setDriveType);
17
+ const driveQuery = useWorkspaceState((s) => s.driveQuery);
18
+ const setDriveQuery = useWorkspaceState((s) => s.setDriveQuery);
19
+ const driveShelf = useWorkspaceState((s) => s.driveShelf);
20
+ const setDriveShelf = useWorkspaceState((s) => s.setDriveShelf);
21
+ const drivePath = useWorkspaceState((s) => s.drivePath);
22
+ const setDrivePath = useWorkspaceState((s) => s.setDrivePath);
23
+ const setDriveToken = useWorkspaceState((s) => s.setDriveToken);
24
+ const setDriveFiles = useWorkspaceState((s) => s.setDriveFiles);
25
+ const setDriveBusy = useWorkspaceState((s) => s.setDriveBusy);
26
+ /** Current Drive folder id for write ops + reload (undefined = My Drive root). */
27
+ function currentFolderId() {
28
+ return driveShelf === "my" && drivePath.length ? drivePath[drivePath.length - 1].id : undefined;
29
+ }
30
+ async function reload(folderId, query, shelf = driveShelf, typeFilter = driveType) {
31
+ if (!activeAccount)
32
+ return;
33
+ setDriveBusy(true);
34
+ try {
35
+ const listing = await listDriveFiles(activeAccount, folderId, query || undefined, undefined, shelf === "my" ? undefined : shelf, typeFilter || undefined);
36
+ setDriveFiles(listing.files);
37
+ setDriveToken(listing.next_page_token);
38
+ }
39
+ catch (e) {
40
+ setErr(String(e));
41
+ }
42
+ finally {
43
+ setDriveBusy(false);
44
+ }
45
+ }
46
+ /** Switch Drive shelf (My Drive / Recent / Shared / Starred / Trash) — clears folder nav. */
47
+ function selectShelf(shelf) {
48
+ setDriveShelf(shelf);
49
+ setDrivePath([]);
50
+ setDriveQuery("");
51
+ reload(undefined, "", shelf, driveType);
52
+ }
53
+ function setTypeFilter(t) {
54
+ setDriveType(t);
55
+ reload(currentFolderId(), driveQuery, driveShelf, t);
56
+ }
57
+ function openFolder(f) {
58
+ setDriveShelf("my");
59
+ setDrivePath((p) => [...p, { id: f.id, name: f.name }]);
60
+ setDriveQuery("");
61
+ reload(f.id, "", "my", driveType);
62
+ }
63
+ function breadcrumbTo(idx) {
64
+ const next = idx < 0 ? [] : drivePath.slice(0, idx + 1);
65
+ setDrivePath(next);
66
+ setDriveQuery("");
67
+ reload(next.length ? next[next.length - 1].id : undefined, "", "my", driveType);
68
+ }
69
+ function refresh() {
70
+ reload(currentFolderId(), driveQuery, driveShelf, driveType);
71
+ }
72
+ function download(f) {
73
+ if (!activeAccount)
74
+ return;
75
+ saveAndScan(f.name, () => driveDownload(activeAccount, f.id, f.mime_type, f.name));
76
+ }
77
+ async function newFolder() {
78
+ if (!activeAccount)
79
+ return;
80
+ const name = await showDialog({ kind: "prompt", message: "New folder name" });
81
+ if (!name || !name.trim())
82
+ return;
83
+ driveCreateFolder(activeAccount, name.trim(), currentFolderId())
84
+ .then(() => {
85
+ showUndo("Folder created");
86
+ refresh();
87
+ })
88
+ .catch((e) => setErr(String(e)));
89
+ }
90
+ /** Upload one or more files into the current folder, then refresh. */
91
+ async function upload(files) {
92
+ if (!activeAccount)
93
+ return;
94
+ const list = Array.from(files);
95
+ if (list.length === 0)
96
+ return;
97
+ const parent = currentFolderId();
98
+ setDriveBusy(true);
99
+ let ok = 0;
100
+ for (const file of list) {
101
+ try {
102
+ const data = Array.from(new Uint8Array(await file.arrayBuffer()));
103
+ await driveUpload(activeAccount, file.name, file.type, data, parent);
104
+ ok++;
105
+ }
106
+ catch (e) {
107
+ setErr(String(e));
108
+ break;
109
+ }
110
+ }
111
+ setDriveBusy(false);
112
+ if (ok > 0) {
113
+ showUndo(`Uploaded ${ok} file${ok > 1 ? "s" : ""}`);
114
+ refresh();
115
+ }
116
+ }
117
+ async function rename(f) {
118
+ if (!activeAccount)
119
+ return;
120
+ const name = await showDialog({ kind: "prompt", message: "Rename to", initial: f.name });
121
+ if (!name || !name.trim() || name.trim() === f.name)
122
+ return;
123
+ driveRename(activeAccount, f.id, name.trim())
124
+ .then(() => {
125
+ showUndo("Renamed");
126
+ refresh();
127
+ })
128
+ .catch((e) => setErr(String(e)));
129
+ }
130
+ async function trash(f) {
131
+ if (!activeAccount)
132
+ return;
133
+ if ((await showDialog({ kind: "confirm", message: `Move "${f.name}" to Drive trash? You can restore it from Google Drive.` })) !== "ok")
134
+ return;
135
+ driveTrash(activeAccount, f.id)
136
+ .then(() => {
137
+ showUndo("Moved to Drive trash");
138
+ refresh();
139
+ })
140
+ .catch((e) => setErr(String(e)));
141
+ }
142
+ /** Insert a Drive file's share link into the compose body (open compose if needed). */
143
+ function insertDriveLink(f) {
144
+ const link = f.web_view_link;
145
+ if (!link) {
146
+ setErr("That item has no shareable link.");
147
+ return;
148
+ }
149
+ const html = `<a href="${link}">${f.name}</a>`;
150
+ showUndo(linkIntoCompose(html) === "inserted" ? `Linked ${f.name}` : `Linked ${f.name} into a new email`);
151
+ }
152
+ return {
153
+ activeAccount, driveShelf, drivePath, driveQuery, driveType,
154
+ setDriveQuery,
155
+ currentFolderId, reload, selectShelf, setTypeFilter, openFolder,
156
+ breadcrumbTo, refresh, download, upload, newFolder, rename, trash, insertDriveLink,
157
+ };
158
+ }
package/dist/host.d.ts ADDED
@@ -0,0 +1,109 @@
1
+ import { type ComponentType, type ReactNode } from "react";
2
+ import type { CalEvent, CalendarInfo, ContactDupes, ContactGroup, DriveFile, DriveShelf, EventForm, StorageQuota, TaskCreate, TaskItem, TaskListInfo, WorkspaceClient } from "./client.js";
3
+ /** The workspace slices of the host's state. */
4
+ export interface WorkspaceFields {
5
+ calendars: CalendarInfo[];
6
+ selectedCal: string;
7
+ shownCals: string[];
8
+ calView: "month" | "week" | "day";
9
+ calCursor: number;
10
+ events: CalEvent[];
11
+ openEvent: CalEvent | null;
12
+ eventForm: EventForm | null;
13
+ freeBusyMsg: string;
14
+ calendarBusy: boolean;
15
+ /** A pending "new event" seed (Meet's "schedule for later" fills it, the calendar opens the form with it). */
16
+ eventSeed: {
17
+ summary: string;
18
+ withMeet: boolean;
19
+ } | null;
20
+ gtasks: TaskItem[];
21
+ taskLists: TaskListInfo[];
22
+ taskListId: string;
23
+ taskDue: string;
24
+ showCompletedTasks: boolean;
25
+ listMgrOpen: boolean;
26
+ taskFormOpen: boolean;
27
+ taskCreate: TaskCreate;
28
+ tasksBusy: boolean;
29
+ driveFiles: DriveFile[];
30
+ drivePath: {
31
+ id: string;
32
+ name: string;
33
+ }[];
34
+ driveQuery: string;
35
+ driveToken: string;
36
+ driveShelf: DriveShelf;
37
+ driveType: string;
38
+ driveViewMode: "grid" | "list";
39
+ driveQuota: StorageQuota | null;
40
+ drivePreview: DriveFile | null;
41
+ driveBusy: boolean;
42
+ contactGroups: ContactGroup[];
43
+ contactsQuery: string;
44
+ contactsBusy: boolean;
45
+ contactDupes: ContactDupes | null;
46
+ }
47
+ /** A setter that takes a value or an updater, like React's `setState`. */
48
+ export type FieldSetter<T> = (v: T | ((prev: T) => T)) => void;
49
+ /** `setX` for every field `x`. */
50
+ export type WorkspaceSetters = {
51
+ [K in keyof WorkspaceFields as `set${Capitalize<K & string>}`]: FieldSetter<WorkspaceFields[K]>;
52
+ };
53
+ /** What `useState` selects from. `activeAccount` is the host's current account key (read only). */
54
+ export type WorkspaceState = WorkspaceFields & WorkspaceSetters & {
55
+ activeAccount: string | null;
56
+ };
57
+ /** The preferences the views persist through the host. */
58
+ export interface WorkspacePreferences {
59
+ shownCals: string[];
60
+ calView: "month" | "week" | "day";
61
+ driveViewMode: "grid" | "list";
62
+ }
63
+ export type WorkspaceDialog = {
64
+ kind: "confirm";
65
+ message: string;
66
+ } | {
67
+ kind: "prompt";
68
+ message: string;
69
+ initial?: string;
70
+ };
71
+ /** The views a workspace view can ask the host to show. */
72
+ export type WorkspaceView = "calendar" | "contacts" | "drive" | "tasks" | "meet";
73
+ export interface WorkspaceHost {
74
+ client: WorkspaceClient;
75
+ /** Selector hook over the host's state. */
76
+ useState: <T>(selector: (s: WorkspaceState) => T) => T;
77
+ /** Shows an error to the user. */
78
+ reportError(message: string): void;
79
+ /** Shows a short notice (the host's toast). */
80
+ notify(message: string): void;
81
+ /** Confirm or prompt. Resolves to "ok" (confirm), the entered text (prompt), or null when cancelled. */
82
+ dialog(cfg: WorkspaceDialog): Promise<string | null>;
83
+ /** Persists a view preference. */
84
+ savePreference<K extends keyof WorkspacePreferences>(key: K, value: WorkspacePreferences[K]): void;
85
+ /** Opens a new message to `to` with `subject` and an HTML `body`. */
86
+ composeTo(to: string, subject: string, body: string): void;
87
+ /**
88
+ * Puts an HTML link into the message being written, or into a new message when none is open. Resolves to which
89
+ * one happened, so the view can say so.
90
+ */
91
+ linkIntoCompose(html: string): "inserted" | "new-message";
92
+ /** Runs a download the host saves (and may scan) under `label`. */
93
+ saveDownload(label: string, download: () => Promise<string>): void;
94
+ /** Switches the host to another workspace view. */
95
+ openView(view: WorkspaceView): void;
96
+ /** The host's account switcher, shown in each view's header. */
97
+ AccountSwitcher?: ComponentType;
98
+ }
99
+ /** Supplies the host to every workspace view and nav below it. */
100
+ export declare function WorkspaceHostProvider({ host, children }: {
101
+ host: WorkspaceHost;
102
+ children?: ReactNode;
103
+ }): import("react").JSX.Element;
104
+ export declare function useWorkspaceHost(): WorkspaceHost;
105
+ /** Selects from the host's workspace state. */
106
+ export declare function useWorkspaceState<T>(selector: (s: WorkspaceState) => T): T;
107
+ export declare function useWorkspaceClient(): WorkspaceClient;
108
+ /** The host's account switcher, or nothing. */
109
+ export declare function HostAccountSwitcher(): import("react").JSX.Element | null;
package/dist/host.js ADDED
@@ -0,0 +1,33 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // The host contract of the workspace views (calendar, contacts, drive, tasks, meet and their navs).
3
+ //
4
+ // The package holds no IPC, no store and no product name. The host supplies, once, above the views:
5
+ // - `client`: every data call (`WorkspaceClient`), over the host's own transport;
6
+ // - `useState`: a selector hook over the host's state that holds the `WorkspaceState` slices (a zustand store
7
+ // with these fields and `set*` setters satisfies it directly), so the views and the host's navs share state;
8
+ // - the host actions the views trigger: errors, notices, dialogs, saved preferences, compose, downloads, view
9
+ // switches, and an optional account switcher.
10
+ import { createContext, useContext } from "react";
11
+ const HostContext = createContext(null);
12
+ /** Supplies the host to every workspace view and nav below it. */
13
+ export function WorkspaceHostProvider({ host, children }) {
14
+ return _jsx(HostContext.Provider, { value: host, children: children });
15
+ }
16
+ export function useWorkspaceHost() {
17
+ const host = useContext(HostContext);
18
+ if (!host)
19
+ throw new Error("@rightkit/workspace-ui: render the views inside <WorkspaceHostProvider host={...}>");
20
+ return host;
21
+ }
22
+ /** Selects from the host's workspace state. */
23
+ export function useWorkspaceState(selector) {
24
+ return useWorkspaceHost().useState(selector);
25
+ }
26
+ export function useWorkspaceClient() {
27
+ return useWorkspaceHost().client;
28
+ }
29
+ /** The host's account switcher, or nothing. */
30
+ export function HostAccountSwitcher() {
31
+ const Switcher = useWorkspaceHost().AccountSwitcher;
32
+ return Switcher ? _jsx(Switcher, {}) : null;
33
+ }
@@ -0,0 +1,34 @@
1
+ import { type CSSProperties } from "react";
2
+ import { type LucideIcon } from "lucide-react";
3
+ declare const GLYPHS: {
4
+ calendar: LucideIcon;
5
+ check: LucideIcon;
6
+ "chevron-left": LucideIcon;
7
+ "chevron-right": LucideIcon;
8
+ clock: LucideIcon;
9
+ doc: LucideIcon;
10
+ draft: LucideIcon;
11
+ drive: LucideIcon;
12
+ folder: LucideIcon;
13
+ grid: LucideIcon;
14
+ image: LucideIcon;
15
+ list: LucideIcon;
16
+ more: LucideIcon;
17
+ people: LucideIcon;
18
+ "person-add": LucideIcon;
19
+ plus: LucideIcon;
20
+ star: LucideIcon;
21
+ tag: LucideIcon;
22
+ trash: LucideIcon;
23
+ upload: LucideIcon;
24
+ video: LucideIcon;
25
+ };
26
+ export type WorkspaceIconName = keyof typeof GLYPHS;
27
+ /** A workspace glyph. Decorative (hidden from assistive technology); stroke follows the text colour. */
28
+ export declare const Icon: import("react").MemoExoticComponent<({ name, size, className, style, }: {
29
+ name: WorkspaceIconName;
30
+ size?: number;
31
+ className?: string;
32
+ style?: CSSProperties;
33
+ }) => import("react").JSX.Element>;
34
+ export {};
package/dist/icons.js ADDED
@@ -0,0 +1,33 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // The workspace views' glyphs, from Lucide (lucide-react). One name per glyph the views use, so a view names a
3
+ // glyph without importing an app's icon set.
4
+ import { memo } from "react";
5
+ import { Calendar, Check, ChevronLeft, ChevronRight, Clock, Ellipsis, File, FileText, Folder, HardDrive, Image, LayoutGrid, List, Plus, Star, Tag, Trash2, Upload, UserPlus, Users, Video, } from "lucide-react";
6
+ const GLYPHS = {
7
+ calendar: Calendar,
8
+ check: Check,
9
+ "chevron-left": ChevronLeft,
10
+ "chevron-right": ChevronRight,
11
+ clock: Clock,
12
+ doc: File,
13
+ draft: FileText,
14
+ drive: HardDrive,
15
+ folder: Folder,
16
+ grid: LayoutGrid,
17
+ image: Image,
18
+ list: List,
19
+ more: Ellipsis,
20
+ people: Users,
21
+ "person-add": UserPlus,
22
+ plus: Plus,
23
+ star: Star,
24
+ tag: Tag,
25
+ trash: Trash2,
26
+ upload: Upload,
27
+ video: Video,
28
+ };
29
+ /** A workspace glyph. Decorative (hidden from assistive technology); stroke follows the text colour. */
30
+ export const Icon = memo(function Icon({ name, size = 16, className, style, }) {
31
+ const Glyph = GLYPHS[name];
32
+ return _jsx(Glyph, { size: size, className: className, style: style, strokeWidth: 1.75, "aria-hidden": "true", focusable: "false" });
33
+ });
@@ -0,0 +1,23 @@
1
+ export * from "./client.js";
2
+ export * from "./host.js";
3
+ export * from "./icons.js";
4
+ export * from "./hooks/useDrive.js";
5
+ export * from "./lib/avatar.js";
6
+ export * from "./lib/calendarUtils.js";
7
+ export * from "./lib/vcard.js";
8
+ export * from "./views/CalendarEventFormModal.js";
9
+ export * from "./views/CalendarEventPopover.js";
10
+ export * from "./views/CalendarGrids.js";
11
+ export * from "./views/CalendarView.js";
12
+ export * from "./views/ContactsView.js";
13
+ export * from "./views/ContactsView.types.js";
14
+ export * from "./views/DriveFileMenu.js";
15
+ export * from "./views/DrivePreviewModal.js";
16
+ export * from "./views/DriveView.js";
17
+ export * from "./views/MeetView.js";
18
+ export * from "./views/TasksView.js";
19
+ export * from "./views/driveFileGlyph.js";
20
+ export * from "./views/nav/CalendarNav.js";
21
+ export * from "./views/nav/ContactsNav.js";
22
+ export * from "./views/nav/DriveNav.js";
23
+ export * from "./views/nav/TasksNav.js";
package/dist/index.js ADDED
@@ -0,0 +1,24 @@
1
+ // The workspace views and navs, the host contract they render over, and the workspace data model.
2
+ export * from "./client.js";
3
+ export * from "./host.js";
4
+ export * from "./icons.js";
5
+ export * from "./hooks/useDrive.js";
6
+ export * from "./lib/avatar.js";
7
+ export * from "./lib/calendarUtils.js";
8
+ export * from "./lib/vcard.js";
9
+ export * from "./views/CalendarEventFormModal.js";
10
+ export * from "./views/CalendarEventPopover.js";
11
+ export * from "./views/CalendarGrids.js";
12
+ export * from "./views/CalendarView.js";
13
+ export * from "./views/ContactsView.js";
14
+ export * from "./views/ContactsView.types.js";
15
+ export * from "./views/DriveFileMenu.js";
16
+ export * from "./views/DrivePreviewModal.js";
17
+ export * from "./views/DriveView.js";
18
+ export * from "./views/MeetView.js";
19
+ export * from "./views/TasksView.js";
20
+ export * from "./views/driveFileGlyph.js";
21
+ export * from "./views/nav/CalendarNav.js";
22
+ export * from "./views/nav/ContactsNav.js";
23
+ export * from "./views/nav/DriveNav.js";
24
+ export * from "./views/nav/TasksNav.js";
@@ -0,0 +1,4 @@
1
+ /** The first letter of a name or address, upper-cased; "?" for an empty one. */
2
+ export declare const initialOf: (s: string) => string;
3
+ /** Stable per-person avatar colour (Gmail-style coloured initials). */
4
+ export declare function colorFor(s: string): string;
@@ -0,0 +1,13 @@
1
+ // Coloured-initial avatars for people (contacts, senders). One copy: the mail views import them from here.
2
+ /** The first letter of a name or address, upper-cased; "?" for an empty one. */
3
+ export const initialOf = (s) => (s.trim()[0] || "?").toUpperCase();
4
+ const AVATAR_COLORS = [
5
+ "#C46A3D", "#6E8B6B", "#B07A84", "#7E8AA8", "#B59A52", "#8A6BA8", "#5E8C8C", "#A8675B",
6
+ ];
7
+ /** Stable per-person avatar colour (Gmail-style coloured initials). */
8
+ export function colorFor(s) {
9
+ let h = 0;
10
+ for (let i = 0; i < s.length; i++)
11
+ h = (h * 31 + s.charCodeAt(i)) >>> 0;
12
+ return AVATAR_COLORS[h % AVATAR_COLORS.length];
13
+ }
@@ -0,0 +1,11 @@
1
+ import type { CalEvent } from "../client.js";
2
+ export declare const DAY = 86400000;
3
+ export declare const WEEKDAY_LABELS: string[];
4
+ /** RRULE BYDAY codes with their short labels, Monday first (the event form's repeat-on picker). */
5
+ export declare const WEEKDAYS: ReadonlyArray<readonly [string, string]>;
6
+ export declare const startOfDay: (ms: number) => number;
7
+ export declare const startOfWeek: (ms: number) => number;
8
+ export declare const sameDay: (a: number, b: number) => boolean;
9
+ export declare function rangeFor(cursor: number, view: "month" | "week" | "day"): [number, number];
10
+ export declare function eventDay(e: CalEvent): number;
11
+ export declare const hhmm: (s: string) => string;
@@ -0,0 +1,42 @@
1
+ export const DAY = 86400000;
2
+ export const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
3
+ /** RRULE BYDAY codes with their short labels, Monday first (the event form's repeat-on picker). */
4
+ export const WEEKDAYS = [
5
+ ["MO", "Mon"],
6
+ ["TU", "Tue"],
7
+ ["WE", "Wed"],
8
+ ["TH", "Thu"],
9
+ ["FR", "Fri"],
10
+ ["SA", "Sat"],
11
+ ["SU", "Sun"],
12
+ ];
13
+ export const startOfDay = (ms) => {
14
+ const d = new Date(ms);
15
+ d.setHours(0, 0, 0, 0);
16
+ return d.getTime();
17
+ };
18
+ export const startOfWeek = (ms) => {
19
+ const d = new Date(startOfDay(ms));
20
+ return d.getTime() - d.getDay() * DAY;
21
+ };
22
+ export const sameDay = (a, b) => startOfDay(a) === startOfDay(b);
23
+ export function rangeFor(cursor, view) {
24
+ if (view === "day")
25
+ return [startOfDay(cursor), startOfDay(cursor) + DAY];
26
+ if (view === "week") {
27
+ const s = startOfWeek(cursor);
28
+ return [s, s + 7 * DAY];
29
+ }
30
+ const d = new Date(cursor);
31
+ const first = new Date(d.getFullYear(), d.getMonth(), 1).getTime();
32
+ const gridStart = startOfWeek(first);
33
+ return [gridStart, gridStart + 42 * DAY];
34
+ }
35
+ export function eventDay(e) {
36
+ if (e.all_day) {
37
+ const [y, m, d] = e.start.slice(0, 10).split("-").map(Number);
38
+ return new Date(y, (m || 1) - 1, d || 1).getTime();
39
+ }
40
+ return startOfDay(new Date(e.start).getTime());
41
+ }
42
+ export const hhmm = (s) => new Date(s).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
@@ -0,0 +1,12 @@
1
+ export interface VCardContact {
2
+ name: string;
3
+ emails: string[];
4
+ phones: string[];
5
+ org?: string;
6
+ }
7
+ /** Serialize contacts to a vCard 3.0 (.vcf) document. */
8
+ export declare function buildVcf(contacts: VCardContact[]): string;
9
+ /** Parse a vCard document (one or many cards) into contacts. */
10
+ export declare function parseVcf(text: string): VCardContact[];
11
+ /** Best-effort CSV parse: needs a header row with name/email columns (Google/Outlook export style). */
12
+ export declare function parseContactsCsv(text: string): VCardContact[];
@@ -0,0 +1,86 @@
1
+ // Minimal vCard 3.0 + simple CSV (de)serialization for contacts import/export.
2
+ // Pure functions, no DOM/IPC — unit-testable.
3
+ function escapeVcard(v) {
4
+ // RFC 6350 escaping for the values we emit.
5
+ return v.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/,/g, "\\,").replace(/;/g, "\\;");
6
+ }
7
+ function unescapeVcard(v) {
8
+ return v.replace(/\\n/gi, "\n").replace(/\\,/g, ",").replace(/\\;/g, ";").replace(/\\\\/g, "\\");
9
+ }
10
+ /** Serialize contacts to a vCard 3.0 (.vcf) document. */
11
+ export function buildVcf(contacts) {
12
+ const cards = contacts.map((c) => {
13
+ const lines = ["BEGIN:VCARD", "VERSION:3.0", `FN:${escapeVcard(c.name || "")}`];
14
+ for (const e of c.emails)
15
+ if (e.trim())
16
+ lines.push(`EMAIL:${escapeVcard(e.trim())}`);
17
+ for (const p of c.phones)
18
+ if (p.trim())
19
+ lines.push(`TEL:${escapeVcard(p.trim())}`);
20
+ if (c.org && c.org.trim())
21
+ lines.push(`ORG:${escapeVcard(c.org.trim())}`);
22
+ lines.push("END:VCARD");
23
+ return lines.join("\r\n");
24
+ });
25
+ return cards.join("\r\n") + "\r\n";
26
+ }
27
+ /** Parse a vCard document (one or many cards) into contacts. */
28
+ export function parseVcf(text) {
29
+ const out = [];
30
+ // Unfold folded lines (RFC 6350 §3.2: a CRLF + space/tab continues a line).
31
+ const unfolded = text.replace(/\r\n[ \t]/g, "").replace(/\n[ \t]/g, "");
32
+ const blocks = unfolded.split(/END:VCARD/i);
33
+ for (const block of blocks) {
34
+ if (!/BEGIN:VCARD/i.test(block))
35
+ continue;
36
+ const c = { name: "", emails: [], phones: [], org: "" };
37
+ for (const raw of block.split(/\r?\n/)) {
38
+ const line = raw.trim();
39
+ // Strip any property parameters before the ':' (e.g. EMAIL;TYPE=WORK:).
40
+ const colon = line.indexOf(":");
41
+ if (colon < 0)
42
+ continue;
43
+ const key = line.slice(0, colon).split(";")[0].toUpperCase();
44
+ const val = unescapeVcard(line.slice(colon + 1).trim());
45
+ if (!val)
46
+ continue;
47
+ if (key === "FN")
48
+ c.name = val;
49
+ else if (key === "N" && !c.name) {
50
+ const [family = "", given = ""] = val.split(";");
51
+ c.name = [given, family].filter(Boolean).join(" ");
52
+ }
53
+ else if (key === "EMAIL")
54
+ c.emails.push(val);
55
+ else if (key === "TEL")
56
+ c.phones.push(val);
57
+ else if (key === "ORG")
58
+ c.org = val.split(";")[0];
59
+ }
60
+ if (c.name || c.emails.length)
61
+ out.push(c);
62
+ }
63
+ return out;
64
+ }
65
+ /** Best-effort CSV parse: needs a header row with name/email columns (Google/Outlook export style). */
66
+ export function parseContactsCsv(text) {
67
+ const rows = text.split(/\r?\n/).filter((r) => r.trim());
68
+ if (rows.length < 2)
69
+ return [];
70
+ const split = (r) => r.split(",").map((c) => c.replace(/^"|"$/g, "").trim());
71
+ const header = split(rows[0]).map((h) => h.toLowerCase());
72
+ const nameIdx = header.findIndex((h) => h.includes("name"));
73
+ const emailIdx = header.findIndex((h) => h.includes("e-mail") || h.includes("email"));
74
+ const phoneIdx = header.findIndex((h) => h.includes("phone"));
75
+ const out = [];
76
+ for (let i = 1; i < rows.length; i++) {
77
+ const cols = split(rows[i]);
78
+ const name = nameIdx >= 0 ? cols[nameIdx] ?? "" : "";
79
+ const email = emailIdx >= 0 ? cols[emailIdx] ?? "" : "";
80
+ const phone = phoneIdx >= 0 ? cols[phoneIdx] ?? "" : "";
81
+ if (name || email) {
82
+ out.push({ name, emails: email ? [email] : [], phones: phone ? [phone] : [], org: "" });
83
+ }
84
+ }
85
+ return out;
86
+ }
@@ -0,0 +1,10 @@
1
+ import type { EventForm } from "../client.js";
2
+ type CalendarEventFormModalProps = {
3
+ eventForm: EventForm;
4
+ setEventForm: (form: EventForm | null) => void;
5
+ freeBusyMsg: string;
6
+ checkAvailability: () => void;
7
+ submitEvent: () => void;
8
+ };
9
+ export declare function CalendarEventFormModal({ eventForm, setEventForm, freeBusyMsg, checkAvailability, submitEvent, }: CalendarEventFormModalProps): import("react").JSX.Element;
10
+ export {};
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Button, Dialog, Input, Checkbox, Select } from "@rightkit/react-shell/react";
3
+ import { WEEKDAYS } from "../lib/calendarUtils.js";
4
+ export function CalendarEventFormModal({ eventForm, setEventForm, freeBusyMsg, checkAvailability, submitEvent, }) {
5
+ return (_jsx(Dialog, { title: eventForm.id ? "Edit event" : "New event", width: 460, onClose: () => setEventForm(null), children: _jsxs("div", { className: "ws-form", children: [_jsx(Input, { className: "event-title-input", placeholder: "Add title", autoFocus: true, value: eventForm.summary, onChange: (e) => setEventForm({ ...eventForm, summary: e.target.value }) }), _jsxs("label", { className: "ws-form-row", children: [_jsx("span", { children: "Start" }), _jsx(Input, { type: eventForm.allDay ? "date" : "datetime-local", value: eventForm.allDay ? eventForm.start.slice(0, 10) : eventForm.start, onChange: (e) => setEventForm({ ...eventForm, start: eventForm.allDay ? `${e.target.value}T09:00` : e.target.value }) })] }), _jsxs("label", { className: "ws-form-row", children: [_jsx("span", { children: "End" }), _jsx(Input, { type: eventForm.allDay ? "date" : "datetime-local", value: eventForm.allDay ? eventForm.end.slice(0, 10) : eventForm.end, onChange: (e) => setEventForm({ ...eventForm, end: eventForm.allDay ? `${e.target.value}T10:00` : e.target.value }) })] }), _jsx("div", { className: "label-toggle", children: _jsx(Checkbox, { label: "All day", checked: eventForm.allDay, onChange: (on) => setEventForm({ ...eventForm, allDay: on }) }) }), _jsx(Input, { placeholder: "Guests (comma-separated emails)", value: eventForm.attendees, onChange: (e) => setEventForm({ ...eventForm, attendees: e.target.value }) }), _jsxs("label", { className: "ws-form-row", children: [_jsx("span", { children: "Remind (min before)" }), _jsx(Input, { type: "number", style: { width: 80 }, value: eventForm.reminderMinutes, onChange: (e) => setEventForm({ ...eventForm, reminderMinutes: e.target.value }) })] }), _jsx("div", { className: "label-toggle", children: _jsx(Checkbox, { label: "Add Google Meet video call", checked: eventForm.addMeet, onChange: (on) => setEventForm({ ...eventForm, addMeet: on }) }) }), _jsxs("label", { className: "ws-form-row", children: [_jsx("span", { children: "Repeats" }), _jsx(Select, { label: "Repeats", value: eventForm.recurrence, options: [
6
+ { value: "", label: "Does not repeat" },
7
+ { value: "DAILY", label: "Daily" },
8
+ { value: "WEEKLY", label: "Weekly" },
9
+ { value: "MONTHLY", label: "Monthly" },
10
+ { value: "YEARLY", label: "Yearly" },
11
+ ], onChange: (v) => setEventForm({ ...eventForm, recurrence: v }) })] }), eventForm.recurrence === "WEEKLY" && (_jsxs("div", { className: "ws-form-row", children: [_jsx("span", { children: "On" }), _jsx("div", { className: "byday-row", children: WEEKDAYS.map(([code, label]) => {
12
+ const on = eventForm.recurByday.includes(code);
13
+ return (_jsx(Button, { variant: "ghost", type: "button", className: `chip ${on ? "on" : ""}`, title: label, onClick: () => setEventForm({ ...eventForm, recurByday: on ? eventForm.recurByday.filter((d) => d !== code) : [...eventForm.recurByday, code] }), children: label[0] }, code));
14
+ }) })] })), eventForm.recurrence && (_jsxs("div", { className: "ws-form-row", children: [_jsx("span", { children: "Ends" }), _jsx(Select, { label: "Ends", value: eventForm.recurEnd, options: [
15
+ { value: "never", label: "Never" },
16
+ { value: "until", label: "On date" },
17
+ { value: "count", label: "After…" },
18
+ ], onChange: (v) => setEventForm({ ...eventForm, recurEnd: v }) }), eventForm.recurEnd === "until" && (_jsx(Input, { type: "date", min: eventForm.start.slice(0, 10), value: eventForm.recurUntil, onChange: (e) => setEventForm({ ...eventForm, recurUntil: e.target.value }) })), eventForm.recurEnd === "count" && (_jsxs("span", { className: "recur-count", children: [_jsx(Input, { type: "number", min: 1, max: 999, value: eventForm.recurCount, onChange: (e) => setEventForm({ ...eventForm, recurCount: e.target.value }) }), "occurrences"] }))] })), eventForm.attendees.trim() && (_jsxs("div", { className: "ws-form-row", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: checkAvailability, children: "Check availability" }), freeBusyMsg && _jsx("span", { className: "hint", children: freeBusyMsg })] })), _jsxs("div", { className: "ws-form-foot", children: [_jsx(Button, { variant: "primary", onClick: submitEvent, children: eventForm.id ? "Save" : "Create" }), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => setEventForm(null), children: "Cancel" })] })] }) }));
19
+ }
@@ -0,0 +1,10 @@
1
+ import type { CalEvent } from "../client.js";
2
+ type CalendarEventPopoverProps = {
3
+ openEvent: CalEvent;
4
+ setOpenEvent: (ev: CalEvent | null) => void;
5
+ openEventForm: (ev: CalEvent) => void;
6
+ deleteEv: (ev: CalEvent) => void;
7
+ rsvp: (ev: CalEvent, response: string) => void;
8
+ };
9
+ export declare function CalendarEventPopover({ openEvent, setOpenEvent, openEventForm, deleteEv, rsvp, }: CalendarEventPopoverProps): import("react").JSX.Element;
10
+ export {};