This commit is contained in:
2025-11-03 12:24:01 +02:00
commit 0806865287
177 changed files with 18453 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
VITE_API_PATH=http://localhost:8555/api
VITE_NEW_API_PATH=http://localhost:8555/api

View 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
View 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
View 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"
]
}

View 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
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_PATH: string;
// more env variables...
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View 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",
}

View 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),
};

View 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),
};

View 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,
};
}
};

View 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;
}
>;

View File

@@ -0,0 +1,5 @@
export type APIFieldError = {
field?: string;
message: string;
messageCode?: string;
};

View 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,
});
};

View 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>
);
}

View 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>
);
};

View 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>
);
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,11 @@
export const NavigationPath = {
Home: {
Base: "/portal",
Dashboard: "dashboard",
Supplements: "supplements",
Work: "work",
Root: "",
},
Login: "/login",
Root: "/",
};

View 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;

View 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[];
}

View 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;
};

View 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"
}
}
}
}

View 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>,
);

View File

@@ -0,0 +1,6 @@
export default function DashboardPage() {
return (
<div className="flex flex-col gap-m py-xxl">
</div>
);
};

View 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>
);
}

View 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>
);
}

View 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>
);
};

View 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>
);
}

View 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>
);
};

View 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>
);
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
};

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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;

View 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;

View 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",
},
};

File diff suppressed because it is too large Load Diff

View 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");
}
};

View 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));
}

View 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);
}
});
}

View 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 };
}

View 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);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View 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")],
};

View 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"]
}

View 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: '.',
};
});