@novacraft-engineering/mailbox 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/.env.example +110 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/app/api/mail/accessors/route.ts +197 -0
- package/app/api/mail/attachments/route.ts +54 -0
- package/app/api/mail/company-signature/route.ts +36 -0
- package/app/api/mail/contacts/route.ts +13 -0
- package/app/api/mail/emails/[id]/attachments/download/route.ts +69 -0
- package/app/api/mail/emails/[id]/attachments/forward/route.ts +65 -0
- package/app/api/mail/emails/[id]/route.ts +127 -0
- package/app/api/mail/emails/route.ts +116 -0
- package/app/api/mail/events/route.ts +12 -0
- package/app/api/mail/fonts/route.ts +44 -0
- package/app/api/mail/inbox/attachments/download/route.ts +50 -0
- package/app/api/mail/inbox/attachments/forward/route.ts +54 -0
- package/app/api/mail/inbox/attachments/route.ts +99 -0
- package/app/api/mail/inbox/body/route.ts +14 -0
- package/app/api/mail/inbox/counts/route.ts +25 -0
- package/app/api/mail/inbox/route.ts +506 -0
- package/app/api/mail/link-check/route.ts +95 -0
- package/app/api/mail/login/route.ts +38 -0
- package/app/api/mail/logout/route.ts +9 -0
- package/app/api/mail/maintenance/attachments/route.ts +26 -0
- package/app/api/mail/maintenance/bodies/route.ts +30 -0
- package/app/api/mail/maintenance/list-columns/route.ts +25 -0
- package/app/api/mail/maintenance/threads/route.ts +32 -0
- package/app/api/mail/me/route.ts +13 -0
- package/app/api/mail/outgoing-upload/route.ts +37 -0
- package/app/api/mail/password/route.ts +64 -0
- package/app/api/mail/pixel/[id]/route.ts +20 -0
- package/app/api/mail/push/route.ts +42 -0
- package/app/api/mail/render-template/route.ts +58 -0
- package/app/api/mail/request-reset/route.ts +65 -0
- package/app/api/mail/reset/route.ts +46 -0
- package/app/api/mail/send/route.ts +205 -0
- package/app/api/mail/settings/route.ts +35 -0
- package/app/api/mail/share/attachment/route.ts +91 -0
- package/app/api/mail/share/route.ts +130 -0
- package/app/api/mail/signature-logo/route.ts +67 -0
- package/app/api/mail/stash/route.ts +55 -0
- package/app/api/mail/threads/route.ts +18 -0
- package/app/api/mail/upload/route.ts +43 -0
- package/app/api/share/[id]/route.ts +85 -0
- package/app/apple-icon.png +0 -0
- package/app/brand/[file]/route.ts +37 -0
- package/app/globals.css +14 -0
- package/app/icon.png +0 -0
- package/app/layout.tsx +30 -0
- package/app/mail/AccessCheck.tsx +45 -0
- package/app/mail/AttachmentLightbox.tsx +241 -0
- package/app/mail/ConfirmDialog.tsx +88 -0
- package/app/mail/MailSelect.tsx +138 -0
- package/app/mail/RichEditor.tsx +859 -0
- package/app/mail/layout.tsx +20 -0
- package/app/mail/page.module.css +6320 -0
- package/app/mail/page.tsx +7971 -0
- package/app/mail/pwa.ts +191 -0
- package/app/mail/reset/page.tsx +132 -0
- package/app/mail/search.ts +172 -0
- package/app/manifest.ts +21 -0
- package/app/page.tsx +5 -0
- package/app/robots.ts +22 -0
- package/app/share/[id]/page.tsx +166 -0
- package/app/share/[id]/share.module.css +148 -0
- package/eslint.config.mjs +9 -0
- package/lib/accent-ramp.ts +54 -0
- package/lib/attachments.ts +52 -0
- package/lib/brand.client.ts +52 -0
- package/lib/brand.ts +87 -0
- package/lib/d1.ts +64 -0
- package/lib/default-signature.ts +68 -0
- package/lib/dev-auth.ts +222 -0
- package/lib/email-html.test.ts +74 -0
- package/lib/email-html.ts +169 -0
- package/lib/emails/index.ts +281 -0
- package/lib/emails/templates/academy-followup.html +68 -0
- package/lib/emails/templates/auto-reply.html +33 -0
- package/lib/emails/templates/contact-followup.html +58 -0
- package/lib/emails/templates/field-row.html +4 -0
- package/lib/emails/templates/notification.html +23 -0
- package/lib/fonts.ts +23 -0
- package/lib/link-safety.ts +81 -0
- package/lib/mail-provider.ts +183 -0
- package/lib/mailbox.ts +1888 -0
- package/lib/password.ts +71 -0
- package/lib/public-url.ts +14 -0
- package/lib/push.ts +37 -0
- package/lib/r2.ts +157 -0
- package/lib/rate-limit.ts +43 -0
- package/lib/session.test.ts +43 -0
- package/lib/session.ts +80 -0
- package/lib/signature.ts +28 -0
- package/lib/threads.test.ts +96 -0
- package/lib/threads.ts +72 -0
- package/lib/turso.ts +53 -0
- package/next.config.ts +51 -0
- package/package.json +69 -0
- package/public/brand/README.md +35 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon-maskable-512.png +0 -0
- package/public/sw.js +41 -0
- package/scripts/backfill-attachments.mjs +96 -0
- package/scripts/backfill-content-ids.mjs +101 -0
- package/scripts/backfill-list-columns.mjs +80 -0
- package/scripts/offload-bodies.mjs +152 -0
- package/scripts/sample-files.mjs +119 -0
- package/scripts/seed-dev.mjs +240 -0
- package/tools/README.md +18 -0
- package/tools/db-backup.py +158 -0
- package/tools/doh.py +46 -0
- package/tools/import-mbox.py +703 -0
- package/tools/import-takeout.sh +49 -0
- package/tsconfig.json +35 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
4
|
+
import styles from './page.module.css'
|
|
5
|
+
|
|
6
|
+
export type PreviewItem = { filename: string; url: string; size?: number; contentType?: string }
|
|
7
|
+
|
|
8
|
+
export type AttachmentKind = 'image' | 'pdf' | 'video' | 'audio' | 'text' | 'other'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The extension decides, and the declared type answers when it cannot: a file saved as
|
|
12
|
+
* `scan` or `Document (1)` still previews if the sender said what it was.
|
|
13
|
+
*/
|
|
14
|
+
export function attachmentKind(filename: string, contentType?: string): AttachmentKind {
|
|
15
|
+
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
|
|
16
|
+
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'svg', 'ico', 'tif', 'tiff'].includes(ext)) return 'image'
|
|
17
|
+
if (ext === 'pdf') return 'pdf'
|
|
18
|
+
if (['mp4', 'webm', 'mov', 'm4v', 'ogv', 'mkv', '3gp'].includes(ext)) return 'video'
|
|
19
|
+
if (['mp3', 'wav', 'ogg', 'm4a', 'aac', 'flac', 'opus', 'oga'].includes(ext)) return 'audio'
|
|
20
|
+
if (['txt', 'csv', 'tsv', 'json', 'xml', 'md', 'log', 'yml', 'yaml', 'ics', 'ini', 'conf', 'sql', 'srt', 'vtt'].includes(ext)) {
|
|
21
|
+
return 'text'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const type = (contentType ?? '').toLowerCase()
|
|
25
|
+
if (type.startsWith('image/')) return 'image'
|
|
26
|
+
if (type === 'application/pdf') return 'pdf'
|
|
27
|
+
if (type.startsWith('video/')) return 'video'
|
|
28
|
+
if (type.startsWith('audio/')) return 'audio'
|
|
29
|
+
if (type.startsWith('text/') || type === 'application/json' || type === 'application/xml') return 'text'
|
|
30
|
+
return 'other'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The same URL serves the preview and the download button, and it defaults to telling the
|
|
35
|
+
* browser to save. Anything rendered here asks for the inline form instead, or a PDF opens
|
|
36
|
+
* the save dialog the moment the viewer appears.
|
|
37
|
+
*/
|
|
38
|
+
function inlineUrl(url: string): string {
|
|
39
|
+
if (!url.startsWith('/api/mail/')) return url
|
|
40
|
+
return `${url}${url.includes('?') ? '&' : '?'}inline=1`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function formatSize(bytes?: number): string {
|
|
44
|
+
if (!bytes) return ''
|
|
45
|
+
if (bytes < 1024) return `${bytes} B`
|
|
46
|
+
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
|
|
47
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ZOOM_STEP = 0.25
|
|
51
|
+
const ZOOM_MIN = 0.25
|
|
52
|
+
const ZOOM_MAX = 6
|
|
53
|
+
|
|
54
|
+
export default function AttachmentLightbox({
|
|
55
|
+
items,
|
|
56
|
+
index,
|
|
57
|
+
onIndex,
|
|
58
|
+
onClose,
|
|
59
|
+
}: {
|
|
60
|
+
items: PreviewItem[]
|
|
61
|
+
index: number
|
|
62
|
+
onIndex: (next: number) => void
|
|
63
|
+
onClose: () => void
|
|
64
|
+
}) {
|
|
65
|
+
const item = items[index]
|
|
66
|
+
const kind = item ? attachmentKind(item.filename, item.contentType) : 'other'
|
|
67
|
+
const [zoom, setZoom] = useState(1)
|
|
68
|
+
const [rotation, setRotation] = useState(0)
|
|
69
|
+
const [offset, setOffset] = useState({ x: 0, y: 0 })
|
|
70
|
+
const [textBody, setTextBody] = useState<string | null>(null)
|
|
71
|
+
const shellRef = useRef<HTMLDivElement>(null)
|
|
72
|
+
const dragRef = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null)
|
|
73
|
+
|
|
74
|
+
const reset = useCallback(() => {
|
|
75
|
+
setZoom(1)
|
|
76
|
+
setRotation(0)
|
|
77
|
+
setOffset({ x: 0, y: 0 })
|
|
78
|
+
}, [])
|
|
79
|
+
|
|
80
|
+
// Adjusting state during render when the prop changes, rather than in an effect,
|
|
81
|
+
// so the old zoom and text never paint against the new item.
|
|
82
|
+
const [seenItem, setSeenItem] = useState(item)
|
|
83
|
+
if (item !== seenItem) {
|
|
84
|
+
setSeenItem(item)
|
|
85
|
+
reset()
|
|
86
|
+
setTextBody(null)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!item || attachmentKind(item.filename) !== 'text') return
|
|
91
|
+
let stale = false
|
|
92
|
+
fetch(item.url)
|
|
93
|
+
.then(response => response.text())
|
|
94
|
+
.then(body => {
|
|
95
|
+
if (!stale) setTextBody(body.slice(0, 200_000))
|
|
96
|
+
})
|
|
97
|
+
.catch(() => {
|
|
98
|
+
if (!stale) setTextBody('(could not load preview)')
|
|
99
|
+
})
|
|
100
|
+
return () => {
|
|
101
|
+
stale = true
|
|
102
|
+
}
|
|
103
|
+
}, [item])
|
|
104
|
+
|
|
105
|
+
const step = useCallback((delta: number) => setZoom(current => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, current + delta))), [])
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const onKey = (event: KeyboardEvent) => {
|
|
109
|
+
if (event.key === 'Escape') onClose()
|
|
110
|
+
else if (event.key === 'ArrowRight' && items.length > 1) onIndex((index + 1) % items.length)
|
|
111
|
+
else if (event.key === 'ArrowLeft' && items.length > 1) onIndex((index - 1 + items.length) % items.length)
|
|
112
|
+
else if (event.key === '+' || event.key === '=') step(ZOOM_STEP)
|
|
113
|
+
else if (event.key === '-') step(-ZOOM_STEP)
|
|
114
|
+
else if (event.key === '0') reset()
|
|
115
|
+
else if (event.key.toLowerCase() === 'r') setRotation(current => (current + 90) % 360)
|
|
116
|
+
else if (event.key.toLowerCase() === 'f') {
|
|
117
|
+
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
|
118
|
+
else shellRef.current?.requestFullscreen?.().catch(() => {})
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
window.addEventListener('keydown', onKey)
|
|
122
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
123
|
+
}, [index, items.length, onClose, onIndex, step, reset])
|
|
124
|
+
|
|
125
|
+
if (!item) return null
|
|
126
|
+
|
|
127
|
+
const onPointerDown = (event: React.PointerEvent) => {
|
|
128
|
+
if (kind !== 'image' || zoom <= 1) return
|
|
129
|
+
dragRef.current = { x: event.clientX, y: event.clientY, ox: offset.x, oy: offset.y }
|
|
130
|
+
;(event.target as HTMLElement).setPointerCapture?.(event.pointerId)
|
|
131
|
+
}
|
|
132
|
+
const onPointerMove = (event: React.PointerEvent) => {
|
|
133
|
+
const drag = dragRef.current
|
|
134
|
+
if (!drag) return
|
|
135
|
+
setOffset({ x: drag.ox + (event.clientX - drag.x), y: drag.oy + (event.clientY - drag.y) })
|
|
136
|
+
}
|
|
137
|
+
const onPointerUp = () => {
|
|
138
|
+
dragRef.current = null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const body = (() => {
|
|
142
|
+
if (kind === 'image') {
|
|
143
|
+
return (
|
|
144
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
145
|
+
<img
|
|
146
|
+
className={styles.lbImage}
|
|
147
|
+
src={item.url}
|
|
148
|
+
alt={item.filename}
|
|
149
|
+
draggable={false}
|
|
150
|
+
style={{
|
|
151
|
+
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom}) rotate(${rotation}deg)`,
|
|
152
|
+
cursor: zoom > 1 ? 'grab' : 'default',
|
|
153
|
+
}}
|
|
154
|
+
/>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
if (kind === 'pdf') return <iframe className={styles.lbFrame} src={inlineUrl(item.url)} title={item.filename} />
|
|
158
|
+
if (kind === 'video') return <video className={styles.lbMedia} src={inlineUrl(item.url)} controls autoPlay={false} />
|
|
159
|
+
if (kind === 'audio') return <audio className={styles.lbAudio} src={inlineUrl(item.url)} controls />
|
|
160
|
+
if (kind === 'text') return <pre className={styles.lbText}>{textBody ?? 'Loading…'}</pre>
|
|
161
|
+
// Nothing here can render a spreadsheet or a Word file, but the browser may be able
|
|
162
|
+
// to, and opening it is a different action from saving it.
|
|
163
|
+
return (
|
|
164
|
+
<div className={styles.lbFallback}>
|
|
165
|
+
<p>This file can't be shown here.</p>
|
|
166
|
+
<a className={styles.lbDownload} href={inlineUrl(item.url)} target="_blank" rel="noopener noreferrer">
|
|
167
|
+
Open in a new tab
|
|
168
|
+
</a>
|
|
169
|
+
<a className={styles.lbDownloadAlt} href={item.url} target="_blank" rel="noopener noreferrer">
|
|
170
|
+
Download {item.filename}
|
|
171
|
+
</a>
|
|
172
|
+
</div>
|
|
173
|
+
)
|
|
174
|
+
})()
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<div className={styles.lbScrim} onClick={onClose} role="dialog" aria-modal="true" aria-label={item.filename}>
|
|
178
|
+
<div className={styles.lbShell} ref={shellRef} onClick={event => event.stopPropagation()}>
|
|
179
|
+
<div className={styles.lbBar}>
|
|
180
|
+
<div className={styles.lbTitle}>
|
|
181
|
+
<span className={styles.lbName} title={item.filename}>{item.filename}</span>
|
|
182
|
+
{item.size ? <span className={styles.lbSize}>{formatSize(item.size)}</span> : null}
|
|
183
|
+
{items.length > 1 && <span className={styles.lbCount}>{index + 1} / {items.length}</span>}
|
|
184
|
+
</div>
|
|
185
|
+
<div className={styles.lbTools}>
|
|
186
|
+
{kind === 'image' && (
|
|
187
|
+
<>
|
|
188
|
+
<button onClick={() => step(-ZOOM_STEP)} title="Zoom out (−)" aria-label="Zoom out">−</button>
|
|
189
|
+
<button onClick={() => step(ZOOM_STEP)} title="Zoom in (+)" aria-label="Zoom in">+</button>
|
|
190
|
+
<button onClick={reset} title="Reset (0)" aria-label="Reset zoom">{Math.round(zoom * 100)}%</button>
|
|
191
|
+
<button onClick={() => setRotation(current => (current + 90) % 360)} title="Rotate (R)" aria-label="Rotate">⟳</button>
|
|
192
|
+
</>
|
|
193
|
+
)}
|
|
194
|
+
<button
|
|
195
|
+
onClick={() => {
|
|
196
|
+
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
|
197
|
+
else shellRef.current?.requestFullscreen?.().catch(() => {})
|
|
198
|
+
}}
|
|
199
|
+
title="Fullscreen (F)"
|
|
200
|
+
aria-label="Fullscreen"
|
|
201
|
+
>
|
|
202
|
+
⛶
|
|
203
|
+
</button>
|
|
204
|
+
<a href={item.url} target="_blank" rel="noopener noreferrer" title="Download" aria-label="Download">↓</a>
|
|
205
|
+
<button onClick={onClose} title="Close (Esc)" aria-label="Close">✕</button>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
<div
|
|
209
|
+
className={`${styles.lbStage} ${kind === 'image' ? styles.lbStagePan : ''}`}
|
|
210
|
+
onPointerDown={onPointerDown}
|
|
211
|
+
onPointerMove={onPointerMove}
|
|
212
|
+
onPointerUp={onPointerUp}
|
|
213
|
+
onPointerCancel={onPointerUp}
|
|
214
|
+
onWheel={event => {
|
|
215
|
+
if (kind === 'image' && (event.ctrlKey || event.metaKey)) step(event.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP)
|
|
216
|
+
}}
|
|
217
|
+
>
|
|
218
|
+
{items.length > 1 && (
|
|
219
|
+
<button
|
|
220
|
+
className={`${styles.lbNav} ${styles.lbPrev}`}
|
|
221
|
+
onClick={() => onIndex((index - 1 + items.length) % items.length)}
|
|
222
|
+
aria-label="Previous attachment"
|
|
223
|
+
>
|
|
224
|
+
‹
|
|
225
|
+
</button>
|
|
226
|
+
)}
|
|
227
|
+
{body}
|
|
228
|
+
{items.length > 1 && (
|
|
229
|
+
<button
|
|
230
|
+
className={`${styles.lbNav} ${styles.lbNext}`}
|
|
231
|
+
onClick={() => onIndex((index + 1) % items.length)}
|
|
232
|
+
aria-label="Next attachment"
|
|
233
|
+
>
|
|
234
|
+
›
|
|
235
|
+
</button>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
)
|
|
241
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
4
|
+
import styles from './page.module.css'
|
|
5
|
+
|
|
6
|
+
export type ConfirmRequest = {
|
|
7
|
+
title: string
|
|
8
|
+
body?: ReactNode
|
|
9
|
+
/** Defaults to "Continue". */
|
|
10
|
+
confirmLabel?: string
|
|
11
|
+
cancelLabel?: string
|
|
12
|
+
/** Paints the confirm button as destructive. */
|
|
13
|
+
danger?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type Pending = ConfirmRequest & { resolve: (value: boolean) => void }
|
|
17
|
+
|
|
18
|
+
const ConfirmContext = createContext<(request: ConfirmRequest) => Promise<boolean>>(async () => false)
|
|
19
|
+
|
|
20
|
+
/** Replaces window.confirm, which cannot be styled and looks like the browser, not the app. */
|
|
21
|
+
export function useConfirm() {
|
|
22
|
+
return useContext(ConfirmContext)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
|
26
|
+
const [pending, setPending] = useState<Pending | null>(null)
|
|
27
|
+
const confirmRef = useRef<HTMLButtonElement>(null)
|
|
28
|
+
|
|
29
|
+
const confirm = useCallback(
|
|
30
|
+
(request: ConfirmRequest) => new Promise<boolean>(resolve => setPending({ ...request, resolve })),
|
|
31
|
+
[],
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const settle = useCallback(
|
|
35
|
+
(value: boolean) => {
|
|
36
|
+
setPending(current => {
|
|
37
|
+
current?.resolve(value)
|
|
38
|
+
return null
|
|
39
|
+
})
|
|
40
|
+
},
|
|
41
|
+
[],
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
// Escape cancels and Enter confirms, matching what the native dialog did.
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!pending) return
|
|
47
|
+
confirmRef.current?.focus()
|
|
48
|
+
const onKey = (event: KeyboardEvent) => {
|
|
49
|
+
if (event.key === 'Escape') { event.preventDefault(); settle(false) }
|
|
50
|
+
if (event.key === 'Enter') { event.preventDefault(); settle(true) }
|
|
51
|
+
}
|
|
52
|
+
window.addEventListener('keydown', onKey)
|
|
53
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
54
|
+
}, [pending, settle])
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<ConfirmContext.Provider value={confirm}>
|
|
58
|
+
{children}
|
|
59
|
+
{pending && (
|
|
60
|
+
<div className={styles.confirmScrim} onClick={() => settle(false)}>
|
|
61
|
+
<div
|
|
62
|
+
className={styles.confirmCard}
|
|
63
|
+
role="alertdialog"
|
|
64
|
+
aria-modal="true"
|
|
65
|
+
aria-label={pending.title}
|
|
66
|
+
onClick={event => event.stopPropagation()}
|
|
67
|
+
>
|
|
68
|
+
<p className={styles.confirmTitle}>{pending.title}</p>
|
|
69
|
+
{pending.body && <div className={styles.confirmBody}>{pending.body}</div>}
|
|
70
|
+
<div className={styles.confirmActions}>
|
|
71
|
+
<button type="button" className={styles.confirmCancel} onClick={() => settle(false)}>
|
|
72
|
+
{pending.cancelLabel ?? 'Cancel'}
|
|
73
|
+
</button>
|
|
74
|
+
<button
|
|
75
|
+
ref={confirmRef}
|
|
76
|
+
type="button"
|
|
77
|
+
className={`${styles.confirmGo} ${pending.danger ? styles.confirmDanger : ''}`}
|
|
78
|
+
onClick={() => settle(true)}
|
|
79
|
+
>
|
|
80
|
+
{pending.confirmLabel ?? 'Continue'}
|
|
81
|
+
</button>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
)}
|
|
86
|
+
</ConfirmContext.Provider>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react'
|
|
4
|
+
import styles from './page.module.css'
|
|
5
|
+
|
|
6
|
+
export function anchoredMenuStyle(anchor: HTMLElement): React.CSSProperties {
|
|
7
|
+
const rect = anchor.getBoundingClientRect()
|
|
8
|
+
const base: React.CSSProperties = { position: 'fixed', left: rect.left, minWidth: rect.width }
|
|
9
|
+
const openUpward = rect.bottom > window.innerHeight - 260
|
|
10
|
+
return openUpward
|
|
11
|
+
? { ...base, bottom: window.innerHeight - rect.top + 6 }
|
|
12
|
+
: { ...base, top: rect.bottom + 6 }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const CHEVRON = (
|
|
16
|
+
<svg className={styles.selectChevron} viewBox="0 0 12 8" fill="none" aria-hidden>
|
|
17
|
+
<path d="M1 1l5 5 5-5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
|
18
|
+
</svg>
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
export default function MailSelect({
|
|
22
|
+
value,
|
|
23
|
+
options,
|
|
24
|
+
onChange,
|
|
25
|
+
ariaLabel,
|
|
26
|
+
buttonClassName,
|
|
27
|
+
placeholder = 'Select',
|
|
28
|
+
editable = false,
|
|
29
|
+
optionStyle,
|
|
30
|
+
}: {
|
|
31
|
+
value: string
|
|
32
|
+
options: Array<{ value: string; label: string }>
|
|
33
|
+
onChange: (value: string) => void
|
|
34
|
+
ariaLabel: string
|
|
35
|
+
buttonClassName?: string
|
|
36
|
+
placeholder?: string
|
|
37
|
+
/** Typed values are accepted too: committed on Enter or when the field loses focus. */
|
|
38
|
+
editable?: boolean
|
|
39
|
+
optionStyle?: (value: string) => React.CSSProperties
|
|
40
|
+
}) {
|
|
41
|
+
const [open, setOpen] = useState(false)
|
|
42
|
+
const [menuStyle, setMenuStyle] = useState<React.CSSProperties>({})
|
|
43
|
+
const [typed, setTyped] = useState<string | null>(null)
|
|
44
|
+
const wrapRef = useRef<HTMLSpanElement>(null)
|
|
45
|
+
const anchorRef = useRef<HTMLElement>(null)
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!open) return
|
|
49
|
+
const onDown = (event: MouseEvent) => {
|
|
50
|
+
if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) setOpen(false)
|
|
51
|
+
}
|
|
52
|
+
const onKey = (event: KeyboardEvent) => {
|
|
53
|
+
if (event.key === 'Escape') {
|
|
54
|
+
event.stopPropagation()
|
|
55
|
+
setOpen(false)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
document.addEventListener('mousedown', onDown)
|
|
59
|
+
document.addEventListener('keydown', onKey)
|
|
60
|
+
return () => {
|
|
61
|
+
document.removeEventListener('mousedown', onDown)
|
|
62
|
+
document.removeEventListener('keydown', onKey)
|
|
63
|
+
}
|
|
64
|
+
}, [open])
|
|
65
|
+
|
|
66
|
+
const current = options.find(option => option.value === value)
|
|
67
|
+
const toggle = () => {
|
|
68
|
+
if (!open && anchorRef.current) setMenuStyle(anchoredMenuStyle(anchorRef.current))
|
|
69
|
+
setOpen(prev => !prev)
|
|
70
|
+
}
|
|
71
|
+
const commitTyped = () => {
|
|
72
|
+
if (typed !== null && typed !== (current?.label ?? value)) onChange(typed.trim())
|
|
73
|
+
setTyped(null)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<span className={styles.select} ref={wrapRef}>
|
|
78
|
+
{editable ? (
|
|
79
|
+
<span ref={anchorRef as React.RefObject<HTMLSpanElement>} className={`${styles.selectBtn} ${styles.selectEditable} ${buttonClassName ?? ''}`}>
|
|
80
|
+
<input
|
|
81
|
+
className={styles.selectInput}
|
|
82
|
+
aria-label={ariaLabel}
|
|
83
|
+
value={typed ?? current?.label ?? value}
|
|
84
|
+
placeholder={placeholder}
|
|
85
|
+
onFocus={() => { if (!open) toggle() }}
|
|
86
|
+
onChange={event => setTyped(event.target.value)}
|
|
87
|
+
onBlur={commitTyped}
|
|
88
|
+
onKeyDown={event => {
|
|
89
|
+
if (event.key === 'Enter') {
|
|
90
|
+
event.preventDefault()
|
|
91
|
+
commitTyped()
|
|
92
|
+
setOpen(false)
|
|
93
|
+
}
|
|
94
|
+
}}
|
|
95
|
+
/>
|
|
96
|
+
<button type="button" className={styles.selectChevronBtn} aria-haspopup="listbox" aria-expanded={open} aria-label={`${ariaLabel} options`} onMouseDown={event => event.preventDefault()} onClick={toggle}>
|
|
97
|
+
{CHEVRON}
|
|
98
|
+
</button>
|
|
99
|
+
</span>
|
|
100
|
+
) : (
|
|
101
|
+
<button
|
|
102
|
+
ref={anchorRef as React.RefObject<HTMLButtonElement>}
|
|
103
|
+
type="button"
|
|
104
|
+
className={`${styles.selectBtn} ${buttonClassName ?? ''}`}
|
|
105
|
+
aria-haspopup="listbox"
|
|
106
|
+
aria-expanded={open}
|
|
107
|
+
aria-label={ariaLabel}
|
|
108
|
+
onClick={toggle}
|
|
109
|
+
>
|
|
110
|
+
<span style={optionStyle && current?.value ? optionStyle(current.value) : undefined}>{current?.label ?? placeholder}</span>
|
|
111
|
+
{CHEVRON}
|
|
112
|
+
</button>
|
|
113
|
+
)}
|
|
114
|
+
{open && (
|
|
115
|
+
<div className={styles.selectMenu} style={menuStyle} role="listbox">
|
|
116
|
+
{options.map(option => (
|
|
117
|
+
<button
|
|
118
|
+
key={option.value}
|
|
119
|
+
type="button"
|
|
120
|
+
role="option"
|
|
121
|
+
aria-selected={option.value === value}
|
|
122
|
+
className={`${styles.selectOption} ${option.value === value ? styles.selectOptionOn : ''}`}
|
|
123
|
+
style={optionStyle ? optionStyle(option.value) : undefined}
|
|
124
|
+
onMouseDown={event => event.preventDefault()}
|
|
125
|
+
onClick={() => {
|
|
126
|
+
setTyped(null)
|
|
127
|
+
onChange(option.value)
|
|
128
|
+
setOpen(false)
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
{option.label}
|
|
132
|
+
</button>
|
|
133
|
+
))}
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
</span>
|
|
137
|
+
)
|
|
138
|
+
}
|