@sentroy-co/client-sdk 2.0.0 → 2.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/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/react/MediaManager.d.ts +69 -0
- package/dist/react/MediaManager.d.ts.map +1 -0
- package/dist/react/MediaManager.js +216 -0
- package/dist/react/MediaManager.js.map +1 -0
- package/dist/react/index.d.ts +4 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +13 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/lib/Lightbox.d.ts +18 -0
- package/dist/react/lib/Lightbox.d.ts.map +1 -0
- package/dist/react/lib/Lightbox.js +40 -0
- package/dist/react/lib/Lightbox.js.map +1 -0
- package/dist/react/lib/use-media-list.d.ts +21 -0
- package/dist/react/lib/use-media-list.d.ts.map +1 -0
- package/dist/react/lib/use-media-list.js +50 -0
- package/dist/react/lib/use-media-list.js.map +1 -0
- package/dist/react/lib/utils.d.ts +17 -0
- package/dist/react/lib/utils.d.ts.map +1 -0
- package/dist/react/lib/utils.js +62 -0
- package/dist/react/lib/utils.js.map +1 -0
- package/dist/resources/storage.d.ts +23 -0
- package/dist/resources/storage.d.ts.map +1 -0
- package/dist/resources/storage.js +31 -0
- package/dist/resources/storage.js.map +1 -0
- package/dist/types.d.ts +40 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +40 -4
- package/src/http.ts +151 -0
- package/src/index.ts +106 -0
- package/src/react/MediaManager.tsx +628 -0
- package/src/react/index.ts +13 -0
- package/src/react/lib/Lightbox.tsx +162 -0
- package/src/react/lib/use-media-list.ts +54 -0
- package/src/react/lib/utils.ts +73 -0
- package/src/resources/buckets.ts +50 -0
- package/src/resources/domains.ts +16 -0
- package/src/resources/inbox.ts +99 -0
- package/src/resources/mailboxes.ts +11 -0
- package/src/resources/media.ts +115 -0
- package/src/resources/send.ts +11 -0
- package/src/resources/storage.ts +28 -0
- package/src/resources/templates.ts +16 -0
- package/src/types.ts +316 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { useEffect } from "react"
|
|
2
|
+
import type { Media } from "../../types"
|
|
3
|
+
import { detectKind, formatBytes } from "./utils"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sadeleştirilmiş lightbox — tek media item için fullscreen preview.
|
|
7
|
+
* Image/video native render, audio HTML5 player, diğerleri "download"
|
|
8
|
+
* fallback. ESC kapat, ←/→ next/prev (caller index callback'i sağlar).
|
|
9
|
+
*
|
|
10
|
+
* Headless yapı: tüm renderable HTML/inline class'lar default; consumer
|
|
11
|
+
* className override'ı ile değiştirebilir.
|
|
12
|
+
*/
|
|
13
|
+
export interface LightboxProps {
|
|
14
|
+
media: Media
|
|
15
|
+
onClose: () => void
|
|
16
|
+
onPrev?: () => void
|
|
17
|
+
onNext?: () => void
|
|
18
|
+
className?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Lightbox({
|
|
22
|
+
media,
|
|
23
|
+
onClose,
|
|
24
|
+
onPrev,
|
|
25
|
+
onNext,
|
|
26
|
+
className,
|
|
27
|
+
}: LightboxProps) {
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const onKey = (e: KeyboardEvent) => {
|
|
30
|
+
if (e.key === "Escape") onClose()
|
|
31
|
+
if (e.key === "ArrowLeft" && onPrev) onPrev()
|
|
32
|
+
if (e.key === "ArrowRight" && onNext) onNext()
|
|
33
|
+
}
|
|
34
|
+
document.addEventListener("keydown", onKey)
|
|
35
|
+
document.body.style.overflow = "hidden"
|
|
36
|
+
return () => {
|
|
37
|
+
document.removeEventListener("keydown", onKey)
|
|
38
|
+
document.body.style.overflow = ""
|
|
39
|
+
}
|
|
40
|
+
}, [onClose, onPrev, onNext])
|
|
41
|
+
|
|
42
|
+
const kind = detectKind(media)
|
|
43
|
+
const url = media.url || media.downloadUrl
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
role="dialog"
|
|
48
|
+
aria-modal="true"
|
|
49
|
+
onClick={(e) => {
|
|
50
|
+
if (e.target === e.currentTarget) onClose()
|
|
51
|
+
}}
|
|
52
|
+
className={
|
|
53
|
+
className ||
|
|
54
|
+
"fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4 backdrop-blur-sm"
|
|
55
|
+
}
|
|
56
|
+
>
|
|
57
|
+
{/* Close button */}
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
onClick={onClose}
|
|
61
|
+
aria-label="Close"
|
|
62
|
+
className="absolute right-4 top-4 rounded-full bg-white/10 p-2 text-white transition-colors hover:bg-white/20"
|
|
63
|
+
>
|
|
64
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
65
|
+
<path d="M18 6L6 18M6 6l12 12" />
|
|
66
|
+
</svg>
|
|
67
|
+
</button>
|
|
68
|
+
|
|
69
|
+
{/* Prev */}
|
|
70
|
+
{onPrev && (
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
onClick={(e) => {
|
|
74
|
+
e.stopPropagation()
|
|
75
|
+
onPrev()
|
|
76
|
+
}}
|
|
77
|
+
aria-label="Previous"
|
|
78
|
+
className="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20"
|
|
79
|
+
>
|
|
80
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
81
|
+
<path d="M15 18l-6-6 6-6" />
|
|
82
|
+
</svg>
|
|
83
|
+
</button>
|
|
84
|
+
)}
|
|
85
|
+
|
|
86
|
+
{/* Next */}
|
|
87
|
+
{onNext && (
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
onClick={(e) => {
|
|
91
|
+
e.stopPropagation()
|
|
92
|
+
onNext()
|
|
93
|
+
}}
|
|
94
|
+
aria-label="Next"
|
|
95
|
+
className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20"
|
|
96
|
+
>
|
|
97
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
98
|
+
<path d="M9 6l6 6-6 6" />
|
|
99
|
+
</svg>
|
|
100
|
+
</button>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
{/* Content */}
|
|
104
|
+
<div className="flex max-h-full max-w-5xl flex-col items-center gap-3">
|
|
105
|
+
{kind === "image" && url && (
|
|
106
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
107
|
+
<img
|
|
108
|
+
src={url}
|
|
109
|
+
alt={media.alt ?? media.fileName}
|
|
110
|
+
className="max-h-[80vh] max-w-full rounded-lg object-contain shadow-2xl"
|
|
111
|
+
/>
|
|
112
|
+
)}
|
|
113
|
+
{kind === "video" && url && (
|
|
114
|
+
<video
|
|
115
|
+
src={url}
|
|
116
|
+
controls
|
|
117
|
+
autoPlay
|
|
118
|
+
className="max-h-[80vh] max-w-full rounded-lg shadow-2xl"
|
|
119
|
+
/>
|
|
120
|
+
)}
|
|
121
|
+
{kind === "audio" && url && (
|
|
122
|
+
<div className="flex w-full max-w-md flex-col gap-3 rounded-lg bg-white/10 p-6 text-white">
|
|
123
|
+
<div className="text-center text-sm font-medium">
|
|
124
|
+
{media.fileName}
|
|
125
|
+
</div>
|
|
126
|
+
<audio src={url} controls className="w-full" />
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
{kind !== "image" && kind !== "video" && kind !== "audio" && (
|
|
130
|
+
<div className="flex flex-col items-center gap-3 rounded-lg bg-white/10 p-8 text-white">
|
|
131
|
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
132
|
+
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
|
133
|
+
<path d="M14 2v6h6" />
|
|
134
|
+
</svg>
|
|
135
|
+
<div className="text-sm font-medium">{media.fileName}</div>
|
|
136
|
+
<a
|
|
137
|
+
href={url}
|
|
138
|
+
download={media.fileName}
|
|
139
|
+
target="_blank"
|
|
140
|
+
rel="noreferrer"
|
|
141
|
+
className="rounded-md bg-white px-4 py-2 text-xs font-semibold text-black hover:bg-white/90"
|
|
142
|
+
>
|
|
143
|
+
Download
|
|
144
|
+
</a>
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
|
|
148
|
+
<div className="flex items-center gap-3 rounded-md bg-black/40 px-3 py-1.5 text-xs text-white/80">
|
|
149
|
+
<span className="font-mono truncate max-w-xs">{media.fileName}</span>
|
|
150
|
+
<span>·</span>
|
|
151
|
+
<span>{formatBytes(media.size ?? 0)}</span>
|
|
152
|
+
{media.mimeType && (
|
|
153
|
+
<>
|
|
154
|
+
<span>·</span>
|
|
155
|
+
<span className="font-mono opacity-70">{media.mimeType}</span>
|
|
156
|
+
</>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react"
|
|
2
|
+
import type { Sentroy } from "../.."
|
|
3
|
+
import type { Media, MediaListResult } from "../../types"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Bir bucket'taki media listesini çeken hook. Search, kind filter ve
|
|
7
|
+
* folder filter local-side; SDK'nın list endpoint'i şimdilik bunları
|
|
8
|
+
* server-side desteklemiyor (genelde 100-200 dosyalık küçük scale,
|
|
9
|
+
* client-side OK). Büyük scale'de paginate + server filter eklenir.
|
|
10
|
+
*/
|
|
11
|
+
export function useMediaList(args: {
|
|
12
|
+
client: Sentroy
|
|
13
|
+
bucketSlug: string | null
|
|
14
|
+
/** Yeniden tetiklenmek için artırılan key. */
|
|
15
|
+
refreshKey?: number
|
|
16
|
+
}) {
|
|
17
|
+
const { client, bucketSlug, refreshKey = 0 } = args
|
|
18
|
+
const [data, setData] = useState<MediaListResult | null>(null)
|
|
19
|
+
const [loading, setLoading] = useState(false)
|
|
20
|
+
const [error, setError] = useState<string | null>(null)
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!bucketSlug) {
|
|
24
|
+
setData(null)
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
let cancelled = false
|
|
28
|
+
setLoading(true)
|
|
29
|
+
setError(null)
|
|
30
|
+
client.media
|
|
31
|
+
.list(bucketSlug, { limit: 200 })
|
|
32
|
+
.then((res) => {
|
|
33
|
+
if (cancelled) return
|
|
34
|
+
setData(res)
|
|
35
|
+
})
|
|
36
|
+
.catch((err: unknown) => {
|
|
37
|
+
if (cancelled) return
|
|
38
|
+
setError(err instanceof Error ? err.message : "Failed to load media")
|
|
39
|
+
})
|
|
40
|
+
.finally(() => {
|
|
41
|
+
if (!cancelled) setLoading(false)
|
|
42
|
+
})
|
|
43
|
+
return () => {
|
|
44
|
+
cancelled = true
|
|
45
|
+
}
|
|
46
|
+
}, [client, bucketSlug, refreshKey])
|
|
47
|
+
|
|
48
|
+
const refresh = useCallback(() => {
|
|
49
|
+
// Caller kendi refreshKey'ini bump'lar; bu sadece convenience.
|
|
50
|
+
setData((d) => d)
|
|
51
|
+
}, [])
|
|
52
|
+
|
|
53
|
+
return { data, items: data?.items ?? ([] as Media[]), loading, error, refresh }
|
|
54
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal class name combiner — clsx + twMerge gibi davranır ama
|
|
3
|
+
* dependency yok. Falsy filtrele, dedup yapma. Tema override'larında
|
|
4
|
+
* Tailwind utility çakışmaları için consumer kendi cn'ini kullanırsa
|
|
5
|
+
* `className` prop'u son geldiği için doğal şekilde kazanır.
|
|
6
|
+
*/
|
|
7
|
+
export function cn(
|
|
8
|
+
...args: Array<string | undefined | null | false | 0>
|
|
9
|
+
): string {
|
|
10
|
+
return args.filter(Boolean).join(" ")
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function formatBytes(bytes: number, decimals = 1): string {
|
|
14
|
+
if (!bytes) return "0 B"
|
|
15
|
+
const units = ["B", "KB", "MB", "GB", "TB"]
|
|
16
|
+
let i = 0
|
|
17
|
+
let v = bytes
|
|
18
|
+
while (v >= 1024 && i < units.length - 1) {
|
|
19
|
+
v /= 1024
|
|
20
|
+
i++
|
|
21
|
+
}
|
|
22
|
+
return `${Number.isInteger(v) ? v : v.toFixed(decimals)} ${units[i]}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** İlk image-like extension veya MIME → "image", "video", "audio", "pdf",
|
|
26
|
+
* "doc" (word/excel/ppt), "archive", "code", "other". */
|
|
27
|
+
export function detectKind(file: {
|
|
28
|
+
mimeType?: string | null
|
|
29
|
+
fileName?: string
|
|
30
|
+
}): MediaKind {
|
|
31
|
+
const mt = (file.mimeType ?? "").toLowerCase()
|
|
32
|
+
const ext = (file.fileName ?? "").split(".").pop()?.toLowerCase() ?? ""
|
|
33
|
+
if (mt.startsWith("image/")) return "image"
|
|
34
|
+
if (mt.startsWith("video/")) return "video"
|
|
35
|
+
if (mt.startsWith("audio/")) return "audio"
|
|
36
|
+
if (mt === "application/pdf" || ext === "pdf") return "pdf"
|
|
37
|
+
if (
|
|
38
|
+
/^(doc|docx|xls|xlsx|ppt|pptx|odt|ods|odp|txt|md|csv)$/.test(ext) ||
|
|
39
|
+
mt.startsWith("application/vnd.")
|
|
40
|
+
) {
|
|
41
|
+
return "doc"
|
|
42
|
+
}
|
|
43
|
+
if (/^(zip|tar|gz|7z|rar|bz2)$/.test(ext)) return "archive"
|
|
44
|
+
if (
|
|
45
|
+
/^(js|ts|tsx|jsx|json|html|css|scss|sh|py|go|rb|php|rs|java|c|cpp)$/.test(
|
|
46
|
+
ext,
|
|
47
|
+
)
|
|
48
|
+
) {
|
|
49
|
+
return "code"
|
|
50
|
+
}
|
|
51
|
+
return "other"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type MediaKind =
|
|
55
|
+
| "image"
|
|
56
|
+
| "video"
|
|
57
|
+
| "audio"
|
|
58
|
+
| "pdf"
|
|
59
|
+
| "doc"
|
|
60
|
+
| "archive"
|
|
61
|
+
| "code"
|
|
62
|
+
| "other"
|
|
63
|
+
|
|
64
|
+
export const KIND_LABELS: Record<MediaKind, string> = {
|
|
65
|
+
image: "Image",
|
|
66
|
+
video: "Video",
|
|
67
|
+
audio: "Audio",
|
|
68
|
+
pdf: "PDF",
|
|
69
|
+
doc: "Document",
|
|
70
|
+
archive: "Archive",
|
|
71
|
+
code: "Code",
|
|
72
|
+
other: "Other",
|
|
73
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { HttpClient } from "../http"
|
|
2
|
+
import type {
|
|
3
|
+
Bucket,
|
|
4
|
+
CreateBucketParams,
|
|
5
|
+
UpdateBucketParams,
|
|
6
|
+
} from "../types"
|
|
7
|
+
|
|
8
|
+
export class Buckets {
|
|
9
|
+
constructor(private http: HttpClient) {}
|
|
10
|
+
|
|
11
|
+
/** List all buckets in the company. */
|
|
12
|
+
async list(): Promise<Bucket[]> {
|
|
13
|
+
return this.http.get<Bucket[]>("/buckets")
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Get a single bucket by its slug. */
|
|
17
|
+
async get(slug: string): Promise<Bucket> {
|
|
18
|
+
return this.http.get<Bucket>(`/buckets/${encodeURIComponent(slug)}`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Create a new bucket. Slug is auto-derived from name if omitted. */
|
|
22
|
+
async create(params: CreateBucketParams): Promise<Bucket> {
|
|
23
|
+
return this.http.post<Bucket>("/buckets", params)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Update a bucket's name, description, or visibility. Toggling
|
|
28
|
+
* `isPublic` cascades to every existing file in the bucket (S3 ACL +
|
|
29
|
+
* Media doc); this call can take a while for large buckets.
|
|
30
|
+
*/
|
|
31
|
+
async update(slug: string, params: UpdateBucketParams): Promise<Bucket> {
|
|
32
|
+
return this.http.patch<Bucket>(
|
|
33
|
+
`/buckets/${encodeURIComponent(slug)}`,
|
|
34
|
+
params,
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Delete a bucket. Fails with 409 if the bucket has any files unless
|
|
40
|
+
* `force: true` is passed — then every file is purged from storage
|
|
41
|
+
* before the bucket is removed.
|
|
42
|
+
*/
|
|
43
|
+
async delete(slug: string, opts?: { force?: boolean }): Promise<void> {
|
|
44
|
+
const query = opts?.force ? { force: "true" } : undefined
|
|
45
|
+
await this.http.del<{ deleted: boolean }>(
|
|
46
|
+
`/buckets/${encodeURIComponent(slug)}`,
|
|
47
|
+
query,
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { HttpClient } from "../http"
|
|
2
|
+
import type { Domain } from "../types"
|
|
3
|
+
|
|
4
|
+
export class Domains {
|
|
5
|
+
constructor(private http: HttpClient) {}
|
|
6
|
+
|
|
7
|
+
/** List all verified domains for the company */
|
|
8
|
+
async list(): Promise<Domain[]> {
|
|
9
|
+
return this.http.get<Domain[]>("/domains")
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Get a single domain by ID */
|
|
13
|
+
async get(id: string): Promise<Domain> {
|
|
14
|
+
return this.http.get<Domain>(`/domains/${encodeURIComponent(id)}`)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { HttpClient } from "../http"
|
|
2
|
+
import type {
|
|
3
|
+
MessageSummary,
|
|
4
|
+
MessageDetail,
|
|
5
|
+
Mailbox,
|
|
6
|
+
InboxListParams,
|
|
7
|
+
} from "../types"
|
|
8
|
+
|
|
9
|
+
export class Inbox {
|
|
10
|
+
constructor(private http: HttpClient) {}
|
|
11
|
+
|
|
12
|
+
/** List messages in a mailbox folder */
|
|
13
|
+
async list(params?: InboxListParams): Promise<MessageSummary[]> {
|
|
14
|
+
const query: Record<string, unknown> = {}
|
|
15
|
+
if (params?.mailbox) query.mailbox = params.mailbox
|
|
16
|
+
if (params?.folder) query.folder = params.folder
|
|
17
|
+
if (params?.page) query.page = params.page
|
|
18
|
+
if (params?.limit) query.limit = params.limit
|
|
19
|
+
if (params?.unreadOnly) query.unreadOnly = "true"
|
|
20
|
+
return this.http.get<MessageSummary[]>("/inbox", query)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Get a single message detail */
|
|
24
|
+
async get(
|
|
25
|
+
uid: number,
|
|
26
|
+
options?: { mailbox?: string; folder?: string },
|
|
27
|
+
): Promise<MessageDetail> {
|
|
28
|
+
const query: Record<string, unknown> = {}
|
|
29
|
+
if (options?.mailbox) query.mailbox = options.mailbox
|
|
30
|
+
if (options?.folder) query.folder = options.folder
|
|
31
|
+
return this.http.get<MessageDetail>(`/inbox/${uid}`, query)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** List IMAP folders (mailboxes) for a given email account */
|
|
35
|
+
async listFolders(mailbox?: string): Promise<Mailbox[]> {
|
|
36
|
+
const query: Record<string, unknown> = {}
|
|
37
|
+
if (mailbox) query.mailbox = mailbox
|
|
38
|
+
return this.http.get<Mailbox[]>("/inbox/mailboxes", query)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Get thread messages by subject */
|
|
42
|
+
async getThread(
|
|
43
|
+
subject: string,
|
|
44
|
+
mailbox?: string,
|
|
45
|
+
): Promise<(MessageDetail & { folder: string })[]> {
|
|
46
|
+
const query: Record<string, unknown> = { subject }
|
|
47
|
+
if (mailbox) query.mailbox = mailbox
|
|
48
|
+
return this.http.get<(MessageDetail & { folder: string })[]>(
|
|
49
|
+
"/inbox/thread",
|
|
50
|
+
query,
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Mark a message as read */
|
|
55
|
+
async markAsRead(
|
|
56
|
+
uid: number,
|
|
57
|
+
options?: { mailbox?: string; folder?: string },
|
|
58
|
+
): Promise<void> {
|
|
59
|
+
await this.http.post(`/inbox/${uid}/read`, {
|
|
60
|
+
mailbox: options?.mailbox,
|
|
61
|
+
folder: options?.folder,
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Mark a message as unread */
|
|
66
|
+
async markAsUnread(
|
|
67
|
+
uid: number,
|
|
68
|
+
options?: { mailbox?: string; folder?: string },
|
|
69
|
+
): Promise<void> {
|
|
70
|
+
await this.http.del(`/inbox/${uid}/read`, {
|
|
71
|
+
mailbox: options?.mailbox,
|
|
72
|
+
folder: options?.folder,
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Move a message to another folder */
|
|
77
|
+
async move(
|
|
78
|
+
uid: number,
|
|
79
|
+
to: string,
|
|
80
|
+
options?: { from?: string; mailbox?: string },
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
await this.http.post(`/inbox/${uid}/move`, {
|
|
83
|
+
to,
|
|
84
|
+
from: options?.from,
|
|
85
|
+
mailbox: options?.mailbox,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Delete a message */
|
|
90
|
+
async delete(
|
|
91
|
+
uid: number,
|
|
92
|
+
options?: { mailbox?: string; folder?: string },
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
const query: Record<string, unknown> = {}
|
|
95
|
+
if (options?.mailbox) query.mailbox = options.mailbox
|
|
96
|
+
if (options?.folder) query.folder = options.folder
|
|
97
|
+
await this.http.del(`/inbox/${uid}`, query)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { HttpClient } from "../http"
|
|
2
|
+
import type { MailboxUser } from "../types"
|
|
3
|
+
|
|
4
|
+
export class Mailboxes {
|
|
5
|
+
constructor(private http: HttpClient) {}
|
|
6
|
+
|
|
7
|
+
/** List all mailbox accounts for the company */
|
|
8
|
+
async list(): Promise<MailboxUser[]> {
|
|
9
|
+
return this.http.get<MailboxUser[]>("/mailboxes")
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { HttpClient } from "../http"
|
|
2
|
+
import type {
|
|
3
|
+
Media,
|
|
4
|
+
MediaListParams,
|
|
5
|
+
MediaListResult,
|
|
6
|
+
UploadMediaParams,
|
|
7
|
+
} from "../types"
|
|
8
|
+
|
|
9
|
+
export class MediaResource {
|
|
10
|
+
constructor(private http: HttpClient) {}
|
|
11
|
+
|
|
12
|
+
/** List files in a bucket (paginated). */
|
|
13
|
+
async list(
|
|
14
|
+
bucketSlug: string,
|
|
15
|
+
params?: MediaListParams,
|
|
16
|
+
): Promise<MediaListResult> {
|
|
17
|
+
return this.http.get<MediaListResult>(
|
|
18
|
+
`/buckets/${encodeURIComponent(bucketSlug)}/media`,
|
|
19
|
+
params as Record<string, unknown> | undefined,
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Get a single media record. */
|
|
24
|
+
async get(bucketSlug: string, mediaId: string): Promise<Media> {
|
|
25
|
+
return this.http.get<Media>(
|
|
26
|
+
`/buckets/${encodeURIComponent(bucketSlug)}/media/${encodeURIComponent(mediaId)}`,
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Upload a file to a bucket. Works in Node and the browser: `body`
|
|
32
|
+
* is a `Blob` or `File`. Returns the full serialized media record
|
|
33
|
+
* (with `url`, `downloadUrl`, image thumbnails when applicable).
|
|
34
|
+
*
|
|
35
|
+
* @example Browser
|
|
36
|
+
* ```ts
|
|
37
|
+
* const file = input.files[0]
|
|
38
|
+
* const media = await sentroy.media.upload("my-bucket", { body: file })
|
|
39
|
+
* console.log(media.url)
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* @example Node 18+
|
|
43
|
+
* ```ts
|
|
44
|
+
* import { openAsBlob } from "node:fs"
|
|
45
|
+
* const blob = await openAsBlob("./photo.jpg")
|
|
46
|
+
* const media = await sentroy.media.upload("my-bucket", {
|
|
47
|
+
* body: blob,
|
|
48
|
+
* filename: "photo.jpg",
|
|
49
|
+
* })
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
async upload(
|
|
53
|
+
bucketSlug: string,
|
|
54
|
+
params: UploadMediaParams,
|
|
55
|
+
): Promise<Media> {
|
|
56
|
+
const form = new FormData()
|
|
57
|
+
const filename =
|
|
58
|
+
params.filename ||
|
|
59
|
+
(params.body as File & { name?: string }).name ||
|
|
60
|
+
"upload.bin"
|
|
61
|
+
form.append("file", params.body, filename)
|
|
62
|
+
if (params.folder) form.append("folder", params.folder)
|
|
63
|
+
if (params.isPublic !== undefined) {
|
|
64
|
+
form.append("public", params.isPublic ? "true" : "false")
|
|
65
|
+
}
|
|
66
|
+
if (params.alt) form.append("alt", params.alt)
|
|
67
|
+
if (params.caption) form.append("caption", params.caption)
|
|
68
|
+
if (params.tags?.length) form.append("tags", params.tags.join(","))
|
|
69
|
+
|
|
70
|
+
return this.http.postForm<Media>(
|
|
71
|
+
`/buckets/${encodeURIComponent(bucketSlug)}/media`,
|
|
72
|
+
form,
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Delete a file. Cascades through the CDN: S3 objects (original +
|
|
78
|
+
* thumbnails) are removed, then the Media record. If any S3 delete
|
|
79
|
+
* fails the record is kept so you can retry.
|
|
80
|
+
*/
|
|
81
|
+
async delete(bucketSlug: string, mediaId: string): Promise<void> {
|
|
82
|
+
await this.http.del<{ deleted: boolean }>(
|
|
83
|
+
`/buckets/${encodeURIComponent(bucketSlug)}/media/${encodeURIComponent(mediaId)}`,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Download a media file. For private buckets this is the only way to
|
|
89
|
+
* fetch the bytes since the CDN public URL won't serve them; the
|
|
90
|
+
* storage app proxies through after auth.
|
|
91
|
+
*
|
|
92
|
+
* Pass `quality` to request a pre-generated thumbnail variant (e.g.
|
|
93
|
+
* `quality: 500` for 500px wide). Falls back to the original if the
|
|
94
|
+
* variant doesn't exist for that file.
|
|
95
|
+
*/
|
|
96
|
+
async download(
|
|
97
|
+
bucketSlug: string,
|
|
98
|
+
mediaId: string,
|
|
99
|
+
opts?: { quality?: number | "original" },
|
|
100
|
+
): Promise<Blob> {
|
|
101
|
+
const quality = opts?.quality
|
|
102
|
+
const query =
|
|
103
|
+
quality && quality !== "original"
|
|
104
|
+
? { quality: String(quality) }
|
|
105
|
+
: undefined
|
|
106
|
+
const res = await this.http.fetchRaw(
|
|
107
|
+
`/buckets/${encodeURIComponent(bucketSlug)}/media/${encodeURIComponent(mediaId)}/download`,
|
|
108
|
+
{ query },
|
|
109
|
+
)
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
throw new Error(`Download failed with status ${res.status}`)
|
|
112
|
+
}
|
|
113
|
+
return res.blob()
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { HttpClient } from "../http"
|
|
2
|
+
import type { SendParams, SendResult } from "../types"
|
|
3
|
+
|
|
4
|
+
export class Send {
|
|
5
|
+
constructor(private http: HttpClient) {}
|
|
6
|
+
|
|
7
|
+
/** Send an email */
|
|
8
|
+
async email(params: SendParams): Promise<SendResult> {
|
|
9
|
+
return this.http.post<SendResult>("/send", params)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { HttpClient } from "../http"
|
|
2
|
+
import type { StorageQuota, StorageUsage } from "../types"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Storage observability — read-only quota + breakdown endpoints. Bucket
|
|
6
|
+
* and media CRUD lives on `client.buckets` / `client.media`.
|
|
7
|
+
*/
|
|
8
|
+
export class Storage {
|
|
9
|
+
constructor(private http: HttpClient) {}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Plan storage quota for the company. `used` reflects bucket totals,
|
|
13
|
+
* `mailUsed` what the mail product has stored under the same plan
|
|
14
|
+
* pool, and `limit: 0` means unlimited.
|
|
15
|
+
*/
|
|
16
|
+
async quota(): Promise<StorageQuota> {
|
|
17
|
+
return this.http.get<StorageQuota>("/storage-quota")
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Combined dashboard payload — plan quota + per-bucket byte/file
|
|
22
|
+
* counts + per-type aggregation across the company. Single round-trip
|
|
23
|
+
* intended for usage UIs.
|
|
24
|
+
*/
|
|
25
|
+
async usage(): Promise<StorageUsage> {
|
|
26
|
+
return this.http.get<StorageUsage>("/usage")
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { HttpClient } from "../http"
|
|
2
|
+
import type { Template } from "../types"
|
|
3
|
+
|
|
4
|
+
export class Templates {
|
|
5
|
+
constructor(private http: HttpClient) {}
|
|
6
|
+
|
|
7
|
+
/** List all templates */
|
|
8
|
+
async list(): Promise<Template[]> {
|
|
9
|
+
return this.http.get<Template[]>("/templates")
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Get a single template by ID */
|
|
13
|
+
async get(id: string): Promise<Template> {
|
|
14
|
+
return this.http.get<Template>(`/templates/${encodeURIComponent(id)}`)
|
|
15
|
+
}
|
|
16
|
+
}
|