updates
This commit is contained in:
156
web/.gitignore
vendored
Normal file
156
web/.gitignore
vendored
Normal file
@@ -0,0 +1,156 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Production builds
|
||||
dist/
|
||||
build/
|
||||
.next/
|
||||
out/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Dependency directories
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
public
|
||||
|
||||
# Vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# Turbo
|
||||
.turbo
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode/launch.json
|
||||
.vscode/tasks.json
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# SQL dumps
|
||||
*.sql
|
||||
|
||||
# SonarQube
|
||||
.scannerwork/
|
||||
server/.scannerwork/
|
||||
biome-report.json
|
||||
eslint-report.json
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
.history
|
||||
|
||||
bundle-analysis.html
|
||||
142
web/biome.json
Normal file
142
web/biome.json
Normal file
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/node_modules/**",
|
||||
"!**/dist/**",
|
||||
"!**/build/**",
|
||||
"!**/.turbo/**",
|
||||
"!**/coverage/**",
|
||||
"!**/*.min.js",
|
||||
"!**/*.bundle.js",
|
||||
"!**/public/**",
|
||||
"!**/static/**",
|
||||
"!**/migration/**/*.ts"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"formatWithErrors": false,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineEnding": "lf",
|
||||
"lineWidth": 160,
|
||||
"attributePosition": "auto"
|
||||
},
|
||||
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"complexity": {
|
||||
"noExtraBooleanCast": "error",
|
||||
"noUselessCatch": "error",
|
||||
"noUselessTypeConstraint": "error",
|
||||
"noStaticOnlyClass": "off",
|
||||
"noThisInStatic": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noConstAssign": "error",
|
||||
"noConstantCondition": "error",
|
||||
"noEmptyCharacterClassInRegex": "error",
|
||||
"noEmptyPattern": "error",
|
||||
"noGlobalObjectCalls": "error",
|
||||
"noInvalidConstructorSuper": "error",
|
||||
"noNonoctalDecimalEscape": "error",
|
||||
"noPrecisionLoss": "error",
|
||||
"noSelfAssign": "error",
|
||||
"noSetterReturn": "error",
|
||||
"noSwitchDeclarations": "error",
|
||||
"noUndeclaredVariables": "error",
|
||||
"noUnreachable": "error",
|
||||
"noUnreachableSuper": "error",
|
||||
"noUnsafeFinally": "error",
|
||||
"noUnsafeOptionalChaining": "error",
|
||||
"noUnusedLabels": "error",
|
||||
"noUnusedVariables": "error",
|
||||
"useIsNan": "error",
|
||||
"useValidForDirection": "error",
|
||||
"useYield": "error"
|
||||
},
|
||||
"style": {
|
||||
"useConst": "error",
|
||||
"useTemplate": "error",
|
||||
"noNonNullAssertion": "off",
|
||||
"noParameterAssign": "error",
|
||||
"useAsConstAssertion": "error",
|
||||
"useDefaultParameterLast": "error",
|
||||
"useEnumInitializers": "error",
|
||||
"useSelfClosingElements": "error",
|
||||
"useSingleVarDeclarator": "error",
|
||||
"noUnusedTemplateLiteral": "error",
|
||||
"useNumberNamespace": "error",
|
||||
"noInferrableTypes": "error",
|
||||
"noUselessElse": "error"
|
||||
},
|
||||
"suspicious": {
|
||||
"noAsyncPromiseExecutor": "error",
|
||||
"noCatchAssign": "error",
|
||||
"noClassAssign": "error",
|
||||
"noCompareNegZero": "error",
|
||||
"noControlCharactersInRegex": "error",
|
||||
"noDebugger": "error",
|
||||
"noDuplicateCase": "error",
|
||||
"noDuplicateClassMembers": "error",
|
||||
"noDuplicateObjectKeys": "error",
|
||||
"noDuplicateParameters": "error",
|
||||
"noEmptyBlockStatements": "error",
|
||||
"noFallthroughSwitchClause": "error",
|
||||
"noFunctionAssign": "error",
|
||||
"noGlobalAssign": "error",
|
||||
"noImportAssign": "error",
|
||||
"noMisleadingCharacterClass": "error",
|
||||
"noPrototypeBuiltins": "error",
|
||||
"noRedeclare": "error",
|
||||
"noShadowRestrictedNames": "error",
|
||||
"noUnsafeNegation": "error",
|
||||
"noEmptyInterface": "off",
|
||||
"noArrayIndexKey": "off"
|
||||
},
|
||||
"performance": {
|
||||
"noAccumulatingSpread": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"jsxQuoteStyle": "double",
|
||||
"quoteProperties": "asNeeded",
|
||||
"trailingCommas": "all",
|
||||
"semicolons": "always",
|
||||
"arrowParentheses": "always",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"quoteStyle": "double",
|
||||
"attributePosition": "auto"
|
||||
},
|
||||
"parser": {
|
||||
"unsafeParameterDecoratorsEnabled": true
|
||||
}
|
||||
},
|
||||
|
||||
"json": {
|
||||
"parser": {
|
||||
"allowComments": true
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineEnding": "lf",
|
||||
"lineWidth": 80,
|
||||
"trailingCommas": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
32
web/components/package.json
Normal file
32
web/components/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@greatness/components",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@greatness/util": "workspace:*",
|
||||
"@types/node": "24.1.0",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"@carbon/icons-react": "11.63.0",
|
||||
"@radix-ui/react-select": "2.2.5",
|
||||
"lucide-react": "0.525.0",
|
||||
"@hookform/resolvers": "5.2.0",
|
||||
"@floating-ui/react": "0.27.13",
|
||||
"react-jss": "10.10.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-error-boundary": "6.0.0",
|
||||
"react-router-dom": "7.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"tailwind-merge": "3.3.1",
|
||||
"react-loading-skeleton": "3.5.0",
|
||||
"react-hook-form": "7.61.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "30.0.5"
|
||||
}
|
||||
}
|
||||
78
web/components/src/AlertProvider.tsx
Normal file
78
web/components/src/AlertProvider.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { ErrorOutline, CheckmarkFilled } from "@carbon/icons-react";
|
||||
import { noop } from "@greatness/util";
|
||||
|
||||
import { cn } from "@/util/cn";
|
||||
|
||||
import { createContext, useContext, useState } from "react";
|
||||
|
||||
interface IAlert {
|
||||
message?: string;
|
||||
severity?: "success" | "error";
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface IAlertState {
|
||||
addAlert: (alert: IAlert) => void;
|
||||
alerts: IAlert[];
|
||||
}
|
||||
|
||||
const AlertContext = createContext<IAlertState>({
|
||||
addAlert: noop,
|
||||
alerts: [],
|
||||
});
|
||||
|
||||
export const useAlert = (): IAlertState => useContext(AlertContext);
|
||||
|
||||
export default function AlertProvider({
|
||||
children,
|
||||
}: React.PropsWithChildren) {
|
||||
const [alerts, setAlerts] = useState<IAlert[]>([]);
|
||||
|
||||
const dismissAlert = (alertToDismiss: IAlert) =>
|
||||
setAlerts((currentAlerts) =>
|
||||
currentAlerts.filter((currentAlert) => currentAlert !== alertToDismiss),
|
||||
);
|
||||
|
||||
const addAlert = (alert: IAlert) => {
|
||||
setAlerts((prev) => {
|
||||
setTimeout(() => dismissAlert(alert), 10000); // 10 sec alert for now
|
||||
return [...prev, alert];
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertContext.Provider value={{ addAlert, alerts }}>
|
||||
{children}
|
||||
<div className="fixed top-0 right-4 top-4 z-50">
|
||||
{alerts.map((alert, idx) => (
|
||||
<div
|
||||
role="presentation"
|
||||
key={[alert.title, idx].join("-")}
|
||||
onClick={() => dismissAlert(alert)}
|
||||
className={cn("flex items-center justify-between bg-black text-white border-white p-2 cursor-pointer", {
|
||||
"bg-green-500": alert.severity === "success",
|
||||
"bg-red-500": alert.severity === "error",
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-center w-6 h-6">
|
||||
{alert.severity === "error" ? (
|
||||
<ErrorOutline />
|
||||
) : (
|
||||
<CheckmarkFilled />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{alert.title && (
|
||||
<span className="text-xs font-medium">{alert.title}</span>
|
||||
)}
|
||||
<span className="text-xs">{alert.message}</span>
|
||||
</div>
|
||||
{/* <div className="flex items-center justify-center w-6 h-6">
|
||||
<Exit />
|
||||
</div> */}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AlertContext.Provider>
|
||||
);
|
||||
}
|
||||
167
web/components/src/Button.tsx
Normal file
167
web/components/src/Button.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import createUseStyles from "./theme/createUseStyles";
|
||||
|
||||
import { applySpinnerAnimation, spinnerAnimation } from "./loading/Spinner";
|
||||
import { cn } from "./util/cn";
|
||||
|
||||
const useStyles = createUseStyles<{
|
||||
color?: string;
|
||||
disabled: boolean;
|
||||
isLoading: boolean;
|
||||
size: ButtonSize;
|
||||
}>({
|
||||
button: {
|
||||
"&:hover": {
|
||||
backgroundColor: (options) => {
|
||||
if (options.disabled) {
|
||||
return "var(--color-darkGrey)";
|
||||
}
|
||||
switch (options.color) {
|
||||
case "var(--color-white)":
|
||||
return "var(--color-lightGreen)";
|
||||
case "var(--color-grey)":
|
||||
return "var(--color-darkGrey)";
|
||||
case "var(--color-green)":
|
||||
return "var(--color-darkGreen)";
|
||||
case "var(--color-lightRed)":
|
||||
return "var(--color-semiLightRed)";
|
||||
case "var(--color-semiLightRed)":
|
||||
return "var(--color-lightRed)";
|
||||
case "var(--color-black)":
|
||||
return "rgba(255,255,255,0.1)";
|
||||
default:
|
||||
return options.color;
|
||||
}
|
||||
},
|
||||
},
|
||||
backgroundColor: (options) =>
|
||||
options.disabled ? "var(--color-darkGrey)" : options.color,
|
||||
border: (options) => {
|
||||
switch (options.color) {
|
||||
case "var(--color-black)":
|
||||
return "1px solid var(--color-white)";
|
||||
default:
|
||||
return "none";
|
||||
}
|
||||
},
|
||||
borderRadius: 0,
|
||||
cursor: ({ disabled }) => (disabled ? "not-allowed" : "pointer"),
|
||||
padding: ({ size }) => {
|
||||
switch (size) {
|
||||
case ButtonSize.S:
|
||||
return "var(--spacing-xs) var(--spacing-m)";
|
||||
default:
|
||||
case ButtonSize.M:
|
||||
return "var(--spacing-m) var(--spacing-l)";
|
||||
}
|
||||
},
|
||||
},
|
||||
buttonLabel: {
|
||||
color: (options) => {
|
||||
if (options.disabled) {
|
||||
return "var(--color-white)";
|
||||
}
|
||||
switch (options.color) {
|
||||
case "var(--color-white)":
|
||||
return "var(--color-black)";
|
||||
case "var(--color-grey)":
|
||||
return "var(--color-black)";
|
||||
default:
|
||||
return "var(--color-white)";
|
||||
}
|
||||
},
|
||||
fontWeight: 700,
|
||||
},
|
||||
...spinnerAnimation,
|
||||
buttonContent: {
|
||||
display: ({ isLoading }) => (isLoading ? "inline-flex" : "flex"),
|
||||
opacity: ({ isLoading }) => (isLoading ? 0 : 1),
|
||||
},
|
||||
loading: {
|
||||
border: "3px solid var(--color-whiteOpacity)",
|
||||
borderTopColor: "var(--color-white)",
|
||||
height: 25,
|
||||
left: 0,
|
||||
right: 0,
|
||||
width: 25,
|
||||
...applySpinnerAnimation,
|
||||
},
|
||||
});
|
||||
|
||||
export enum ButtonSize {
|
||||
M = "M",
|
||||
S = "S",
|
||||
}
|
||||
|
||||
export default function Button({
|
||||
children,
|
||||
label,
|
||||
className,
|
||||
isDisabled = false,
|
||||
onClick,
|
||||
color = "var(--color-black)",
|
||||
type = "button",
|
||||
isLoading = false,
|
||||
prefix,
|
||||
size = ButtonSize.M,
|
||||
isUppercase = true,
|
||||
...buttonAttributes
|
||||
}: React.PropsWithChildren<{
|
||||
className?: string;
|
||||
color?: string;
|
||||
isDisabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
isUppercase?: boolean;
|
||||
label: React.ReactNode;
|
||||
onClick?: (
|
||||
event: React.MouseEvent<HTMLButtonElement>,
|
||||
) => void | Promise<void>;
|
||||
prefix?: React.ReactNode;
|
||||
size?: ButtonSize;
|
||||
type?: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
|
||||
style?: React.CSSProperties;
|
||||
} & Partial<Pick<React.ButtonHTMLAttributes<HTMLButtonElement>, "style" | "onMouseEnter" | "onMouseLeave">>>) {
|
||||
const classes = useStyles({
|
||||
color,
|
||||
disabled: isDisabled,
|
||||
isLoading,
|
||||
size,
|
||||
});
|
||||
|
||||
const onClickWrapper = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
if (onClick) {
|
||||
void onClick(e);
|
||||
}
|
||||
},
|
||||
[isDisabled, onClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
// eslint-disable-next-line react/button-has-type
|
||||
type={type}
|
||||
className={cn(classes.button, "relative user-select-none word-break-keep-all outline-none", className)}
|
||||
{...(isDisabled && { disabled: isDisabled })}
|
||||
{...(!isDisabled && onClick && { onClick: onClickWrapper })}
|
||||
{...buttonAttributes}
|
||||
>
|
||||
{isLoading && <div className={cn("margin-auto absolute inline-block border-radius-round", classes.loading)} />}
|
||||
<div className={cn("flex-row items-center", classes.buttonContent)}>
|
||||
{prefix}
|
||||
<span
|
||||
className={cn("flex-1 font-size-s font-letterSpacing-m font-lineHeight-l text-center", classes.buttonLabel, {
|
||||
"text-uppercase": isUppercase,
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
124
web/components/src/Collapsible.tsx
Normal file
124
web/components/src/Collapsible.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { TextUtil } from "@greatness/util";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import ComponentErrorBoundary from "./ComponentErrorBoundary";
|
||||
import createUseStyles from "./theme/createUseStyles";
|
||||
import { cn } from "./util/cn";
|
||||
|
||||
const useStyles = createUseStyles({
|
||||
collapsible: {
|
||||
"& h2, & h3, & h4": {
|
||||
margin: 0,
|
||||
},
|
||||
appearance: "none",
|
||||
},
|
||||
});
|
||||
|
||||
class CollapsibleStorage {
|
||||
static set(blockKey: string, isCollapsed: boolean) {
|
||||
const key = CollapsibleStorage.getKey(blockKey);
|
||||
const value = isCollapsed ? "true" : "false";
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
static isCollapsed(blockKey: string) {
|
||||
const storageValue = localStorage.getItem(CollapsibleStorage.getKey(blockKey));
|
||||
if (storageValue === null) {
|
||||
return true;
|
||||
}
|
||||
return storageValue === "true";
|
||||
}
|
||||
|
||||
static getKey(blockKey: string) {
|
||||
return `is-collapsed-${blockKey}`;
|
||||
}
|
||||
}
|
||||
|
||||
const useCollapsible = ({
|
||||
blockKey: blockKeyProp,
|
||||
title,
|
||||
}: {
|
||||
blockKey?: string;
|
||||
title: string | React.ReactNode;
|
||||
}) => {
|
||||
const blockKey = (() => {
|
||||
if (!TextUtil.isEmpty(blockKeyProp)) {
|
||||
return blockKeyProp!;
|
||||
}
|
||||
if (typeof title === "string") {
|
||||
return title;
|
||||
}
|
||||
throw new Error("Invalid block key");
|
||||
})();
|
||||
|
||||
const [isCollapsed, setIsCollapsed] = useState(
|
||||
CollapsibleStorage.isCollapsed(blockKey),
|
||||
);
|
||||
|
||||
const toggleVisible = useCallback(() => {
|
||||
setIsCollapsed((prev) => {
|
||||
CollapsibleStorage.set(blockKey, !prev);
|
||||
return !prev;
|
||||
});
|
||||
}, [blockKey]);
|
||||
|
||||
const onError = useCallback((error: Error) => {
|
||||
console.error("Error in Collapsible", error);
|
||||
CollapsibleStorage.set(blockKey, true);
|
||||
setIsCollapsed(true);
|
||||
}, [blockKey]);
|
||||
|
||||
return {
|
||||
isCollapsed,
|
||||
toggleVisible,
|
||||
onError,
|
||||
};
|
||||
};
|
||||
|
||||
export default function Collapsible({
|
||||
Title = "h4",
|
||||
blockKey: blockKeyProp,
|
||||
children,
|
||||
color,
|
||||
title,
|
||||
onIsCollapsedChange,
|
||||
}: React.PropsWithChildren<{
|
||||
Title?: "h2" | "h3" | "h4";
|
||||
blockKey?: string;
|
||||
color?: string;
|
||||
title: string | React.ReactNode;
|
||||
onIsCollapsedChange?: (isCollapsed: boolean) => void;
|
||||
}>) {
|
||||
const classes = useStyles();
|
||||
|
||||
const { isCollapsed, toggleVisible, onError } = useCollapsible({
|
||||
blockKey: blockKeyProp,
|
||||
title,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (onIsCollapsedChange) {
|
||||
onIsCollapsedChange(isCollapsed);
|
||||
}
|
||||
}, [isCollapsed, onIsCollapsedChange]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("items-center flex gap-m outline-none cursor-pointer w-full py-s px-m my-m bg-greyOpacity border-none", classes.collapsible)}
|
||||
onClick={toggleVisible}
|
||||
style={{
|
||||
...(!TextUtil.isEmpty(color) && {
|
||||
background: color!,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{typeof title === "string" ? <Title>{title}</Title> : title}
|
||||
</button>
|
||||
<ComponentErrorBoundary onError={onError}>
|
||||
{!isCollapsed && children}
|
||||
</ComponentErrorBoundary>
|
||||
</>
|
||||
);
|
||||
};
|
||||
45
web/components/src/ComponentErrorBoundary.tsx
Normal file
45
web/components/src/ComponentErrorBoundary.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { FallbackProps } from "react-error-boundary";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
function ErrorFallback({ error, resetErrorBoundary, onError }: FallbackProps & { onError?: (error: Error) => void }) {
|
||||
const location = useLocation();
|
||||
|
||||
const [initialLocation] = useState(location.pathname);
|
||||
|
||||
useEffect(() => {
|
||||
if (onError) {
|
||||
onError(error);
|
||||
resetErrorBoundary();
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
||||
if (location.pathname !== initialLocation || !error) {
|
||||
resetErrorBoundary();
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error occurred", error);
|
||||
}
|
||||
}, [error, location.pathname, initialLocation, resetErrorBoundary, onError]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-l">
|
||||
<h1>Something went wrong!</h1>
|
||||
<p>Please try again later.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ComponentErrorBoundary({
|
||||
children,
|
||||
onError,
|
||||
}: React.PropsWithChildren<{
|
||||
onError?: (error: Error) => void;
|
||||
}>) {
|
||||
return (
|
||||
<ErrorBoundary FallbackComponent={(props) => <ErrorFallback {...props} onError={onError} />}>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
8
web/components/src/LayoutWrapper.tsx
Normal file
8
web/components/src/LayoutWrapper.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useOutlet } from "react-router-dom";
|
||||
|
||||
import ComponentErrorBoundary from "./ComponentErrorBoundary";
|
||||
|
||||
export default function LayoutWrapper() {
|
||||
const outlet = useOutlet();
|
||||
return <ComponentErrorBoundary>{outlet}</ComponentErrorBoundary>;
|
||||
}
|
||||
47
web/components/src/Modal.tsx
Normal file
47
web/components/src/Modal.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import createUseStyles from "./theme/createUseStyles";
|
||||
import { cn } from "./util/cn";
|
||||
|
||||
const useStyles = createUseStyles(({ media }) => ({
|
||||
modal: {
|
||||
[media.md]: {
|
||||
padding: "var(--spacing-m) var(--spacing-xl)",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export const MODAL_BACKDROP_TIMEOUT = 250;
|
||||
|
||||
export default function Modal({
|
||||
children,
|
||||
handleClose,
|
||||
isOpen,
|
||||
}: React.PropsWithChildren<{
|
||||
handleClose: () => void;
|
||||
isOpen: boolean;
|
||||
}>) {
|
||||
const classes = useStyles();
|
||||
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
ref.current?.showModal();
|
||||
} else {
|
||||
ref.current?.close();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
onCancel={handleClose}
|
||||
className={cn("overflow-auto justify-center items-center border-white bg-black py-xxl px-m shadow-none", classes.modal, {
|
||||
["flex"]: isOpen,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
35
web/components/src/StatusIndicator.tsx
Normal file
35
web/components/src/StatusIndicator.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import createUseStyles from "./theme/createUseStyles";
|
||||
import { cn } from "./util/cn";
|
||||
|
||||
const useStyles = createUseStyles({
|
||||
root: {
|
||||
height: 10,
|
||||
width: 10,
|
||||
},
|
||||
});
|
||||
|
||||
export default function StatusIndicator({
|
||||
active = false,
|
||||
positive = false,
|
||||
intermediary = false,
|
||||
negative = false,
|
||||
}: {
|
||||
active?: boolean;
|
||||
intermediary?: boolean;
|
||||
negative?: boolean;
|
||||
positive?: boolean;
|
||||
}) {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("inline-block cursor-pointer border-radius-round", classes.root, {
|
||||
["bg-grey"]: !active && !positive && !intermediary && !negative,
|
||||
["bg-black"]: active,
|
||||
["bg-green"]: positive,
|
||||
["bg-warningOrange"]: intermediary,
|
||||
["bg-lightRed"]: negative,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
71
web/components/src/Tooltip.tsx
Normal file
71
web/components/src/Tooltip.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
autoPlacement,
|
||||
autoUpdate,
|
||||
offset,
|
||||
shift,
|
||||
useFloating,
|
||||
useHover,
|
||||
useInteractions,
|
||||
} from "@floating-ui/react";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import createUseStyles from "./theme/createUseStyles";
|
||||
import { cn } from "./util/cn";
|
||||
|
||||
const useStyles = createUseStyles({
|
||||
tooltip: {
|
||||
fontWeight: "normal",
|
||||
opacity: 0.9,
|
||||
zIndex: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
export default function Tooltip({
|
||||
children,
|
||||
content,
|
||||
isDisabled = false,
|
||||
offsetDistance = 4,
|
||||
}: React.PropsWithChildren<{
|
||||
content: React.ReactNode;
|
||||
isDisabled?: boolean;
|
||||
offsetDistance?: number;
|
||||
}>) {
|
||||
const classes = useStyles();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
middleware: [offset(offsetDistance), autoPlacement(), shift()],
|
||||
onOpenChange: setIsOpen,
|
||||
open: isOpen && !isDisabled,
|
||||
whileElementsMounted: autoUpdate,
|
||||
});
|
||||
|
||||
const hover = useHover(context);
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([hover]);
|
||||
|
||||
if (isDisabled || content === undefined) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={refs.setReference}
|
||||
{...getReferenceProps()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
className={cn("bg-black border-white border-radius-s text-white font-size-xs font-lineHeight-s py-xxs px-s", classes.tooltip)}
|
||||
{...getFloatingProps()}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
24
web/components/src/confirm/ConfirmContext.ts
Normal file
24
web/components/src/confirm/ConfirmContext.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { noop } from "@greatness/util";
|
||||
import { createContext } from "react";
|
||||
|
||||
export interface IConfirmContextState {
|
||||
show: boolean;
|
||||
text: string | null;
|
||||
}
|
||||
|
||||
interface IConfirmContextActions {
|
||||
closeConfirm: () => void;
|
||||
showConfirm: (text: string) => void;
|
||||
}
|
||||
|
||||
const ConfirmContext = createContext<
|
||||
[IConfirmContextState, IConfirmContextActions]
|
||||
>([
|
||||
{
|
||||
show: false,
|
||||
text: null,
|
||||
},
|
||||
{ closeConfirm: noop, showConfirm: noop },
|
||||
]);
|
||||
|
||||
export default ConfirmContext;
|
||||
67
web/components/src/confirm/ConfirmDialog.tsx
Normal file
67
web/components/src/confirm/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import "react-loading-skeleton/dist/skeleton.css";
|
||||
|
||||
import { TextUtil } from "@greatness/util";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
|
||||
import Button from "../Button";
|
||||
import Modal from "../Modal";
|
||||
import createUseStyles from "../theme/createUseStyles";
|
||||
import { cn } from "../util/cn";
|
||||
|
||||
import useConfirm from "./useConfirm";
|
||||
|
||||
const useStyles = createUseStyles({
|
||||
buttonsWrapper: {
|
||||
"& button": {
|
||||
flex: 1,
|
||||
maxWidth: 125,
|
||||
wordBreak: "keep-all",
|
||||
},
|
||||
},
|
||||
modalButton: {
|
||||
"& > div": {
|
||||
justifyContent: "center",
|
||||
},
|
||||
},
|
||||
modalContent: {
|
||||
width: 350,
|
||||
},
|
||||
});
|
||||
|
||||
export default function ConfirmDialog({
|
||||
confirmText,
|
||||
cancelText,
|
||||
}: {
|
||||
confirmText: string;
|
||||
cancelText: string;
|
||||
}) {
|
||||
const classes = useStyles();
|
||||
const { onCancel, onConfirm, confirmState } = useConfirm();
|
||||
|
||||
return (
|
||||
<Modal isOpen={confirmState.show} handleClose={onCancel}>
|
||||
<div className={cn("flex flex-col", classes.modalContent)}>
|
||||
<h5>
|
||||
{TextUtil.isEmpty(confirmState.text) ? (
|
||||
<Skeleton width={325} height={28} />
|
||||
) : (
|
||||
confirmState.text
|
||||
)}
|
||||
</h5>
|
||||
<div className={cn("flex gap-m justify-center", classes.buttonsWrapper)}>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
label={confirmText}
|
||||
className={classes.modalButton}
|
||||
/>
|
||||
<Button
|
||||
color="var(--color-semiLightRed)"
|
||||
onClick={onCancel}
|
||||
label={cancelText}
|
||||
className={classes.modalButton}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
44
web/components/src/confirm/ConfirmProvider.tsx
Normal file
44
web/components/src/confirm/ConfirmProvider.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useCallback, useMemo, useReducer } from "react";
|
||||
|
||||
import ConfirmContext from "./ConfirmContext";
|
||||
import {
|
||||
HIDE_CONFIRM,
|
||||
initialState,
|
||||
reducer,
|
||||
SHOW_CONFIRM,
|
||||
} from "./ConfirmReducer";
|
||||
|
||||
export default function ConfirmContextProvider({
|
||||
children,
|
||||
}: React.PropsWithChildren) {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
const showConfirm = useCallback((text: string) => {
|
||||
dispatch({
|
||||
payload: {
|
||||
text,
|
||||
},
|
||||
type: SHOW_CONFIRM,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeConfirm = useCallback(() => {
|
||||
dispatch({
|
||||
type: HIDE_CONFIRM,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const actions = useMemo(
|
||||
() => ({
|
||||
closeConfirm,
|
||||
showConfirm,
|
||||
}),
|
||||
[showConfirm, closeConfirm],
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={[state, actions]}>
|
||||
{children}
|
||||
</ConfirmContext.Provider>
|
||||
);
|
||||
}
|
||||
28
web/components/src/confirm/ConfirmReducer.ts
Normal file
28
web/components/src/confirm/ConfirmReducer.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Reducer } from "react";
|
||||
|
||||
import type { IConfirmContextState } from "./ConfirmContext";
|
||||
|
||||
export const SHOW_CONFIRM = "SHOW_CONFIRM";
|
||||
export const HIDE_CONFIRM = "HIDE_CONFIRM";
|
||||
|
||||
export const initialState: IConfirmContextState = {
|
||||
show: false,
|
||||
text: null,
|
||||
};
|
||||
|
||||
export const reducer: Reducer<
|
||||
IConfirmContextState,
|
||||
{ type: "HIDE_CONFIRM" } | { payload: { text: string }; type: "SHOW_CONFIRM" }
|
||||
> = (_, action) => {
|
||||
switch (action.type) {
|
||||
case SHOW_CONFIRM:
|
||||
return {
|
||||
show: true,
|
||||
text: action.payload.text,
|
||||
};
|
||||
case HIDE_CONFIRM:
|
||||
return initialState;
|
||||
default:
|
||||
return initialState;
|
||||
}
|
||||
};
|
||||
38
web/components/src/confirm/useConfirm.ts
Normal file
38
web/components/src/confirm/useConfirm.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useCallback, useContext } from "react";
|
||||
|
||||
import { MODAL_BACKDROP_TIMEOUT } from "../Modal";
|
||||
|
||||
import ConfirmContext from "./ConfirmContext";
|
||||
|
||||
type ResolveCallback = ((value: unknown) => void) | null;
|
||||
|
||||
let resolveCallback: ResolveCallback = null;
|
||||
|
||||
export default function useConfirm() {
|
||||
const [confirmState, { closeConfirm, showConfirm }] =
|
||||
useContext(ConfirmContext);
|
||||
|
||||
const onConfirm = () => {
|
||||
closeConfirm();
|
||||
resolveCallback?.(true);
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
closeConfirm();
|
||||
resolveCallback?.(false);
|
||||
};
|
||||
|
||||
const confirm = useCallback(
|
||||
async (text: string) => {
|
||||
showConfirm(text);
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolveCallback = resolve;
|
||||
}, MODAL_BACKDROP_TIMEOUT);
|
||||
});
|
||||
},
|
||||
[showConfirm],
|
||||
);
|
||||
|
||||
return { confirm, confirmState, onCancel, onConfirm };
|
||||
}
|
||||
76
web/components/src/form/Form.tsx
Normal file
76
web/components/src/form/Form.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { noop } from "@greatness/util";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { useMemo } from "react";
|
||||
import type {
|
||||
DefaultValues,
|
||||
FieldValues,
|
||||
RegisterOptions,
|
||||
UseFormReset,
|
||||
} from "react-hook-form";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export type ResetForm<T extends FieldValues = any> = UseFormReset<T>;
|
||||
export type FormValidation = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
type HandleSubmit<T extends FieldValues> = (values: T) => Promise<void>;
|
||||
|
||||
type FormDefaultValues = Record<
|
||||
string,
|
||||
unknown[] | boolean | number | string | undefined
|
||||
>;
|
||||
export interface IFormProperties<T extends FieldValues = FormDefaultValues> {
|
||||
autocomplete?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
defaultValues?: DefaultValues<T>;
|
||||
handleSubmit?: HandleSubmit<T>;
|
||||
isDisabled?: boolean;
|
||||
validation?: FormValidation;
|
||||
}
|
||||
export interface IFormError {
|
||||
message: string;
|
||||
type: keyof RegisterOptions | "email" | "manual";
|
||||
}
|
||||
|
||||
const Form = <T extends FieldValues>(
|
||||
props: IFormProperties<T>,
|
||||
): React.ReactElement => {
|
||||
const {
|
||||
children,
|
||||
handleSubmit: handleSubmitProp = noop,
|
||||
validation,
|
||||
className,
|
||||
defaultValues,
|
||||
autocomplete,
|
||||
} = props;
|
||||
const methods = useForm<T>({
|
||||
defaultValues,
|
||||
mode: "onBlur",
|
||||
...(validation && {
|
||||
resolver: yupResolver(validation),
|
||||
}),
|
||||
});
|
||||
|
||||
const { handleSubmit } = methods;
|
||||
|
||||
const onSubmit = useMemo(
|
||||
() => handleSubmit(async (values) => handleSubmitProp(values)),
|
||||
[handleSubmit, handleSubmitProp],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
className={className}
|
||||
onSubmit={onSubmit}
|
||||
noValidate
|
||||
autoComplete={autocomplete}
|
||||
>
|
||||
{children}
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Form;
|
||||
82
web/components/src/form/checkbox/CheckboxInput.tsx
Normal file
82
web/components/src/form/checkbox/CheckboxInput.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import createUseStyles from "../../theme/createUseStyles";
|
||||
import { cn } from "../../util/cn";
|
||||
import { Checkmark } from "@carbon/icons-react";
|
||||
|
||||
const useStyles = createUseStyles<{ isChecked?: boolean }>({
|
||||
buttonDisabled: {
|
||||
backgroundColor: ({ isChecked }) => isChecked === true && "var(--color-darkGrey)",
|
||||
},
|
||||
buttonError: {
|
||||
border: "2px solid var(--color-red) !important",
|
||||
},
|
||||
});
|
||||
|
||||
export default function CheckboxInput({
|
||||
children,
|
||||
className,
|
||||
name,
|
||||
label,
|
||||
error = false,
|
||||
}: React.PropsWithChildren<{
|
||||
className?: string;
|
||||
error?: boolean;
|
||||
label?: string | ReactNode;
|
||||
name: string;
|
||||
}>) {
|
||||
const { control, watch } = useFormContext();
|
||||
const isChecked = watch(name) as boolean;
|
||||
const classes = useStyles({ isChecked });
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={(props) => {
|
||||
const {
|
||||
field: { onChange, ...field },
|
||||
} = props;
|
||||
return (
|
||||
<div className={cn("flex", className)}>
|
||||
<input
|
||||
className="hidden absolute"
|
||||
data-testid={`checkbox-input-${name}`}
|
||||
type="text"
|
||||
aria-hidden
|
||||
readOnly
|
||||
{...field}
|
||||
/>
|
||||
{/** biome-ignore lint/a11y/useSemanticElements: <explanation> todo*/}
|
||||
<div
|
||||
className={cn("items-center bg-black border-white cursor-pointer flex justify-center w-l h-l min-w-l min-h-l outline-none", {
|
||||
[classes.buttonError]: error,
|
||||
[cn("border-white cursor-default", classes.buttonDisabled)]: field.disabled,
|
||||
})}
|
||||
onClick={() => {
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
||||
onChange(!field.value);
|
||||
}}
|
||||
onKeyUp={() => {
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
||||
onChange(!field.value);
|
||||
}}
|
||||
data-testid={`checkbox-${name}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{isChecked ? <Checkmark /> : null}
|
||||
</div>
|
||||
<span className="word-break-break-word user-select-none pl-m font-lineHeight-l font-size-s text-white max-w-full">
|
||||
{[null, undefined].includes(label as null)
|
||||
? null
|
||||
: label}
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
73
web/components/src/form/select/SelectInput.tsx
Normal file
73
web/components/src/form/select/SelectInput.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { TextUtil } from "@greatness/util";
|
||||
import { memo, useCallback } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../ui/select";
|
||||
|
||||
import type { SelectInputValue } from "./types";
|
||||
|
||||
const SelectInput: React.FC<{
|
||||
data: SelectInputValue[];
|
||||
isLoading?: boolean;
|
||||
label: string;
|
||||
maxHeight?: number;
|
||||
name: string;
|
||||
popupIcon?: React.ReactNode;
|
||||
// @ts-expect-error check later
|
||||
}> = ({ name, label, data, isLoading = false, maxHeight = 250 }) => {
|
||||
const { setValue, watch, control } = useFormContext();
|
||||
const [currentValue] = watch([name]);
|
||||
|
||||
const onChange = useCallback(
|
||||
(newInputValue: string) => {
|
||||
setValue(name, newInputValue, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
},
|
||||
[name, setValue],
|
||||
);
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={name}
|
||||
render={() => {
|
||||
return (
|
||||
<Select
|
||||
value={currentValue}
|
||||
onValueChange={(newInputValue) => {
|
||||
onChange(newInputValue);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={TextUtil.isEmpty(label) ? "" : label}
|
||||
>
|
||||
{/** biome-ignore lint/suspicious/noDoubleEquals: <explanation> todo*/}
|
||||
{data.find((a) => a.value == currentValue)?.label ?? ""}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{data
|
||||
.filter((a) => a.value !== "")
|
||||
.map((a) => (
|
||||
<SelectItem key={a.value as string} value={a.value as string}>
|
||||
{a.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(SelectInput);
|
||||
4
web/components/src/form/select/types.ts
Normal file
4
web/components/src/form/select/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface SelectInputValue {
|
||||
label: string;
|
||||
value: string | number | boolean;
|
||||
}
|
||||
84
web/components/src/form/text/TextInput.tsx
Normal file
84
web/components/src/form/text/TextInput.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { TextUtil } from "@greatness/util";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import createUseStyles from "../../theme/createUseStyles";
|
||||
import { cn } from "../../util/cn";
|
||||
|
||||
const useStyles = createUseStyles({
|
||||
textField: {
|
||||
"& > input": {
|
||||
"-moz-box-shadow": "none",
|
||||
"-webkit-box-shadow": "none",
|
||||
appearance: "none",
|
||||
backgroundImage: "none",
|
||||
boxShadow: "none",
|
||||
minHeight: 40,
|
||||
},
|
||||
"& > label": {
|
||||
left: 10,
|
||||
top: -5,
|
||||
whiteSpace: "nowrap",
|
||||
width: "min-content",
|
||||
},
|
||||
minHeight: 42,
|
||||
},
|
||||
});
|
||||
|
||||
export default function TextInput({
|
||||
className,
|
||||
label,
|
||||
name,
|
||||
type = "text",
|
||||
isDisabled = false,
|
||||
autoComplete,
|
||||
}: {
|
||||
autoComplete?: string;
|
||||
className?: string;
|
||||
isDisabled?: boolean;
|
||||
label?: string;
|
||||
name: string;
|
||||
type?: "text" | "password" | "email" | "textarea";
|
||||
}) {
|
||||
const classes = useStyles();
|
||||
const {
|
||||
watch,
|
||||
control,
|
||||
} = useFormContext();
|
||||
const currentValue = watch(name);
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
defaultValue={currentValue ?? ""}
|
||||
render={({ field }) => (
|
||||
<div className={cn("flex flex-col flex-1 relative justify-center", classes.textField, className)}>
|
||||
{type === "textarea" ? (
|
||||
<textarea
|
||||
id={name}
|
||||
{...field}
|
||||
disabled={isDisabled}
|
||||
className="border-white text-white outline-none py-xs px-s bg-black"
|
||||
rows={4}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={type}
|
||||
id={name}
|
||||
{...field}
|
||||
disabled={isDisabled}
|
||||
autoComplete={autoComplete}
|
||||
{...(name === "newPassword" && {
|
||||
autoComplete: "new-password",
|
||||
})}
|
||||
className="border-white text-white outline-none py-xs px-s bg-black"
|
||||
/>
|
||||
)}
|
||||
<label htmlFor={name} className="absolute word-break-keep-all bg-black font-size-xxs py-0 px-s text-white">
|
||||
{TextUtil.isEmpty(label) ? "" : label}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
0
web/components/src/index.ts
Normal file
0
web/components/src/index.ts
Normal file
112
web/components/src/loading/LoadingPage.tsx
Normal file
112
web/components/src/loading/LoadingPage.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { TextUtil } from "@greatness/util";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import createUseStyles from "../theme/createUseStyles";
|
||||
|
||||
import Spinner from "./Spinner";
|
||||
import { cn } from "../util/cn";
|
||||
|
||||
const useStyles = createUseStyles<{ containerHeight?: number | string }>({
|
||||
container: {
|
||||
height: ({ containerHeight }) =>
|
||||
containerHeight !== undefined
|
||||
? typeof containerHeight === "number"
|
||||
? `${containerHeight}px`
|
||||
: containerHeight
|
||||
: "100%",
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type ScrollIntoViewRef = React.RefObject<HTMLDivElement | null> | null;
|
||||
type ScrollIntoViewOptions = {
|
||||
behavior?: ScrollBehavior;
|
||||
block?: ScrollLogicalPosition;
|
||||
inline?: ScrollLogicalPosition;
|
||||
narrow?: boolean;
|
||||
} | null;
|
||||
type ScrollIntoViewFunction = (
|
||||
ref?: ScrollIntoViewRef,
|
||||
options?: ScrollIntoViewOptions,
|
||||
) => void;
|
||||
|
||||
const scrollIntoView: ScrollIntoViewFunction = (ref, options) => {
|
||||
if (!ref?.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
let scrollOptions = options;
|
||||
const isWide = window.innerWidth > 768;
|
||||
if (!scrollOptions || (options?.narrow === true && isWide)) {
|
||||
scrollOptions = {
|
||||
behavior: "smooth",
|
||||
};
|
||||
if (isWide) {
|
||||
scrollOptions.block = "center";
|
||||
}
|
||||
}
|
||||
ref.current.scrollIntoView(scrollOptions);
|
||||
};
|
||||
|
||||
export default function LoadingPage({
|
||||
className,
|
||||
isLoading = true,
|
||||
children,
|
||||
containerHeight,
|
||||
scroll,
|
||||
persist = false,
|
||||
label,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
containerHeight?: number | string;
|
||||
isLoading?: boolean;
|
||||
label?: string;
|
||||
persist?: boolean;
|
||||
scroll?: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const classes = useStyles({ containerHeight });
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
if (scroll === true) {
|
||||
scrollIntoView(ref);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isLoading, scroll]);
|
||||
|
||||
const isAnimationFinished = !isLoading;
|
||||
if (isAnimationFinished && !persist) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center bg-white w-auto p-xs",
|
||||
classes.container,
|
||||
className,
|
||||
{
|
||||
["hidden"]: isAnimationFinished,
|
||||
}
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
<Spinner />
|
||||
{!TextUtil.isEmpty(label) && (
|
||||
<div className="pb-m pt-0 text-center word-break-break-word">
|
||||
<h4>{label!}</h4>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{persist && (
|
||||
<div className={isAnimationFinished ? "persist" : "hidden"}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
web/components/src/loading/Spinner.tsx
Normal file
44
web/components/src/loading/Spinner.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { cn } from "../util/cn";
|
||||
import createUseStyles from "../theme/createUseStyles";
|
||||
|
||||
export const spinnerAnimation = {
|
||||
"@keyframes spinner, @-webkit-keyframes spinner": {
|
||||
"0%": {
|
||||
"-webkit-transform": "rotate(0deg)",
|
||||
transform: "rotate(0deg)",
|
||||
},
|
||||
"100%": {
|
||||
"-webkit-transform": "rotate(360deg)",
|
||||
transform: "rotate(360deg)",
|
||||
},
|
||||
},
|
||||
};
|
||||
export const applySpinnerAnimation = {
|
||||
"-webkit-animation": "$spinner 1.1s ease-in-out infinite",
|
||||
animation: "$spinner 1.1s ease-in-out infinite",
|
||||
};
|
||||
|
||||
const useStyles = createUseStyles({
|
||||
...spinnerAnimation,
|
||||
loader: {
|
||||
MsTransform: "translateZ(0)",
|
||||
WebkitTransform: "translateZ(0)",
|
||||
border: "var(--spacing-s) solid var(--color-white)",
|
||||
borderLeftColor: "var(--color-darkGrey)",
|
||||
margin: "var(--spacing-xl) auto",
|
||||
position: "relative",
|
||||
textIndent: "-9999em",
|
||||
transform: "translateZ(0)",
|
||||
...applySpinnerAnimation,
|
||||
"&, &:after": {
|
||||
borderRadius: "50%",
|
||||
height: 80,
|
||||
width: 80,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default function Spinner() {
|
||||
const classes = useStyles();
|
||||
return <div className={cn("font-size-xxxs", classes.loader)} />;
|
||||
}
|
||||
3
web/components/src/table/TableBody.tsx
Normal file
3
web/components/src/table/TableBody.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function TableBody({ children }: React.PropsWithChildren) {
|
||||
return <div className="flex-1 flex flex-col">{children}</div>;
|
||||
};
|
||||
20
web/components/src/table/TableHead.tsx
Normal file
20
web/components/src/table/TableHead.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import createUseStyles from "../theme/createUseStyles";
|
||||
import { cn } from "../util/cn";
|
||||
|
||||
const useStyles = createUseStyles({
|
||||
tableHead: {
|
||||
"& > div": {
|
||||
"&:hover": {
|
||||
backgroundColor: "inherit !important",
|
||||
cursor: "initial !important",
|
||||
},
|
||||
borderBottom: "1px solid var(--color-grey)",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default function TableHead({ children }: React.PropsWithChildren) {
|
||||
const classes = useStyles();
|
||||
|
||||
return <div className={cn("flex-1 flex", classes.tableHead)}>{children}</div>;
|
||||
}
|
||||
55
web/components/src/table/TableRow.tsx
Normal file
55
web/components/src/table/TableRow.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import createUseStyles from "../theme/createUseStyles";
|
||||
import { cn } from "../util/cn";
|
||||
|
||||
export const TABLE_ROW_HEIGHT = 35;
|
||||
|
||||
const useStyles = createUseStyles({
|
||||
tableRow: {
|
||||
"& > div": {
|
||||
"&:first-child": {
|
||||
flexGrow: [3, "!important"],
|
||||
},
|
||||
"&:last-child": {
|
||||
alignContent: "flex-end",
|
||||
},
|
||||
"&:nth-last-child(2)": {
|
||||
flexGrow: [1, "!important"],
|
||||
},
|
||||
alignContent: "center",
|
||||
display: "flex",
|
||||
flex: 1,
|
||||
flexFlow: "column",
|
||||
flexGrow: 2,
|
||||
justifyContent: "center",
|
||||
padding: "var(--spacing-xxs) 0",
|
||||
},
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||
cursor: "pointer",
|
||||
},
|
||||
"&:not(:last-child)": {
|
||||
borderBottom: "1px solid var(--color-grey)",
|
||||
},
|
||||
height: TABLE_ROW_HEIGHT,
|
||||
},
|
||||
});
|
||||
|
||||
export default function TableRow({
|
||||
children,
|
||||
rowClassName,
|
||||
rowProps,
|
||||
}: React.PropsWithChildren<{
|
||||
rowClassName?: string;
|
||||
rowProps?: {
|
||||
onClick: React.MouseEventHandler<HTMLElement>;
|
||||
tabIndex: 0;
|
||||
};
|
||||
}>) {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<div className={cn("flex-1 flex gap-m py-xxs px-s", classes.tableRow, rowClassName)} {...rowProps}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
14
web/components/src/table/TableWrapper.tsx
Normal file
14
web/components/src/table/TableWrapper.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { cn } from "../util/cn";
|
||||
|
||||
export default function TableWrapper({
|
||||
children,
|
||||
tableWrapperClassName,
|
||||
}: React.PropsWithChildren<{
|
||||
tableWrapperClassName?: string;
|
||||
}>) {
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full w-full border-white", tableWrapperClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
web/components/src/theme/createUseStyles.ts
Normal file
16
web/components/src/theme/createUseStyles.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Styles } from "react-jss";
|
||||
import { createUseStyles as ogCreateUseStyles } from "react-jss";
|
||||
|
||||
import type commonTheme from "./index";
|
||||
|
||||
const createUseStyles = <Props>(
|
||||
styles:
|
||||
| Styles<string | number, Props, typeof commonTheme>
|
||||
| ((
|
||||
theme: typeof commonTheme,
|
||||
) => Styles<string | number, Props, undefined>),
|
||||
) => {
|
||||
return ogCreateUseStyles(styles);
|
||||
};
|
||||
|
||||
export default createUseStyles;
|
||||
91
web/components/src/theme/index.ts
Normal file
91
web/components/src/theme/index.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
const theme = {
|
||||
borderRadius: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
s: 4,
|
||||
m: 8,
|
||||
l: 16,
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
color: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
white: "rgba(255, 255, 255, 1)",
|
||||
whiteOpacity: "rgba(255, 255, 255, 0.3)",
|
||||
|
||||
black: "rgba(0, 0, 0, 1)",
|
||||
|
||||
grey: "rgba(238, 238, 238, 1)",
|
||||
darkGrey: "rgba(144, 144, 144, 1)",
|
||||
|
||||
red: "rgba(255, 0, 0, 1)",
|
||||
semiLightRed: "rgba(255, 0, 0, 0.75)",
|
||||
lightRed: "rgba(255, 0, 0, 0.5)",
|
||||
|
||||
lightGreen: "rgba(0, 255, 255, 0.75)",
|
||||
green: "rgba(0, 255, 0, 1)",
|
||||
darkGreen: "rgba(27, 120, 43, 1)",
|
||||
|
||||
warningOrange: "rgba(237, 111, 46, 0.8)",
|
||||
|
||||
yellow: "rgba(255, 255, 0, 0.8)",
|
||||
|
||||
blue: "rgba(0, 0, 255, 1)",
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
font: {
|
||||
letterSpacing: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
s: "0.15px",
|
||||
m: "0.32px",
|
||||
l: "0.4px",
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
lineHeight: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
s: 1.2,
|
||||
m: 1.4,
|
||||
l: 1.7,
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
size: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
xxs: 12,
|
||||
xs: 14,
|
||||
s: 16,
|
||||
m: 18,
|
||||
l: 24,
|
||||
xl: 36,
|
||||
xxl: 48,
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
},
|
||||
media: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
xs: "@media screen and (min-width: 396px)",
|
||||
sm: "@media screen and (min-width: 576px)",
|
||||
md: "@media screen and (min-width: 768px)",
|
||||
lg: "@media screen and (min-width: 992px)",
|
||||
xl: "@media screen and (min-width: 1200px)",
|
||||
xxl: "@media screen and (min-width: 1350px)",
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
sizes: {
|
||||
maxWidth: "@media screen and (min-width: 1440px)",
|
||||
maxWidthValue: 1440,
|
||||
},
|
||||
spacing: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
xxs: 2,
|
||||
xs: 4,
|
||||
s: 8,
|
||||
m: 16,
|
||||
l: 24,
|
||||
xl: 32,
|
||||
xxl: 40,
|
||||
"3xl": 48,
|
||||
"4xl": 64,
|
||||
"5xl": 128,
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
};
|
||||
|
||||
export default theme;
|
||||
22
web/components/src/theme/scrollbar.ts
Normal file
22
web/components/src/theme/scrollbar.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export const scrollbarStyles = {
|
||||
"&::-webkit-scrollbar": {
|
||||
background: "#E6E8EC",
|
||||
width: 8,
|
||||
},
|
||||
"&::-webkit-scrollbar-thumb": {
|
||||
"&:hover": {
|
||||
background: "#75787A",
|
||||
},
|
||||
background: "#C3C4C6",
|
||||
},
|
||||
};
|
||||
|
||||
export const horizontalScrollBar = {
|
||||
"&::-webkit-scrollbar": {
|
||||
background: "#E6E8EC",
|
||||
height: 8,
|
||||
},
|
||||
"&::-webkit-scrollbar-thumb": {
|
||||
backgroundColor: "#C3C4C6",
|
||||
},
|
||||
};
|
||||
198
web/components/src/ui/select.tsx
Normal file
198
web/components/src/ui/select.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "../util/cn";
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-2 rounded-none border px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"!bg-black !text-white !border-white",
|
||||
"data-[placeholder]:!text-white",
|
||||
"hover:!bg-neutral-900 focus-visible:!border-white focus-visible:ring-white/30",
|
||||
"aria-invalid:!border-red aria-invalid:ring-red/20",
|
||||
"data-[size=default]:h-10 data-[size=sm]:h-8",
|
||||
"*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"dark:!bg-black dark:!text-white dark:!border-white dark:hover:!bg-neutral-900 data-[placeholder]:dark:!text-white focus-visible:dark:!border-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 !text-white opacity-100" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-none border shadow-md",
|
||||
"!bg-black !text-white !border-white",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-sm text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"py-xs pl-m pr-xxl",
|
||||
"!bg-black !text-white",
|
||||
"focus:!bg-neutral-900 focus:!text-white",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
"[&_svg:not([class*='text-'])]:!text-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
6
web/components/src/util/cn.ts
Normal file
6
web/components/src/util/cn.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
22
web/components/tsconfig.json
Normal file
22
web/components/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"composite": true,
|
||||
"noEmit": false,
|
||||
"jsx": "react-jsx",
|
||||
"types": [
|
||||
"node",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react-router-dom"
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||
}
|
||||
2
web/frontend/.env.example
Normal file
2
web/frontend/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_PATH=http://localhost:8555/api
|
||||
VITE_NEW_API_PATH=http://localhost:8555/api
|
||||
21
web/frontend/components.json
Normal file
21
web/frontend/components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/theme/styles.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
15
web/frontend/index.html
Normal file
15
web/frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="theme-color" content="#1B8839">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="version" content="1">
|
||||
<link rel="icon" type="image/x-icon" href="../static/favicon.ico">
|
||||
<title>Biostacker</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" />
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
81
web/frontend/package.json
Executable file
81
web/frontend/package.json
Executable file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "biostacker-frontend",
|
||||
"version": "1.0.0",
|
||||
"license": "UNLICENSED",
|
||||
"author": "k4rli",
|
||||
"private": true,
|
||||
"main": "vite.config.ts",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite --no-open",
|
||||
"build": "tsc && vite build",
|
||||
"build:analyze": "ANALYZE=true vite build",
|
||||
"clean": "rimraf dist",
|
||||
"lint": "biome lint src",
|
||||
"lint:fix": "biome lint src --write",
|
||||
"format": "biome format src",
|
||||
"format:check": "biome format src --check",
|
||||
"check": "biome check src",
|
||||
"depcheck": "depcheck",
|
||||
"find-deadcode": "ts-prune"
|
||||
},
|
||||
"dependencies": {
|
||||
"@carbon/icons-react": "11.63.0",
|
||||
"@greatness/components": "workspace:*",
|
||||
"@greatness/util": "workspace:*",
|
||||
"@floating-ui/react": "0.27.13",
|
||||
"@hookform/resolvers": "5.2.0",
|
||||
"@radix-ui/react-select": "2.2.5",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"@tanstack/react-query": "5.83.0",
|
||||
"@tanstack/react-query-devtools": "5.83.0",
|
||||
"@types/i18n-js": "3.8.9",
|
||||
"@types/js-cookie": "3.0.6",
|
||||
"@types/node": "24.1.0",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"awesome-debounce-promise": "2.1.0",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"i18n-js": "3.8.0",
|
||||
"js-cookie": "3.0.5",
|
||||
"lucide-react": "0.525.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-error-boundary": "6.0.0",
|
||||
"react-hook-form": "7.61.1",
|
||||
"react-jss": "10.10.0",
|
||||
"react-router-dom": "7.7.1",
|
||||
"react-select": "5.10.2",
|
||||
"recharts": "3.1.0",
|
||||
"tailwind-merge": "3.3.1",
|
||||
"tailwindcss": "4.1.11",
|
||||
"vis-timeline": "8.1.2",
|
||||
"yup": "1.6.1",
|
||||
"zod": "3.25.74"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@js-temporal/polyfill": "0.5.1",
|
||||
"@types/jest": "30.0.0",
|
||||
"@vitejs/plugin-react-swc": "3.11.0",
|
||||
"depcheck": "1.4.7",
|
||||
"react-loading-skeleton": "3.5.0",
|
||||
"rimraf": "6.0.1",
|
||||
"rollup-plugin-visualizer": "6.0.3",
|
||||
"sass": "1.89.2",
|
||||
"ts-prune": "0.10.3",
|
||||
"tw-animate-css": "1.3.6",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "7.0.6",
|
||||
"vite-tsconfig-paths": "5.1.4"
|
||||
},
|
||||
"browserslist": [
|
||||
">1%",
|
||||
"last 4 versions",
|
||||
"not ie <= 11",
|
||||
"not op_mini all",
|
||||
"not dead"
|
||||
]
|
||||
}
|
||||
4
web/frontend/src/@types/declarations.d.ts
vendored
Normal file
4
web/frontend/src/@types/declarations.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module "*.scss" {
|
||||
const content: { [className: string]: string };
|
||||
export = content;
|
||||
}
|
||||
10
web/frontend/src/@types/env.d.ts
vendored
Normal file
10
web/frontend/src/@types/env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_PATH: string;
|
||||
// more env variables...
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
60
web/frontend/src/api/common.types.ts
Normal file
60
web/frontend/src/api/common.types.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export enum ContentType {
|
||||
APPLICATION_JSON = "application/json",
|
||||
URL_ENCODED = "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
}
|
||||
|
||||
export enum RequestMethod {
|
||||
DELETE = "DELETE",
|
||||
GET = "GET",
|
||||
POST = "POST",
|
||||
PUT = "PUT",
|
||||
}
|
||||
|
||||
export enum RequestHeader {
|
||||
ACCEPT = "accept",
|
||||
CONTENT_TYPE = "content-Type",
|
||||
REFERRER = "referrer",
|
||||
SET_COOKIE = "set-cookie",
|
||||
X_JWT = "x-jwt",
|
||||
}
|
||||
|
||||
export enum ResponseStatus {
|
||||
OK = 200,
|
||||
BAD_REQUEST = 400,
|
||||
UNAUTHORIZED = 401,
|
||||
FORBIDDEN = 403,
|
||||
METHOD_NOT_ALLOWED = 405,
|
||||
UNPROCESSABLE_CONTENT = 422,
|
||||
INTERNAL_SERVER_ERROR = 500,
|
||||
}
|
||||
|
||||
export enum HeaderAccept {
|
||||
IMAGE_PNG = "image/png",
|
||||
}
|
||||
|
||||
export enum MimeType {
|
||||
DOCUMENT_CSV = "text/csv",
|
||||
DOCUMENT_MS_EXCEL = "application/vnd.ms-excel",
|
||||
DOCUMENT_MS_EXCEL_OPENXML = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
DOCUMENT_MS_POWERPOINT = "application/vnd.ms-powerpoint",
|
||||
DOCUMENT_MS_POWERPOINT_OPENXML = "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
DOCUMENT_MS_WORD = "application/msword",
|
||||
DOCUMENT_MS_WORD_OPENXML = "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
DOCUMENT_OPEN_PRESENTATION = "application/vnd.oasis.opendocument.presentation",
|
||||
DOCUMENT_OPEN_SPREADSHEET = "application/vnd.oasis.opendocument.spreadsheet",
|
||||
DOCUMENT_OPEN_TEXT = "application/vnd.oasis.opendocument.text",
|
||||
DOCUMENT_PDF = "application/pdf",
|
||||
DOCUMENT_PLAIN_TEXT = "text/plain",
|
||||
DOCUMENT_RTF = "application/rtf",
|
||||
IMAGE_AVIF = "image/avif",
|
||||
IMAGE_BMP = "image/bmp",
|
||||
IMAGE_GIF = "image/gif",
|
||||
IMAGE_HEIC = "image/heic",
|
||||
IMAGE_HEIF = "image/heif",
|
||||
IMAGE_JPEG = "image/jpeg",
|
||||
IMAGE_JPG = "image/jpg",
|
||||
IMAGE_PNG = "image/png",
|
||||
IMAGE_SVG = "image/svg+xml",
|
||||
IMAGE_TIFF = "image/tiff",
|
||||
IMAGE_WEBP = "image/webp",
|
||||
}
|
||||
73
web/frontend/src/api/controller/todo.ts
Normal file
73
web/frontend/src/api/controller/todo.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { RequestMethod } from '../common.types';
|
||||
import { request } from '../request';
|
||||
|
||||
// Types
|
||||
export interface Todo {
|
||||
id: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
color: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TodoWithStats extends Todo {
|
||||
currentStreak: number;
|
||||
longestStreak: number;
|
||||
totalCompletions: number;
|
||||
lastCompletedAt?: string;
|
||||
completedToday: boolean;
|
||||
}
|
||||
|
||||
export interface TodoCompletion {
|
||||
id: string;
|
||||
todoId: string;
|
||||
userId: string;
|
||||
completedAt: string;
|
||||
description: string;
|
||||
todo: Todo;
|
||||
}
|
||||
|
||||
export interface DailyTodoSummary {
|
||||
date: string;
|
||||
todos: TodoWithStats[];
|
||||
completedCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export interface CreateTodoRequest {
|
||||
title: string;
|
||||
description: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface UpdateTodoRequest {
|
||||
title: string;
|
||||
description: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface CompleteTodoRequest {
|
||||
description: string;
|
||||
}
|
||||
|
||||
// API Functions
|
||||
export const todoApi = {
|
||||
// Create a new todo
|
||||
createTodo: (data: CreateTodoRequest) =>
|
||||
request<Todo>(RequestMethod.POST, '/todo/create', data),
|
||||
|
||||
// Update a todo
|
||||
updateTodo: (id: string, data: UpdateTodoRequest) =>
|
||||
request<Todo>(RequestMethod.PUT, `/todo/${id}`, data),
|
||||
|
||||
// Delete a todo
|
||||
deleteTodo: (id: string) =>
|
||||
request<{ message: string }>(RequestMethod.DELETE, `/todo/${id}`),
|
||||
|
||||
// Complete a todo
|
||||
completeTodo: (id: string, data: CompleteTodoRequest) =>
|
||||
request<{ message: string }>(RequestMethod.POST, `/todo/${id}/complete`, data),
|
||||
};
|
||||
30
web/frontend/src/api/controller/user.ts
Normal file
30
web/frontend/src/api/controller/user.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
|
||||
import { RequestMethod } from "../common.types";
|
||||
import { request } from "../request";
|
||||
|
||||
export interface IUserLoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
rememberMe?: boolean;
|
||||
}
|
||||
|
||||
export interface IUserLoginResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface IUserMeResponse {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const UserApiPath = {
|
||||
login: "/user/login",
|
||||
me: "/user/me",
|
||||
};
|
||||
|
||||
export const User = {
|
||||
login: async (data: IUserLoginRequest) =>
|
||||
request<IUserLoginResponse>(RequestMethod.POST, UserApiPath.login, data),
|
||||
me: async () => request<IUserMeResponse>(RequestMethod.GET, UserApiPath.me),
|
||||
};
|
||||
120
web/frontend/src/api/request.ts
Normal file
120
web/frontend/src/api/request.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type {
|
||||
IErrorResponse,
|
||||
RequestBody,
|
||||
RequestResponse,
|
||||
} from "./request.types";
|
||||
import { ContentType, RequestHeader, RequestMethod } from "./common.types";
|
||||
import { TextUtil } from "@greatness/util";
|
||||
|
||||
import { NavigationPath } from "@/constants/navigation";
|
||||
import JWTUtil from "@/util/JWTUtil";
|
||||
|
||||
const GOLANG_API_PATHS = [
|
||||
"/admin/",
|
||||
"/crime-user-service/millionaires-questions",
|
||||
"/services-list",
|
||||
];
|
||||
|
||||
export class RequestHelper {
|
||||
public static async handleFetch(
|
||||
pathname: string,
|
||||
requestOptions?: RequestInit,
|
||||
): Promise<Response> {
|
||||
return fetch(RequestHelper.getApiPath(pathname), {
|
||||
...requestOptions,
|
||||
headers: {
|
||||
[RequestHeader.CONTENT_TYPE]: ContentType.APPLICATION_JSON,
|
||||
...RequestHelper.getAuthenticationHeaders(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static getAuthenticationHeaders() {
|
||||
const headers: Record<string, string> = {};
|
||||
const { accessToken } = JWTUtil.get();
|
||||
if (!TextUtil.isEmpty(accessToken)) {
|
||||
headers[RequestHeader.X_JWT] = accessToken!;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
public static getApiPath(pathname: string, apiBasePath?: string): string {
|
||||
const apiPath = GOLANG_API_PATHS.some((p) => pathname.includes(p))
|
||||
? import.meta.env.VITE_NEW_API_PATH
|
||||
: import.meta.env.VITE_API_PATH;
|
||||
return `${apiBasePath ?? apiPath}${pathname}`;
|
||||
}
|
||||
|
||||
public static redirectToLoginPage(): void {
|
||||
JWTUtil.unset();
|
||||
|
||||
const loginPath = NavigationPath.Login;
|
||||
if (window.location.pathname !== loginPath) {
|
||||
window.location.pathname = loginPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const queryFetcher = async <T>({
|
||||
queryKey,
|
||||
}: {
|
||||
queryKey: readonly unknown[];
|
||||
}): Promise<T> =>
|
||||
RequestHelper.handleFetch(queryKey[0] as string).then(async (res) => res.json());
|
||||
|
||||
export const request = async <T extends object | undefined>(
|
||||
method: RequestMethod,
|
||||
pathname: string,
|
||||
body?: RequestBody,
|
||||
): RequestResponse<T> => {
|
||||
let response = null;
|
||||
let responseData: T | null = null;
|
||||
try {
|
||||
const isFormData = body instanceof FormData;
|
||||
response = await fetch(RequestHelper.getApiPath(pathname), {
|
||||
headers: {
|
||||
...(!isFormData && {
|
||||
[RequestHeader.CONTENT_TYPE]: ContentType.APPLICATION_JSON,
|
||||
}),
|
||||
...RequestHelper.getAuthenticationHeaders(),
|
||||
},
|
||||
method,
|
||||
...([RequestMethod.POST, RequestMethod.PUT].includes(method) && {
|
||||
body: isFormData ? body : JSON.stringify(body),
|
||||
}),
|
||||
});
|
||||
try {
|
||||
responseData = (await response.json()) as T;
|
||||
} catch {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("No response data", responseData);
|
||||
}
|
||||
if (
|
||||
response.status === 401 &&
|
||||
!window.location.pathname.startsWith(NavigationPath.Login)
|
||||
) {
|
||||
RequestHelper.redirectToLoginPage();
|
||||
throw new Error();
|
||||
}
|
||||
return {
|
||||
isResponseOk: true,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
response: responseData as any,
|
||||
responseStatus: response.status,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Request error", error);
|
||||
return {
|
||||
isResponseOk: false,
|
||||
response: (() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (responseData !== null) {
|
||||
return responseData as IErrorResponse;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return "" as unknown as IErrorResponse;
|
||||
})(),
|
||||
responseStatus: null,
|
||||
};
|
||||
}
|
||||
};
|
||||
40
web/frontend/src/api/request.types.ts
Normal file
40
web/frontend/src/api/request.types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export interface IErrorResponse {
|
||||
details?: Record<
|
||||
string,
|
||||
{
|
||||
message?: string;
|
||||
}
|
||||
>;
|
||||
fields?: {
|
||||
field: string;
|
||||
message: string;
|
||||
}[];
|
||||
message: string;
|
||||
}
|
||||
export type SuccessResponseParameter = object | undefined;
|
||||
export type ErrorResponseParameter = string | undefined;
|
||||
export type SuccessResponse<T extends SuccessResponseParameter = undefined> = T;
|
||||
export type ControllerResponse<
|
||||
T extends SuccessResponseParameter = SuccessResponseParameter,
|
||||
> = IErrorResponse | SuccessResponse<T>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type RequestBody = FormData | Record<string, any>;
|
||||
|
||||
export interface IResponseToken {
|
||||
authToken: string;
|
||||
}
|
||||
export type RequestResponse<
|
||||
T extends SuccessResponseParameter = SuccessResponseParameter,
|
||||
> = Promise<
|
||||
| {
|
||||
isResponseOk: false;
|
||||
response: IErrorResponse;
|
||||
responseStatus: number | null;
|
||||
}
|
||||
| {
|
||||
isResponseOk: true;
|
||||
response: SuccessResponse<T>;
|
||||
responseStatus: number | null;
|
||||
}
|
||||
>;
|
||||
5
web/frontend/src/api/types.ts
Normal file
5
web/frontend/src/api/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type APIFieldError = {
|
||||
field?: string;
|
||||
message: string;
|
||||
messageCode?: string;
|
||||
};
|
||||
24
web/frontend/src/api/useQuery.ts
Normal file
24
web/frontend/src/api/useQuery.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useQuery as useQueryTanstack } from "@tanstack/react-query";
|
||||
import { queryFetcher } from "./request";
|
||||
|
||||
export const REFETCH_INTERVAL_XS = 1_000;
|
||||
export const REFETCH_INTERVAL_S = 5_000;
|
||||
export const REFETCH_INTERVAL_L = 120_000;
|
||||
|
||||
export const useQuery = <T>({
|
||||
queryKey,
|
||||
refetchInterval = REFETCH_INTERVAL_S,
|
||||
isEnabled = true,
|
||||
}: {
|
||||
queryKey: string;
|
||||
refetchInterval?: number;
|
||||
isEnabled?: boolean;
|
||||
}) => {
|
||||
return useQueryTanstack({
|
||||
queryKey: [queryKey],
|
||||
queryFn: queryFetcher<T>,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchInterval,
|
||||
enabled: isEnabled,
|
||||
});
|
||||
};
|
||||
31
web/frontend/src/components/App.tsx
Normal file
31
web/frontend/src/components/App.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { ThemeProvider } from "react-jss";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
|
||||
import IntlProvider from "@/i18n/IntlProvider";
|
||||
import theme from "@/theme";
|
||||
|
||||
import AppRouter from "./root/AppRouter";
|
||||
import ScrollToTop from "./root/components/ScrollToTop";
|
||||
import SessionContextProvider from "./SessionContext";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<SessionContextProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<IntlProvider>
|
||||
<AppRouter />
|
||||
<ScrollToTop />
|
||||
</IntlProvider>
|
||||
</ThemeProvider>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</SessionContextProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
136
web/frontend/src/components/SessionContext.tsx
Normal file
136
web/frontend/src/components/SessionContext.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { TextUtil } from "@greatness/util";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { User } from "@/api/controller/user";
|
||||
import { RequestHelper } from "@/api/request";
|
||||
import type { APIFieldError } from "@/api/types";
|
||||
import { NavigationPath } from "@/constants/navigation";
|
||||
import useOAuthQueryToken from "@/util/useOAuthQueryToken";
|
||||
import JWTUtil from "@/util/JWTUtil";
|
||||
|
||||
export interface IUser {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface SessionContextState {
|
||||
hasJwtStored: boolean;
|
||||
isAuthenticated: boolean;
|
||||
login: (
|
||||
email: string,
|
||||
password: string,
|
||||
rememberMe: boolean,
|
||||
) => Promise<APIFieldError | null>;
|
||||
logout: () => boolean;
|
||||
user: IUser | null;
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: SessionContextState = {
|
||||
hasJwtStored: false,
|
||||
isAuthenticated: false,
|
||||
login: async () => null,
|
||||
logout: () => true,
|
||||
user: null,
|
||||
};
|
||||
|
||||
const SessionContext = createContext(DEFAULT_STATE);
|
||||
|
||||
export const useSessionContext = (): SessionContextState => useContext(SessionContext);
|
||||
|
||||
export default function SessionContextProvider({
|
||||
children,
|
||||
}: React.PropsWithChildren) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(true);
|
||||
const [user, setUser] = useState<IUser | null>(null);
|
||||
|
||||
const { setToken, removeTokenFromQuery } = useOAuthQueryToken();
|
||||
|
||||
const logout = useCallback(() => {
|
||||
setIsAuthenticated(false);
|
||||
RequestHelper.redirectToLoginPage();
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const checkSession = useCallback(async () => {
|
||||
setUser({
|
||||
id: "1",
|
||||
email: "test@test.com",
|
||||
firstName: "Test",
|
||||
lastName: "Test",
|
||||
role: "admin",
|
||||
});
|
||||
return;
|
||||
|
||||
const hasToken = !TextUtil.isEmpty(JWTUtil.get().accessToken);
|
||||
if (!hasToken) {
|
||||
logout();
|
||||
return;
|
||||
}
|
||||
const { response, isResponseOk } = await User.me();
|
||||
console.log("response", response);
|
||||
if (!isResponseOk) {
|
||||
logout();
|
||||
return;
|
||||
}
|
||||
setUser(response.user);
|
||||
|
||||
if (location.pathname.endsWith(NavigationPath.Login)) {
|
||||
removeTokenFromQuery();
|
||||
navigate(NavigationPath.Home.Base);
|
||||
}
|
||||
}, [location.pathname, navigate, removeTokenFromQuery, logout]);
|
||||
|
||||
const login = useCallback(
|
||||
async (email: string, password: string, rememberMe: boolean) => {
|
||||
const { response, isResponseOk } = await User.login({
|
||||
email,
|
||||
password,
|
||||
rememberMe,
|
||||
});
|
||||
if (isResponseOk) {
|
||||
setIsAuthenticated(true);
|
||||
setToken({
|
||||
accessToken: response.accessToken,
|
||||
});
|
||||
await checkSession();
|
||||
return null;
|
||||
}
|
||||
// @TODO types
|
||||
return response as unknown as APIFieldError;
|
||||
},
|
||||
[checkSession, setToken],
|
||||
);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation> great
|
||||
useEffect(() => {
|
||||
checkSession();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SessionContext.Provider
|
||||
value={{
|
||||
hasJwtStored: !TextUtil.isEmpty(JWTUtil.get().accessToken),
|
||||
isAuthenticated,
|
||||
login,
|
||||
logout,
|
||||
user,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SessionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
38
web/frontend/src/components/root/AppRouter.tsx
Normal file
38
web/frontend/src/components/root/AppRouter.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
|
||||
import routes from "@/constants/routes";
|
||||
import LayoutWrapper from "@greatness/components/src/LayoutWrapper";
|
||||
|
||||
import PrivateRoute from "./components/PrivateRoute";
|
||||
|
||||
export default function AppRouter() {
|
||||
return (
|
||||
<Routes>
|
||||
{routes.public.map((route) => (
|
||||
<Route key={route.path} path={route.path} element={<route.Component />} />
|
||||
))}
|
||||
<Route path="/" element={<LayoutWrapper />}>
|
||||
{routes.private.map(({ path, Component, subroutes, redirectTo }) => (
|
||||
<Route
|
||||
key={path}
|
||||
path={path}
|
||||
element={<PrivateRoute Component={Component} redirectTo={redirectTo} />}
|
||||
>
|
||||
{subroutes?.map((subroute) => (
|
||||
<Route
|
||||
key={subroute.path}
|
||||
path={subroute.path}
|
||||
element={
|
||||
<PrivateRoute
|
||||
Component={subroute.Component}
|
||||
redirectTo={subroute.redirectTo}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)) || null}
|
||||
</Route>
|
||||
))}
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
32
web/frontend/src/components/root/components/PrivateRoute.tsx
Normal file
32
web/frontend/src/components/root/components/PrivateRoute.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { TextUtil } from "@greatness/util";
|
||||
import { useEffect } from "react";
|
||||
import { Navigate, useNavigate } from "react-router-dom";
|
||||
|
||||
import { useSessionContext } from "@/components/SessionContext";
|
||||
import { NavigationPath } from "@/constants/navigation";
|
||||
|
||||
export default function PrivateRoute({
|
||||
Component,
|
||||
redirectTo,
|
||||
}: {
|
||||
Component?: React.FC;
|
||||
redirectTo?: string;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated } = useSessionContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
navigate(NavigationPath.Login);
|
||||
}
|
||||
}, [navigate, isAuthenticated]);
|
||||
|
||||
if (isAuthenticated) {
|
||||
if (!TextUtil.isEmpty(redirectTo)) {
|
||||
return <Navigate to={redirectTo!} />;
|
||||
}
|
||||
return Component ? <Component /> : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
13
web/frontend/src/components/root/components/ScrollToTop.tsx
Normal file
13
web/frontend/src/components/root/components/ScrollToTop.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
export default function ScrollToTop() {
|
||||
const location = useLocation();
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation> great
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [location.pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
11
web/frontend/src/constants/navigation.ts
Normal file
11
web/frontend/src/constants/navigation.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const NavigationPath = {
|
||||
Home: {
|
||||
Base: "/portal",
|
||||
Dashboard: "dashboard",
|
||||
Supplements: "supplements",
|
||||
Work: "work",
|
||||
Root: "",
|
||||
},
|
||||
Login: "/login",
|
||||
Root: "/",
|
||||
};
|
||||
50
web/frontend/src/constants/routes.ts
Normal file
50
web/frontend/src/constants/routes.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { lazy } from "react";
|
||||
|
||||
import { NavigationPath } from "./navigation";
|
||||
import type { NavigationRoutes } from "./types";
|
||||
|
||||
const HomeViewBase = lazy(() => import("@/pages/home/HomeViewBase"));
|
||||
const DashboardPage = lazy(() => import("@/pages/dashboard/DashboardPage"));
|
||||
const SupplementsPage = lazy(() => import("@/pages/supplements/SupplementsPage"));
|
||||
const WorkPage = lazy(() => import("@/pages/work/WorkPage"));
|
||||
|
||||
const LoginPage = lazy(() => import("@/pages/login/LoginPage"));
|
||||
|
||||
const routes: NavigationRoutes = {
|
||||
private: [
|
||||
{
|
||||
path: NavigationPath.Root,
|
||||
redirectTo: NavigationPath.Home.Base,
|
||||
},
|
||||
{
|
||||
Component: HomeViewBase,
|
||||
path: NavigationPath.Home.Base,
|
||||
subroutes: [
|
||||
{
|
||||
path: NavigationPath.Home.Root,
|
||||
redirectTo: NavigationPath.Home.Dashboard,
|
||||
},
|
||||
{
|
||||
Component: DashboardPage,
|
||||
path: NavigationPath.Home.Dashboard,
|
||||
},
|
||||
{
|
||||
Component: SupplementsPage,
|
||||
path: NavigationPath.Home.Supplements,
|
||||
},
|
||||
{
|
||||
Component: WorkPage,
|
||||
path: NavigationPath.Home.Work,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
public: [
|
||||
{
|
||||
Component: LoginPage,
|
||||
path: NavigationPath.Login,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default routes;
|
||||
17
web/frontend/src/constants/types.ts
Normal file
17
web/frontend/src/constants/types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
type NavigationRouteWithOptionalComponent = Omit<NavigationRoute, "Component"> & {
|
||||
Component?: NavigationRoute["Component"];
|
||||
};
|
||||
|
||||
interface NavigationRoute {
|
||||
Component: FC;
|
||||
path: string;
|
||||
redirectTo?: string;
|
||||
subroutes?: NavigationRouteWithOptionalComponent[];
|
||||
}
|
||||
|
||||
export interface NavigationRoutes {
|
||||
private: NavigationRouteWithOptionalComponent[];
|
||||
public: NavigationRoute[];
|
||||
}
|
||||
89
web/frontend/src/i18n/IntlProvider.tsx
Normal file
89
web/frontend/src/i18n/IntlProvider.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import I18n from "i18n-js";
|
||||
import { createContext, useCallback, useContext, useEffect } from "react";
|
||||
|
||||
import type { IFormError } from "@greatness/components/src/form/Form";
|
||||
import translationsEn from "@/i18n/en.json";
|
||||
|
||||
const Language = {
|
||||
EN: "EN",
|
||||
};
|
||||
|
||||
const defaultLocale = Language.EN;
|
||||
|
||||
I18n.fallbacks = false;
|
||||
I18n.defaultLocale = defaultLocale;
|
||||
I18n.locale = defaultLocale;
|
||||
I18n.translations = {
|
||||
[Language.EN]: translationsEn,
|
||||
};
|
||||
|
||||
type TranslateFunction<T = string> = (
|
||||
translationKey: string,
|
||||
translateOptions?: Record<string, number | string>,
|
||||
) => T;
|
||||
type IntlProviderState = {
|
||||
locale: string;
|
||||
t: TranslateFunction;
|
||||
};
|
||||
|
||||
const DEFAULT_STATE: IntlProviderState = {
|
||||
locale: defaultLocale,
|
||||
t: () => "",
|
||||
};
|
||||
|
||||
const I18nContext = createContext(DEFAULT_STATE);
|
||||
export const useIntl = (): IntlProviderState => useContext(I18nContext);
|
||||
|
||||
export default function IntlProvider({ children }: React.PropsWithChildren) {
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation> good
|
||||
const translate: TranslateFunction = useCallback(
|
||||
(translationKey, translateOptions) => {
|
||||
const translated = I18n.t(translationKey, translateOptions);
|
||||
if (translationKey === "") {
|
||||
throw new Error("Invalid translator");
|
||||
}
|
||||
return typeof translated === "string" && translated.includes("[missing")
|
||||
? ""
|
||||
: translated;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
I18n.translations[Language.EN] = translationsEn;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<I18nContext.Provider
|
||||
value={{
|
||||
// @todo language switching
|
||||
locale: DEFAULT_STATE.locale,
|
||||
t: translate,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const getTranslatedError = (
|
||||
t: TranslateFunction,
|
||||
error?: IFormError,
|
||||
): string | undefined => {
|
||||
if (error) {
|
||||
switch (error.type) {
|
||||
case "email":
|
||||
return t("field.validation.invalidEmail");
|
||||
case "min":
|
||||
case "max":
|
||||
case "required":
|
||||
return t("field.validation.required");
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (error.message) {
|
||||
return error.type === "manual" ? error.message : t(error.message);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
41
web/frontend/src/i18n/en.json
Normal file
41
web/frontend/src/i18n/en.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"field": {
|
||||
"common": {
|
||||
"email": "E-mail",
|
||||
"phone": "Phone",
|
||||
"firstName": "Name",
|
||||
"lastName": "Surname",
|
||||
"date": "Date",
|
||||
"password": "Password",
|
||||
"name": "Name",
|
||||
"location": "Location",
|
||||
"username": "Username"
|
||||
},
|
||||
"validation": {
|
||||
"required": "Field is required",
|
||||
"invalidEmail": "Invalid email"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"confirm": "Continue",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"page": {
|
||||
"login": {
|
||||
"title": "Login",
|
||||
"rememberMe": "Remember me",
|
||||
"error": {
|
||||
"wrongPassword": "Invalid password or email"
|
||||
},
|
||||
"action": {
|
||||
"login": "Login"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"header": "Biostacker",
|
||||
"tab": {
|
||||
"DASHBOARD": "Dashboard"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
web/frontend/src/index.tsx
Normal file
12
web/frontend/src/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import "./theme/styles.css";
|
||||
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import App from "./components/App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
6
web/frontend/src/pages/dashboard/DashboardPage.tsx
Normal file
6
web/frontend/src/pages/dashboard/DashboardPage.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-m py-xxl">
|
||||
</div>
|
||||
);
|
||||
};
|
||||
26
web/frontend/src/pages/home/HomePageWrapper.tsx
Normal file
26
web/frontend/src/pages/home/HomePageWrapper.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useIntl } from "@/i18n/IntlProvider";
|
||||
import createUseStyles from "@/theme/createUseStyles";
|
||||
|
||||
import PageHeader from "../home/components/PageHeader";
|
||||
import { cn } from "@/util/cn";
|
||||
|
||||
const useStyles = createUseStyles(({ sizes }) => ({
|
||||
tabContent: {
|
||||
maxWidth: sizes.maxWidthValue,
|
||||
},
|
||||
}));
|
||||
|
||||
export default function HomePageWrapper({ children }: React.PropsWithChildren) {
|
||||
const { t } = useIntl();
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<div className="bg-black w-full h-screen flex flex-col">
|
||||
<PageHeader title={t("page.home.header")} />
|
||||
<div className="w-full h-full">
|
||||
<div className="flex flex-col items-center bg-black m-auto">
|
||||
<div className={cn("p-m w-full", classes.tabContent)}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
web/frontend/src/pages/home/HomeViewBase.tsx
Normal file
14
web/frontend/src/pages/home/HomeViewBase.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useOutlet } from "react-router-dom";
|
||||
|
||||
import ComponentErrorBoundary from "@greatness/components/src/ComponentErrorBoundary";
|
||||
|
||||
import HomePageWrapper from "./HomePageWrapper";
|
||||
|
||||
export default function HomeViewBase() {
|
||||
const outlet = useOutlet();
|
||||
return (
|
||||
<ComponentErrorBoundary>
|
||||
<HomePageWrapper>{outlet}</HomePageWrapper>
|
||||
</ComponentErrorBoundary>
|
||||
);
|
||||
}
|
||||
66
web/frontend/src/pages/home/components/PageHeader.tsx
Normal file
66
web/frontend/src/pages/home/components/PageHeader.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Logout } from "@carbon/icons-react";
|
||||
import { TextUtil } from "@greatness/util";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useSessionContext } from "@/components/SessionContext";
|
||||
import createUseStyles from "@/theme/createUseStyles";
|
||||
import { cn } from "@/util/cn";
|
||||
|
||||
const useStyles = createUseStyles(({ sizes, media }) => ({
|
||||
closeButton: {
|
||||
border: "none",
|
||||
height: 64,
|
||||
marginLeft: "auto",
|
||||
},
|
||||
pageHeader: {
|
||||
margin: "0 auto",
|
||||
maxWidth: sizes.maxWidthValue,
|
||||
[media.md]: {
|
||||
alignItems: "center",
|
||||
padding: "var(--spacing-m) var(--spacing-l)",
|
||||
},
|
||||
},
|
||||
titleWrapper: {
|
||||
[media.md]: {
|
||||
alignItems: "center",
|
||||
flex: 1,
|
||||
flexFlow: "row",
|
||||
gap: "var(--spacing-m)",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export default function PageHeader({
|
||||
className,
|
||||
title,
|
||||
}: {
|
||||
className?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const classes = useStyles();
|
||||
const { isAuthenticated, logout } = useSessionContext();
|
||||
|
||||
const handleClose = useCallback(
|
||||
() => (isAuthenticated ? logout() : window.close()),
|
||||
[isAuthenticated, logout],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn(classes.pageHeaderWrapper)}>
|
||||
<div className={cn("flex pt-xxl pr-m pb-0 pl-m", classes.pageHeader, className)}>
|
||||
<div className={cn("flex flex-col", classes.titleWrapper)}>
|
||||
{!TextUtil.isEmpty(title) && (
|
||||
<h2 className="font-size-l flex-1 m-0">{title!}</h2>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("items-center justify-center flex flex-col p-l cursor-pointer bg-black", classes.closeButton)}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<Logout />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
35
web/frontend/src/pages/login/LoginPage.tsx
Normal file
35
web/frontend/src/pages/login/LoginPage.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import createUseStyles from "@/theme/createUseStyles";
|
||||
|
||||
import PageHeader from "../home/components/PageHeader";
|
||||
import LoginForm from "./components/LoginForm";
|
||||
import { cn } from "@/util/cn";
|
||||
|
||||
const useStyles = createUseStyles(({ sizes, media }) => ({
|
||||
loginPage: {
|
||||
maxWidth: sizes.maxWidthValue,
|
||||
[media.md]: {
|
||||
padding: "var(--spacing-l)",
|
||||
},
|
||||
},
|
||||
loginPageForm: {
|
||||
[media.md]: {
|
||||
height: "60%",
|
||||
minWidth: 400,
|
||||
paddingTop: 0,
|
||||
width: 450,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export default function LoginPage() {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col p-m w-full m-auto h-screen", classes.loginPage)}>
|
||||
<PageHeader />
|
||||
<div className={cn("flex flex-col justify-center pt-l align-self-center w-full", classes.loginPageForm)}>
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
web/frontend/src/pages/login/components/LoginForm.tsx
Normal file
82
web/frontend/src/pages/login/components/LoginForm.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { TextUtil } from "@greatness/util";
|
||||
import { useCallback, useState } from "react";
|
||||
import { boolean, object, Schema, string } from "yup";
|
||||
|
||||
import Button from "@greatness/components/src/Button";
|
||||
import CheckboxInput from "@greatness/components/src/form/checkbox/CheckboxInput";
|
||||
import Form from "@greatness/components/src/form/Form";
|
||||
import TextInput from "@greatness/components/src/form/text/TextInput";
|
||||
import { useSessionContext } from "@/components/SessionContext";
|
||||
import { useIntl } from "@/i18n/IntlProvider";
|
||||
import type { IUserLoginRequest } from "@/api/controller/user";
|
||||
|
||||
const loginValidation: Schema<IUserLoginRequest> = object({
|
||||
email: string().required(),
|
||||
password: string().required(),
|
||||
rememberMe: boolean().required(),
|
||||
});
|
||||
|
||||
export default function LoginForm() {
|
||||
const { t } = useIntl();
|
||||
const { login } = useSessionContext();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isLoginLoading, setIsLoginLoading] = useState(false);
|
||||
|
||||
const handleLogin = useCallback(
|
||||
async (loginValues: IUserLoginRequest) => {
|
||||
setIsLoginLoading(true);
|
||||
const submitError = await login(
|
||||
loginValues.email.toLowerCase(),
|
||||
loginValues.password,
|
||||
loginValues.rememberMe ?? false,
|
||||
);
|
||||
if (submitError) {
|
||||
const shownMessage = TextUtil.isEmpty(submitError.message)
|
||||
? t("page.login.error.wrongPassword")
|
||||
: submitError.message;
|
||||
setErrorMessage(shownMessage);
|
||||
setIsLoginLoading(false);
|
||||
}
|
||||
},
|
||||
[t, login],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<h3 className="my-l mx-0 text-center">{t("page.login.title")}</h3>
|
||||
{!TextUtil.isEmpty(errorMessage) && (
|
||||
<h6 className="text-red mb-m">{errorMessage!}</h6>
|
||||
)}
|
||||
<Form<IUserLoginRequest>
|
||||
className="flex flex-col gap-y-m"
|
||||
handleSubmit={handleLogin}
|
||||
defaultValues={{
|
||||
rememberMe: false,
|
||||
}}
|
||||
validation={loginValidation}
|
||||
>
|
||||
<div className="flex gap-m">
|
||||
<TextInput type="email" name="email" label={t("field.common.email")} />
|
||||
</div>
|
||||
<div className="flex gap-m">
|
||||
<TextInput
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
name="password"
|
||||
label={t("field.common.password")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-m flex-col mb-m">
|
||||
<div className="flex-1 flex word-break-keep-all">
|
||||
<CheckboxInput name="rememberMe" label={t("page.login.rememberMe")} />
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
label={t("page.login.action.login")}
|
||||
isLoading={isLoginLoading}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
11
web/frontend/src/pages/supplements/SupplementsPage.tsx
Normal file
11
web/frontend/src/pages/supplements/SupplementsPage.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import DailyNutrientsOverview from "./components/DailyNutrientsOverview";
|
||||
import NutrientsTable from "./components/NutrientsTable";
|
||||
import SupplementsTable from "./components/SupplementsTable";
|
||||
|
||||
export default function SupplementsPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-m py-xxl">
|
||||
<DailyNutrientsOverview />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useQuery } from "@/api/useQuery";
|
||||
import { cn } from "@/util/cn";
|
||||
import TableBody from "@greatness/components/src/table/TableBody";
|
||||
import TableHeader from "@greatness/components/src/table/TableHead";
|
||||
import TableRow from "@greatness/components/src/table/TableRow";
|
||||
import TableWrapper from "@greatness/components/src/table/TableWrapper";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Supplement {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Nutrient {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface DailyNutrientsOverview {
|
||||
supplementBreakdown: Supplement[];
|
||||
nutrientTotals: Nutrient[];
|
||||
}
|
||||
|
||||
interface INutrientTotal {
|
||||
nutrientId: string;
|
||||
nutrientName: string;
|
||||
description: string;
|
||||
totalAmount: string;
|
||||
unit: string;
|
||||
categories: string[] | null;
|
||||
}
|
||||
|
||||
interface IDailyNutrientSummary {
|
||||
nutrientTotals: INutrientTotal[];
|
||||
byCategory: Record<string, INutrientTotal[]>;
|
||||
supplementBreakdown: Supplement[];
|
||||
summary: {
|
||||
totalNutrients: number;
|
||||
totalSupplements: number;
|
||||
categoriesCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ICategory {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default function DailyNutrientsOverview() {
|
||||
const { data, isLoading, error } = useQuery<IDailyNutrientSummary>({
|
||||
queryKey: "/daily-overview/overview",
|
||||
});
|
||||
|
||||
const { data: categories } = useQuery<ICategory[]>({
|
||||
queryKey: "/category/get-all",
|
||||
});
|
||||
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div>Error: {error.message}</div>;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-xl">
|
||||
<h1>Supplements</h1>
|
||||
{/* <TableWrapper>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<div>Name</div>
|
||||
<div>Description</div>
|
||||
<div>Total Amount</div>
|
||||
<div>Unit</div>
|
||||
<div>Categories</div>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.nutrientTotals.map((nutrient) => (
|
||||
<TableRow key={nutrient.nutrientId}>
|
||||
<div>{nutrient.nutrientName}</div>
|
||||
<div>{nutrient.description}</div>
|
||||
<div>{nutrient.totalAmount}</div>
|
||||
<div>{nutrient.unit}</div>
|
||||
<div>{nutrient.unit}</div>
|
||||
<div>{nutrient.categories?.join(", ") || "-"}</div>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</TableWrapper> */}
|
||||
|
||||
<div className="flex flex-col gap-s">
|
||||
<span>Total Nutrients: {data.summary.totalNutrients}</span>
|
||||
<span>Total Supplements: {data.summary.totalSupplements}</span>
|
||||
<span>Categories Count: {data.summary.categoriesCount}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-xl">
|
||||
<div className="flex flex-col gap-xl">
|
||||
<h3>Categories</h3>
|
||||
<div className="flex flex-col gap-s">
|
||||
{categories?.map((category) => {
|
||||
const isSelected = selectedCategories.includes(category.id);
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
className={cn("border-radius-m px-l py-s", { ["font-bold"]: isSelected })}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
setSelectedCategories(selectedCategories.filter((c) => c !== category.id));
|
||||
} else {
|
||||
setSelectedCategories([...selectedCategories, category.id]);
|
||||
}
|
||||
}}
|
||||
style={isSelected ? {
|
||||
backgroundColor: "var(--color-green)",
|
||||
color: "var(--color-black)",
|
||||
} : {
|
||||
backgroundColor: "var(--color-darkGrey)",
|
||||
color: "var(--color-white)",
|
||||
}}
|
||||
>
|
||||
{category.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Object.entries(data.byCategory).map(([category, nutrients]) => (
|
||||
<div className="flex flex-col gap-xl">
|
||||
<h3>{category}</h3>
|
||||
<TableWrapper>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<div>Name</div>
|
||||
<div>Description</div>
|
||||
<div>Total Amount</div>
|
||||
<div>Unit</div>
|
||||
<div>Categories</div>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{nutrients.map((nutrient) => (
|
||||
<TableRow key={nutrient.nutrientId}>
|
||||
<div>{nutrient.nutrientName}</div>
|
||||
<div>{nutrient.description}</div>
|
||||
<div>{nutrient.totalAmount}</div>
|
||||
<div>{nutrient.unit}</div>
|
||||
<div>{nutrient.categories?.join(", ") || "-"}</div>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</TableWrapper>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from "@/api/useQuery";
|
||||
|
||||
export default function NutrientsTable() {
|
||||
const { data, isLoading, error } = useQuery<{ id: string; name: string; description: string }[]>({
|
||||
queryKey: "/nutrient/get-all",
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<h1>Nutrients</h1>
|
||||
{data?.map((nutrient) => (
|
||||
<div key={nutrient.id}>
|
||||
<h2>{nutrient.name}</h2>
|
||||
<p>{nutrient.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useQuery } from "@/api/useQuery";
|
||||
import TableBody from "@greatness/components/src/table/TableBody";
|
||||
import TableHeader from "@greatness/components/src/table/TableHead";
|
||||
import TableRow from "@greatness/components/src/table/TableRow";
|
||||
import TableWrapper from "@greatness/components/src/table/TableWrapper";
|
||||
|
||||
export default function SupplementsTable() {
|
||||
const { data, isLoading, error } = useQuery<{ id: string; name: string; description: string }[]>({
|
||||
queryKey: "/supplement/get-all",
|
||||
});
|
||||
return (
|
||||
<div className="flex flex-col gap-xl">
|
||||
<h1>Supplements</h1>
|
||||
<TableWrapper>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<div>Name</div>
|
||||
<div>Description</div>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.map((supplement) => (
|
||||
<TableRow key={supplement.id}>
|
||||
<div>{supplement.name}</div>
|
||||
<div>{supplement.description}</div>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</TableWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
web/frontend/src/pages/work/WorkPage.tsx
Normal file
250
web/frontend/src/pages/work/WorkPage.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery } from '../../api/useQuery';
|
||||
import { TodoWithStats, DailyTodoSummary, CreateTodoRequest, UpdateTodoRequest, CompleteTodoRequest, todoApi } from '../../api/controller/todo';
|
||||
import TodoCard from './components/TodoCard';
|
||||
import TodoForm from './components/TodoForm';
|
||||
import ActivityLog from './components/ActivityLog';
|
||||
import Button from '@greatness/components/src/Button';
|
||||
|
||||
export default function WorkPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [isActivityLogOpen, setIsActivityLogOpen] = useState(false);
|
||||
const [editingTodo, setEditingTodo] = useState<TodoWithStats | undefined>();
|
||||
|
||||
// Queries
|
||||
const { data: todos = [], isLoading: todosLoading, error: todosError } = useQuery<TodoWithStats[]>({
|
||||
queryKey: '/todo/list',
|
||||
});
|
||||
|
||||
const { data: summary, isLoading: summaryLoading, error: summaryError } = useQuery<DailyTodoSummary>({
|
||||
queryKey: '/todo/today',
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const createTodoMutation = useMutation({
|
||||
mutationFn: (data: CreateTodoRequest) => todoApi.createTodo(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/todo/list'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/todo/today'] });
|
||||
setIsFormOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to create todo:', error);
|
||||
},
|
||||
});
|
||||
|
||||
const updateTodoMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateTodoRequest }) =>
|
||||
todoApi.updateTodo(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/todo/list'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/todo/today'] });
|
||||
setEditingTodo(undefined);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to update todo:', error);
|
||||
},
|
||||
});
|
||||
|
||||
const completeTodoMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: CompleteTodoRequest }) =>
|
||||
todoApi.completeTodo(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/todo/list'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/todo/today'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to complete todo:', error);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteTodoMutation = useMutation({
|
||||
mutationFn: (id: string) => todoApi.deleteTodo(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/todo/list'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/todo/today'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to delete todo:', error);
|
||||
},
|
||||
});
|
||||
|
||||
// Handlers
|
||||
const handleCreateTodo = async (data: CreateTodoRequest) => {
|
||||
await createTodoMutation.mutateAsync(data);
|
||||
};
|
||||
|
||||
const handleUpdateTodo = async (data: UpdateTodoRequest) => {
|
||||
if (!editingTodo) return;
|
||||
await updateTodoMutation.mutateAsync({ id: editingTodo.id, data });
|
||||
};
|
||||
|
||||
const handleCompleteTodo = async (id: string, data: CompleteTodoRequest) => {
|
||||
await completeTodoMutation.mutateAsync({ id, data });
|
||||
};
|
||||
|
||||
const handleDeleteTodo = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this todo?')) return;
|
||||
await deleteTodoMutation.mutateAsync(id);
|
||||
};
|
||||
|
||||
const handleEditTodo = (todo: TodoWithStats) => {
|
||||
setEditingTodo(todo);
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseForm = () => {
|
||||
setIsFormOpen(false);
|
||||
setEditingTodo(undefined);
|
||||
};
|
||||
|
||||
const getProgressPercentage = () => {
|
||||
if (!summary || summary.totalCount === 0) return 0;
|
||||
return Math.round((summary.completedCount / summary.totalCount) * 100);
|
||||
};
|
||||
|
||||
const getProgressColor = () => {
|
||||
const percentage = getProgressPercentage();
|
||||
if (percentage === 100) return 'bg-green-500';
|
||||
if (percentage >= 75) return 'bg-blue-500';
|
||||
if (percentage >= 50) return 'bg-yellow-500';
|
||||
if (percentage >= 25) return 'bg-orange-500';
|
||||
return 'bg-red-500';
|
||||
};
|
||||
|
||||
const isLoading = todosLoading || summaryLoading;
|
||||
const hasError = todosError || summaryError;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-m py-xxl">
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="text-gray-500">Loading your todos...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className="flex flex-col gap-m py-xxl">
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="text-red-500">
|
||||
Error loading todos: {todosError?.message || summaryError?.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-m py-xxl max-w-6xl mx-auto px-4">
|
||||
{/* Header with daily summary */}
|
||||
<div className="bg-greyOpacity rounded-lg shadow-sm border p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Work & Habits</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{new Date().toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={() => setIsActivityLogOpen(true)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50 flex items-center gap-2"
|
||||
label="📊 Activity Log"
|
||||
>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsFormOpen(true)}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md flex items-center gap-2"
|
||||
label="➕ Add Todo"
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress summary */}
|
||||
{summary && (
|
||||
<div className="bg-greyOpacity rounded-lg p-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium text-white">
|
||||
Today's Progress
|
||||
</span>
|
||||
<span className="text-sm text-white">
|
||||
{summary.completedCount} of {summary.totalCount} completed
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-gray-200 rounded-full h-3 mb-2">
|
||||
<div
|
||||
className={`h-3 rounded-full transition-all duration-300 ${getProgressColor()}`}
|
||||
style={{ width: `${getProgressPercentage()}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-xs text-white">
|
||||
<span>{getProgressPercentage()}% complete</span>
|
||||
{getProgressPercentage() === 100 && (
|
||||
<span className="text-green-600 font-medium">🎉 All done!</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Todos grid */}
|
||||
{!Array.isArray(todos) || todos.length === 0 ? (
|
||||
<div className="bg-greyOpacity rounded-lg shadow-sm border p-12 text-center">
|
||||
<div className="text-6xl mb-4">📝</div>
|
||||
<h3 className="text-lg font-medium text-white mb-2">No todos yet</h3>
|
||||
<p className="text-white mb-6">
|
||||
Create your first todo to start building healthy daily habits!
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setIsFormOpen(true)}
|
||||
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-md"
|
||||
label="Create Your First Todo"
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{todos.map((todo) => (
|
||||
<TodoCard
|
||||
key={todo.id}
|
||||
todo={todo}
|
||||
onComplete={handleCompleteTodo}
|
||||
onEdit={handleEditTodo}
|
||||
onDelete={handleDeleteTodo}
|
||||
isLoading={completeTodoMutation.isPending || deleteTodoMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Todo Form Modal */}
|
||||
<TodoForm
|
||||
isOpen={isFormOpen}
|
||||
onClose={handleCloseForm}
|
||||
onSubmit={editingTodo ? handleUpdateTodo : handleCreateTodo}
|
||||
todo={editingTodo}
|
||||
isLoading={createTodoMutation.isPending || updateTodoMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* Activity Log Modal */}
|
||||
<ActivityLog
|
||||
isOpen={isActivityLogOpen}
|
||||
onClose={() => setIsActivityLogOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
209
web/frontend/src/pages/work/components/ActivityLog.tsx
Normal file
209
web/frontend/src/pages/work/components/ActivityLog.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '../../../api/useQuery';
|
||||
import { TodoCompletion } from '../../../api/controller/todo';
|
||||
import Button from '@greatness/components/src/Button';
|
||||
import Modal from '@greatness/components/src/Modal';
|
||||
|
||||
interface ActivityLogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ActivityLog({ isOpen, onClose }: ActivityLogProps) {
|
||||
const [selectedDate, setSelectedDate] = useState('');
|
||||
const [viewMode, setViewMode] = useState<'recent' | 'date'>('recent');
|
||||
|
||||
// Query for recent activities (only enabled when modal is open and in recent mode)
|
||||
const { data: recentActivities = [], isLoading: recentLoading, error: recentError } = useQuery<TodoCompletion[]>({
|
||||
queryKey: '/todo/activity?limit=100&offset=0',
|
||||
isEnabled: isOpen && viewMode === 'recent',
|
||||
});
|
||||
|
||||
// Query for activities by date (only enabled when modal is open, in date mode, and date is selected)
|
||||
const { data: dateActivities = [], isLoading: dateLoading, error: dateError } = useQuery<TodoCompletion[]>({
|
||||
queryKey: selectedDate ? `/todo/activity/${selectedDate}` : '',
|
||||
isEnabled: isOpen && viewMode === 'date' && selectedDate !== '',
|
||||
});
|
||||
|
||||
const activities = viewMode === 'recent' ? recentActivities : dateActivities;
|
||||
const isLoading = viewMode === 'recent' ? recentLoading : dateLoading;
|
||||
const error = viewMode === 'recent' ? recentError : dateError;
|
||||
|
||||
const loadRecentActivities = () => {
|
||||
setViewMode('recent');
|
||||
setSelectedDate('');
|
||||
};
|
||||
|
||||
const loadActivitiesByDate = (date: string) => {
|
||||
setSelectedDate(date);
|
||||
setViewMode('date');
|
||||
};
|
||||
|
||||
const handleDateChange = (date: string) => {
|
||||
setSelectedDate(date);
|
||||
if (date) {
|
||||
loadActivitiesByDate(date);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return {
|
||||
date: date.toLocaleDateString(),
|
||||
time: date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||
};
|
||||
};
|
||||
|
||||
const groupActivitiesByDate = (activities: TodoCompletion[]) => {
|
||||
const grouped: Record<string, TodoCompletion[]> = {};
|
||||
activities.forEach(activity => {
|
||||
const date = new Date(activity.completedAt).toDateString();
|
||||
if (!grouped[date]) {
|
||||
grouped[date] = [];
|
||||
}
|
||||
grouped[date].push(activity);
|
||||
});
|
||||
return grouped;
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
};
|
||||
|
||||
const groupedActivities = groupActivitiesByDate(activities);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} handleClose={onClose}>
|
||||
<div className="bg-greyOpacity rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center p-6 border-b">
|
||||
<h2 className="text-xl font-semibold">Activity Log</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white text-xl"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="p-4 border-b bg-greyOpacity">
|
||||
<div className="flex gap-4 items-center">
|
||||
<Button
|
||||
onClick={loadRecentActivities}
|
||||
className={`px-4 py-2 rounded-md ${
|
||||
viewMode === 'recent'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-greyOpacity text-white'
|
||||
}`}
|
||||
label="Recent Activities"
|
||||
>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<label htmlFor="date-picker" className="text-sm font-medium">
|
||||
View by date:
|
||||
</label>
|
||||
<input
|
||||
id="date-picker"
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
onChange={(e) => handleDateChange(e.target.value)}
|
||||
className="px-3 py-1 border border-greyOpacity rounded-md"
|
||||
max={new Date().toISOString().split('T')[0]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center h-32">
|
||||
<div className="text-white">Loading activities...</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex justify-center items-center h-32">
|
||||
<div className="text-red-500">Error: {error.message}</div>
|
||||
</div>
|
||||
) : activities.length === 0 ? (
|
||||
<div className="text-center text-white py-12">
|
||||
<div className="text-4xl mb-4">📝</div>
|
||||
<h3 className="text-lg font-medium mb-2">No activities found</h3>
|
||||
<p className="text-sm">
|
||||
{viewMode === 'date'
|
||||
? 'No todos were completed on this date.'
|
||||
: 'Start completing todos to see your activity here!'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{Object.entries(groupedActivities).map(([date, dayActivities]) => (
|
||||
<div key={date} className="border-l-4 border-blue-500 pl-4">
|
||||
<h3 className="font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<span className="text-blue-600">📅</span>
|
||||
{date}
|
||||
<span className="text-sm text-white ml-2">
|
||||
({dayActivities.length} completed)
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{dayActivities.map((activity) => {
|
||||
const { time } = formatDateTime(activity.completedAt);
|
||||
return (
|
||||
<div
|
||||
key={activity.id}
|
||||
className="bg-greyOpacity border border-white rounded-lg p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: activity.todo.color }}
|
||||
/>
|
||||
<h4 className="font-medium text-white">
|
||||
{activity.todo.title}
|
||||
</h4>
|
||||
<span className="text-xs text-white bg-greyOpacity px-2 py-1 rounded">
|
||||
{time}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{activity.todo.description && (
|
||||
<p className="text-sm text-white mb-2">
|
||||
{activity.todo.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activity.description && (
|
||||
<p className="text-sm text-green-800">
|
||||
{activity.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t bg-greyOpacity flex justify-end">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md"
|
||||
label="Close"
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
166
web/frontend/src/pages/work/components/TodoCard.tsx
Normal file
166
web/frontend/src/pages/work/components/TodoCard.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useState } from 'react';
|
||||
import { TodoWithStats, CompleteTodoRequest } from '../../../api/controller/todo';
|
||||
import Button from '@greatness/components/src/Button';
|
||||
|
||||
interface TodoCardProps {
|
||||
todo: TodoWithStats;
|
||||
onComplete: (id: string, data: CompleteTodoRequest) => void;
|
||||
onEdit: (todo: TodoWithStats) => void;
|
||||
onDelete: (id: string) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function TodoCard({ todo, onComplete, onEdit, onDelete, isLoading = false }: TodoCardProps) {
|
||||
const [showCompleteForm, setShowCompleteForm] = useState(false);
|
||||
const [completionDescription, setCompletionDescription] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (todo.completedToday) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onComplete(todo.id, { description: completionDescription });
|
||||
setShowCompleteForm(false);
|
||||
setCompletionDescription('');
|
||||
} catch (error) {
|
||||
console.error('Failed to complete todo:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStreakColor = (streak: number) => {
|
||||
if (streak === 0) return 'text-gray-500';
|
||||
if (streak < 7) return 'text-yellow-600';
|
||||
if (streak < 30) return 'text-orange-600';
|
||||
return 'text-green-600';
|
||||
};
|
||||
|
||||
const getStreakIcon = (streak: number) => {
|
||||
if (streak === 0) return '⚪';
|
||||
if (streak < 7) return '🔥';
|
||||
if (streak < 30) return '🚀';
|
||||
return '⭐';
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative p-4 rounded-lg border-2 shadow-sm transition-all hover:shadow-md"
|
||||
style={{
|
||||
borderColor: todo.color,
|
||||
backgroundColor: todo.completedToday ? `${todo.color}10` : 'var(--color-greyOpacity'
|
||||
}}
|
||||
>
|
||||
{/* Color indicator */}
|
||||
<div
|
||||
className="absolute top-0 left-0 w-full h-1 rounded-t-lg"
|
||||
style={{ backgroundColor: todo.color }}
|
||||
/>
|
||||
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900 mb-1">{todo.title}</h3>
|
||||
{todo.description && (
|
||||
<p className="text-sm text-gray-600 mb-2">{todo.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 ml-4">
|
||||
<button
|
||||
onClick={() => onEdit(todo)}
|
||||
className="text-gray-400 hover:text-gray-600 p-1"
|
||||
title="Edit todo"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(todo.id)}
|
||||
className="text-gray-400 hover:text-red-600 p-1"
|
||||
title="Delete todo"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex justify-between items-center mb-3 text-sm">
|
||||
<div className="flex gap-4">
|
||||
<span className={`font-medium ${getStreakColor(todo.currentStreak)}`}>
|
||||
{getStreakIcon(todo.currentStreak)} {todo.currentStreak} day streak
|
||||
</span>
|
||||
<span className="text-gray-500">
|
||||
Best: {todo.longestStreak} days
|
||||
</span>
|
||||
<span className="text-gray-500">
|
||||
Total: {todo.totalCompletions}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{todo.lastCompletedAt && (
|
||||
<span className="text-xs text-gray-400">
|
||||
Last: {new Date(todo.lastCompletedAt).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Completion section */}
|
||||
{todo.completedToday ? (
|
||||
<div className="flex items-center gap-2 p-2 bg-green-50 rounded border border-green-200">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-green-800 font-medium">Completed today!</span>
|
||||
</div>
|
||||
) : showCompleteForm ? (
|
||||
<div className="space-y-3 p-3 bg-greyOpacity rounded border">
|
||||
<textarea
|
||||
value={completionDescription}
|
||||
onChange={(e) => setCompletionDescription(e.target.value)}
|
||||
placeholder="How did you complete this today? (optional)"
|
||||
className="w-full p-2 border rounded-md resize-none h-20 text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleComplete}
|
||||
isDisabled={isSubmitting}
|
||||
className="flex-1 bg-green-600 hover:bg-green-700 text-white py-2 px-4 rounded-md text-sm"
|
||||
label={isSubmitting ? 'Completing...' : 'Mark Complete'}
|
||||
>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowCompleteForm(false);
|
||||
setCompletionDescription('');
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md text-sm hover:bg-gray-50"
|
||||
label="Cancel"
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => setShowCompleteForm(true)}
|
||||
isDisabled={isLoading}
|
||||
className="w-full py-2 px-4 border-2 rounded-md text-sm font-medium transition-colors"
|
||||
style={{
|
||||
borderColor: todo.color,
|
||||
color: todo.color,
|
||||
backgroundColor: 'var(--color-greyOpacity)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = todo.color;
|
||||
e.currentTarget.style.color = 'white';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--color-greyOpacity)';
|
||||
e.currentTarget.style.color = todo.color;
|
||||
}}
|
||||
label="Complete Today"
|
||||
>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
192
web/frontend/src/pages/work/components/TodoForm.tsx
Normal file
192
web/frontend/src/pages/work/components/TodoForm.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { object, string, Schema } from 'yup';
|
||||
import { useFormContext, Controller } from 'react-hook-form';
|
||||
import { TodoWithStats, CreateTodoRequest, UpdateTodoRequest } from '../../../api/controller/todo';
|
||||
import Button from '@greatness/components/src/Button';
|
||||
import Modal from '@greatness/components/src/Modal';
|
||||
import Form from '@greatness/components/src/form/Form';
|
||||
import TextInput from '@greatness/components/src/form/text/TextInput';
|
||||
|
||||
interface TodoFormProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: CreateTodoRequest | UpdateTodoRequest) => Promise<void>;
|
||||
todo?: TodoWithStats; // If provided, we're editing
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_COLORS = [
|
||||
'#3B82F6', // Blue
|
||||
'#EF4444', // Red
|
||||
'#10B981', // Green
|
||||
'#F59E0B', // Yellow
|
||||
'#8B5CF6', // Purple
|
||||
'#EC4899', // Pink
|
||||
'#06B6D4', // Cyan
|
||||
'#84CC16', // Lime
|
||||
'#F97316', // Orange
|
||||
'#6366F1', // Indigo
|
||||
];
|
||||
|
||||
const todoValidation: Schema<CreateTodoRequest> = object({
|
||||
title: string()
|
||||
.required('Title is required')
|
||||
.max(200, 'Title must be less than 200 characters')
|
||||
.trim(),
|
||||
description: string()
|
||||
.max(1000, 'Description must be less than 1000 characters')
|
||||
.default(''),
|
||||
color: string()
|
||||
.required('Please select a color')
|
||||
.matches(/^#[0-9A-F]{6}$/i, 'Please select a valid color'),
|
||||
});
|
||||
|
||||
export default function TodoForm({ isOpen, onClose, onSubmit, todo, isLoading = false }: TodoFormProps) {
|
||||
const defaultValues: CreateTodoRequest = {
|
||||
title: todo?.title || '',
|
||||
description: todo?.description || '',
|
||||
color: todo?.color || DEFAULT_COLORS[0],
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (data: CreateTodoRequest) => {
|
||||
try {
|
||||
await onSubmit(data);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to save todo:', error);
|
||||
throw error; // Let form handle the error
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
handleClose={onClose}
|
||||
>
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">
|
||||
{todo ? 'Edit Todo' : 'Create New Todo'}
|
||||
</h2>
|
||||
|
||||
<Form
|
||||
defaultValues={defaultValues}
|
||||
validation={todoValidation}
|
||||
handleSubmit={handleFormSubmit}
|
||||
className="space-y-4"
|
||||
>
|
||||
<TodoFormContent
|
||||
isLoading={isLoading}
|
||||
onClose={onClose}
|
||||
todo={todo}
|
||||
/>
|
||||
</Form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Separate component for form content to access form context
|
||||
interface TodoFormContentProps {
|
||||
isLoading: boolean;
|
||||
onClose: () => void;
|
||||
todo?: TodoWithStats;
|
||||
}
|
||||
|
||||
function TodoFormContent({ isLoading, onClose, todo }: TodoFormContentProps) {
|
||||
const { watch, setValue } = useFormContext<CreateTodoRequest>();
|
||||
const watchedValues = watch();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Title */}
|
||||
<div>
|
||||
<TextInput
|
||||
name="title"
|
||||
label="Title *"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<TextInput
|
||||
name="description"
|
||||
label="Description"
|
||||
type="textarea"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Color Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
Color *
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DEFAULT_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => setValue('color', color)}
|
||||
className={`w-8 h-8 rounded-full border-2 transition-all ${
|
||||
watchedValues.color === color
|
||||
? 'border-gray-800 scale-110'
|
||||
: 'border-gray-300 hover:border-gray-500'
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
title={color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom color input */}
|
||||
<div className="mt-2">
|
||||
<input
|
||||
type="color"
|
||||
value={watchedValues.color || DEFAULT_COLORS[0]}
|
||||
onChange={(e) => setValue('color', e.target.value)}
|
||||
className="w-16 h-8 border border-gray-300 rounded cursor-pointer"
|
||||
title="Choose custom color"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-600">{watchedValues.color}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
Preview
|
||||
</label>
|
||||
<div
|
||||
className="p-3 rounded-lg border-2"
|
||||
style={{
|
||||
borderColor: watchedValues.color || DEFAULT_COLORS[0],
|
||||
backgroundColor: `${watchedValues.color || DEFAULT_COLORS[0]}10`
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full h-1 rounded-t-lg mb-2"
|
||||
style={{ backgroundColor: watchedValues.color || DEFAULT_COLORS[0] }}
|
||||
/>
|
||||
<h4 className="font-semibold text-white">
|
||||
{watchedValues.title || 'Todo Title'}
|
||||
</h4>
|
||||
{watchedValues.description && (
|
||||
<p className="text-sm text-white mt-1">{watchedValues.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
isDisabled={isLoading}
|
||||
label={isLoading ? 'Saving...' : (todo ? 'Update Todo' : 'Create Todo')}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
label="Cancel"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
16
web/frontend/src/theme/createUseStyles.ts
Normal file
16
web/frontend/src/theme/createUseStyles.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Styles } from "react-jss";
|
||||
import { createUseStyles as ogCreateUseStyles } from "react-jss";
|
||||
|
||||
import type commonTheme from "./index";
|
||||
|
||||
const createUseStyles = <Props>(
|
||||
styles:
|
||||
| Styles<string | number, Props, typeof commonTheme>
|
||||
| ((
|
||||
theme: typeof commonTheme,
|
||||
) => Styles<string | number, Props, undefined>),
|
||||
) => {
|
||||
return ogCreateUseStyles(styles);
|
||||
};
|
||||
|
||||
export default createUseStyles;
|
||||
91
web/frontend/src/theme/index.ts
Normal file
91
web/frontend/src/theme/index.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
const theme = {
|
||||
borderRadius: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
s: 4,
|
||||
m: 8,
|
||||
l: 16,
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
color: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
white: "rgba(255, 255, 255, 1)",
|
||||
whiteOpacity: "rgba(255, 255, 255, 0.3)",
|
||||
|
||||
black: "rgba(0, 0, 0, 1)",
|
||||
|
||||
grey: "rgba(238, 238, 238, 1)",
|
||||
darkGrey: "rgba(144, 144, 144, 1)",
|
||||
|
||||
red: "rgba(255, 0, 0, 1)",
|
||||
semiLightRed: "rgba(255, 0, 0, 0.75)",
|
||||
lightRed: "rgba(255, 0, 0, 0.5)",
|
||||
|
||||
lightGreen: "rgba(0, 255, 255, 0.75)",
|
||||
green: "rgba(0, 255, 0, 1)",
|
||||
darkGreen: "rgba(27, 120, 43, 1)",
|
||||
|
||||
warningOrange: "rgba(237, 111, 46, 0.8)",
|
||||
|
||||
yellow: "rgba(255, 255, 0, 0.8)",
|
||||
|
||||
blue: "rgba(0, 0, 255, 1)",
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
font: {
|
||||
letterSpacing: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
s: "0.15px",
|
||||
m: "0.32px",
|
||||
l: "0.4px",
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
lineHeight: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
s: 1.2,
|
||||
m: 1.4,
|
||||
l: 1.7,
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
size: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
xxs: 12,
|
||||
xs: 14,
|
||||
s: 16,
|
||||
m: 18,
|
||||
l: 24,
|
||||
xl: 36,
|
||||
xxl: 48,
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
},
|
||||
media: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
xs: "@media screen and (min-width: 396px)",
|
||||
sm: "@media screen and (min-width: 576px)",
|
||||
md: "@media screen and (min-width: 768px)",
|
||||
lg: "@media screen and (min-width: 992px)",
|
||||
xl: "@media screen and (min-width: 1200px)",
|
||||
xxl: "@media screen and (min-width: 1350px)",
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
sizes: {
|
||||
maxWidth: "@media screen and (min-width: 1440px)",
|
||||
maxWidthValue: 1440,
|
||||
},
|
||||
spacing: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
xxs: 2,
|
||||
xs: 4,
|
||||
s: 8,
|
||||
m: 16,
|
||||
l: 24,
|
||||
xl: 32,
|
||||
xxl: 40,
|
||||
"3xl": 48,
|
||||
"4xl": 64,
|
||||
"5xl": 128,
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
};
|
||||
|
||||
export default theme;
|
||||
22
web/frontend/src/theme/scrollbar.ts
Normal file
22
web/frontend/src/theme/scrollbar.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export const scrollbarStyles = {
|
||||
"&::-webkit-scrollbar": {
|
||||
background: "#E6E8EC",
|
||||
width: 8,
|
||||
},
|
||||
"&::-webkit-scrollbar-thumb": {
|
||||
"&:hover": {
|
||||
background: "#75787A",
|
||||
},
|
||||
background: "#C3C4C6",
|
||||
},
|
||||
};
|
||||
|
||||
export const horizontalScrollBar = {
|
||||
"&::-webkit-scrollbar": {
|
||||
background: "#E6E8EC",
|
||||
height: 8,
|
||||
},
|
||||
"&::-webkit-scrollbar-thumb": {
|
||||
backgroundColor: "#C3C4C6",
|
||||
},
|
||||
};
|
||||
1075
web/frontend/src/theme/styles.css
Normal file
1075
web/frontend/src/theme/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
24
web/frontend/src/util/JWTUtil.ts
Normal file
24
web/frontend/src/util/JWTUtil.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { TextUtil } from "@greatness/util";
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
export interface AccessToken {
|
||||
accessToken?: string;
|
||||
}
|
||||
|
||||
export default class JWTUtil {
|
||||
public static get(): AccessToken {
|
||||
return {
|
||||
accessToken: Cookies.get("accessToken"),
|
||||
};
|
||||
}
|
||||
|
||||
public static set({ accessToken }: AccessToken): void {
|
||||
if (!TextUtil.isEmpty(accessToken)) {
|
||||
Cookies.set("accessToken", accessToken!);
|
||||
}
|
||||
}
|
||||
|
||||
public static unset(): void {
|
||||
Cookies.remove("accessToken");
|
||||
}
|
||||
};
|
||||
6
web/frontend/src/util/cn.ts
Normal file
6
web/frontend/src/util/cn.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
22
web/frontend/src/util/removeQueryParameter.ts
Normal file
22
web/frontend/src/util/removeQueryParameter.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { URLSearchParamsInit } from "react-router-dom";
|
||||
|
||||
export default function removeQueryParameter(
|
||||
setSearchParams: (
|
||||
nextInit: URLSearchParamsInit,
|
||||
navigateOptions?:
|
||||
| {
|
||||
replace?: boolean | undefined;
|
||||
state?: Record<string, unknown> | null | undefined;
|
||||
}
|
||||
| undefined,
|
||||
) => void,
|
||||
...parametersToRemove: string[]
|
||||
): void {
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
parametersToRemove.forEach((parameterKey) => {
|
||||
if (queryParams.has(parameterKey)) {
|
||||
queryParams.delete(parameterKey);
|
||||
setSearchParams(queryParams);
|
||||
}
|
||||
});
|
||||
}
|
||||
32
web/frontend/src/util/useOAuthQueryToken.ts
Normal file
32
web/frontend/src/util/useOAuthQueryToken.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { TextUtil } from "@greatness/util";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import removeQueryParameter from "./removeQueryParameter";
|
||||
import useQueryParameter from "./useQueryParameter";
|
||||
import JWTUtil, { AccessToken } from "./JWTUtil";
|
||||
|
||||
export default function useOAuthQueryToken(): {
|
||||
removeTokenFromQuery: () => void;
|
||||
setToken: (response: AccessToken) => void;
|
||||
token: string | null;
|
||||
} {
|
||||
const token = useQueryParameter("token");
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (!TextUtil.isEmpty(token)) {
|
||||
JWTUtil.set({ accessToken: token! });
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const setToken = useCallback((response: AccessToken) => {
|
||||
JWTUtil.set(response);
|
||||
}, []);
|
||||
|
||||
const removeTokenFromQuery = useCallback(() => {
|
||||
removeQueryParameter(setSearchParams, "token");
|
||||
}, [setSearchParams]);
|
||||
|
||||
return { removeTokenFromQuery, setToken, token };
|
||||
}
|
||||
6
web/frontend/src/util/useQueryParameter.ts
Normal file
6
web/frontend/src/util/useQueryParameter.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
export default function useQueryParameter(parameter: string): string | null {
|
||||
const { search } = useLocation();
|
||||
return new URLSearchParams(search).get(parameter);
|
||||
}
|
||||
BIN
web/frontend/static/favicon.ico
Normal file
BIN
web/frontend/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
BIN
web/frontend/static/icons.png
Normal file
BIN
web/frontend/static/icons.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
92
web/frontend/tailwind.config.js
Normal file
92
web/frontend/tailwind.config.js
Normal file
@@ -0,0 +1,92 @@
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
darkMode: ["class"],
|
||||
prefix: "",
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
borderRadius: {
|
||||
DEFAULT: "var(--radius)",
|
||||
s: "4px",
|
||||
m: "8px",
|
||||
l: "16px",
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
colors: {
|
||||
white: "rgba(255, 255, 255, 1)",
|
||||
whiteOpacity: "rgba(255, 255, 255, 0.3)",
|
||||
black: "rgba(0, 0, 0, 1)",
|
||||
grey: "rgba(238, 238, 238, 1)",
|
||||
darkGrey: "rgba(144, 144, 144, 1)",
|
||||
red: "rgba(255, 0, 0, 1)",
|
||||
semiLightRed: "rgba(255, 0, 0, 0.75)",
|
||||
lightRed: "rgba(255, 0, 0, 0.5)",
|
||||
lightGreen: "rgba(0, 255, 255, 0.75)",
|
||||
green: "rgba(0, 255, 0, 1)",
|
||||
darkGreen: "rgba(27, 120, 43, 1)",
|
||||
warningOrange: "rgba(237, 111, 46, 0.8)",
|
||||
yellow: "rgba(255, 255, 0, 0.8)",
|
||||
blue: "rgba(0, 0, 255, 1)",
|
||||
},
|
||||
fontSize: {
|
||||
xxs: "12px",
|
||||
xs: "14px",
|
||||
s: "16px",
|
||||
m: "18px",
|
||||
l: "24px",
|
||||
xl: "36px",
|
||||
xxl: "48px",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
},
|
||||
lineHeight: {
|
||||
s: "1.2",
|
||||
m: "1.4",
|
||||
l: "1.7",
|
||||
},
|
||||
screens: {
|
||||
xs: "396px",
|
||||
sm: "576px",
|
||||
md: "768px",
|
||||
lg: "992px",
|
||||
xl: "1200px",
|
||||
xxl: "1350px",
|
||||
},
|
||||
spacing: {
|
||||
xxs: "2px",
|
||||
xs: "4px",
|
||||
s: "8px",
|
||||
m: "16px",
|
||||
l: "24px",
|
||||
xl: "32px",
|
||||
xxl: "40px",
|
||||
"3xl": "48px",
|
||||
"4xl": "64px",
|
||||
"5xl": "128px",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
};
|
||||
34
web/frontend/tsconfig.json
Normal file
34
web/frontend/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@/api/*": ["src/api/*"]
|
||||
},
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"types": [
|
||||
"node",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react-router-dom",
|
||||
"jest"
|
||||
]
|
||||
},
|
||||
"include": ["src", "tailwind.config.js"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
63
web/frontend/vite.config.mts
Normal file
63
web/frontend/vite.config.mts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
|
||||
export default defineConfig(({ command }) => {
|
||||
const plugins = [
|
||||
react(),
|
||||
tsconfigPaths(),
|
||||
tailwindcss(),
|
||||
];
|
||||
|
||||
if (process.env.ANALYZE) {
|
||||
plugins.push(visualizer({
|
||||
template: 'sunburst',
|
||||
open: true,
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
filename: 'bundle-analysis.html',
|
||||
}));
|
||||
}
|
||||
return {
|
||||
plugins,
|
||||
server: {
|
||||
port: 3456,
|
||||
host: '127.0.0.1',
|
||||
open: true,
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: command === 'serve',
|
||||
commonjsOptions: {
|
||||
include: [/node_modules/]
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'vendor': [
|
||||
'react',
|
||||
'react-dom',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: []
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@/': resolve(__dirname, './src/'),
|
||||
},
|
||||
},
|
||||
css: {
|
||||
modules: {
|
||||
localsConvention: 'camelCase',
|
||||
},
|
||||
},
|
||||
root: '.',
|
||||
};
|
||||
});
|
||||
30
web/package.json
Normal file
30
web/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "biostacker-web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"engines": {
|
||||
"node": ">=24",
|
||||
"pnpm": ">=8"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "turbo build",
|
||||
"dev": "turbo dev",
|
||||
"lint": "turbo lint",
|
||||
"lint:fix": "turbo lint:fix",
|
||||
"format": "turbo format",
|
||||
"format:check": "turbo format:check",
|
||||
"test": "turbo test",
|
||||
"test:watch": "turbo test:watch",
|
||||
"clean": "turbo clean",
|
||||
"start": "turbo dev --filter=biostacker-frontend",
|
||||
"biome": "biome"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "2.5.4",
|
||||
"@biomejs/biome": "2.0.6",
|
||||
"@types/node": "24.0.10",
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.12.4"
|
||||
}
|
||||
7087
web/pnpm-lock.yaml
generated
Normal file
7087
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
web/pnpm-workspace.yaml
Normal file
13
web/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
packages:
|
||||
- frontend
|
||||
- util
|
||||
- components
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- '@carbon/icon-helpers'
|
||||
- '@carbon/icons-react'
|
||||
- '@parcel/watcher'
|
||||
- '@scarf/scarf'
|
||||
- '@swc/core'
|
||||
- '@tailwindcss/oxide'
|
||||
- esbuild
|
||||
22
web/tsconfig.base.json
Normal file
22
web/tsconfig.base.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"noEmit": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
72
web/turbo.json
Normal file
72
web/turbo.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"ui": "tui",
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"inputs": ["src/**/*.ts", "src/**/*.tsx", "*.ts", "*.tsx", "package.json", "tsconfig.json"],
|
||||
"outputs": ["dist/**", "build/**", ".next/**", "!.next/cache/**"]
|
||||
},
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
"lint": {
|
||||
"dependsOn": ["^build"],
|
||||
"inputs": ["src/**/*.ts", "src/**/*.tsx", "*.ts", "*.tsx", "biome.json", "package.json"],
|
||||
"outputs": []
|
||||
},
|
||||
"lint:fix": {
|
||||
"dependsOn": ["^build"],
|
||||
"inputs": ["src/**/*.ts", "src/**/*.tsx", "*.ts", "*.tsx", "biome.json", "package.json"],
|
||||
"outputs": ["src/**/*.ts", "src/**/*.tsx", "*.ts", "*.tsx"],
|
||||
"cache": false
|
||||
},
|
||||
"format": {
|
||||
"inputs": ["src/**/*.ts", "src/**/*.tsx", "*.ts", "*.tsx", "biome.json", "package.json"],
|
||||
"outputs": ["src/**/*.ts", "src/**/*.tsx", "*.ts", "*.tsx"],
|
||||
"cache": false
|
||||
},
|
||||
"format:check": {
|
||||
"inputs": ["src/**/*.ts", "src/**/*.tsx", "*.ts", "*.tsx", "biome.json", "package.json"],
|
||||
"outputs": []
|
||||
},
|
||||
"check": {
|
||||
"inputs": ["src/**/*.ts", "src/**/*.tsx", "*.ts", "*.tsx", "biome.json", "package.json"],
|
||||
"outputs": []
|
||||
},
|
||||
"test": {
|
||||
"dependsOn": ["^build"],
|
||||
"inputs": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.test.ts", "src/**/*.test.tsx", "jest.config.*", "package.json"],
|
||||
"outputs": ["coverage/**"]
|
||||
},
|
||||
"test:watch": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
"clean": {
|
||||
"cache": false,
|
||||
"outputs": []
|
||||
},
|
||||
"start": {
|
||||
"dependsOn": ["^build"],
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
"db:migrate": {
|
||||
"dependsOn": ["^build"],
|
||||
"cache": false,
|
||||
"outputs": []
|
||||
},
|
||||
"db:push": {
|
||||
"dependsOn": ["^build"],
|
||||
"cache": false,
|
||||
"outputs": []
|
||||
},
|
||||
"tsoa:spec-and-routes": {
|
||||
"inputs": ["src/**/*.ts", "tsconfig.json", "package.json"],
|
||||
"outputs": ["src/routes/**", "dist/swagger.json"]
|
||||
}
|
||||
},
|
||||
"globalDependencies": ["**/.env.*local", "**/.env", "biome.json"]
|
||||
}
|
||||
15
web/util/package.json
Normal file
15
web/util/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@greatness/util",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"dayjs": "1.11.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "30.0.5"
|
||||
}
|
||||
}
|
||||
43
web/util/src/ArrayUtil.test.ts
Normal file
43
web/util/src/ArrayUtil.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import ArrayUtil from "./ArrayUtil";
|
||||
|
||||
describe("ArrayUtil", () => {
|
||||
it("isEmpty", () => {
|
||||
expect(ArrayUtil.isEmpty()).toBe(true);
|
||||
expect(ArrayUtil.isEmpty(null)).toBe(true);
|
||||
expect(ArrayUtil.isEmpty([])).toBe(true);
|
||||
|
||||
expect(ArrayUtil.isEmpty([undefined])).toBe(false);
|
||||
});
|
||||
|
||||
it("removeDuplicates", () => {
|
||||
expect(
|
||||
ArrayUtil.removeDuplicates(
|
||||
[
|
||||
{ val: "a" },
|
||||
{ val: "a" },
|
||||
{ val: "b" },
|
||||
{ val: "b " },
|
||||
{ val: "c" },
|
||||
{ val: "c" },
|
||||
{ val: "d" },
|
||||
],
|
||||
"val",
|
||||
),
|
||||
).toEqual([
|
||||
{ val: "a" },
|
||||
{ val: "b" },
|
||||
{ val: "b " },
|
||||
{ val: "c" },
|
||||
{ val: "d" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uniqueStrings", () => {
|
||||
expect(ArrayUtil.uniqueStrings(["A@a.ee", "a@A.ee", "a@a.ee"])).toEqual([
|
||||
"a@a.ee",
|
||||
]);
|
||||
expect(
|
||||
ArrayUtil.uniqueStrings(["A.B@a.ee", "a.b@A.ee", "a.b@a.ee", "b.c@a.ee"]),
|
||||
).toEqual(["a.b@a.ee", "b.c@a.ee"]);
|
||||
});
|
||||
});
|
||||
21
web/util/src/ArrayUtil.ts
Normal file
21
web/util/src/ArrayUtil.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export default class ArrayUtil {
|
||||
public static isEmpty(array?: unknown[] | null): boolean {
|
||||
return !Array.isArray(array) || array.length === 0;
|
||||
}
|
||||
|
||||
public static removeDuplicates<T>(array: T[], key: keyof T): T[] {
|
||||
const seen = new Set();
|
||||
return array.filter((item) => {
|
||||
const value = item[key];
|
||||
if (seen.has(value)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(value);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public static uniqueStrings(array: string[]): string[] {
|
||||
return [...new Set(array.map((value) => value.toLowerCase()))];
|
||||
}
|
||||
}
|
||||
42
web/util/src/BlobUtil.test.ts
Normal file
42
web/util/src/BlobUtil.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import BlobUtil from "./BlobUtil";
|
||||
|
||||
describe("BlobUtil", () => {
|
||||
describe("blobToBase64", () => {
|
||||
test("converts blob to base64 string", async () => {
|
||||
const testString = "Hello, World!";
|
||||
const blob = new Blob([testString], { type: "text/plain" });
|
||||
|
||||
const result = await BlobUtil.blobToBase64(blob);
|
||||
|
||||
expect(result).toBe("SGVsbG8sIFdvcmxkIQ==");
|
||||
});
|
||||
|
||||
test("converts empty blob to empty base64 string", async () => {
|
||||
const blob = new Blob([], { type: "text/plain" });
|
||||
|
||||
const result = await BlobUtil.blobToBase64(blob);
|
||||
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
test("converts binary data blob to base64", async () => {
|
||||
const binaryData = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" in ASCII
|
||||
const blob = new Blob([binaryData], { type: "application/octet-stream" });
|
||||
|
||||
const result = await BlobUtil.blobToBase64(blob);
|
||||
|
||||
expect(result).toBe("SGVsbG8=");
|
||||
});
|
||||
|
||||
test("handles blob with special characters", async () => {
|
||||
const testString = "Testing 123 @#$%";
|
||||
const blob = new Blob([testString], { type: "text/plain" });
|
||||
|
||||
const result = await BlobUtil.blobToBase64(blob);
|
||||
|
||||
// Convert back to verify
|
||||
const decoded = Buffer.from(result, "base64").toString();
|
||||
expect(decoded).toBe(testString);
|
||||
});
|
||||
});
|
||||
});
|
||||
6
web/util/src/BlobUtil.ts
Normal file
6
web/util/src/BlobUtil.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export default class BlobUtil {
|
||||
public static async blobToBase64(blob: Blob): Promise<string> {
|
||||
const blobBuffer = Buffer.from(await blob.arrayBuffer());
|
||||
return blobBuffer.toString("base64");
|
||||
}
|
||||
}
|
||||
21
web/util/src/BooleanUtil.test.ts
Normal file
21
web/util/src/BooleanUtil.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import BooleanUtil from "./BooleanUtil";
|
||||
|
||||
describe("BooleanUtil", () => {
|
||||
it("isTrue", () => {
|
||||
expect(BooleanUtil.isTrue("true")).toEqual(true);
|
||||
expect(BooleanUtil.isTrue(true)).toEqual(true);
|
||||
|
||||
expect(BooleanUtil.isTrue("")).toEqual(false);
|
||||
expect(BooleanUtil.isTrue()).toEqual(false);
|
||||
expect(BooleanUtil.isTrue("false")).toEqual(false);
|
||||
});
|
||||
|
||||
it("isFalse", () => {
|
||||
expect(BooleanUtil.isFalse("false")).toEqual(true);
|
||||
expect(BooleanUtil.isFalse(false)).toEqual(true);
|
||||
|
||||
expect(BooleanUtil.isFalse("")).toEqual(false);
|
||||
expect(BooleanUtil.isFalse()).toEqual(false);
|
||||
expect(BooleanUtil.isFalse("true")).toEqual(false);
|
||||
});
|
||||
});
|
||||
9
web/util/src/BooleanUtil.ts
Normal file
9
web/util/src/BooleanUtil.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export default class BooleanUtil {
|
||||
public static isTrue(value?: unknown): boolean {
|
||||
return value === true || value === "true";
|
||||
}
|
||||
|
||||
public static isFalse(value?: unknown): boolean {
|
||||
return value === false || value === "false";
|
||||
}
|
||||
}
|
||||
179
web/util/src/CommonUtil.test.ts
Normal file
179
web/util/src/CommonUtil.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import CommonUtil from "./CommonUtil";
|
||||
|
||||
describe("Util", () => {
|
||||
describe("query", () => {
|
||||
test("returns empty string when empty object is passed", () => {
|
||||
const result = CommonUtil.query({});
|
||||
expect(result).toEqual("");
|
||||
});
|
||||
|
||||
test("returns encoded query string with single key-value pair", () => {
|
||||
const result = CommonUtil.query({ key: "value" });
|
||||
expect(result).toEqual("key=value");
|
||||
});
|
||||
|
||||
test("returns encoded query string with multiple key-value pairs", () => {
|
||||
const result = CommonUtil.query({ key1: "value1", key2: "value2" });
|
||||
expect(result).toEqual("key1=value1&key2=value2");
|
||||
});
|
||||
|
||||
test("ignores key-value pairs with undefined values", () => {
|
||||
const result = CommonUtil.query({ key1: "value1", key2: undefined });
|
||||
expect(result).toEqual("key1=value1");
|
||||
});
|
||||
|
||||
test("ignores key-value pairs with undefined keys", () => {
|
||||
const result = CommonUtil.query({ key1: "value1", undefined: "value2" });
|
||||
expect(result).toEqual("key1=value1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sleep", () => {
|
||||
test("resolves after the specified time", async () => {
|
||||
const start = new Date();
|
||||
await CommonUtil.sleep(200);
|
||||
const end = new Date();
|
||||
const duration = end.getTime() - start.getTime();
|
||||
expect(duration).toBeGreaterThan(198);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sleepRandom", () => {
|
||||
test("resolves after the specified time", async () => {
|
||||
const start = new Date();
|
||||
await CommonUtil.sleepRandom(300);
|
||||
const end = new Date();
|
||||
const duration = end.getTime() - start.getTime();
|
||||
expect(duration).toBeGreaterThan(299);
|
||||
});
|
||||
});
|
||||
|
||||
describe("minutesToMs", () => {
|
||||
test("converts minutes to milliseconds", () => {
|
||||
const result = CommonUtil.minutesToMs(2);
|
||||
expect(result).toEqual(120000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("between", () => {
|
||||
test("returns true when number is between min and max", () => {
|
||||
expect(CommonUtil.between(5, 1, 10)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true when number equals min", () => {
|
||||
expect(CommonUtil.between(5, 5, 10)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true when number equals max", () => {
|
||||
expect(CommonUtil.between(10, 5, 10)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when number is below min", () => {
|
||||
expect(CommonUtil.between(1, 5, 10)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when number is above max", () => {
|
||||
expect(CommonUtil.between(15, 5, 10)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("futureDateByHours", () => {
|
||||
test("returns date in future by specified hours", () => {
|
||||
const now = new Date();
|
||||
const futureDate = CommonUtil.futureDateByHours(2);
|
||||
|
||||
expect(futureDate.getTime()).toBeGreaterThan(now.getTime());
|
||||
expect(futureDate.getHours()).toBe((now.getHours() + 2) % 24);
|
||||
});
|
||||
});
|
||||
|
||||
describe("futureDateByMinutes", () => {
|
||||
test("returns date in future by specified minutes", () => {
|
||||
const now = new Date();
|
||||
const futureDate = CommonUtil.futureDateByMinutes(30);
|
||||
|
||||
expect(futureDate.getTime()).toBeGreaterThan(now.getTime());
|
||||
const expectedTime = now.getTime() + (30 + 1) * 60 * 1000; // +1 for padding
|
||||
expect(Math.abs(futureDate.getTime() - expectedTime)).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("futureDateBySeconds", () => {
|
||||
test("returns date in future by specified seconds", () => {
|
||||
const now = new Date();
|
||||
const futureDate = CommonUtil.futureDateBySeconds(30);
|
||||
|
||||
expect(futureDate.getTime()).toBeGreaterThan(now.getTime());
|
||||
const expectedTime = now.getTime() + 30 * 1000;
|
||||
expect(Math.abs(futureDate.getTime() - expectedTime)).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hoursToMinutes", () => {
|
||||
test("converts hours to minutes", () => {
|
||||
expect(CommonUtil.hoursToMinutes(2)).toBe(120);
|
||||
expect(CommonUtil.hoursToMinutes(0.5)).toBe(30);
|
||||
expect(CommonUtil.hoursToMinutes(24)).toBe(1440);
|
||||
});
|
||||
});
|
||||
|
||||
describe("minutesToSeconds", () => {
|
||||
test("converts minutes to seconds", () => {
|
||||
expect(CommonUtil.minutesToSeconds(1)).toBe(60);
|
||||
expect(CommonUtil.minutesToSeconds(5)).toBe(300);
|
||||
expect(CommonUtil.minutesToSeconds(0.5)).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe("random", () => {
|
||||
test("returns number within specified range", () => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const result = CommonUtil.random(1, 10);
|
||||
expect(result).toBeGreaterThanOrEqual(1);
|
||||
expect(result).toBeLessThanOrEqual(10);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns single number when min equals max", () => {
|
||||
expect(CommonUtil.random(5, 5)).toBe(5);
|
||||
});
|
||||
|
||||
test("handles negative numbers", () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const result = CommonUtil.random(-10, -1);
|
||||
expect(result).toBeGreaterThanOrEqual(-10);
|
||||
expect(result).toBeLessThanOrEqual(-1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("query - extended tests", () => {
|
||||
test("handles boolean values", () => {
|
||||
const result = CommonUtil.query({ active: true, visible: false });
|
||||
expect(result).toBe("active=true&visible=false");
|
||||
});
|
||||
|
||||
test("handles number values", () => {
|
||||
const result = CommonUtil.query({ count: 42, rating: 4.5 });
|
||||
expect(result).toBe("count=42&rating=4.5");
|
||||
});
|
||||
|
||||
test("handles special characters in values", () => {
|
||||
const result = CommonUtil.query({
|
||||
search: "hello world",
|
||||
tag: "tag&value",
|
||||
});
|
||||
expect(result).toBe("search=hello%20world&tag=tag%26value");
|
||||
});
|
||||
|
||||
test("ignores keys that are string 'undefined'", () => {
|
||||
const result = CommonUtil.query({ key1: "value1", undefined: "value2" });
|
||||
expect(result).toBe("key1=value1");
|
||||
});
|
||||
|
||||
test("handles zero values", () => {
|
||||
const result = CommonUtil.query({ count: 0, rating: 0 });
|
||||
expect(result).toBe("count=0&rating=0");
|
||||
});
|
||||
});
|
||||
});
|
||||
58
web/util/src/CommonUtil.ts
Normal file
58
web/util/src/CommonUtil.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
export type QueryParams = Record<string, string | number | boolean | undefined>;
|
||||
|
||||
const CommonUtil = {
|
||||
between(x: number, min: number, max: number) {
|
||||
return x >= min && x <= max;
|
||||
},
|
||||
futureDateByHours(hours: number): Date {
|
||||
return this.futureDateByMinutes(this.hoursToMinutes(hours));
|
||||
},
|
||||
futureDateByMinutes(minutes: number): Date {
|
||||
const MINUTES_EXTRA_PADDING = 1;
|
||||
const minutesInFuture = minutes + MINUTES_EXTRA_PADDING;
|
||||
return new Date(
|
||||
new Date().setMinutes(new Date().getMinutes() + minutesInFuture),
|
||||
);
|
||||
},
|
||||
futureDateBySeconds(secondsInFuture: number) {
|
||||
return new Date(
|
||||
new Date().setSeconds(new Date().getSeconds() + secondsInFuture),
|
||||
);
|
||||
},
|
||||
hoursToMinutes(hours: number) {
|
||||
return hours * 60;
|
||||
},
|
||||
minutesToMs: (minutes: number) => minutes * 60_000,
|
||||
minutesToSeconds(minutes: number) {
|
||||
return minutes * 60;
|
||||
},
|
||||
query: (params: QueryParams) =>
|
||||
Object.entries(params)
|
||||
.map(([paramKey, paramValue]) => {
|
||||
if (
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
paramKey === undefined ||
|
||||
paramKey === "undefined" ||
|
||||
paramValue === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const [key, value] = [
|
||||
encodeURIComponent(paramKey),
|
||||
encodeURIComponent(paramValue),
|
||||
];
|
||||
return `${key}=${value}`;
|
||||
})
|
||||
.filter((value) => value !== null)
|
||||
.join("&"),
|
||||
random: (min: number, max: number) =>
|
||||
Math.floor(Math.random() * (max - min + 1) + min),
|
||||
async sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
},
|
||||
async sleepRandom(ms: number) {
|
||||
return this.sleep(this.random(ms, ms + ms * 0.2));
|
||||
},
|
||||
};
|
||||
|
||||
export default CommonUtil;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user