@codesuma/baseline 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +648 -0
- package/components/advanced/page.module.css +57 -0
- package/components/advanced/page.ts +53 -0
- package/components/base.ts +45 -0
- package/components/native/a.ts +15 -0
- package/components/native/button.ts +17 -0
- package/components/native/canvas.ts +15 -0
- package/components/native/div.ts +11 -0
- package/components/native/h.ts +17 -0
- package/components/native/img.ts +17 -0
- package/components/native/input.ts +29 -0
- package/components/native/option.ts +8 -0
- package/components/native/p.ts +11 -0
- package/components/native/select.ts +15 -0
- package/components/native/span.ts +11 -0
- package/components/native/svg.ts +18 -0
- package/helpers/date.ts +17 -0
- package/helpers/device.ts +13 -0
- package/helpers/format.ts +13 -0
- package/helpers/locales.ts +15 -0
- package/helpers/regex.ts +12 -0
- package/index.ts +35 -0
- package/lib/http.ts +96 -0
- package/lib/idb.ts +135 -0
- package/lib/ldb.ts +44 -0
- package/lib/router.ts +179 -0
- package/lib/state.ts +35 -0
- package/package.json +35 -0
- package/utils/appender.ts +141 -0
- package/utils/emitter.ts +63 -0
- package/utils/id.ts +13 -0
- package/utils/mounter.ts +8 -0
- package/utils/ripple.ts +59 -0
- package/utils/styler.ts +56 -0
- package/utils/wait.ts +3 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Page component with directional enter/exit transitions
|
|
2
|
+
|
|
3
|
+
import { Div } from '../native/div'
|
|
4
|
+
import { IRouteEvent } from '../../lib/router'
|
|
5
|
+
import { waitFor } from '../../utils/wait'
|
|
6
|
+
import styles from './page.module.css'
|
|
7
|
+
|
|
8
|
+
const TRANSITION_DURATION = 160
|
|
9
|
+
|
|
10
|
+
export const Page = () => {
|
|
11
|
+
const base = Div()
|
|
12
|
+
base.addClass(styles.page) // Initial state is hidden below (in CSS)
|
|
13
|
+
|
|
14
|
+
// Enter: direction based on isBack flag
|
|
15
|
+
base.on('enter', async ({ isBack }: IRouteEvent) => {
|
|
16
|
+
await waitFor(10)
|
|
17
|
+
// Clear all state classes
|
|
18
|
+
base.removeClass(
|
|
19
|
+
styles.exitUp,
|
|
20
|
+
styles.exitDown,
|
|
21
|
+
styles.hiddenBelow,
|
|
22
|
+
styles.hiddenAbove
|
|
23
|
+
)
|
|
24
|
+
// Add enter class (same visual result, but positioned correctly before)
|
|
25
|
+
base.addClass(isBack ? styles.enterDown : styles.enterUp)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
// Exit: direction based on isBack flag
|
|
29
|
+
base.on('exit', async ({ isBack }: IRouteEvent) => {
|
|
30
|
+
base.removeClass(styles.enterUp, styles.enterDown)
|
|
31
|
+
if (isBack) {
|
|
32
|
+
// Back: current page goes DOWN
|
|
33
|
+
base.addClass(styles.exitDown)
|
|
34
|
+
} else {
|
|
35
|
+
// Forward: current page goes UP
|
|
36
|
+
base.addClass(styles.exitUp)
|
|
37
|
+
}
|
|
38
|
+
await waitFor(TRANSITION_DURATION)
|
|
39
|
+
|
|
40
|
+
// Position for next enter
|
|
41
|
+
if (isBack) {
|
|
42
|
+
// After exiting down, position below for next forward enter
|
|
43
|
+
base.addClass(styles.hiddenBelow)
|
|
44
|
+
} else {
|
|
45
|
+
// After exiting up, position above for next back enter
|
|
46
|
+
base.addClass(styles.hiddenAbove)
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
return base
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default Page
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { nextId } from '../utils/id'
|
|
2
|
+
import { createAppender, IAppender } from '../utils/appender'
|
|
3
|
+
import { createStyler, IStyler } from '../utils/styler'
|
|
4
|
+
import { createEmitter, IEmitter } from '../utils/emitter'
|
|
5
|
+
import { initMounter } from '../utils/mounter'
|
|
6
|
+
|
|
7
|
+
initMounter()
|
|
8
|
+
|
|
9
|
+
export function Base<K extends keyof HTMLElementTagNameMap>(name: K = 'div' as K): IBaseComponent<K> {
|
|
10
|
+
const id = nextId()
|
|
11
|
+
const el = document.createElement(name)
|
|
12
|
+
el.setAttribute('data-base-id', id)
|
|
13
|
+
|
|
14
|
+
const component: any = { id, el, isMounted: false, parent: undefined }
|
|
15
|
+
|
|
16
|
+
Object.assign(component, createEmitter(), createAppender(component), createStyler(component))
|
|
17
|
+
|
|
18
|
+
return component
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function BaseSVG<K extends keyof SVGElementTagNameMap>(name: K = 'svg' as K): IBaseSVGComponent<K> {
|
|
22
|
+
const id = nextId()
|
|
23
|
+
const el = document.createElementNS('http://www.w3.org/2000/svg', name)
|
|
24
|
+
el.setAttribute('data-base-id', id)
|
|
25
|
+
|
|
26
|
+
const component: any = { id, el, isMounted: false, parent: undefined }
|
|
27
|
+
|
|
28
|
+
Object.assign(component, createEmitter(), createAppender(component), createStyler(component))
|
|
29
|
+
|
|
30
|
+
return component
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface IBaseComponent<K extends keyof HTMLElementTagNameMap = 'div'> extends IEmitter, IAppender, IStyler {
|
|
34
|
+
id: string
|
|
35
|
+
el: HTMLElementTagNameMap[K]
|
|
36
|
+
parent?: IBaseComponent<any>
|
|
37
|
+
isMounted: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface IBaseSVGComponent<K extends keyof SVGElementTagNameMap = 'svg'> extends IEmitter, IAppender, IStyler {
|
|
41
|
+
id: string
|
|
42
|
+
el: SVGElementTagNameMap[K]
|
|
43
|
+
parent?: IBaseComponent<any> | IBaseSVGComponent<any>
|
|
44
|
+
isMounted: boolean
|
|
45
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Base } from '../base'
|
|
2
|
+
|
|
3
|
+
export const A = (href = '#', text = '', target = '_self') => {
|
|
4
|
+
const base = Base('a')
|
|
5
|
+
base.el.href = href
|
|
6
|
+
base.el.target = target
|
|
7
|
+
if (text) base.el.textContent = text
|
|
8
|
+
|
|
9
|
+
return Object.assign(base, {
|
|
10
|
+
text(t: string) { base.el.textContent = t },
|
|
11
|
+
href(h: string) { base.el.href = h },
|
|
12
|
+
target(t: string) { base.el.target = t },
|
|
13
|
+
download(filename: string) { base.el.download = filename }
|
|
14
|
+
})
|
|
15
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Base } from '../base'
|
|
2
|
+
|
|
3
|
+
export const Button = (text = '') => {
|
|
4
|
+
const base = Base('button')
|
|
5
|
+
|
|
6
|
+
base.style({ cursor: 'pointer' })
|
|
7
|
+
base.el.textContent = text
|
|
8
|
+
base.el.onclick = () => base.emit('click')
|
|
9
|
+
|
|
10
|
+
return Object.assign(base, {
|
|
11
|
+
focus() { base.el.focus() },
|
|
12
|
+
blur() { base.el.blur() },
|
|
13
|
+
disable() { base.el.disabled = true },
|
|
14
|
+
enable() { base.el.disabled = false },
|
|
15
|
+
text(t: string) { base.el.textContent = t }
|
|
16
|
+
})
|
|
17
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Base } from '../base'
|
|
2
|
+
|
|
3
|
+
export const Canvas = (width: number, height: number) => {
|
|
4
|
+
const base = Base('canvas')
|
|
5
|
+
base.el.width = width
|
|
6
|
+
base.el.height = height
|
|
7
|
+
|
|
8
|
+
return Object.assign(base, {
|
|
9
|
+
ctx: base.el.getContext('2d')!,
|
|
10
|
+
resize(w: number, h: number) {
|
|
11
|
+
base.el.width = w
|
|
12
|
+
base.el.height = h
|
|
13
|
+
}
|
|
14
|
+
})
|
|
15
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Base } from '../base'
|
|
2
|
+
|
|
3
|
+
export const Div = (content = '') => {
|
|
4
|
+
const base = Base('div')
|
|
5
|
+
if (content) base.el.innerHTML = content
|
|
6
|
+
|
|
7
|
+
return Object.assign(base, {
|
|
8
|
+
text(t: string) { base.el.textContent = t },
|
|
9
|
+
html(h: string) { base.el.innerHTML = h }
|
|
10
|
+
})
|
|
11
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Base } from '../base'
|
|
2
|
+
|
|
3
|
+
const createHeading = (tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6', content = '') => {
|
|
4
|
+
const base = Base(tag)
|
|
5
|
+
if (content) base.el.textContent = content
|
|
6
|
+
|
|
7
|
+
return Object.assign(base, {
|
|
8
|
+
text(t: string) { base.el.textContent = t }
|
|
9
|
+
})
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const H1 = (content = '') => createHeading('h1', content)
|
|
13
|
+
export const H2 = (content = '') => createHeading('h2', content)
|
|
14
|
+
export const H3 = (content = '') => createHeading('h3', content)
|
|
15
|
+
export const H4 = (content = '') => createHeading('h4', content)
|
|
16
|
+
export const H5 = (content = '') => createHeading('h5', content)
|
|
17
|
+
export const H6 = (content = '') => createHeading('h6', content)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Base } from '../base'
|
|
2
|
+
|
|
3
|
+
const PLACEHOLDER = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
|
|
4
|
+
|
|
5
|
+
export const Img = (src = '', options: { width?: number; height?: number; alt?: string } = {}) => {
|
|
6
|
+
const base = Base('img')
|
|
7
|
+
|
|
8
|
+
base.el.src = src || PLACEHOLDER
|
|
9
|
+
if (options.alt) base.el.alt = options.alt
|
|
10
|
+
if (options.width) base.style({ width: options.width + 'px' })
|
|
11
|
+
if (options.height) base.style({ height: options.height + 'px' })
|
|
12
|
+
|
|
13
|
+
return Object.assign(base, {
|
|
14
|
+
src(s: string) { base.el.src = s },
|
|
15
|
+
alt(a: string) { base.el.alt = a }
|
|
16
|
+
})
|
|
17
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Base } from '../base'
|
|
2
|
+
|
|
3
|
+
export const Input = (placeholder = '', type = 'text', options: { value?: string; accept?: string } = {}) => {
|
|
4
|
+
const base = type === 'textarea' ? Base('textarea') : Base('input')
|
|
5
|
+
|
|
6
|
+
base.el.setAttribute('type', type)
|
|
7
|
+
base.el.setAttribute('placeholder', placeholder)
|
|
8
|
+
if (options.accept) base.el.setAttribute('accept', options.accept)
|
|
9
|
+
if (options.value) base.el.value = options.value
|
|
10
|
+
|
|
11
|
+
// Events
|
|
12
|
+
base.el.onblur = () => base.emit('blur')
|
|
13
|
+
base.el.onfocus = () => base.emit('focus')
|
|
14
|
+
base.el.oninput = () => base.emit('input', base.el.value)
|
|
15
|
+
base.el.onkeydown = (e: KeyboardEvent) => {
|
|
16
|
+
if (e.key === 'Enter') base.emit('enter', base.el.value)
|
|
17
|
+
if (e.key === 'Escape') base.emit('escape')
|
|
18
|
+
base.emit('keydown', { key: e.key, value: base.el.value })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return Object.assign(base, {
|
|
22
|
+
focus() { base.el.focus() },
|
|
23
|
+
blur() { base.el.blur() },
|
|
24
|
+
select() { base.el.select() },
|
|
25
|
+
value() { return base.el.value },
|
|
26
|
+
setValue(v: string) { base.el.value = v },
|
|
27
|
+
clear() { base.el.value = '' }
|
|
28
|
+
})
|
|
29
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Base } from '../base'
|
|
2
|
+
|
|
3
|
+
export const P = (content = '') => {
|
|
4
|
+
const base = Base('p')
|
|
5
|
+
if (content) base.el.textContent = content
|
|
6
|
+
|
|
7
|
+
return Object.assign(base, {
|
|
8
|
+
text(t: string) { base.el.textContent = t },
|
|
9
|
+
html(h: string) { base.el.innerHTML = h }
|
|
10
|
+
})
|
|
11
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Base } from '../base'
|
|
2
|
+
import { Option } from './option'
|
|
3
|
+
|
|
4
|
+
export const Select = (options: { value: string; text: string }[] = []) => {
|
|
5
|
+
const base = Base('select')
|
|
6
|
+
|
|
7
|
+
options.forEach(({ value, text }) => base.append(Option(value, text)))
|
|
8
|
+
base.el.onchange = () => base.emit('change', base.el.value)
|
|
9
|
+
|
|
10
|
+
return Object.assign(base, {
|
|
11
|
+
add(value: string, text: string) { base.append(Option(value, text)) },
|
|
12
|
+
value() { return base.el.value },
|
|
13
|
+
setValue(v: string) { base.el.value = v }
|
|
14
|
+
})
|
|
15
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Base } from '../base'
|
|
2
|
+
|
|
3
|
+
export const Span = (content = '') => {
|
|
4
|
+
const base = Base('span')
|
|
5
|
+
if (content) base.el.textContent = content
|
|
6
|
+
|
|
7
|
+
return Object.assign(base, {
|
|
8
|
+
text(t: string) { base.el.textContent = t },
|
|
9
|
+
html(h: string) { base.el.innerHTML = h }
|
|
10
|
+
})
|
|
11
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { BaseSVG } from '../base'
|
|
2
|
+
|
|
3
|
+
export const SVG = (viewBox: string | number, height?: number) => {
|
|
4
|
+
const base = BaseSVG('svg')
|
|
5
|
+
|
|
6
|
+
if (typeof viewBox === 'string') {
|
|
7
|
+
base.el.setAttribute('viewBox', viewBox)
|
|
8
|
+
} else {
|
|
9
|
+
const h = height || viewBox
|
|
10
|
+
base.el.setAttribute('viewBox', `0 0 ${viewBox} ${h}`)
|
|
11
|
+
base.el.setAttribute('width', String(viewBox))
|
|
12
|
+
base.el.setAttribute('height', String(h))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return Object.assign(base, {
|
|
16
|
+
html(content: string) { base.el.innerHTML = content }
|
|
17
|
+
})
|
|
18
|
+
}
|
package/helpers/date.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Date formatting helpers
|
|
2
|
+
|
|
3
|
+
export const dateFormatter = (locale = 'en-US', options: Intl.DateTimeFormatOptions = {}) =>
|
|
4
|
+
new Intl.DateTimeFormat(locale, {
|
|
5
|
+
year: 'numeric',
|
|
6
|
+
month: 'long',
|
|
7
|
+
day: 'numeric',
|
|
8
|
+
...options
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
// Persian/Jalali date formatter
|
|
12
|
+
export const jDateFormatter = dateFormatter('fa-IR', { weekday: 'long' })
|
|
13
|
+
|
|
14
|
+
// Common formatters
|
|
15
|
+
export const formatDate = (date: Date, locale = 'en-US') => dateFormatter(locale).format(date)
|
|
16
|
+
export const formatTime = (date: Date, locale = 'en-US') =>
|
|
17
|
+
new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }).format(date)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Device detection helpers
|
|
2
|
+
|
|
3
|
+
export const isMobile = () =>
|
|
4
|
+
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
|
|
5
|
+
|
|
6
|
+
export const isTouch = () =>
|
|
7
|
+
'ontouchstart' in window || navigator.maxTouchPoints > 0
|
|
8
|
+
|
|
9
|
+
export const isIOS = () =>
|
|
10
|
+
/iPad|iPhone|iPod/.test(navigator.userAgent)
|
|
11
|
+
|
|
12
|
+
export const isAndroid = () =>
|
|
13
|
+
/Android/.test(navigator.userAgent)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Number formatting helpers
|
|
2
|
+
|
|
3
|
+
export const formatNumber = (n: number, decimals?: number) => {
|
|
4
|
+
if (typeof n !== 'number') return String(n)
|
|
5
|
+
const fixed = decimals !== undefined ? n.toFixed(decimals) : n.toString()
|
|
6
|
+
return fixed.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const formatCurrency = (n: number, currency = 'USD', locale = 'en-US') =>
|
|
10
|
+
new Intl.NumberFormat(locale, { style: 'currency', currency }).format(n)
|
|
11
|
+
|
|
12
|
+
export const formatPercent = (n: number, decimals = 0) =>
|
|
13
|
+
(n * 100).toFixed(decimals) + '%'
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Locale and text direction helpers
|
|
2
|
+
|
|
3
|
+
const RTL_CHARS = /[\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC]/
|
|
4
|
+
|
|
5
|
+
export const isRTL = (text: string) => RTL_CHARS.test(text)
|
|
6
|
+
|
|
7
|
+
// Persian/Arabic digits to Latin
|
|
8
|
+
export const toLatinDigits = (str: string) =>
|
|
9
|
+
str.replace(/[\u06F0-\u06F9\u0660-\u0669]/g, d =>
|
|
10
|
+
String(d.charCodeAt(0) - (d >= '۰' ? 0x06F0 : 0x0660))
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// Latin digits to Persian
|
|
14
|
+
export const toPersianDigits = (str: string) =>
|
|
15
|
+
str.replace(/[0-9]/g, d => String.fromCharCode(0x06F0 + parseInt(d)))
|
package/helpers/regex.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Validation regex patterns
|
|
2
|
+
|
|
3
|
+
export const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
4
|
+
export const URL = /^https?:\/\/[^\s/$.?#].[^\s]*$/i
|
|
5
|
+
export const PHONE = /^\+?[\d\s-()]+$/
|
|
6
|
+
export const PASSWORD_STRONG = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}$/
|
|
7
|
+
|
|
8
|
+
// Validators (return boolean)
|
|
9
|
+
export const isEmail = (s: string) => EMAIL.test(s)
|
|
10
|
+
export const isUrl = (s: string) => URL.test(s)
|
|
11
|
+
export const isPhone = (s: string) => PHONE.test(s)
|
|
12
|
+
export const isStrongPassword = (s: string) => PASSWORD_STRONG.test(s)
|
package/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Base - A minimal, imperative UI framework
|
|
2
|
+
// Main entry point
|
|
3
|
+
|
|
4
|
+
// Core component factory
|
|
5
|
+
export { Base, IBaseComponent } from './components/base'
|
|
6
|
+
|
|
7
|
+
// Native components
|
|
8
|
+
export { Div } from './components/native/div'
|
|
9
|
+
export { Span } from './components/native/span'
|
|
10
|
+
export { P } from './components/native/p'
|
|
11
|
+
export { H1, H2, H3, H4, H5, H6 } from './components/native/h'
|
|
12
|
+
export { Button } from './components/native/button'
|
|
13
|
+
export { Input } from './components/native/input'
|
|
14
|
+
export { A } from './components/native/a'
|
|
15
|
+
export { Img } from './components/native/img'
|
|
16
|
+
export { Select } from './components/native/select'
|
|
17
|
+
export { Option } from './components/native/option'
|
|
18
|
+
export { Canvas } from './components/native/canvas'
|
|
19
|
+
export { SVG } from './components/native/svg'
|
|
20
|
+
|
|
21
|
+
// Advanced components
|
|
22
|
+
export { Page } from './components/advanced/page'
|
|
23
|
+
|
|
24
|
+
// Libraries
|
|
25
|
+
export { default as router, IRouteEvent } from './lib/router'
|
|
26
|
+
export { default as http } from './lib/http'
|
|
27
|
+
export { default as ldb } from './lib/ldb'
|
|
28
|
+
export { default as createDB } from './lib/idb'
|
|
29
|
+
export { default as state } from './lib/state'
|
|
30
|
+
|
|
31
|
+
// Utilities
|
|
32
|
+
export { createEmitter, emitter, IEmitter } from './utils/emitter'
|
|
33
|
+
export { createStyler, injectCSS, IStyler } from './utils/styler'
|
|
34
|
+
export { waitFor } from './utils/wait'
|
|
35
|
+
export { withRipple } from './utils/ripple'
|
package/lib/http.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// HTTP client with request deduplication and progress support
|
|
2
|
+
|
|
3
|
+
type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
|
4
|
+
|
|
5
|
+
interface RequestOptions {
|
|
6
|
+
headers?: Record<string, string>
|
|
7
|
+
auth?: string
|
|
8
|
+
onProgress?: (loaded: number, total: number) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Request queue for deduplication
|
|
12
|
+
const pending: Record<string, Promise<any>> = {}
|
|
13
|
+
|
|
14
|
+
const request = async <T = any>(
|
|
15
|
+
method: Method,
|
|
16
|
+
url: string,
|
|
17
|
+
body?: any,
|
|
18
|
+
options: RequestOptions = {}
|
|
19
|
+
): Promise<{ status: number; data: T }> => {
|
|
20
|
+
const headers: Record<string, string> = {
|
|
21
|
+
'Content-Type': 'application/json',
|
|
22
|
+
...options.headers
|
|
23
|
+
}
|
|
24
|
+
if (options.auth) headers['Authorization'] = `Bearer ${options.auth}`
|
|
25
|
+
|
|
26
|
+
const res = await fetch(url, {
|
|
27
|
+
method,
|
|
28
|
+
headers,
|
|
29
|
+
credentials: 'include',
|
|
30
|
+
body: body ? JSON.stringify(body) : undefined
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
let data: T
|
|
34
|
+
const contentType = res.headers.get('content-type')
|
|
35
|
+
if (contentType?.includes('application/json')) {
|
|
36
|
+
data = await res.json()
|
|
37
|
+
} else {
|
|
38
|
+
data = await res.text() as any
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { status: res.status, data }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Deduplicated request - same URL returns same promise
|
|
45
|
+
const dedupe = <T>(key: string, fn: () => Promise<T>): Promise<T> => {
|
|
46
|
+
if (!pending[key]) {
|
|
47
|
+
pending[key] = fn().finally(() => delete pending[key])
|
|
48
|
+
}
|
|
49
|
+
return pending[key]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Public API
|
|
53
|
+
export const http = {
|
|
54
|
+
get: <T = any>(url: string, options?: RequestOptions) =>
|
|
55
|
+
dedupe(url, () => request<T>('GET', url, undefined, options)),
|
|
56
|
+
|
|
57
|
+
post: <T = any>(url: string, body?: any, options?: RequestOptions) =>
|
|
58
|
+
request<T>('POST', url, body, options),
|
|
59
|
+
|
|
60
|
+
put: <T = any>(url: string, body?: any, options?: RequestOptions) =>
|
|
61
|
+
request<T>('PUT', url, body, options),
|
|
62
|
+
|
|
63
|
+
patch: <T = any>(url: string, body?: any, options?: RequestOptions) =>
|
|
64
|
+
request<T>('PATCH', url, body, options),
|
|
65
|
+
|
|
66
|
+
delete: <T = any>(url: string, options?: RequestOptions) =>
|
|
67
|
+
request<T>('DELETE', url, undefined, options),
|
|
68
|
+
|
|
69
|
+
// Upload with progress (uses XHR for progress events)
|
|
70
|
+
upload: (url: string, file: File, options: RequestOptions = {}) => {
|
|
71
|
+
return new Promise<{ status: number; data: any }>((resolve, reject) => {
|
|
72
|
+
const xhr = new XMLHttpRequest()
|
|
73
|
+
xhr.open('POST', url)
|
|
74
|
+
if (options.auth) xhr.setRequestHeader('Authorization', `Bearer ${options.auth}`)
|
|
75
|
+
xhr.setRequestHeader('Content-Type', file.type)
|
|
76
|
+
|
|
77
|
+
if (options.onProgress) {
|
|
78
|
+
xhr.upload.onprogress = (e) => {
|
|
79
|
+
if (e.lengthComputable) options.onProgress!(e.loaded, e.total)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
xhr.onload = () => {
|
|
84
|
+
try {
|
|
85
|
+
resolve({ status: xhr.status, data: JSON.parse(xhr.response) })
|
|
86
|
+
} catch {
|
|
87
|
+
resolve({ status: xhr.status, data: xhr.response })
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
xhr.onerror = () => reject(new Error('Upload failed'))
|
|
91
|
+
xhr.send(file)
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export default http
|
package/lib/idb.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Simplified IndexedDB wrapper
|
|
2
|
+
|
|
3
|
+
type StoreOptions = { keyPath?: string; autoIncrement?: boolean; indices?: string[] }
|
|
4
|
+
|
|
5
|
+
const openDB = (name: string, version?: number): Promise<IDBDatabase> =>
|
|
6
|
+
new Promise((resolve, reject) => {
|
|
7
|
+
const req = indexedDB.open(name, version)
|
|
8
|
+
req.onsuccess = () => resolve(req.result)
|
|
9
|
+
req.onerror = () => reject(req.error)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const withStore = async <T>(
|
|
13
|
+
dbName: string,
|
|
14
|
+
storeName: string,
|
|
15
|
+
mode: IDBTransactionMode,
|
|
16
|
+
fn: (store: IDBObjectStore, db: IDBDatabase) => IDBRequest | void
|
|
17
|
+
): Promise<T> => {
|
|
18
|
+
const db = await openDB(dbName)
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
if (!db.objectStoreNames.contains(storeName)) {
|
|
21
|
+
db.close()
|
|
22
|
+
return resolve([] as any)
|
|
23
|
+
}
|
|
24
|
+
const tx = db.transaction(storeName, mode)
|
|
25
|
+
const store = tx.objectStore(storeName)
|
|
26
|
+
const req = fn(store, db)
|
|
27
|
+
|
|
28
|
+
tx.oncomplete = () => {
|
|
29
|
+
db.close()
|
|
30
|
+
resolve(req ? (req.result as T) : (undefined as any))
|
|
31
|
+
}
|
|
32
|
+
tx.onerror = () => {
|
|
33
|
+
db.close()
|
|
34
|
+
reject(tx.error)
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default (dbName: string) => ({
|
|
40
|
+
// Get database info
|
|
41
|
+
async info(): Promise<{ version: number; objectStoreNames: DOMStringList }> {
|
|
42
|
+
const db = await openDB(dbName)
|
|
43
|
+
const result = { version: db.version, objectStoreNames: db.objectStoreNames }
|
|
44
|
+
db.close()
|
|
45
|
+
return result
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
// Create or upgrade store
|
|
49
|
+
async createStore(name: string, version: number, options: StoreOptions = {}) {
|
|
50
|
+
const opts = { keyPath: 'id', autoIncrement: true, indices: [], ...options }
|
|
51
|
+
return new Promise<void>((resolve, reject) => {
|
|
52
|
+
const req = indexedDB.open(dbName, version)
|
|
53
|
+
req.onupgradeneeded = (e) => {
|
|
54
|
+
const db = (e.target as IDBOpenDBRequest).result
|
|
55
|
+
if (!db.objectStoreNames.contains(name)) {
|
|
56
|
+
const store = db.createObjectStore(name, { keyPath: opts.keyPath, autoIncrement: opts.autoIncrement })
|
|
57
|
+
opts.indices!.forEach(idx => store.createIndex(idx, idx))
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
req.onsuccess = () => { req.result.close(); resolve() }
|
|
61
|
+
req.onerror = () => reject(req.error)
|
|
62
|
+
})
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
// CRUD operations
|
|
66
|
+
async save<T>(store: string, data: T | T[]): Promise<T | T[]> {
|
|
67
|
+
const items = Array.isArray(data) ? data : [data]
|
|
68
|
+
await withStore(dbName, store, 'readwrite', (s) => {
|
|
69
|
+
items.forEach(item => s.add(item))
|
|
70
|
+
})
|
|
71
|
+
return data
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async get<T>(store: string, id: any): Promise<T | undefined> {
|
|
75
|
+
return withStore(dbName, store, 'readonly', s => s.get(id))
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
async all<T>(store: string): Promise<T[]> {
|
|
79
|
+
return withStore(dbName, store, 'readonly', s => s.getAll())
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
async update<T>(store: string, data: T): Promise<T> {
|
|
83
|
+
await withStore(dbName, store, 'readwrite', s => s.put(data))
|
|
84
|
+
return data
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
async delete(store: string, id: any): Promise<void> {
|
|
88
|
+
await withStore(dbName, store, 'readwrite', s => s.delete(id))
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
async clear(store: string): Promise<void> {
|
|
92
|
+
await withStore(dbName, store, 'readwrite', s => s.clear())
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
async count(store: string): Promise<number> {
|
|
96
|
+
return withStore(dbName, store, 'readonly', s => s.count())
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
// Query with options
|
|
100
|
+
async find<T>(store: string, options: {
|
|
101
|
+
index?: string
|
|
102
|
+
value?: any
|
|
103
|
+
limit?: number
|
|
104
|
+
reverse?: boolean
|
|
105
|
+
} = {}): Promise<T[]> {
|
|
106
|
+
const { index, value, limit = 1000, reverse = false } = options
|
|
107
|
+
const db = await openDB(dbName)
|
|
108
|
+
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
if (!db.objectStoreNames.contains(store)) {
|
|
111
|
+
db.close()
|
|
112
|
+
return resolve([])
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const tx = db.transaction(store, 'readonly')
|
|
116
|
+
const s = tx.objectStore(store)
|
|
117
|
+
const source = index ? s.index(index) : s
|
|
118
|
+
const range = value !== undefined ? IDBKeyRange.only(value) : undefined
|
|
119
|
+
const req = source.openCursor(range, reverse ? 'prev' : 'next')
|
|
120
|
+
|
|
121
|
+
const results: T[] = []
|
|
122
|
+
req.onsuccess = () => {
|
|
123
|
+
const cursor = req.result
|
|
124
|
+
if (cursor && results.length < limit) {
|
|
125
|
+
results.push(cursor.value)
|
|
126
|
+
cursor.continue()
|
|
127
|
+
} else {
|
|
128
|
+
db.close()
|
|
129
|
+
resolve(results)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
tx.onerror = () => { db.close(); reject(tx.error) }
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
})
|