Compare commits
2 Commits
83e019c8fa
...
1a8a315afc
Author | SHA1 | Date | |
---|---|---|---|
1a8a315afc | |||
bee72ee8a3 |
@ -20,7 +20,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>kangaroo-and-rabbit</groupId>
|
<groupId>kangaroo-and-rabbit</groupId>
|
||||||
<artifactId>archidata</artifactId>
|
<artifactId>archidata</artifactId>
|
||||||
<version>0.21.1-SNAPSHOT</version>
|
<version>0.23.2</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<!-- Loopback of logger JDK logging API to SLF4J -->
|
<!-- Loopback of logger JDK logging API to SLF4J -->
|
||||||
<dependency>
|
<dependency>
|
||||||
|
@ -6,6 +6,7 @@ import java.util.List;
|
|||||||
import org.kar.archidata.api.DataResource;
|
import org.kar.archidata.api.DataResource;
|
||||||
import org.kar.archidata.externalRestApi.AnalyzeApi;
|
import org.kar.archidata.externalRestApi.AnalyzeApi;
|
||||||
import org.kar.archidata.externalRestApi.TsGenerateApi;
|
import org.kar.archidata.externalRestApi.TsGenerateApi;
|
||||||
|
import org.kar.archidata.model.token.JwtToken;
|
||||||
import org.kar.archidata.tools.ConfigBaseVariable;
|
import org.kar.archidata.tools.ConfigBaseVariable;
|
||||||
import org.kar.karso.api.ApplicationResource;
|
import org.kar.karso.api.ApplicationResource;
|
||||||
import org.kar.karso.api.ApplicationTokenResource;
|
import org.kar.karso.api.ApplicationTokenResource;
|
||||||
@ -30,6 +31,7 @@ public class WebLauncherLocal extends WebLauncher {
|
|||||||
SystemConfigResource.class);
|
SystemConfigResource.class);
|
||||||
final AnalyzeApi api = new AnalyzeApi();
|
final AnalyzeApi api = new AnalyzeApi();
|
||||||
api.addAllApi(listOfResources);
|
api.addAllApi(listOfResources);
|
||||||
|
api.addModel(JwtToken.class);
|
||||||
TsGenerateApi.generateApi(api, "../front/src/back-api/");
|
TsGenerateApi.generateApi(api, "../front/src/back-api/");
|
||||||
LOGGER.info("Generate APIs (DONE)");
|
LOGGER.info("Generate APIs (DONE)");
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@ import org.kar.archidata.dataAccess.DBAccess;
|
|||||||
import org.kar.archidata.dataAccess.addOnSQL.AddOnManyToMany;
|
import org.kar.archidata.dataAccess.addOnSQL.AddOnManyToMany;
|
||||||
import org.kar.archidata.filter.PartRight;
|
import org.kar.archidata.filter.PartRight;
|
||||||
import org.kar.archidata.migration.MigrationSqlStep;
|
import org.kar.archidata.migration.MigrationSqlStep;
|
||||||
|
import org.kar.archidata.model.token.JwtToken;
|
||||||
import org.kar.karso.api.UserResource;
|
import org.kar.karso.api.UserResource;
|
||||||
import org.kar.karso.model.Application;
|
import org.kar.karso.model.Application;
|
||||||
import org.kar.karso.model.ApplicationToken;
|
import org.kar.karso.model.ApplicationToken;
|
||||||
@ -22,7 +23,7 @@ public class Initialization extends MigrationSqlStep {
|
|||||||
public static final int KARSO_INITIALISATION_ID = 1;
|
public static final int KARSO_INITIALISATION_ID = 1;
|
||||||
|
|
||||||
public static final List<Class<?>> CLASSES_BASE = List.of(Settings.class, UserAuth.class, Application.class,
|
public static final List<Class<?>> CLASSES_BASE = List.of(Settings.class, UserAuth.class, Application.class,
|
||||||
ApplicationToken.class, RightDescription.class, Right.class);
|
ApplicationToken.class, RightDescription.class, Right.class, JwtToken.class);
|
||||||
// created object
|
// created object
|
||||||
private Application app = null;
|
private Application app = null;
|
||||||
private UserAuth user = null;
|
private UserAuth user = null;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
package org.kar.karso.model;
|
package org.kar.karso.model;
|
||||||
|
|
||||||
import org.kar.archidata.dataAccess.options.CheckJPA;
|
import org.kar.archidata.checker.CheckJPA;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
|
||||||
|
@ -7,7 +7,7 @@ import java.time.ZoneOffset;
|
|||||||
import java.time.ZonedDateTime;
|
import java.time.ZonedDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
import org.kar.archidata.dataAccess.options.CheckJPA;
|
import org.kar.archidata.checker.CheckJPA;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@ import java.sql.Timestamp;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import org.kar.archidata.annotation.DataIfNotExists;
|
import org.kar.archidata.annotation.DataIfNotExists;
|
||||||
import org.kar.archidata.dataAccess.options.CheckJPA;
|
import org.kar.archidata.checker.CheckJPA;
|
||||||
import org.kar.archidata.model.User;
|
import org.kar.archidata.model.User;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
package org.kar.karso.model;
|
package org.kar.karso.model;
|
||||||
|
|
||||||
import org.kar.archidata.dataAccess.options.CheckJPA;
|
import org.kar.archidata.checker.CheckJPA;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
|
||||||
|
2
front/.env.production
Normal file
2
front/.env.production
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# URL for database connection
|
||||||
|
VITE_API_BASE_URL=karso/api/
|
27
front/.storybook/main.ts
Normal file
27
front/.storybook/main.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import type { StorybookConfig } from '@storybook/react-vite';
|
||||||
|
|
||||||
|
const config: StorybookConfig = {
|
||||||
|
framework: {
|
||||||
|
name: '@storybook/react-vite',
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
|
||||||
|
core: {
|
||||||
|
disableTelemetry: true,
|
||||||
|
builder: '@storybook/builder-vite',
|
||||||
|
},
|
||||||
|
|
||||||
|
stories: ['../src/**/*.@(mdx|stories.@(js|jsx|ts|tsx))'],
|
||||||
|
|
||||||
|
addons: ['@storybook/addon-links', '@storybook/addon-essentials'],
|
||||||
|
|
||||||
|
staticDirs: ['../public'],
|
||||||
|
|
||||||
|
typescript: {
|
||||||
|
reactDocgen: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
docs: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
16
front/.storybook/preview-head.html
Normal file
16
front/.storybook/preview-head.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<style>
|
||||||
|
html {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-story > :first-child {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.docs-story > * {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root #start-ui-storybook-wrapper {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
43
front/.storybook/preview.tsx
Normal file
43
front/.storybook/preview.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Box } from '@chakra-ui/react';
|
||||||
|
import { ChakraProvider } from '@chakra-ui/react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
|
||||||
|
import theme from '../src/theme';
|
||||||
|
|
||||||
|
// .storybook/preview.js
|
||||||
|
export const parameters = {
|
||||||
|
options: {
|
||||||
|
storySort: {
|
||||||
|
order: ['StyleGuide', 'Components', 'Fields', 'App Layout'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions: {},
|
||||||
|
layout: 'fullscreen',
|
||||||
|
backgrounds: { disable: true, grid: { disable: true } },
|
||||||
|
chakra: {
|
||||||
|
theme,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const DocumentationWrapper = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<Box id="start-ui-storybook-wrapper" p="4" pb="8" flex="1">
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decorators = [
|
||||||
|
(Story, context) => (
|
||||||
|
<ChakraProvider theme={theme}>
|
||||||
|
{/* Using MemoryRouter to avoid route clashing with Storybook */}
|
||||||
|
<MemoryRouter>
|
||||||
|
<DocumentationWrapper>
|
||||||
|
<Story {...context} />
|
||||||
|
</DocumentationWrapper>
|
||||||
|
</MemoryRouter>
|
||||||
|
</ChakraProvider>
|
||||||
|
),
|
||||||
|
];
|
2
front/LICENSE
Normal file
2
front/LICENSE
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
Proprietary
|
||||||
|
@copyright Edouard Dupin 2024
|
6
front/app-build.json
Normal file
6
front/app-build.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"display": "2025-01-14",
|
||||||
|
"version": "0.0.1-dev\n - 2025-01-14T20:19:20+01:00",
|
||||||
|
"commit": "0.0.1-dev\n",
|
||||||
|
"date": "2025-01-14T20:19:20+01:00"
|
||||||
|
}
|
25
front/build.js
Normal file
25
front/build.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
const dayjs = require('dayjs');
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const generateAppBuild = () => {
|
||||||
|
const getVersion = () => fs.readFileSync('version.txt', 'utf8');
|
||||||
|
|
||||||
|
const commit = process.env.VERCEL_GIT_COMMIT_SHA
|
||||||
|
? process.env.VERCEL_GIT_COMMIT_SHA
|
||||||
|
: getVersion();
|
||||||
|
|
||||||
|
const appBuildContent = {
|
||||||
|
display: `${dayjs().format('YYYY-MM-DD')}`,
|
||||||
|
version: `${commit} - ${dayjs().format()}`,
|
||||||
|
commit,
|
||||||
|
date: dayjs().format(),
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
'./app-build.json',
|
||||||
|
JSON.stringify(appBuildContent, null, 2)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
generateAppBuild();
|
10888
front/config sample.ts
Normal file
10888
front/config sample.ts
Normal file
File diff suppressed because it is too large
Load Diff
13
front/index.html
Normal file
13
front/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>karso</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
</head>
|
||||||
|
<body style="width:100vw;height:100vh;min-width:100%;min-height:100%;">
|
||||||
|
<div id="root" style="width:100%;height:100%;min-width:100%;min-height:100%;"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
18
front/knip.ts
Normal file
18
front/knip.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import type { KnipConfig } from 'knip';
|
||||||
|
|
||||||
|
const config: KnipConfig = {
|
||||||
|
// Ignoring mostly shell binaries
|
||||||
|
ignoreBinaries: ['export', 'sleep'],
|
||||||
|
ignore: [
|
||||||
|
// Related to tests
|
||||||
|
'tests/**',
|
||||||
|
'**.conf.js',
|
||||||
|
'steps.d.ts',
|
||||||
|
'steps_file.js',
|
||||||
|
'env_ci/codecept.conf.js',
|
||||||
|
// Generic components are useful.
|
||||||
|
'src/components/**',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
@ -1,64 +1,94 @@
|
|||||||
{
|
{
|
||||||
"name": "karso",
|
"name": "karso",
|
||||||
"version": "0.0.0",
|
|
||||||
"license": "MPL-2",
|
|
||||||
"scripts": {
|
|
||||||
"all": "npm run build && npm run test",
|
|
||||||
"ng": "ng",
|
|
||||||
"dev": "ng serve karso --configuration=develop --watch --port 4200",
|
|
||||||
"dev-hot-update": "ng serve karso --configuration=develop --watch --hmr --port 4200",
|
|
||||||
"dev_edge": "ng serve karso-edge --configuration=develop --watch --port 4199",
|
|
||||||
"build": "ng build karso --prod",
|
|
||||||
"test": "ng test karso",
|
|
||||||
"test-coverage": "ng test karso --code-coverage",
|
|
||||||
"lint": "ng lint",
|
|
||||||
"style": "prettier --write .",
|
|
||||||
"e2e": "ng e2e",
|
|
||||||
"update_packages": "ncu --upgrade",
|
|
||||||
"install_dependency": "pnpm install --force",
|
|
||||||
"link_kar_cw": "pnpm link ../../kar-cw/dist/kar-cw/",
|
|
||||||
"unlink_kar_cw": "pnpm unlink ../../kar-cw/dist/kar-cw/"
|
|
||||||
},
|
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "KAR web SSO application",
|
||||||
|
"author": {
|
||||||
|
"name": "Edouard DUPIN",
|
||||||
|
"email": "yui.heero@gmail.farm"
|
||||||
|
},
|
||||||
|
"license": "PROPRIETARY",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"update_packages": "ncu --target minor",
|
||||||
|
"upgrade_packages": "ncu --upgrade ",
|
||||||
|
"install_dependency": "pnpm install",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest watch",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"static:build": "node build.js && pnpm build",
|
||||||
|
"dev": "vite",
|
||||||
|
"pretty": "prettier -w .",
|
||||||
|
"lint": "pnpm tsc --noEmit",
|
||||||
|
"storybook": "storybook dev -p 3001",
|
||||||
|
"storybook:build": "storybook build && mv ./storybook-static ./public/storybook"
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*.{ts,tsx,js,jsx,json}": "prettier --write"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^18.0.2",
|
"@lukemorales/query-key-factory": "1.3.4",
|
||||||
"@angular/cdk": "^18.0.2",
|
"@tanstack/react-query": "^5.65.1",
|
||||||
"@angular/common": "^18.0.2",
|
"@tanstack/react-query-devtools": "^5.65.1",
|
||||||
"@angular/compiler": "^18.0.2",
|
"@chakra-ui/cli": "3.3.1",
|
||||||
"@angular/core": "^18.0.2",
|
"@chakra-ui/react": "3.3.1",
|
||||||
"@angular/forms": "^18.0.2",
|
"@emotion/react": "11.14.0",
|
||||||
"@angular/material": "^18.0.2",
|
"allotment": "1.20.2",
|
||||||
"@angular/platform-browser": "^18.0.2",
|
"css-mediaquery": "0.1.2",
|
||||||
"@angular/platform-browser-dynamic": "^18.0.2",
|
"dayjs": "1.11.13",
|
||||||
"@angular/router": "^18.0.2",
|
"history": "5.3.0",
|
||||||
"rxjs": "^7.8.1",
|
"next-themes": "^0.4.4",
|
||||||
"zone.js": "^0.14.6",
|
"react": "18.3.1",
|
||||||
"zod": "3.23.8",
|
"react-dom": "18.3.1",
|
||||||
"@kangaroo-and-rabbit/kar-cw": "^0.4.1"
|
"react-error-boundary": "5.0.0",
|
||||||
|
"react-icons": "5.4.0",
|
||||||
|
"react-router-dom": "7.1.1",
|
||||||
|
"react-select": "5.9.0",
|
||||||
|
"react-use": "17.6.0",
|
||||||
|
"zod": "3.24.1",
|
||||||
|
"zustand": "5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "^18.0.3",
|
"@chakra-ui/styled-system": "^2.12.0",
|
||||||
"@angular-eslint/builder": "18.0.1",
|
"@playwright/test": "1.49.1",
|
||||||
"@angular-eslint/eslint-plugin": "18.0.1",
|
"@storybook/addon-actions": "8.4.7",
|
||||||
"@angular-eslint/eslint-plugin-template": "18.0.1",
|
"@storybook/addon-essentials": "8.4.7",
|
||||||
"@angular-eslint/schematics": "18.0.1",
|
"@storybook/addon-links": "8.4.7",
|
||||||
"@angular-eslint/template-parser": "18.0.1",
|
"@storybook/addon-mdx-gfm": "8.4.7",
|
||||||
"@angular/cli": "^18.0.3",
|
"@storybook/react": "8.4.7",
|
||||||
"@angular/compiler-cli": "^18.0.2",
|
"@storybook/react-vite": "8.4.7",
|
||||||
"@angular/language-service": "^18.0.2",
|
"@storybook/theming": "8.4.7",
|
||||||
"@playwright/test": "^1.44.1",
|
"@testing-library/jest-dom": "6.6.3",
|
||||||
"@types/jest": "^29.5.12",
|
"@testing-library/react": "16.1.0",
|
||||||
"jasmine": "^5.1.0",
|
"@testing-library/user-event": "14.5.2",
|
||||||
"jasmine-core": "^5.1.2",
|
"@trivago/prettier-plugin-sort-imports": "5.2.1",
|
||||||
"karma": "^6.4.3",
|
"@types/jest": "29.5.14",
|
||||||
"karma-coverage": "^2.2.1",
|
"@types/node": "22.10.6",
|
||||||
"karma-coverage-istanbul-reporter": "^3.0.3",
|
"@types/react": "18.3.8",
|
||||||
"karma-firefox-launcher": "^2.1.3",
|
"@types/react-dom": "18.3.0",
|
||||||
"karma-jasmine": "^5.1.0",
|
"@typescript-eslint/eslint-plugin": "8.20.0",
|
||||||
"karma-jasmine-html-reporter": "^2.1.0",
|
"@typescript-eslint/parser": "8.20.0",
|
||||||
"karma-spec-reporter": "^0.0.36",
|
"@vitejs/plugin-react": "4.3.4",
|
||||||
"prettier": "^3.3.1",
|
"eslint": "9.18.0",
|
||||||
"npm-check-updates": "^16.14.20",
|
"eslint-plugin-codeceptjs": "1.3.0",
|
||||||
"tslib": "^2.6.3"
|
"eslint-plugin-import": "2.31.0",
|
||||||
|
"eslint-plugin-react": "7.37.4",
|
||||||
|
"eslint-plugin-react-hooks": "5.1.0",
|
||||||
|
"eslint-plugin-storybook": "0.11.2",
|
||||||
|
"jest": "29.7.0",
|
||||||
|
"jest-environment-jsdom": "29.7.0",
|
||||||
|
"knip": "5.42.0",
|
||||||
|
"lint-staged": "15.3.0",
|
||||||
|
"npm-check-updates": "^17.1.13",
|
||||||
|
"prettier": "3.4.2",
|
||||||
|
"puppeteer": "24.0.0",
|
||||||
|
"react-is": "19.0.0",
|
||||||
|
"storybook": "8.4.7",
|
||||||
|
"ts-node": "10.9.2",
|
||||||
|
"typescript": "5.7.3",
|
||||||
|
"vite": "6.0.7",
|
||||||
|
"vitest": "2.1.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
0
front/playwright-report/.keep
Normal file
0
front/playwright-report/.keep
Normal file
19261
front/pnpm-lock.yaml
generated
19261
front/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
16
front/prettier.config.js
Normal file
16
front/prettier.config.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
// Using a JS file, allowing us to add comments
|
||||||
|
module.exports = {
|
||||||
|
// This plugins line is mandatory for the plugin to work with pnpm.
|
||||||
|
// https://github.com/trivago/prettier-plugin-sort-imports/blob/61d069711008c530f5a41ca4e254781abc5de358/README.md?plain=1#L89-L96
|
||||||
|
plugins: ['@trivago/prettier-plugin-sort-imports'],
|
||||||
|
endOfLine: 'lf',
|
||||||
|
semi: true,
|
||||||
|
singleQuote: true,
|
||||||
|
tabWidth: 2,
|
||||||
|
trailingComma: 'es5',
|
||||||
|
arrowParens: 'always',
|
||||||
|
importOrder: ['^react$', '^(?!^react$|^@/|^[./]).*', '^@/(.*)$', '^[./]'],
|
||||||
|
importOrderSeparation: true,
|
||||||
|
importOrderSortSpecifiers: true,
|
||||||
|
importOrderParserPlugins: ['jsx', 'typescript'],
|
||||||
|
};
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
119
front/src/App.tsx
Normal file
119
front/src/App.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
ChakraProvider,
|
||||||
|
Dialog,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
useDisclosure,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { environment } from '@/environment';
|
||||||
|
import { App as SpaApp } from '@/scene/App';
|
||||||
|
import { USERS, USERS_COLLECTION } from '@/service/session';
|
||||||
|
import { hashLocalData } from '@/utils/sso';
|
||||||
|
import { Toaster } from './components/ui/toaster';
|
||||||
|
import { systemTheme } from './theme/theme';
|
||||||
|
|
||||||
|
const AppEnvHint = () => {
|
||||||
|
const dialog = useDisclosure();
|
||||||
|
const [selectUserTest, setSelectUserTest] = useState<string>('NO_USER');
|
||||||
|
//const setUser = useRightsStore((store) => store.setUser);
|
||||||
|
const buildEnv =
|
||||||
|
process.env.NODE_ENV === 'development'
|
||||||
|
? 'Development'
|
||||||
|
: import.meta.env.VITE_DEV_ENV_NAME;
|
||||||
|
const envName: Array<string> = [];
|
||||||
|
!!buildEnv && envName.push(buildEnv);
|
||||||
|
if (!envName.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const handleChange = (selectedOption) => {
|
||||||
|
console.log(`SELECT: [${selectedOption.target.value}]`);
|
||||||
|
setSelectUserTest(selectedOption.target.value);
|
||||||
|
};
|
||||||
|
const onClose = () => {
|
||||||
|
dialog.onClose();
|
||||||
|
if (selectUserTest == 'NO_USER') {
|
||||||
|
window.location.href = `/${environment.applName}/sso/${hashLocalData()}/false/__LOGOUT__`;
|
||||||
|
} else {
|
||||||
|
window.location.href = `/${environment.applName}/sso/${hashLocalData()}/true/${USERS[selectUserTest]}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
as="button"
|
||||||
|
zIndex="100000"
|
||||||
|
position="fixed"
|
||||||
|
top="0"
|
||||||
|
insetStart="0"
|
||||||
|
insetEnd="0"
|
||||||
|
h="2px"
|
||||||
|
bg="warning.400"
|
||||||
|
cursor="pointer"
|
||||||
|
data-test-id="devtools"
|
||||||
|
onClick={dialog.onOpen}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
position="fixed"
|
||||||
|
top="0"
|
||||||
|
insetStart="4"
|
||||||
|
bg="warning.400"
|
||||||
|
color="warning.900"
|
||||||
|
fontSize="0.6rem"
|
||||||
|
fontWeight="bold"
|
||||||
|
px="10px"
|
||||||
|
marginLeft="25%"
|
||||||
|
borderBottomStartRadius="sm"
|
||||||
|
borderBottomEndRadius="sm"
|
||||||
|
textTransform="uppercase"
|
||||||
|
>
|
||||||
|
{envName.join(' : ')}
|
||||||
|
</Text>
|
||||||
|
</Box >
|
||||||
|
<Dialog.Root open={dialog.open} onOpenChange={dialog.onClose}>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Header>Outils développeurs</Dialog.Header>
|
||||||
|
<Dialog.Body>
|
||||||
|
<Stack>
|
||||||
|
<Text>User</Text>
|
||||||
|
<Select.Root onChange={handleChange} collection={USERS_COLLECTION}>
|
||||||
|
<Select.Trigger>
|
||||||
|
<Select.ValueText placeholder="Select test user" />
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{USERS_COLLECTION.items.map((value) => (
|
||||||
|
<Select.Item item={value} key={value.value}>
|
||||||
|
{value.label}
|
||||||
|
</Select.Item>
|
||||||
|
))}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</Stack>
|
||||||
|
</Dialog.Body>
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button onClick={onClose}>Close</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChakraProvider value={systemTheme}>
|
||||||
|
<AppEnvHint />
|
||||||
|
<SpaApp />
|
||||||
|
<Toaster />
|
||||||
|
</ChakraProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
@ -7,9 +7,9 @@ import {ZodLong} from "./long";
|
|||||||
|
|
||||||
export const ZodApplicationSmall = zod.object({
|
export const ZodApplicationSmall = zod.object({
|
||||||
id: ZodLong.optional(),
|
id: ZodLong.optional(),
|
||||||
name: zod.string().max(255).optional(),
|
name: zod.string().optional(),
|
||||||
description: zod.string().max(255).optional(),
|
description: zod.string().optional(),
|
||||||
redirect: zod.string().max(255).optional(),
|
redirect: zod.string().optional(),
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -26,9 +26,9 @@ export function isApplicationSmall(data: any): data is ApplicationSmall {
|
|||||||
}
|
}
|
||||||
export const ZodApplicationSmallWrite = zod.object({
|
export const ZodApplicationSmallWrite = zod.object({
|
||||||
id: ZodLong.nullable().optional(),
|
id: ZodLong.nullable().optional(),
|
||||||
name: zod.string().max(255).nullable().optional(),
|
name: zod.string().nullable().optional(),
|
||||||
description: zod.string().max(255).nullable().optional(),
|
description: zod.string().nullable().optional(),
|
||||||
redirect: zod.string().max(255).nullable().optional(),
|
redirect: zod.string().nullable().optional(),
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -7,11 +7,11 @@ import {ZodInteger} from "./integer";
|
|||||||
import {ZodGenericDataSoftDelete, ZodGenericDataSoftDeleteWrite } from "./generic-data-soft-delete";
|
import {ZodGenericDataSoftDelete, ZodGenericDataSoftDeleteWrite } from "./generic-data-soft-delete";
|
||||||
|
|
||||||
export const ZodApplication = ZodGenericDataSoftDelete.extend({
|
export const ZodApplication = ZodGenericDataSoftDelete.extend({
|
||||||
name: zod.string().max(256).optional(),
|
name: zod.string().optional(),
|
||||||
description: zod.string().max(2048).optional(),
|
description: zod.string().optional(),
|
||||||
redirect: zod.string().max(2048),
|
redirect: zod.string(),
|
||||||
redirectDev: zod.string().max(2048).optional(),
|
redirectDev: zod.string().optional(),
|
||||||
notification: zod.string().max(2048).optional(),
|
notification: zod.string().optional(),
|
||||||
/**
|
/**
|
||||||
* Expiration time
|
* Expiration time
|
||||||
*/
|
*/
|
||||||
@ -35,11 +35,11 @@ export function isApplication(data: any): data is Application {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
export const ZodApplicationWrite = ZodGenericDataSoftDeleteWrite.extend({
|
export const ZodApplicationWrite = ZodGenericDataSoftDeleteWrite.extend({
|
||||||
name: zod.string().max(256).nullable().optional(),
|
name: zod.string().nullable().optional(),
|
||||||
description: zod.string().max(2048).nullable().optional(),
|
description: zod.string().nullable().optional(),
|
||||||
redirect: zod.string().max(2048).optional(),
|
redirect: zod.string().optional(),
|
||||||
redirectDev: zod.string().max(2048).nullable().optional(),
|
redirectDev: zod.string().nullable().optional(),
|
||||||
notification: zod.string().max(2048).nullable().optional(),
|
notification: zod.string().nullable().optional(),
|
||||||
/**
|
/**
|
||||||
* Expiration time
|
* Expiration time
|
||||||
*/
|
*/
|
||||||
|
@ -5,7 +5,7 @@ import { z as zod } from "zod";
|
|||||||
|
|
||||||
|
|
||||||
export const ZodClientToken = zod.object({
|
export const ZodClientToken = zod.object({
|
||||||
url: zod.string().max(1024).optional(),
|
url: zod.string().optional(),
|
||||||
jwt: zod.string().optional(),
|
jwt: zod.string().optional(),
|
||||||
|
|
||||||
});
|
});
|
||||||
@ -22,7 +22,7 @@ export function isClientToken(data: any): data is ClientToken {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
export const ZodClientTokenWrite = zod.object({
|
export const ZodClientTokenWrite = zod.object({
|
||||||
url: zod.string().max(1024).nullable().optional(),
|
url: zod.string().nullable().optional(),
|
||||||
jwt: zod.string().nullable().optional(),
|
jwt: zod.string().nullable().optional(),
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@ -6,7 +6,7 @@ import { z as zod } from "zod";
|
|||||||
import {ZodInteger} from "./integer";
|
import {ZodInteger} from "./integer";
|
||||||
|
|
||||||
export const ZodCreateTokenRequest = zod.object({
|
export const ZodCreateTokenRequest = zod.object({
|
||||||
name: zod.string().max(255).optional(),
|
name: zod.string().optional(),
|
||||||
validity: ZodInteger.optional(),
|
validity: ZodInteger.optional(),
|
||||||
|
|
||||||
});
|
});
|
||||||
@ -23,7 +23,7 @@ export function isCreateTokenRequest(data: any): data is CreateTokenRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
export const ZodCreateTokenRequestWrite = zod.object({
|
export const ZodCreateTokenRequestWrite = zod.object({
|
||||||
name: zod.string().max(255).nullable().optional(),
|
name: zod.string().nullable().optional(),
|
||||||
validity: ZodInteger.nullable().optional(),
|
validity: ZodInteger.nullable().optional(),
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@ -17,6 +17,9 @@ export * from "./get-sign-up-available"
|
|||||||
export * from "./get-token"
|
export * from "./get-token"
|
||||||
export * from "./integer"
|
export * from "./integer"
|
||||||
export * from "./iso-date"
|
export * from "./iso-date"
|
||||||
|
export * from "./jwt-header"
|
||||||
|
export * from "./jwt-payload"
|
||||||
|
export * from "./jwt-token"
|
||||||
export * from "./long"
|
export * from "./long"
|
||||||
export * from "./object-id"
|
export * from "./object-id"
|
||||||
export * from "./part-right"
|
export * from "./part-right"
|
||||||
|
40
front/src/back-api/model/jwt-header.ts
Normal file
40
front/src/back-api/model/jwt-header.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Interface of the server (auto-generated code)
|
||||||
|
*/
|
||||||
|
import { z as zod } from "zod";
|
||||||
|
|
||||||
|
|
||||||
|
export const ZodJwtHeader = zod.object({
|
||||||
|
typ: zod.string().max(128),
|
||||||
|
alg: zod.string().max(128),
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
export type JwtHeader = zod.infer<typeof ZodJwtHeader>;
|
||||||
|
|
||||||
|
export function isJwtHeader(data: any): data is JwtHeader {
|
||||||
|
try {
|
||||||
|
ZodJwtHeader.parse(data);
|
||||||
|
return true;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(`Fail to parse data type='ZodJwtHeader' error=${e}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const ZodJwtHeaderWrite = zod.object({
|
||||||
|
typ: zod.string().max(128).optional(),
|
||||||
|
alg: zod.string().max(128).optional(),
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
export type JwtHeaderWrite = zod.infer<typeof ZodJwtHeaderWrite>;
|
||||||
|
|
||||||
|
export function isJwtHeaderWrite(data: any): data is JwtHeaderWrite {
|
||||||
|
try {
|
||||||
|
ZodJwtHeaderWrite.parse(data);
|
||||||
|
return true;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(`Fail to parse data type='ZodJwtHeaderWrite' error=${e}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
51
front/src/back-api/model/jwt-payload.ts
Normal file
51
front/src/back-api/model/jwt-payload.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Interface of the server (auto-generated code)
|
||||||
|
*/
|
||||||
|
import { z as zod } from "zod";
|
||||||
|
|
||||||
|
import {ZodLong} from "./long";
|
||||||
|
|
||||||
|
export const ZodJwtPayload = zod.object({
|
||||||
|
sub: zod.string(),
|
||||||
|
application: zod.string(),
|
||||||
|
iss: zod.string(),
|
||||||
|
right: zod.record(zod.string(), zod.record(zod.string(), ZodLong)),
|
||||||
|
login: zod.string(),
|
||||||
|
exp: ZodLong,
|
||||||
|
iat: ZodLong,
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
export type JwtPayload = zod.infer<typeof ZodJwtPayload>;
|
||||||
|
|
||||||
|
export function isJwtPayload(data: any): data is JwtPayload {
|
||||||
|
try {
|
||||||
|
ZodJwtPayload.parse(data);
|
||||||
|
return true;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(`Fail to parse data type='ZodJwtPayload' error=${e}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const ZodJwtPayloadWrite = zod.object({
|
||||||
|
sub: zod.string().optional(),
|
||||||
|
application: zod.string().optional(),
|
||||||
|
iss: zod.string().optional(),
|
||||||
|
right: zod.record(zod.string(), zod.record(zod.string(), ZodLong)).optional(),
|
||||||
|
login: zod.string().optional(),
|
||||||
|
exp: ZodLong.optional(),
|
||||||
|
iat: ZodLong.optional(),
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
export type JwtPayloadWrite = zod.infer<typeof ZodJwtPayloadWrite>;
|
||||||
|
|
||||||
|
export function isJwtPayloadWrite(data: any): data is JwtPayloadWrite {
|
||||||
|
try {
|
||||||
|
ZodJwtPayloadWrite.parse(data);
|
||||||
|
return true;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(`Fail to parse data type='ZodJwtPayloadWrite' error=${e}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
44
front/src/back-api/model/jwt-token.ts
Normal file
44
front/src/back-api/model/jwt-token.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Interface of the server (auto-generated code)
|
||||||
|
*/
|
||||||
|
import { z as zod } from "zod";
|
||||||
|
|
||||||
|
import {ZodJwtHeader, ZodJwtHeaderWrite } from "./jwt-header";
|
||||||
|
import {ZodJwtPayload, ZodJwtPayloadWrite } from "./jwt-payload";
|
||||||
|
|
||||||
|
export const ZodJwtToken = zod.object({
|
||||||
|
header: ZodJwtHeader,
|
||||||
|
payload: ZodJwtPayload,
|
||||||
|
signature: zod.string(),
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
export type JwtToken = zod.infer<typeof ZodJwtToken>;
|
||||||
|
|
||||||
|
export function isJwtToken(data: any): data is JwtToken {
|
||||||
|
try {
|
||||||
|
ZodJwtToken.parse(data);
|
||||||
|
return true;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(`Fail to parse data type='ZodJwtToken' error=${e}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const ZodJwtTokenWrite = zod.object({
|
||||||
|
header: ZodJwtHeader.optional(),
|
||||||
|
payload: ZodJwtPayload.optional(),
|
||||||
|
signature: zod.string().optional(),
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
export type JwtTokenWrite = zod.infer<typeof ZodJwtTokenWrite>;
|
||||||
|
|
||||||
|
export function isJwtTokenWrite(data: any): data is JwtTokenWrite {
|
||||||
|
try {
|
||||||
|
ZodJwtTokenWrite.parse(data);
|
||||||
|
return true;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(`Fail to parse data type='ZodJwtTokenWrite' error=${e}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
@ -5,7 +5,7 @@ import { z as zod } from "zod";
|
|||||||
|
|
||||||
|
|
||||||
export const ZodPublicKey = zod.object({
|
export const ZodPublicKey = zod.object({
|
||||||
key: zod.string().max(255).optional(),
|
key: zod.string().optional(),
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -21,7 +21,7 @@ export function isPublicKey(data: any): data is PublicKey {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
export const ZodPublicKeyWrite = zod.object({
|
export const ZodPublicKeyWrite = zod.object({
|
||||||
key: zod.string().max(255).nullable().optional(),
|
key: zod.string().nullable().optional(),
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -14,15 +14,15 @@ export const ZodRightDescription = ZodGenericDataSoftDelete.extend({
|
|||||||
/**
|
/**
|
||||||
* Key of the property
|
* Key of the property
|
||||||
*/
|
*/
|
||||||
key: zod.string().max(64),
|
key: zod.string(),
|
||||||
/**
|
/**
|
||||||
* Title of the right
|
* Title of the right
|
||||||
*/
|
*/
|
||||||
title: zod.string().max(1024),
|
title: zod.string(),
|
||||||
/**
|
/**
|
||||||
* Description of the right
|
* Description of the right
|
||||||
*/
|
*/
|
||||||
description: zod.string().max(1024),
|
description: zod.string(),
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -45,15 +45,15 @@ export const ZodRightDescriptionWrite = ZodGenericDataSoftDeleteWrite.extend({
|
|||||||
/**
|
/**
|
||||||
* Key of the property
|
* Key of the property
|
||||||
*/
|
*/
|
||||||
key: zod.string().max(64).optional(),
|
key: zod.string().optional(),
|
||||||
/**
|
/**
|
||||||
* Title of the right
|
* Title of the right
|
||||||
*/
|
*/
|
||||||
title: zod.string().max(1024).optional(),
|
title: zod.string().optional(),
|
||||||
/**
|
/**
|
||||||
* Description of the right
|
* Description of the right
|
||||||
*/
|
*/
|
||||||
description: zod.string().max(1024).optional(),
|
description: zod.string().optional(),
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@ import { z as zod } from "zod";
|
|||||||
import {ZodUser, ZodUserWrite } from "./user";
|
import {ZodUser, ZodUserWrite } from "./user";
|
||||||
|
|
||||||
export const ZodUserAuthGet = ZodUser.extend({
|
export const ZodUserAuthGet = ZodUser.extend({
|
||||||
email: zod.string().max(512),
|
email: zod.string(),
|
||||||
avatar: zod.boolean(),
|
avatar: zod.boolean(),
|
||||||
|
|
||||||
});
|
});
|
||||||
@ -23,7 +23,7 @@ export function isUserAuthGet(data: any): data is UserAuthGet {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
export const ZodUserAuthGetWrite = ZodUserWrite.extend({
|
export const ZodUserAuthGetWrite = ZodUserWrite.extend({
|
||||||
email: zod.string().max(512).optional(),
|
email: zod.string().optional(),
|
||||||
avatar: zod.boolean().optional(),
|
avatar: zod.boolean().optional(),
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@ -7,7 +7,7 @@ import {ZodLong} from "./long";
|
|||||||
|
|
||||||
export const ZodUserOut = zod.object({
|
export const ZodUserOut = zod.object({
|
||||||
id: ZodLong,
|
id: ZodLong,
|
||||||
login: zod.string().max(255).optional(),
|
login: zod.string().optional(),
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -24,7 +24,7 @@ export function isUserOut(data: any): data is UserOut {
|
|||||||
}
|
}
|
||||||
export const ZodUserOutWrite = zod.object({
|
export const ZodUserOutWrite = zod.object({
|
||||||
id: ZodLong,
|
id: ZodLong,
|
||||||
login: zod.string().max(255).nullable().optional(),
|
login: zod.string().nullable().optional(),
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,57 +0,0 @@
|
|||||||
/** @file
|
|
||||||
* @author Edouard DUPIN
|
|
||||||
* @copyright 2018, Edouard DUPIN, all right reserved
|
|
||||||
* @license PROPRIETARY (see license file)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Injectable } from '@angular/core';
|
|
||||||
import { SessionService } from '@kangaroo-and-rabbit/kar-cw';
|
|
||||||
import { ApplicationToken, ApplicationTokenResource, Long } from 'back-api';
|
|
||||||
import { RESTConfig } from 'back-api/rest-tools';
|
|
||||||
import { environment } from 'environments/environment';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ApplicationTokenService {
|
|
||||||
getRestConfig(): RESTConfig {
|
|
||||||
return {
|
|
||||||
server: environment.server.karso,
|
|
||||||
token: this.session.getToken()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private session: SessionService) {
|
|
||||||
console.log('Start ApplicationTokenService');
|
|
||||||
}
|
|
||||||
|
|
||||||
gets(applicationId: Long): Promise<ApplicationToken[]> {
|
|
||||||
return ApplicationTokenResource.gets({
|
|
||||||
restConfig: this.getRestConfig(),
|
|
||||||
params: {
|
|
||||||
applicationId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
create(applicationId: number, name: string, validity: number): Promise<ApplicationToken> {
|
|
||||||
return ApplicationTokenResource.create({
|
|
||||||
restConfig: this.getRestConfig(),
|
|
||||||
params: {
|
|
||||||
applicationId
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
name,
|
|
||||||
validity
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
remove(applicationId: number, tokenId: number): Promise<void> {
|
|
||||||
return ApplicationTokenResource.remove({
|
|
||||||
restConfig: this.getRestConfig(),
|
|
||||||
params: {
|
|
||||||
applicationId,
|
|
||||||
tokenId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,127 +0,0 @@
|
|||||||
/** @file
|
|
||||||
* @author Edouard DUPIN
|
|
||||||
* @copyright 2018, Edouard DUPIN, all right reserved
|
|
||||||
* @license PROPRIETARY (see license file)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Injectable } from '@angular/core';
|
|
||||||
import { SessionService } from '@kangaroo-and-rabbit/kar-cw';
|
|
||||||
import { RightDescription, ApplicationSmall, Application, ApplicationResource, ClientToken, Long } from 'back-api';
|
|
||||||
import { RESTConfig } from 'back-api/rest-tools';
|
|
||||||
import { environment } from 'environments/environment';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ApplicationService {
|
|
||||||
getRestConfig(): RESTConfig {
|
|
||||||
return {
|
|
||||||
server: environment.server.karso,
|
|
||||||
token: this.session.getToken()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(private session: SessionService) {
|
|
||||||
console.log('Start ApplicationService');
|
|
||||||
}
|
|
||||||
|
|
||||||
getRights(id: Long): Promise<RightDescription[]> {
|
|
||||||
return ApplicationResource.getRightsDescription({
|
|
||||||
restConfig: this.getRestConfig(),
|
|
||||||
params: {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
getApplicationSpecificToken(application: string): Promise<ClientToken> {
|
|
||||||
return ApplicationResource.getClientToken({
|
|
||||||
restConfig: this.getRestConfig(),
|
|
||||||
queries: {
|
|
||||||
application
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
addUser(id: Long, userId: Long): Promise<void> {
|
|
||||||
return ApplicationResource.addUser({
|
|
||||||
restConfig: this.getRestConfig(),
|
|
||||||
params: {
|
|
||||||
id
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
userId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
removeUser(id: Long, userId: Long): Promise<void> {
|
|
||||||
return ApplicationResource.removeUser({
|
|
||||||
restConfig: this.getRestConfig(),
|
|
||||||
params: {
|
|
||||||
id,
|
|
||||||
userId
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getApplicationReturn(application: string): Promise<string> {
|
|
||||||
return ApplicationResource.logOut({
|
|
||||||
restConfig: this.getRestConfig(),
|
|
||||||
queries: {
|
|
||||||
application
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
getApplicationsSmall(): Promise<ApplicationSmall[]> {
|
|
||||||
return ApplicationResource.getApplicationsSmall({
|
|
||||||
restConfig: this.getRestConfig(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getUsers(id: number): Promise<Long[]> {
|
|
||||||
return ApplicationResource.getApplicationUsers({
|
|
||||||
restConfig: this.getRestConfig(),
|
|
||||||
params: {
|
|
||||||
id,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
gets(): Promise<Application[]> {
|
|
||||||
return ApplicationResource.gets({
|
|
||||||
restConfig: this.getRestConfig(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
get(id: number): Promise<Application> {
|
|
||||||
return ApplicationResource.get({
|
|
||||||
restConfig: this.getRestConfig(),
|
|
||||||
params: {
|
|
||||||
id,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
update(id: number, updateState: Application): Promise<Application> {
|
|
||||||
return ApplicationResource.patch({
|
|
||||||
restConfig: this.getRestConfig(),
|
|
||||||
params: {
|
|
||||||
id,
|
|
||||||
},
|
|
||||||
data: updateState
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
create(name: string, redirect: string): Promise<Application> {
|
|
||||||
return ApplicationResource.create({
|
|
||||||
restConfig: this.getRestConfig(),
|
|
||||||
data: {
|
|
||||||
name,
|
|
||||||
redirect,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
remove(id: number): Promise<void> {
|
|
||||||
return ApplicationResource.remove({
|
|
||||||
restConfig: this.getRestConfig(),
|
|
||||||
params: {
|
|
||||||
id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
99
front/src/components/Cover.tsx
Normal file
99
front/src/components/Cover.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { ReactElement, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Box, BoxProps, Flex, FlexProps } from '@chakra-ui/react';
|
||||||
|
import { Image } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { DataUrlAccess } from '@/utils/data-url-access';
|
||||||
|
import { Icon } from './Icon';
|
||||||
|
import { ObjectId } from '@/back-api';
|
||||||
|
|
||||||
|
export type CoversProps = Omit<BoxProps, "iconEmpty"> & {
|
||||||
|
data?: ObjectId[];
|
||||||
|
size?: BoxProps["width"];
|
||||||
|
iconEmpty?: ReactElement;
|
||||||
|
slideshow?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Covers = ({
|
||||||
|
data,
|
||||||
|
iconEmpty,
|
||||||
|
size = '100px',
|
||||||
|
slideshow = false,
|
||||||
|
...rest
|
||||||
|
}: CoversProps) => {
|
||||||
|
const [currentImageIndex, setCurrentImageIndex] = useState(0);
|
||||||
|
const [previousImageIndex, setPreviousImageIndex] = useState(0);
|
||||||
|
const [topOpacity, setTopOpacity] = useState(0.0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slideshow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setPreviousImageIndex(currentImageIndex);
|
||||||
|
setTopOpacity(0.0);
|
||||||
|
setTimeout(() => {
|
||||||
|
setCurrentImageIndex((prevIndex) => (prevIndex + 1) % (data?.length ?? 1));
|
||||||
|
setTopOpacity(1.0);
|
||||||
|
}, 1500);
|
||||||
|
}, 3000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [slideshow, data]);
|
||||||
|
|
||||||
|
if (!data || data.length < 1) {
|
||||||
|
if (iconEmpty) {
|
||||||
|
return <Icon icon={iconEmpty} sizeIcon={size} />;
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
minHeight={size}
|
||||||
|
minWidth={size}
|
||||||
|
borderColor="blue"
|
||||||
|
borderWidth="1px"
|
||||||
|
margin="auto"
|
||||||
|
{...rest}
|
||||||
|
></Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (slideshow === false || data.length === 1) {
|
||||||
|
const url = DataUrlAccess.getThumbnailUrl(data[0]);
|
||||||
|
return <Image loading="lazy" src={url} maxWidth={size} boxSize={size} /*{...rest}*/ />;
|
||||||
|
}
|
||||||
|
const urlCurrent = DataUrlAccess.getThumbnailUrl(data[currentImageIndex]);
|
||||||
|
const urlPrevious = DataUrlAccess.getThumbnailUrl(data[previousImageIndex]);
|
||||||
|
return <Flex
|
||||||
|
position="relative"
|
||||||
|
// {...rest}
|
||||||
|
maxWidth={size}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
overflow="hidden">
|
||||||
|
<Image
|
||||||
|
src={urlPrevious}
|
||||||
|
loading="lazy"
|
||||||
|
position="absolute"
|
||||||
|
top="0"
|
||||||
|
left="0"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
zIndex={1}
|
||||||
|
boxSize={size}
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
src={urlCurrent}
|
||||||
|
loading="lazy"
|
||||||
|
position="absolute"
|
||||||
|
top="0"
|
||||||
|
left="0"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
boxSize={size}
|
||||||
|
transition="opacity 0.5s ease-in-out"
|
||||||
|
opacity={topOpacity}
|
||||||
|
zIndex={2}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
};
|
13
front/src/components/EmptyEnd.tsx
Normal file
13
front/src/components/EmptyEnd.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Box } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
export const EmptyEnd = () => {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
width="full"
|
||||||
|
height="25%"
|
||||||
|
minHeight="250px"
|
||||||
|
// borderWidth="1px"
|
||||||
|
// borderColor="red"
|
||||||
|
></Box>
|
||||||
|
);
|
||||||
|
};
|
41
front/src/components/Icon.tsx
Normal file
41
front/src/components/Icon.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Flex,
|
||||||
|
FlexProps,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { forwardRef, ReactNode } from 'react';
|
||||||
|
|
||||||
|
export type IconProps = FlexProps & {
|
||||||
|
icon: ReactNode;
|
||||||
|
color?: string;
|
||||||
|
sizeIcon?: FlexProps['width'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Icon = forwardRef<HTMLDivElement, IconProps>(
|
||||||
|
({ icon: IconEl, color, sizeIcon = '1em', ...rest }, ref) => {
|
||||||
|
return (
|
||||||
|
<Flex flex="none"
|
||||||
|
minWidth={sizeIcon}
|
||||||
|
minHeight={sizeIcon}
|
||||||
|
maxWidth={sizeIcon}
|
||||||
|
maxHeight={sizeIcon}
|
||||||
|
align="center"
|
||||||
|
padding="1px"
|
||||||
|
ref={ref}
|
||||||
|
{...rest}>
|
||||||
|
<Box
|
||||||
|
marginX="auto"
|
||||||
|
width="100%"
|
||||||
|
minWidth="100%"
|
||||||
|
height="100%"
|
||||||
|
color={color}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
{IconEl}
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Icon.displayName = 'Icon';
|
52
front/src/components/Layout/PageLayout.tsx
Normal file
52
front/src/components/Layout/PageLayout.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import React, { ReactNode, useEffect } from 'react';
|
||||||
|
|
||||||
|
import { Flex, Image } from '@chakra-ui/react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
import background from '@/assets/images/ikon.svg';
|
||||||
|
import { TOP_BAR_HEIGHT } from '@/components/TopBar/TopBar';
|
||||||
|
|
||||||
|
export type LayoutProps = React.PropsWithChildren<unknown> & {
|
||||||
|
topBar?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PageLayout = ({ children }: LayoutProps) => {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Flex
|
||||||
|
minH={`calc(100vh - ${TOP_BAR_HEIGHT})`}
|
||||||
|
maxH={`calc(100vh - ${TOP_BAR_HEIGHT})`}
|
||||||
|
position="absolute"
|
||||||
|
top={TOP_BAR_HEIGHT}
|
||||||
|
bottom={0}
|
||||||
|
left={0}
|
||||||
|
right={0}
|
||||||
|
minWidth="300px"
|
||||||
|
zIndex={-1}
|
||||||
|
>
|
||||||
|
<Image src={background} boxSize="90%" margin="auto" opacity="30%" />
|
||||||
|
</Flex>
|
||||||
|
<Flex
|
||||||
|
direction="column"
|
||||||
|
overflowX="auto"
|
||||||
|
overflowY="auto"
|
||||||
|
minH={`calc(100vh - ${TOP_BAR_HEIGHT})`}
|
||||||
|
maxH={`calc(100vh - ${TOP_BAR_HEIGHT})`}
|
||||||
|
position="absolute"
|
||||||
|
top={TOP_BAR_HEIGHT}
|
||||||
|
bottom={0}
|
||||||
|
left={0}
|
||||||
|
right={0}
|
||||||
|
minWidth="300px"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Flex>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
43
front/src/components/Layout/PageLayoutInfoCenter.tsx
Normal file
43
front/src/components/Layout/PageLayoutInfoCenter.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import React, { ReactNode, useEffect } from 'react';
|
||||||
|
|
||||||
|
import { Flex, FlexProps } from '@chakra-ui/react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { PageLayout } from '@/components/Layout/PageLayout';
|
||||||
|
import { colors } from '@/theme/colors';
|
||||||
|
import { useColorModeValue } from '@/components/ui/color-mode';
|
||||||
|
|
||||||
|
export type LayoutProps = FlexProps & {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PageLayoutInfoCenter = ({
|
||||||
|
children,
|
||||||
|
width = '25%',
|
||||||
|
...rest
|
||||||
|
}: LayoutProps) => {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<Flex
|
||||||
|
direction="column"
|
||||||
|
margin="auto"
|
||||||
|
minWidth={width}
|
||||||
|
border="back.900"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderRadius="8px"
|
||||||
|
padding="10px"
|
||||||
|
boxShadow={'0px 0px 16px ' + colors.back[900]}
|
||||||
|
backgroundColor={useColorModeValue('#FFFFFF', '#000000')}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Flex>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
};
|
56
front/src/components/SearchInput.tsx
Normal file
56
front/src/components/SearchInput.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Group,
|
||||||
|
Input,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { MdSearch } from 'react-icons/md';
|
||||||
|
|
||||||
|
export type SearchInputProps = {
|
||||||
|
onChange?: (data?: string) => void;
|
||||||
|
onSubmit?: (data?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SearchInput = ({
|
||||||
|
onChange: onChangeValue,
|
||||||
|
onSubmit: onSubmitValue,
|
||||||
|
}: SearchInputProps) => {
|
||||||
|
const [inputData, setInputData] = useState<string | undefined>(undefined);
|
||||||
|
const [searchInputProperty, setSearchInputProperty] =
|
||||||
|
useState<any>(undefined);
|
||||||
|
function onFocusKeep(): void {
|
||||||
|
setSearchInputProperty({
|
||||||
|
width: '70%',
|
||||||
|
maxWidth: '70%',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function onFocusLost(): void {
|
||||||
|
setSearchInputProperty({
|
||||||
|
width: '250px',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function onChange(event): void {
|
||||||
|
const data =
|
||||||
|
event.target.value.length === 0 ? undefined : event.target.value;
|
||||||
|
setInputData(data);
|
||||||
|
if (onChangeValue) {
|
||||||
|
onChangeValue(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onSubmit(): void {
|
||||||
|
if (onSubmitValue) {
|
||||||
|
onSubmitValue(inputData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Group maxWidth="200px" marginLeft="auto" {...searchInputProperty}>
|
||||||
|
<MdSearch color="gray.300" />
|
||||||
|
<Input
|
||||||
|
onFocus={onFocusKeep}
|
||||||
|
onBlur={() => setTimeout(() => onFocusLost(), 200)}
|
||||||
|
onChange={onChange}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
221
front/src/components/TopBar/TopBar.tsx
Normal file
221
front/src/components/TopBar/TopBar.tsx
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Flex,
|
||||||
|
HStack,
|
||||||
|
IconButton,
|
||||||
|
Text,
|
||||||
|
useDisclosure,
|
||||||
|
Button,
|
||||||
|
ConditionalValue,
|
||||||
|
Span,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import {
|
||||||
|
LuAlignJustify,
|
||||||
|
LuArrowBigLeft,
|
||||||
|
LuLogIn,
|
||||||
|
LuLogOut,
|
||||||
|
LuMoon,
|
||||||
|
LuSettings,
|
||||||
|
LuSun,
|
||||||
|
} from 'react-icons/lu';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useServiceContext } from '@/service/ServiceContext';
|
||||||
|
import { colors } from '@/theme/colors';
|
||||||
|
import { useSessionService } from '@/service/session';
|
||||||
|
import { MdHelp, MdHome, MdKey, MdMore, MdOutlineDashboardCustomize, MdOutlineGroup, MdSettings, MdSupervisedUserCircle } from 'react-icons/md';
|
||||||
|
import { MenuContent, MenuItem, MenuRoot, MenuTrigger } from '@/components/ui/menu';
|
||||||
|
import { useColorMode, useColorModeValue } from '@/components/ui/color-mode';
|
||||||
|
import {
|
||||||
|
DrawerBody,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerRoot,
|
||||||
|
} from '@/components/ui/drawer';
|
||||||
|
|
||||||
|
export const TOP_BAR_HEIGHT = '50px';
|
||||||
|
|
||||||
|
export const BUTTON_TOP_BAR_PROPERTY = {
|
||||||
|
variant: "ghost" as ConditionalValue<"ghost" | "outline" | "solid" | "subtle" | "surface" | "plain" | undefined>,
|
||||||
|
//colorPalette: "brand",
|
||||||
|
fontSize: '20px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
height: TOP_BAR_HEIGHT,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TopBarProps = {
|
||||||
|
children?: ReactNode;
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ButtonMenuLeft = ({ dest, title, icon }: { dest: string, title: string, icon: ReactNode }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return <>
|
||||||
|
<Button
|
||||||
|
background="#00000000"
|
||||||
|
borderRadius="0px"
|
||||||
|
onClick={() => navigate(dest)}
|
||||||
|
width="full"
|
||||||
|
{...BUTTON_TOP_BAR_PROPERTY}
|
||||||
|
>
|
||||||
|
<Box asChild style={{ width: "45px", height: "45px" }}>{icon}</Box>
|
||||||
|
<Text paddingLeft="3px" fontWeight="bold" marginRight="auto">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
<Box marginY="5" marginX="10" height="2px" background="brand.600" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
export const TopBar = ({ title, children }: TopBarProps) => {
|
||||||
|
const { colorMode, toggleColorMode } = useColorMode();
|
||||||
|
const { clearToken } = useSessionService();
|
||||||
|
|
||||||
|
const { session } = useServiceContext();
|
||||||
|
const backColor = useColorModeValue('back.100', 'back.800');
|
||||||
|
const drawerDisclose = useDisclosure();
|
||||||
|
const onChangeTheme = () => {
|
||||||
|
drawerDisclose.onOpen();
|
||||||
|
};
|
||||||
|
const navigate = useNavigate();
|
||||||
|
// const onSignIn = (): void => {
|
||||||
|
// clearToken();
|
||||||
|
// requestSignIn();
|
||||||
|
// };
|
||||||
|
// const onSignUp = (): void => {
|
||||||
|
// clearToken();
|
||||||
|
// requestSignUp();
|
||||||
|
// };
|
||||||
|
// const onSignOut = (): void => {
|
||||||
|
// clearToken();
|
||||||
|
// requestSignOut();
|
||||||
|
// };
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
position="absolute"
|
||||||
|
top={0}
|
||||||
|
left={0}
|
||||||
|
right={0}
|
||||||
|
height={TOP_BAR_HEIGHT}
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
backgroundColor={backColor}
|
||||||
|
gap="2"
|
||||||
|
px="2"
|
||||||
|
boxShadow={'0px 2px 4px ' + colors.back[900]}
|
||||||
|
zIndex={200}
|
||||||
|
>
|
||||||
|
<Button {...BUTTON_TOP_BAR_PROPERTY} onClick={onChangeTheme}>
|
||||||
|
<HStack>
|
||||||
|
<LuAlignJustify />
|
||||||
|
<Text paddingLeft="3px" fontWeight="bold">
|
||||||
|
karso
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</Button>
|
||||||
|
{title && (
|
||||||
|
<Text
|
||||||
|
fontSize="20px"
|
||||||
|
fontWeight="bold"
|
||||||
|
textTransform="uppercase"
|
||||||
|
marginRight="auto"
|
||||||
|
userSelect="none"
|
||||||
|
color="brand.500"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
<Flex right="0">
|
||||||
|
{!session?.token && (
|
||||||
|
<>
|
||||||
|
<Button {...BUTTON_TOP_BAR_PROPERTY} onClick={() => navigate('/login')}>
|
||||||
|
<LuLogIn />
|
||||||
|
<Text paddingLeft="3px" fontWeight="bold">
|
||||||
|
Sign-in
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
{...BUTTON_TOP_BAR_PROPERTY}
|
||||||
|
onClick={() => navigate('/sing-up')}
|
||||||
|
disabled={true}
|
||||||
|
>
|
||||||
|
<MdMore />
|
||||||
|
<Text paddingLeft="3px" fontWeight="bold">
|
||||||
|
Sign-up
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{session?.token && (
|
||||||
|
<MenuRoot>
|
||||||
|
<MenuTrigger asChild>
|
||||||
|
<IconButton
|
||||||
|
asChild
|
||||||
|
aria-label="Options"
|
||||||
|
{...BUTTON_TOP_BAR_PROPERTY}
|
||||||
|
width={TOP_BAR_HEIGHT}
|
||||||
|
><MdSupervisedUserCircle /></IconButton>
|
||||||
|
</MenuTrigger>
|
||||||
|
<MenuContent>
|
||||||
|
<MenuItem value="user" valueText="user" color={useColorModeValue('brand.800', 'brand.200')}>
|
||||||
|
<MdSupervisedUserCircle />
|
||||||
|
<Box flex="1">Sign in as {session?.login ?? 'Fail'}</Box>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem value="Settings" valueText="Settings" onClick={() => navigate('/settings')}><LuSettings />Settings</MenuItem>
|
||||||
|
<MenuItem value="Help" valueText="Help" onClick={() => navigate('/help')}><MdHelp /> Help</MenuItem>
|
||||||
|
<MenuItem value="Sign-out" valueText="Sign-out" onClick={() => navigate('/logout')}>
|
||||||
|
<LuLogOut /> Sign-out
|
||||||
|
</MenuItem>
|
||||||
|
{colorMode === 'light' ? (
|
||||||
|
<MenuItem value="set-dark" valueText="set-dark" onClick={toggleColorMode}>
|
||||||
|
<LuMoon /> Set dark mode
|
||||||
|
</MenuItem>
|
||||||
|
) : (
|
||||||
|
<MenuItem value="set-light" valueText="set-light" onClick={toggleColorMode}>
|
||||||
|
<LuSun /> Set light mode
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
</MenuContent>
|
||||||
|
</MenuRoot>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
<DrawerRoot
|
||||||
|
placement="start"
|
||||||
|
onOpenChange={drawerDisclose.onClose}
|
||||||
|
open={drawerDisclose.open}
|
||||||
|
data-testid="top-bar_drawer-root"
|
||||||
|
>
|
||||||
|
<DrawerContent
|
||||||
|
data-testid="top-bar_drawer-content">
|
||||||
|
<DrawerHeader
|
||||||
|
paddingY="auto"
|
||||||
|
as="button"
|
||||||
|
onClick={drawerDisclose.onClose}
|
||||||
|
boxShadow={'0px 2px 4px ' + colors.back[900]}
|
||||||
|
backgroundColor={backColor}
|
||||||
|
color={useColorModeValue('brand.900', 'brand.50')}
|
||||||
|
textTransform="uppercase"
|
||||||
|
>
|
||||||
|
<HStack
|
||||||
|
{...BUTTON_TOP_BAR_PROPERTY} cursor="pointer">
|
||||||
|
<LuArrowBigLeft />
|
||||||
|
<Span paddingLeft="3px">
|
||||||
|
karso
|
||||||
|
</Span>
|
||||||
|
</HStack>
|
||||||
|
</DrawerHeader>
|
||||||
|
<DrawerBody paddingX="0px">
|
||||||
|
<Box marginY="3" />
|
||||||
|
<ButtonMenuLeft dest="/" title="Home" icon={<MdHome />} />
|
||||||
|
<ButtonMenuLeft dest="/change-password" title="Change password" icon={<MdKey />} />
|
||||||
|
<ButtonMenuLeft dest="/admin-settings" title="Admin settings" icon={<MdSettings />} />
|
||||||
|
<ButtonMenuLeft dest="/manage-account" title="Manage account" icon={<MdOutlineGroup />} />
|
||||||
|
<ButtonMenuLeft dest="/manage-application" title="Manage application" icon={<MdOutlineDashboardCustomize />} />
|
||||||
|
</DrawerBody>
|
||||||
|
</DrawerContent>
|
||||||
|
</DrawerRoot>
|
||||||
|
</Flex >
|
||||||
|
);
|
||||||
|
};
|
63
front/src/components/album/DisplayAlbum.tsx
Normal file
63
front/src/components/album/DisplayAlbum.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { Flex, Span, Text } from '@chakra-ui/react';
|
||||||
|
import { LuDisc3 } from 'react-icons/lu';
|
||||||
|
|
||||||
|
import { Album } from '@/back-api';
|
||||||
|
import { Covers } from '@/components/Cover';
|
||||||
|
import { useCountTracksWithAlbumId } from '@/service/Track';
|
||||||
|
import { BASE_WRAP_ICON_SIZE } from '@/constants/genericSpacing';
|
||||||
|
|
||||||
|
export type DisplayAlbumProps = {
|
||||||
|
dataAlbum?: Album;
|
||||||
|
};
|
||||||
|
export const DisplayAlbum = ({ dataAlbum }: DisplayAlbumProps) => {
|
||||||
|
const { countTracksOfAnAlbum } = useCountTracksWithAlbumId(dataAlbum?.id);
|
||||||
|
if (!dataAlbum) {
|
||||||
|
return (
|
||||||
|
<Flex direction="row" width="full" height="full">
|
||||||
|
Fail to retrieve Album Data.
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Flex direction="row" width="full" height="full"
|
||||||
|
data-testid="display-album_flex">
|
||||||
|
<Covers
|
||||||
|
data={dataAlbum?.covers}
|
||||||
|
size={BASE_WRAP_ICON_SIZE}
|
||||||
|
flex={1}
|
||||||
|
iconEmpty={<LuDisc3 />}
|
||||||
|
/>
|
||||||
|
<Flex
|
||||||
|
direction="column"
|
||||||
|
width="150px"
|
||||||
|
//maxWidth="150px"
|
||||||
|
height="full"
|
||||||
|
paddingLeft="5px"
|
||||||
|
overflowX="hidden"
|
||||||
|
flex={1}
|
||||||
|
>
|
||||||
|
<Span
|
||||||
|
textAlign="left"
|
||||||
|
fontSize="20px"
|
||||||
|
fontWeight="bold"
|
||||||
|
userSelect="none"
|
||||||
|
marginRight="auto"
|
||||||
|
overflow="hidden"
|
||||||
|
// noOfLines={[1, 2]}
|
||||||
|
>
|
||||||
|
{dataAlbum?.name}
|
||||||
|
</Span>
|
||||||
|
<Span
|
||||||
|
textAlign="left"
|
||||||
|
fontSize="15px"
|
||||||
|
userSelect="none"
|
||||||
|
marginRight="auto"
|
||||||
|
overflow="hidden"
|
||||||
|
// noOfLines={1}
|
||||||
|
>
|
||||||
|
{countTracksOfAnAlbum} track{countTracksOfAnAlbum >= 1 && 's'}
|
||||||
|
</Span>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
11
front/src/components/album/DisplayAlbumId.tsx
Normal file
11
front/src/components/album/DisplayAlbumId.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { DisplayAlbum } from '@/components/album/DisplayAlbum';
|
||||||
|
import { useSpecificAlbum } from '@/service/Album';
|
||||||
|
|
||||||
|
export type DisplayAlbumIdProps = {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
export const DisplayAlbumId = ({ id }: DisplayAlbumIdProps) => {
|
||||||
|
const { dataAlbum } = useSpecificAlbum(id);
|
||||||
|
return <DisplayAlbum dataAlbum={dataAlbum}
|
||||||
|
data-testid="display-album-id" />;
|
||||||
|
};
|
46
front/src/components/contextMenu/ContextMenu.tsx
Normal file
46
front/src/components/contextMenu/ContextMenu.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { ReactNode, useState } from 'react';
|
||||||
|
|
||||||
|
import { LuMenu } from 'react-icons/lu';
|
||||||
|
import { MenuContent, MenuItem, MenuRoot, MenuTrigger } from '../ui/menu';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
|
||||||
|
|
||||||
|
export type MenuElement = {
|
||||||
|
icon?: ReactNode;
|
||||||
|
name: string;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContextMenuProps = {
|
||||||
|
elements?: MenuElement[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ContextMenu = ({ elements }: ContextMenuProps) => {
|
||||||
|
if (!elements) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<MenuRoot
|
||||||
|
data-testid="context-menu">
|
||||||
|
<MenuTrigger asChild
|
||||||
|
marginY="auto"
|
||||||
|
marginRight="4px"
|
||||||
|
data-testid="context-menu_trigger">
|
||||||
|
{/* This is very stupid, we need to set as span to prevent a button in button... WTF */}
|
||||||
|
<Button variant="ghost" color="brand.500">
|
||||||
|
<LuMenu />
|
||||||
|
</Button>
|
||||||
|
</MenuTrigger>
|
||||||
|
<MenuContent
|
||||||
|
data-testid="context-menu_content">
|
||||||
|
{elements?.map((data) => (
|
||||||
|
<MenuItem key={data.name} value={data.name} onClick={data.onClick} height="65px" fontSize="25px"
|
||||||
|
data-test-id="context-menu_item" >
|
||||||
|
{data.icon}
|
||||||
|
{data.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</MenuContent>
|
||||||
|
</MenuRoot >
|
||||||
|
);
|
||||||
|
};
|
175
front/src/components/form/FormCovers.tsx
Normal file
175
front/src/components/form/FormCovers.tsx
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
import {
|
||||||
|
DragEventHandler,
|
||||||
|
RefObject,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
BoxProps,
|
||||||
|
Center,
|
||||||
|
Flex,
|
||||||
|
HStack,
|
||||||
|
Image,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import {
|
||||||
|
MdHighlightOff,
|
||||||
|
MdUploadFile,
|
||||||
|
} from 'react-icons/md';
|
||||||
|
|
||||||
|
import { FormGroup } from '@/components/form/FormGroup';
|
||||||
|
import { UseFormidableReturn } from '@/components/form/Formidable';
|
||||||
|
import { DataUrlAccess } from '@/utils/data-url-access';
|
||||||
|
|
||||||
|
export type DragNdropProps = {
|
||||||
|
onFilesSelected?: (file: File[]) => void;
|
||||||
|
onUriSelected?: (uri: string) => void;
|
||||||
|
width?: string;
|
||||||
|
height?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DragNdrop = ({
|
||||||
|
onFilesSelected = () => { },
|
||||||
|
onUriSelected = () => { },
|
||||||
|
width = '100px',
|
||||||
|
height = '100px',
|
||||||
|
}: DragNdropProps) => {
|
||||||
|
const handleFileChange = (event) => {
|
||||||
|
const selectedFiles = event.target.files;
|
||||||
|
if (selectedFiles && selectedFiles.length > 0) {
|
||||||
|
const newFiles: File[] = Array.from(selectedFiles);
|
||||||
|
onFilesSelected(newFiles);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleDrop = (eventInput: any) => {
|
||||||
|
const event = eventInput as DragEvent;
|
||||||
|
event.preventDefault();
|
||||||
|
const droppedFiles = event.dataTransfer?.files;
|
||||||
|
console.log('drop ...' + droppedFiles?.length);
|
||||||
|
if (droppedFiles && droppedFiles?.length > 0) {
|
||||||
|
const newFiles: File[] = Array.from(droppedFiles);
|
||||||
|
onFilesSelected(newFiles);
|
||||||
|
} else {
|
||||||
|
console.log(`drop types: ${event.dataTransfer?.types}`);
|
||||||
|
const listUri = event.dataTransfer?.getData('text/uri-list');
|
||||||
|
console.log(`listUri: ${listUri}`);
|
||||||
|
if (!listUri) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onUriSelected(listUri);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
border="2px"
|
||||||
|
borderRadius="5px"
|
||||||
|
borderStyle="dashed"
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
<label htmlFor="browse">
|
||||||
|
<Box paddingY="15%" height="100%" cursor="pointer">
|
||||||
|
<Center>
|
||||||
|
<MdUploadFile size="50%" />
|
||||||
|
</Center>
|
||||||
|
<Center>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
hidden
|
||||||
|
id="browse"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
//accept=".pdf,.docx,.pptx,.txt,.xlsx"
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
|
Browse files
|
||||||
|
</Center>
|
||||||
|
</Box>
|
||||||
|
</label>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CenterIconProps = BoxProps & {
|
||||||
|
icon: any;
|
||||||
|
sizeIcon?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CenterIcon = ({
|
||||||
|
icon: IconEl,
|
||||||
|
sizeIcon = '15px',
|
||||||
|
...rest
|
||||||
|
}: CenterIconProps) => {
|
||||||
|
return (
|
||||||
|
<Box position="relative" w={sizeIcon} h={sizeIcon} flex="none" {...rest}>
|
||||||
|
<Box
|
||||||
|
w={sizeIcon}
|
||||||
|
h={sizeIcon}
|
||||||
|
position="absolute"
|
||||||
|
top="50%"
|
||||||
|
left="50%"
|
||||||
|
transform="translate(-50%, -50%)"
|
||||||
|
>{IconEl}</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormCoversProps = {
|
||||||
|
form: UseFormidableReturn;
|
||||||
|
variableName: string;
|
||||||
|
ref?: RefObject<any>;
|
||||||
|
label?: string;
|
||||||
|
isRequired?: boolean;
|
||||||
|
onFilesSelected?: (files: File[]) => void;
|
||||||
|
onUriSelected?: (uri: string) => void;
|
||||||
|
onRemove?: (index: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FormCovers = ({
|
||||||
|
form,
|
||||||
|
variableName,
|
||||||
|
ref,
|
||||||
|
onFilesSelected = () => { },
|
||||||
|
onUriSelected = () => { },
|
||||||
|
onRemove = () => { },
|
||||||
|
...rest
|
||||||
|
}: FormCoversProps) => {
|
||||||
|
const urls =
|
||||||
|
DataUrlAccess.getListThumbnailUrl(form.values[variableName]) ?? [];
|
||||||
|
return (
|
||||||
|
<FormGroup
|
||||||
|
isModify={form.isModify[variableName]}
|
||||||
|
onRestore={() => form.restoreValue({ [variableName]: true })}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<HStack wrap="wrap" width="full">
|
||||||
|
{urls.map((data, index) => (
|
||||||
|
<Flex align="flex-start" key={data}>
|
||||||
|
<Box width="125px" height="125px" position="relative">
|
||||||
|
<Box width="125px" height="125px" position="absolute">
|
||||||
|
<CenterIcon
|
||||||
|
width="125px"
|
||||||
|
sizeIcon="100%"
|
||||||
|
zIndex="+1"
|
||||||
|
color="#00000020"
|
||||||
|
_hover={{ color: 'red' }}
|
||||||
|
onClick={() => onRemove && onRemove(index)}
|
||||||
|
><MdHighlightOff /></CenterIcon>
|
||||||
|
</Box>
|
||||||
|
<Image loading="lazy" src={data} boxSize="full" />
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
<Flex align="flex-start" key="data">
|
||||||
|
<DragNdrop
|
||||||
|
height="125px"
|
||||||
|
width="125px"
|
||||||
|
onFilesSelected={onFilesSelected}
|
||||||
|
onUriSelected={onUriSelected}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</HStack>
|
||||||
|
</FormGroup>
|
||||||
|
);
|
||||||
|
};
|
63
front/src/components/form/FormGroup.tsx
Normal file
63
front/src/components/form/FormGroup.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { Flex, Text } from '@chakra-ui/react';
|
||||||
|
import { MdErrorOutline, MdHelpOutline, MdRefresh } from 'react-icons/md';
|
||||||
|
|
||||||
|
export type FormGroupProps = {
|
||||||
|
error?: ReactNode;
|
||||||
|
help?: ReactNode;
|
||||||
|
label?: ReactNode;
|
||||||
|
isModify?: boolean;
|
||||||
|
onRestore?: () => void;
|
||||||
|
isRequired?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FormGroup = ({
|
||||||
|
children,
|
||||||
|
error,
|
||||||
|
help,
|
||||||
|
label,
|
||||||
|
isModify = false,
|
||||||
|
isRequired = false,
|
||||||
|
onRestore,
|
||||||
|
}: FormGroupProps) => (
|
||||||
|
<Flex
|
||||||
|
borderLeftWidth="3px"
|
||||||
|
borderLeftColor={error ? 'red' : isModify ? 'blue' : '#00000000'}
|
||||||
|
paddingLeft="7px"
|
||||||
|
paddingY="4px"
|
||||||
|
width="full"
|
||||||
|
direction="column"
|
||||||
|
>
|
||||||
|
<Flex direction="row" width="full" gap="52px">
|
||||||
|
{!!label && (
|
||||||
|
<Text marginRight="auto" fontWeight="bold">
|
||||||
|
{label}{' '}
|
||||||
|
{isRequired && (
|
||||||
|
<Text as="span" color="red.600">
|
||||||
|
*
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{!!onRestore && isModify && (
|
||||||
|
<MdRefresh size="15px" onClick={onRestore} cursor="pointer" />
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
{children}
|
||||||
|
{!!help && (
|
||||||
|
<Flex direction="row">
|
||||||
|
<MdHelpOutline />
|
||||||
|
<Text>{help}</Text>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!error && (
|
||||||
|
<Flex direction="row">
|
||||||
|
<MdErrorOutline />
|
||||||
|
<Text>{error}</Text>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
);
|
39
front/src/components/form/FormInput.tsx
Normal file
39
front/src/components/form/FormInput.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { RefObject } from 'react';
|
||||||
|
|
||||||
|
import { Input } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { FormGroup, FormGroupProps } from '@/components/form/FormGroup';
|
||||||
|
import { UseFormidableReturn } from '@/components/form/Formidable';
|
||||||
|
|
||||||
|
export type FormInputProps = {
|
||||||
|
form: UseFormidableReturn;
|
||||||
|
variableName: string;
|
||||||
|
ref?: RefObject<any>;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
isRequired?: boolean;
|
||||||
|
} & Omit<FormGroupProps, 'children'>;
|
||||||
|
|
||||||
|
export const FormInput = ({
|
||||||
|
form,
|
||||||
|
variableName,
|
||||||
|
ref,
|
||||||
|
placeholder,
|
||||||
|
...rest
|
||||||
|
}: FormInputProps) => {
|
||||||
|
return (
|
||||||
|
<FormGroup
|
||||||
|
isModify={form.isModify[variableName]}
|
||||||
|
onRestore={() => form.restoreValue({ [variableName]: true })}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
ref={ref}
|
||||||
|
type="text"
|
||||||
|
name={variableName}
|
||||||
|
value={form.values[variableName]}
|
||||||
|
onChange={(e) => form.setValues({ [variableName]: e.target.value })}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
);
|
||||||
|
};
|
52
front/src/components/form/FormNumber.tsx
Normal file
52
front/src/components/form/FormNumber.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { RefObject } from 'react';
|
||||||
|
|
||||||
|
import { FormGroup } from '@/components/form/FormGroup';
|
||||||
|
import { UseFormidableReturn } from '@/components/form/Formidable';
|
||||||
|
import { NumberInputField, NumberInputProps, NumberInputRoot } from '../ui/number-input';
|
||||||
|
|
||||||
|
export type FormNumberProps = Pick<
|
||||||
|
NumberInputProps,
|
||||||
|
'step' | 'defaultValue' | 'min' | 'max'
|
||||||
|
> & {
|
||||||
|
form: UseFormidableReturn;
|
||||||
|
variableName: string;
|
||||||
|
ref?: RefObject<any>;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
isRequired?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FormNumber = ({
|
||||||
|
form,
|
||||||
|
variableName,
|
||||||
|
ref,
|
||||||
|
placeholder,
|
||||||
|
step,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
defaultValue,
|
||||||
|
...rest
|
||||||
|
}: FormNumberProps) => {
|
||||||
|
const onEvent = (value) => {
|
||||||
|
form.setValues({ [variableName]: value.value });
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<FormGroup
|
||||||
|
isModify={form.isModify[variableName]}
|
||||||
|
onRestore={() => form.restoreValue({ [variableName]: true })}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<NumberInputRoot
|
||||||
|
ref={ref}
|
||||||
|
value={form.values[variableName]}
|
||||||
|
onValueChange={onEvent}
|
||||||
|
step={step}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
>
|
||||||
|
<NumberInputField />
|
||||||
|
</NumberInputRoot>
|
||||||
|
</FormGroup>
|
||||||
|
);
|
||||||
|
};
|
119
front/src/components/form/FormSelect.stories.tsx
Normal file
119
front/src/components/form/FormSelect.stories.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Box } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { FormSelect } from '@/components/form/FormSelect';
|
||||||
|
import { useFormidable } from '@/components/form/Formidable';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Components/FormSelect',
|
||||||
|
};
|
||||||
|
|
||||||
|
type BasicFormData = {
|
||||||
|
data?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Default = () => {
|
||||||
|
const form = useFormidable<BasicFormData>({});
|
||||||
|
return (
|
||||||
|
<FormSelect
|
||||||
|
label="Simple Title"
|
||||||
|
form={form}
|
||||||
|
variableName={'data'}
|
||||||
|
keyInputValue="id"
|
||||||
|
options={[{ id: 111 }, { id: 222 }, { id: 333 }, { id: 123 }]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChangeKeys = () => {
|
||||||
|
const form = useFormidable<BasicFormData>({});
|
||||||
|
return (
|
||||||
|
<FormSelect
|
||||||
|
label="Simple Title for (ChangeKeys)"
|
||||||
|
form={form}
|
||||||
|
variableName={'data'}
|
||||||
|
keyInputKey="key"
|
||||||
|
keyInputValue="plop"
|
||||||
|
options={[
|
||||||
|
{ key: 111, plop: 'first Item' },
|
||||||
|
{ key: 222, plop: 'Second Item' },
|
||||||
|
{ key: 333, plop: 'third item' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export const ChangeName = () => {
|
||||||
|
const form = useFormidable<BasicFormData>({});
|
||||||
|
return (
|
||||||
|
<FormSelect
|
||||||
|
label="Simple Title for (ChangeName)"
|
||||||
|
form={form}
|
||||||
|
variableName={'data'}
|
||||||
|
options={[
|
||||||
|
{ id: 111, name: 'first Item' },
|
||||||
|
{ id: 222, name: 'Second Item' },
|
||||||
|
{ id: 333, name: 'third item' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export const AddableItem = () => {
|
||||||
|
const form = useFormidable<BasicFormData>({});
|
||||||
|
const [data, setData] = useState([
|
||||||
|
{ id: 111, name: 'first Item' },
|
||||||
|
{ id: 222, name: 'Second Item' },
|
||||||
|
{ id: 333, name: 'third item' },
|
||||||
|
]);
|
||||||
|
return (
|
||||||
|
<FormSelect
|
||||||
|
label="Simple Title for (ChangeName)"
|
||||||
|
form={form}
|
||||||
|
variableName={'data'}
|
||||||
|
addNewItem={(data: string) => {
|
||||||
|
return new Promise((resolve, _rejects) => {
|
||||||
|
let upperId = 0;
|
||||||
|
setData((previous) => {
|
||||||
|
previous.forEach((element) => {
|
||||||
|
if (element['id'] > upperId) {
|
||||||
|
upperId = element['id'];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
upperId++;
|
||||||
|
return [...previous, { id: upperId, name: data }];
|
||||||
|
});
|
||||||
|
resolve({ id: upperId, name: data });
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
options={data}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DarkBackground = {
|
||||||
|
render: () => {
|
||||||
|
const form = useFormidable<BasicFormData>({});
|
||||||
|
return (
|
||||||
|
<Box p="4" color="white" bg="gray.800">
|
||||||
|
<FormSelect
|
||||||
|
label="Simple Title for (DarkBackground)"
|
||||||
|
form={form}
|
||||||
|
variableName={'data'}
|
||||||
|
options={[
|
||||||
|
{ id: 111, name: 'first Item' },
|
||||||
|
{ id: 222, name: 'Second Item' },
|
||||||
|
{ id: 333, name: 'third item' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story: 'some story **markdown**',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
70
front/src/components/form/FormSelect.tsx
Normal file
70
front/src/components/form/FormSelect.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { RefObject } from 'react';
|
||||||
|
|
||||||
|
import { Text } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { FormGroup } from '@/components/form/FormGroup';
|
||||||
|
import { UseFormidableReturn } from '@/components/form/Formidable';
|
||||||
|
import { SelectSingle } from '@/components/select/SelectSingle';
|
||||||
|
|
||||||
|
export type FormSelectProps = {
|
||||||
|
// Generic Form input
|
||||||
|
form: UseFormidableReturn;
|
||||||
|
// Form: Name of the variable
|
||||||
|
variableName: string;
|
||||||
|
// Forward object reference
|
||||||
|
ref?: RefObject<any>;
|
||||||
|
// Form: Label of the input
|
||||||
|
label?: string;
|
||||||
|
// Form: Placeholder if nothing is selected
|
||||||
|
placeholder?: string;
|
||||||
|
// Form: Specify if the element is required or not
|
||||||
|
isRequired?: boolean;
|
||||||
|
// List of object options
|
||||||
|
options: object[];
|
||||||
|
// in the option specify the value Key
|
||||||
|
keyInputKey?: string;
|
||||||
|
// in the option specify the value field
|
||||||
|
keyInputValue?: string;
|
||||||
|
// Add capability to add an item (no key but only value)
|
||||||
|
addNewItem?: (data: string) => Promise<any>;
|
||||||
|
// if a suggestion exist at the auto compleat
|
||||||
|
suggestion?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FormSelect = ({
|
||||||
|
form,
|
||||||
|
variableName,
|
||||||
|
ref,
|
||||||
|
placeholder,
|
||||||
|
options,
|
||||||
|
keyInputKey = 'id',
|
||||||
|
keyInputValue = 'name',
|
||||||
|
suggestion,
|
||||||
|
addNewItem,
|
||||||
|
...rest
|
||||||
|
}: FormSelectProps) => {
|
||||||
|
// if set add capability to add the search item
|
||||||
|
const onCreate = !addNewItem
|
||||||
|
? undefined
|
||||||
|
: (data: string) => {
|
||||||
|
addNewItem(data).then((data: object) => form.setValues({ [variableName]: data[keyInputKey] }));
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<FormGroup
|
||||||
|
isModify={form.isModify[variableName]}
|
||||||
|
onRestore={() => form.restoreValue({ [variableName]: true })}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<SelectSingle
|
||||||
|
ref={ref}
|
||||||
|
value={form.values[variableName]}
|
||||||
|
options={options}
|
||||||
|
onChange={(value) => form.setValues({ [variableName]: value })}
|
||||||
|
keyKey={keyInputKey}
|
||||||
|
keyValue={keyInputValue}
|
||||||
|
onCreate={onCreate}
|
||||||
|
suggestion={suggestion}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
);
|
||||||
|
};
|
119
front/src/components/form/FormSelectMultiple.stories.tsx
Normal file
119
front/src/components/form/FormSelectMultiple.stories.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Box } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { FormSelectMultiple } from '@/components/form/FormSelectMultiple';
|
||||||
|
import { useFormidable } from '@/components/form/Formidable';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Components/FormSelectMultipleMultiple',
|
||||||
|
};
|
||||||
|
|
||||||
|
type BasicFormData = {
|
||||||
|
data?: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Default = () => {
|
||||||
|
const form = useFormidable<BasicFormData>({});
|
||||||
|
return (
|
||||||
|
<FormSelectMultiple
|
||||||
|
label="Simple Title"
|
||||||
|
form={form}
|
||||||
|
variableName={'data'}
|
||||||
|
keyInputValue="id"
|
||||||
|
options={[{ id: 111 }, { id: 222 }, { id: 333 }, { id: 123 }]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChangeKeys = () => {
|
||||||
|
const form = useFormidable<BasicFormData>({});
|
||||||
|
return (
|
||||||
|
<FormSelectMultiple
|
||||||
|
label="Simple Title for (ChangeKeys)"
|
||||||
|
form={form}
|
||||||
|
variableName={'data'}
|
||||||
|
keyInputKey="key"
|
||||||
|
keyInputValue="plop"
|
||||||
|
options={[
|
||||||
|
{ key: 111, plop: 'first Item' },
|
||||||
|
{ key: 222, plop: 'Second Item' },
|
||||||
|
{ key: 333, plop: 'third item' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export const ChangeName = () => {
|
||||||
|
const form = useFormidable<BasicFormData>({});
|
||||||
|
return (
|
||||||
|
<FormSelectMultiple
|
||||||
|
label="Simple Title for (ChangeName)"
|
||||||
|
form={form}
|
||||||
|
variableName={'data'}
|
||||||
|
options={[
|
||||||
|
{ id: 111, name: 'first Item' },
|
||||||
|
{ id: 222, name: 'Second Item' },
|
||||||
|
{ id: 333, name: 'third item' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export const AddableItem = () => {
|
||||||
|
const form = useFormidable<BasicFormData>({});
|
||||||
|
const [data, setData] = useState([
|
||||||
|
{ id: 111, name: 'first Item' },
|
||||||
|
{ id: 222, name: 'Second Item' },
|
||||||
|
{ id: 333, name: 'third item' },
|
||||||
|
]);
|
||||||
|
return (
|
||||||
|
<FormSelectMultiple
|
||||||
|
label="Simple Title for (ChangeName)"
|
||||||
|
form={form}
|
||||||
|
variableName={'data'}
|
||||||
|
addNewItem={(data: string) => {
|
||||||
|
return new Promise((resolve, _rejects) => {
|
||||||
|
let upperId = 0;
|
||||||
|
setData((previous) => {
|
||||||
|
previous.forEach((element) => {
|
||||||
|
if (element['id'] > upperId) {
|
||||||
|
upperId = element['id'];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
upperId++;
|
||||||
|
return [...previous, { id: upperId, name: data }];
|
||||||
|
});
|
||||||
|
resolve({ id: upperId, name: data });
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
options={data}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DarkBackground = {
|
||||||
|
render: () => {
|
||||||
|
const form = useFormidable<BasicFormData>({});
|
||||||
|
return (
|
||||||
|
<Box p="4" color="white" bg="gray.800">
|
||||||
|
<FormSelectMultiple
|
||||||
|
label="Simple Title for (DarkBackground)"
|
||||||
|
form={form}
|
||||||
|
variableName={'data'}
|
||||||
|
options={[
|
||||||
|
{ id: 111, name: 'first Item' },
|
||||||
|
{ id: 222, name: 'Second Item' },
|
||||||
|
{ id: 333, name: 'third item' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story: 'some story **markdown**',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
64
front/src/components/form/FormSelectMultiple.tsx
Normal file
64
front/src/components/form/FormSelectMultiple.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { RefObject } from 'react';
|
||||||
|
|
||||||
|
import { FormGroup } from '@/components/form/FormGroup';
|
||||||
|
import { UseFormidableReturn } from '@/components/form/Formidable';
|
||||||
|
import { SelectMultiple } from '@/components/select/SelectMultiple';
|
||||||
|
|
||||||
|
export type FormSelectMultipleProps = {
|
||||||
|
// Generic Form input
|
||||||
|
form: UseFormidableReturn;
|
||||||
|
// Form: Name of the variable
|
||||||
|
variableName: string;
|
||||||
|
// Forward object reference
|
||||||
|
ref?: RefObject<any>;
|
||||||
|
// Form: Label of the input
|
||||||
|
label?: string;
|
||||||
|
// Form: Placeholder if nothing is selected
|
||||||
|
placeholder?: string;
|
||||||
|
// Form: Specify if the element is required or not
|
||||||
|
isRequired?: boolean;
|
||||||
|
// List of object options
|
||||||
|
options: object[];
|
||||||
|
// in the option specify the value Key
|
||||||
|
keyInputKey?: string;
|
||||||
|
// in the option specify the value field
|
||||||
|
keyInputValue?: string;
|
||||||
|
// Add capability to add an item (no key but only value)
|
||||||
|
addNewItem?: (data: string) => Promise<any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FormSelectMultiple = ({
|
||||||
|
form,
|
||||||
|
variableName,
|
||||||
|
ref,
|
||||||
|
placeholder,
|
||||||
|
options,
|
||||||
|
keyInputKey = 'id',
|
||||||
|
keyInputValue = 'name',
|
||||||
|
addNewItem,
|
||||||
|
...rest
|
||||||
|
}: FormSelectMultipleProps) => {
|
||||||
|
// if set add capability to add the search item
|
||||||
|
const onCreate = !addNewItem
|
||||||
|
? undefined
|
||||||
|
: (data: string) => {
|
||||||
|
addNewItem(data).then((data: object) => form.setValues({ [variableName]: [...(form.values[variableName] ?? []), data[keyInputKey]] }));
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<FormGroup
|
||||||
|
isModify={form.isModify[variableName]}
|
||||||
|
onRestore={() => form.restoreValue({ [variableName]: true })}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<SelectMultiple
|
||||||
|
//ref={ref}
|
||||||
|
values={form.values[variableName]}
|
||||||
|
options={options}
|
||||||
|
onChange={(value) => form.setValues({ [variableName]: value })}
|
||||||
|
keyKey={keyInputKey}
|
||||||
|
keyValue={keyInputValue}
|
||||||
|
onCreate={onCreate}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
);
|
||||||
|
};
|
38
front/src/components/form/FormTextarea.tsx
Normal file
38
front/src/components/form/FormTextarea.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { RefObject } from 'react';
|
||||||
|
|
||||||
|
import { Textarea } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { FormGroup } from '@/components/form/FormGroup';
|
||||||
|
import { UseFormidableReturn } from '@/components/form/Formidable';
|
||||||
|
|
||||||
|
export type FormTextareaProps = {
|
||||||
|
form: UseFormidableReturn;
|
||||||
|
variableName: string;
|
||||||
|
ref?: RefObject<any>;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
isRequired?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FormTextarea = ({
|
||||||
|
form,
|
||||||
|
variableName,
|
||||||
|
ref,
|
||||||
|
placeholder,
|
||||||
|
...rest
|
||||||
|
}: FormTextareaProps) => {
|
||||||
|
return (
|
||||||
|
<FormGroup
|
||||||
|
isModify={form.isModify[variableName]}
|
||||||
|
onRestore={() => form.restoreValue({ [variableName]: true })}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
name={variableName}
|
||||||
|
ref={ref}
|
||||||
|
value={form.values[variableName]}
|
||||||
|
onChange={(e) => form.setValues({ [variableName]: e.target.value })}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
);
|
||||||
|
};
|
167
front/src/components/form/Formidable.tsx
Normal file
167
front/src/components/form/Formidable.tsx
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { isArray, isNullOrUndefined, isObject } from '@/utils/validator';
|
||||||
|
|
||||||
|
const hasAnyTrue = (obj: { [key: string]: boolean }): boolean => {
|
||||||
|
for (const key in obj) {
|
||||||
|
if (obj.hasOwnProperty(key) && obj[key] === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDifferences(
|
||||||
|
obj1: object,
|
||||||
|
obj2: object
|
||||||
|
): { [key: string]: boolean } {
|
||||||
|
// Create an empty object to store the differences
|
||||||
|
const result: { [key: string]: boolean } = {};
|
||||||
|
// Recursive function to compare values
|
||||||
|
function compareValues(value1: any, value2: any): boolean {
|
||||||
|
// If both values are objects, compare their properties recursively
|
||||||
|
if (isObject(value1) && isObject(value2)) {
|
||||||
|
return hasAnyTrue(getDifferences(value1, value2));
|
||||||
|
}
|
||||||
|
// If both values are arrays, compare their elements
|
||||||
|
if (isArray(value1) && isArray(value2)) {
|
||||||
|
//console.log(`Check is array: ${JSON.stringify(value1)} =?= ${JSON.stringify(value2)}`);
|
||||||
|
if (value1.length !== value2.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < value1.length; i++) {
|
||||||
|
if (compareValues(value1[i], value2[i])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Otherwise, compare the values directly
|
||||||
|
//console.log(`compare : ${value1} =?= ${value2}`);
|
||||||
|
return value1 !== value2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all keys from both objects
|
||||||
|
const allKeys = new Set([...Object.keys(obj1), ...Object.keys(obj2)]);
|
||||||
|
|
||||||
|
// Iterate over all keys
|
||||||
|
for (const key of allKeys) {
|
||||||
|
if (compareValues(obj1[key], obj2[key])) {
|
||||||
|
result[key] = true;
|
||||||
|
} else {
|
||||||
|
result[key] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFormidable = <TYPE extends object = object>({
|
||||||
|
initialValues = {} as TYPE,
|
||||||
|
}: {
|
||||||
|
initialValues?: TYPE;
|
||||||
|
}) => {
|
||||||
|
const [values, setValues] = useState<TYPE>({ ...initialValues } as TYPE);
|
||||||
|
const [initialData, setInitialData] = useState<TYPE>(initialValues);
|
||||||
|
const [isModify, setIsModify] = useState<{ [key: string]: boolean }>({});
|
||||||
|
const [isFormModified, setIsFormModified] = useState<boolean>(false);
|
||||||
|
useEffect(() => {
|
||||||
|
setInitialData((previous) => {
|
||||||
|
//console.log(`FORMIDABLE: useMemo initial Values(${JSON.stringify(initialValues)})`);
|
||||||
|
const previousJson = JSON.stringify(previous);
|
||||||
|
const newJson = JSON.stringify(initialValues);
|
||||||
|
if (previousJson === newJson) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
//console.log(`FORMIDABLE: ==> update new values`);
|
||||||
|
setValues({ ...initialValues });
|
||||||
|
const ret = getDifferences(initialValues, initialValues);
|
||||||
|
setIsModify(ret);
|
||||||
|
setIsFormModified(hasAnyTrue(ret));
|
||||||
|
return initialValues;
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
initialValues,
|
||||||
|
setInitialData,
|
||||||
|
setValues,
|
||||||
|
setIsModify,
|
||||||
|
setIsFormModified,
|
||||||
|
]);
|
||||||
|
const restoreValues = useCallback(() => {
|
||||||
|
setValues({ ...initialData });
|
||||||
|
}, [setValues, initialData]);
|
||||||
|
const setValuesExternal = useCallback(
|
||||||
|
(data: object) => {
|
||||||
|
//console.log(`FORMIDABLE: setValuesExternal(${JSON.stringify(data)}) ==> keys=${Object.keys(data)}`);
|
||||||
|
setValues((previous) => {
|
||||||
|
const newValues = { ...previous, ...data };
|
||||||
|
const ret = getDifferences(initialData, newValues);
|
||||||
|
setIsModify(ret);
|
||||||
|
setIsFormModified(hasAnyTrue(ret));
|
||||||
|
return newValues;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setValues, initialData]
|
||||||
|
);
|
||||||
|
const restoreValue = useCallback(
|
||||||
|
(data: object) => {
|
||||||
|
setValues((previous) => {
|
||||||
|
const keysInPrevious = Object.keys(previous);
|
||||||
|
const newValue = { ...previous };
|
||||||
|
let countModify = 0;
|
||||||
|
//console.log(`restore value ${JSON.stringify(data, null, 2)}`);
|
||||||
|
for (const key of Object.keys(data)) {
|
||||||
|
if (!keysInPrevious.includes(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (data[key] === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
newValue[key] = initialValues[key];
|
||||||
|
countModify++;
|
||||||
|
}
|
||||||
|
if (countModify === 0) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
//console.log(`initialData data ${JSON.stringify(initialData, null, 2)}`);
|
||||||
|
//console.log(`New data ${JSON.stringify(newValue, null, 2)}`);
|
||||||
|
const ret = getDifferences(initialData, newValue);
|
||||||
|
setIsModify(ret);
|
||||||
|
setIsFormModified(hasAnyTrue(ret));
|
||||||
|
return newValue;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setValues, initialData, setIsFormModified, setIsModify]
|
||||||
|
);
|
||||||
|
const getDeltaData = useCallback(
|
||||||
|
({ omit = [], only }: { omit?: string[]; only?: string[] }) => {
|
||||||
|
const out = {};
|
||||||
|
Object.keys(isModify).forEach((key) => {
|
||||||
|
if (omit.includes(key) || (only && !only.includes(key))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isModify[key]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tmpValue = values[key];
|
||||||
|
if (isNullOrUndefined(tmpValue)) {
|
||||||
|
out[key] = null;
|
||||||
|
} else {
|
||||||
|
out[key] = tmpValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
[isModify, values]
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
getDeltaData,
|
||||||
|
isFormModified,
|
||||||
|
isModify,
|
||||||
|
restoreValues,
|
||||||
|
restoreValue,
|
||||||
|
setValues: setValuesExternal,
|
||||||
|
values,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseFormidableReturn = ReturnType<typeof useFormidable>;
|
62
front/src/components/gender/DisplayGender.tsx
Normal file
62
front/src/components/gender/DisplayGender.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { Flex, Text } from '@chakra-ui/react';
|
||||||
|
import { LuDisc3 } from 'react-icons/lu';
|
||||||
|
|
||||||
|
import { Gender } from '@/back-api';
|
||||||
|
import { Covers } from '@/components/Cover';
|
||||||
|
import { useCountTracksOfAGender } from '@/service/Track';
|
||||||
|
|
||||||
|
export type DisplayGenderProps = {
|
||||||
|
dataGender?: Gender;
|
||||||
|
};
|
||||||
|
export const DisplayGender = ({ dataGender }: DisplayGenderProps) => {
|
||||||
|
const { countTracksOnAGender } = useCountTracksOfAGender(dataGender?.id);
|
||||||
|
if (!dataGender) {
|
||||||
|
return (
|
||||||
|
<Flex direction="row" width="full" height="full">
|
||||||
|
Fail to retrieve Gender Data.
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Flex direction="row" width="full" height="full">
|
||||||
|
<Covers
|
||||||
|
data={dataGender?.covers}
|
||||||
|
size="100"
|
||||||
|
height="full"
|
||||||
|
iconEmpty={<LuDisc3 />}
|
||||||
|
/>
|
||||||
|
<Flex
|
||||||
|
direction="column"
|
||||||
|
width="150px"
|
||||||
|
maxWidth="150px"
|
||||||
|
height="full"
|
||||||
|
paddingLeft="5px"
|
||||||
|
overflowX="hidden"
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
as="span"
|
||||||
|
alignContent="left"
|
||||||
|
fontSize="20px"
|
||||||
|
fontWeight="bold"
|
||||||
|
userSelect="none"
|
||||||
|
marginRight="auto"
|
||||||
|
overflow="hidden"
|
||||||
|
//TODO: noOfLines={[1, 2]}
|
||||||
|
>
|
||||||
|
{dataGender?.name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
as="span"
|
||||||
|
alignContent="left"
|
||||||
|
fontSize="15px"
|
||||||
|
userSelect="none"
|
||||||
|
marginRight="auto"
|
||||||
|
overflow="hidden"
|
||||||
|
//TODO: noOfLines={1}
|
||||||
|
>
|
||||||
|
{countTracksOnAGender} track{countTracksOnAGender >= 1 && 's'}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
10
front/src/components/gender/DisplayGenderId.tsx
Normal file
10
front/src/components/gender/DisplayGenderId.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { DisplayGender } from '@/components/gender/DisplayGender';
|
||||||
|
import { useSpecificGender } from '@/service/Gender';
|
||||||
|
|
||||||
|
export type DisplayGenderIdProps = {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
export const DisplayGenderId = ({ id }: DisplayGenderIdProps) => {
|
||||||
|
const { dataGender } = useSpecificGender(id);
|
||||||
|
return <DisplayGender dataGender={dataGender} />;
|
||||||
|
};
|
5
front/src/components/index.ts
Normal file
5
front/src/components/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export * from './AudioPlayer';
|
||||||
|
export * from './Cover';
|
||||||
|
export * from './EmptyEnd';
|
||||||
|
export * from './Icon';
|
||||||
|
export * from './SearchInput';
|
243
front/src/components/popup/AlbumEditPopUp.tsx
Normal file
243
front/src/components/popup/AlbumEditPopUp.tsx
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Flex,
|
||||||
|
Text,
|
||||||
|
useDisclosure,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import {
|
||||||
|
MdAdminPanelSettings,
|
||||||
|
MdDeleteForever,
|
||||||
|
MdEdit,
|
||||||
|
MdWarning,
|
||||||
|
} from 'react-icons/md';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { Album, AlbumResource } from '@/back-api';
|
||||||
|
import { FormCovers } from '@/components/form/FormCovers';
|
||||||
|
import { FormGroup } from '@/components/form/FormGroup';
|
||||||
|
import { FormInput } from '@/components/form/FormInput';
|
||||||
|
import { FormTextarea } from '@/components/form/FormTextarea';
|
||||||
|
import { useFormidable } from '@/components/form/Formidable';
|
||||||
|
import { ConfirmPopUp } from '@/components/popup/ConfirmPopUp';
|
||||||
|
import { useAlbumService, useSpecificAlbum } from '@/service/Album';
|
||||||
|
import { useServiceContext } from '@/service/ServiceContext';
|
||||||
|
import { useCountTracksWithAlbumId } from '@/service/Track';
|
||||||
|
import { isNullOrUndefined } from '@/utils/validator';
|
||||||
|
import { DialogBody, DialogContent, DialogFooter, DialogHeader, DialogRoot } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
|
||||||
|
|
||||||
|
export type AlbumEditPopUpProps = {};
|
||||||
|
|
||||||
|
export const AlbumEditPopUp = ({ }: AlbumEditPopUpProps) => {
|
||||||
|
const { albumId } = useParams();
|
||||||
|
const albumIdInt = isNullOrUndefined(albumId)
|
||||||
|
? undefined
|
||||||
|
: parseInt(albumId, 10);
|
||||||
|
const { session } = useServiceContext();
|
||||||
|
const { countTracksOfAnAlbum } = useCountTracksWithAlbumId(albumIdInt);
|
||||||
|
const { store } = useAlbumService();
|
||||||
|
const { dataAlbum } = useSpecificAlbum(albumIdInt);
|
||||||
|
const [admin, setAdmin] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const disclosure = useDisclosure();
|
||||||
|
const onClose = () => {
|
||||||
|
navigate('../../', { relative: 'path' });
|
||||||
|
};
|
||||||
|
const onRemove = () => {
|
||||||
|
if (isNullOrUndefined(albumIdInt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
store.remove(
|
||||||
|
albumIdInt,
|
||||||
|
AlbumResource.remove({
|
||||||
|
restConfig: session.getRestConfig(),
|
||||||
|
params: {
|
||||||
|
id: albumIdInt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
const initialRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const finalRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const form = useFormidable<Album>({
|
||||||
|
initialValues: dataAlbum,
|
||||||
|
});
|
||||||
|
const onSave = async () => {
|
||||||
|
if (isNullOrUndefined(albumIdInt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dataThatNeedToBeUpdated = form.getDeltaData({ omit: ['covers'] });
|
||||||
|
console.log(`onSave = ${JSON.stringify(dataThatNeedToBeUpdated, null, 2)}`);
|
||||||
|
store.update(
|
||||||
|
AlbumResource.patch({
|
||||||
|
restConfig: session.getRestConfig(),
|
||||||
|
data: dataThatNeedToBeUpdated,
|
||||||
|
params: {
|
||||||
|
id: albumIdInt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUriSelected = (uri: string) => {
|
||||||
|
if (isNullOrUndefined(albumIdInt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
store.update(
|
||||||
|
AlbumResource.uploadCover({
|
||||||
|
restConfig: session.getRestConfig(),
|
||||||
|
data: {
|
||||||
|
uri: uri,
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
id: albumIdInt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFilesSelected = (files: File[]) => {
|
||||||
|
files.forEach((element) => {
|
||||||
|
console.log(`Select file: '${element.name}'`);
|
||||||
|
});
|
||||||
|
if (isNullOrUndefined(albumIdInt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
store.update(
|
||||||
|
AlbumResource.uploadCover({
|
||||||
|
restConfig: session.getRestConfig(),
|
||||||
|
data: {
|
||||||
|
file: files[0],
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
id: albumIdInt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const onRemoveCover = (index: number) => {
|
||||||
|
if (isNullOrUndefined(dataAlbum?.covers)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isNullOrUndefined(albumIdInt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
store.update(
|
||||||
|
AlbumResource.removeCover({
|
||||||
|
restConfig: session.getRestConfig(),
|
||||||
|
params: {
|
||||||
|
id: albumIdInt,
|
||||||
|
coverId: dataAlbum.covers[index],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<DialogRoot
|
||||||
|
//initialFocusRef={initialRef}
|
||||||
|
//finalFocusRef={finalRef}
|
||||||
|
//closeOnOverlayClick={false}
|
||||||
|
//onOpenChange={onClose}
|
||||||
|
open={true}
|
||||||
|
data-testid="album-edit-pop-up"
|
||||||
|
>
|
||||||
|
{/* <DialogOverlay /> */}
|
||||||
|
{/* <DialogCloseTrigger /> */}
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>Edit Album</DialogHeader>
|
||||||
|
{/* <DialogCloseButton ref={finalRef} /> */}
|
||||||
|
|
||||||
|
<DialogBody pb={6} gap="0px" paddingLeft="18px">
|
||||||
|
{admin && (
|
||||||
|
<>
|
||||||
|
<FormGroup isRequired label="Id">
|
||||||
|
<Text>{dataAlbum?.id}</Text>
|
||||||
|
</FormGroup>
|
||||||
|
{countTracksOfAnAlbum !== 0 && (
|
||||||
|
<Flex paddingLeft="14px">
|
||||||
|
<MdWarning color="red.600" />
|
||||||
|
<Text paddingLeft="6px" color="red.600" fontWeight="bold">
|
||||||
|
Can not remove album {countTracksOfAnAlbum} track(s) depend
|
||||||
|
on it.
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
<FormGroup label="Action(s):">
|
||||||
|
<Button
|
||||||
|
onClick={disclosure.onOpen}
|
||||||
|
marginRight="auto"
|
||||||
|
colorPalette="@danger"
|
||||||
|
disabled={countTracksOfAnAlbum !== 0}
|
||||||
|
>
|
||||||
|
<MdDeleteForever /> Remove Media
|
||||||
|
</Button>
|
||||||
|
</FormGroup>
|
||||||
|
<ConfirmPopUp
|
||||||
|
disclosure={disclosure}
|
||||||
|
title="Remove album"
|
||||||
|
body={`Remove Album [${dataAlbum?.id}] ${dataAlbum?.name}`}
|
||||||
|
confirmTitle="Remove"
|
||||||
|
onConfirm={onRemove}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!admin && (
|
||||||
|
<>
|
||||||
|
<FormInput
|
||||||
|
form={form}
|
||||||
|
variableName="name"
|
||||||
|
isRequired
|
||||||
|
label="Title"
|
||||||
|
ref={initialRef}
|
||||||
|
/>
|
||||||
|
<FormTextarea
|
||||||
|
form={form}
|
||||||
|
variableName="description"
|
||||||
|
label="Description"
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
form={form}
|
||||||
|
variableName="publication"
|
||||||
|
label="Publication"
|
||||||
|
/>
|
||||||
|
<FormCovers
|
||||||
|
form={form}
|
||||||
|
variableName="covers"
|
||||||
|
onFilesSelected={onFilesSelected}
|
||||||
|
onUriSelected={onUriSelected}
|
||||||
|
onRemove={onRemoveCover}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={() => setAdmin((value) => !value)}
|
||||||
|
marginRight="auto"
|
||||||
|
>
|
||||||
|
{admin ? (
|
||||||
|
<>
|
||||||
|
<MdEdit />
|
||||||
|
Edit
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MdAdminPanelSettings />
|
||||||
|
Admin
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{!admin && form.isFormModified && (
|
||||||
|
<Button colorScheme="blue" mr={3} onClick={onSave}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogRoot>
|
||||||
|
);
|
||||||
|
};
|
244
front/src/components/popup/ArtistEditPopUp.tsx
Normal file
244
front/src/components/popup/ArtistEditPopUp.tsx
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Flex,
|
||||||
|
Text,
|
||||||
|
useDisclosure,
|
||||||
|
Button,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { DialogBody, DialogContent, DialogFooter, DialogHeader, DialogRoot } from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
MdAdminPanelSettings,
|
||||||
|
MdDeleteForever,
|
||||||
|
MdEdit,
|
||||||
|
MdWarning,
|
||||||
|
} from 'react-icons/md';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { Artist, ArtistResource } from '@/back-api';
|
||||||
|
import { FormCovers } from '@/components/form/FormCovers';
|
||||||
|
import { FormGroup } from '@/components/form/FormGroup';
|
||||||
|
import { FormInput } from '@/components/form/FormInput';
|
||||||
|
import { FormTextarea } from '@/components/form/FormTextarea';
|
||||||
|
import { useFormidable } from '@/components/form/Formidable';
|
||||||
|
import { ConfirmPopUp } from '@/components/popup/ConfirmPopUp';
|
||||||
|
import { useArtistService, useSpecificArtist } from '@/service/Artist';
|
||||||
|
import { useServiceContext } from '@/service/ServiceContext';
|
||||||
|
import { useCountTracksOfAnArtist } from '@/service/Track';
|
||||||
|
import { isNullOrUndefined } from '@/utils/validator';
|
||||||
|
|
||||||
|
export type ArtistEditPopUpProps = {};
|
||||||
|
|
||||||
|
export const ArtistEditPopUp = ({ }: ArtistEditPopUpProps) => {
|
||||||
|
const { artistId } = useParams();
|
||||||
|
const artistIdInt = isNullOrUndefined(artistId)
|
||||||
|
? undefined
|
||||||
|
: parseInt(artistId, 10);
|
||||||
|
const { session } = useServiceContext();
|
||||||
|
const { countTracksOnAnArtist } = useCountTracksOfAnArtist(artistIdInt);
|
||||||
|
const { store } = useArtistService();
|
||||||
|
const { dataArtist } = useSpecificArtist(artistIdInt);
|
||||||
|
const [admin, setAdmin] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const disclosure = useDisclosure();
|
||||||
|
const onClose = () => {
|
||||||
|
navigate('../../', { relative: 'path' });
|
||||||
|
};
|
||||||
|
const onRemove = () => {
|
||||||
|
if (isNullOrUndefined(artistIdInt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
store.remove(
|
||||||
|
artistIdInt,
|
||||||
|
ArtistResource.remove({
|
||||||
|
restConfig: session.getRestConfig(),
|
||||||
|
params: {
|
||||||
|
id: artistIdInt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
const initialRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const finalRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const form = useFormidable<Artist>({
|
||||||
|
initialValues: dataArtist,
|
||||||
|
});
|
||||||
|
const onSave = async () => {
|
||||||
|
if (isNullOrUndefined(artistIdInt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dataThatNeedToBeUpdated = form.getDeltaData({ omit: ['covers'] });
|
||||||
|
console.log(`onSave = ${JSON.stringify(dataThatNeedToBeUpdated, null, 2)}`);
|
||||||
|
store.update(
|
||||||
|
ArtistResource.patch({
|
||||||
|
restConfig: session.getRestConfig(),
|
||||||
|
data: dataThatNeedToBeUpdated,
|
||||||
|
params: {
|
||||||
|
id: artistIdInt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUriSelected = (uri: string) => {
|
||||||
|
if (isNullOrUndefined(artistIdInt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
store.update(
|
||||||
|
ArtistResource.uploadCover({
|
||||||
|
restConfig: session.getRestConfig(),
|
||||||
|
data: {
|
||||||
|
uri: uri,
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
id: artistIdInt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const onFilesSelected = (files: File[]) => {
|
||||||
|
files.forEach((element) => {
|
||||||
|
console.log(`Select file: '${element.name}'`);
|
||||||
|
});
|
||||||
|
if (isNullOrUndefined(artistIdInt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
store.update(
|
||||||
|
ArtistResource.uploadCover({
|
||||||
|
restConfig: session.getRestConfig(),
|
||||||
|
data: {
|
||||||
|
file: files[0],
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
id: artistIdInt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const onRemoveCover = (index: number) => {
|
||||||
|
if (isNullOrUndefined(dataArtist?.covers)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isNullOrUndefined(artistIdInt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
store.update(
|
||||||
|
ArtistResource.removeCover({
|
||||||
|
restConfig: session.getRestConfig(),
|
||||||
|
params: {
|
||||||
|
id: artistIdInt,
|
||||||
|
coverId: dataArtist.covers[index],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<DialogRoot
|
||||||
|
//initialFocusRef={initialRef}
|
||||||
|
//finalFocusRef={finalRef}
|
||||||
|
//closeOnOverlayClick={false}
|
||||||
|
onOpenChange={onClose}
|
||||||
|
open={true}
|
||||||
|
data-testid="artist-edit-pop-up"
|
||||||
|
>
|
||||||
|
{/* <DialogOverlay /> */}
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>Edit Artist</DialogHeader>
|
||||||
|
{/* <DialogCloseButton ref={finalRef} /> */}
|
||||||
|
|
||||||
|
<DialogBody pb={6} gap="0px" paddingLeft="18px">
|
||||||
|
{admin && (
|
||||||
|
<>
|
||||||
|
<FormGroup isRequired label="Id">
|
||||||
|
<Text>{dataArtist?.id}</Text>
|
||||||
|
</FormGroup>
|
||||||
|
{countTracksOnAnArtist !== 0 && (
|
||||||
|
<Flex paddingLeft="14px">
|
||||||
|
<MdWarning color="red.600" />
|
||||||
|
<Text paddingLeft="6px" color="red.600" fontWeight="bold">
|
||||||
|
Can not remove artist {countTracksOnAnArtist} track(s)
|
||||||
|
depend on it.
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
<FormGroup label="Action(s):">
|
||||||
|
<Button
|
||||||
|
onClick={disclosure.onOpen}
|
||||||
|
marginRight="auto"
|
||||||
|
colorPalette="@danger"
|
||||||
|
disabled={countTracksOnAnArtist !== 0}
|
||||||
|
>
|
||||||
|
<MdDeleteForever /> Remove Media
|
||||||
|
</Button>
|
||||||
|
</FormGroup>
|
||||||
|
<ConfirmPopUp
|
||||||
|
disclosure={disclosure}
|
||||||
|
title="Remove artist"
|
||||||
|
body={`Remove Artist [${dataArtist?.id}] ${dataArtist?.name}`}
|
||||||
|
confirmTitle="Remove"
|
||||||
|
onConfirm={onRemove}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!admin && (
|
||||||
|
<>
|
||||||
|
<FormInput
|
||||||
|
form={form}
|
||||||
|
variableName="name"
|
||||||
|
isRequired
|
||||||
|
label="Artist name"
|
||||||
|
ref={initialRef}
|
||||||
|
/>
|
||||||
|
<FormTextarea
|
||||||
|
form={form}
|
||||||
|
variableName="description"
|
||||||
|
label="Description"
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
form={form}
|
||||||
|
variableName="firstName"
|
||||||
|
label="First Name"
|
||||||
|
/>
|
||||||
|
<FormInput form={form} variableName="surname" label="SurName" />
|
||||||
|
<FormInput form={form} variableName="birth" label="Birth date" />
|
||||||
|
<FormInput form={form} variableName="death" label="Death date" />
|
||||||
|
<FormCovers
|
||||||
|
form={form}
|
||||||
|
variableName="covers"
|
||||||
|
onFilesSelected={onFilesSelected}
|
||||||
|
onUriSelected={onUriSelected}
|
||||||
|
onRemove={onRemoveCover}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={() => setAdmin((value) => !value)}
|
||||||
|
marginRight="auto"
|
||||||
|
colorPalette={admin ? undefined : "@danger"}
|
||||||
|
>
|
||||||
|
{admin ? (
|
||||||
|
<>
|
||||||
|
<MdEdit />
|
||||||
|
Edit
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MdAdminPanelSettings />
|
||||||
|
Admin
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{!admin && form.isFormModified && (
|
||||||
|
<Button colorScheme="blue" mr={3} onClick={onSave}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogRoot>
|
||||||
|
);
|
||||||
|
};
|
54
front/src/components/popup/ConfirmPopUp.tsx
Normal file
54
front/src/components/popup/ConfirmPopUp.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
UseDisclosureReturn,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { DialogBody, DialogContent, DialogFooter, DialogHeader, DialogRoot } from '@/components/ui/dialog';
|
||||||
|
export type ConfirmPopUpProps = {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
confirmTitle: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
disclosure: UseDisclosureReturn;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ConfirmPopUp = ({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
confirmTitle,
|
||||||
|
onConfirm,
|
||||||
|
disclosure,
|
||||||
|
}: ConfirmPopUpProps) => {
|
||||||
|
const onClickConfirm = () => {
|
||||||
|
onConfirm();
|
||||||
|
disclosure.onClose();
|
||||||
|
};
|
||||||
|
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||||
|
return (
|
||||||
|
<DialogRoot role="alertdialog"
|
||||||
|
open={disclosure.open}
|
||||||
|
//leastDestructiveRef={cancelRef}
|
||||||
|
onOpenChange={disclosure.onClose}
|
||||||
|
data-testid="confirm-pop-up"
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader fontSize="lg" fontWeight="bold">
|
||||||
|
{title}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogBody>{body}</DialogBody>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={disclosure.onClose} ref={cancelRef}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button colorScheme="red" onClick={onClickConfirm} ml={3}>
|
||||||
|
{confirmTitle}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogRoot>
|
||||||
|
);
|
||||||
|
};
|
235
front/src/components/popup/GenderEditPopUp.tsx
Normal file
235
front/src/components/popup/GenderEditPopUp.tsx
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Flex,
|
||||||
|
Text,
|
||||||
|
useDisclosure,
|
||||||
|
Button
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import {
|
||||||
|
MdAdminPanelSettings,
|
||||||
|
MdDeleteForever,
|
||||||
|
MdEdit,
|
||||||
|
MdWarning,
|
||||||
|
} from 'react-icons/md';
|
||||||
|
|
||||||
|
import { DialogBody, DialogContent, DialogFooter, DialogHeader, DialogRoot } from '@/components/ui/dialog';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { Gender, GenderResource } from '@/back-api';
|
||||||
|
import { FormCovers } from '@/components/form/FormCovers';
|
||||||
|
import { FormGroup } from '@/components/form/FormGroup';
|
||||||
|
import { FormInput } from '@/components/form/FormInput';
|
||||||
|
import { FormTextarea } from '@/components/form/FormTextarea';
|
||||||
|
import { useFormidable } from '@/components/form/Formidable';
|
||||||
|
import { ConfirmPopUp } from '@/components/popup/ConfirmPopUp';
|
||||||
|
import { useGenderService, useSpecificGender } from '@/service/Gender';
|
||||||
|
import { useServiceContext } from '@/service/ServiceContext';
|
||||||
|
import { useCountTracksOfAGender } from '@/service/Track';
|
||||||
|
import { isNullOrUndefined } from '@/utils/validator';
|
||||||
|
|
||||||
|
export type GenderEditPopUpProps = {};
|
||||||
|
|
||||||
|
export const GenderEditPopUp = ({ }: GenderEditPopUpProps) => {
|
||||||
|
const { genderId } = useParams();
|
||||||
|
const genderIdInt = isNullOrUndefined(genderId)
|
||||||
|
? undefined
|
||||||
|
: parseInt(genderId, 10);
|
||||||
|
const { session } = useServiceContext();
|
||||||
|
const { countTracksOnAGender } = useCountTracksOfAGender(genderIdInt);
|
||||||
|
const { store } = useGenderService();
|
||||||
|
const { dataGender } = useSpecificGender(genderIdInt);
|
||||||
|
const [admin, setAdmin] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const disclosure = useDisclosure();
|
||||||
|
const onClose = () => {
|
||||||
|
navigate('../../', { relative: 'path' });
|
||||||
|
};
|
||||||
|
const onRemove = () => {
|
||||||
|
if (isNullOrUndefined(genderIdInt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
store.remove(
|
||||||
|
genderIdInt,
|
||||||
|
GenderResource.remove({
|
||||||
|
restConfig: session.getRestConfig(),
|
||||||
|
params: {
|
||||||
|
id: genderIdInt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
const initialRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const finalRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const form = useFormidable<Gender>({
|
||||||
|
initialValues: dataGender,
|
||||||
|
});
|
||||||
|
const onSave = async () => {
|
||||||
|
if (isNullOrUndefined(genderIdInt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dataThatNeedToBeUpdated = form.getDeltaData({ omit: ['covers'] });
|
||||||
|
console.log(`onSave = ${JSON.stringify(dataThatNeedToBeUpdated, null, 2)}`);
|
||||||
|
store.update(
|
||||||
|
GenderResource.patch({
|
||||||
|
restConfig: session.getRestConfig(),
|
||||||
|
data: dataThatNeedToBeUpdated,
|
||||||
|
params: {
|
||||||
|
id: genderIdInt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const onUriSelected = (uri: string) => {
|
||||||
|
if (isNullOrUndefined(genderIdInt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
store.update(
|
||||||
|
GenderResource.uploadCover({
|
||||||
|
restConfig: session.getRestConfig(),
|
||||||
|
data: {
|
||||||
|
uri: uri,
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
id: genderIdInt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const onFilesSelected = (files: File[]) => {
|
||||||
|
files.forEach((element) => {
|
||||||
|
console.log(`Select file: '${element.name}'`);
|
||||||
|
});
|
||||||
|
if (isNullOrUndefined(genderIdInt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
store.update(
|
||||||
|
GenderResource.uploadCover({
|
||||||
|
restConfig: session.getRestConfig(),
|
||||||
|
data: {
|
||||||
|
file: files[0],
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
id: genderIdInt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const onRemoveCover = (index: number) => {
|
||||||
|
if (isNullOrUndefined(dataGender?.covers)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isNullOrUndefined(genderIdInt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
store.update(
|
||||||
|
GenderResource.removeCover({
|
||||||
|
restConfig: session.getRestConfig(),
|
||||||
|
params: {
|
||||||
|
id: genderIdInt,
|
||||||
|
coverId: dataGender.covers[index],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<DialogRoot
|
||||||
|
//initialFocusRef={initialRef}
|
||||||
|
//finalFocusRef={finalRef}
|
||||||
|
//closeOnOverlayClick={false}
|
||||||
|
onOpenChange={onClose}
|
||||||
|
open={true}
|
||||||
|
data-testid="gender-edit-pop-up"
|
||||||
|
>
|
||||||
|
{/* <DialogOverlay /> */}
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>Edit Gender</DialogHeader>
|
||||||
|
{/* <DialogCloseButton ref={finalRef} /> */}
|
||||||
|
|
||||||
|
<DialogBody pb={6} gap="0px" paddingLeft="18px">
|
||||||
|
{admin && (
|
||||||
|
<>
|
||||||
|
<FormGroup isRequired label="Id">
|
||||||
|
<Text>{dataGender?.id}</Text>
|
||||||
|
</FormGroup>
|
||||||
|
{countTracksOnAGender !== 0 && (
|
||||||
|
<Flex paddingLeft="14px">
|
||||||
|
<MdWarning color="red.600" />
|
||||||
|
<Text paddingLeft="6px" color="red.600" fontWeight="bold">
|
||||||
|
Can not remove gender {countTracksOnAGender} track(s) depend
|
||||||
|
on it.
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
<FormGroup label="Action(s):">
|
||||||
|
<Button
|
||||||
|
onClick={disclosure.onOpen}
|
||||||
|
marginRight="auto"
|
||||||
|
colorPalette="@danger"
|
||||||
|
disabled={countTracksOnAGender !== 0}
|
||||||
|
>
|
||||||
|
<MdDeleteForever /> Remove gender
|
||||||
|
</Button>
|
||||||
|
</FormGroup>
|
||||||
|
<ConfirmPopUp
|
||||||
|
disclosure={disclosure}
|
||||||
|
title="Remove gender"
|
||||||
|
body={`Remove gender [${dataGender?.id}] ${dataGender?.name}`}
|
||||||
|
confirmTitle="Remove"
|
||||||
|
onConfirm={onRemove}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!admin && (
|
||||||
|
<>
|
||||||
|
<FormInput
|
||||||
|
form={form}
|
||||||
|
variableName="name"
|
||||||
|
isRequired
|
||||||
|
label="Gender name"
|
||||||
|
ref={initialRef}
|
||||||
|
/>
|
||||||
|
<FormTextarea
|
||||||
|
form={form}
|
||||||
|
variableName="description"
|
||||||
|
label="Description"
|
||||||
|
/>
|
||||||
|
<FormCovers
|
||||||
|
form={form}
|
||||||
|
variableName="covers"
|
||||||
|
onFilesSelected={onFilesSelected}
|
||||||
|
onUriSelected={onUriSelected}
|
||||||
|
onRemove={onRemoveCover}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={() => setAdmin((value) => !value)}
|
||||||
|
marginRight="auto"
|
||||||
|
>
|
||||||
|
{admin ? (
|
||||||
|
<>
|
||||||
|
<MdEdit />
|
||||||
|
Edit
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MdAdminPanelSettings />
|
||||||
|
Admin
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{!admin && form.isFormModified && (
|
||||||
|
<Button colorScheme="blue" mr={3} onClick={onSave}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogRoot>
|
||||||
|
);
|
||||||
|
};
|
104
front/src/components/popup/PopUpUploadProgress.tsx
Normal file
104
front/src/components/popup/PopUpUploadProgress.tsx
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Flex,
|
||||||
|
Progress,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { DialogBody, DialogContent, DialogFooter, DialogHeader, DialogRoot } from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
|
||||||
|
export type PopUpUploadProgressProps = {
|
||||||
|
title: string;
|
||||||
|
// current size send or receive
|
||||||
|
currentSize: number;
|
||||||
|
// in case of error this element is set to != undefined
|
||||||
|
error?: string;
|
||||||
|
// Total size to transfer
|
||||||
|
totalSize: number;
|
||||||
|
// index of the file to transfer
|
||||||
|
index: number;
|
||||||
|
// When finished the boolean is set to true
|
||||||
|
isFinished: boolean;
|
||||||
|
// List of element to Transfer
|
||||||
|
elements: string[];
|
||||||
|
onAbort: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PopUpUploadProgress = ({
|
||||||
|
currentSize,
|
||||||
|
elements,
|
||||||
|
error,
|
||||||
|
index,
|
||||||
|
isFinished,
|
||||||
|
onAbort,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
totalSize,
|
||||||
|
}: PopUpUploadProgressProps) => {
|
||||||
|
const initialRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const finalRef = useRef<HTMLButtonElement>(null);
|
||||||
|
return (
|
||||||
|
<DialogRoot
|
||||||
|
//initialFocusRef={initialRef}
|
||||||
|
//finalFocusRef={finalRef}
|
||||||
|
//closeOnOverlayClick={false}
|
||||||
|
onOpenChange={onClose}
|
||||||
|
open={true}
|
||||||
|
data-testid="upload-progress-edit-pop-up"
|
||||||
|
>
|
||||||
|
{/* <DialogOverlay /> */}
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>{title}</DialogHeader>
|
||||||
|
{/* <DialogCloseButton ref={finalRef} /> */}
|
||||||
|
|
||||||
|
<DialogBody pb={6} paddingLeft="18px">
|
||||||
|
<Flex direction="column" gap="10px">
|
||||||
|
{isFinished ? (
|
||||||
|
<Text fontSize="20px" fontWeight="bold">
|
||||||
|
All {elements.length} element have been sent
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text fontSize="20px" fontWeight="bold">
|
||||||
|
[{index + 1}/{elements.length}] {elements[index]}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Progress.Root
|
||||||
|
colorScheme="green"
|
||||||
|
striped
|
||||||
|
value={currentSize}
|
||||||
|
animated
|
||||||
|
max={totalSize}
|
||||||
|
height="24px"
|
||||||
|
/>
|
||||||
|
<Flex>
|
||||||
|
<Text>{currentSize.toLocaleString('fr-FR')} Bytes</Text>
|
||||||
|
<Text marginLeft="auto">
|
||||||
|
{totalSize.toLocaleString('fr-FR')} Bytes
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
{error && (
|
||||||
|
<Text fontWeight="bold" color="darkred">
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter>
|
||||||
|
{isFinished ? (
|
||||||
|
<Button onClick={onClose} colorPalette="green">
|
||||||
|
Ok
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button colorScheme="red" mr={3} onClick={onAbort} ref={initialRef}>
|
||||||
|
Abort
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogRoot>
|
||||||
|
);
|
||||||
|
};
|
201
front/src/components/popup/TrackEditPopUp.tsx
Normal file
201
front/src/components/popup/TrackEditPopUp.tsx
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Text,
|
||||||
|
useDisclosure,
|
||||||
|
Button,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { DialogBody, DialogContent, DialogFooter, DialogHeader, DialogRoot } from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
import { MdAdminPanelSettings, MdDeleteForever, MdEdit } from 'react-icons/md';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { Track, TrackResource } from '@/back-api';
|
||||||
|
import { FormGroup } from '@/components/form/FormGroup';
|
||||||
|
import { FormInput } from '@/components/form/FormInput';
|
||||||
|
import { FormNumber } from '@/components/form/FormNumber';
|
||||||
|
import { FormSelect } from '@/components/form/FormSelect';
|
||||||
|
import { FormSelectMultiple } from '@/components/form/FormSelectMultiple';
|
||||||
|
import { FormTextarea } from '@/components/form/FormTextarea';
|
||||||
|
import { useFormidable } from '@/components/form/Formidable';
|
||||||
|
import { ConfirmPopUp } from '@/components/popup/ConfirmPopUp';
|
||||||
|
import { useOrderedAlbums } from '@/service/Album';
|
||||||
|
import { useOrderedArtists } from '@/service/Artist';
|
||||||
|
import { useOrderedGenders } from '@/service/Gender';
|
||||||
|
import { useServiceContext } from '@/service/ServiceContext';
|
||||||
|
import { useSpecificTrack, useTrackService } from '@/service/Track';
|
||||||
|
import { isNullOrUndefined } from '@/utils/validator';
|
||||||
|
|
||||||
|
export type TrackEditPopUpProps = {};
|
||||||
|
|
||||||
|
export const TrackEditPopUp = ({ }: TrackEditPopUpProps) => {
|
||||||
|
const { trackId } = useParams();
|
||||||
|
const trackIdInt = isNullOrUndefined(trackId)
|
||||||
|
? undefined
|
||||||
|
: parseInt(trackId, 10);
|
||||||
|
const { session } = useServiceContext();
|
||||||
|
const { dataGenders } = useOrderedGenders(undefined);
|
||||||
|
const { dataArtist } = useOrderedArtists(undefined);
|
||||||
|
const { dataAlbums } = useOrderedAlbums(undefined);
|
||||||
|
const { store } = useTrackService();
|
||||||
|
const { dataTrack } = useSpecificTrack(trackIdInt);
|
||||||
|
const [admin, setAdmin] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const disclosure = useDisclosure();
|
||||||
|
const onClose = () => {
|
||||||
|
navigate('../../', { relative: 'path' });
|
||||||
|
};
|
||||||
|
const onRemove = () => {
|
||||||
|
if (isNullOrUndefined(trackIdInt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
store.remove(
|
||||||
|
trackIdInt,
|
||||||
|
TrackResource.remove({
|
||||||
|
restConfig: session.getRestConfig(),
|
||||||
|
params: {
|
||||||
|
id: trackIdInt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
const initialRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const finalRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const form = useFormidable<Track>({
|
||||||
|
//onSubmit,
|
||||||
|
//onValuesChange,
|
||||||
|
initialValues: dataTrack,
|
||||||
|
//onValid: () => console.log('onValid'),
|
||||||
|
//onInvalid: () => console.log('onInvalid'),
|
||||||
|
});
|
||||||
|
const onSave = async () => {
|
||||||
|
if (isNullOrUndefined(trackIdInt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dataThatNeedToBeUpdated = form.getDeltaData({ omit: ['covers'] });
|
||||||
|
console.log(`onSave = ${JSON.stringify(dataThatNeedToBeUpdated, null, 2)}`);
|
||||||
|
store.update(
|
||||||
|
TrackResource.patch({
|
||||||
|
restConfig: session.getRestConfig(),
|
||||||
|
data: dataThatNeedToBeUpdated,
|
||||||
|
params: {
|
||||||
|
id: trackIdInt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<DialogRoot
|
||||||
|
//initialFocusRef={initialRef}
|
||||||
|
//finalFocusRef={finalRef}
|
||||||
|
//closeOnOverlayClick={false}
|
||||||
|
onOpenChange={onClose}
|
||||||
|
open={true}
|
||||||
|
data-testid="track-edit-pop-up"
|
||||||
|
>
|
||||||
|
{/* <DialogOverlay /> */}
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>Edit Track</DialogHeader>
|
||||||
|
{/* <DialogCloseButton ref={finalRef} /> */}
|
||||||
|
|
||||||
|
<DialogBody pb={6} gap="0px" paddingLeft="18px">
|
||||||
|
{admin && (
|
||||||
|
<>
|
||||||
|
<FormGroup isRequired label="Id">
|
||||||
|
<Text>{dataTrack?.id}</Text>
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup label="Data Id">
|
||||||
|
<Text>{dataTrack?.dataId}</Text>
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup label="Action(s):">
|
||||||
|
<Button
|
||||||
|
onClick={disclosure.onOpen}
|
||||||
|
marginRight="auto"
|
||||||
|
colorPalette="@danger"
|
||||||
|
>
|
||||||
|
<MdDeleteForever /> Remove Media
|
||||||
|
</Button>
|
||||||
|
</FormGroup>
|
||||||
|
<ConfirmPopUp
|
||||||
|
disclosure={disclosure}
|
||||||
|
title="Remove track"
|
||||||
|
body={`Remove Media [${dataTrack?.id}] ${dataTrack?.name}`}
|
||||||
|
confirmTitle="Remove"
|
||||||
|
onConfirm={onRemove}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!admin && (
|
||||||
|
<>
|
||||||
|
<FormInput
|
||||||
|
form={form}
|
||||||
|
variableName="name"
|
||||||
|
isRequired
|
||||||
|
label="Title"
|
||||||
|
ref={initialRef}
|
||||||
|
/>
|
||||||
|
<FormTextarea
|
||||||
|
form={form}
|
||||||
|
variableName="description"
|
||||||
|
label="Description"
|
||||||
|
/>
|
||||||
|
<FormSelect
|
||||||
|
form={form}
|
||||||
|
variableName="genderId"
|
||||||
|
options={dataGenders}
|
||||||
|
label="Gender"
|
||||||
|
/>
|
||||||
|
<FormSelectMultiple
|
||||||
|
form={form}
|
||||||
|
variableName="artists"
|
||||||
|
options={dataArtist}
|
||||||
|
label="Artist(s)"
|
||||||
|
/>
|
||||||
|
<FormSelect
|
||||||
|
form={form}
|
||||||
|
variableName="albumId"
|
||||||
|
options={dataAlbums}
|
||||||
|
label="Album"
|
||||||
|
/>
|
||||||
|
<FormNumber
|
||||||
|
form={form}
|
||||||
|
variableName="track"
|
||||||
|
label="Track n°"
|
||||||
|
step={1}
|
||||||
|
//defaultValue={0}
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={() => setAdmin((value) => !value)}
|
||||||
|
marginRight="auto"
|
||||||
|
>
|
||||||
|
{admin ? (
|
||||||
|
<>
|
||||||
|
<MdEdit />
|
||||||
|
Edit
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MdAdminPanelSettings />
|
||||||
|
Admin
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{!admin && form.isFormModified && (
|
||||||
|
<Button colorScheme="blue" mr={3} onClick={onSave}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogRoot>
|
||||||
|
);
|
||||||
|
};
|
120
front/src/components/select/SelectList.tsx
Normal file
120
front/src/components/select/SelectList.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import { Box, Button, Flex, Text } from '@chakra-ui/react';
|
||||||
|
import { MdAdd } from 'react-icons/md';
|
||||||
|
|
||||||
|
import { isNullOrUndefined, isNumber } from '@/utils/validator';
|
||||||
|
|
||||||
|
export type SelectListModel = {
|
||||||
|
id: any;
|
||||||
|
name: any;
|
||||||
|
isSelected?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const optionToOptionDisplay = (
|
||||||
|
data: SelectListModel[] | undefined,
|
||||||
|
selectedOptions: SelectListModel[],
|
||||||
|
search?: string
|
||||||
|
): SelectListModel[] => {
|
||||||
|
if (isNullOrUndefined(data)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const out: SelectListModel[] = [];
|
||||||
|
data.forEach((element) => {
|
||||||
|
if (search) {
|
||||||
|
if (isNumber(element.name)) {
|
||||||
|
if (!element.name.toString().includes(search.toLowerCase())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (!element.name.toLowerCase().includes(search.toLowerCase())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push({
|
||||||
|
...element,
|
||||||
|
isSelected:
|
||||||
|
selectedOptions.find((elem) => elem.id === element.id) !== undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SelectListProps = {
|
||||||
|
options?: SelectListModel[];
|
||||||
|
selected: SelectListModel[];
|
||||||
|
onSelectValue: (data: SelectListModel) => void;
|
||||||
|
search?: string;
|
||||||
|
// if set add capability to add the search item
|
||||||
|
onCreate?: (data: string) => void;
|
||||||
|
};
|
||||||
|
export const SelectList = ({
|
||||||
|
options,
|
||||||
|
selected,
|
||||||
|
onSelectValue,
|
||||||
|
search,
|
||||||
|
onCreate,
|
||||||
|
}: SelectListProps) => {
|
||||||
|
const displayedValue = optionToOptionDisplay(options, selected, search);
|
||||||
|
const scrollToRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollToRef?.current) {
|
||||||
|
scrollToRef?.current?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<Box position="relative">
|
||||||
|
<Flex
|
||||||
|
direction="column"
|
||||||
|
width="full"
|
||||||
|
position="absolute"
|
||||||
|
border="1px"
|
||||||
|
borderColor="black"
|
||||||
|
backgroundColor="gray.700"
|
||||||
|
overflowY="auto"
|
||||||
|
overflowX="hidden"
|
||||||
|
maxHeight="300px"
|
||||||
|
zIndex={300}
|
||||||
|
transform="translateY(1px)"
|
||||||
|
>
|
||||||
|
{displayedValue.length === 0 && (
|
||||||
|
<Text marginX="auto" color="red.500" fontWeight="bold" marginY="10px">
|
||||||
|
... No element found...
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{displayedValue.map((data) => (
|
||||||
|
<Button
|
||||||
|
key={data.id}
|
||||||
|
marginY="1px"
|
||||||
|
borderRadius="0px"
|
||||||
|
autoFocus={false}
|
||||||
|
backgroundColor={data.isSelected ? 'green.800' : '0x00000000'}
|
||||||
|
_hover={{ backgroundColor: 'gray.400' }}
|
||||||
|
onClick={() => onSelectValue(data)}
|
||||||
|
ref={data.isSelected ? scrollToRef : undefined}
|
||||||
|
>
|
||||||
|
<Text marginRight="auto" autoFocus={false}>
|
||||||
|
{data.name}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
{onCreate && search && search.length > 0 && (
|
||||||
|
<Button
|
||||||
|
marginY="1px"
|
||||||
|
borderRadius="0px"
|
||||||
|
autoFocus={false}
|
||||||
|
_hover={{ backgroundColor: 'gray.400' }}
|
||||||
|
onClick={() => onCreate(search)}
|
||||||
|
>
|
||||||
|
<Flex marginRight="auto">
|
||||||
|
<MdAdd />
|
||||||
|
<Text autoFocus={false}>Create '{search}'</Text>
|
||||||
|
</Flex>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
166
front/src/components/select/SelectMultiple.tsx
Normal file
166
front/src/components/select/SelectMultiple.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { RefObject, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Flex,
|
||||||
|
HStack,
|
||||||
|
Input,
|
||||||
|
Spinner,
|
||||||
|
Tag,
|
||||||
|
TagLabel,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { MdEdit, MdKeyboardArrowDown, MdKeyboardArrowUp } from 'react-icons/md';
|
||||||
|
|
||||||
|
import { SelectList, SelectListModel } from '@/components/select/SelectList';
|
||||||
|
import { isNullOrUndefined } from '@/utils/validator';
|
||||||
|
|
||||||
|
export type SelectMultipleProps = {
|
||||||
|
options?: object[];
|
||||||
|
values?: (number | string)[];
|
||||||
|
onChange?: (value: (number | string)[] | undefined) => void;
|
||||||
|
keyKey?: string;
|
||||||
|
keyValue?: string;
|
||||||
|
ref?: RefObject<any>;
|
||||||
|
// if set add capability to add the search item
|
||||||
|
onCreate?: (data: string) => void;
|
||||||
|
// if a suggestion exist at the auto compleat
|
||||||
|
suggestion?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SelectMultiple = ({
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
values,
|
||||||
|
ref,
|
||||||
|
keyKey = 'id',
|
||||||
|
keyValue = keyKey,
|
||||||
|
suggestion,
|
||||||
|
onCreate,
|
||||||
|
}: SelectMultipleProps) => {
|
||||||
|
const [showList, setShowList] = useState(false);
|
||||||
|
const transformedOption = useMemo(() => {
|
||||||
|
return options?.map((element) => {
|
||||||
|
return {
|
||||||
|
id: element[keyKey],
|
||||||
|
name: element[keyValue],
|
||||||
|
} as SelectListModel;
|
||||||
|
});
|
||||||
|
}, [options, keyKey, keyValue]);
|
||||||
|
const [hasSuggestion, setHasSuggestion] = useState<boolean>(
|
||||||
|
onCreate && suggestion ? true : false
|
||||||
|
);
|
||||||
|
const [currentSearch, setCurrentSearch] = useState<string | undefined>(
|
||||||
|
onCreate ? suggestion : undefined
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (suggestion) {
|
||||||
|
setCurrentSearch(suggestion);
|
||||||
|
setHasSuggestion(true);
|
||||||
|
}
|
||||||
|
}, [suggestion]);
|
||||||
|
|
||||||
|
const refFocus = ref ?? useRef<HTMLInputElement | null>(null);
|
||||||
|
const selectedOptions = useMemo(() => {
|
||||||
|
if (isNullOrUndefined(values) || !transformedOption) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return transformedOption.filter((element) => {
|
||||||
|
return values.includes(element[keyKey]);
|
||||||
|
});
|
||||||
|
}, [values, transformedOption]);
|
||||||
|
|
||||||
|
const selectValue = (data: SelectListModel) => {
|
||||||
|
const newValues = values?.includes(data.id)
|
||||||
|
? values.filter((elem) => data.id !== elem)
|
||||||
|
: [...(values ?? []), data.id];
|
||||||
|
setShowList(false);
|
||||||
|
if (onChange) {
|
||||||
|
if (newValues.length == 0) {
|
||||||
|
onChange(undefined);
|
||||||
|
} else {
|
||||||
|
onChange(newValues);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (!options) {
|
||||||
|
return <Spinner />;
|
||||||
|
}
|
||||||
|
const onChangeInput = (value: string): void => {
|
||||||
|
setHasSuggestion(false);
|
||||||
|
if (value === '') {
|
||||||
|
setCurrentSearch(undefined);
|
||||||
|
} else {
|
||||||
|
setCurrentSearch(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onOpenClose = () => {
|
||||||
|
if (!showList) {
|
||||||
|
refFocus?.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const createNewItem = !onCreate
|
||||||
|
? undefined
|
||||||
|
: (data: string) => {
|
||||||
|
onCreate(data);
|
||||||
|
setCurrentSearch(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="column" width="full" gap="0px">
|
||||||
|
{selectedOptions && (
|
||||||
|
<HStack wrap="wrap" gap="5px" justify="left" width="full" marginBottom="2px">
|
||||||
|
{selectedOptions.map((data) => (
|
||||||
|
<Flex align="flex-start" key={data[keyKey]}>
|
||||||
|
<Tag.Root
|
||||||
|
size="xl"
|
||||||
|
borderRadius="5px"
|
||||||
|
variant="surface"
|
||||||
|
backgroundColor="green.800"
|
||||||
|
>
|
||||||
|
<Tag.Label>{data[keyValue] ?? `id=${data[keyKey]}`}</Tag.Label>
|
||||||
|
<Tag.CloseTrigger boxSize="5" onClick={() => selectValue(data)} />
|
||||||
|
</Tag.Root>
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Flex>
|
||||||
|
<Input
|
||||||
|
ref={refFocus}
|
||||||
|
width="full"
|
||||||
|
onChange={(e) => onChangeInput(e.target.value)}
|
||||||
|
//onSubmit={onSubmit}
|
||||||
|
onFocus={() => setShowList(true)}
|
||||||
|
onBlur={() => setTimeout(() => setShowList(false), 200)}
|
||||||
|
value={showList ? (currentSearch ?? '') : hasSuggestion ? `suggest: ${currentSearch}` : ''}
|
||||||
|
borderRadius="5px 0 0 5px"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={onOpenClose}
|
||||||
|
variant="outline"
|
||||||
|
borderRadius="0 5px 5px 0"
|
||||||
|
borderWidth="1px 1px 1px 0"
|
||||||
|
>
|
||||||
|
{showList ? (
|
||||||
|
<MdKeyboardArrowUp color="gray.300" />
|
||||||
|
) : hasSuggestion ? (
|
||||||
|
<MdEdit color="gray.300" />
|
||||||
|
) : (
|
||||||
|
<MdKeyboardArrowDown color="gray.300" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
{showList && (
|
||||||
|
<SelectList
|
||||||
|
options={transformedOption}
|
||||||
|
selected={selectedOptions}
|
||||||
|
search={currentSearch}
|
||||||
|
onSelectValue={selectValue}
|
||||||
|
onCreate={createNewItem}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
148
front/src/components/select/SelectSingle.tsx
Normal file
148
front/src/components/select/SelectSingle.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import { RefObject, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { Button, Flex, Input, Spinner } from '@chakra-ui/react';
|
||||||
|
import {
|
||||||
|
MdClose,
|
||||||
|
MdEdit,
|
||||||
|
MdKeyboardArrowDown,
|
||||||
|
MdKeyboardArrowUp,
|
||||||
|
} from 'react-icons/md';
|
||||||
|
|
||||||
|
import { SelectList, SelectListModel } from '@/components/select/SelectList';
|
||||||
|
import { isNullOrUndefined } from '@/utils/validator';
|
||||||
|
|
||||||
|
export type SelectSingleProps = {
|
||||||
|
options?: object[];
|
||||||
|
value?: number | string;
|
||||||
|
onChange?: (value: number | string | undefined) => void;
|
||||||
|
keyKey?: string;
|
||||||
|
keyValue?: string;
|
||||||
|
ref?: RefObject<any>;
|
||||||
|
// if set add capability to add the search item
|
||||||
|
onCreate?: (data: string) => void;
|
||||||
|
// if a suggestion exist at the auto compleat
|
||||||
|
suggestion?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SelectSingle = ({
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
value,
|
||||||
|
ref,
|
||||||
|
keyKey = 'id',
|
||||||
|
keyValue = keyKey,
|
||||||
|
suggestion,
|
||||||
|
onCreate,
|
||||||
|
}: SelectSingleProps) => {
|
||||||
|
const [showList, setShowList] = useState(false);
|
||||||
|
const transformedOption = useMemo(() => {
|
||||||
|
return options?.map((element) => {
|
||||||
|
return {
|
||||||
|
id: element[keyKey],
|
||||||
|
name: element[keyValue],
|
||||||
|
} as SelectListModel;
|
||||||
|
});
|
||||||
|
}, [options, keyKey, keyValue]);
|
||||||
|
const [hasSuggestion, setHasSuggestion] = useState<boolean>(
|
||||||
|
onCreate && suggestion ? true : false
|
||||||
|
);
|
||||||
|
const [currentSearch, setCurrentSearch] = useState<string | undefined>(
|
||||||
|
onCreate ? suggestion : undefined
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(`Update suggestion : ${onCreate} ${suggestion} ==> ${onCreate ? suggestion : undefined} .. ${onCreate && !isNullOrUndefined(suggestion) ? true : false}`);
|
||||||
|
setCurrentSearch(onCreate ? suggestion : undefined);
|
||||||
|
setHasSuggestion(onCreate && !isNullOrUndefined(suggestion) ? true : false);
|
||||||
|
}, [suggestion]);
|
||||||
|
const refFocus = ref ?? useRef<HTMLInputElement | null>(null);
|
||||||
|
const selectedOptions = useMemo(() => {
|
||||||
|
if (isNullOrUndefined(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return transformedOption?.find((data) => data.id === value);
|
||||||
|
}, [value, transformedOption]);
|
||||||
|
|
||||||
|
const selectValue = (data?: SelectListModel) => {
|
||||||
|
const tmpData = data?.id == selectedOptions?.id ? undefined : data;
|
||||||
|
setShowList(false);
|
||||||
|
if (onChange) {
|
||||||
|
onChange(tmpData?.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (!transformedOption) {
|
||||||
|
return <Spinner />;
|
||||||
|
}
|
||||||
|
function onChangeInput(value: string): void {
|
||||||
|
setHasSuggestion(false);
|
||||||
|
if (value === '') {
|
||||||
|
setCurrentSearch(undefined);
|
||||||
|
} else {
|
||||||
|
setCurrentSearch(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onRemoveItem = () => {
|
||||||
|
setHasSuggestion(false);
|
||||||
|
if (selectedOptions) {
|
||||||
|
if (onChange) {
|
||||||
|
onChange(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!showList || selectedOptions) {
|
||||||
|
refFocus?.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createNewItem = !onCreate
|
||||||
|
? undefined
|
||||||
|
: (data: string) => {
|
||||||
|
onCreate(data);
|
||||||
|
setCurrentSearch(undefined);
|
||||||
|
setHasSuggestion(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="column" width="full" gap="0px">
|
||||||
|
<Flex>
|
||||||
|
<Input
|
||||||
|
ref={refFocus}
|
||||||
|
width="full"
|
||||||
|
onChange={(e) => onChangeInput(e.target.value)}
|
||||||
|
onFocus={() => setShowList(true)}
|
||||||
|
onBlur={() => setTimeout(() => setShowList(false), 200)}
|
||||||
|
value={
|
||||||
|
showList ? (currentSearch ?? '') : (selectedOptions?.name ?? (hasSuggestion ? `suggest: ${currentSearch}` : ''))
|
||||||
|
}
|
||||||
|
backgroundColor={
|
||||||
|
showList || !selectedOptions ? undefined : 'green.800'
|
||||||
|
}
|
||||||
|
borderRadius="5px 0 0 5px"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={onRemoveItem}
|
||||||
|
variant="outline"
|
||||||
|
borderRadius="0 5px 5px 0"
|
||||||
|
borderWidth="1px 1px 1px 0"
|
||||||
|
>
|
||||||
|
{selectedOptions ? (
|
||||||
|
<MdClose color="gray.300" />
|
||||||
|
) : showList ? (
|
||||||
|
<MdKeyboardArrowUp color="gray.300" />
|
||||||
|
) : hasSuggestion ? (
|
||||||
|
<MdEdit color="gray.300" />
|
||||||
|
) : (
|
||||||
|
<MdKeyboardArrowDown color="gray.300" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
{showList && (
|
||||||
|
<SelectList
|
||||||
|
options={transformedOption}
|
||||||
|
selected={selectedOptions ? [selectedOptions] : []}
|
||||||
|
search={currentSearch}
|
||||||
|
onSelectValue={selectValue}
|
||||||
|
onCreate={createNewItem}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
57
front/src/components/track/DisplayTrack.tsx
Normal file
57
front/src/components/track/DisplayTrack.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { Flex, Text } from '@chakra-ui/react';
|
||||||
|
import { LuMusic2, LuPlay } from 'react-icons/lu';
|
||||||
|
|
||||||
|
import { Track } from '@/back-api';
|
||||||
|
import { Covers } from '@/components/Cover';
|
||||||
|
import { ContextMenu, MenuElement } from '@/components/contextMenu/ContextMenu';
|
||||||
|
import { useActivePlaylistService } from '@/service/ActivePlaylist';
|
||||||
|
|
||||||
|
export type DisplayTrackProps = {
|
||||||
|
track: Track;
|
||||||
|
onClick?: () => void;
|
||||||
|
contextMenu?: MenuElement[];
|
||||||
|
};
|
||||||
|
export const DisplayTrack = ({
|
||||||
|
track,
|
||||||
|
onClick,
|
||||||
|
contextMenu,
|
||||||
|
}: DisplayTrackProps) => {
|
||||||
|
const { trackActive } = useActivePlaylistService();
|
||||||
|
return (
|
||||||
|
<Flex direction="row" width="full" height="full">
|
||||||
|
<Covers
|
||||||
|
data={track?.covers}
|
||||||
|
size="50"
|
||||||
|
height="full"
|
||||||
|
iconEmpty={
|
||||||
|
trackActive?.id === track.id ? <LuPlay /> : <LuMusic2 />
|
||||||
|
}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
<Flex
|
||||||
|
direction="column"
|
||||||
|
width="full"
|
||||||
|
height="full"
|
||||||
|
paddingLeft="5px"
|
||||||
|
overflowX="hidden"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
as="span"
|
||||||
|
alignContent="left"
|
||||||
|
fontSize="20px"
|
||||||
|
fontWeight="bold"
|
||||||
|
userSelect="none"
|
||||||
|
marginRight="auto"
|
||||||
|
overflow="hidden"
|
||||||
|
// TODO: noOfLines={[1, 2]}
|
||||||
|
marginY="auto"
|
||||||
|
color={trackActive?.id === track.id ? 'green.700' : undefined}
|
||||||
|
>
|
||||||
|
[{track.track}] {track.name}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
<ContextMenu elements={contextMenu} />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
114
front/src/components/track/DisplayTrackFull.tsx
Normal file
114
front/src/components/track/DisplayTrackFull.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
import { Flex, Text } from '@chakra-ui/react';
|
||||||
|
import { LuMusic2, LuPlay } from 'react-icons/lu';
|
||||||
|
|
||||||
|
import { Track } from '@/back-api';
|
||||||
|
import { Covers } from '@/components/Cover';
|
||||||
|
import { ContextMenu, MenuElement } from '@/components/contextMenu/ContextMenu';
|
||||||
|
import { useActivePlaylistService } from '@/service/ActivePlaylist';
|
||||||
|
import { useSpecificAlbum } from '@/service/Album';
|
||||||
|
import { useSpecificArtists } from '@/service/Artist';
|
||||||
|
import { useSpecificGender } from '@/service/Gender';
|
||||||
|
|
||||||
|
export type DisplayTrackProps = {
|
||||||
|
track: Track;
|
||||||
|
onClick?: () => void;
|
||||||
|
contextMenu?: MenuElement[];
|
||||||
|
};
|
||||||
|
export const DisplayTrackFull = ({
|
||||||
|
track,
|
||||||
|
onClick,
|
||||||
|
contextMenu,
|
||||||
|
}: DisplayTrackProps) => {
|
||||||
|
const { trackActive } = useActivePlaylistService();
|
||||||
|
const { dataAlbum } = useSpecificAlbum(track?.albumId);
|
||||||
|
const { dataGender } = useSpecificGender(track?.genderId);
|
||||||
|
const { dataArtists } = useSpecificArtists(track?.artists);
|
||||||
|
return (
|
||||||
|
<Flex direction="row" width="full" height="full"
|
||||||
|
data-testid="display-track-full">
|
||||||
|
<Covers
|
||||||
|
data={track?.covers}
|
||||||
|
size="60px"
|
||||||
|
marginY="auto"
|
||||||
|
iconEmpty={
|
||||||
|
trackActive?.id === track.id ? <LuPlay /> : <LuMusic2 />
|
||||||
|
}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
<Flex
|
||||||
|
direction="column"
|
||||||
|
width="full"
|
||||||
|
height="full"
|
||||||
|
paddingLeft="5px"
|
||||||
|
overflowX="hidden"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
as="span"
|
||||||
|
alignContent="left"
|
||||||
|
fontSize="20px"
|
||||||
|
fontWeight="bold"
|
||||||
|
userSelect="none"
|
||||||
|
marginRight="auto"
|
||||||
|
overflow="hidden"
|
||||||
|
// TODO: noOfLines={1}
|
||||||
|
color={trackActive?.id === track.id ? 'green.700' : undefined}
|
||||||
|
>
|
||||||
|
{track.name} {track.track && ` [${track.track}]`}
|
||||||
|
</Text>
|
||||||
|
{dataAlbum && (
|
||||||
|
<Text
|
||||||
|
as="span"
|
||||||
|
alignContent="left"
|
||||||
|
fontSize="15px"
|
||||||
|
fontWeight="bold"
|
||||||
|
userSelect="none"
|
||||||
|
marginRight="auto"
|
||||||
|
overflow="hidden"
|
||||||
|
//noOfLines={1}
|
||||||
|
marginY="auto"
|
||||||
|
color={trackActive?.id === track.id ? 'green.700' : undefined}
|
||||||
|
>
|
||||||
|
<Text as="span" fontWeight="normal">Album:</Text> {dataAlbum.name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{dataArtists && (
|
||||||
|
<Text
|
||||||
|
as="span"
|
||||||
|
alignContent="left"
|
||||||
|
fontSize="15px"
|
||||||
|
fontWeight="bold"
|
||||||
|
userSelect="none"
|
||||||
|
marginRight="auto"
|
||||||
|
overflow="hidden"
|
||||||
|
//noOfLines={1}
|
||||||
|
marginY="auto"
|
||||||
|
color={trackActive?.id === track.id ? 'green.700' : undefined}
|
||||||
|
>
|
||||||
|
<Text as="span" fontWeight="normal">Artist(s):</Text> {dataArtists.map((data) => data.name).join(', ')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{dataGender && (
|
||||||
|
<Text
|
||||||
|
as="span"
|
||||||
|
alignContent="left"
|
||||||
|
fontSize="15px"
|
||||||
|
fontWeight="bold"
|
||||||
|
userSelect="none"
|
||||||
|
marginRight="auto"
|
||||||
|
overflow="hidden"
|
||||||
|
//noOfLines={1}
|
||||||
|
marginY="auto"
|
||||||
|
color={trackActive?.id === track.id ? 'green.700' : undefined}
|
||||||
|
>
|
||||||
|
<Text as="span" fontWeight="normal">Gender:</Text> {dataGender.name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
<ContextMenu elements={contextMenu}
|
||||||
|
data-testid="display-track-full_context-menu" />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
28
front/src/components/track/DisplayTrackFullId.tsx
Normal file
28
front/src/components/track/DisplayTrackFullId.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { Track } from '@/back-api';
|
||||||
|
import { Covers } from '@/components/Cover';
|
||||||
|
import { MenuElement } from '@/components/contextMenu/ContextMenu';
|
||||||
|
import { useSpecificTrack } from '@/service/Track';
|
||||||
|
import { DisplayTrackFull } from './DisplayTrackFull';
|
||||||
|
import { DisplayTrackSkeleton } from './DisplayTrackSkeleton';
|
||||||
|
|
||||||
|
export type DisplayTrackProps = {
|
||||||
|
trackId: Track["id"];
|
||||||
|
onClick?: () => void;
|
||||||
|
contextMenu?: MenuElement[];
|
||||||
|
};
|
||||||
|
export const DisplayTrackFullId = ({
|
||||||
|
trackId,
|
||||||
|
onClick,
|
||||||
|
contextMenu,
|
||||||
|
}: DisplayTrackProps) => {
|
||||||
|
const { dataTrack } = useSpecificTrack(trackId);
|
||||||
|
if (dataTrack) {
|
||||||
|
return (
|
||||||
|
<DisplayTrackFull track={dataTrack} onClick={onClick} contextMenu={contextMenu} />
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<DisplayTrackSkeleton />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
30
front/src/components/track/DisplayTrackSkeleton.tsx
Normal file
30
front/src/components/track/DisplayTrackSkeleton.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { Flex, Skeleton } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
export const DisplayTrackSkeleton = () => {
|
||||||
|
return (
|
||||||
|
<Flex direction="row" width="full" height="full">
|
||||||
|
<Skeleton
|
||||||
|
borderRadius="0px"
|
||||||
|
height="50"
|
||||||
|
width="50"
|
||||||
|
minWidth="50"
|
||||||
|
minHeight="50"
|
||||||
|
/>
|
||||||
|
<Flex
|
||||||
|
direction="column"
|
||||||
|
width="full"
|
||||||
|
height="full"
|
||||||
|
paddingLeft="5px"
|
||||||
|
overflowX="hidden"
|
||||||
|
>
|
||||||
|
{/* <SkeletonText
|
||||||
|
skeletonHeight="20px"
|
||||||
|
noOfLines={1}
|
||||||
|
gap={0}
|
||||||
|
width="50%"
|
||||||
|
marginY="auto"
|
||||||
|
/> */}
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
74
front/src/components/ui/avatar.tsx
Normal file
74
front/src/components/ui/avatar.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { GroupProps, SlotRecipeProps } from "@chakra-ui/react"
|
||||||
|
import { Avatar as ChakraAvatar, Group } from "@chakra-ui/react"
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
type ImageProps = React.ImgHTMLAttributes<HTMLImageElement>
|
||||||
|
|
||||||
|
export interface AvatarProps extends ChakraAvatar.RootProps {
|
||||||
|
name?: string
|
||||||
|
src?: string
|
||||||
|
srcSet?: string
|
||||||
|
loading?: ImageProps["loading"]
|
||||||
|
icon?: React.ReactElement
|
||||||
|
fallback?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
|
||||||
|
function Avatar(props, ref) {
|
||||||
|
const { name, src, srcSet, loading, icon, fallback, children, ...rest } =
|
||||||
|
props
|
||||||
|
return (
|
||||||
|
<ChakraAvatar.Root ref={ref} {...rest}>
|
||||||
|
<AvatarFallback name={name} icon={icon}>
|
||||||
|
{fallback}
|
||||||
|
</AvatarFallback>
|
||||||
|
<ChakraAvatar.Image src={src} srcSet={srcSet} loading={loading} />
|
||||||
|
{children}
|
||||||
|
</ChakraAvatar.Root>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
interface AvatarFallbackProps extends ChakraAvatar.FallbackProps {
|
||||||
|
name?: string
|
||||||
|
icon?: React.ReactElement
|
||||||
|
}
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<HTMLDivElement, AvatarFallbackProps>(
|
||||||
|
function AvatarFallback(props, ref) {
|
||||||
|
const { name, icon, children, ...rest } = props
|
||||||
|
return (
|
||||||
|
<ChakraAvatar.Fallback ref={ref} {...rest}>
|
||||||
|
{children}
|
||||||
|
{name != null && children == null && <>{getInitials(name)}</>}
|
||||||
|
{name == null && children == null && (
|
||||||
|
<ChakraAvatar.Icon asChild={!!icon}>{icon}</ChakraAvatar.Icon>
|
||||||
|
)}
|
||||||
|
</ChakraAvatar.Fallback>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function getInitials(name: string) {
|
||||||
|
const names = name.trim().split(" ")
|
||||||
|
const firstName = names[0] != null ? names[0] : ""
|
||||||
|
const lastName = names.length > 1 ? names[names.length - 1] : ""
|
||||||
|
return firstName && lastName
|
||||||
|
? `${firstName.charAt(0)}${lastName.charAt(0)}`
|
||||||
|
: firstName.charAt(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AvatarGroupProps extends GroupProps, SlotRecipeProps<"avatar"> {}
|
||||||
|
|
||||||
|
export const AvatarGroup = React.forwardRef<HTMLDivElement, AvatarGroupProps>(
|
||||||
|
function AvatarGroup(props, ref) {
|
||||||
|
const { size, variant, borderless, ...rest } = props
|
||||||
|
return (
|
||||||
|
<ChakraAvatar.PropsProvider value={{ size, variant, borderless }}>
|
||||||
|
<Group gap="0" spaceX="-3" ref={ref} {...rest} />
|
||||||
|
</ChakraAvatar.PropsProvider>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
40
front/src/components/ui/button.tsx
Normal file
40
front/src/components/ui/button.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import type { ButtonProps as ChakraButtonProps } from "@chakra-ui/react"
|
||||||
|
import {
|
||||||
|
AbsoluteCenter,
|
||||||
|
Button as ChakraButton,
|
||||||
|
Span,
|
||||||
|
Spinner,
|
||||||
|
} from "@chakra-ui/react"
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
interface ButtonLoadingProps {
|
||||||
|
loading?: boolean
|
||||||
|
loadingText?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ButtonProps extends ChakraButtonProps, ButtonLoadingProps {}
|
||||||
|
|
||||||
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
function Button(props, ref) {
|
||||||
|
const { loading, disabled, loadingText, children, ...rest } = props
|
||||||
|
return (
|
||||||
|
<ChakraButton disabled={loading || disabled} ref={ref} {...rest}>
|
||||||
|
{loading && !loadingText ? (
|
||||||
|
<>
|
||||||
|
<AbsoluteCenter display="inline-flex">
|
||||||
|
<Spinner size="inherit" color="inherit" />
|
||||||
|
</AbsoluteCenter>
|
||||||
|
<Span opacity={0}>{children}</Span>
|
||||||
|
</>
|
||||||
|
) : loading && loadingText ? (
|
||||||
|
<>
|
||||||
|
<Spinner size="inherit" color="inherit" />
|
||||||
|
{loadingText}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</ChakraButton>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
25
front/src/components/ui/checkbox.tsx
Normal file
25
front/src/components/ui/checkbox.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Checkbox as ChakraCheckbox } from "@chakra-ui/react"
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
export interface CheckboxProps extends ChakraCheckbox.RootProps {
|
||||||
|
icon?: React.ReactNode
|
||||||
|
inputProps?: React.InputHTMLAttributes<HTMLInputElement>
|
||||||
|
rootRef?: React.Ref<HTMLLabelElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||||
|
function Checkbox(props, ref) {
|
||||||
|
const { icon, children, inputProps, rootRef, ...rest } = props
|
||||||
|
return (
|
||||||
|
<ChakraCheckbox.Root ref={rootRef} {...rest}>
|
||||||
|
<ChakraCheckbox.HiddenInput ref={ref} {...inputProps} />
|
||||||
|
<ChakraCheckbox.Control>
|
||||||
|
{icon || <ChakraCheckbox.Indicator />}
|
||||||
|
</ChakraCheckbox.Control>
|
||||||
|
{children != null && (
|
||||||
|
<ChakraCheckbox.Label>{children}</ChakraCheckbox.Label>
|
||||||
|
)}
|
||||||
|
</ChakraCheckbox.Root>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
17
front/src/components/ui/close-button.tsx
Normal file
17
front/src/components/ui/close-button.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import type { ButtonProps } from "@chakra-ui/react"
|
||||||
|
import { IconButton as ChakraIconButton } from "@chakra-ui/react"
|
||||||
|
import * as React from "react"
|
||||||
|
import { LuX } from "react-icons/lu"
|
||||||
|
|
||||||
|
export type CloseButtonProps = ButtonProps
|
||||||
|
|
||||||
|
export const CloseButton = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
CloseButtonProps
|
||||||
|
>(function CloseButton(props, ref) {
|
||||||
|
return (
|
||||||
|
<ChakraIconButton variant="ghost" aria-label="Close" ref={ref} {...props}>
|
||||||
|
{props.children ?? <LuX />}
|
||||||
|
</ChakraIconButton>
|
||||||
|
)
|
||||||
|
})
|
75
front/src/components/ui/color-mode.tsx
Normal file
75
front/src/components/ui/color-mode.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { IconButtonProps } from "@chakra-ui/react"
|
||||||
|
import { ClientOnly, IconButton, Skeleton } from "@chakra-ui/react"
|
||||||
|
import { ThemeProvider, useTheme } from "next-themes"
|
||||||
|
import type { ThemeProviderProps } from "next-themes"
|
||||||
|
import * as React from "react"
|
||||||
|
import { LuMoon, LuSun } from "react-icons/lu"
|
||||||
|
|
||||||
|
export interface ColorModeProviderProps extends ThemeProviderProps {}
|
||||||
|
|
||||||
|
export function ColorModeProvider(props: ColorModeProviderProps) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider attribute="class" disableTransitionOnChange {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ColorMode = "light" | "dark"
|
||||||
|
|
||||||
|
export interface UseColorModeReturn {
|
||||||
|
colorMode: ColorMode
|
||||||
|
setColorMode: (colorMode: ColorMode) => void
|
||||||
|
toggleColorMode: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useColorMode(): UseColorModeReturn {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme()
|
||||||
|
const toggleColorMode = () => {
|
||||||
|
setTheme(resolvedTheme === "light" ? "dark" : "light")
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
colorMode: resolvedTheme as ColorMode,
|
||||||
|
setColorMode: setTheme,
|
||||||
|
toggleColorMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useColorModeValue<T>(light: T, dark: T) {
|
||||||
|
const { colorMode } = useColorMode()
|
||||||
|
return colorMode === "dark" ? dark : light
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColorModeIcon() {
|
||||||
|
const { colorMode } = useColorMode()
|
||||||
|
return colorMode === "dark" ? <LuMoon /> : <LuSun />
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColorModeButtonProps extends Omit<IconButtonProps, "aria-label"> {}
|
||||||
|
|
||||||
|
export const ColorModeButton = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
ColorModeButtonProps
|
||||||
|
>(function ColorModeButton(props, ref) {
|
||||||
|
const { toggleColorMode } = useColorMode()
|
||||||
|
return (
|
||||||
|
<ClientOnly fallback={<Skeleton boxSize="8" />}>
|
||||||
|
<IconButton
|
||||||
|
onClick={toggleColorMode}
|
||||||
|
variant="ghost"
|
||||||
|
aria-label="Toggle color mode"
|
||||||
|
size="sm"
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
css={{
|
||||||
|
_icon: {
|
||||||
|
width: "5",
|
||||||
|
height: "5",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ColorModeIcon />
|
||||||
|
</IconButton>
|
||||||
|
</ClientOnly>
|
||||||
|
)
|
||||||
|
})
|
62
front/src/components/ui/dialog.tsx
Normal file
62
front/src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { Dialog as ChakraDialog, Portal } from "@chakra-ui/react"
|
||||||
|
import { CloseButton } from "./close-button"
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
interface DialogContentProps extends ChakraDialog.ContentProps {
|
||||||
|
portalled?: boolean
|
||||||
|
portalRef?: React.RefObject<HTMLElement>
|
||||||
|
backdrop?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DialogContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
DialogContentProps
|
||||||
|
>(function DialogContent(props, ref) {
|
||||||
|
const {
|
||||||
|
children,
|
||||||
|
portalled = true,
|
||||||
|
portalRef,
|
||||||
|
backdrop = true,
|
||||||
|
...rest
|
||||||
|
} = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Portal disabled={!portalled} container={portalRef}>
|
||||||
|
{backdrop && <ChakraDialog.Backdrop />}
|
||||||
|
<ChakraDialog.Positioner>
|
||||||
|
<ChakraDialog.Content ref={ref} {...rest} asChild={false}>
|
||||||
|
{children}
|
||||||
|
</ChakraDialog.Content>
|
||||||
|
</ChakraDialog.Positioner>
|
||||||
|
</Portal>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const DialogCloseTrigger = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
ChakraDialog.CloseTriggerProps
|
||||||
|
>(function DialogCloseTrigger(props, ref) {
|
||||||
|
return (
|
||||||
|
<ChakraDialog.CloseTrigger
|
||||||
|
position="absolute"
|
||||||
|
top="2"
|
||||||
|
insetEnd="2"
|
||||||
|
{...props}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<CloseButton size="sm" ref={ref}>
|
||||||
|
{props.children}
|
||||||
|
</CloseButton>
|
||||||
|
</ChakraDialog.CloseTrigger>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const DialogRoot = ChakraDialog.Root
|
||||||
|
export const DialogFooter = ChakraDialog.Footer
|
||||||
|
export const DialogHeader = ChakraDialog.Header
|
||||||
|
export const DialogBody = ChakraDialog.Body
|
||||||
|
export const DialogBackdrop = ChakraDialog.Backdrop
|
||||||
|
export const DialogTitle = ChakraDialog.Title
|
||||||
|
export const DialogDescription = ChakraDialog.Description
|
||||||
|
export const DialogTrigger = ChakraDialog.Trigger
|
||||||
|
export const DialogActionTrigger = ChakraDialog.ActionTrigger
|
52
front/src/components/ui/drawer.tsx
Normal file
52
front/src/components/ui/drawer.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { Drawer as ChakraDrawer, Portal } from "@chakra-ui/react"
|
||||||
|
import { CloseButton } from "./close-button"
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
interface DrawerContentProps extends ChakraDrawer.ContentProps {
|
||||||
|
portalled?: boolean
|
||||||
|
portalRef?: React.RefObject<HTMLElement>
|
||||||
|
offset?: ChakraDrawer.ContentProps["padding"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DrawerContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
DrawerContentProps
|
||||||
|
>(function DrawerContent(props, ref) {
|
||||||
|
const { children, portalled = true, portalRef, offset, ...rest } = props
|
||||||
|
return (
|
||||||
|
<Portal disabled={!portalled} container={portalRef}>
|
||||||
|
<ChakraDrawer.Positioner padding={offset}>
|
||||||
|
<ChakraDrawer.Content ref={ref} {...rest} asChild={false}>
|
||||||
|
{children}
|
||||||
|
</ChakraDrawer.Content>
|
||||||
|
</ChakraDrawer.Positioner>
|
||||||
|
</Portal>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const DrawerCloseTrigger = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
ChakraDrawer.CloseTriggerProps
|
||||||
|
>(function DrawerCloseTrigger(props, ref) {
|
||||||
|
return (
|
||||||
|
<ChakraDrawer.CloseTrigger
|
||||||
|
position="absolute"
|
||||||
|
top="2"
|
||||||
|
insetEnd="2"
|
||||||
|
{...props}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<CloseButton size="sm" ref={ref} />
|
||||||
|
</ChakraDrawer.CloseTrigger>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const DrawerTrigger = ChakraDrawer.Trigger
|
||||||
|
export const DrawerRoot = ChakraDrawer.Root
|
||||||
|
export const DrawerFooter = ChakraDrawer.Footer
|
||||||
|
export const DrawerHeader = ChakraDrawer.Header
|
||||||
|
export const DrawerBody = ChakraDrawer.Body
|
||||||
|
export const DrawerBackdrop = ChakraDrawer.Backdrop
|
||||||
|
export const DrawerDescription = ChakraDrawer.Description
|
||||||
|
export const DrawerTitle = ChakraDrawer.Title
|
||||||
|
export const DrawerActionTrigger = ChakraDrawer.ActionTrigger
|
33
front/src/components/ui/field.tsx
Normal file
33
front/src/components/ui/field.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { Field as ChakraField } from "@chakra-ui/react"
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
export interface FieldProps extends Omit<ChakraField.RootProps, "label"> {
|
||||||
|
label?: React.ReactNode
|
||||||
|
helperText?: React.ReactNode
|
||||||
|
errorText?: React.ReactNode
|
||||||
|
optionalText?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Field = React.forwardRef<HTMLDivElement, FieldProps>(
|
||||||
|
function Field(props, ref) {
|
||||||
|
const { label, children, helperText, errorText, optionalText, ...rest } =
|
||||||
|
props
|
||||||
|
return (
|
||||||
|
<ChakraField.Root ref={ref} {...rest}>
|
||||||
|
{label && (
|
||||||
|
<ChakraField.Label>
|
||||||
|
{label}
|
||||||
|
<ChakraField.RequiredIndicator fallback={optionalText} />
|
||||||
|
</ChakraField.Label>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
{helperText && (
|
||||||
|
<ChakraField.HelperText>{helperText}</ChakraField.HelperText>
|
||||||
|
)}
|
||||||
|
{errorText && (
|
||||||
|
<ChakraField.ErrorText>{errorText}</ChakraField.ErrorText>
|
||||||
|
)}
|
||||||
|
</ChakraField.Root>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
0
front/src/components/ui/index.ts
Normal file
0
front/src/components/ui/index.ts
Normal file
53
front/src/components/ui/input-group.tsx
Normal file
53
front/src/components/ui/input-group.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import type { BoxProps, InputElementProps } from "@chakra-ui/react"
|
||||||
|
import { Group, InputElement } from "@chakra-ui/react"
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
export interface InputGroupProps extends BoxProps {
|
||||||
|
startElementProps?: InputElementProps
|
||||||
|
endElementProps?: InputElementProps
|
||||||
|
startElement?: React.ReactNode
|
||||||
|
endElement?: React.ReactNode
|
||||||
|
children: React.ReactElement<InputElementProps>
|
||||||
|
startOffset?: InputElementProps["paddingStart"]
|
||||||
|
endOffset?: InputElementProps["paddingEnd"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InputGroup = React.forwardRef<HTMLDivElement, InputGroupProps>(
|
||||||
|
function InputGroup(props, ref) {
|
||||||
|
const {
|
||||||
|
startElement,
|
||||||
|
startElementProps,
|
||||||
|
endElement,
|
||||||
|
endElementProps,
|
||||||
|
children,
|
||||||
|
startOffset = "6px",
|
||||||
|
endOffset = "6px",
|
||||||
|
...rest
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const child =
|
||||||
|
React.Children.only<React.ReactElement<InputElementProps>>(children)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group ref={ref} {...rest}>
|
||||||
|
{startElement && (
|
||||||
|
<InputElement pointerEvents="none" {...startElementProps}>
|
||||||
|
{startElement}
|
||||||
|
</InputElement>
|
||||||
|
)}
|
||||||
|
{React.cloneElement(child, {
|
||||||
|
...(startElement && {
|
||||||
|
ps: `calc(var(--input-height) - ${startOffset})`,
|
||||||
|
}),
|
||||||
|
...(endElement && { pe: `calc(var(--input-height) - ${endOffset})` }),
|
||||||
|
...children.props,
|
||||||
|
})}
|
||||||
|
{endElement && (
|
||||||
|
<InputElement placement="end" {...endElementProps}>
|
||||||
|
{endElement}
|
||||||
|
</InputElement>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
110
front/src/components/ui/menu.tsx
Normal file
110
front/src/components/ui/menu.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AbsoluteCenter, Menu as ChakraMenu, Portal } from "@chakra-ui/react"
|
||||||
|
import * as React from "react"
|
||||||
|
import { LuCheck, LuChevronRight } from "react-icons/lu"
|
||||||
|
|
||||||
|
interface MenuContentProps extends ChakraMenu.ContentProps {
|
||||||
|
portalled?: boolean
|
||||||
|
portalRef?: React.RefObject<HTMLElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MenuContent = React.forwardRef<HTMLDivElement, MenuContentProps>(
|
||||||
|
function MenuContent(props, ref) {
|
||||||
|
const { portalled = true, portalRef, ...rest } = props
|
||||||
|
return (
|
||||||
|
<Portal disabled={!portalled} container={portalRef}>
|
||||||
|
<ChakraMenu.Positioner>
|
||||||
|
<ChakraMenu.Content ref={ref} {...rest} />
|
||||||
|
</ChakraMenu.Positioner>
|
||||||
|
</Portal>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export const MenuArrow = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
ChakraMenu.ArrowProps
|
||||||
|
>(function MenuArrow(props, ref) {
|
||||||
|
return (
|
||||||
|
<ChakraMenu.Arrow ref={ref} {...props}>
|
||||||
|
<ChakraMenu.ArrowTip />
|
||||||
|
</ChakraMenu.Arrow>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const MenuCheckboxItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
ChakraMenu.CheckboxItemProps
|
||||||
|
>(function MenuCheckboxItem(props, ref) {
|
||||||
|
return (
|
||||||
|
<ChakraMenu.CheckboxItem ref={ref} {...props}>
|
||||||
|
<ChakraMenu.ItemIndicator hidden={false}>
|
||||||
|
<LuCheck />
|
||||||
|
</ChakraMenu.ItemIndicator>
|
||||||
|
{props.children}
|
||||||
|
</ChakraMenu.CheckboxItem>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const MenuRadioItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
ChakraMenu.RadioItemProps
|
||||||
|
>(function MenuRadioItem(props, ref) {
|
||||||
|
const { children, ...rest } = props
|
||||||
|
return (
|
||||||
|
<ChakraMenu.RadioItem ps="8" ref={ref} {...rest}>
|
||||||
|
<AbsoluteCenter axis="horizontal" left="4" asChild>
|
||||||
|
<ChakraMenu.ItemIndicator>
|
||||||
|
<LuCheck />
|
||||||
|
</ChakraMenu.ItemIndicator>
|
||||||
|
</AbsoluteCenter>
|
||||||
|
<ChakraMenu.ItemText>{children}</ChakraMenu.ItemText>
|
||||||
|
</ChakraMenu.RadioItem>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const MenuItemGroup = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
ChakraMenu.ItemGroupProps
|
||||||
|
>(function MenuItemGroup(props, ref) {
|
||||||
|
const { title, children, ...rest } = props
|
||||||
|
return (
|
||||||
|
<ChakraMenu.ItemGroup ref={ref} {...rest}>
|
||||||
|
{title && (
|
||||||
|
<ChakraMenu.ItemGroupLabel userSelect="none">
|
||||||
|
{title}
|
||||||
|
</ChakraMenu.ItemGroupLabel>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</ChakraMenu.ItemGroup>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface MenuTriggerItemProps extends ChakraMenu.ItemProps {
|
||||||
|
startIcon?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MenuTriggerItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
MenuTriggerItemProps
|
||||||
|
>(function MenuTriggerItem(props, ref) {
|
||||||
|
const { startIcon, children, ...rest } = props
|
||||||
|
return (
|
||||||
|
<ChakraMenu.TriggerItem ref={ref} {...rest}>
|
||||||
|
{startIcon}
|
||||||
|
{children}
|
||||||
|
<LuChevronRight />
|
||||||
|
</ChakraMenu.TriggerItem>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const MenuRadioItemGroup = ChakraMenu.RadioItemGroup
|
||||||
|
export const MenuContextTrigger = ChakraMenu.ContextTrigger
|
||||||
|
export const MenuRoot = ChakraMenu.Root
|
||||||
|
export const MenuSeparator = ChakraMenu.Separator
|
||||||
|
|
||||||
|
export const MenuItem = ChakraMenu.Item
|
||||||
|
export const MenuItemText = ChakraMenu.ItemText
|
||||||
|
export const MenuItemCommand = ChakraMenu.ItemCommand
|
||||||
|
export const MenuTrigger = ChakraMenu.Trigger
|
24
front/src/components/ui/number-input.tsx
Normal file
24
front/src/components/ui/number-input.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { NumberInput as ChakraNumberInput } from "@chakra-ui/react"
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
export interface NumberInputProps extends ChakraNumberInput.RootProps {}
|
||||||
|
|
||||||
|
export const NumberInputRoot = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
NumberInputProps
|
||||||
|
>(function NumberInput(props, ref) {
|
||||||
|
const { children, ...rest } = props
|
||||||
|
return (
|
||||||
|
<ChakraNumberInput.Root ref={ref} variant="outline" {...rest}>
|
||||||
|
{children}
|
||||||
|
<ChakraNumberInput.Control>
|
||||||
|
<ChakraNumberInput.IncrementTrigger />
|
||||||
|
<ChakraNumberInput.DecrementTrigger />
|
||||||
|
</ChakraNumberInput.Control>
|
||||||
|
</ChakraNumberInput.Root>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const NumberInputField = ChakraNumberInput.Input
|
||||||
|
export const NumberInputScrubber = ChakraNumberInput.Scrubber
|
||||||
|
export const NumberInputLabel = ChakraNumberInput.Label
|
59
front/src/components/ui/popover.tsx
Normal file
59
front/src/components/ui/popover.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { Popover as ChakraPopover, Portal } from "@chakra-ui/react"
|
||||||
|
import { CloseButton } from "./close-button"
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
interface PopoverContentProps extends ChakraPopover.ContentProps {
|
||||||
|
portalled?: boolean
|
||||||
|
portalRef?: React.RefObject<HTMLElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PopoverContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
PopoverContentProps
|
||||||
|
>(function PopoverContent(props, ref) {
|
||||||
|
const { portalled = true, portalRef, ...rest } = props
|
||||||
|
return (
|
||||||
|
<Portal disabled={!portalled} container={portalRef}>
|
||||||
|
<ChakraPopover.Positioner>
|
||||||
|
<ChakraPopover.Content ref={ref} {...rest} />
|
||||||
|
</ChakraPopover.Positioner>
|
||||||
|
</Portal>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const PopoverArrow = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
ChakraPopover.ArrowProps
|
||||||
|
>(function PopoverArrow(props, ref) {
|
||||||
|
return (
|
||||||
|
<ChakraPopover.Arrow {...props} ref={ref}>
|
||||||
|
<ChakraPopover.ArrowTip />
|
||||||
|
</ChakraPopover.Arrow>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const PopoverCloseTrigger = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
ChakraPopover.CloseTriggerProps
|
||||||
|
>(function PopoverCloseTrigger(props, ref) {
|
||||||
|
return (
|
||||||
|
<ChakraPopover.CloseTrigger
|
||||||
|
position="absolute"
|
||||||
|
top="1"
|
||||||
|
insetEnd="1"
|
||||||
|
{...props}
|
||||||
|
asChild
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<CloseButton size="sm" />
|
||||||
|
</ChakraPopover.CloseTrigger>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const PopoverTitle = ChakraPopover.Title
|
||||||
|
export const PopoverDescription = ChakraPopover.Description
|
||||||
|
export const PopoverFooter = ChakraPopover.Footer
|
||||||
|
export const PopoverHeader = ChakraPopover.Header
|
||||||
|
export const PopoverRoot = ChakraPopover.Root
|
||||||
|
export const PopoverBody = ChakraPopover.Body
|
||||||
|
export const PopoverTrigger = ChakraPopover.Trigger
|
15
front/src/components/ui/provider.tsx
Normal file
15
front/src/components/ui/provider.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ChakraProvider, defaultSystem } from "@chakra-ui/react"
|
||||||
|
import {
|
||||||
|
ColorModeProvider,
|
||||||
|
type ColorModeProviderProps,
|
||||||
|
} from "./color-mode"
|
||||||
|
|
||||||
|
export function Provider(props: ColorModeProviderProps) {
|
||||||
|
return (
|
||||||
|
<ChakraProvider value={defaultSystem}>
|
||||||
|
<ColorModeProvider {...props} />
|
||||||
|
</ChakraProvider>
|
||||||
|
)
|
||||||
|
}
|
24
front/src/components/ui/radio.tsx
Normal file
24
front/src/components/ui/radio.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { RadioGroup as ChakraRadioGroup } from "@chakra-ui/react"
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
export interface RadioProps extends ChakraRadioGroup.ItemProps {
|
||||||
|
rootRef?: React.Ref<HTMLDivElement>
|
||||||
|
inputProps?: React.InputHTMLAttributes<HTMLInputElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Radio = React.forwardRef<HTMLInputElement, RadioProps>(
|
||||||
|
function Radio(props, ref) {
|
||||||
|
const { children, inputProps, rootRef, ...rest } = props
|
||||||
|
return (
|
||||||
|
<ChakraRadioGroup.Item ref={rootRef} {...rest}>
|
||||||
|
<ChakraRadioGroup.ItemHiddenInput ref={ref} {...inputProps} />
|
||||||
|
<ChakraRadioGroup.ItemIndicator />
|
||||||
|
{children && (
|
||||||
|
<ChakraRadioGroup.ItemText>{children}</ChakraRadioGroup.ItemText>
|
||||||
|
)}
|
||||||
|
</ChakraRadioGroup.Item>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export const RadioGroup = ChakraRadioGroup.Root
|
82
front/src/components/ui/slider.tsx
Normal file
82
front/src/components/ui/slider.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { Slider as ChakraSlider, For, HStack } from "@chakra-ui/react"
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
export interface SliderProps extends ChakraSlider.RootProps {
|
||||||
|
marks?: Array<number | { value: number; label: React.ReactNode }>
|
||||||
|
label?: React.ReactNode
|
||||||
|
showValue?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
|
||||||
|
function Slider(props, ref) {
|
||||||
|
const { marks: marksProp, label, showValue, ...rest } = props
|
||||||
|
const value = props.defaultValue ?? props.value
|
||||||
|
|
||||||
|
const marks = marksProp?.map((mark) => {
|
||||||
|
if (typeof mark === "number") return { value: mark, label: undefined }
|
||||||
|
return mark
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasMarkLabel = !!marks?.some((mark) => mark.label)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChakraSlider.Root ref={ref} thumbAlignment="center" {...rest}>
|
||||||
|
{label && !showValue && (
|
||||||
|
<ChakraSlider.Label>{label}</ChakraSlider.Label>
|
||||||
|
)}
|
||||||
|
{label && showValue && (
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<ChakraSlider.Label>{label}</ChakraSlider.Label>
|
||||||
|
<ChakraSlider.ValueText />
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
<ChakraSlider.Control data-has-mark-label={hasMarkLabel || undefined}>
|
||||||
|
<ChakraSlider.Track>
|
||||||
|
<ChakraSlider.Range />
|
||||||
|
</ChakraSlider.Track>
|
||||||
|
<SliderThumbs value={value} />
|
||||||
|
<SliderMarks marks={marks} />
|
||||||
|
</ChakraSlider.Control>
|
||||||
|
</ChakraSlider.Root>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function SliderThumbs(props: { value?: number[] }) {
|
||||||
|
const { value } = props
|
||||||
|
return (
|
||||||
|
<For each={value}>
|
||||||
|
{(_, index) => (
|
||||||
|
<ChakraSlider.Thumb key={index} index={index}>
|
||||||
|
<ChakraSlider.HiddenInput />
|
||||||
|
</ChakraSlider.Thumb>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SliderMarksProps {
|
||||||
|
marks?: Array<number | { value: number; label: React.ReactNode }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const SliderMarks = React.forwardRef<HTMLDivElement, SliderMarksProps>(
|
||||||
|
function SliderMarks(props, ref) {
|
||||||
|
const { marks } = props
|
||||||
|
if (!marks?.length) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChakraSlider.MarkerGroup ref={ref}>
|
||||||
|
{marks.map((mark, index) => {
|
||||||
|
const value = typeof mark === "number" ? mark : mark.value
|
||||||
|
const label = typeof mark === "number" ? undefined : mark.label
|
||||||
|
return (
|
||||||
|
<ChakraSlider.Marker key={index} value={value}>
|
||||||
|
<ChakraSlider.MarkerIndicator />
|
||||||
|
{label}
|
||||||
|
</ChakraSlider.Marker>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ChakraSlider.MarkerGroup>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
51
front/src/components/ui/toaster.tsx
Normal file
51
front/src/components/ui/toaster.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { RestErrorResponse } from "@/back-api";
|
||||||
|
import {
|
||||||
|
Toaster as ChakraToaster,
|
||||||
|
Portal,
|
||||||
|
Spinner,
|
||||||
|
Stack,
|
||||||
|
Toast,
|
||||||
|
createToaster,
|
||||||
|
} from "@chakra-ui/react"
|
||||||
|
|
||||||
|
export const toaster = createToaster({
|
||||||
|
placement: "bottom-end",
|
||||||
|
pauseOnPageIdle: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const toasterAPIError = (error: RestErrorResponse) => {
|
||||||
|
toaster.create({
|
||||||
|
title: `[${error.status}] ${error.statusMessage}`,
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Toaster = () => {
|
||||||
|
return (
|
||||||
|
<Portal>
|
||||||
|
<ChakraToaster toaster={toaster} insetInline={{ mdDown: "4" }}>
|
||||||
|
{(toast) => (
|
||||||
|
<Toast.Root width={{ md: "sm" }}>
|
||||||
|
{toast.type === "loading" ? (
|
||||||
|
<Spinner size="sm" color="blue.solid" />
|
||||||
|
) : (
|
||||||
|
<Toast.Indicator />
|
||||||
|
)}
|
||||||
|
<Stack gap="1" flex="1" maxWidth="100%">
|
||||||
|
{toast.title && <Toast.Title>{toast.title}</Toast.Title>}
|
||||||
|
{toast.description && (
|
||||||
|
<Toast.Description>{toast.description}</Toast.Description>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
{toast.action && (
|
||||||
|
<Toast.ActionTrigger>{toast.action.label}</Toast.ActionTrigger>
|
||||||
|
)}
|
||||||
|
{toast.meta?.closable && <Toast.CloseTrigger />}
|
||||||
|
</Toast.Root>
|
||||||
|
)}
|
||||||
|
</ChakraToaster>
|
||||||
|
</Portal>
|
||||||
|
)
|
||||||
|
}
|
46
front/src/components/ui/tooltip.tsx
Normal file
46
front/src/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { Tooltip as ChakraTooltip, Portal } from "@chakra-ui/react"
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
export interface TooltipProps extends ChakraTooltip.RootProps {
|
||||||
|
showArrow?: boolean
|
||||||
|
portalled?: boolean
|
||||||
|
portalRef?: React.RefObject<HTMLElement>
|
||||||
|
content: React.ReactNode
|
||||||
|
contentProps?: ChakraTooltip.ContentProps
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>(
|
||||||
|
function Tooltip(props, ref) {
|
||||||
|
const {
|
||||||
|
showArrow,
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
portalled = true,
|
||||||
|
content,
|
||||||
|
contentProps,
|
||||||
|
portalRef,
|
||||||
|
...rest
|
||||||
|
} = props
|
||||||
|
|
||||||
|
if (disabled) return children
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChakraTooltip.Root {...rest}>
|
||||||
|
<ChakraTooltip.Trigger asChild>{children}</ChakraTooltip.Trigger>
|
||||||
|
<Portal disabled={!portalled} container={portalRef}>
|
||||||
|
<ChakraTooltip.Positioner>
|
||||||
|
<ChakraTooltip.Content ref={ref} {...contentProps}>
|
||||||
|
{showArrow && (
|
||||||
|
<ChakraTooltip.Arrow>
|
||||||
|
<ChakraTooltip.ArrowTip />
|
||||||
|
</ChakraTooltip.Arrow>
|
||||||
|
)}
|
||||||
|
{content}
|
||||||
|
</ChakraTooltip.Content>
|
||||||
|
</ChakraTooltip.Positioner>
|
||||||
|
</Portal>
|
||||||
|
</ChakraTooltip.Root>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
28
front/src/config/dayjs.ts
Normal file
28
front/src/config/dayjs.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import 'dayjs/locale/fr';
|
||||||
|
import advancedFormat from 'dayjs/plugin/advancedFormat';
|
||||||
|
import customParseFormat from 'dayjs/plugin/customParseFormat';
|
||||||
|
import dayOfYear from 'dayjs/plugin/dayOfYear';
|
||||||
|
import duration from 'dayjs/plugin/duration';
|
||||||
|
import isBetween from 'dayjs/plugin/isBetween';
|
||||||
|
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
||||||
|
import isToday from 'dayjs/plugin/isToday';
|
||||||
|
import isTomorrow from 'dayjs/plugin/isTomorrow';
|
||||||
|
import isYesterday from 'dayjs/plugin/isYesterday';
|
||||||
|
import quarterOfYear from 'dayjs/plugin/quarterOfYear';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
import weekOfYear from 'dayjs/plugin/weekOfYear';
|
||||||
|
|
||||||
|
dayjs.locale('fr');
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
dayjs.extend(customParseFormat);
|
||||||
|
dayjs.extend(weekOfYear);
|
||||||
|
dayjs.extend(isSameOrAfter);
|
||||||
|
dayjs.extend(isToday);
|
||||||
|
dayjs.extend(isTomorrow);
|
||||||
|
dayjs.extend(isYesterday);
|
||||||
|
dayjs.extend(dayOfYear);
|
||||||
|
dayjs.extend(isBetween);
|
||||||
|
dayjs.extend(advancedFormat);
|
||||||
|
dayjs.extend(quarterOfYear);
|
||||||
|
dayjs.extend(duration);
|
2
front/src/config/index.ts
Normal file
2
front/src/config/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import './axios';
|
||||||
|
import './dayjs';
|
2
front/src/constants/date.ts
Normal file
2
front/src/constants/date.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export const DATE_FORMAT = 'YYYY-MM-DD';
|
||||||
|
export const DATE_FORMAT_FULL = 'dddd DD MMMM HH:mm';
|
4
front/src/constants/genericSpacing.ts
Normal file
4
front/src/constants/genericSpacing.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export const BASE_WRAP_SPACING = { base: "5px", md: "10px", lg: "20px" };
|
||||||
|
export const BASE_WRAP_WIDTH = { base: "90%", md: "45%", lg: "270px" };
|
||||||
|
export const BASE_WRAP_HEIGHT = { base: "75px", lg: "120px" };
|
||||||
|
export const BASE_WRAP_ICON_SIZE = { base: "50px", lg: "100px" };
|
1
front/src/constants/index.ts
Normal file
1
front/src/constants/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './date'
|
62
front/src/environment.ts
Normal file
62
front/src/environment.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
export interface Environment {
|
||||||
|
production: boolean;
|
||||||
|
applName: string;
|
||||||
|
defaultServer: string;
|
||||||
|
server: {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
tokenStoredInPermanentStorage: boolean;
|
||||||
|
replaceDataToRealServer?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverSSOAddress = 'http://atria-soft.org';
|
||||||
|
|
||||||
|
const environment_back_prod: Environment = {
|
||||||
|
production: false,
|
||||||
|
// URL of development API
|
||||||
|
applName: 'karso',
|
||||||
|
defaultServer: 'karso',
|
||||||
|
server: {
|
||||||
|
karso: `${serverSSOAddress}/karso/api`,
|
||||||
|
},
|
||||||
|
tokenStoredInPermanentStorage: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const environment_local: Environment = {
|
||||||
|
production: false,
|
||||||
|
// URL of development API
|
||||||
|
applName: 'karso',
|
||||||
|
defaultServer: 'karso',
|
||||||
|
server: {
|
||||||
|
karso: 'http://localhost:15080/karso/api',
|
||||||
|
},
|
||||||
|
tokenStoredInPermanentStorage: false,
|
||||||
|
replaceDataToRealServer: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current environment is for development
|
||||||
|
* @returns true if development is active.
|
||||||
|
*/
|
||||||
|
export const isDevelopmentEnvironment = () => {
|
||||||
|
return import.meta.env.MODE === 'development';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const environment = isDevelopmentEnvironment() ? environment_local : environment_back_prod;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get the current REST api URL. Depend on the VITE_API_BASE_URL env variable.
|
||||||
|
* @returns The URL with http(s)://***
|
||||||
|
*/
|
||||||
|
export const getApiUrl = () => {
|
||||||
|
const baseUrl: string | undefined = import.meta.env.VITE_API_BASE_URL;
|
||||||
|
if (baseUrl === undefined || baseUrl === null) {
|
||||||
|
//return `${window.location.protocol}//${window.location.host}/api`;
|
||||||
|
return environment.server.karso;
|
||||||
|
}
|
||||||
|
if (baseUrl.startsWith('http')) {
|
||||||
|
return baseUrl;
|
||||||
|
}
|
||||||
|
return `${window.location.protocol}//${window.location.host}/${baseUrl}`;
|
||||||
|
};
|
27
front/src/errors/Error401.tsx
Normal file
27
front/src/errors/Error401.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { Box, Button, Center, Heading, Link, Text } from '@chakra-ui/react';
|
||||||
|
import { MdControlCamera } from 'react-icons/md';
|
||||||
|
|
||||||
|
import { PageLayoutInfoCenter } from '@/components/Layout/PageLayoutInfoCenter';
|
||||||
|
import { TopBar } from '@/components/TopBar/TopBar';
|
||||||
|
|
||||||
|
export const Error401 = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TopBar />
|
||||||
|
<PageLayoutInfoCenter padding="25px">
|
||||||
|
<Center>
|
||||||
|
<MdControlCamera style={{ width: "250px", height: "250px", color: "orange" }} />
|
||||||
|
</Center>
|
||||||
|
<Box textAlign="center">
|
||||||
|
<Heading>Erreur 401</Heading>
|
||||||
|
<Text color="red.600">
|
||||||
|
Vous n'êtes pas autorisé a accéder a ce contenu.
|
||||||
|
</Text>
|
||||||
|
<Link as="a" href="/">
|
||||||
|
Retour à l'accueil
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
</PageLayoutInfoCenter>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
25
front/src/errors/Error403.tsx
Normal file
25
front/src/errors/Error403.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Box, Button, Center, Heading, Link, Text } from '@chakra-ui/react';
|
||||||
|
import { MdDangerous } from 'react-icons/md';
|
||||||
|
|
||||||
|
import { PageLayoutInfoCenter } from '@/components/Layout/PageLayoutInfoCenter';
|
||||||
|
import { TopBar } from '@/components/TopBar/TopBar';
|
||||||
|
|
||||||
|
export const Error403 = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TopBar />
|
||||||
|
<PageLayoutInfoCenter padding="25px">
|
||||||
|
<Center>
|
||||||
|
<MdDangerous style={{ width: "250px", height: "250px", color: "red" }} />
|
||||||
|
</Center>
|
||||||
|
<Box textAlign="center">
|
||||||
|
<Heading>Erreur 403</Heading>
|
||||||
|
<Text color="orange.600">Cette page vous est interdite</Text>
|
||||||
|
<Link href="/">
|
||||||
|
Retour à l'accueil
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
</PageLayoutInfoCenter>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user