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
|
PGPORT=5432
|
||||||
PGDATABASE=brf_books
|
PGDATABASE=brf_books
|
||||||
PGUSER=brf_books
|
PGUSER=brf_books
|
||||||
PGPASSWORD=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 PNPM_HOME="/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
RUN corepack enable pnpm
|
|
||||||
|
|
||||||
ARG UID=1000
|
ARG UID=1000
|
||||||
ARG GID=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
|
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
|
# 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
|
RUN usermod -u $UID -g node node
|
||||||
|
|
||||||
USER node
|
USER node
|
||||||
RUN mkdir /home/node/startbit
|
RUN mkdir /home/node/brf_books
|
||||||
RUN git config --global --add safe.directory /home/node/startbit
|
WORKDIR /home/node/brf_books
|
||||||
WORKDIR /home/node/startbit
|
|
||||||
|
|
||||||
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
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||||
COPY --chown=node \
|
COPY --chown=node vite.config.js .
|
||||||
.env.secrets \
|
|
||||||
vite.config.js ./
|
|
||||||
|
|
||||||
# -------- development -------- #
|
# -------- development -------- #
|
||||||
FROM base AS development
|
FROM base AS development
|
||||||
@ -42,4 +39,4 @@ RUN pnpm run build
|
|||||||
|
|
||||||
COPY --chown=node .git .git
|
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 { source } = await nextLoad(url, { ...context, format })
|
||||||
|
|
||||||
const transformedSource = await esbuild.transform(source, {
|
const transformedSource = await esbuild.transform(source as string, {
|
||||||
loader: 'tsx',
|
loader: 'tsx',
|
||||||
jsx: 'transform',
|
jsx: 'transform',
|
||||||
jsxFactory: 'h',
|
jsxFactory: 'h',
|
||||||
|
|||||||
@ -1,8 +1,39 @@
|
|||||||
volumes:
|
volumes:
|
||||||
|
caddy_config:
|
||||||
|
caddy_data:
|
||||||
|
external: ${EXTERNAL_CADDY_DATA-true}
|
||||||
postgres:
|
postgres:
|
||||||
|
|
||||||
services:
|
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:
|
postgres:
|
||||||
extends:
|
extends:
|
||||||
file: docker-compose.base.yml
|
file: docker-compose.base.yml
|
||||||
service: postgres
|
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
|
const __STATE__: any
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '*.scss'
|
|
||||||
|
|
||||||
declare module '*.module.scss' {
|
|
||||||
const classes: Record<string, string>
|
|
||||||
export default classes
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
auth: string
|
auth: string
|
||||||
|
|||||||
13
package.json
13
package.json
@ -23,10 +23,21 @@
|
|||||||
"types": "tsc"
|
"types": "tsc"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@bmp/console": "^0.1.0",
|
||||||
"@bmp/highlight-stack": "^0.1.2",
|
"@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",
|
"knex": "^3.1.0",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"lowline": "^0.4.2",
|
||||||
"pg": "^8.16.3",
|
"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": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.26.10",
|
"@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
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export default read(['PGDATABASE', 'PGHOST', 'PGPASSWORD', 'PGPORT', 'PGUSER'], {
|
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,
|
PGPASSWORD: null,
|
||||||
PGPORT: null,
|
PGPORT: null,
|
||||||
PGUSER: 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': {
|
case '#TRANS': {
|
||||||
const { objectList, ...transaction } = parseTrans(line)
|
const { objectList, ...transaction } = parseTrans(line)
|
||||||
|
|
||||||
let objectId: number
|
|
||||||
|
|
||||||
const transactionId = (
|
const transactionId = (
|
||||||
await trx('transaction')
|
await trx('transaction')
|
||||||
.insert({
|
.insert({
|
||||||
entryId: currentEntryId,
|
entryId: currentEntryId,
|
||||||
objectId,
|
|
||||||
...transaction,
|
...transaction,
|
||||||
})
|
})
|
||||||
.returning('id')
|
.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,
|
"erasableSyntaxOnly": true,
|
||||||
"allowArbitraryExtensions": true
|
"allowArbitraryExtensions": true
|
||||||
},
|
},
|
||||||
"include": ["./global.d.ts", "./client/", "./server/"]
|
"include": ["global.d.ts", "client", "server"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export default defineConfig(({ isSsrBuild }) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
allowedHosts: ['startbit.local'],
|
allowedHosts: ['brf.local'],
|
||||||
port: 1338,
|
port: 1338,
|
||||||
hmr: process.env.VITE_HMR_PROXY
|
hmr: process.env.VITE_HMR_PROXY
|
||||||
? {
|
? {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user