@rightkit/workspace-ui 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +24 -14
- package/dist/client.d.ts +190 -0
- package/dist/client.js +2 -0
- package/dist/hooks/useDrive.d.ts +26 -0
- package/dist/hooks/useDrive.js +158 -0
- package/dist/host.d.ts +109 -0
- package/dist/host.js +33 -0
- package/dist/icons.d.ts +34 -0
- package/dist/icons.js +33 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +11 -2
- package/dist/lib/avatar.d.ts +4 -0
- package/dist/lib/avatar.js +13 -0
- package/dist/lib/calendarUtils.d.ts +11 -0
- package/dist/lib/calendarUtils.js +42 -0
- package/dist/views/CalendarEventFormModal.d.ts +1 -1
- package/dist/views/CalendarEventFormModal.js +1 -1
- package/dist/views/CalendarEventPopover.d.ts +1 -1
- package/dist/views/CalendarEventPopover.js +3 -2
- package/dist/views/CalendarGrids.d.ts +1 -1
- package/dist/views/CalendarGrids.js +1 -1
- package/dist/views/CalendarView.js +34 -35
- package/dist/views/ContactsView.js +24 -27
- package/dist/views/DriveFileMenu.d.ts +2 -2
- package/dist/views/DriveFileMenu.js +3 -3
- package/dist/views/DrivePreviewModal.d.ts +2 -2
- package/dist/views/DrivePreviewModal.js +4 -4
- package/dist/views/DriveView.js +23 -24
- package/dist/views/MeetView.js +11 -11
- package/dist/views/TasksView.js +29 -29
- package/dist/views/driveFileGlyph.d.ts +3 -2
- package/dist/views/nav/CalendarNav.d.ts +1 -0
- package/dist/views/nav/CalendarNav.js +93 -0
- package/dist/views/nav/ContactsNav.d.ts +1 -0
- package/dist/views/nav/ContactsNav.js +22 -0
- package/dist/views/nav/DriveNav.d.ts +1 -0
- package/dist/views/nav/DriveNav.js +44 -0
- package/dist/views/nav/TasksNav.d.ts +1 -0
- package/dist/views/nav/TasksNav.js +22 -0
- package/package.json +23 -9
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
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";
|
|
1
7
|
export * from "./lib/vcard.js";
|
|
2
8
|
export * from "./views/CalendarEventFormModal.js";
|
|
3
9
|
export * from "./views/CalendarEventPopover.js";
|
|
@@ -11,3 +17,7 @@ export * from "./views/DriveView.js";
|
|
|
11
17
|
export * from "./views/MeetView.js";
|
|
12
18
|
export * from "./views/TasksView.js";
|
|
13
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
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
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";
|
|
3
8
|
export * from "./lib/vcard.js";
|
|
4
9
|
export * from "./views/CalendarEventFormModal.js";
|
|
5
10
|
export * from "./views/CalendarEventPopover.js";
|
|
@@ -13,3 +18,7 @@ export * from "./views/DriveView.js";
|
|
|
13
18
|
export * from "./views/MeetView.js";
|
|
14
19
|
export * from "./views/TasksView.js";
|
|
15
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,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" });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Button, Dialog, Input, Checkbox, Select } from "@rightkit/react-shell/react";
|
|
3
|
-
import { WEEKDAYS } from "
|
|
3
|
+
import { WEEKDAYS } from "../lib/calendarUtils.js";
|
|
4
4
|
export function CalendarEventFormModal({ eventForm, setEventForm, freeBusyMsg, checkAvailability, submitEvent, }) {
|
|
5
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
6
|
{ value: "", label: "Does not repeat" },
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Button, Dialog } from "@rightkit/react-shell/react";
|
|
3
|
-
import { eventDay, hhmm } from "
|
|
4
|
-
import {
|
|
3
|
+
import { eventDay, hhmm } from "../lib/calendarUtils.js";
|
|
4
|
+
import { useWorkspaceClient } from "../host.js";
|
|
5
5
|
export function CalendarEventPopover({ openEvent, setOpenEvent, openEventForm, deleteEv, rsvp, }) {
|
|
6
|
+
const { openExternal } = useWorkspaceClient();
|
|
6
7
|
return (_jsxs(Dialog, { title: openEvent.summary || "(no title)", width: 420, onClose: () => setOpenEvent(null), children: [_jsxs("p", { className: "event-when", children: [openEvent.all_day
|
|
7
8
|
? new Date(eventDay(openEvent)).toLocaleDateString([], { weekday: "long", month: "long", day: "numeric" }) + " · all day"
|
|
8
9
|
: `${new Date(openEvent.start).toLocaleDateString([], { weekday: "long", month: "long", day: "numeric" })} · ${hhmm(openEvent.start)}${openEvent.end ? `–${hhmm(openEvent.end)}` : ""}`, openEvent.is_recurring ? " · repeats" : ""] }), openEvent.location && _jsx("p", { className: "hint", children: openEvent.location }), openEvent.description && _jsx("p", { className: "event-desc", children: openEvent.description }), openEvent.attendees.length > 0 && (_jsxs("p", { className: "hint", children: [openEvent.attendees.length, " guest", openEvent.attendees.length > 1 ? "s" : ""] })), _jsxs("div", { className: "ws-detail-actions", children: [openEvent.meet_link && (_jsx(Button, { variant: "primary", size: "sm", onClick: () => openExternal(openEvent.meet_link), children: "Join Meet" })), openEvent.my_response && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => rsvp(openEvent, "accepted"), children: "Yes" }), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => rsvp(openEvent, "tentative"), children: "Maybe" }), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => rsvp(openEvent, "declined"), children: "No" })] })), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => openEventForm(openEvent), children: "Edit" }), _jsx(Button, { variant: "danger", size: "sm", onClick: () => deleteEv(openEvent), children: "Delete" })] })] }));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Button } from "@rightkit/react-shell/react";
|
|
3
3
|
import { useEffect, useMemo, useRef } from "react";
|
|
4
|
-
import { DAY, WEEKDAY_LABELS, eventDay, hhmm, sameDay, startOfWeek } from "
|
|
4
|
+
import { DAY, WEEKDAY_LABELS, eventDay, hhmm, sameDay, startOfWeek } from "../lib/calendarUtils.js";
|
|
5
5
|
export function MonthGrid({ cursor, events, today, onDay, onEvent }) {
|
|
6
6
|
const d = new Date(cursor);
|
|
7
7
|
const first = new Date(d.getFullYear(), d.getMonth(), 1).getTime();
|
|
@@ -1,42 +1,41 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Button, IconButton, Dialog, SegmentedControl } from "@rightkit/react-shell/react";
|
|
3
3
|
import { memo, useEffect, useRef, useState } from "react";
|
|
4
|
-
import Icon from "
|
|
5
|
-
import { AccountSwitcher } from "@rightkit/mail-ui/components/AccountSwitcher";
|
|
6
|
-
import { setPref } from "@rightkit/mail-ui/lib/preferences";
|
|
7
|
-
import { useAppStore } from "@rightkit/mail-ui/store/index";
|
|
8
|
-
import { showDialog } from "@rightkit/mail-ui/hooks/dialog";
|
|
9
|
-
import { listCalendars, listEvents, createEvent, updateEvent, deleteEvent, truncateEventSeries, rsvpEvent, freeBusy, } from "@rightkit/mail-ui/lib/ipc";
|
|
4
|
+
import { Icon } from "../icons.js";
|
|
10
5
|
import { MonthGrid, TimeGrid } from "./CalendarGrids.js";
|
|
11
|
-
import { DAY, rangeFor, startOfDay, startOfWeek } from "
|
|
6
|
+
import { DAY, rangeFor, startOfDay, startOfWeek } from "../lib/calendarUtils.js";
|
|
12
7
|
import { CalendarEventFormModal } from "./CalendarEventFormModal.js";
|
|
13
8
|
import { CalendarEventPopover } from "./CalendarEventPopover.js";
|
|
9
|
+
import { HostAccountSwitcher, useWorkspaceClient, useWorkspaceHost, useWorkspaceState } from "../host.js";
|
|
14
10
|
const CALENDAR_VIEWS = ["month", "week", "day"];
|
|
15
11
|
function CalendarViewInner() {
|
|
16
12
|
const [recurScope, setRecurScope] = useState(null);
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
const
|
|
13
|
+
const { listCalendars, listEvents, createEvent, updateEvent, deleteEvent, truncateEventSeries, rsvpEvent, freeBusy } = useWorkspaceClient();
|
|
14
|
+
const showDialog = useWorkspaceHost().dialog;
|
|
15
|
+
const setPref = useWorkspaceHost().savePreference;
|
|
16
|
+
const activeAccount = useWorkspaceState((s) => s.activeAccount);
|
|
17
|
+
const setErr = useWorkspaceHost().reportError;
|
|
18
|
+
const showUndo = useWorkspaceHost().notify;
|
|
19
|
+
const setCalendars = useWorkspaceState((s) => s.setCalendars);
|
|
20
|
+
const selectedCal = useWorkspaceState((s) => s.selectedCal);
|
|
21
|
+
const setSelectedCal = useWorkspaceState((s) => s.setSelectedCal);
|
|
22
|
+
const shownCals = useWorkspaceState((s) => s.shownCals);
|
|
23
|
+
const setShownCals = useWorkspaceState((s) => s.setShownCals);
|
|
24
|
+
const calView = useWorkspaceState((s) => s.calView);
|
|
25
|
+
const setCalView = useWorkspaceState((s) => s.setCalView);
|
|
26
|
+
const calCursor = useWorkspaceState((s) => s.calCursor);
|
|
27
|
+
const setCalCursor = useWorkspaceState((s) => s.setCalCursor);
|
|
28
|
+
const events = useWorkspaceState((s) => s.events);
|
|
29
|
+
const setEvents = useWorkspaceState((s) => s.setEvents);
|
|
30
|
+
const openEvent = useWorkspaceState((s) => s.openEvent);
|
|
31
|
+
const setOpenEvent = useWorkspaceState((s) => s.setOpenEvent);
|
|
32
|
+
const eventForm = useWorkspaceState((s) => s.eventForm);
|
|
33
|
+
const setEventForm = useWorkspaceState((s) => s.setEventForm);
|
|
34
|
+
const freeBusyMsg = useWorkspaceState((s) => s.freeBusyMsg);
|
|
35
|
+
const setFreeBusyMsg = useWorkspaceState((s) => s.setFreeBusyMsg);
|
|
36
|
+
const setCalendarBusy = useWorkspaceState((s) => s.setCalendarBusy);
|
|
37
|
+
const eventSeed = useWorkspaceState((s) => s.eventSeed);
|
|
38
|
+
const setEventSeed = useWorkspaceState((s) => s.setEventSeed);
|
|
40
39
|
const [calendarNow] = useState(() => {
|
|
41
40
|
const now = new Date();
|
|
42
41
|
return {
|
|
@@ -192,7 +191,7 @@ function CalendarViewInner() {
|
|
|
192
191
|
await createEvent(activeAccount, selectedCal, ev);
|
|
193
192
|
setEventForm(null);
|
|
194
193
|
await reloadEvents();
|
|
195
|
-
showUndo(f.id ? "Event updated" : "Event created"
|
|
194
|
+
showUndo(f.id ? "Event updated" : "Event created");
|
|
196
195
|
}
|
|
197
196
|
catch (e) {
|
|
198
197
|
setErr(String(e));
|
|
@@ -207,7 +206,7 @@ function CalendarViewInner() {
|
|
|
207
206
|
setOpenEvent(null);
|
|
208
207
|
return reloadEvents();
|
|
209
208
|
})
|
|
210
|
-
.then(() => showUndo("Event deleted"
|
|
209
|
+
.then(() => showUndo("Event deleted"))
|
|
211
210
|
.catch((e) => setErr(String(e)));
|
|
212
211
|
}
|
|
213
212
|
function performTruncate(ev) {
|
|
@@ -219,7 +218,7 @@ function CalendarViewInner() {
|
|
|
219
218
|
setOpenEvent(null);
|
|
220
219
|
return reloadEvents();
|
|
221
220
|
})
|
|
222
|
-
.then(() => showUndo("This and following events removed"
|
|
221
|
+
.then(() => showUndo("This and following events removed"))
|
|
223
222
|
.catch((e) => setErr(String(e)));
|
|
224
223
|
}
|
|
225
224
|
async function deleteEv(ev) {
|
|
@@ -240,7 +239,7 @@ function CalendarViewInner() {
|
|
|
240
239
|
.then(() => reloadEvents())
|
|
241
240
|
.then(() => {
|
|
242
241
|
setOpenEvent(null);
|
|
243
|
-
showUndo(`RSVP: ${response}
|
|
242
|
+
showUndo(`RSVP: ${response}`);
|
|
244
243
|
})
|
|
245
244
|
.catch((e) => setErr(String(e)));
|
|
246
245
|
}
|
|
@@ -326,7 +325,7 @@ function CalendarViewInner() {
|
|
|
326
325
|
}
|
|
327
326
|
return d.toLocaleDateString([], { month: "long", year: "numeric" });
|
|
328
327
|
})();
|
|
329
|
-
return (_jsxs("div", { className: "calview", children: [_jsxs("div", { className: "cal-bar", children: [_jsx(Button, { variant: "ghost", className: "cal-today", onClick: () => setCalCursor(Date.now()), children: "Today" }), _jsx(IconButton, { label: "Previous", className: "cal-step", title: "Previous", onClick: () => step(-1), children: _jsx(Icon, { name: "chevron-left", size: 18 }) }), _jsx(IconButton, { label: "Next", className: "cal-step", title: "Next", onClick: () => step(1), children: _jsx(Icon, { name: "chevron-right", size: 18 }) }), _jsx("h2", { className: "cal-label", children: headerLabel }), _jsx("div", { className: "cal-bar-spacer" }), _jsx("div", { className: "cal-viewtoggle", children: _jsx(SegmentedControl, { label: "Calendar view", value: calView, options: CALENDAR_VIEWS.map((v) => ({ value: v, label: v[0].toUpperCase() + v.slice(1) })), onChange: (v) => pickView(v) }) }), _jsx(
|
|
328
|
+
return (_jsxs("div", { className: "calview", children: [_jsxs("div", { className: "cal-bar", children: [_jsx(Button, { variant: "ghost", className: "cal-today", onClick: () => setCalCursor(Date.now()), children: "Today" }), _jsx(IconButton, { label: "Previous", className: "cal-step", title: "Previous", onClick: () => step(-1), children: _jsx(Icon, { name: "chevron-left", size: 18 }) }), _jsx(IconButton, { label: "Next", className: "cal-step", title: "Next", onClick: () => step(1), children: _jsx(Icon, { name: "chevron-right", size: 18 }) }), _jsx("h2", { className: "cal-label", children: headerLabel }), _jsx("div", { className: "cal-bar-spacer" }), _jsx("div", { className: "cal-viewtoggle", children: _jsx(SegmentedControl, { label: "Calendar view", value: calView, options: CALENDAR_VIEWS.map((v) => ({ value: v, label: v[0].toUpperCase() + v.slice(1) })), onChange: (v) => pickView(v) }) }), _jsx(HostAccountSwitcher, {})] }), _jsx("div", { className: "cal-grid", onWheel: onWheel, children: calView === "month" ? (_jsx(MonthGrid, { cursor: calCursor, events: events, today: calendarNow.today, onDay: (ms) => openEventForm(undefined, undefined, false, ms + 9 * 3600000), onEvent: setOpenEvent })) : (_jsx(TimeGrid, { days: calView === "week"
|
|
330
329
|
? Array.from({ length: 7 }, (_, i) => startOfWeek(calCursor) + i * DAY)
|
|
331
330
|
: [startOfDay(calCursor)], events: events, today: calendarNow.today, nowMin: calendarNow.minute, onSlot: (ms) => openEventForm(undefined, undefined, false, ms), onEvent: setOpenEvent })) }), openEvent && (_jsx(CalendarEventPopover, { openEvent: openEvent, setOpenEvent: setOpenEvent, openEventForm: openEventForm, deleteEv: deleteEv, rsvp: rsvp })), eventForm && (_jsx(CalendarEventFormModal, { eventForm: eventForm, setEventForm: setEventForm, freeBusyMsg: freeBusyMsg, checkAvailability: checkAvailability, submitEvent: submitEvent })), recurScope && (_jsxs(Dialog, { title: recurScope.mode === "edit" ? "Edit recurring event" : "Delete recurring event", width: 420, onClose: () => setRecurScope(null), children: [_jsxs("p", { className: "hint", children: ["\u201C", recurScope.ev.summary, "\u201D repeats. Apply to:"] }), _jsxs("div", { className: "recur-choices", children: [_jsx(Button, { variant: "primary", onClick: () => recurScope.mode === "edit" ? fillEditForm(recurScope.ev, recurScope.ev.id) : performDelete(recurScope.ev, recurScope.ev.id), children: "This event" }), recurScope.mode === "delete" && (_jsx(Button, { variant: "primary", onClick: () => performTruncate(recurScope.ev), children: "This and following events" })), _jsx(Button, { variant: "primary", onClick: () => recurScope.mode === "edit" ? fillEditForm(recurScope.ev, recurScope.ev.recurring_event_id) : performDelete(recurScope.ev, recurScope.ev.recurring_event_id), children: "All events" })] })] }))] }));
|
|
332
331
|
}
|
|
@@ -3,29 +3,27 @@ import { Button, Dialog, SkeletonRows, Input } from "@rightkit/react-shell/react
|
|
|
3
3
|
// Contacts — a browsable list (parity with contacts.google.com): search +
|
|
4
4
|
// full contact list with avatars, a create/edit modal, delete, and the
|
|
5
5
|
// import/export/dedupe tools. Group filtering reads the active group from the
|
|
6
|
-
//
|
|
6
|
+
// host state (set in ContactsNav). Directory search falls back to the People API for
|
|
7
7
|
// addresses outside the personal contact list.
|
|
8
8
|
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
9
|
-
import Icon from "
|
|
10
|
-
import {
|
|
11
|
-
import { colorFor, initialOf } from "@rightkit/mail-ui/lib/mail";
|
|
9
|
+
import { Icon } from "../icons.js";
|
|
10
|
+
import { colorFor, initialOf } from "../lib/avatar.js";
|
|
12
11
|
import { buildVcf, parseVcf, parseContactsCsv } from "../lib/vcard.js";
|
|
13
|
-
import { useAppStore } from "@rightkit/mail-ui/store/index";
|
|
14
|
-
import { showDialog } from "@rightkit/mail-ui/hooks/dialog";
|
|
15
|
-
import { searchRecipients, listAllContacts, createContact, updateContact, deleteContact, saveTextDownload, } from "@rightkit/mail-ui/lib/ipc";
|
|
16
12
|
import { EMPTY_EDITOR } from "./ContactsView.types.js";
|
|
13
|
+
import { HostAccountSwitcher, useWorkspaceClient, useWorkspaceHost, useWorkspaceState } from "../host.js";
|
|
17
14
|
function ContactsViewInner() {
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
15
|
+
const { searchRecipients, listAllContacts, createContact, updateContact, deleteContact, saveTextDownload } = useWorkspaceClient();
|
|
16
|
+
const showDialog = useWorkspaceHost().dialog;
|
|
17
|
+
const openComposeTo = useWorkspaceHost().composeTo;
|
|
18
|
+
const activeAccount = useWorkspaceState((s) => s.activeAccount);
|
|
19
|
+
const setErr = useWorkspaceHost().reportError;
|
|
20
|
+
const showUndo = useWorkspaceHost().notify;
|
|
21
|
+
const contactsQuery = useWorkspaceState((s) => s.contactsQuery);
|
|
22
|
+
const setContactsQuery = useWorkspaceState((s) => s.setContactsQuery);
|
|
23
|
+
const contactsBusy = useWorkspaceState((s) => s.contactsBusy);
|
|
24
|
+
const setContactsBusy = useWorkspaceState((s) => s.setContactsBusy);
|
|
25
|
+
const contactDupes = useWorkspaceState((s) => s.contactDupes);
|
|
26
|
+
const setContactDupes = useWorkspaceState((s) => s.setContactDupes);
|
|
29
27
|
const [contacts, setContacts] = useState([]);
|
|
30
28
|
const [dirResults, setDirResults] = useState([]);
|
|
31
29
|
const [editor, setEditor] = useState(null);
|
|
@@ -75,8 +73,7 @@ function ContactsViewInner() {
|
|
|
75
73
|
return dirResults.filter((r) => r.email && !known.has(r.email.toLowerCase()));
|
|
76
74
|
}, [dirResults, contacts, query]);
|
|
77
75
|
function emailContact(addr) {
|
|
78
|
-
|
|
79
|
-
setTimeout(() => openComposeTo(addr, "", ""), 0);
|
|
76
|
+
openComposeTo(addr, "", "");
|
|
80
77
|
}
|
|
81
78
|
function startCreate() {
|
|
82
79
|
setEditor(EMPTY_EDITOR);
|
|
@@ -105,7 +102,7 @@ function ContactsViewInner() {
|
|
|
105
102
|
? updateContact(activeAccount, editor.resource_name, editor.etag, name, emails, phones)
|
|
106
103
|
: createContact(activeAccount, name, emails, phones);
|
|
107
104
|
op.then(() => {
|
|
108
|
-
showUndo(editor.resource_name ? "Contact saved" : "Contact created"
|
|
105
|
+
showUndo(editor.resource_name ? "Contact saved" : "Contact created");
|
|
109
106
|
setEditor(null);
|
|
110
107
|
loadAll();
|
|
111
108
|
})
|
|
@@ -120,7 +117,7 @@ function ContactsViewInner() {
|
|
|
120
117
|
setContactsBusy(true);
|
|
121
118
|
deleteContact(activeAccount, c.resource_name)
|
|
122
119
|
.then(() => {
|
|
123
|
-
showUndo("Contact deleted"
|
|
120
|
+
showUndo("Contact deleted");
|
|
124
121
|
setContacts((prev) => prev.filter((x) => x.resource_name !== c.resource_name));
|
|
125
122
|
})
|
|
126
123
|
.catch((e) => setErr(String(e)))
|
|
@@ -132,7 +129,7 @@ function ContactsViewInner() {
|
|
|
132
129
|
const vcf = buildVcf(contacts.map((c) => ({ name: c.display_name, emails: c.emails, phones: c.phones, org: c.org })));
|
|
133
130
|
// Saved natively to Downloads (exclusive atomic publish), like every other download.
|
|
134
131
|
saveTextDownload("contacts.vcf", vcf)
|
|
135
|
-
.then(() => showUndo(`Exported ${contacts.length} contacts
|
|
132
|
+
.then(() => showUndo(`Exported ${contacts.length} contacts`))
|
|
136
133
|
.catch((e) => setErr(String(e)));
|
|
137
134
|
}
|
|
138
135
|
function importContactsFile(file) {
|
|
@@ -162,7 +159,7 @@ function ContactsViewInner() {
|
|
|
162
159
|
break;
|
|
163
160
|
}
|
|
164
161
|
}
|
|
165
|
-
showUndo(`Imported ${ok} of ${parsed.length} contacts
|
|
162
|
+
showUndo(`Imported ${ok} of ${parsed.length} contacts`);
|
|
166
163
|
loadAll();
|
|
167
164
|
})
|
|
168
165
|
.catch((e) => setErr(String(e)))
|
|
@@ -196,7 +193,7 @@ function ContactsViewInner() {
|
|
|
196
193
|
}
|
|
197
194
|
setContactDupes(groups);
|
|
198
195
|
if (groups.length === 0)
|
|
199
|
-
showUndo("No duplicate contacts found"
|
|
196
|
+
showUndo("No duplicate contacts found");
|
|
200
197
|
}
|
|
201
198
|
async function mergeDupeGroup(group) {
|
|
202
199
|
if (!activeAccount || contactsBusy)
|
|
@@ -212,7 +209,7 @@ function ContactsViewInner() {
|
|
|
212
209
|
.then(async () => {
|
|
213
210
|
for (const d of group.dups)
|
|
214
211
|
await deleteContact(activeAccount, d.resource_name);
|
|
215
|
-
showUndo("Merged duplicates"
|
|
212
|
+
showUndo("Merged duplicates");
|
|
216
213
|
setContactDupes((prev) => (prev ? prev.filter((g) => g !== group) : prev));
|
|
217
214
|
loadAll();
|
|
218
215
|
})
|
|
@@ -220,7 +217,7 @@ function ContactsViewInner() {
|
|
|
220
217
|
.finally(() => setContactsBusy(false));
|
|
221
218
|
}
|
|
222
219
|
const setEd = (patch) => setEditor((e) => (e ? { ...e, ...patch } : e));
|
|
223
|
-
return (_jsxs("div", { className: "contactsview", children: [_jsxs("div", { className: "contacts-bar", children: [_jsx("h2", { className: "workspace-title", children: "Contacts" }), _jsx("span", { className: "contacts-count", children: contacts.length }), _jsx("div", { className: "drive-bar-spacer" }), _jsx(Input, { className: "workspace-search", placeholder: "Search contacts & directory\u2026", value: contactsQuery, onChange: (e) => onSearch(e.target.value) }), _jsx(Button, { variant: "ghost", size: "sm", disabled: contactsBusy, onClick: exportContacts, children: "Export" }), _jsx(Button, { variant: "ghost", size: "sm", disabled: contactsBusy, onClick: () => fileRef.current?.click(), children: "Import" }), _jsx(Button, { variant: "ghost", size: "sm", disabled: contactsBusy, onClick: findContactDupes, children: "Find duplicates" }), _jsxs(Button, { variant: "primary", size: "sm", onClick: startCreate, children: [_jsx(Icon, { name: "person-add", size: 15 }), " Create"] }), _jsx(
|
|
220
|
+
return (_jsxs("div", { className: "contactsview", children: [_jsxs("div", { className: "contacts-bar", children: [_jsx("h2", { className: "workspace-title", children: "Contacts" }), _jsx("span", { className: "contacts-count", children: contacts.length }), _jsx("div", { className: "drive-bar-spacer" }), _jsx(Input, { className: "workspace-search", placeholder: "Search contacts & directory\u2026", value: contactsQuery, onChange: (e) => onSearch(e.target.value) }), _jsx(Button, { variant: "ghost", size: "sm", disabled: contactsBusy, onClick: exportContacts, children: "Export" }), _jsx(Button, { variant: "ghost", size: "sm", disabled: contactsBusy, onClick: () => fileRef.current?.click(), children: "Import" }), _jsx(Button, { variant: "ghost", size: "sm", disabled: contactsBusy, onClick: findContactDupes, children: "Find duplicates" }), _jsxs(Button, { variant: "primary", size: "sm", onClick: startCreate, children: [_jsx(Icon, { name: "person-add", size: 15 }), " Create"] }), _jsx(HostAccountSwitcher, {}), _jsx(Input, { ref: fileRef, type: "file", accept: ".vcf,.csv", style: { display: "none" }, onChange: (e) => {
|
|
224
221
|
const f = e.target.files?.[0];
|
|
225
222
|
if (f)
|
|
226
223
|
importContactsFile(f);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { useDrive } from "
|
|
2
|
-
import {
|
|
1
|
+
import { useDrive } from "../hooks/useDrive.js";
|
|
2
|
+
import type { DriveFile } from "../client.js";
|
|
3
3
|
type DriveFileMenuProps = {
|
|
4
4
|
f: DriveFile;
|
|
5
5
|
anchor: Element;
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { useMemo } from "react";
|
|
3
3
|
import { ContextMenu } from "@rightkit/react-shell/react";
|
|
4
|
-
import {
|
|
5
|
-
import { useAppStore } from "@rightkit/mail-ui/store/index";
|
|
4
|
+
import { useWorkspaceClient, useWorkspaceHost } from "../host.js";
|
|
6
5
|
/** The per-file dropdown (preview/open/download/attach/rename/move/share/trash). */
|
|
7
6
|
export function DriveFileMenu({ f, anchor, onClose, onPreview, drive, onMove, onShare }) {
|
|
8
|
-
const
|
|
7
|
+
const { openExternal } = useWorkspaceClient();
|
|
8
|
+
const setErr = useWorkspaceHost().reportError;
|
|
9
9
|
const items = useMemo(() => {
|
|
10
10
|
const list = [];
|
|
11
11
|
if (!f.is_folder)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { useDrive } from "
|
|
2
|
-
import {
|
|
1
|
+
import { useDrive } from "../hooks/useDrive.js";
|
|
2
|
+
import type { DriveFile } from "../client.js";
|
|
3
3
|
type DrivePreviewModalProps = {
|
|
4
4
|
drivePreview: DriveFile;
|
|
5
5
|
setDrivePreview: (f: DriveFile | null) => void;
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Button, Dialog } from "@rightkit/react-shell/react";
|
|
3
|
-
import Icon from "
|
|
4
|
-
import { openExternal } from "@rightkit/mail-ui/lib/ipc";
|
|
3
|
+
import { Icon } from "../icons.js";
|
|
5
4
|
import { formatBytes } from "@rightkit/settings-ui/core";
|
|
6
|
-
import { useAppStore } from "@rightkit/mail-ui/store/index";
|
|
7
5
|
import { fileGlyph } from "./driveFileGlyph.js";
|
|
6
|
+
import { useWorkspaceClient, useWorkspaceHost } from "../host.js";
|
|
8
7
|
export function DrivePreviewModal({ drivePreview, setDrivePreview, drive }) {
|
|
9
|
-
const
|
|
8
|
+
const { openExternal } = useWorkspaceClient();
|
|
9
|
+
const setErr = useWorkspaceHost().reportError;
|
|
10
10
|
return (_jsxs(Dialog, { title: drivePreview.name, width: 520, onClose: () => setDrivePreview(null), children: [drivePreview.thumbnail_link ? (_jsx("img", { className: "dp-thumb", src: drivePreview.thumbnail_link, alt: "", onError: (e) => { e.currentTarget.style.display = "none"; } })) : (_jsxs("div", { className: "dp-noimg", children: [_jsx(Icon, { name: fileGlyph(drivePreview).icon, size: 48 }), _jsx("span", { className: "hint", children: "No preview available" })] })), _jsxs("div", { className: "dp-meta hint", children: [drivePreview.mime_type, drivePreview.size ? ` · ${formatBytes(Number(drivePreview.size))}` : "", drivePreview.modified_time ? ` · ${new Date(drivePreview.modified_time).toLocaleString()}` : ""] }), _jsxs("div", { className: "dp-actions", children: [drivePreview.web_view_link && (_jsx(Button, { variant: "primary", onClick: () => openExternal(drivePreview.web_view_link).catch((e) => setErr(String(e))), children: "Open in Drive" })), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => drive.download(drivePreview), children: "Download" }), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => { drive.insertDriveLink(drivePreview); setDrivePreview(null); }, children: "Insert link in email" })] })] }));
|
|
11
11
|
}
|
package/dist/views/DriveView.js
CHANGED
|
@@ -4,16 +4,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
4
4
|
// cards with thumbnails or colored type tiles, each with a hover ⋮ menu.
|
|
5
5
|
// Shelf nav + storage live in DriveNav; shared Drive logic in useDrive.
|
|
6
6
|
import { memo, useEffect, useState } from "react";
|
|
7
|
-
import Icon from "
|
|
8
|
-
import { AccountSwitcher } from "@rightkit/mail-ui/components/AccountSwitcher";
|
|
7
|
+
import { Icon } from "../icons.js";
|
|
9
8
|
import { Button, Dialog, IconButton, Skeleton, SegmentedControl, Input, Select } from "@rightkit/react-shell/react";
|
|
10
|
-
import {
|
|
11
|
-
import { useAppStore } from "@rightkit/mail-ui/store/index";
|
|
12
|
-
import { useDrive, FOLDER_MIME } from "@rightkit/mail-ui/hooks/useDrive";
|
|
9
|
+
import { useDrive, FOLDER_MIME } from "../hooks/useDrive.js";
|
|
13
10
|
import { DriveFileMenu } from "./DriveFileMenu.js";
|
|
14
11
|
import { DrivePreviewModal } from "./DrivePreviewModal.js";
|
|
15
12
|
import { fileGlyph } from "./driveFileGlyph.js";
|
|
16
|
-
import {
|
|
13
|
+
import { HostAccountSwitcher, useWorkspaceClient, useWorkspaceHost, useWorkspaceState } from "../host.js";
|
|
17
14
|
/** Native button that carries the keyboard/AT semantics of a file card or row;
|
|
18
15
|
* the mouse click is handled by the card itself. */
|
|
19
16
|
const FILE_OPEN_STYLE = {
|
|
@@ -38,20 +35,22 @@ const SHELF_LABEL = {
|
|
|
38
35
|
};
|
|
39
36
|
function DriveViewInner() {
|
|
40
37
|
const drive = useDrive();
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
const
|
|
38
|
+
const { listDriveFiles, driveStorageQuota, driveMove, driveShare, driveListPermissions, driveRevokePermission } = useWorkspaceClient();
|
|
39
|
+
const setPref = useWorkspaceHost().savePreference;
|
|
40
|
+
const setErr = useWorkspaceHost().reportError;
|
|
41
|
+
const showUndo = useWorkspaceHost().notify;
|
|
42
|
+
const setDrivePath = useWorkspaceState((s) => s.setDrivePath);
|
|
43
|
+
const setDriveQuery = useWorkspaceState((s) => s.setDriveQuery);
|
|
44
|
+
const driveFiles = useWorkspaceState((s) => s.driveFiles);
|
|
45
|
+
const setDriveFiles = useWorkspaceState((s) => s.setDriveFiles);
|
|
46
|
+
const driveToken = useWorkspaceState((s) => s.driveToken);
|
|
47
|
+
const setDriveToken = useWorkspaceState((s) => s.setDriveToken);
|
|
48
|
+
const drivePreview = useWorkspaceState((s) => s.drivePreview);
|
|
49
|
+
const setDrivePreview = useWorkspaceState((s) => s.setDrivePreview);
|
|
50
|
+
const driveViewMode = useWorkspaceState((s) => s.driveViewMode);
|
|
51
|
+
const setDriveViewMode = useWorkspaceState((s) => s.setDriveViewMode);
|
|
52
|
+
const setDriveQuota = useWorkspaceState((s) => s.setDriveQuota);
|
|
53
|
+
const driveBusy = useWorkspaceState((s) => s.driveBusy);
|
|
55
54
|
const { activeAccount, driveShelf, drivePath, driveQuery, driveType } = drive;
|
|
56
55
|
// Transient UI state: the open ⋮ menu, the move/share modals, drag-over state.
|
|
57
56
|
const [menuFor, setMenuFor] = useState(null);
|
|
@@ -89,7 +88,7 @@ function DriveViewInner() {
|
|
|
89
88
|
const oldParent = driveShelf === "my" ? drive.currentFolderId() ?? "root" : undefined;
|
|
90
89
|
driveMove(activeAccount, moveFile.id, targetId, oldParent)
|
|
91
90
|
.then(() => {
|
|
92
|
-
showUndo(`Moved to ${targetName}
|
|
91
|
+
showUndo(`Moved to ${targetName}`);
|
|
93
92
|
setMoveFile(null);
|
|
94
93
|
drive.refresh();
|
|
95
94
|
})
|
|
@@ -110,7 +109,7 @@ function DriveViewInner() {
|
|
|
110
109
|
return;
|
|
111
110
|
driveShare(activeAccount, shareFile.id, shareEmail.trim(), shareRole)
|
|
112
111
|
.then(() => {
|
|
113
|
-
showUndo(`Shared with ${shareEmail.trim()} (${shareRole})
|
|
112
|
+
showUndo(`Shared with ${shareEmail.trim()} (${shareRole})`);
|
|
114
113
|
setShareEmail("");
|
|
115
114
|
driveListPermissions(activeAccount, shareFile.id).then(setPerms).catch(() => { });
|
|
116
115
|
})
|
|
@@ -122,7 +121,7 @@ function DriveViewInner() {
|
|
|
122
121
|
driveRevokePermission(activeAccount, shareFile.id, permId)
|
|
123
122
|
.then(() => {
|
|
124
123
|
setPerms((prev) => prev.filter((p) => p.id !== permId));
|
|
125
|
-
showUndo("Access removed"
|
|
124
|
+
showUndo("Access removed");
|
|
126
125
|
})
|
|
127
126
|
.catch((e) => setErr(String(e)));
|
|
128
127
|
}
|
|
@@ -148,7 +147,7 @@ function DriveViewInner() {
|
|
|
148
147
|
], onChange: drive.setTypeFilter }), _jsx("div", { className: "drive-toggle", children: _jsx(SegmentedControl, { label: "Drive view", value: driveViewMode, options: [
|
|
149
148
|
{ value: "list", label: _jsx(Icon, { name: "list", size: 18 }), title: "List view" },
|
|
150
149
|
{ value: "grid", label: _jsx(Icon, { name: "grid", size: 18 }), title: "Grid view" },
|
|
151
|
-
], onChange: (m) => { setDriveViewMode(m); setPref("driveViewMode", m); } }) }), _jsx(
|
|
150
|
+
], onChange: (m) => { setDriveViewMode(m); setPref("driveViewMode", m); } }) }), _jsx(HostAccountSwitcher, {})] }), _jsxs("div", { className: `drive-scroll ${dragOver ? "drag-over" : ""}`, onDragOver: (e) => { e.preventDefault(); if (!dragOver)
|
|
152
151
|
setDragOver(true); }, onDragLeave: (e) => { if (e.currentTarget === e.target)
|
|
153
152
|
setDragOver(false); }, onDrop: (e) => {
|
|
154
153
|
e.preventDefault();
|