Initial commit

This commit is contained in:
2026-06-24 23:47:55 -07:00
commit d134b480a0
297 changed files with 30726 additions and 0 deletions
@@ -0,0 +1,37 @@
import { type RouteLocationNormalized } from "vue-router"
import { isEmpty, isNil } from "lodash"
import useCurrentUser, { type UserAsShow } from "@/use/use-current-user"
export async function authorizationGuard(to: RouteLocationNormalized) {
const guardGroups = to.matched
.map((record) => record.meta.guards as ((user: UserAsShow) => boolean)[] | undefined)
.filter((g): g is ((user: UserAsShow) => boolean)[] => !isNil(g) && !isEmpty(g))
if (guardGroups.length === 0) return true
const { currentUser, fetch } = useCurrentUser()
if (isNil(currentUser.value)) {
await fetch()
}
if (isNil(currentUser.value)) {
throw new Error("No current user")
}
// AND across matched records (every level must pass), OR within each
// record's guards array (any guard at that level satisfies it).
for (const guards of guardGroups) {
const passes = guards.some((guard) => {
if (typeof guard !== "function") {
throw new Error(`Guard ${guard} is not a function`)
}
return guard(currentUser.value as UserAsShow)
})
if (!passes) return false
}
return true
}
export default authorizationGuard
@@ -0,0 +1,2 @@
export { authorizationGuard } from "./authorization-guard"
export { isSystemAdmin } from "./user-guards"
@@ -0,0 +1,6 @@
import { UserRoles } from "@/api/users-api"
import { type UserAsShow } from "@/api/current-user-api"
export function isSystemAdmin(user: UserAsShow): boolean {
return user.roles.includes(UserRoles.SYSTEM_ADMIN) ?? false
}
+57
View File
@@ -0,0 +1,57 @@
import { debounce } from "lodash"
type DebounceableFunction = Parameters<typeof debounce>["0"]
/**
* @example
* ```
* const usersApi = {
* async get(userId, params = {}) {
* console.trace(`usersApi.get: Fetching user: ${userId} with params:`, params)
* const { data } = await http.get(`/api/users/${userId}`, { params })
* return data
* },
* }
*
* usersApi.get = debounceWithArgsCache(usersApi.get, 300, 10)
*
* usersApi.get(1, { role: "admin" })
* usersApi.get(2, { role: "user" })
* usersApi.get(1, { role: "admin" })
* setTimeout(() => usersApi.get(1, { role: "admin" }), 400)
* ```
*/
export function debounceWithArgsCache(
fn: DebounceableFunction,
{
wait = 300,
leading = true,
trailing = true,
cacheSize = 10,
}: { wait?: number; leading?: boolean; trailing?: boolean; cacheSize?: number }
) {
const invocationCache = new Map()
return (...args: unknown[]) => {
const argsKey = JSON.stringify(args)
if (invocationCache.has(argsKey)) {
const debouncedFn = invocationCache.get(argsKey)
const result = debouncedFn(...args)
return result
}
if (invocationCache.size >= cacheSize) {
const oldestInvocation = invocationCache.keys().next().value
invocationCache.delete(oldestInvocation)
}
const debouncedFn = debounce(fn, wait, { leading, trailing })
invocationCache.set(argsKey, debouncedFn)
const result = debouncedFn(...args)
return result
}
}
export default debounceWithArgsCache
+13
View File
@@ -0,0 +1,13 @@
export function formatBytes(bytes: number, decimals = 2) {
if (bytes === 0) return "0 Bytes"
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]
}
export default formatBytes
@@ -0,0 +1,21 @@
import { isNil, isEmpty } from "lodash"
import { DateTime } from "luxon"
/**
* Note: \u00A0d is a non-breaking space, and permits date to wrap like this:
* November 07, 72024
* @ 710:21 AM
*/
export function formatDateLong(input: string | Date | undefined): string {
if (isNil(input) || isEmpty(input)) {
return ""
} else if (typeof input == "string") {
return DateTime.fromISO(input).toLocal().toFormat("MMMM\u00A0dd,\u00A0yyyy @\u00A0t")
} else if (input instanceof Date) {
return DateTime.fromJSDate(input).toLocal().toFormat("MMMM\u00A0dd,\u00A0yyyy @\u00A0t")
} else {
return ""
}
}
export default formatDateLong
@@ -0,0 +1,16 @@
import { isNil, isEmpty } from "lodash"
import { DateTime } from "luxon"
export function formatDateWords(input: string | Date | undefined): string {
if (isNil(input) || isEmpty(input)) {
return ""
} else if (typeof input == "string") {
return DateTime.fromISO(input).toLocal().toFormat("MMMM dd, yyyy")
} else if (input instanceof Date) {
return DateTime.fromJSDate(input).toLocal().toFormat("MMMM dd, yyyy")
} else {
return ""
}
}
export default formatDateWords
+18
View File
@@ -0,0 +1,18 @@
import { isNil, isEmpty } from "lodash"
import { DateTime } from "luxon"
export function formatDate(input: string | Date | undefined): string {
if (isNil(input) || isEmpty(input)) {
return ""
} else if (typeof input == "string") {
const parsed = DateTime.fromISO(input, { zone: "utc" })
const isMidnight = parsed.hour === 0 && parsed.minute === 0 && parsed.second === 0
return isMidnight ? parsed.toFormat("yyyy-MM-dd") : parsed.toLocal().toFormat("yyyy-MM-dd")
} else if (input instanceof Date) {
return DateTime.fromJSDate(input).toLocal().toFormat("yyyy-MM-dd")
} else {
return ""
}
}
export default formatDate
@@ -0,0 +1,24 @@
import { isNil } from "lodash"
/**
* Extracts the numeric part of a string and formats it to desired decimal places
*
* Ex. -15.030000000000001 Cycles => -15.03 Cycles
*
* NOTE: number of decimals defaults to 2
*/
export function formatNumberInStringToFixedDecimal(
input: string | null | undefined,
decimals: number = 2
): string {
if (isNil(input) || decimals < 0) {
console.warn(
`formatNumberInStringToFixedDecimal received invalid input: input=${input}, decimals=${decimals}`
)
return ""
}
return input.replace(/([-+]?\d[\d,]*\.?\d*)/, (match) => {
return parseFloat(match.replace(/,/g, "")).toFixed(decimals)
})
}
@@ -0,0 +1,16 @@
import { isNil, isEmpty } from "lodash"
import { DateTime } from "luxon"
export function formatRelative(input: string | Date | undefined): string {
if (isNil(input) || isEmpty(input)) {
return ""
} else if (typeof input == "string") {
return DateTime.fromISO(input).toLocal().toRelative() ?? ""
} else if (input instanceof Date) {
return DateTime.fromJSDate(input).toLocal().toRelative() ?? ""
} else {
return ""
}
}
export default formatRelative
+6
View File
@@ -0,0 +1,6 @@
export { formatBytes } from "./format-bytes"
export { formatDate } from "./format-date"
export { formatDateLong } from "./format-date-long"
export { formatRelative } from "./format-relative"
export { formatDateWords } from "./format-date-words"
+14
View File
@@ -0,0 +1,14 @@
export function safeJsonParse(values: string): unknown | null {
try {
return JSON.parse(values)
} catch (error) {
if (error instanceof SyntaxError) {
return null
} else {
console.warn(`Unexpected json parsing error: ${error}`, { error })
return null
}
}
}
export default safeJsonParse
+3
View File
@@ -0,0 +1,3 @@
export function sleep(milliseconds: number) {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}
+3
View File
@@ -0,0 +1,3 @@
export function stripTrailingSlash(url: string) {
return url.endsWith("/") ? url.slice(0, -1) : url
}
@@ -0,0 +1,22 @@
import { isNil } from "lodash"
export const booleanTransformer = {
get(value: boolean | string | null | undefined): boolean {
if (isNil(value)) return false
if (typeof value === "string") {
return value === "true"
}
return value
},
set(value: boolean | string | null | undefined): string {
if (isNil(value)) return "false"
if (typeof value === "boolean") {
return value.toString()
}
return value
},
}
export default booleanTransformer
@@ -0,0 +1,4 @@
export { booleanTransformer } from "./boolean-transformer"
export { integerTransformer } from "./integer-transformer"
export { integerTransformerLegacy } from "./integer-transformer-legacy"
export { stringTransformer } from "./string-transformer"
@@ -0,0 +1,9 @@
import { isNil } from "lodash"
export function integerTransformerLegacy(value: string | null | undefined): number | null {
if (isNil(value)) return null
return parseInt(value)
}
export default integerTransformerLegacy
@@ -0,0 +1,27 @@
import { isNil } from "lodash"
function integerGet<T extends string | number | null | undefined>(
value: T
): number | (T & undefined) | (T & null) {
if (isNil(value)) return value
if (typeof value === "number") return value
return parseInt(value, 10)
}
function integerSet<T extends number | string | null | undefined>(
value: T
): string | (T & undefined) | (T & null) {
if (isNil(value)) return value
if (typeof value === "number") return value.toString()
if (typeof value === "string") return value
return ""
}
export const integerTransformer = {
get: integerGet,
set: integerSet,
} as const
export default integerTransformer
@@ -0,0 +1,16 @@
import { isNil } from "lodash"
export const stringTransformer = {
get(value: string | null | undefined) {
if (isNil(value)) return ""
return value
},
set(value: string | null | undefined) {
if (isNil(value)) return ""
return value
},
}
export default stringTransformer
+5
View File
@@ -0,0 +1,5 @@
export type VueHtmlClass = string | Record<string, boolean> | (string | Record<string, boolean>)[]
export type SetIntersection<T extends object[]> = {
[K in keyof T[number]]: K extends keyof T[0] ? Extract<T[number][K], T[number][K]> : never
}
+7
View File
@@ -0,0 +1,7 @@
import isEmail from "validator/lib/isEmail"
export function email(value: string) {
if (isEmail(value)) return true
return "Invalid email"
}
+4
View File
@@ -0,0 +1,4 @@
export { email } from "./email"
export { minimum } from "./minimum"
export { required } from "./required"
export { requiredFile } from "./required-file"
+13
View File
@@ -0,0 +1,13 @@
import { isString } from "lodash"
export function minimum(length: number) {
return (v: unknown): boolean | string => {
if (isString(v) && v.length < length) {
return `Must be at least ${length} characters`
}
return true
}
}
export default minimum
+20
View File
@@ -0,0 +1,20 @@
export function requiredFile(v: unknown): boolean | string {
// Handle File object
if (v instanceof File) {
return true
}
// Handle null or undefined
if (v === null || v === undefined) {
return "File is required"
}
// Handle empty array (when file input is cleared)
if (Array.isArray(v) && v.length === 0) {
return "File is required"
}
return "File is required"
}
export default requiredFile
+15
View File
@@ -0,0 +1,15 @@
import { isArray, isEmpty, isString, isNull, isObject, isUndefined } from "lodash"
export function required(v: unknown): boolean | string {
if (isNull(v) || isUndefined(v)) {
return "This field is required"
}
if ((isArray(v) || isString(v) || isObject(v)) && isEmpty(v)) {
return "This field is required"
}
return true
}
export default required