intial server setup
This commit is contained in:
parent
033262103f
commit
e7f70b9295
@ -1,5 +1,16 @@
|
||||
PGHOST=localhost
|
||||
NODE_ENV=development
|
||||
DOMAIN=bitmill.io
|
||||
PROTOCOL=https
|
||||
HOSTNAME=brf.local
|
||||
PORT=null
|
||||
FASTIFY_HOST=0.0.0.0
|
||||
FASTIFY_PORT=1337
|
||||
LOG_LEVEL=debug
|
||||
LOG_STREAM=console
|
||||
PGHOST=postgres
|
||||
PGPORT=5432
|
||||
PGDATABASE=brf_books
|
||||
PGUSER=brf_books
|
||||
PGPASSWORD=brf_books
|
||||
REDIS_HOST=redis
|
||||
VITE_HMR_PROXY=true
|
||||
|
||||
19
Dockerfile
19
Dockerfile
@ -1,27 +1,24 @@
|
||||
FROM node:23-alpine AS base
|
||||
FROM node:25-alpine AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable pnpm
|
||||
|
||||
ARG UID=1000
|
||||
ARG GID=1000
|
||||
|
||||
RUN apk add --no-cache git shadow
|
||||
RUN apk add --no-cache shadow
|
||||
RUN npm install -g pnpm@^10.23.0
|
||||
|
||||
RUN groupmod -g $GID node
|
||||
# this does not seem to be having full effect. eg /home/node gets 1337:1000 ownership despite group node having id 1337
|
||||
RUN usermod -u $UID -g node node
|
||||
|
||||
USER node
|
||||
RUN mkdir /home/node/startbit
|
||||
RUN git config --global --add safe.directory /home/node/startbit
|
||||
WORKDIR /home/node/startbit
|
||||
RUN mkdir /home/node/brf_books
|
||||
WORKDIR /home/node/brf_books
|
||||
|
||||
COPY --chown=node pnpm-lock.yaml package.json ./
|
||||
COPY --chown=node pnpm-lock.yaml package.json .
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||
COPY --chown=node \
|
||||
.env.secrets \
|
||||
vite.config.js ./
|
||||
COPY --chown=node vite.config.js .
|
||||
|
||||
# -------- development -------- #
|
||||
FROM base AS development
|
||||
@ -42,4 +39,4 @@ RUN pnpm run build
|
||||
|
||||
COPY --chown=node .git .git
|
||||
|
||||
CMD pnpm run start server/index.ts
|
||||
CMD pnpm run start
|
||||
|
||||
6
client/client.d.ts
vendored
Normal file
6
client/client.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
declare module '*.module.scss' {
|
||||
const classNames: Record<string, string>
|
||||
export default classNames
|
||||
}
|
||||
|
||||
declare module '*.scss'
|
||||
8
client/public/client.ts
Normal file
8
client/public/client.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import './styles/main.scss'
|
||||
|
||||
import { h, hydrate } from 'preact'
|
||||
import App from './components/app.tsx'
|
||||
|
||||
const STATE = typeof __STATE__ !== 'undefined' ? __STATE__ : undefined
|
||||
|
||||
hydrate(h(App, STATE), document.body)
|
||||
3
client/public/components/app.module.scss
Normal file
3
client/public/components/app.module.scss
Normal file
@ -0,0 +1,3 @@
|
||||
.title {
|
||||
font-size: 120px;
|
||||
}
|
||||
36
client/public/components/app.tsx
Normal file
36
client/public/components/app.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { h } from 'preact'
|
||||
import { Router } from 'preact-router'
|
||||
import Head from './head.ts'
|
||||
import Footer from './footer.tsx'
|
||||
import Header from './header.tsx'
|
||||
import ErrorPage from './error_page.tsx'
|
||||
import routes from '../routes.ts'
|
||||
|
||||
import s from './app.module.scss'
|
||||
|
||||
export default function App({ error, url, title }) {
|
||||
return (
|
||||
<div id='app' className={s.base}>
|
||||
<Head>
|
||||
<title>{title || 'Untitled'}</title>
|
||||
</Head>
|
||||
|
||||
<Header routes={routes} />
|
||||
|
||||
<main className={s.main}>
|
||||
{error ? (
|
||||
<ErrorPage error={error} />
|
||||
) : (
|
||||
<Router url={url}>
|
||||
{routes.map((route) => (
|
||||
// @ts-ignore
|
||||
<route.component key={route.path} path={route.path} route={route} />
|
||||
))}
|
||||
</Router>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
15
client/public/components/error_page.tsx
Normal file
15
client/public/components/error_page.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { h } from 'preact'
|
||||
import Head from './head.ts'
|
||||
|
||||
const OtherPage = ({ error }) => (
|
||||
<section>
|
||||
<Head>
|
||||
<title> : Error</title>
|
||||
</Head>
|
||||
|
||||
<h1>Oh no!</h1>
|
||||
<pre>{JSON.stringify(error, ['name', ...Object.getOwnPropertyNames(error)], ' ')}</pre>
|
||||
</section>
|
||||
)
|
||||
|
||||
export default OtherPage
|
||||
5
client/public/components/footer.tsx
Normal file
5
client/public/components/footer.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { h } from 'preact'
|
||||
|
||||
const Footer = () => <footer />
|
||||
|
||||
export default Footer
|
||||
172
client/public/components/head.ts
Normal file
172
client/public/components/head.ts
Normal file
@ -0,0 +1,172 @@
|
||||
import { toChildArray, Component } from 'preact'
|
||||
import { mapKeys } from 'lowline'
|
||||
|
||||
const CLASSNAME = '__preact_generated__'
|
||||
|
||||
const DOMAttributeNames = {
|
||||
acceptCharset: 'accept-charset',
|
||||
className: 'class',
|
||||
htmlFor: 'for',
|
||||
httpEquiv: 'http-equiv',
|
||||
}
|
||||
|
||||
const isBrowser = typeof window !== 'undefined'
|
||||
|
||||
let mounted = []
|
||||
|
||||
function reducer(components) {
|
||||
return components
|
||||
.map((c) => toChildArray(c.props.children))
|
||||
.reduce((result, c) => result.concat(c), [])
|
||||
.reverse()
|
||||
.filter(unique())
|
||||
.reverse()
|
||||
.reduce(
|
||||
(result, c) => {
|
||||
if (c.type === 'title') {
|
||||
result.title += toChildArray(c.props.children).join('')
|
||||
} else {
|
||||
result.tags.push({
|
||||
type: c.type,
|
||||
attributes: mapKeys(c.props, (_value, key) => DOMAttributeNames[key] || key),
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
{ title: '', tags: [] },
|
||||
)
|
||||
}
|
||||
|
||||
function updateClient({ title, tags }) {
|
||||
const head = document.head
|
||||
|
||||
const prevElements = Array.from(head.getElementsByClassName(CLASSNAME))
|
||||
|
||||
for (const tag of tags) {
|
||||
const el = createDOMElement(tag)
|
||||
|
||||
const prevIndex = prevElements.findIndex((prevEl) => prevEl.isEqualNode(el))
|
||||
|
||||
if (~prevIndex) {
|
||||
prevElements.splice(prevIndex, 1)
|
||||
} else {
|
||||
head.appendChild(el)
|
||||
}
|
||||
}
|
||||
|
||||
prevElements.forEach((prevEl) => prevEl.remove())
|
||||
|
||||
document.title = title
|
||||
}
|
||||
|
||||
function createDOMElement(tag) {
|
||||
const el = document.createElement(tag.type)
|
||||
const attributes = tag.attributes || {}
|
||||
|
||||
el.setAttribute('class', CLASSNAME)
|
||||
|
||||
for (const p in attributes || {}) {
|
||||
const attribute = DOMAttributeNames[p] || p.toLowerCase()
|
||||
|
||||
el.setAttribute(attribute, attributes[p])
|
||||
}
|
||||
|
||||
return el
|
||||
}
|
||||
|
||||
const METATYPES = ['name', 'httpEquiv', 'charSet', 'itemProp']
|
||||
|
||||
// returns a function for filtering head child elements
|
||||
// which shouldn't be duplicated, like <title/>.
|
||||
function unique() {
|
||||
const tags = []
|
||||
const metaTypes = []
|
||||
const metaCategories = {}
|
||||
|
||||
return (h) => {
|
||||
switch (h.type) {
|
||||
case 'base':
|
||||
if (~tags.indexOf(h.type)) {
|
||||
return false
|
||||
}
|
||||
|
||||
tags.push(h.type)
|
||||
|
||||
break
|
||||
case 'meta':
|
||||
for (let i = 0, len = METATYPES.length; i < len; i++) {
|
||||
const metatype = METATYPES[i]
|
||||
|
||||
if (!(metatype in h)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (metatype === 'charSet') {
|
||||
if (~metaTypes.indexOf(metatype)) {
|
||||
return false
|
||||
}
|
||||
|
||||
metaTypes.push(metatype)
|
||||
} else {
|
||||
const category = h.props[metatype]
|
||||
const categories = metaCategories[metatype] || []
|
||||
|
||||
if (~categories.indexOf(category)) {
|
||||
return false
|
||||
}
|
||||
|
||||
categories.push(category)
|
||||
|
||||
metaCategories[metatype] = categories
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function update() {
|
||||
if (isBrowser) updateClient(reducer(mounted))
|
||||
}
|
||||
|
||||
export default class Head extends Component {
|
||||
static rewind() {
|
||||
const state = reducer(mounted)
|
||||
|
||||
mounted = []
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
static clear() {
|
||||
mounted = []
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
update()
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/no-deprecated
|
||||
componentWillMount() {
|
||||
mounted.push(this)
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const i = mounted.indexOf(this)
|
||||
|
||||
if (~i) {
|
||||
mounted.splice(i, 1)
|
||||
}
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
render() {
|
||||
return null
|
||||
}
|
||||
}
|
||||
18
client/public/components/header.tsx
Normal file
18
client/public/components/header.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { h } from 'preact'
|
||||
|
||||
const Header = ({ routes }) => (
|
||||
<header>
|
||||
<h1>BRF Tegeltrasten</h1>
|
||||
<nav>
|
||||
<ul>
|
||||
{routes.map((route) => (
|
||||
<li key={route.path}>
|
||||
<a href={route.path}>{route.title}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
|
||||
export default Header
|
||||
5
client/public/components/not_found_page.tsx
Normal file
5
client/public/components/not_found_page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { h } from 'preact'
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return <h1>Not Found</h1>
|
||||
}
|
||||
15
client/public/components/other_page.tsx
Normal file
15
client/public/components/other_page.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { h } from 'preact'
|
||||
import Head from './head.ts'
|
||||
|
||||
const OtherPage = () => (
|
||||
<section>
|
||||
<Head>
|
||||
<title> : Other</title>
|
||||
</Head>
|
||||
|
||||
<h1>Other Page</h1>
|
||||
<p>Not the page where it begins</p>
|
||||
</section>
|
||||
)
|
||||
|
||||
export default OtherPage
|
||||
11
client/public/components/start_page.tsx
Normal file
11
client/public/components/start_page.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { h } from 'preact'
|
||||
|
||||
const StartPage = () => (
|
||||
<section>
|
||||
<h1>Fart Page</h1>
|
||||
|
||||
<p>Haha oh my, fart page.</p>
|
||||
</section>
|
||||
)
|
||||
|
||||
export default StartPage
|
||||
17
client/public/routes.ts
Normal file
17
client/public/routes.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import Start from './components/start_page.tsx'
|
||||
import Other from './components/other_page.tsx'
|
||||
|
||||
export default [
|
||||
{
|
||||
path: '/',
|
||||
name: 'start',
|
||||
title: 'Start',
|
||||
component: Start,
|
||||
},
|
||||
{
|
||||
path: '/other',
|
||||
name: 'other',
|
||||
title: 'Other',
|
||||
component: Other,
|
||||
},
|
||||
]
|
||||
22
client/public/server.ts
Normal file
22
client/public/server.ts
Normal file
@ -0,0 +1,22 @@
|
||||
// import { h } from 'preact'
|
||||
// import preactRender from 'preact-render-to-string'
|
||||
// import preactRenderJsx from 'preact-render-to-string/jsx'
|
||||
|
||||
// import App from './components/app.tsx'
|
||||
// import Head from './components/head.ts'
|
||||
|
||||
// export async function render(ctx, pretty) {
|
||||
// const vdom = h(App, ctx)
|
||||
|
||||
// const content = pretty ? preactRenderJsx(vdom, {}, { pretty }) : preactRender(vdom)
|
||||
|
||||
// const head = Head.rewind()
|
||||
|
||||
// return {
|
||||
// head,
|
||||
// content,
|
||||
// state: ctx,
|
||||
// }
|
||||
// }
|
||||
|
||||
export { default as routes } from './routes.ts'
|
||||
62
client/public/shared/components/button_factory.tsx
Normal file
62
client/public/shared/components/button_factory.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { h, type FunctionComponent, type PointerEventHandler } from 'preact'
|
||||
import cn from 'classnames'
|
||||
|
||||
export default function buttonFactory({ defaults, icons, styles }) {
|
||||
const Button: FunctionComponent<{
|
||||
autoHeight?: boolean
|
||||
className?: string
|
||||
color?: string
|
||||
design?: string
|
||||
disabled?: boolean
|
||||
fullWidth?: boolean
|
||||
icon?: string
|
||||
iconSize?: string
|
||||
invert?: boolean
|
||||
onClick?: PointerEventHandler<HTMLButtonElement>
|
||||
size?: string
|
||||
tabIndex?: number
|
||||
tag?: string
|
||||
title?: string
|
||||
type?: 'button' | 'reset' | 'submit'
|
||||
}> = ({
|
||||
autoHeight = defaults?.autoHeight,
|
||||
children,
|
||||
className,
|
||||
color = defaults?.color,
|
||||
design = defaults?.design,
|
||||
disabled,
|
||||
fullWidth = defaults?.fullWidth,
|
||||
icon = defaults?.icon,
|
||||
iconSize,
|
||||
invert = defaults?.invert,
|
||||
onClick,
|
||||
size = defaults?.size,
|
||||
tabIndex,
|
||||
title,
|
||||
type,
|
||||
}) => (
|
||||
<button
|
||||
className={cn(
|
||||
styles.base,
|
||||
design && styles[design],
|
||||
size && styles[size],
|
||||
color && styles[color],
|
||||
autoHeight && styles.autoHeight,
|
||||
fullWidth && styles.fullWidth,
|
||||
invert && styles.invert,
|
||||
className,
|
||||
)}
|
||||
style={iconSize && { '--icon-size': iconSize }}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
tabIndex={tabIndex}
|
||||
title={title}
|
||||
type={type}
|
||||
>
|
||||
{icon && <i className={styles.icon} style={{ maskImage: `url(${icons[icon] || icon})` }} />}
|
||||
{children && <span>{children}</span>}
|
||||
</button>
|
||||
)
|
||||
|
||||
return Button
|
||||
}
|
||||
77
client/public/shared/components/checkbox_factory.tsx
Normal file
77
client/public/shared/components/checkbox_factory.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { h } from 'preact'
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
||||
import cn from 'classnames'
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* base?: string
|
||||
* touched?: string
|
||||
* element?: string
|
||||
* label?: string
|
||||
* }} styles
|
||||
* @returns {import('preact').FunctionComponent<{
|
||||
* autoFocus?: boolean
|
||||
* className?: string
|
||||
* defaultChecked?: boolean
|
||||
* disabled?: boolean
|
||||
* name: string
|
||||
* label?: string
|
||||
* required?: boolean
|
||||
* }>}
|
||||
*/
|
||||
export default function checkboxFactory(styles) {
|
||||
const Checkbox = ({
|
||||
autoFocus,
|
||||
className,
|
||||
defaultChecked,
|
||||
disabled,
|
||||
name,
|
||||
label,
|
||||
onChange,
|
||||
required,
|
||||
style = 'normal',
|
||||
value = 'true',
|
||||
}) => {
|
||||
const [touched, setTouched] = useState(false)
|
||||
const checkboxRef = useRef<HTMLInputElement>()
|
||||
|
||||
const onBlur = useCallback(() => setTouched(true), [])
|
||||
|
||||
useEffect(() => {
|
||||
const form = checkboxRef.current.form
|
||||
const resetTouched = setTouched.bind(null, false)
|
||||
|
||||
form.addEventListener('reset', resetTouched)
|
||||
|
||||
return () => form.removeEventListener('reset', resetTouched)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus) checkboxRef.current.focus()
|
||||
}, [autoFocus])
|
||||
|
||||
return (
|
||||
<label className={cn(styles.base, styles[style], touched && styles.touched, className)}>
|
||||
<input
|
||||
autoFocus={autoFocus}
|
||||
className={styles.element}
|
||||
defaultChecked={defaultChecked}
|
||||
disabled={disabled}
|
||||
name={name}
|
||||
onBlur={onBlur}
|
||||
onChange={onChange}
|
||||
ref={checkboxRef}
|
||||
required={required}
|
||||
type='checkbox'
|
||||
value={value}
|
||||
/>
|
||||
|
||||
<i />
|
||||
|
||||
{label && <span className={styles.label}>{label}</span>}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
return Checkbox
|
||||
}
|
||||
70
client/public/shared/components/image_upload_factory.tsx
Normal file
70
client/public/shared/components/image_upload_factory.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import { h } from 'preact'
|
||||
import { useCallback, useRef, useState } from 'preact/hooks'
|
||||
import cn from 'classnames'
|
||||
|
||||
// TODO when form resets after update the previous image flashes quickly after setImageSrc(null) and before new defaultValue propagates
|
||||
|
||||
const fileUploadFactory = ({ styles }) => {
|
||||
const FileUpload = ({
|
||||
autoFocus,
|
||||
className,
|
||||
defaultValue,
|
||||
disabled,
|
||||
label,
|
||||
name,
|
||||
noMargin,
|
||||
placeholder,
|
||||
required,
|
||||
}) => {
|
||||
const [touched, setTouched] = useState(false)
|
||||
const inputRef = useRef(null)
|
||||
const imgRef = useRef(null)
|
||||
const [imageSrc, setImageSrc] = useState(null)
|
||||
|
||||
const onImageChange = useCallback((e) => {
|
||||
e.preventDefault()
|
||||
|
||||
const form = inputRef.current.form
|
||||
|
||||
const onReset = () => {
|
||||
setTouched(false)
|
||||
setImageSrc(null)
|
||||
}
|
||||
|
||||
form.addEventListener('reset', onReset)
|
||||
|
||||
imgRef.current.addEventListener('load', () => URL.revokeObjectURL(imgRef.current.src), { once: true })
|
||||
setImageSrc(URL.createObjectURL(e.currentTarget.files[0]))
|
||||
|
||||
return () => form.removeEventListerner('reset', onReset)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={cn(styles.base, noMargin && styles.noMargin, touched && styles.touched, className)}>
|
||||
<input
|
||||
type='file'
|
||||
accept='image/*'
|
||||
name={name}
|
||||
ref={inputRef}
|
||||
className={styles.element}
|
||||
onChange={onImageChange}
|
||||
autoFocus={autoFocus}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
defaultValue={defaultValue}
|
||||
/>
|
||||
<label htmlFor={name} className={styles.label}>
|
||||
{label}
|
||||
</label>
|
||||
<figure className={styles.image}>
|
||||
<img className={styles.image} src={imageSrc || (defaultValue && `/uploads/${defaultValue}`)} ref={imgRef} />
|
||||
</figure>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return FileUpload
|
||||
}
|
||||
|
||||
export default fileUploadFactory
|
||||
144
client/public/shared/components/input_factory.tsx
Normal file
144
client/public/shared/components/input_factory.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import { h, type FunctionComponent } from 'preact'
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
||||
import cn from 'classnames'
|
||||
import escapeRegex from '../utils/escape_regex.ts'
|
||||
|
||||
export default function inputFactory(styles: {
|
||||
base?: string
|
||||
touched?: string
|
||||
element?: string
|
||||
label?: string
|
||||
statusIcon?: string
|
||||
noMargin?: string
|
||||
icon?: string
|
||||
labelIcon?: string
|
||||
center?: string
|
||||
}): FunctionComponent<{
|
||||
autoFocus?: boolean
|
||||
autoComplete?: string
|
||||
className?: string
|
||||
defaultValue?: string
|
||||
disabled?: boolean
|
||||
name: string
|
||||
noMargin?: boolean
|
||||
label: string
|
||||
pattern?: string
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
type?: string
|
||||
showValidity?: boolean
|
||||
}> {
|
||||
const Input = ({
|
||||
autoComplete,
|
||||
autoFocus,
|
||||
center,
|
||||
className,
|
||||
classNames,
|
||||
color,
|
||||
defaultValue,
|
||||
design,
|
||||
disabled,
|
||||
icon,
|
||||
label,
|
||||
name,
|
||||
noMargin,
|
||||
pattern,
|
||||
placeholder,
|
||||
required,
|
||||
sameAs,
|
||||
size = 'medium',
|
||||
type = 'text',
|
||||
value,
|
||||
showValidity = true,
|
||||
}) => {
|
||||
const [touched, setTouched] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>()
|
||||
|
||||
const onBlur = useCallback(() => setTouched(true), [])
|
||||
|
||||
useEffect(() => {
|
||||
function onInput(e) {
|
||||
inputRef.current!.pattern = escapeRegex(e.target.value)
|
||||
}
|
||||
|
||||
const form = inputRef.current!.form
|
||||
const resetTouched = setTouched.bind(null, false)
|
||||
|
||||
form.addEventListener('reset', resetTouched)
|
||||
|
||||
let sameAsInput
|
||||
|
||||
if (sameAs) {
|
||||
sameAsInput = form[sameAs]
|
||||
sameAsInput.addEventListener('input', onInput)
|
||||
}
|
||||
|
||||
return () => {
|
||||
form.removeEventListener('reset', resetTouched)
|
||||
if (sameAsInput) sameAsInput.removeEventListener('input', onInput)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus) inputRef.current.focus()
|
||||
}, [autoFocus])
|
||||
|
||||
const id = styles.element + '_' + name
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
styles.base,
|
||||
design && cn(styles[design], classNames?.[design]),
|
||||
color && cn(styles[color], classNames?.[color]),
|
||||
size && cn(styles[size], classNames?.[size]),
|
||||
noMargin && cn(styles.noMargin, classNames?.noMargin),
|
||||
touched && cn(styles.touched, classNames?.touched),
|
||||
center && cn(styles.center, classNames?.center),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon && (styles.icon || classNames?.icon) && (
|
||||
<div className={cn(styles.icon, classNames?.icon)} style={{ maskImage: icon }} />
|
||||
)}
|
||||
|
||||
<input
|
||||
autoComplete={autoComplete}
|
||||
autoFocus={autoFocus}
|
||||
className={cn(styles.element, classNames?.element)}
|
||||
defaultValue={
|
||||
defaultValue && type === 'datetime-local' ? new Date(defaultValue).toLocaleString('sv-SE') : defaultValue
|
||||
}
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
name={name}
|
||||
onBlur={onBlur}
|
||||
pattern={pattern}
|
||||
placeholder={placeholder ?? label}
|
||||
ref={inputRef}
|
||||
required={required}
|
||||
type={type}
|
||||
value={value}
|
||||
/>
|
||||
|
||||
{showValidity && <i className={cn(styles.statusIcon, classNames?.statusIcon)} />}
|
||||
|
||||
{/**
|
||||
* <label> comes after <input> to be able to use next sibling CSS selector
|
||||
* (see public input.module.scss). Use `flexbox` and `order` if <label> needs to be
|
||||
* displayed before <input> (see admin input.module.scss)
|
||||
*/}
|
||||
{label && (
|
||||
<label className={cn(styles.label, classNames?.label)} htmlFor={id}>
|
||||
{icon && (styles.labelIcon || classNames?.labelIcon) && (
|
||||
<div className={cn(styles.labelIcon, classNames?.labelIcon)} style={{ maskImage: icon }} />
|
||||
)}
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return Input
|
||||
}
|
||||
55
client/public/shared/components/link_button_factory.tsx
Normal file
55
client/public/shared/components/link_button_factory.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { h, type FunctionComponent } from 'preact'
|
||||
import cn from 'classnames'
|
||||
|
||||
export default function linkButtonFactory({ defaults, icons, styles }) {
|
||||
const LinkButton: FunctionComponent<{
|
||||
autoHeight?: boolean
|
||||
className?: string
|
||||
color?: string
|
||||
design?: string
|
||||
href?: string
|
||||
fullWidth?: boolean
|
||||
icon?: string
|
||||
iconSize?: string
|
||||
invert?: boolean
|
||||
size?: string
|
||||
tabIndex?: number
|
||||
title?: string
|
||||
}> = ({
|
||||
autoHeight = defaults?.autoHeight,
|
||||
children,
|
||||
className,
|
||||
color = defaults?.color,
|
||||
design = defaults?.design,
|
||||
href,
|
||||
fullWidth = defaults?.fullWidth,
|
||||
icon = defaults?.icon,
|
||||
iconSize,
|
||||
invert = defaults?.invert,
|
||||
size = defaults?.size,
|
||||
tabIndex,
|
||||
title,
|
||||
}) => (
|
||||
<a
|
||||
className={cn(
|
||||
styles.base,
|
||||
design && styles[design],
|
||||
size && styles[size],
|
||||
color && styles[color],
|
||||
autoHeight && styles.autoHeight,
|
||||
fullWidth && styles.fullWidth,
|
||||
invert && styles.invert,
|
||||
className,
|
||||
)}
|
||||
style={iconSize && { '--icon-size': iconSize }}
|
||||
href={href}
|
||||
tabIndex={tabIndex}
|
||||
title={title}
|
||||
>
|
||||
{icon && <i className={styles.icon} style={{ maskImage: `url(${icons[icon] || icon})` }} />}
|
||||
{children && <span>{children}</span>}
|
||||
</a>
|
||||
)
|
||||
|
||||
return LinkButton
|
||||
}
|
||||
32
client/public/shared/components/message_factory.test.tsx
Normal file
32
client/public/shared/components/message_factory.test.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { describe, it, afterEach, type TestContext } from 'node:test'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/preact'
|
||||
|
||||
import messageFactory from './message_factory.tsx'
|
||||
|
||||
import { h } from 'preact'
|
||||
|
||||
const Message = messageFactory({})
|
||||
|
||||
describe('Message', () => {
|
||||
afterEach(cleanup)
|
||||
|
||||
it('should display initial count', (t: TestContext) => {
|
||||
render(<Message>We are great</Message>)
|
||||
|
||||
t.assert.ok(screen.getByText('We are great'))
|
||||
})
|
||||
|
||||
it('should dismiss', async (t: TestContext) => {
|
||||
const dismiss = t.mock.fn()
|
||||
|
||||
render(<Message dismiss={dismiss}>We are great</Message>)
|
||||
|
||||
t.assert.equal(dismiss.mock.callCount(), 0)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
await waitFor(() => {
|
||||
t.assert.equal(dismiss.mock.callCount(), 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
34
client/public/shared/components/message_factory.tsx
Normal file
34
client/public/shared/components/message_factory.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { h, type FunctionComponent } from 'preact'
|
||||
import cn from 'classnames'
|
||||
|
||||
export default function messageFactory(styles: {
|
||||
base?: string
|
||||
text?: string
|
||||
icon?: string
|
||||
button?: string
|
||||
noMargin?: string
|
||||
normal?: string
|
||||
error?: string
|
||||
success?: string
|
||||
}) {
|
||||
const Message: FunctionComponent<{
|
||||
className?: string
|
||||
dismiss?: () => void
|
||||
noMargin?: boolean
|
||||
type?: 'normal' | 'error' | 'success'
|
||||
}> = ({ children, className, dismiss, noMargin, type = 'normal' }) => (
|
||||
<div className={cn(styles.base, styles[type], noMargin && styles.noMargin, className)}>
|
||||
<i className={styles.icon} />
|
||||
|
||||
<div className={styles.text}>{children}</div>
|
||||
|
||||
{dismiss && (
|
||||
<button className={styles.button} onClick={() => dismiss()}>
|
||||
X
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return Message
|
||||
}
|
||||
6
client/public/shared/components/portal.tsx
Normal file
6
client/public/shared/components/portal.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import { createPortal } from 'preact/compat'
|
||||
|
||||
const Portal = ({ children, container = typeof document !== 'undefined' && document.body }) =>
|
||||
createPortal(children, container)
|
||||
|
||||
export default Portal
|
||||
116
client/public/shared/components/select_factory.tsx
Normal file
116
client/public/shared/components/select_factory.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import { h, type FunctionComponent } from 'preact'
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
||||
import cn from 'classnames'
|
||||
import mergeStyles from '../utils/merge_styles.ts'
|
||||
|
||||
// interface Styles {
|
||||
// base?: string
|
||||
// touched?: string
|
||||
// element?: string
|
||||
// label?: string
|
||||
// }
|
||||
|
||||
export default function selectFactory({ styles }): FunctionComponent<{
|
||||
autoFocus?: boolean
|
||||
className?: string
|
||||
defaultValue?: string
|
||||
disabled?: boolean
|
||||
name: string
|
||||
noEmpty?: boolean
|
||||
label: string
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
options: string[] | number[] | { label: string; value: string | number }
|
||||
}> {
|
||||
if (Array.isArray(styles)) {
|
||||
styles = mergeStyles(styles)
|
||||
}
|
||||
|
||||
const Select = ({
|
||||
autoFocus,
|
||||
className,
|
||||
color,
|
||||
defaultValue,
|
||||
design = 'main',
|
||||
disabled,
|
||||
icon,
|
||||
name,
|
||||
label,
|
||||
noEmpty,
|
||||
onChange,
|
||||
placeholder,
|
||||
required,
|
||||
size = 'medium',
|
||||
options,
|
||||
}) => {
|
||||
const [touched, setTouched] = useState(false)
|
||||
const selectRef = useRef<HTMLSelectElement>()
|
||||
|
||||
const onBlur = useCallback(() => setTouched(true), [])
|
||||
|
||||
useEffect(() => {
|
||||
const form = selectRef.current!.form
|
||||
const resetTouched = setTouched.bind(null, false)
|
||||
|
||||
form!.addEventListener('reset', resetTouched)
|
||||
|
||||
return () => form!.removeEventListener('reset', resetTouched)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus) selectRef.current!.focus()
|
||||
}, [autoFocus])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
styles.base,
|
||||
design && styles[design],
|
||||
color && styles[color],
|
||||
size && styles[size],
|
||||
touched && styles.touched,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon && <div className={cn(styles.icon)} style={{ maskImage: icon }} />}
|
||||
|
||||
<select
|
||||
autoFocus={autoFocus}
|
||||
className={styles.element}
|
||||
disabled={disabled}
|
||||
name={name}
|
||||
onBlur={onBlur}
|
||||
onChange={onChange}
|
||||
ref={selectRef}
|
||||
defaultValue={defaultValue}
|
||||
required={required}
|
||||
>
|
||||
{!noEmpty && <option value=''>{placeholder || label}</option>}
|
||||
|
||||
{options &&
|
||||
options.map((option) => (
|
||||
<option
|
||||
key={option.value != null ? option.value : option}
|
||||
value={option.value != null ? option.value : option}
|
||||
>
|
||||
{option.label || option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/**
|
||||
* <label> comes after <input> to be able to use next sibling CSS selector
|
||||
* (see public input.module.scss). Use `flexbox` and `order` if <label> needs to be
|
||||
* displayed before <input> (see admin input.module.scss)
|
||||
*/}
|
||||
{label && (
|
||||
<label className={styles.label} htmlFor={name}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return Select
|
||||
}
|
||||
88
client/public/shared/components/textarea_factory.tsx
Normal file
88
client/public/shared/components/textarea_factory.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { h, type FunctionComponent } from 'preact'
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
||||
import cn from 'classnames'
|
||||
import mergeStyles from '../utils/merge_styles.ts'
|
||||
|
||||
export default function textareaFactory(styles: {
|
||||
base?: string
|
||||
touched?: string
|
||||
element?: string
|
||||
label?: string
|
||||
statusIcon?: string
|
||||
}): FunctionComponent<{
|
||||
autoFocus?: boolean
|
||||
className?: string
|
||||
cols?: number
|
||||
defaultValue?: string
|
||||
disabled?: boolean
|
||||
name: string
|
||||
label: string
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
showValidity?: boolean
|
||||
}> {
|
||||
if (Array.isArray(styles)) {
|
||||
styles = mergeStyles(styles)
|
||||
}
|
||||
|
||||
const Textarea = ({
|
||||
cols,
|
||||
autoFocus,
|
||||
className,
|
||||
defaultValue,
|
||||
disabled,
|
||||
name,
|
||||
label,
|
||||
placeholder,
|
||||
required,
|
||||
rows,
|
||||
showValidity = true,
|
||||
}) => {
|
||||
const [touched, setTouched] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>()
|
||||
|
||||
const onBlur = useCallback(() => setTouched(true), [])
|
||||
|
||||
useEffect(() => {
|
||||
const form = textareaRef.current!.form
|
||||
const resetTouched = setTouched.bind(null, false)
|
||||
|
||||
form.addEventListener('reset', resetTouched)
|
||||
|
||||
return () => form.removeEventListener('reset', resetTouched)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={cn(styles.base, touched && styles.touched, className)}>
|
||||
<textarea
|
||||
autoFocus={autoFocus}
|
||||
className={styles.element}
|
||||
cols={cols}
|
||||
disabled={disabled}
|
||||
name={name}
|
||||
onBlur={onBlur}
|
||||
placeholder={placeholder ?? label}
|
||||
ref={textareaRef}
|
||||
defaultValue={defaultValue}
|
||||
required={required}
|
||||
rows={rows}
|
||||
/>
|
||||
|
||||
{showValidity && <i className={styles.statusIcon} />}
|
||||
|
||||
{/**
|
||||
* <label> comes after <textarea> to be able to use next sibling CSS selector
|
||||
* (see public input.module.scss). Use `flexbox` and `order` if <label> needs to be
|
||||
* displayed before <textarea> (see admin input.module.scss)
|
||||
*/}
|
||||
{label && (
|
||||
<label className={styles.label} htmlFor={name}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return Textarea
|
||||
}
|
||||
58
client/public/shared/hooks/use_request_state.ts
Normal file
58
client/public/shared/hooks/use_request_state.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { useState } from 'preact/hooks'
|
||||
import isLoading from '../utils/is_loading.ts'
|
||||
|
||||
const useRequestState = () => {
|
||||
const initialState = {
|
||||
error: null,
|
||||
pending: false,
|
||||
success: false,
|
||||
response: null,
|
||||
}
|
||||
|
||||
const [state, setState] = useState(initialState)
|
||||
|
||||
const actions = {
|
||||
reset() {
|
||||
isLoading(false)
|
||||
|
||||
setState(initialState)
|
||||
},
|
||||
|
||||
error(error) {
|
||||
isLoading(false)
|
||||
|
||||
setState({
|
||||
pending: false,
|
||||
success: false,
|
||||
error,
|
||||
response: null,
|
||||
})
|
||||
},
|
||||
|
||||
pending() {
|
||||
isLoading(true)
|
||||
|
||||
setState({
|
||||
pending: true,
|
||||
success: false,
|
||||
error: null,
|
||||
response: null,
|
||||
})
|
||||
},
|
||||
|
||||
success(response = null) {
|
||||
isLoading(false)
|
||||
|
||||
setState({
|
||||
pending: false,
|
||||
success: true,
|
||||
error: null,
|
||||
response,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
return [state, actions]
|
||||
}
|
||||
|
||||
export default useRequestState
|
||||
66
client/public/shared/styles/_breakpoints.scss
Normal file
66
client/public/shared/styles/_breakpoints.scss
Normal file
@ -0,0 +1,66 @@
|
||||
// Need to be in pixels for IE8
|
||||
$xsmall-min: 0px;
|
||||
$xsmall-max: 375px;
|
||||
$xsmall-range: $xsmall-min, $xsmall-max;
|
||||
$small-min: 376px;
|
||||
$small-max: 639px;
|
||||
$small-range: $small-min, $small-max;
|
||||
$medium-min: 640px;
|
||||
$medium-max: 1023px;
|
||||
$medium-range: $medium-min, $medium-max;
|
||||
$large-min: 1024px;
|
||||
$large-max: 1439px;
|
||||
$large-range: $large-min, $large-max;
|
||||
$xlarge-min: 1440px;
|
||||
$xlarge-max: 1920px;
|
||||
$xlarge-range: $xlarge-min, $xlarge-max;
|
||||
$xxlarge-min: 1921px;
|
||||
$xxlarge-max: 2650px;
|
||||
$xxlarge-range: $xxlarge-min, $xxlarge-max;
|
||||
$xxxlarge-min: 2651px;
|
||||
$xxxlarge-max: 999999px;
|
||||
$xxxlarge-range: $xxlarge-min, $xxlarge-max;
|
||||
|
||||
// TODO
|
||||
// $content-max-width: 1440px;
|
||||
// $global-gutter: 30px;
|
||||
|
||||
// $large-max: ($xlarge-min - 1px);
|
||||
// $xlarge-min: ($content-max-width + $global-gutter);
|
||||
|
||||
//
|
||||
// Media Queries
|
||||
//
|
||||
|
||||
$screen: 'only screen';
|
||||
$xsmall-only: '#{$screen} and (max-width: #{$xsmall-max})';
|
||||
$small-only: '#{$screen} and (min-width: #{$small-min}) and (max-width: #{$small-max})';
|
||||
$small-down: '#{$screen} and (max-width: #{$small-max})';
|
||||
$small-up: '#{$screen} and (min-width: #{$small-min})';
|
||||
$medium-only: '#{$screen} and (min-width: #{$medium-min}) and (max-width: #{$medium-max})';
|
||||
$medium-up: '#{$screen} and (min-width: #{$medium-min})';
|
||||
$medium-down: '#{$screen} and (max-width: #{$medium-max})';
|
||||
$large-only: '#{$screen} and (min-width: #{$large-min}) and (max-width: #{$large-max})';
|
||||
$large-up: '#{$screen} and (min-width: #{$large-min})';
|
||||
$large-down: '#{$screen} and (max-width: #{$large-max})';
|
||||
$xlarge-only: '#{$screen} and (min-width: #{$xlarge-min}) and (max-width: #{$xlarge-max})';
|
||||
$xlarge-up: '#{$screen} and (min-width: #{$xlarge-min})';
|
||||
$xlarge-down: '#{$screen} and (max-width: #{$xlarge-max})';
|
||||
$xxlarge-only: '#{$screen} and (min-width: #{$xxlarge-min})';
|
||||
$xxlarge-up: '#{$screen} and (min-width: #{$xxlarge-min})';
|
||||
$xxlarge-down: '#{$screen} and (max-width: #{$xxlarge-max})';
|
||||
$xxxlarge-up: '#{$screen} and (min-width: #{$xxxlarge-min})';
|
||||
$landscape: '#{$screen} and (orientation: landscape)';
|
||||
// $portrait: "#{$screen} and (orientation: portrait)";
|
||||
// $small-only: "#{$screen} and (min-width: #{min($small-range...)}) and (max-width: #{max($small-range...)})";
|
||||
// $small-up: "#{$screen} and (min-width: #{min($small-range...)})";
|
||||
// $medium-only: "#{$screen} and (min-width: #{min($medium-range...)}) and (max-width: #{max($medium-range...)})";
|
||||
// $medium-up: "#{$screen} and (min-width: #{min($medium-range...)})";
|
||||
// $medium-down: "#{$screen} and (max-width: #{max($medium-range...)})";
|
||||
// $large-only: "#{$screen} and (min-width: #{min($large-range...)}) and (max-width: #{max($large-range...)})";
|
||||
// $large-up: "#{$screen} and (min-width: #{min($large-range...)})";
|
||||
// $large-down: "#{$screen} and (max-width: #{max($large-range...)})";
|
||||
// $xlarge-only: "only screen and (min-width: #{min($xlarge-range...)})";
|
||||
// $xlarge-up: "#{$screen} and (min-width: #{min($xlarge-range...)})";
|
||||
// $landscape: "#{$screen} and (orientation: landscape)";
|
||||
// $portrait: "#{$screen} and (orientation: portrait)";
|
||||
60
client/public/shared/styles/_flex.scss
Normal file
60
client/public/shared/styles/_flex.scss
Normal file
@ -0,0 +1,60 @@
|
||||
@use 'sass:math';
|
||||
|
||||
$gutter: 30px !default;
|
||||
$max-width: 1000px !default;
|
||||
|
||||
@mixin container($max-width: $max-width, $gutter: $gutter) {
|
||||
width: calc(100% - #{$gutter});
|
||||
max-width: $max-width;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@mixin grid($gutter: $gutter, $direction: row, $justify: flex-start) {
|
||||
display: flex;
|
||||
flex-flow: $direction wrap;
|
||||
justify-content: $justify;
|
||||
|
||||
@if $direction != column {
|
||||
margin-left: math.div(-$gutter, 2);
|
||||
margin-right: math.div(-$gutter, 2);
|
||||
} @else {
|
||||
margin-top: math.div(-$gutter, 2);
|
||||
margin-bottom: math.div(-$gutter, 2);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin cell($count: 5, $gutter: $gutter, $span: 1, $direction: row) {
|
||||
flex: 0 0 0px;
|
||||
@if $span > 1 {
|
||||
flex-basis: calc(((100% - #{$gutter * $count}) / #{$count}) * #{$span} + #{($span - 1) * $gutter});
|
||||
} @else {
|
||||
flex-basis: calc(#{math.div(100%, $count)} - #{$gutter});
|
||||
}
|
||||
|
||||
@if $direction != column {
|
||||
margin-left: math.div($gutter, 2);
|
||||
margin-right: math.div($gutter, 2);
|
||||
} @else {
|
||||
margin-top: math.div($gutter, 2);
|
||||
margin-bottom: math.div($gutter, 2);
|
||||
}
|
||||
}
|
||||
|
||||
// not to be used with grid above
|
||||
@mixin simple-cell($gutter: $gutter, $direction: row) {
|
||||
flex: 1 1 0px;
|
||||
|
||||
@if $direction != column {
|
||||
margin-right: $gutter;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
} @else {
|
||||
margin-bottom: $gutter;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
154
client/public/shared/styles/_reset.scss
Normal file
154
client/public/shared/styles/_reset.scss
Normal file
@ -0,0 +1,154 @@
|
||||
/* http://meyerweb.com/eric/tools/css/reset/
|
||||
v2.0 | 20110126
|
||||
License: none (public domain)
|
||||
*/
|
||||
|
||||
html,
|
||||
body,
|
||||
div,
|
||||
span,
|
||||
applet,
|
||||
object,
|
||||
iframe,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p,
|
||||
blockquote,
|
||||
pre,
|
||||
a,
|
||||
abbr,
|
||||
acronym,
|
||||
address,
|
||||
big,
|
||||
cite,
|
||||
code,
|
||||
del,
|
||||
dfn,
|
||||
em,
|
||||
img,
|
||||
ins,
|
||||
kbd,
|
||||
q,
|
||||
s,
|
||||
samp,
|
||||
small,
|
||||
strike,
|
||||
strong,
|
||||
sub,
|
||||
sup,
|
||||
tt,
|
||||
var,
|
||||
b,
|
||||
u,
|
||||
i,
|
||||
center,
|
||||
dl,
|
||||
dt,
|
||||
dd,
|
||||
ol,
|
||||
ul,
|
||||
li,
|
||||
fieldset,
|
||||
form,
|
||||
label,
|
||||
legend,
|
||||
table,
|
||||
caption,
|
||||
tbody,
|
||||
tfoot,
|
||||
thead,
|
||||
tr,
|
||||
th,
|
||||
td,
|
||||
article,
|
||||
aside,
|
||||
canvas,
|
||||
details,
|
||||
embed,
|
||||
figure,
|
||||
figcaption,
|
||||
footer,
|
||||
header,
|
||||
hgroup,
|
||||
menu,
|
||||
nav,
|
||||
output,
|
||||
ruby,
|
||||
section,
|
||||
summary,
|
||||
time,
|
||||
mark,
|
||||
audio,
|
||||
video {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-size: 100%;
|
||||
font: inherit;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
/* HTML5 display-role reset for older browsers */
|
||||
article,
|
||||
aside,
|
||||
details,
|
||||
figcaption,
|
||||
figure,
|
||||
footer,
|
||||
header,
|
||||
hgroup,
|
||||
main,
|
||||
menu,
|
||||
nav,
|
||||
section {
|
||||
display: block;
|
||||
}
|
||||
|
||||
html {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
blockquote,
|
||||
q {
|
||||
quotes: none;
|
||||
}
|
||||
|
||||
blockquote:before,
|
||||
blockquote:after,
|
||||
q:before,
|
||||
q:after {
|
||||
content: '';
|
||||
content: none;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
caption,
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
q,
|
||||
blockquote {
|
||||
quotes: none;
|
||||
}
|
||||
|
||||
a img {
|
||||
border: none;
|
||||
}
|
||||
|
||||
$tmp: register-module();
|
||||
92
client/public/shared/styles/_units.scss
Normal file
92
client/public/shared/styles/_units.scss
Normal file
@ -0,0 +1,92 @@
|
||||
@use 'sass:list';
|
||||
@use 'sass:math';
|
||||
|
||||
$base-font-size: 16px !default;
|
||||
|
||||
@function strip-unit($num) {
|
||||
@return math.div($num, ($num * 0 + 1));
|
||||
}
|
||||
|
||||
@function convert-to-em($value, $base: $base-font-size) {
|
||||
$value: math.div(strip-unit($value), strip-unit($base)) + 0em;
|
||||
@if ($value == 0em) {
|
||||
$value: 0;
|
||||
}
|
||||
@return $value;
|
||||
}
|
||||
|
||||
@function convert-to-rem($value, $base: $base-font-size) {
|
||||
$value: math.div(strip-unit($value), strip-unit($base)) + 0rem;
|
||||
@if ($value == 0rem) {
|
||||
$value: 0;
|
||||
}
|
||||
@return $value;
|
||||
}
|
||||
|
||||
@function convert-to-px($value, $base: $base-font-size) {
|
||||
$value: strip-unit($value) * strip-unit($base) + 0px;
|
||||
@if ($value == 0px) {
|
||||
$value: 0;
|
||||
}
|
||||
@return $value;
|
||||
}
|
||||
|
||||
@function px($values, $base: $base-font-size) {
|
||||
$max: list.length($values);
|
||||
|
||||
$px-values: ();
|
||||
@for $i from 1 through $max {
|
||||
$value: list.nth($values, $i);
|
||||
@if math.unit($value) != 'px' {
|
||||
$px-values: list.append($px-values, convert-to-px($value, $base));
|
||||
} @else {
|
||||
$px-values: list.append($px-values, $value);
|
||||
}
|
||||
}
|
||||
|
||||
@if $max == 1 {
|
||||
$px-values: list.nth($px-values, 1);
|
||||
}
|
||||
|
||||
@return $px-values;
|
||||
}
|
||||
|
||||
@function rem($values, $base: $base-font-size) {
|
||||
$max: list.length($values);
|
||||
|
||||
$rem-values: ();
|
||||
@for $i from 1 through $max {
|
||||
$value: list.nth($values, $i);
|
||||
@if math.unit($value) != 'rem' and math.unit($value) != 'em' {
|
||||
$rem-values: list.append($rem-values, convert-to-rem($value, $base));
|
||||
} @else {
|
||||
$rem-values: list.append($rem-values, $value);
|
||||
}
|
||||
}
|
||||
|
||||
@if $max == 1 {
|
||||
$rem-values: list.nth($rem-values, 1);
|
||||
}
|
||||
|
||||
@return $rem-values;
|
||||
}
|
||||
|
||||
@function em($values, $base: $base-font-size) {
|
||||
$max: list.length($values);
|
||||
|
||||
$em-values: ();
|
||||
@for $i from 1 through $max {
|
||||
$value: list.nth($values, $i);
|
||||
@if math.unit($value) != 'rem' and math.unit($value) != 'em' {
|
||||
$em-values: list.append($em-values, convert-to-em($value, $base));
|
||||
} @else {
|
||||
$em-values: list.append($em-values, $value);
|
||||
}
|
||||
}
|
||||
|
||||
@if $max == 1 {
|
||||
$em-values: list.nth($em-values, 1);
|
||||
}
|
||||
|
||||
@return $em-values;
|
||||
}
|
||||
74
client/public/shared/styles/_utils.scss
Normal file
74
client/public/shared/styles/_utils.scss
Normal file
@ -0,0 +1,74 @@
|
||||
@use 'sass:math';
|
||||
|
||||
@mixin clear {
|
||||
&:after {
|
||||
content: '';
|
||||
display: block;
|
||||
height: 0;
|
||||
clear: both;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin stretch($top: 0, $right: 0, $bottom: 0, $left: 0) {
|
||||
position: absolute;
|
||||
top: $top;
|
||||
right: $right;
|
||||
bottom: $bottom;
|
||||
left: $left;
|
||||
}
|
||||
|
||||
@mixin ratio($ratio, $initial-width: 100%, $pseudo: 'before') {
|
||||
&:#{$pseudo} {
|
||||
content: '';
|
||||
display: block;
|
||||
width: $initial-width;
|
||||
height: 0;
|
||||
padding-top: math.div(1, $ratio) * $initial-width;
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin full-inline($vertical-align: middle, $pseudo: 'before') {
|
||||
&:#{$pseudo} {
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
height: 100%;
|
||||
content: '';
|
||||
vertical-align: $vertical-align;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin wipe {
|
||||
/*Reset's every elements apperance*/
|
||||
background: none repeat scroll 0 0 transparent;
|
||||
border: medium none;
|
||||
border-spacing: 0;
|
||||
border-radius: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
text-transform: inherit;
|
||||
text-indent: 0;
|
||||
}
|
||||
|
||||
@mixin wipe-button {
|
||||
@include wipe;
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
&::-moz-focus-inner {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin wipe-list {
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
}
|
||||
20
client/public/shared/utils/disable_scroll.ts
Normal file
20
client/public/shared/utils/disable_scroll.ts
Normal file
@ -0,0 +1,20 @@
|
||||
const scrollingElement = typeof window !== 'undefined' && (document.scrollingElement || document.documentElement)
|
||||
|
||||
let scrollTop
|
||||
|
||||
export default {
|
||||
disable() {
|
||||
scrollTop = (document.scrollingElement || document.documentElement).scrollTop
|
||||
|
||||
document.body.style.top = `-${scrollTop}px`
|
||||
document.body.style.position = 'fixed'
|
||||
document.body.style.width = '100%'
|
||||
},
|
||||
|
||||
enable() {
|
||||
document.body.style.position = ''
|
||||
document.body.style.top = ''
|
||||
document.body.style.width = ''
|
||||
scrollingElement.scrollTop = scrollTop
|
||||
},
|
||||
}
|
||||
3
client/public/shared/utils/escape_regex.ts
Normal file
3
client/public/shared/utils/escape_regex.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export default function escapeRegex(string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
3
client/public/shared/utils/is_loading.ts
Normal file
3
client/public/shared/utils/is_loading.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export default (state) => {
|
||||
document.body.classList.toggle('loading', state)
|
||||
}
|
||||
19
client/public/shared/utils/merge_styles.ts
Normal file
19
client/public/shared/utils/merge_styles.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import cn from 'classnames'
|
||||
|
||||
export default function mergeStyles(styles: Record<string, string>[]) {
|
||||
const props = new Set<string>()
|
||||
|
||||
for (const style of styles) {
|
||||
for (const prop of Object.keys(style)) {
|
||||
props.add(prop)
|
||||
}
|
||||
}
|
||||
|
||||
const obj = {}
|
||||
|
||||
for (const prop of props) {
|
||||
obj[prop] = cn(styles.map((style) => style[prop]))
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
115
client/public/shared/utils/serialize_form.test.ts
Normal file
115
client/public/shared/utils/serialize_form.test.ts
Normal file
@ -0,0 +1,115 @@
|
||||
// @ts-nocheck
|
||||
import a from 'node:assert'
|
||||
import test from 'node:test'
|
||||
|
||||
import serializeForm from './serialize_form.ts'
|
||||
|
||||
test('serializeForm', async (t) => {
|
||||
await t.test('single values', () => {
|
||||
const result = serializeForm({
|
||||
elements: [
|
||||
{
|
||||
name: 't1',
|
||||
value: 'v1',
|
||||
},
|
||||
{
|
||||
name: 't2',
|
||||
value: 'v2',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
a.deepStrictEqual(result, {
|
||||
t1: 'v1',
|
||||
t2: 'v2',
|
||||
})
|
||||
})
|
||||
|
||||
await t.test('multiple values', () => {
|
||||
const result = serializeForm({
|
||||
elements: [
|
||||
{
|
||||
name: 't1',
|
||||
value: 'v1',
|
||||
},
|
||||
{
|
||||
name: 't1',
|
||||
value: 'v2',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
a.deepStrictEqual(result, {
|
||||
t1: ['v1', 'v2'],
|
||||
})
|
||||
})
|
||||
|
||||
await t.test('checkbox', () => {
|
||||
const result = serializeForm({
|
||||
elements: [
|
||||
{
|
||||
name: 'c1',
|
||||
type: 'checkbox',
|
||||
checked: false,
|
||||
value: 'v1',
|
||||
},
|
||||
{
|
||||
name: 'c2',
|
||||
type: 'checkbox',
|
||||
checked: true,
|
||||
value: 'v2',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
a.deepStrictEqual(result, {
|
||||
c2: 'v2',
|
||||
})
|
||||
})
|
||||
|
||||
await t.test('multiple checkboxes', () => {
|
||||
const result = serializeForm({
|
||||
elements: [
|
||||
{
|
||||
name: 'c1',
|
||||
type: 'checkbox',
|
||||
checked: false,
|
||||
value: 'v1',
|
||||
},
|
||||
{
|
||||
name: 'c2',
|
||||
type: 'checkbox',
|
||||
checked: true,
|
||||
value: 'v2',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
a.deepStrictEqual(result, {
|
||||
c2: 'v2',
|
||||
})
|
||||
})
|
||||
|
||||
await t.test('radio', () => {
|
||||
const result = serializeForm({
|
||||
elements: [
|
||||
{
|
||||
name: 'r1',
|
||||
type: 'radio',
|
||||
checked: false,
|
||||
value: 'v1',
|
||||
},
|
||||
{
|
||||
name: 'r1',
|
||||
type: 'radio',
|
||||
checked: true,
|
||||
value: 'v2',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
a.deepStrictEqual(result, {
|
||||
r1: 'v2',
|
||||
})
|
||||
})
|
||||
})
|
||||
30
client/public/shared/utils/serialize_form.ts
Normal file
30
client/public/shared/utils/serialize_form.ts
Normal file
@ -0,0 +1,30 @@
|
||||
export default function (form: HTMLFormElement, hook?: (...args: ANY[]) => ANY) {
|
||||
const result = {}
|
||||
|
||||
for (const element of form.elements as unknown as HTMLInputElement[]) {
|
||||
const { checked, name, type, value } = element
|
||||
|
||||
if (
|
||||
(!hook || !hook(result, name, value, element)) &&
|
||||
name &&
|
||||
value &&
|
||||
(checked || (type !== 'checkbox' && type !== 'radio'))
|
||||
) {
|
||||
if (
|
||||
type === 'range' &&
|
||||
((element.dataset.lower && element.min === value) || (!element.dataset.lower && element.max === value))
|
||||
)
|
||||
continue
|
||||
|
||||
if (result[name]) {
|
||||
const arr = Array.isArray(result[name]) ? result[name] : [result[name]]
|
||||
arr.push(value)
|
||||
result[name] = arr
|
||||
} else {
|
||||
result[name] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
5
client/public/styles/main.scss
Normal file
5
client/public/styles/main.scss
Normal file
@ -0,0 +1,5 @@
|
||||
*,
|
||||
*:before,
|
||||
*:after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@ -7,7 +7,7 @@ export const load: LoadHook = async function (url, context, nextLoad) {
|
||||
|
||||
const { source } = await nextLoad(url, { ...context, format })
|
||||
|
||||
const transformedSource = await esbuild.transform(source, {
|
||||
const transformedSource = await esbuild.transform(source as string, {
|
||||
loader: 'tsx',
|
||||
jsx: 'transform',
|
||||
jsxFactory: 'h',
|
||||
|
||||
@ -1,8 +1,39 @@
|
||||
volumes:
|
||||
caddy_config:
|
||||
caddy_data:
|
||||
external: ${EXTERNAL_CADDY_DATA-true}
|
||||
postgres:
|
||||
|
||||
services:
|
||||
node:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: node
|
||||
build:
|
||||
target: development
|
||||
tty: true
|
||||
ports:
|
||||
- 24678:24678
|
||||
volumes:
|
||||
- ./client:/home/node/brf_books/client
|
||||
- ./server:/home/node/brf_books/server
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
env_file:
|
||||
- ./.env.development
|
||||
|
||||
caddy:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: caddy
|
||||
|
||||
postgres:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: postgres
|
||||
|
||||
redis:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: redis
|
||||
|
||||
7
global.d.ts
vendored
7
global.d.ts
vendored
@ -8,13 +8,6 @@ declare global {
|
||||
const __STATE__: any
|
||||
}
|
||||
|
||||
declare module '*.scss'
|
||||
|
||||
declare module '*.module.scss' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
auth: string
|
||||
|
||||
13
package.json
13
package.json
@ -23,10 +23,21 @@
|
||||
"types": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bmp/console": "^0.1.0",
|
||||
"@bmp/highlight-stack": "^0.1.2",
|
||||
"@fastify/middie": "^9.0.3",
|
||||
"@fastify/static": "^8.3.0",
|
||||
"chalk": "^5.6.2",
|
||||
"fastify": "^5.6.2",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"knex": "^3.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lowline": "^0.4.2",
|
||||
"pg": "^8.16.3",
|
||||
"pino-abstract-transport": "^3.0.0"
|
||||
"pg-protocol": "^1.10.3",
|
||||
"pino-abstract-transport": "^3.0.0",
|
||||
"preact": "^10.27.2",
|
||||
"preact-router": "^4.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.10",
|
||||
|
||||
696
pnpm-lock.yaml
generated
696
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -35,8 +35,27 @@ export function read(columns, defaults = {}) {
|
||||
return result
|
||||
}
|
||||
|
||||
export default read(['PGDATABASE', 'PGHOST', 'PGPASSWORD', 'PGPORT', 'PGUSER'], {
|
||||
PGPASSWORD: null,
|
||||
PGPORT: null,
|
||||
PGUSER: null,
|
||||
})
|
||||
export default read(
|
||||
[
|
||||
'DOMAIN',
|
||||
'PROTOCOL',
|
||||
'HOSTNAME',
|
||||
'PORT',
|
||||
'FASTIFY_PORT',
|
||||
'FASTIFY_HOST',
|
||||
'LOG_LEVEL',
|
||||
'LOG_STREAM',
|
||||
'NODE_ENV',
|
||||
'PGDATABASE',
|
||||
'PGHOST',
|
||||
'PGPASSWORD',
|
||||
'PGPORT',
|
||||
'PGUSER',
|
||||
'REDIS_HOST',
|
||||
],
|
||||
{
|
||||
PGPASSWORD: null,
|
||||
PGPORT: null,
|
||||
PGUSER: null,
|
||||
},
|
||||
)
|
||||
|
||||
39
server/handlers/error.ts
Normal file
39
server/handlers/error.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { DatabaseError } from 'pg-protocol'
|
||||
import StatusError from '../lib/status_error.ts'
|
||||
|
||||
const databaseErrorCodes = {
|
||||
23505: 409,
|
||||
23514: 409,
|
||||
}
|
||||
|
||||
function defaultHandler(error, request, reply) {
|
||||
return reply
|
||||
.type('text/html')
|
||||
.send(
|
||||
`<h1>Error</h1><pre>${JSON.stringify(error, ['name', ...Object.getOwnPropertyNames(error)], 2).replaceAll('\\n', '\n')}</pre>`,
|
||||
)
|
||||
}
|
||||
|
||||
export default function ErrorHandler(handler = defaultHandler) {
|
||||
return async function errorHandler(error, request, reply) {
|
||||
if (error instanceof DatabaseError && error.code in databaseErrorCodes) {
|
||||
error = new StatusError(databaseErrorCodes[error.code], error.detail || 'Database Error', { cause: error })
|
||||
}
|
||||
|
||||
reply.log.error({ req: request, err: error }, error?.message)
|
||||
|
||||
reply.status(error.status || error.statusCode || 500)
|
||||
|
||||
if (request.headers.accept?.includes('application/json')) {
|
||||
return reply.send(
|
||||
error.toJSON?.() || {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
status: error.status || error.statusCode || 500,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return handler(error, request, reply)
|
||||
}
|
||||
}
|
||||
18
server/handlers/robots.ts
Normal file
18
server/handlers/robots.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import env from '../env.ts'
|
||||
|
||||
const CONTENT = {
|
||||
development: `
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
`,
|
||||
production: `
|
||||
User-agent: *
|
||||
Disallow: /admin
|
||||
`,
|
||||
}
|
||||
|
||||
const contents = (CONTENT[env.NODE_ENV] || CONTENT.development).trim()
|
||||
|
||||
export default function robots(_request, reply) {
|
||||
return reply.type('text/plain').send(contents)
|
||||
}
|
||||
25
server/index.ts
Normal file
25
server/index.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import './lib/console.ts'
|
||||
import stream from './lib/pino_transport_console.ts'
|
||||
import env from './env.ts'
|
||||
import Server from './server.ts'
|
||||
|
||||
const server = await Server({
|
||||
logger: {
|
||||
level: env.LOG_LEVEL,
|
||||
stream,
|
||||
serializers: stream && {
|
||||
req: (req) => ({
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
headers: req.headers,
|
||||
ip: req.ip,
|
||||
}),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await server.listen({ port: env.FASTIFY_PORT, host: env.FASTIFY_HOST })
|
||||
} catch (e) {
|
||||
server.log.error(e)
|
||||
}
|
||||
19
server/lib/console.ts
Normal file
19
server/lib/console.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import env from '../env.ts'
|
||||
|
||||
if (env.NODE_ENV === 'development') {
|
||||
// output filename in console log and colour console.dir
|
||||
await import('@bmp/console').then((mod) => mod.default({ log: true, error: true, dir: true }))
|
||||
|
||||
const { default: chalk } = await import('chalk')
|
||||
|
||||
process.on('uncaughtException', (err, origin) => {
|
||||
console.error(chalk.red(`UNCAUGHT EXCEPTION`), chalk.yellow(` (${origin})`))
|
||||
console.error(err)
|
||||
})
|
||||
|
||||
process.on('unhandledRejection', (err, promise) => {
|
||||
console.error(chalk.red('UNHANDLED REJECTION'))
|
||||
console.error(promise)
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
79
server/lib/html.ts
Normal file
79
server/lib/html.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { Readable } from 'node:stream'
|
||||
|
||||
type ExpressionPrimitives = string | number | Date | boolean | undefined | null | ExpressionPrimitives[]
|
||||
|
||||
type Expression = ExpressionPrimitives | (() => ExpressionPrimitives) | Promise<ExpressionPrimitives>
|
||||
|
||||
type ParseGenerator = Generator<ExpressionPrimitives, void>
|
||||
|
||||
export interface Template {
|
||||
generator: (minify?: boolean) => ParseGenerator
|
||||
stream: (minify?: boolean) => Readable
|
||||
text: (minify?: boolean) => string | Promise<string>
|
||||
}
|
||||
|
||||
const cache = new WeakMap()
|
||||
|
||||
function apply(expression: Expression) {
|
||||
if (!expression) {
|
||||
return ''
|
||||
} else if ((expression as Promise<ExpressionPrimitives>).then) {
|
||||
return (expression as Promise<ExpressionPrimitives>).then(apply)
|
||||
} else if (typeof expression === 'function') {
|
||||
return apply(expression())
|
||||
} else {
|
||||
return (expression as ExpressionPrimitives[]).join?.('') ?? expression
|
||||
}
|
||||
}
|
||||
|
||||
function* parse(strings: TemplateStringsArray, expressions: Expression[], minify?: boolean): ParseGenerator {
|
||||
if (minify) {
|
||||
let minified = cache.get(strings)
|
||||
|
||||
if (!minified) {
|
||||
minified = strings.map(removeWhitespace)
|
||||
|
||||
cache.set(strings, minified)
|
||||
}
|
||||
|
||||
strings = minified
|
||||
}
|
||||
|
||||
for (let i = 0; i < strings.length; i++) {
|
||||
yield strings[i]
|
||||
|
||||
const expression = expressions[i]
|
||||
|
||||
yield apply(expression)
|
||||
}
|
||||
}
|
||||
|
||||
export default function html(strings: TemplateStringsArray, ...expressions: Expression[]): Template {
|
||||
return {
|
||||
generator: (minify?: boolean) => parse(strings, expressions, minify),
|
||||
stream: (minify?: boolean) => stream(parse(strings, expressions, minify)),
|
||||
text: (minify?: boolean) => text(parse(strings, expressions, minify)),
|
||||
}
|
||||
}
|
||||
|
||||
html.generator = (strings: TemplateStringsArray, ...expressions: Expression[]) => parse(strings, expressions)
|
||||
html.stream = (strings: TemplateStringsArray, ...expressions: Expression[]) => stream(parse(strings, expressions))
|
||||
html.text = (strings: TemplateStringsArray, ...expressions: Expression[]) => text(parse(strings, expressions))
|
||||
|
||||
function stream(generator: ParseGenerator) {
|
||||
return Readable.from(generator)
|
||||
}
|
||||
|
||||
async function text(generator: ParseGenerator) {
|
||||
let result = ''
|
||||
|
||||
for await (const chunk of generator) {
|
||||
result += chunk
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function removeWhitespace(str: string) {
|
||||
return str.split(/\n\s*/).join('')
|
||||
}
|
||||
@ -188,13 +188,10 @@ export default async function parseStream(stream: ReadableStream, decoder: Decod
|
||||
case '#TRANS': {
|
||||
const { objectList, ...transaction } = parseTrans(line)
|
||||
|
||||
let objectId: number
|
||||
|
||||
const transactionId = (
|
||||
await trx('transaction')
|
||||
.insert({
|
||||
entryId: currentEntryId,
|
||||
objectId,
|
||||
...transaction,
|
||||
})
|
||||
.returning('id')
|
||||
|
||||
71
server/lib/pino_transport_console.ts
Normal file
71
server/lib/pino_transport_console.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import util from 'node:util'
|
||||
import chalk from 'chalk'
|
||||
import _ from 'lodash'
|
||||
import highlightStack from '@bmp/highlight-stack'
|
||||
|
||||
const LEVELS = {
|
||||
default: 'USERLVL',
|
||||
60: 'FATAL',
|
||||
50: 'ERROR',
|
||||
40: 'WARN',
|
||||
30: 'INFO',
|
||||
20: 'DEBUG',
|
||||
10: 'TRACE',
|
||||
}
|
||||
|
||||
const COLORS = {
|
||||
60: chalk.bgRed,
|
||||
50: chalk.red,
|
||||
40: chalk.yellow,
|
||||
30: chalk.green,
|
||||
20: chalk.blue,
|
||||
10: chalk.grey,
|
||||
}
|
||||
|
||||
const requests = new Map()
|
||||
|
||||
function colorStatusCode(statusCode) {
|
||||
if (statusCode < 300) {
|
||||
return chalk.bold.green(statusCode)
|
||||
} else if (statusCode < 400) {
|
||||
return chalk.bold.blue(statusCode)
|
||||
} else if (statusCode < 500) {
|
||||
return chalk.bold.yellow(statusCode)
|
||||
} else {
|
||||
return chalk.bold.red(statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
write(line) {
|
||||
const obj = JSON.parse(line)
|
||||
|
||||
if (obj.msg === 'incoming request') {
|
||||
requests.set(obj.reqId, obj.req)
|
||||
} else if (obj.msg === 'request completed') {
|
||||
const req = requests.get(obj.reqId)
|
||||
requests.delete(obj.reqId)
|
||||
process.stdout.write(
|
||||
`${chalk.bold(req.method)} ${req.url} ${colorStatusCode(obj.res.statusCode)} ${obj.responseTime.toFixed(
|
||||
3,
|
||||
)} ms\n`,
|
||||
)
|
||||
} else if (obj.level === 50) {
|
||||
// TODO figure out if there is a way to get the error instances here... console.log(Error) and util.inspect(Error)
|
||||
// looks better than the serialized errors
|
||||
if (obj.err.status < 500) return
|
||||
|
||||
process.stdout.write(
|
||||
`${COLORS[obj.level](LEVELS[obj.level] || LEVELS.default)} ${highlightStack(obj.err.stack)}\n`,
|
||||
)
|
||||
|
||||
const details = _.omit(obj.err, ['type', 'message', 'stack'])
|
||||
|
||||
if (!_.isEmpty(details)) {
|
||||
process.stdout.write(`${chalk.red('Details')} ${util.inspect(details, false, 4, true)}\n`)
|
||||
}
|
||||
} else {
|
||||
process.stdout.write(`${COLORS[obj.level](LEVELS[obj.level] || LEVELS.default)} ${obj.msg}\n`)
|
||||
}
|
||||
},
|
||||
}
|
||||
52
server/plugins/vite.ts
Normal file
52
server/plugins/vite.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { type FastifyPluginCallback } from 'fastify'
|
||||
import _ from 'lodash'
|
||||
import fp from 'fastify-plugin'
|
||||
import { type Template } from '../lib/html.ts'
|
||||
|
||||
export interface Entry {
|
||||
name: string
|
||||
path: string
|
||||
template: (...args: any[]) => Template
|
||||
routes?: ANY[]
|
||||
ssr?: boolean
|
||||
hostname?: string
|
||||
ctx?: Record<string, any>
|
||||
setupRebuildCache?: (buildCache: () => any) => any
|
||||
preHandler?: ANY
|
||||
createPreHandler?: (route: ANY, entry: Entry) => any
|
||||
createErrorHandler?: (handler?: ANY) => ANY
|
||||
}
|
||||
|
||||
export type EntryWithoutName = Omit<Entry, 'name'>
|
||||
|
||||
export interface Config {
|
||||
mode: 'development' | 'production'
|
||||
createPreHandler?: (route: ANY, entry: Entry) => any
|
||||
createErrorHandler?: (handler?: ANY) => ANY
|
||||
entries: Record<string, EntryWithoutName>
|
||||
}
|
||||
|
||||
export interface ParsedConfig {
|
||||
mode: 'development' | 'production'
|
||||
entries: Entry[]
|
||||
}
|
||||
|
||||
const vitePlugin: FastifyPluginCallback<Config> = async function (fastify, config) {
|
||||
const parsedConfig = {
|
||||
mode: config.mode || 'production',
|
||||
entries: _.map(config.entries, (entry, name) => getEntryConfig(name, entry, config)) as Entry[],
|
||||
}
|
||||
|
||||
const setup = (await import(`./vite/${config.mode}.ts`)).default
|
||||
|
||||
await setup(fastify, parsedConfig)
|
||||
}
|
||||
|
||||
function getEntryConfig(name: string, entry: EntryWithoutName, config: Config): Entry {
|
||||
return Object.assign(_.pick(config, ['createErrorHandler', 'createPreHandler', 'ssr', 'template']), entry, { name })
|
||||
}
|
||||
|
||||
export default fp(vitePlugin, {
|
||||
name: 'vite',
|
||||
fastify: '5.x',
|
||||
})
|
||||
130
server/plugins/vite/development.ts
Normal file
130
server/plugins/vite/development.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import { type FastifyInstance } from 'fastify'
|
||||
import fmiddie from '@fastify/middie'
|
||||
import { createServer } from 'vite'
|
||||
import StatusError from '../../lib/status_error.ts'
|
||||
|
||||
import { type Entry, type ParsedConfig } from '../vite.ts'
|
||||
|
||||
export default async function viteDevelopment(fastify: FastifyInstance, config: ParsedConfig) {
|
||||
const devServer = await createServer({
|
||||
server: { middlewareMode: true },
|
||||
appType: 'custom',
|
||||
})
|
||||
|
||||
fastify.decorate('devServer', devServer)
|
||||
fastify.decorateReply('ctx', null)
|
||||
|
||||
await fastify.register(fmiddie)
|
||||
|
||||
fastify.use(devServer.middlewares)
|
||||
|
||||
for (const entry of Object.values(config.entries)) {
|
||||
fastify.register((fastify, _, done) => setupEntry(fastify, entry).then(() => done()), {
|
||||
prefix: entry.path,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function setupEntry(fastify: FastifyInstance, entry: Entry) {
|
||||
const renderer = createRenderer(fastify, entry)
|
||||
const handler = createHandler(renderer)
|
||||
|
||||
if (entry.preHandler || entry.createPreHandler) {
|
||||
fastify.addHook('onRequest', (_request, reply, done) => {
|
||||
reply.ctx = Object.create(null)
|
||||
|
||||
done()
|
||||
})
|
||||
}
|
||||
|
||||
if (entry.preHandler) {
|
||||
fastify.addHook('preHandler', entry.preHandler)
|
||||
}
|
||||
const routes = entry.routes || (await fastify.devServer.ssrLoadModule(`/${entry.name}/server.ts`)).routes
|
||||
|
||||
for (const route of routes.flatMap((route) => route.routes || route)) {
|
||||
// const preHandler = entry.preHandler || entry.createPreHandler?.(route, entry)
|
||||
const preHandler = entry.createPreHandler?.(route, entry)
|
||||
|
||||
if (route.locales?.length) {
|
||||
const locales = [{ hostname: entry.hostname, path: route.path }, ...route.locales]
|
||||
|
||||
for (const locale of locales) {
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: locale.path,
|
||||
onRequest(request, reply, done) {
|
||||
if (request.hostname !== locale.hostname) {
|
||||
// TODO should probably redirect to correct path on request.hostname, not to locale.hostname
|
||||
return reply.redirect('http://' + locale.hostname + request.url)
|
||||
}
|
||||
|
||||
done()
|
||||
},
|
||||
handler,
|
||||
preHandler,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: route.path,
|
||||
handler,
|
||||
preHandler,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fastify.setNotFoundHandler(() => {
|
||||
throw new StatusError(404)
|
||||
})
|
||||
|
||||
fastify.setErrorHandler(
|
||||
entry.createErrorHandler(async (error, request, reply) => {
|
||||
reply.type('text/html').status(error.status || 500)
|
||||
|
||||
return renderer(
|
||||
request.url,
|
||||
Object.assign(
|
||||
{
|
||||
error,
|
||||
},
|
||||
reply.ctx,
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function createRenderer(fastify, entry) {
|
||||
return async (url, ctx) => {
|
||||
ctx = Object.assign({ url }, entry.ctx, ctx)
|
||||
|
||||
const { render } = await fastify.devServer.ssrLoadModule(`/${entry.name}/server.ts`)
|
||||
|
||||
const renderPromise = render && entry.ssr !== false ? render(ctx) : null
|
||||
|
||||
return fastify.devServer.transformIndexHtml(
|
||||
url,
|
||||
await entry
|
||||
.template({
|
||||
env: 'development',
|
||||
preloads: [],
|
||||
script: `/${entry.name}/client.ts`,
|
||||
styles: [],
|
||||
content: renderPromise?.then((result) => result.html),
|
||||
head: renderPromise?.then((result) => result.head),
|
||||
state: renderPromise?.then((result) => result.state) || Promise.resolve(ctx),
|
||||
})
|
||||
.text(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function createHandler(renderer) {
|
||||
return async function handler(request, reply) {
|
||||
reply.type('text/html')
|
||||
|
||||
return renderer(request.url, reply.ctx)
|
||||
}
|
||||
}
|
||||
166
server/plugins/vite/production.ts
Normal file
166
server/plugins/vite/production.ts
Normal file
@ -0,0 +1,166 @@
|
||||
import { type FastifyInstance } from 'fastify'
|
||||
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import fstatic from '@fastify/static'
|
||||
import { resolveConfig } from 'vite'
|
||||
import StatusError from '../../lib/status_error.ts'
|
||||
|
||||
import { type Entry, type ParsedConfig } from '../vite.ts'
|
||||
|
||||
export default async function viteProduction(fastify: FastifyInstance, config: ParsedConfig) {
|
||||
const viteConfig = await resolveConfig({}, 'build', 'production')
|
||||
|
||||
fastify.register(fstatic, {
|
||||
root: viteConfig.build.outDir,
|
||||
wildcard: false,
|
||||
})
|
||||
|
||||
fastify.decorateReply('ctx', null)
|
||||
|
||||
for (const entry of Object.values(config.entries)) {
|
||||
fastify.register((fastify, _, done) => setupEntry(fastify, entry, config, viteConfig).then(() => done()), {
|
||||
prefix: entry.path,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function setupEntry(fastify: FastifyInstance, entry: Entry, config: ParsedConfig, viteConfig) {
|
||||
const viteFilePath = path.join(viteConfig.build.outDir, entry.name + '.js')
|
||||
const viteFile = fs.existsSync(viteFilePath)
|
||||
? await import(viteFilePath)
|
||||
: await import(path.join(viteConfig.root, entry.name, 'server.ts'))
|
||||
const manifestPath = path.join(viteConfig.build.outDir, `manifest.${entry.name}.json`)
|
||||
const manifest = fs.existsSync(manifestPath) ? (await import(manifestPath, { with: { type: 'json' } })).default : null
|
||||
const routes = entry.routes || viteFile.routes
|
||||
|
||||
const cache = new Map()
|
||||
|
||||
const renderer = createRenderer(fastify, entry, viteFile.render, manifest)
|
||||
const cachedHandler = createCachedHandler(cache)
|
||||
const handler = createHandler(renderer)
|
||||
|
||||
if (entry.preHandler || entry.createPreHandler) {
|
||||
fastify.addHook('onRequest', (_request, reply, done) => {
|
||||
reply.ctx = Object.create(null)
|
||||
|
||||
done()
|
||||
})
|
||||
}
|
||||
|
||||
if (entry.preHandler) {
|
||||
fastify.addHook('preHandler', entry.preHandler)
|
||||
}
|
||||
|
||||
for (const route of routes.flatMap((route) => route.routes || route)) {
|
||||
const preHandler = entry.createPreHandler?.(route, entry)
|
||||
|
||||
if (route.locales?.length) {
|
||||
const locales = [{ hostname: entry.hostname, path: route.path }, ...route.locales]
|
||||
|
||||
for (const locale of locales) {
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: locale.path,
|
||||
onRequest(request, reply, done) {
|
||||
if (request.hostname !== locale.hostname) {
|
||||
// TODO should probably redirect to correct path on request.hostname, not to locale.hostname
|
||||
return reply.redirect('http://' + locale.hostname + request.url)
|
||||
}
|
||||
|
||||
done()
|
||||
},
|
||||
handler: route.cache ? cachedHandler : handler,
|
||||
preHandler,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: route.path,
|
||||
handler: route.cache ? cachedHandler : handler,
|
||||
preHandler,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
entry.setupRebuildCache?.(() => buildCache(entry, routes, renderer, cache))
|
||||
|
||||
fastify.setNotFoundHandler(() => {
|
||||
throw new StatusError(404)
|
||||
})
|
||||
|
||||
fastify.setErrorHandler(
|
||||
entry.createErrorHandler(async (error, request, reply) => {
|
||||
reply.type('text/html').status(error.status || 500)
|
||||
|
||||
return renderer(
|
||||
request.url,
|
||||
Object.assign(
|
||||
{
|
||||
error,
|
||||
},
|
||||
reply.ctx,
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function createRenderer(fastify, entry, render, manifest) {
|
||||
const files = manifest[`${entry.name}/client.ts`]
|
||||
console.log(files)
|
||||
const bundle = path.join('/', files.file)
|
||||
const preload = files.imports?.map((name) => path.join('/', manifest[name].file))
|
||||
const css = files.css?.map((name) => path.join('/', name))
|
||||
|
||||
console.log(css)
|
||||
return (url, ctx) => {
|
||||
ctx = Object.assign({ url }, entry.ctx, ctx)
|
||||
|
||||
const renderPromise = render?.(ctx)
|
||||
|
||||
return entry
|
||||
.template({
|
||||
env: 'production',
|
||||
script: bundle,
|
||||
preload,
|
||||
css,
|
||||
content: renderPromise?.then((result) => result.content),
|
||||
head: renderPromise?.then((result) => result.head),
|
||||
state: renderPromise?.then((result) => result.state) || Promise.resolve(ctx),
|
||||
})
|
||||
.stream(true)
|
||||
}
|
||||
}
|
||||
|
||||
function createHandler(renderer) {
|
||||
return async function handler(request, reply) {
|
||||
reply.type('text/html')
|
||||
|
||||
return renderer(request.url, reply.ctx)
|
||||
}
|
||||
}
|
||||
|
||||
function createCachedHandler(cache) {
|
||||
return function cachedHandler(request, reply) {
|
||||
reply.type('text/html')
|
||||
|
||||
return cache.get(request.url)
|
||||
}
|
||||
}
|
||||
|
||||
async function buildCache(entry, routes, renderer, cache) {
|
||||
for (const route of routes) {
|
||||
if (route.cache) {
|
||||
let html = ''
|
||||
|
||||
// TODO run preHandlers
|
||||
for await (const chunk of renderer(route.path)) {
|
||||
html += chunk
|
||||
}
|
||||
|
||||
cache.set(route.path, html)
|
||||
}
|
||||
}
|
||||
}
|
||||
63
server/routes/api.ts
Normal file
63
server/routes/api.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { type FastifyPluginCallback } from 'fastify'
|
||||
import knex from '../lib/knex.ts'
|
||||
|
||||
const apiRoutes: FastifyPluginCallback = (fastify, _, done) => {
|
||||
fastify.route({
|
||||
url: '/result',
|
||||
method: 'GET',
|
||||
async handler(req, res) {
|
||||
const years = await knex('financialYear').select('*')
|
||||
|
||||
return Promise.all(
|
||||
years.map((year) =>
|
||||
knex('account')
|
||||
.select('account.number', 'account.description')
|
||||
.sum('transaction.amount as amount')
|
||||
.innerJoin('transaction', function () {
|
||||
this.on('transaction.accountNumber', '=', 'account.number')
|
||||
})
|
||||
.innerJoin('entry', function () {
|
||||
this.on('transaction.entryId', '=', 'entry.id')
|
||||
})
|
||||
.groupBy('account.number', 'account.description')
|
||||
.where('account.number', '>=', 3000)
|
||||
.where('entry.financialYearId', year.id)
|
||||
.orderBy('account.number')
|
||||
.then((result) => ({
|
||||
startDate: year.startDate,
|
||||
endDate: year.endDate,
|
||||
result,
|
||||
})),
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
fastify.route({
|
||||
url: '/result/:year',
|
||||
method: 'GET',
|
||||
async handler(req, res) {
|
||||
const year = await knex('financialYear').first('*').where('startDate', `${req.params.year}0101`)
|
||||
|
||||
const result = await knex('account')
|
||||
.select('account.number', 'account.description')
|
||||
.sum('transaction.amount as amount')
|
||||
.innerJoin('transaction', function () {
|
||||
this.on('transaction.accountNumber', '=', 'account.number')
|
||||
})
|
||||
.innerJoin('entry', function () {
|
||||
this.on('transaction.entryId', '=', 'entry.id')
|
||||
})
|
||||
.groupBy('account.number', 'account.description')
|
||||
.where('account.number', '>=', 3000)
|
||||
.where('entry.financialYearId', year.id)
|
||||
.orderBy('account.number')
|
||||
|
||||
return result
|
||||
},
|
||||
})
|
||||
|
||||
done()
|
||||
}
|
||||
|
||||
export default apiRoutes
|
||||
32
server/server.ts
Normal file
32
server/server.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import fastify from 'fastify'
|
||||
import StatusError from './lib/status_error.ts'
|
||||
import env from './env.ts'
|
||||
import ErrorHandler from './handlers/error.ts'
|
||||
import vitePlugin from './plugins/vite.ts'
|
||||
import apiRoutes from './routes/api.ts'
|
||||
import templatePublic from './templates/public.ts'
|
||||
|
||||
export default async (options) => {
|
||||
const server = fastify(options)
|
||||
|
||||
server.setNotFoundHandler(() => {
|
||||
throw new StatusError(404)
|
||||
})
|
||||
|
||||
console.dir(env)
|
||||
|
||||
server.register(vitePlugin, {
|
||||
mode: env.NODE_ENV,
|
||||
createErrorHandler: ErrorHandler,
|
||||
entries: {
|
||||
public: {
|
||||
path: '/',
|
||||
template: templatePublic,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
server.register(apiRoutes, { prefix: '/api' })
|
||||
|
||||
return server
|
||||
}
|
||||
28
server/templates/public.ts
Normal file
28
server/templates/public.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import html from '../lib/html.ts'
|
||||
|
||||
export default ({ content, css, head, preload, script, state }) => html`<!DOCTYPE html>
|
||||
<html lang='sv-SE'>
|
||||
<head>
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
<script type="module" src="${script}"></script>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
${css?.map((href) => `<link rel='stylesheet' crossorigin href='${href}'>`)}
|
||||
${preload?.map((href) => `<link rel='modulepreload' crossorigin href='${href}'>`)}
|
||||
<title>${head?.then((head) => head.title)}</title>
|
||||
${head?.then((head) =>
|
||||
head.tags?.map(
|
||||
(tag) =>
|
||||
`<${tag.type} ${Object.entries(tag.attributes)
|
||||
.map(([name, content]) => `${name}='${content}'`)
|
||||
.join(' ')}/>`,
|
||||
),
|
||||
)}
|
||||
</head>
|
||||
<body>
|
||||
${content}
|
||||
${state?.then((state) => `<script>window.__STATE__ = ${JSON.stringify(state)}</script>`)}
|
||||
<script>var offset=window.history.state&&window.history.state.scrollTop||0;if(offset)window.scrollTo(0,offset)</script>
|
||||
</body>
|
||||
</html>`
|
||||
@ -12,5 +12,5 @@
|
||||
"erasableSyntaxOnly": true,
|
||||
"allowArbitraryExtensions": true
|
||||
},
|
||||
"include": ["./global.d.ts", "./client/", "./server/"]
|
||||
"include": ["global.d.ts", "client", "server"]
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ export default defineConfig(({ isSsrBuild }) => {
|
||||
},
|
||||
},
|
||||
server: {
|
||||
allowedHosts: ['startbit.local'],
|
||||
allowedHosts: ['brf.local'],
|
||||
port: 1338,
|
||||
hmr: process.env.VITE_HMR_PROXY
|
||||
? {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user