Compare commits

...

58 Commits

Author SHA1 Message Date
9c9caa409d Add Docker build target and new dependencies.
Enhanced `project.json` to include a Docker build target configuration using `@nx-tools/nx-container`. Additionally, updated `package.json` and `pnpm-lock.yaml` files with new dependencies for project functionality and maintenance.
2024-10-25 14:44:34 +02:00
bcf2f28a6b Add useCallbackRef hook and update imports
Implemented a custom `useCallbackRef` hook to optimize callback refs. Updated import paths for consistency and replaced `Cross2Icon` with `CrossIcon` in FileUploader component.
2024-10-25 14:44:22 +02:00
b512732a14 Add Dockerfile for frontend and backend apps
Introduced Dockerfiles for both frontend and backend applications to set up production environments. The frontend Dockerfile configures Next.js and the backend Dockerfile configures a Nest.js setup, both using node:lts-alpine base images. This update streamlines dependencies installation and container runtime configurations.
2024-10-25 09:35:50 +02:00
3cd94e5999 Add new dependencies to package.json
Added several new dependencies including `@hookform/resolvers`, `@tanstack/react-query`, `cors`, `react-dropzone`, `react-hook-form`, and `zod`. This update ensures the latest libraries and utilities are available for development. Corresponding updates were made to the `pnpm-lock.yaml` file to maintain integrity and compatibility.
2024-10-24 16:14:55 +02:00
d5370220a4 Improve code consistency and formatting
Standardize import order and formatting across DTOs and controllers. Ensure `HttpCode` usage aligns with correct HTTP status codes. Add CORS setup to `main.ts` for local development support.
2024-10-24 16:14:40 +02:00
bfe49f65ec Refactor frontend components and API interactions
Removed redundant API endpoint and added reusable hooks. Implemented various UI component updates including loading spinner, file upload form, and machine selector. Improved state management in page and layout components and introduced new request handling functionalities.
2024-10-24 16:14:20 +02:00
ee127f431c Add @tanstack/react-table dependency
Included @tanstack/react-table version ^8.20.5 in package.json to enhance table handling capabilities. Updated pnpm-lock.yaml to reflect the new dependencies and their respective versions.
2024-10-21 14:51:58 +02:00
13c77bfc32 Add files table and refactor sub-page components
Introduced a files table component for managing file data in the UI. Refactored sub-page components into a separate module for better code organization and maintainability. Adjusted text and links for consistency with language and configuration standards.
2024-10-21 14:51:49 +02:00
2d6815efb6 Update HTTP status code and import statements
Changed HTTP status code from FOUND to OK in files.controller.ts for better clarity. Added ApiResponse and IMachinesTable imports to machines.controller.ts to enhance API documentation and type checking.
2024-10-21 14:51:13 +02:00
30706118a8 Improve file handling in saveFile method
Added comments to clarify the buffer management in the file save process. This enhances code readability and maintenance.
2024-10-17 16:46:38 +02:00
b028fa653a Add SWC configuration file for TypeScript support
This commit introduces a .swrc file to define SWC configuration. It specifies the use of TypeScript syntax and targets ES2021, along with support for decorator metadata and legacy decorators. Additionally, the module type is set to CommonJS and source maps are enabled.
2024-10-17 16:46:15 +02:00
1d550e29b1 Add @nestjs/cli to devDependencies
This commit adds the @nestjs/cli package, version ^10.4.5, to the devDependencies in the `package.json` file. Updates related dependency information in the pnpm-lock.yaml file to reflect this addition.
2024-10-17 16:46:03 +02:00
d9f0acac58 Enhance file upload endpoint in FilesController
Added detailed Swagger annotations including headers, body schema, and operation summary to improve API documentation for the file upload endpoint. These updates provide clear information about the expected input parameters for better usability and integration.
2024-10-17 14:56:06 +02:00
4547a22f5c Add Swagger metadata to DTOs and controllers
Enhanced API documentation by adding Swagger decorators like @ApiProperty, @ApiTags, and @ApiBearerAuth to DTOs and controllers. This will improve clarity and usability of the API for developers using Swagger.
2024-10-17 14:21:23 +02:00
1020d6283d Refactor code to improve readability and consistency
Reformatted import statements and function parameters for better readability. Improved alignment and punctuation to enhance code consistency and maintainability.
2024-10-17 12:26:51 +02:00
0330358139 Add missing semicolons for consistency
This commit adds missing semicolons to type definitions in schema.ts for consistent code style and better readability. No functional changes were made to the existing code logic.
2024-10-17 12:25:11 +02:00
38634132ba Add search method to Files Service
Implemented a method to search for files in the database by limit, offset, and search term. This method enhances the service by providing a way to paginate and filter file results.
2024-10-17 12:21:58 +02:00
7b4792b612 Fix data type in pagination schema
Updated the `data` property in the pagination schema from a single object to an array of objects to accurately represent paginated data. This change ensures that the schema aligns with the actual data structure used in the application.
2024-10-17 12:21:21 +02:00
989ec71e2e Fix data type in pagination schema
Updated the `data` property in the pagination schema from a single object to an array of objects to accurately represent paginated data. This change ensures that the schema aligns with the actual data structure used in the application.
2024-10-17 12:21:14 +02:00
2f0c2f8b7c Add IWithCount interface to schema.ts
Introduced a new interface IWithCount to handle paginated data responses. It includes properties for count, limit, currentOffset, and a generic data type. This change will aid in standardizing pagination across the application.
2024-10-17 12:15:58 +02:00
8b5a4640c1 Introduce type exports in database schema
Improved the readability and usability of the database schema by introducing type exports for each table. The exported types (IFileTable, IFileGroupTable, IFilesTypesTable, IMachinesTable, IFilesForMachinesTable, IFilesTypeForMachine) will allow the schemas to be more easily referenced and utilized in other parts of the codebase.
2024-10-17 12:12:26 +02:00
92bb6bf367 Add SubHomePage component and minor styling updates
Introduced the SubHomePage component to enhance modularity of the home page. Adjusted styling to include margins and a dark mode class for the body element.
2024-10-17 12:04:28 +02:00
4d6962afb5 Update Next.js to version 14.2.15
Upgraded Next.js dependency from version 14.2.3 to 14.2.15 in package.json and pnpm-lock.yaml. This ensures improved stability and integrates the latest features and fixes in the Next.js framework.
**2 vulnerabilities found in dependency**
2024-10-17 12:04:17 +02:00
09ec8d683f Upgrade dependencies to latest versions for security and fixes
Updated multiple dependencies in `package.json` and `pnpm-lock.yaml` to their latest versions. This includes key packages such as `@nestjs`, `express`, `typescript`, and others for enhanced stability, security, and performance improvements.
2024-10-17 12:01:07 +02:00
4f40ef371c Change default port from 3000 to 3333
Updated the main application file to change the default port used when the environment variable PORT is not set from 3000 to 3333. This ensures the backend operates on a different port to avoid conflicts with other services running on port 3000.
2024-10-17 10:26:59 +02:00
ed1defb1da Add NewFileModal component and implement resizable panels
Introduce a new NewFileModal component for adding files with a dialog box. Adjust page layout to use resizable panels to improve flexibility and user experience. Modify CSS variables for better dark mode color scheme.
2024-10-17 10:26:51 +02:00
3c31223293 Refactor code to use consistent tab spacing
Replaced mixed spaces and tabs with consistent tab spacing across multiple files for better code readability and maintenance. Adjusted import statements and string literals formatting to maintain coherence.
2024-10-15 16:51:12 +02:00
6f9d25a58b Refactor and log changes in storage and file services
Convert array of machine IDs to sets for uniqueness in methods. Add console logs for debugging and update error handling to improve clarity. Refactor the files controller for better readability.
2024-10-15 16:50:33 +02:00
ff649ebdbf Add Swagger integration and console logging for debugging
Integrated Swagger module in the backend for API documentation. Added console logs for better traceability and debugging in service and controller methods.
2024-10-15 15:15:55 +02:00
6f0f209e00 Delete associated machine file entries before removing main file
Added a step to delete file associations from `FilesForMachinesTable` before deleting the main file from `FilesTable`. This ensures proper cleanup of related entries in the database.
2024-10-15 14:24:00 +02:00
18a5999334 Implement file type removal functionality
Add logic to remove file types by ID, ensuring they are not in use by any files or machines. Handle errors appropriately, throwing specific exceptions for cases where the file type cannot be deleted or an internal error occurs.
2024-10-15 14:09:15 +02:00
877e13043d Implement getAllFilesTypes in files controller
Added return statement to getTypes endpoint in files controller, utilizing the newly updated getAllFilesTypes method from the files service. Also fixed minor formatting issues.
2024-10-15 13:51:53 +02:00
6bb9510356 Add retrieval method for all file types in files service
Implemented the `getAllFilesTypes` method to retrieve all file types from the database. This method uses the database connection to select and return an array of file types, completing the previously marked TODO item.
2024-10-15 13:49:45 +02:00
a5f54e165b Implement file type creation endpoint
Completed the `newType` method in `files.controller.ts` to call `filesService.createFileType` with the provided name and mime type. This enables the creation of new file types through the backend API.
2024-10-15 13:47:51 +02:00
b29b188912 Add createFileType method and DTO validations
Introduced createFileType method to handle file type creation, including format validation and error handling for database operations. Updated CreateFileTypeDto with length constraints on name and mime properties.
2024-10-15 13:47:11 +02:00
c7e1a949a2 Update file inclusion pattern in biome.json
Changed the file inclusion pattern to ensure only TypeScript files in src directories are included. This helps in focusing solely on source files and avoids unintended files from being processed.
2024-10-15 13:47:02 +02:00
16487e5985 Refactor test URL and add new npm script
Updated the axios GET request in the backend test to use double quotes for consistency. Added a new 'fix' script in package.json for Biome to automatically fix and write unsafe changes. Changed var to let in global-setup.ts for better variable handling.
2024-10-15 11:29:56 +02:00
8ee5410c91 Refactor files controller imports and rearrange decorators
Reorganized import statements for cleaner structure and added a missing import for CreateFileTypeDto. Adjusted the placement of UseGuards decorators to maintain consistency across methods.
2024-10-15 11:27:47 +02:00
e7830095b3 Add CreateFileTypeDto class to files.dto.ts
Introduced a new DTO class named CreateFileTypeDto in files.dto.ts for future implementations. The class currently has no properties defined, marked with a TODO comment for subsequent development.
2024-10-15 11:23:13 +02:00
055c48dbf9 Add endpoints for file types management
Introduced new endpoints to handle file types: fetching all types, adding a new type, and deleting a type. These additions include necessary guards and parameter parsing for robust API functionality.
2024-10-15 11:23:04 +02:00
1c78912f99 Add missing semicolons and format code for consistency
Updated the controllers and services by adding missing semicolons to ensure code consistency and prevent potential runtime errors. Reformatted the import statements to enhance readability and maintain a uniform style across the codebase.
2024-10-14 15:16:13 +02:00
04cc2daf43 Improve groups.controller with service integration
This commit adds service method calls for managing groups in the controller. It includes integration for fetching groups by name, creating a new group, deleting a group, and finding files for a group. These changes enhance the functionality and efficiency of the groups controller.
2024-10-14 14:52:52 +02:00
fc2f437556 Implement group deletion and add group files retrieval
Added logic to delete a group and update the associated files to have a null group reference. Also implemented a method to retrieve files associated with a group using pagination and search functionality.
2024-10-14 14:43:50 +02:00
1fc9185afc Refactor storage and file deletion logic
Adjusted the indentation and formatting across several files for better readability. Enhanced the file deletion logic to handle cases where a file is found in the database but not in storage, and to delete only from the database when there are multiple references.
2024-10-14 13:56:43 +02:00
8686f0c27b Add file deletion method in storage service
Implemented a new method to delete files from the storage by checksum and extension. This method logs the deletion process and throws a NotFoundException if the file cannot be found. Updated dependencies to include 'rm' from 'node:fs/promises'.
2024-10-14 12:10:52 +02:00
79b2dec9e9 Add file deletion endpoint with admin guard
Introduced the deleteFile method in files.service.ts to handle file deletion, considering duplicates. Also updated files.controller.ts to create a new DELETE endpoint protected by the AdminGuard to ensure only admins can delete files.
2024-10-14 12:04:12 +02:00
3e6b2dc2bc Add TODO comment for future file deletion implementation
A TODO comment was added to the files.service.ts to indicate the need for a file deletion feature. This serves as a placeholder for future development to ensure the feature is addressed.
2024-10-14 11:58:30 +02:00
04a37d19b7 Add file saving functionality and update group ID handling
Implemented the save method to store files and associate them with machines, including handling optional group associations. Modified the database schema to allow null as a default for group_id and added logic to update group ID after file insertion.
2024-10-14 11:53:54 +02:00
30c9c28e3d Refactor MIME type check for machine-specific validation
Updated the MIME type validation function to handle machine-specific MIME type sets. This ensures that a MIME type must be acceptable across all associated machines. Enhanced clarity and structure by using a map to maintain MIME types for each machine.
2024-10-14 11:21:50 +02:00
2ca13714a4 Refactor file and group handling logic
Allow groupId to be optional and added methods for group operations. Updated file insertion to handle optional groupId and adjusted group verification logic accordingly. Added method stubs for creating, deleting, and finding files within groups.
2024-10-11 11:00:37 +02:00
356b6869ad Remove commented-out code in schema
Eliminated a comment in the FilesGroupTable definition that was redundant. This helps in maintaining cleaner and more readable code.
2024-10-10 15:26:34 +02:00
030b5c814c Add type management endpoints for machines
This commit introduces endpoints to add, remove, and list types associated with machines. It also includes the addition of the `TypeDto` class to handle type validations and updates HTTP response codes for the existing endpoints.
2024-10-10 15:26:06 +02:00
16ed8d3420 Refactor machines controller todo comments
Removed obsolete todos for DTO and Patch operations. Added todos for types handling related to machine operations as placeholders for future implementation.
2024-10-10 14:15:17 +02:00
0874ffb835 Implement machine list retrieval in controllers
Add functionality to fetch and return a list of machines with limit, offset, and search parameters using the machineService. This enhances the controller's capability to handle queries more effectively.
2024-10-10 14:10:23 +02:00
3d67b8ad18 Add service methods for managing authors and files
Implemented methods in AuthorsService to find authors, filter files by author, and delete authors. Integrated these methods in AuthorsController to handle HTTP requests accordingly.
2024-10-10 11:44:24 +02:00
3bc440cbf8 Remove unnecessary TODO comment
Deleted a redundant TODO comment about deleting a machine and associated file type. The method to remove a specified file type from a machine is already implemented.
2024-10-08 16:34:07 +02:00
44dab5ba11 Add primary key to FilesTypeForMachine table
Added a new 'id' column as the primary key with a unique constraint and default random value generation for the 'FilesTypeForMachine' table. This ensures each row has a unique identifier.
2024-10-08 16:33:41 +02:00
7876bc2c38 Revise file search logic for machine service
Updated the file search functionality to include a search field that filters files by name and added pagination support. This enhances the API by providing more flexible querying and making it easier to handle large datasets.
2024-10-08 16:11:34 +02:00
58 changed files with 4436 additions and 912 deletions

View File

@@ -2,7 +2,7 @@ import axios from "axios";
describe("GET /api", () => { describe("GET /api", () => {
it("should return a message", async () => { it("should return a message", async () => {
const res = await axios.get(`/api`); const res = await axios.get("/api");
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.data).toEqual({ message: "Hello API" }); expect(res.data).toEqual({ message: "Hello API" });

View File

@@ -1,5 +1,5 @@
/* eslint-disable */ /* eslint-disable */
var __TEARDOWN_MESSAGE__: string; let __TEARDOWN_MESSAGE__: string;
module.exports = async () => { module.exports = async () => {
// Start services that that the app needs to run (e.g. database, docker-compose, etc.). // Start services that that the app needs to run (e.g. database, docker-compose, etc.).

16
apps/backend/.swrc Normal file
View File

@@ -0,0 +1,16 @@
{
"jsc": {
"parser": {
"syntax": "typescript"
},
"target": "es2021",
"transform": {
"decoratorMetadata": true,
"legacyDecorator": true
}
},
"module": {
"type": "commonjs"
},
"sourceMaps": true
}

21
apps/backend/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
# Install dependencies only when needed
FROM docker.io/node:lts-alpine AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /usr/src/app
COPY dist/apps/backend/package*.json ./
RUN npm install --omit=dev
# Production image, copy all the files and run nest
FROM docker.io/node:lts-alpine AS runner
RUN apk add --no-cache dumb-init
ENV NODE_ENV=production
ENV PORT=3000
WORKDIR /usr/src/app
COPY --from=deps /usr/src/app/node_modules ./node_modules
COPY --from=deps /usr/src/app/package.json ./package.json
COPY dist/apps/backend .
RUN chown -R node:node .
USER node
EXPOSE 3000
CMD ["dumb-init", "node", "main.js"]

View File

@@ -21,6 +21,24 @@
"buildTarget": "backend:build:production" "buildTarget": "backend:build:production"
} }
} }
},
"container": {
"executor": "@nx-tools/nx-container:build",
"dependsOn": ["build"],
"options": {
"engine": "docker",
"metadata": {
"images": ["backend"],
"load": true,
"tags": [
"type=schedule",
"type=ref,event=branch",
"type=ref,event=tag",
"type=ref,event=pr",
"type=sha,prefix=sha-"
]
}
}
} }
} }
} }

View File

@@ -1,8 +1,10 @@
import { Controller, Get } from "@nestjs/common"; import { Controller, Get } from "@nestjs/common";
import { ApiTags } from "@nestjs/swagger";
import { AppService } from "./app.service"; import { AppService } from "./app.service";
@Controller() @Controller()
@ApiTags("useless")
export class AppController { export class AppController {
constructor(private readonly appService: AppService) {} constructor(private readonly appService: AppService) {}

View File

@@ -10,10 +10,12 @@ import {
UnauthorizedException, UnauthorizedException,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { SignInDto, SignUpDto } from "apps/backend/src/app/auth/auth.dto"; import { SignInDto, SignUpDto } from "apps/backend/src/app/auth/auth.dto";
import { AuthService } from "apps/backend/src/app/auth/auth.service"; import { AuthService } from "apps/backend/src/app/auth/auth.service";
import { UserGuard } from "./auth.guard"; import { UserGuard } from "./auth.guard";
@ApiTags("User authentification")
@Controller("auth") @Controller("auth")
export class AuthController { export class AuthController {
constructor(private readonly authService: AuthService) {} constructor(private readonly authService: AuthService) {}
@@ -23,7 +25,6 @@ export class AuthController {
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@Post("signup") @Post("signup")
async signUp(@Body() dto: SignUpDto) { async signUp(@Body() dto: SignUpDto) {
console.log(dto);
return this.authService.doRegister(dto); return this.authService.doRegister(dto);
} }
@@ -31,10 +32,10 @@ export class AuthController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post("signin") @Post("signin")
async signIn(@Body() dto: SignInDto) { async signIn(@Body() dto: SignInDto) {
console.log(dto);
return this.authService.doLogin(dto); return this.authService.doLogin(dto);
} }
//GET me -- Get current user data via jwt //GET me -- Get current user data via jwt
@ApiBearerAuth()
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Get("me") @Get("me")
@UseGuards(UserGuard) @UseGuards(UserGuard)
@@ -48,6 +49,7 @@ export class AuthController {
return userData; return userData;
} }
//DELETE me //DELETE me
@ApiBearerAuth()
@HttpCode(HttpStatus.FOUND) @HttpCode(HttpStatus.FOUND)
@Delete("me") @Delete("me")
@UseGuards(UserGuard) @UseGuards(UserGuard)

View File

@@ -1,3 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { import {
IsEmail, IsEmail,
IsNotEmpty, IsNotEmpty,
@@ -22,31 +23,41 @@ export class SignUpDto {
lastName: string; lastName: string;
**/ **/
@ApiProperty({
example: "jean@paul.fr",
})
@MaxLength(32) @MaxLength(32)
@IsEmail() @IsEmail()
@IsNotEmpty() @IsNotEmpty()
email: string; email: string;
@ApiProperty({
example: "zSEs-6ze$",
})
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@IsStrongPassword({ @IsStrongPassword({
minLength: 6, minLength: 6,
minSymbols: 1,
}) })
password: string; password: string;
} }
export class SignInDto { export class SignInDto {
@MaxLength(32) @MaxLength(32)
@ApiProperty({
example: "jean@paul.fr",
})
@IsEmail() @IsEmail()
@IsNotEmpty() @IsNotEmpty()
email: string; email: string;
@IsString() @IsString()
@ApiProperty({
example: "zSEs-6ze$",
})
@IsNotEmpty() @IsNotEmpty()
@IsStrongPassword({ @IsStrongPassword({
minLength: 6, minLength: 6,
minSymbols: 1,
}) })
password: string; password: string;
} }

View File

@@ -18,7 +18,6 @@ export class AuthService implements OnModuleInit {
//TODO Initial account validation for admin privileges //TODO Initial account validation for admin privileges
async doRegister(data: SignUpDto) { async doRegister(data: SignUpDto) {
console.log(data);
const existingUser = await this.db const existingUser = await this.db
.use() .use()
.select() .select()

View File

@@ -9,9 +9,11 @@ import {
Query, Query,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { AdminGuard } from "apps/backend/src/app/auth/auth.guard"; import { AdminGuard } from "apps/backend/src/app/auth/auth.guard";
import { AuthorsService } from "apps/backend/src/app/authors/authors.service"; import { AuthorsService } from "apps/backend/src/app/authors/authors.service";
@ApiTags("File authors")
@Controller("authors") @Controller("authors")
export class AuthorsController { export class AuthorsController {
constructor(private readonly authorService: AuthorsService) {} constructor(private readonly authorService: AuthorsService) {}
@@ -21,23 +23,32 @@ export class AuthorsController {
@Query("limit", new DefaultValuePipe(20), ParseIntPipe) limit: number, @Query("limit", new DefaultValuePipe(20), ParseIntPipe) limit: number,
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number, @Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
@Query("search", new DefaultValuePipe("")) search: string, @Query("search", new DefaultValuePipe("")) search: string,
) {} ) {
return await this.authorService.find(limit, offset, search);
//TODO DTO }
@Post("new")
async newAuthor() {}
//TODO Refactor
@ApiBearerAuth()
@UseGuards(AdminGuard) @UseGuards(AdminGuard)
@Delete(":autor") @Delete(":autor")
async deleteAuthor(@Param("author") author: string) {} async deleteAuthor(@Param("author") author: string) {
return await this.authorService.delete(author);
}
//TODO Patch //TODO Patch
@Get("files/:author") @Get("files/:author")
async getFilesForAuthor( async filterFilesForAuthor(
@Query("limit", new DefaultValuePipe(20), ParseIntPipe) limit: number, @Query("limit", new DefaultValuePipe(20), ParseIntPipe) limit: number,
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number, @Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
@Query("search", new DefaultValuePipe("")) search: string, @Query("search", new DefaultValuePipe("")) search: string,
@Param("machineId") author: string, @Param("machineId") author: string,
) {} ) {
return await this.authorService.findFileForAuthor(
limit,
offset,
search,
author,
);
}
} }

View File

@@ -1,4 +1,76 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { DbService } from "apps/backend/src/app/db/db.service";
import { FilesTable } from "apps/backend/src/app/db/schema";
import { and, eq, ilike } from "drizzle-orm";
@Injectable() @Injectable()
export class AuthorsService {} export class AuthorsService {
constructor(private readonly database: DbService) {}
/**
* Searches for records in the FilesTable based on the uploadedBy field.
*
* @param {number} limit - The maximum number of records to return.
* @param {number} offset - The number of records to skip before starting to collect the result set.
* @param {string} searchField - The search term to filter the uploadedBy field.
* @return {Promise<Array>} A promise that resolves to an array of matching records.
*/
async find(limit: number, offset: number, searchField: string) {
return this.database
.use()
.select()
.from(FilesTable)
.where(ilike(FilesTable.uploadedBy, String(`%${searchField}%`)))
.limit(limit)
.offset(offset)
.prepare("searchAuthor")
.execute();
}
/**
* Finds files uploaded by a specific author based on search criteria.
*
* @param {number} limit - The maximum number of files to return.
* @param {number} offset - The offset for pagination purposes.
* @param {string} searchField - The search term to filter files by their name.
* @param {string} author - The author of the files.
* @return {Promise<Array>} A promise that resolves to an array of files matching the criteria.
*/
async findFileForAuthor(
limit: number,
offset: number,
searchField: string,
author: string,
) {
return this.database
.use()
.select()
.from(FilesTable)
.where(
and(
eq(FilesTable.uploadedBy, author),
ilike(FilesTable.fileName, String(`%${searchField}%`)),
),
)
.prepare("searchAuthor")
.execute();
}
/**
* Deletes the reference of an author from the FilesTable by replacing the uploadedBy field with "none".
*
* @param {string} authorName - The name of the author whose reference is to be deleted.
* @return {Promise<void>} A promise that resolves when the operation is complete.
*/
async delete(authorName: string) {
await this.database
.use()
.update(FilesTable)
.set({
uploadedBy: "none",
})
.where(eq(FilesTable.uploadedBy, authorName))
.prepare("replaceAuthorFieldForMatch")
.execute();
}
}

View File

@@ -9,7 +9,6 @@ export class CredentialsService {
constructor(private readonly configService: ConfigService) {} constructor(private readonly configService: ConfigService) {}
async hash(plaintextPassword: string) { async hash(plaintextPassword: string) {
console.log(plaintextPassword);
if (plaintextPassword.length < 6) if (plaintextPassword.length < 6)
throw new BadRequestException("Password is not strong enough !"); throw new BadRequestException("Password is not strong enough !");
return argon.hash(plaintextPassword, { return argon.hash(plaintextPassword, {

View File

@@ -75,7 +75,7 @@ export const FilesTable = pgTable("files", {
groupId: p groupId: p
.uuid("group_id") .uuid("group_id")
.notNull() .default(null)
.references(() => FilesGroupTable.uuid), .references(() => FilesGroupTable.uuid),
fileSize: p.integer("file_size").notNull(), fileSize: p.integer("file_size").notNull(),
@@ -103,9 +103,9 @@ export const FilesTable = pgTable("files", {
}) })
.notNull(), .notNull(),
}); });
export type IFileTable = typeof FilesTable.$inferSelect;
export const FilesGroupTable = pgTable("f_groups", { export const FilesGroupTable = pgTable("f_groups", {
// Unique identifier on a technical aspect.
uuid: p.uuid("uuid").unique().primaryKey().defaultRandom().notNull(), uuid: p.uuid("uuid").unique().primaryKey().defaultRandom().notNull(),
groupName: p groupName: p
@@ -115,6 +115,7 @@ export const FilesGroupTable = pgTable("f_groups", {
.unique() .unique()
.notNull(), .notNull(),
}); });
export type IFileGroupTable = typeof FilesGroupTable.$inferSelect;
//TODO Files types //TODO Files types
export const FilesTypesTable = pgTable("f_types", { export const FilesTypesTable = pgTable("f_types", {
@@ -135,6 +136,7 @@ export const FilesTypesTable = pgTable("f_types", {
.unique() .unique()
.notNull(), .notNull(),
}); });
export type IFilesTypesTable = typeof FilesTypesTable.$inferSelect;
export const MachinesTable = pgTable("machines", { export const MachinesTable = pgTable("machines", {
id: p.uuid("id").unique().primaryKey().defaultRandom().notNull(), id: p.uuid("id").unique().primaryKey().defaultRandom().notNull(),
@@ -153,6 +155,7 @@ export const MachinesTable = pgTable("machines", {
//supported files format //supported files format
}); });
export type IMachinesTable = typeof MachinesTable.$inferSelect;
//TODO Many to Many table betwen File en Machine //TODO Many to Many table betwen File en Machine
export const FilesForMachinesTable = pgTable("files_for_machines", { export const FilesForMachinesTable = pgTable("files_for_machines", {
@@ -168,8 +171,11 @@ export const FilesForMachinesTable = pgTable("files_for_machines", {
.notNull() .notNull()
.references(() => MachinesTable.id), .references(() => MachinesTable.id),
}); });
export type IFilesForMachinesTable = typeof FilesForMachinesTable.$inferSelect;
export const FilesTypeForMachine = pgTable("f_type_for_machines", { export const FilesTypeForMachine = pgTable("f_type_for_machines", {
id: p.uuid("id").unique().primaryKey().defaultRandom().notNull(),
machineId: p machineId: p
.uuid("machine_id") .uuid("machine_id")
.notNull() .notNull()
@@ -179,3 +185,11 @@ export const FilesTypeForMachine = pgTable("f_type_for_machines", {
.notNull() .notNull()
.references(() => FilesTypesTable.id), .references(() => FilesTypesTable.id),
}); });
export type IFilesTypeForMachine = typeof FilesTypeForMachine.$inferSelect;
export interface IWithCount<T> {
count: number;
limit: number;
currentOffset: number;
data: T[];
}

View File

@@ -1,13 +1,16 @@
import { IncomingMessage } from "node:http"; import { IncomingMessage } from "node:http";
import { import {
BadRequestException, BadRequestException,
Body,
Controller, Controller,
DefaultValuePipe, DefaultValuePipe,
Delete,
Get, Get,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Param, Param,
ParseIntPipe, ParseIntPipe,
ParseUUIDPipe,
Post, Post,
Query, Query,
Req, Req,
@@ -17,19 +20,86 @@ import {
StreamableFile, StreamableFile,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { InsertAdminState } from "../auth/auth.guard"; import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiHeaders,
ApiOperation,
ApiSecurity,
ApiTags,
} from "@nestjs/swagger";
import { CreateFileTypeDto } from "apps/backend/src/app/files/files.dto";
import { AdminGuard, InsertAdminState } from "../auth/auth.guard";
import { FilesService } from "./files.service"; import { FilesService } from "./files.service";
@ApiTags("Files")
@Controller("files") @Controller("files")
export class FilesController { export class FilesController {
constructor(private readonly filesService: FilesService) {} constructor(private readonly filesService: FilesService) {}
@UseGuards(InsertAdminState) @ApiOperation({ summary: "Uploader un fichier" })
@ApiConsumes("application/octet-stream")
@ApiBody({
schema: {
type: "string",
format: "binary",
},
})
@ApiHeaders([
{
name: "file_name",
description: "Nom du fichier",
example: "Logo marianne",
allowEmptyValue: false,
required: true,
},
{
name: "group_id",
description: "Groupe de fichier optionnel",
example: "dfd0fbb1-2bf3-4dbe-b86d-89b1bff5106c",
allowEmptyValue: true,
},
{
name: "machine_id",
description: "Identifiant(s) de machine(s)",
example: [
"dfd0fbb1-2bf3-4dbe-b86d-89b1bff5106c",
"dfd0fbb1-2bf3-4dbe-b86d-89b1bff5106c",
],
allowEmptyValue: false,
required: true,
},
{
name: "uploaded_by",
description: "Pseudonyme requis mais au choix.",
example: "Jean Paul",
allowEmptyValue: false,
required: true,
},
{
name: "is_documentation",
description:
"Admin uniquement, défini le fichier comme étant une documentation",
enum: ["true", "false"],
allowEmptyValue: false,
},
{
name: "is_restricted",
description:
"Admin uniquement, rend impossible l'écrasement d'un fichier (non implémenté pour l'instant)",
enum: ["true", "false"],
allowEmptyValue: false,
},
])
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@UseGuards(InsertAdminState)
@Post("new") @Post("new")
async saveFile(@Req() req: IncomingMessage, @Res() res: Response) { async saveFile(@Req() req: IncomingMessage, @Res() res: Response) {
let fileBuffer: Buffer = Buffer.from([]); let fileBuffer: Buffer = Buffer.from([]);
//On row of a file
req.on("data", (chunk: Buffer) => { req.on("data", (chunk: Buffer) => {
// Represents a buffer for handling file operations.
fileBuffer = Buffer.concat([fileBuffer, chunk]); fileBuffer = Buffer.concat([fileBuffer, chunk]);
}); });
@@ -54,20 +124,22 @@ export class FilesController {
); );
// Vérifier que les en-têtes nécessaires sont présents // Vérifier que les en-têtes nécessaires sont présents
if (!_fileName || !_groupId || !_machineId) { if (!_fileName || !_machineId || !_uploadedBy) {
throw new BadRequestException("Header(s) manquant(s)"); throw new BadRequestException("Header(s) manquant(s)");
} }
console.log("Header found !"); console.log("Header found !");
const machineId = Array(_machineId); const machineId = Array(_machineId);
const Params = new Map() const Params = new Map()
.set("groupId", null)
.set("fileName", _fileName.toString()) .set("fileName", _fileName.toString())
.set("groupId", _groupId.toString()) .set("uploadedBy", String(_uploadedBy))
.set("uploadedBy", _uploadedBy.toString()) .set("machineId", Array(machineId))
.set("machineId", Array(JSON.parse(machineId.toString())))
.set("isDocumentation", false) .set("isDocumentation", false)
.set("isRestricted", false); .set("isRestricted", false);
if (_groupId) Params.set("groupId", String(_groupId));
console.log("Current params :\n", Params); console.log("Current params :\n", Params);
//TODO Integrate a verification if the source is an admin, if that the case then it can define isDocumentation and isRestricted else throw in case of presence of those parameters. //TODO Integrate a verification if the source is an admin, if that the case then it can define isDocumentation and isRestricted else throw in case of presence of those parameters.
@@ -106,7 +178,7 @@ export class FilesController {
return; return;
} }
@HttpCode(HttpStatus.FOUND) @HttpCode(HttpStatus.OK)
@Get("find") @Get("find")
async findMany( async findMany(
@Query("limit", new DefaultValuePipe(20), ParseIntPipe) limit: number, @Query("limit", new DefaultValuePipe(20), ParseIntPipe) limit: number,
@@ -116,9 +188,40 @@ export class FilesController {
return this.filesService.search(limit, offset, search); return this.filesService.search(limit, offset, search);
} }
@HttpCode(HttpStatus.FOUND) @HttpCode(HttpStatus.OK)
@Get("types")
async getTypes() {
console.log("Performing request");
return await this.filesService.getAllFilesTypes();
}
@ApiBearerAuth()
@HttpCode(HttpStatus.CREATED)
@UseGuards(AdminGuard)
@Post("types/new")
async newType(@Body() body: CreateFileTypeDto) {
return await this.filesService.createFileType(body.name, body.mime);
}
@ApiBearerAuth()
@HttpCode(HttpStatus.ACCEPTED)
@UseGuards(AdminGuard)
@Delete("types/:typeId")
async delType(@Param(":typeId", ParseUUIDPipe) typeId: string) {
return await this.filesService.removeFileType(typeId);
}
@HttpCode(HttpStatus.OK)
@Get(":fileId") @Get(":fileId")
async getFile(@Param("fileId") fileId: string) { async getFile(@Param("fileId") fileId: string) {
return await this.filesService.get(fileId); return await this.filesService.get(fileId);
} }
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@UseGuards(AdminGuard)
@Delete(":fileId")
async deleteFile(@Param("fileId", ParseUUIDPipe) fileId: string) {
return await this.filesService.deleteFile(fileId);
}
} }

View File

@@ -1,18 +1,22 @@
import { DefaultValuePipe } from "@nestjs/common"; import { DefaultValuePipe } from "@nestjs/common";
import { ApiBearerAuth, ApiProperty } from "@nestjs/swagger";
import { IsUUID, MaxLength, MinLength } from "class-validator"; import { IsUUID, MaxLength, MinLength } from "class-validator";
export class CreateFilesDto { export class CreateFileTypeDto {
@ApiProperty({
description: "Admin uniquement, nom d'affichage.",
examples: [".scad", "jpg"],
})
@MaxLength(128) @MaxLength(128)
@MinLength(4) @MinLength(3)
fileName: string; name: string;
@ApiProperty({
description:
"Admin uniquement, Multipurpose Internet Mail Extensions (MIME)",
examples: ["application/x-openscad", "image/jpeg"],
})
@MaxLength(64) @MaxLength(64)
@MinLength(2) @MinLength(4)
uploadedBy: string; mime: string;
isDocumentation?: boolean;
isRestricted?: boolean;
@IsUUID()
groupId: string;
} }

View File

@@ -1,4 +1,5 @@
import { import {
BadRequestException,
Injectable, Injectable,
InternalServerErrorException, InternalServerErrorException,
NotFoundException, NotFoundException,
@@ -11,11 +12,12 @@ import {
FilesTable, FilesTable,
FilesTypeForMachine, FilesTypeForMachine,
FilesTypesTable, FilesTypesTable,
IFileTable,
IWithCount,
MachinesTable, MachinesTable,
} from "apps/backend/src/app/db/schema"; } from "apps/backend/src/app/db/schema";
import { StorageService } from "apps/backend/src/app/storage/storage.service"; import { StorageService } from "apps/backend/src/app/storage/storage.service";
import { data } from "autoprefixer"; import { count, eq, ilike } from "drizzle-orm";
import { eq, ilike } from "drizzle-orm";
@Injectable() @Injectable()
export class FilesService { export class FilesService {
@@ -69,17 +71,82 @@ export class FilesService {
} }
/** /**
* Searches for files in the database using the specified search field, limit, and offset. * Deletes a file from the storage and/or the database based on the provided file ID.
*
* @param {string} fileId - The unique identifier of the file to delete.
* @return {Promise<void>} A promise that resolves when the deletion process is complete.
* @throws {NotFoundException} If the file is not found in the database.
*/
public async deleteFile(fileId: string) {
//get checksum for fileId
const currentFileInDb = await this.database
.use()
.select()
.from(FilesTable)
.where(eq(FilesTable.uuid, fileId))
.prepare("findFileById")
.execute();
if (currentFileInDb.length === 0)
throw new NotFoundException("File not found in database");
//check if multiple entry for checksum
const sameFileInStorage = await this.database
.use()
.select()
.from(FilesTable)
.where(eq(FilesTable.checksum, currentFileInDb[0].checksum));
if (sameFileInStorage.length > 1) {
//if that the case then only remove the entry in database relative to fileId.
await this.database
.use()
.delete(FilesTable)
.where(eq(FilesTable.uuid, fileId))
.execute();
}
if (sameFileInStorage.length === 1) {
//if there is one only entry then remove the file from the storage and the database.
await this.database
.use()
.delete(FilesForMachinesTable)
.where(eq(FilesForMachinesTable.fileId, fileId))
.prepare("deleteFileAssociationFromMachine")
.execute();
await this.database
.use()
.delete(FilesTable)
.where(eq(FilesTable.uuid, fileId))
.execute();
await this.storage.delete(
currentFileInDb[0].checksum,
currentFileInDb[0].extension,
currentFileInDb[0].isDocumentation,
);
}
}
/**
* Searches for files in the database based on the provided search field, limit, and offset.
* *
* @param {number} limit - The maximum number of results to return. * @param {number} limit - The maximum number of results to return.
* @param {number} offset - The number of results to skip before starting to return results. * @param {number} offset - The offset for the results.
* @param {string} searchField - The field used to search for matching file names. * @param {string} searchField - The value to search for within the file names.
* * @return {Promise<IWithCount<IFileTable>>} A promise that resolves to an object containing the count of files that match the search criteria, the provided limit, the current offset, and the matching data.
* @return {Promise<object>} A promise that resolves to the search results.
*/ */
public async search(limit: number, offset: number, searchField: string) { public async search(
limit: number,
offset: number,
searchField: string,
): Promise<IWithCount<IFileTable>> {
try { try {
return await this.database const countResult = await this.database
.use()
.select({ count: count() })
.from(FilesTable)
.where(ilike(FilesTable.fileName, String(`%${searchField}%`)))
.prepare("searchFilesCount")
.execute();
const dataResult = await this.database
.use() .use()
.select() .select()
.from(FilesTable) .from(FilesTable)
@@ -88,12 +155,25 @@ export class FilesService {
.offset(offset) .offset(offset)
.prepare("searchFiles") .prepare("searchFiles")
.execute(); .execute();
return {
count: countResult[0].count,
limit: limit,
currentOffset: offset,
data: dataResult,
};
} catch (error) { } catch (error) {
throw new InternalServerErrorException(error); throw new InternalServerErrorException(error);
} }
} }
//TODO save a file /**
* Saves a file and associates it with machines and an optional group in the database.
*
* @param {Buffer} file - The file data to be saved.
* @param {Map<string, unknown>} data - A map containing file and association metadata.
* @throws {NotFoundException} If a machine or group specified in the data does not exist in the database.
* @return {Promise<Object>} The inserted file record.
*/
public async save(file: Buffer, data: Map<string, unknown>) { public async save(file: Buffer, data: Map<string, unknown>) {
const _machineIds = data.get("machineId").toString().split(","); const _machineIds = data.get("machineId").toString().split(",");
@@ -119,11 +199,12 @@ export class FilesService {
machinesIds.add(machineExists[0].uuid); machinesIds.add(machineExists[0].uuid);
} }
const _group = data.get("groupId") as string; const _group = data.get("groupId") as string | null;
console.log("Linking to group :\n", _group); console.log("Linking to group :\n", _group);
if (!_group) { /*if (!_group) {
throw new NotFoundException(`Group with ID "${_group}" not found`); throw new NotFoundException(`Group with ID "${_group}" not found`);
} }*/
// verify that the group exist in the database // verify that the group exist in the database
const groupExists = await this.database const groupExists = await this.database
.use() .use()
@@ -132,15 +213,15 @@ export class FilesService {
.where(eq(FilesGroupTable.uuid, _group)) .where(eq(FilesGroupTable.uuid, _group))
.prepare("checkGroupExists") .prepare("checkGroupExists")
.execute(); .execute();
if (!_group) console.log("No group to link with the file.");
if (groupExists.length === 0) { if (_group && groupExists.length === 0) {
throw new NotFoundException(`Group with ID "${_group}" not found`); throw new NotFoundException(`Group with ID "${_group}" not found`);
} }
const saveResult = await this.storage.new( const saveResult = await this.storage.new(
data.get("fileName") as string, data.get("fileName") as string,
file, file,
_machineIds, machinesIds,
Boolean(data.get("isDocumentation")), Boolean(data.get("isDocumentation")),
); );
console.log(saveResult); console.log(saveResult);
@@ -149,13 +230,12 @@ export class FilesService {
.select() .select()
.from(FilesTypesTable) .from(FilesTypesTable)
.where(eq(FilesTypesTable.mime, saveResult.fileType.mime)); .where(eq(FilesTypesTable.mime, saveResult.fileType.mime));
console.log(mimeId);
const inserted = await this.database const inserted = await this.database
.use() .use()
.insert(FilesTable) .insert(FilesTable)
.values({ .values({
fileName: data.get("fileName") as string, fileName: data.get("fileName") as string,
groupId: groupExists[0].uuid,
checksum: saveResult.fileChecksum, checksum: saveResult.fileChecksum,
extension: saveResult.fileType.extension, extension: saveResult.fileType.extension,
fileSize: saveResult.fileSize, fileSize: saveResult.fileSize,
@@ -166,9 +246,21 @@ export class FilesService {
}) })
.returning(); .returning();
console.log(inserted); console.log(inserted);
if (_group) {
console.log("Adding group ip to file..");
await this.database
.use()
.update(FilesTable)
// @ts-ignore TODO FIX
.set({ groupId: groupExists[0].uuid })
.where(eq(FilesTable.uuid, inserted[0].uuid))
.prepare("addGroupToFile")
.execute();
}
console.log("File insertion done");
for (const machineId of machinesIds) { for (const machineId of machinesIds) {
//TODO insert a link betwen fileId and MachineIds[]
console.log( console.log(
`Append file ${inserted[0].fileName} for machine : "${machineId}"`, `Append file ${inserted[0].fileName} for machine : "${machineId}"`,
); );
@@ -182,4 +274,103 @@ export class FilesService {
} }
return inserted[0]; return inserted[0];
} }
/**
* Creates a new file type in the database.
*
* @param {string} name - The name of the file type.
* @param {string} mime - The MIME type for the file type, expected in `type/subtype` format.
* @return {Promise<Object>} A promise that resolves to the created file type object.
* @throws {BadRequestException} If the MIME type format is invalid.
* @throws {InternalServerErrorException} If an error occurs during the database operation.
*/
public async createFileType(name: string, mime: string) {
if (!/^[\w-]+\/[\w-]+$/.test(mime)) {
throw new BadRequestException("Invalid MIME type format");
}
try {
return await this.database
.use()
.insert(FilesTypesTable)
.values({
typeName: name,
mime: mime,
})
.returning()
.prepare("createFileType")
.execute();
} catch (e) {
console.error(e);
throw new InternalServerErrorException(
"An error occured while creating the file type",
);
}
}
/**
* Retrieves all file types from the database.
*
* @return {Promise<Array>} Promise that resolves to an array of file types.
*/
public async getAllFilesTypes(): Promise<Array<object>> {
const result = await this.database
.use()
.select()
.from(FilesTypesTable)
.prepare("getAllFilesTypes")
.execute();
console.log(result);
return result;
}
/**
* Removes a file type by its ID if it is not in use by any files or machines.
*
* @param {string} fileTypeId - The ID of the file type to be removed.
* @return {Promise<object>} - A promise that resolves to the result of the deletion operation.
* @throws {BadRequestException} - If the file type is in use by any files or machines.
* @throws {InternalServerErrorException} - If an error occurs during the deletion process.
*/
public async removeFileType(fileTypeId: string): Promise<object> {
const fileUsingFileType = await this.database
.use()
.select()
.from(FilesTable)
.where(eq(FilesTable.fileType, fileTypeId))
.prepare("checkFileTypeInUseInFiles")
.execute();
if (fileUsingFileType.length > 0) {
throw new BadRequestException(
"This file type is in use by some files and cannot be deleted.",
);
}
const machinesUsingFileType = await this.database
.use()
.select()
.from(FilesTypeForMachine)
.where(eq(FilesTypeForMachine.fileTypeId, fileTypeId))
.prepare("getMachineByFileTypeId")
.execute();
if (machinesUsingFileType.length > 0) {
throw new BadRequestException(
"This file type is in use by some machines and cannot be deleted until there is no machine using this fileType.",
);
}
try {
return await this.database
.use()
.delete(FilesTypesTable)
.where(eq(FilesTypesTable.id, fileTypeId))
.returning()
.prepare("deleteFileTypeById")
.execute();
} catch (e) {
throw new InternalServerErrorException(
"An error occurred while deleting a file type",
);
}
}
} }

View File

@@ -10,11 +10,13 @@ import {
Query, Query,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { AdminGuard } from "apps/backend/src/app/auth/auth.guard"; import { AdminGuard } from "apps/backend/src/app/auth/auth.guard";
import { CreateGroupDto } from "apps/backend/src/app/groups/groups.dto"; import { CreateGroupDto } from "apps/backend/src/app/groups/groups.dto";
import { ISearchQuery } from "apps/backend/src/app/groups/groups.types"; import { ISearchQuery } from "apps/backend/src/app/groups/groups.types";
import { GroupsService } from "./groups.service"; import { GroupsService } from "./groups.service";
@ApiTags("File groups")
@Controller("groups") @Controller("groups")
export class GroupsController { export class GroupsController {
constructor(private readonly groupsService: GroupsService) {} constructor(private readonly groupsService: GroupsService) {}
@@ -24,15 +26,21 @@ export class GroupsController {
@Query("limit", new DefaultValuePipe(20), ParseIntPipe) limit: number, @Query("limit", new DefaultValuePipe(20), ParseIntPipe) limit: number,
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number, @Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
@Query("search", new DefaultValuePipe("")) search: string, @Query("search", new DefaultValuePipe("")) search: string,
) {} ) {
return await this.groupsService.getGroupsByName(limit, offset, search);
}
//TODO DTO
@Post("new") @Post("new")
async newGroup() {} async newGroup(@Body() body: CreateGroupDto) {
return await this.groupsService.newGroup(body.groupName);
}
@ApiBearerAuth()
@UseGuards(AdminGuard) @UseGuards(AdminGuard)
@Delete(":groupId") @Delete(":groupId")
async deleteGroup(@Param("groupId") groupId: string) {} async deleteGroup(@Param("groupId") groupId: string) {
return await this.groupsService.deleteGroup(groupId);
}
//TODO Patch //TODO Patch
@@ -42,5 +50,12 @@ export class GroupsController {
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number, @Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
@Query("search", new DefaultValuePipe("")) search: string, @Query("search", new DefaultValuePipe("")) search: string,
@Param("groupId") groupId: string, @Param("groupId") groupId: string,
) {} ) {
return await this.groupsService.findFilesForGroup(
limit,
offset,
search,
groupId,
);
}
} }

View File

@@ -1,6 +1,11 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsString, MaxLength, MinLength } from "class-validator"; import { IsString, MaxLength, MinLength } from "class-validator";
export class CreateGroupDto { export class CreateGroupDto {
@ApiProperty({
description: "Nom unique.",
example: "Numérique en communs",
})
@IsString() @IsString()
@MinLength(4) @MinLength(4)
@MaxLength(64) @MaxLength(64)

View File

@@ -1,13 +1,16 @@
import { Injectable } from "@nestjs/common"; import {
Injectable,
InternalServerErrorException,
NotFoundException,
} from "@nestjs/common";
import { DbService } from "apps/backend/src/app/db/db.service"; import { DbService } from "apps/backend/src/app/db/db.service";
import { FilesGroupTable } from "apps/backend/src/app/db/schema"; import { FilesGroupTable, FilesTable } from "apps/backend/src/app/db/schema";
import { ilike } from "drizzle-orm"; import { and, eq, ilike } from "drizzle-orm";
@Injectable() @Injectable()
export class GroupsService { export class GroupsService {
constructor(private readonly database: DbService) {} constructor(private readonly database: DbService) {}
//TODO a method to fetch groups in the database by a specific search with limit, offset and a search field (can be blank)
async getGroupsByName(limit: number, offset: number, search: string) { async getGroupsByName(limit: number, offset: number, search: string) {
return await this.database return await this.database
.use() .use()
@@ -20,9 +23,66 @@ export class GroupsService {
.execute(); .execute();
} }
//TODO The method to create a group async newGroup(groupName: string) {
return await this.database
.use()
.insert(FilesGroupTable)
.values({
groupName: groupName,
})
.returning()
.prepare("newGroup")
.execute();
}
//TODO a method to delete a group and place the associated file at a null group reference async deleteGroup(groupId: string) {
const groupInDb = await this.database
.use()
.select()
.from(FilesGroupTable)
.where(eq(FilesGroupTable.uuid, groupId))
.prepare("findGroupById")
.execute();
if (groupInDb.length === 0) throw new NotFoundException("Group not found");
// Replace entry by null
await this.database
.use()
.update(FilesTable)
// @ts-ignore
.set({ groupId: null })
.where(eq(FilesTable.groupId, groupId))
.prepare("updateFilesGroupReference")
.execute();
try {
await this.database
.use()
.delete(FilesGroupTable)
.where(eq(FilesGroupTable.uuid, groupId));
return true;
} catch (e) {
throw new InternalServerErrorException("Error while deleting group");
}
}
//TODO a method to get the files of a group in the database by a specific search with limit, offset and a search field (can be blank) async findFilesForGroup(
limit: number,
offset: number,
searchField: string,
groupId: string,
) {
return await this.database
.use()
.select()
.from(FilesTable)
.where(
and(
eq(FilesTable.groupId, groupId),
ilike(FilesTable.fileName, String(`%${searchField}%`)),
),
)
.limit(limit)
.offset(offset)
.prepare("findFilesInGroup")
.execute();
}
} }

View File

@@ -13,35 +13,66 @@ import {
Query, Query,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiResponse, ApiTags } from "@nestjs/swagger";
import { AdminGuard } from "apps/backend/src/app/auth/auth.guard"; import { AdminGuard } from "apps/backend/src/app/auth/auth.guard";
import { CreateMachineDto } from "apps/backend/src/app/machines/machines.dto"; import { IMachinesTable } from "apps/backend/src/app/db/schema";
import {
CreateMachineDto,
TypeDto,
} from "apps/backend/src/app/machines/machines.dto";
import { MachinesService } from "apps/backend/src/app/machines/machines.service"; import { MachinesService } from "apps/backend/src/app/machines/machines.service";
@ApiTags("Machines")
@Controller("machines") @Controller("machines")
export class MachinesController { export class MachinesController {
constructor(private readonly machineService: MachinesService) {} constructor(private readonly machineService: MachinesService) {}
@HttpCode(HttpStatus.OK)
@Get("find") @Get("find")
async findMany( async findMany(
@Query("limit", new DefaultValuePipe(20), ParseIntPipe) limit: number, @Query("limit", new DefaultValuePipe(20), ParseIntPipe) limit: number,
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number, @Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
@Query("search", new DefaultValuePipe("")) search: string, @Query("search", new DefaultValuePipe("")) search: string,
) {} ) {
return await this.machineService.findMany(limit, offset, search);
}
//TODO DTO
@UseGuards(AdminGuard) @UseGuards(AdminGuard)
@Post("new") @Post("new")
async newMachine(@Body() body: CreateMachineDto) { async newMachine(@Body() body: CreateMachineDto) {
return await this.machineService.create(body.machineName, body.machineType); return await this.machineService.create(body.machineName, body.machineType);
} }
@HttpCode(HttpStatus.ACCEPTED)
@UseGuards(AdminGuard) @UseGuards(AdminGuard)
@Delete(":machineId") @Delete(":machineId")
async deleteMachine(@Param("machineId") machineId: string) {} async deleteMachine(@Param("machineId") machineId: string) {}
//TODO Patch @HttpCode(HttpStatus.ACCEPTED)
@UseGuards(AdminGuard)
@Post("types/:machineId")
async addTypeToMachine(
@Param("machineId") machineId: string,
@Body() body: TypeDto,
) {
return await this.machineService.addFileType(machineId, body.fileTypeId);
}
//TODO CRUD fileType associated to machine @HttpCode(HttpStatus.ACCEPTED)
@UseGuards(AdminGuard)
@Delete("types/:machineId")
async remTypeToMachine(
@Param("machineId") machineId: string,
@Body() body: TypeDto,
) {
return await this.machineService.removeFileType(machineId, body.fileTypeId);
}
@HttpCode(HttpStatus.OK)
@Get("types/:machineId")
async getTypesOfMachine(@Param("machineId") machineId: string) {
return await this.machineService.getFilesTypes(machineId);
}
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Get("files/:machineId") @Get("files/:machineId")

View File

@@ -1,11 +1,28 @@
import { MaxLength, MinLength } from "class-validator"; import { ApiProperty } from "@nestjs/swagger";
import { IsUUID, MaxLength, MinLength } from "class-validator";
export class CreateMachineDto { export class CreateMachineDto {
@ApiProperty({
example: "Découpeuse laser portable",
})
@MaxLength(128) @MaxLength(128)
@MinLength(4) @MinLength(4)
machineName: string; machineName: string;
@ApiProperty({
example: "Découpe au laser",
})
@MaxLength(64) @MaxLength(64)
@MinLength(2) @MinLength(2)
machineType: string; machineType: string;
} }
export class TypeDto {
@ApiProperty({
description:
"Un identifiant unique présent en base de donnée qui représente un MIME",
example: "dfd0fbb1-2bf3-4dbe-b86d-89b1bff5106c",
})
@IsUUID()
fileTypeId: string;
}

View File

@@ -70,8 +70,6 @@ export class MachinesService {
} }
} }
//TODO a method to delete a machine and delete the associated FilesTypeForMachine row
/** /**
* Removes a specified file type from a machine. * Removes a specified file type from a machine.
* *
@@ -177,14 +175,15 @@ export class MachinesService {
} }
/** /**
* Finds files associated with a specific machine. * Finds files associated with a specific machine based on a search field,
* and returns a limited set of results with an offset for pagination.
* *
* @param {number} limit - The maximum number of files to return. * @param {number} limit - The maximum number of files to return.
* @param {number} offset - The number of files to skip before starting to return results. * @param {number} offset - The offset for pagination.
* @param {string} searchField - The field to search within for files. * @param {string} searchField - The search query to filter files by name.
* @param {string} machineId - The ID of the machine to find files for. * @param {string} machineId - The unique identifier of the machine to find files for.
* @returns {Promise<Array<Object>>} A promise that resolves to an array of files associated with the specified machine. * @returns {Promise<Array>} A promise that resolves to an array of files associated with the specified machine.
* @throws {NotFoundException} If the machine ID is not found. * @throws {NotFoundException} If the specified machine id is not found.
*/ */
async findFilesForMachine( async findFilesForMachine(
limit: number, limit: number,
@@ -212,7 +211,13 @@ export class MachinesService {
.where(eq(FilesForMachinesTable.machineId, machineId)) .where(eq(FilesForMachinesTable.machineId, machineId))
.limit(limit) .limit(limit)
.offset(offset) .offset(offset)
.leftJoin(FilesTable, eq(FilesTable.uuid, FilesForMachinesTable.fileId)) .leftJoin(
FilesTable,
and(
eq(FilesTable.uuid, FilesForMachinesTable.fileId),
ilike(FilesTable.fileName, String(`%${searchField}%`)),
),
)
.prepare("findFilesForMachineId") .prepare("findFilesForMachineId")
.execute(); .execute();
} }

View File

@@ -1,6 +1,5 @@
import * as console from "node:console";
import * as crypto from "node:crypto"; import * as crypto from "node:crypto";
import { readFile, writeFile } from "node:fs/promises"; import { readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { import {
BadRequestException, BadRequestException,
@@ -43,23 +42,37 @@ export class StorageService {
} }
/** /**
* Checks if the current MIME type and file size meet the specified conditions. * Checks the conditions for the provided file and machine IDs.
* @param {Array<string>} machineIds - The IDs of the associated machines. *
* @param {Buffer} file - The file to check. * @param {Set<string>} machinesIds - A set containing machine identifiers.
* @return {Promise<boolean>} - A Promise that resolves to true if the conditions are met, false otherwise. * @param {Buffer} file - The file buffer that needs to be checked.
* @return {Promise<boolean>} Returns a promise that resolves to true if the conditions are met, otherwise it throws an exception.
* @throws {BadRequestException} If the file size exceeds the allowed maximum size or the file MIME type is not allowed.
*/ */
private async checkConditions( private async checkConditions(
machineIds: Array<string>, machinesIds: Set<string>,
file: Buffer, file: Buffer,
): Promise<boolean> { ): Promise<boolean> {
/** /**
* Checks if the current MIME type is allowed based on the given set of allowed MIME types. * Checks if the given MIME type is present in all machines' MIME type sets.
* @param {Set<string>} allowedMime - The set of allowed MIME types. *
* @param {string} currentMime - The current MIME type to check. * @param {Map<string, Set<string>>} mimesForMachines - A map where the key is the machine identifier and the value is a set of MIME types supported by that machine.
* @return {boolean} - True if the current MIME type is allowed, false otherwise. * @param {string} currentMime - The MIME type to check for presence in all sets.
* @return {boolean} Returns true if the MIME type is found in all sets, otherwise false.
*/ */
function checkMime(allowedMime: Set<string>, currentMime: string): boolean { function checkMime(
return allowedMime.has(currentMime); mimesForMachines: Map<string, Set<string>>,
currentMime: string,
): boolean {
let notFoundCount = 0;
for (const mimesForMachine of mimesForMachines) {
console.log(mimesForMachine);
const [key, set] = mimesForMachine;
if (!set.has(currentMime)) {
notFoundCount++;
}
}
return notFoundCount === 0;
} }
const fileType = filetypeinfo(file); const fileType = filetypeinfo(file);
@@ -67,10 +80,16 @@ export class StorageService {
// Array of MIMEs with possible duplicate field // Array of MIMEs with possible duplicate field
const _mimes: Array<string> = []; const _mimes: Array<string> = [];
const machinesMap: Map<string, Set<string>> = new Map<
string,
Set<string>
>();
const mimeSet = new Set(_mimes);
// Fetching MIMEs for the associated machines // Fetching MIMEs for the associated machines
for (const machineId of machineIds) { for (const machineId of machinesIds) {
console.debug(`Fetching mimeTypes for machine : ${machineId}`); console.debug(`Fetching mimeTypes for machine : ${machineId}`);
// Get MIMEs associated to a machine // Get MIMEs associated to a machine
/*
const allowedMimeId = this.dbService const allowedMimeId = this.dbService
.use() .use()
.select() .select()
@@ -87,15 +106,31 @@ export class StorageService {
.leftJoin( .leftJoin(
allowedMimeId, allowedMimeId,
eq(FilesTypesTable.id, allowedMimeId.fileTypeId), eq(FilesTypesTable.id, allowedMimeId.fileTypeId),
);*/
const _allowedMime = await this.dbService
.use()
.select()
.from(FilesTypeForMachine)
.where(eq(FilesTypeForMachine.machineId, machineId))
.leftJoin(
FilesTypesTable,
eq(FilesTypesTable.id, FilesTypeForMachine.fileTypeId),
); );
console.log(_allowedMime);
console.debug(`Total : ${_allowedMime.length}`); console.debug(`Total : ${_allowedMime.length}`);
// Append each MIME of a machine // Append each MIME of a machine
const tempSet = new Set<string>();
for (const allowedMimeElement of _allowedMime) { for (const allowedMimeElement of _allowedMime) {
_mimes.push(allowedMimeElement.slug); console.debug(
`Adding ${allowedMimeElement.f_types.mime} for verification..`,
);
tempSet.add(allowedMimeElement.f_types.mime);
mimeSet.add(allowedMimeElement.f_types.mime);
} }
machinesMap.set(machineId, tempSet);
} }
//Store the MIMEs without duplicate //Store the MIMEs without duplicate
const mimeSet = new Set(_mimes);
console.debug(`Indexed ${mimeSet.size} unique mimeTypes`); console.debug(`Indexed ${mimeSet.size} unique mimeTypes`);
//check file size is less than 2mb //check file size is less than 2mb
@@ -107,7 +142,7 @@ export class StorageService {
}); });
} }
if (!checkMime(mimeSet, fileType[0].mime)) if (!checkMime(machinesMap, fileType[0].mime))
throw new BadRequestException({ throw new BadRequestException({
cause: "MIME type", cause: "MIME type",
description: `Invalid MIME type. Allowed MIME types are: ${[...mimeSet].join(", ")}.`, description: `Invalid MIME type. Allowed MIME types are: ${[...mimeSet].join(", ")}.`,
@@ -218,7 +253,7 @@ export class StorageService {
public async new( public async new(
fileDisplayName: string, fileDisplayName: string,
file: Buffer, file: Buffer,
machinesId: Array<string>, machinesId: Set<string>,
isDocumentation?: boolean, isDocumentation?: boolean,
): Promise<IFileInformation> { ): Promise<IFileInformation> {
try { try {
@@ -228,7 +263,7 @@ export class StorageService {
isDocumentation, isDocumentation,
); );
console.log( console.log(
`Trying to append a new file : "${info.fileDisplayName}"...\n > Checksum SHA-256 : ${info.fileChecksum}\n > Size : ${info.fileSize / (1024 * 1024)}Mio\n > File format : ${info.fileType.mime}\n`, `Trying to append a new file : "${info.fileDisplayName}"...\n > Checksum SHA-256 : ${info.fileChecksum}\n > Size : ${(info.fileSize / (1024 * 1024)).toFixed(6)} Mio\n > File format : ${info.fileType.mime}\n`,
); );
const condition = await this.checkConditions(machinesId, file); const condition = await this.checkConditions(machinesId, file);
if (!condition) { if (!condition) {
@@ -253,4 +288,29 @@ export class StorageService {
throw err; throw err;
} }
} }
/**
* Deletes a file from the storage.
*
* @param {string} checksum - The checksum of the file to delete.
* @param {string} extension - The extension of the file to delete.
* @param {boolean} isDocumentation - A flag indicating whether the file is a documentation file.
* @return {Promise<void>} A promise that resolves when the file is successfully deleted.
* @throws {NotFoundException} Throws an exception if the file cannot be found.
*/
public async delete(
checksum: string,
extension: string,
isDocumentation: boolean,
) {
try {
const fileName = `${isDocumentation ? "doc" : "file"}-${checksum}.${extension.toLowerCase()}`;
console.log(`Deleting file "${fileName}" from storage...`);
await rm(join(process.cwd(), "assets/", fileName));
console.log(`File "${fileName}" deleted successfully.`);
} catch (err) {
console.log("File not found.");
throw new NotFoundException(err);
}
}
} }

View File

@@ -1,20 +1,40 @@
/**
* This is not a production server yet!
* This is only a minimal backend to get started.
*/
import { Logger } from "@nestjs/common"; import { Logger } from "@nestjs/common";
import { NestFactory } from "@nestjs/core"; import { NestFactory } from "@nestjs/core";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import cors from "cors";
import helmet from "helmet"; import helmet from "helmet";
import { AppModule } from "./app/app.module"; import { AppModule } from "./app/app.module";
async function bootstrap() { async function bootstrap() {
const config = new DocumentBuilder()
.setTitle("Fab Explorer")
.setDescription("Définition de l'api du FabLab Explorer")
.setVersion("1.0")
.addBearerAuth({
type: "http",
scheme: "bearer",
in: "header",
})
.build();
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
const globalPrefix = "api"; const globalPrefix = "api";
app.setGlobalPrefix(globalPrefix); app.setGlobalPrefix(globalPrefix);
app.use(helmet()); app.use(helmet());
const port = process.env.PORT || 3000; const port = process.env.PORT || 3333;
const corsOptions = {
origin: "http://localhost:3000",
methods: ["GET", "POST", "PUT", "DELETE"],
};
app.use(cors(corsOptions));
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("api", app, document);
await app.listen(port); await app.listen(port);
Logger.log( Logger.log(
`🚀 Application is running on: http://localhost:${port}/${globalPrefix}`, `🚀 Application is running on: http://localhost:${port}/${globalPrefix}`,

27
apps/frontend/Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
# This template uses Automatically Copying Traced Files feature
# so you need to setup your Next Config file to use `output: 'standalone'`
# Please read this for more information https://nextjs.org/docs/pages/api-reference/next-config-js/output
# Production image, copy all the files and run next
FROM docker.io/node:lts-alpine AS runner
RUN apk add --no-cache dumb-init
ENV NODE_ENV=production
ENV PORT=3000
WORKDIR /usr/src/app
COPY apps/frontend/next.config.js ./
COPY apps/frontend/public ./public
COPY apps/frontend/.next/standalone/apps/frontend ./
COPY apps/frontend/.next/standalone/package.json ./
COPY apps/frontend/.next/standalone/node_modules ./node_modules
COPY apps/frontend/.next/static ./.next/static
# RUN npm i sharp
RUN chown -R node:node .
USER node
EXPOSE 3000
# COPY --chown=node:node ./tools/scripts/entrypoints/api.sh /usr/local/bin/docker-entrypoint.sh
# ENTRYPOINT [ "docker-entrypoint.sh" ]
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry.
ENV NEXT_TELEMETRY_DISABLED=1
CMD ["dumb-init", "node", "server.js"]

View File

@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information. // see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

View File

@@ -4,6 +4,24 @@
"sourceRoot": "apps/frontend", "sourceRoot": "apps/frontend",
"projectType": "application", "projectType": "application",
"tags": [], "tags": [],
"// targets": "to see all targets run: nx show project frontend --web", "targets": {
"targets": {} "container": {
"executor": "@nx-tools/nx-container:build",
"dependsOn": ["build"],
"options": {
"engine": "docker",
"metadata": {
"images": ["frontend"],
"load": true,
"tags": [
"type=schedule",
"type=ref,event=branch",
"type=ref,event=tag",
"type=ref,event=pr",
"type=sha,prefix=sha-"
]
}
}
}
}
} }

View File

@@ -1,3 +0,0 @@
export async function GET(request: Request) {
return new Response("Hello, from API!");
}

View File

@@ -31,9 +31,9 @@
} }
.dark { .dark {
--background: 20 12.91% 14%; --background: 20 14.3% 4.1%;
--foreground: 0 0% 92.55%; --foreground: 0 0% 92.55%;
--muted: 12 6.83% 26.64%; --muted: 12 6.5% 15.1%;
--muted-foreground: 24 5.4% 63.9%; --muted-foreground: 24 5.4% 63.9%;
--popover: 20 14.3% 4.1%; --popover: 20 14.3% 4.1%;
--popover-foreground: 60 9.1% 97.8%; --popover-foreground: 60 9.1% 97.8%;
@@ -41,13 +41,13 @@
--card-foreground: 0 0% 92.55%; --card-foreground: 0 0% 92.55%;
--border: 12 6.19% 17.63%; --border: 12 6.19% 17.63%;
--input: 12 6.5% 15.1%; --input: 12 6.5% 15.1%;
--primary: 60 96.56% 44.61%; --primary: 60 96.08% 41.3%;
--primary-foreground: 26 90.03% 12.55%; --primary-foreground: 26 90.03% 12.55%;
--secondary: 12 26.8% 16.18%; --secondary: 12 26.8% 16.18%;
--secondary-foreground: 0 0% 92.55%; --secondary-foreground: 0 0% 92.55%;
--accent: 12 5.7% 26.68%; --accent: 12 5.7% 26.68%;
--accent-foreground: 0 0% 92.55%; --accent-foreground: 0 0% 92.55%;
--destructive: 0 60.83% 54.18%; --destructive: 0 76.12% 38%;
--destructive-foreground: 0 0% 92.55%; --destructive-foreground: 0 0% 92.55%;
--ring: 47.95 95.82% 53.14%; --ring: 47.95 95.82% 53.14%;
} }

View File

@@ -8,11 +8,13 @@ export const metadata = {
description: 'Generated by create-nx-workspace', description: 'Generated by create-nx-workspace',
}; };
export default function RootLayout({ export default function RootLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<html lang="en"> <html lang="en">
<body className={"h-screen w-screen bg-card flex flex-col justify-between items-center police-ubuntu"}> <body className={"h-screen w-screen bg-card flex flex-col justify-between items-center police-ubuntu"}>

View File

@@ -1,53 +1,31 @@
"use client" "use client"
import { useState } from 'react'; import { Dispatch, SetStateAction, useState } from 'react';
import { Button } from '../components/ui/button'; import {
import { Home, NotepadTextDashed } from 'lucide-react'; ResizableHandle,
ResizablePanel,
ResizablePanelGroup
} from 'apps/frontend/src/components/ui/resizable';
import { ESubPage, SubPage, SubPageSelector } from '../components/sub-pages';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({})
export enum ESubPage {
Home,
Documentation,
}
export default function HomePage() { export default function HomePage() {
const [currentSubPage, setCurrentSubPage] = useState<ESubPage>(0) const [currentSubPage, setCurrentSubPage] = useState<ESubPage>(0)
return ( return (
<main className="w-full h-full bg-background border border-muted p-2 rounded-md flex flex-row justify-stretch items-stretch"> <QueryClientProvider client={queryClient}>
<div className={"w-1/5 h-full p-1 flex flex-col items-center"}> <main
<SubPageSelector/> className="w-full h-full bg-background border border-muted p-2 rounded-md flex flex-row justify-stretch items-stretch">
<div className={"h-full flex flex-col justify-start items-center"}>
<SubPageSelector
currentSubPage={currentSubPage}
setCurrentSubPage={setCurrentSubPage}
/>
</div> </div>
<SubPage currentSubPage={currentSubPage}/> <SubPage currentSubPage={currentSubPage} />
</main> </main>
</QueryClientProvider>
); );
} }
function SubPageSelector() {
return (
<div className={"w-full flex flex-col justify-center items-stretch pt-4 p-4 gap-2"}>
<Button className={"gap-1 font-bold"}>
<Home/>
Accueil
</Button>
<Button>
<NotepadTextDashed />
Documentation
</Button>
</div>
)
}
export interface ISubPageProps {
currentSubPage: ESubPage
}
function SubPage(props: ISubPageProps) {
switch (props.currentSubPage) {
case ESubPage.Home:
return (<>Home</>)
case ESubPage.Documentation:
return (<>Doc</>)
default:
return (<>Default</>)
}
}

View File

@@ -0,0 +1,329 @@
import Dropzone, { DropzoneProps, FileRejection } from 'react-dropzone';
import { toast } from 'sonner';
import React from 'react';
import { CrossIcon, FileTextIcon, UploadIcon } from 'lucide-react';
import { ScrollArea } from './ui/scroll-area';
import { Progress } from '@radix-ui/react-progress';
import { Button } from './ui/button';
import { cn, formatBytes } from '../lib/utils';
import { useControllableState } from '../hooks/use-controllable-state';
import Image from 'next/image';
interface FileUploaderProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Value of the uploader.
* @type File[]
* @default undefined
* @example value={files}
*/
value?: File[]
/**
* Function to be called when the value changes.
* @type (files: File[]) => void
* @default undefined
* @example onValueChange={(files) => setFiles(files)}
*/
onValueChange?: (files: File[]) => void
/**
* Function to be called when files are uploaded.
* @type (files: File[]) => Promise<void>
* @default undefined
* @example onUpload={(files) => uploadFiles(files)}
*/
onUpload?: (files: File[]) => Promise<void>
/**
* Progress of the uploaded files.
* @type Record<string, number> | undefined
* @default undefined
* @example progresses={{ "file1.png": 50 }}
*/
progresses?: Record<string, number>
/**
* Accepted file types for the uploader.
* @type { [key: string]: string[]}
* @default
* ```ts
* { "image/*": [] }
* ```
* @example accept={["image/png", "image/jpeg"]}
*/
accept?: DropzoneProps["accept"]
/**
* Maximum file size for the uploader.
* @type number | undefined
* @default 1024 * 1024 * 2 // 2MB
* @example maxSize={1024 * 1024 * 2} // 2MB
*/
maxSize?: DropzoneProps["maxSize"]
/**
* Maximum number of files for the uploader.
* @type number | undefined
* @default 1
* @example maxFileCount={4}
*/
maxFileCount?: DropzoneProps["maxFiles"]
/**
* Whether the uploader should accept multiple files.
* @type boolean
* @default false
* @example multiple
*/
multiple?: boolean
/**
* Whether the uploader is disabled.
* @type boolean
* @default false
* @example disabled
*/
disabled?: boolean
}
export function FileUploader(props: FileUploaderProps) {
const {
value: valueProp,
onValueChange,
onUpload,
progresses,
accept = {
"image/*": [],
},
maxSize = 1024 * 1024 * 2,
maxFileCount = 1,
multiple = false,
disabled = false,
className,
...dropzoneProps
} = props
const [files, setFiles] = useControllableState({
prop: valueProp,
onChange: onValueChange,
})
const onDrop = React.useCallback(
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
if (!multiple && maxFileCount === 1 && acceptedFiles.length > 1) {
toast.error("Cannot upload more than 1 file at a time")
return
}
if ((files?.length ?? 0) + acceptedFiles.length > maxFileCount) {
toast.error(`Cannot upload more than ${maxFileCount} files`)
return
}
const newFiles = acceptedFiles.map((file) =>
Object.assign(file, {
preview: URL.createObjectURL(file),
})
)
const updatedFiles = files ? [...files, ...newFiles] : newFiles
setFiles(updatedFiles)
if (rejectedFiles.length > 0) {
rejectedFiles.forEach(({ file }) => {
toast.error(`File ${file.name} was rejected`)
})
}
if (
onUpload &&
updatedFiles.length > 0 &&
updatedFiles.length <= maxFileCount
) {
const target =
updatedFiles.length > 0 ? `${updatedFiles.length} files` : `file`
toast.promise(onUpload(updatedFiles), {
loading: `Uploading ${target}...`,
success: () => {
setFiles([])
return `${target} uploaded`
},
error: `Failed to upload ${target}`,
})
}
},
[files, maxFileCount, multiple, onUpload, setFiles]
)
function onRemove(index: number) {
if (!files) return
const newFiles = files.filter((_, i) => i !== index)
setFiles(newFiles)
onValueChange?.(newFiles)
}
// Revoke preview url when component unmounts
React.useEffect(() => {
return () => {
if (!files) return
files.forEach((file) => {
if (isFileWithPreview(file)) {
URL.revokeObjectURL(file.preview)
}
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const isDisabled = disabled || (files?.length ?? 0) >= maxFileCount
return (
<div className="relative flex flex-col gap-6 overflow-hidden">
<Dropzone
onDrop={onDrop}
accept={accept}
maxSize={maxSize}
maxFiles={maxFileCount}
multiple={maxFileCount > 1 || multiple}
disabled={isDisabled}
>
{({ getRootProps, getInputProps, isDragActive }) => (
<div
{...getRootProps()}
className={cn(
"group relative grid h-52 w-full cursor-pointer place-items-center rounded-lg border-2 border-dashed border-muted-foreground/25 px-5 py-2.5 text-center transition hover:bg-muted/25",
"ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
isDragActive && "border-muted-foreground/50",
isDisabled && "pointer-events-none opacity-60",
className
)}
{...dropzoneProps}
>
<input {...getInputProps()} />
{isDragActive ? (
<div className="flex flex-col items-center justify-center gap-4 sm:px-5">
<div className="rounded-full border border-dashed p-3">
<UploadIcon
className="size-7 text-muted-foreground"
aria-hidden="true"
/>
</div>
<p className="font-medium text-muted-foreground">
Drop the files here
</p>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-4 sm:px-5">
<div className="rounded-full border border-dashed p-3">
<UploadIcon
className="size-7 text-muted-foreground"
aria-hidden="true"
/>
</div>
<div className="flex flex-col gap-px">
<p className="font-medium text-muted-foreground">
Drag {`'n'`} drop files here, or click to select files
</p>
<p className="text-sm text-muted-foreground/70">
You can upload
{maxFileCount > 1
? ` ${maxFileCount === Infinity ? "multiple" : maxFileCount}
files (up to ${formatBytes(maxSize)} each)`
: ` a file with ${formatBytes(maxSize)}`}
</p>
</div>
</div>
)}
</div>
)}
</Dropzone>
{files?.length ? (
<ScrollArea className="h-fit w-full px-3">
<div className="flex max-h-48 flex-col gap-4">
{files?.map((file, index) => (
<FileCard
key={index}
file={file}
onRemove={() => onRemove(index)}
progress={progresses?.[file.name]}
/>
))}
</div>
</ScrollArea>
) : null}
</div>
)
}
interface FileCardProps {
file: File
onRemove: () => void
progress?: number
}
function FileCard({ file, progress, onRemove }: FileCardProps) {
return (
<div className="relative flex items-center gap-2.5">
<div className="flex flex-1 gap-2.5">
{isFileWithPreview(file) ? <FilePreview file={file} /> : null}
<div className="flex w-full flex-col gap-2">
<div className="flex flex-col gap-px">
<p className="line-clamp-1 text-sm font-medium text-foreground/80">
{file.name}
</p>
<p className="text-xs text-muted-foreground">
{formatBytes(file.size)}
</p>
</div>
{progress ? <Progress value={progress} /> : null}
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="icon"
className="size-7"
onClick={onRemove}
>
<CrossIcon className="size-4" aria-hidden="true" />
<span className="sr-only">Remove file</span>
</Button>
</div>
</div>
)
}
function isFileWithPreview(file: File): file is File & { preview: string } {
return "preview" in file && typeof file.preview === "string"
}
interface FilePreviewProps {
file: File & { preview: string }
}
function FilePreview({ file }: FilePreviewProps) {
if (file.type.startsWith("image/")) {
return (
<Image
src={file.preview}
alt={file.name}
width={48}
height={48}
loading="lazy"
className="aspect-square shrink-0 rounded-md object-cover"
/>
)
}
return (
<FileTextIcon
className="size-10 text-muted-foreground"
aria-hidden="true"
/>
)
}

View File

@@ -0,0 +1,154 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { Button } from "../ui/button"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../ui/form"
import { Input } from "../ui/input"
import { FilesApi } from 'apps/frontend/src/requests/files';
import {
MultipleMachinesSelector
} from 'apps/frontend/src/components/forms/machines-selector';
import MultipleSelector, { Option } from 'apps/frontend/src/components/ui/multiple-selector';
import { MachinesApi } from 'apps/frontend/src/requests/machines';
import { Loader } from 'lucide-react';
import React from 'react';
async function getMachines(value: string): Promise<Option[]> {
try {
const machines = await MachinesApi.get.all();
console.log(machines.length);
const filtered = machines.filter((machine) => machine.machineName && machine.machineName.toLowerCase().includes(value.toLowerCase()));
console.log(filtered.length);
const mapped = filtered.map((machine) => ({
label: machine.machineName,
value: machine.id,
}));
return mapped;
} catch (error) {
console.error('Erreur lors de la récupération des machines:', error);
return [];
}
}
const machinesSchema = z.object({
label: z.string(),
value: z.string(),
disable: z.boolean().optional(),
});
const fileUploadSchema = z.object({
fileName: z.string().min(2, {
message: "Le nom du fichier doit faire au moins faire 2 carractères.",
}).max(128, {
message: "Le nom du fichier ne peux pas faire plus de 128 carractères."
}),
author: z.string().min(2, {
message: "Votre pseudonyme doit faire au moins faire 2 carractères."
}).max(64, {
message: "Votre pseudonyme ne peux pas faire plus de 64 carractères."
}),
machinesId: z.array(machinesSchema).min(1, {
message: "Vous devez indiqué au moins une machine."
})
})
export function FileUploadForm() {
const executeUpload = FilesApi.post.upload;
const form = useForm<z.infer<typeof fileUploadSchema>>({
resolver: zodResolver(fileUploadSchema),
})
function onSubmit(data: z.infer<typeof fileUploadSchema>) {
console.log(data)
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="w-2/3 space-y-6">
<FormField
control={form.control}
name="fileName"
render={({ field }) => (
<FormItem>
<FormLabel>Nom du fichier</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
Le nom qui sera affiché lors d'une recherche.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="machinesId"
render={({ field }) => (
<FormItem>
<FormLabel>Associer à des machines</FormLabel>
<FormControl>
<MultipleSelector
{...field}
onSearch={async (value) => {
const res = await getMachines(value);
return res;
}}
triggerSearchOnFocus
placeholder="Cliquez pour chercher."
loadingIndicator={
<div>
<Loader className={"animate-spin h-4 w-4 mr-2"} />
<p className="py-2 text-center text-lg leading-10 text-muted-foreground">Chargement...</p>
</div>
}
emptyIndicator={
<p
className="w-full text-center text-lg leading-10 text-muted-foreground">
Auccun résultats
</p>
}
/>
</FormControl>
<FormDescription>
Machine(s) qui seront associé(s) à ce fichier.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="author"
render={({ field }) => (
<FormItem>
<FormLabel>Votre pseudonyme ou nom.</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
Votre nom d'affichage qui sera lié au fichier.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
)
}

View File

@@ -0,0 +1,50 @@
'use client';
import React from 'react';
import MultipleSelector, { Option } from '../ui/multiple-selector';
import { MachinesApi } from '../../requests/machines';
import { Loader } from 'lucide-react';
import IntrinsicAttributes = React.JSX.IntrinsicAttributes;
async function getMachines(value: string): Promise<Option[]> {
try {
const machines = await MachinesApi.get.all();
console.log(machines.length);
const filtered = machines.filter((machine) => machine.machineName && machine.machineName.toLowerCase().includes(value.toLowerCase()));
console.log(filtered.length);
const mapped = filtered.map((machine) => ({
label: machine.machineName,
value: machine.id,
}));
return mapped;
} catch (error) {
console.error('Erreur lors de la récupération des machines:', error);
return [];
}
}
export function MultipleMachinesSelector(fields: IntrinsicAttributes) {
return (<MultipleSelector
onSearch={async (value) => {
const res = await getMachines(value);
return res;
}}
triggerSearchOnFocus
placeholder="Cliquez pour chercher."
loadingIndicator={
<div>
<Loader className={"animate-spin h-4 w-4 mr-2"} />
<p className="py-2 text-center text-lg leading-10 text-muted-foreground">Chargement...</p>
</div>
}
emptyIndicator={
<p
className="w-full text-center text-lg leading-10 text-muted-foreground">
Auccun résultats
</p>
}
/>);
};

View File

@@ -0,0 +1,8 @@
import { Loader } from 'lucide-react';
export function LoadingSpinner() {
return (
<div className={"flex justify-center items-center gap-2 text-xl"}><Loader
className={"animate-spin w-10 h-10"} />Loading...</div>)
}

View File

@@ -0,0 +1,32 @@
import { Button } from './ui/button';
import {
Dialog,
DialogContent, DialogHeader,
DialogTrigger
} from './ui/dialog';
import { FileInput } from 'lucide-react';
import { FileUploadForm } from 'apps/frontend/src/components/forms/file-upload';
import {
MultipleMachinesSelector
} from 'apps/frontend/src/components/forms/machines-selector';
export interface NewFileModalProps {
classname?: string;
}
export function NewFileModal(props: NewFileModalProps) {
return (
<Dialog>
<DialogTrigger asChild>
<Button className={"flex gap-2 items-center bg-secondary text-secondary-foreground"}>
<FileInput />
Ajouter un fichier
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>Ajouter un fichier</DialogHeader>
<FileUploadForm/>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,56 @@
import { NewFileModal } from "../new-file-modal";
import { Button } from "../ui/button"
import { Home, NotepadTextDashed } from 'lucide-react';
import {
SubHomePage
} from './sub-home-page';
import { Dispatch, SetStateAction } from 'react';
import {
SubDocPage
} from 'apps/frontend/src/components/sub-pages/sub-doc-page';
export interface SubPageSelectorProps {
currentSubPage: ESubPage
setCurrentSubPage: Dispatch<SetStateAction<ESubPage>>
}
export enum ESubPage {
Home,
Documentation,
}
export function SubPageSelector(props: SubPageSelectorProps) {
return (
<div className={"max-[20%]: flex flex-col justify-center items-stretch pt-4 p-4 gap-2"}>
<Button
onClick={()=>props.setCurrentSubPage(ESubPage.Home)}
disabled={props.currentSubPage === ESubPage.Home}
className={"gap-1 font-bold"}>
<Home/>
Accueil
</Button>
<NewFileModal/>
<Button
onClick={()=>props.setCurrentSubPage(ESubPage.Documentation)}
disabled={props.currentSubPage === ESubPage.Documentation}>
<NotepadTextDashed />
Documentation
</Button>
</div>
)
}
export interface ISubPageProps {
currentSubPage: ESubPage
}
export function SubPage(props: ISubPageProps) {
switch (props.currentSubPage) {
case ESubPage.Home:
return (<SubHomePage/>)
case ESubPage.Documentation:
return (<SubDocPage/>)
default:
return (<>Default</>)
}
}

View File

@@ -0,0 +1,14 @@
import { useState } from 'react';
export interface SubHomePageProps {
}
export function SubDocPage(props: SubHomePageProps) {
const [isLoaded, setIsLoaded] = useState<boolean>(false);
return (<section className={"w-full h-full rounded bg-card flex flex-col"}>
<h1 className={"text-2xl m-2 font-bold"}>Documentations</h1>
</section>)
}

View File

@@ -0,0 +1,35 @@
import { useState } from 'react';
import { HomeIcon } from 'lucide-react';
import {
FilesDataTable, filesTableColumns
} from 'apps/frontend/src/components/tables/files-table';
import {
ISearchBarResult, SearchBar
} from 'apps/frontend/src/components/tables/search-bar';
export interface SubHomePageProps {
}
export function SubHomePage(props: SubHomePageProps) {
const [isLoaded, setIsLoaded] = useState<boolean>(false);
const [searchField, setSearchField] = useState<ISearchBarResult>({value: ""})
return (<section className={"w-full h-full rounded bg-card flex flex-col"}>
<div className={"flex flex-row justify-start items-center gap-2 m-2"}>
<HomeIcon
className={"w-8 h-8 text-secondary"}
/>
<h1 className={"text-2xl font-bold"}>Accueil</h1>
</div>
<div
className={"m-1 flex flex-col justify-start items-center w-5/6 self-center h-full"}>
<div className={"flex w-full justify-start items-center mb-4"}>
<SearchBar
stateSetter={setSearchField} />
</div>
<FilesDataTable columns={filesTableColumns} searchField={searchField} />
</div>
</section>)
}

View File

@@ -0,0 +1,244 @@
"use client"
import {
ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
PaginationState,
SortingState,
useReactTable
} from '@tanstack/react-table';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../ui/table"
import { Button } from '../ui/button';
import { Badge } from '../ui/badge'
import { ArrowUpDown, Clock, Download, Trash, TriangleAlert } from 'lucide-react';
import { Dispatch, SetStateAction, useMemo, useReducer, useState } from 'react';
import Link from 'next/link';
import { IFile } from '../../types/file';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { FilesApi } from 'apps/frontend/src/requests/files';
import { Alert, AlertDescription, AlertTitle } from '../ui/alert';
import { LoadingSpinner } from 'apps/frontend/src/components/loading-spinner';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion';
import {
ISearchBarResult,
SearchBar
} from 'apps/frontend/src/components/tables/search-bar';
import {
TablePagination
} from 'apps/frontend/src/components/tables/pagination';
function ContextButtonForFile() {
return (
<div className={"scale-75"}>
<Button variant={"destructive"}>
<Trash />
</Button>
</div>
);
}
export const filesTableColumns: ColumnDef<IFile>[] = [
{
accessorKey: "fileName",
header: ({ column }) => (
<div className={"flex justify-center items-center"}>
Nom du fichier
</div>
),
},
{
accessorKey: "uploadedBy",
header: ({ column }) => (
<div className={"flex justify-center items-center"}>
Autheur(s)
</div>
),
},
{
accessorKey: "uploadedAt",
header: ({ column }) => (
<Button
variant="ghost"
className={"flex w-full"}
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Ajouté le
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => {
const date = new Date(row.getValue("uploadedAt"));
const formatted = `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()} à ${date.getHours()}:${date.getMinutes()}`;
return (
<div className={"flex justify-center items-center"}>
<Badge variant="outline" className={"gap-1 flex w-fit items-center"}>
<Clock className={"w-4 h-4"} />
<p className={"font-light"}>
{formatted}
</p>
</Badge>
</div>
);
},
},
{
accessorKey: "extension",
header: ({ column }) => (
<div className={"flex justify-center items-center"}>
Extension du fichier
</div>
),
cell: ({ row }) => {
const extension = row.getValue("extension") as string;
return (
<div className={"flex justify-center items-center"}>
<code className={"bg-gray-300 p-1 px-2 rounded-full"}>{extension}</code>
</div>
);
},
},
{
id: "actions",
header: ({ column }) => (
<div className={"flex justify-center items-center"}>
Actions
</div>
),
cell: ({ row }) => {
const file = row.original;
return (
<div className={"flex gap"}>
<Button variant={"ghost"} asChild>
<Link href={`http://localhost:3333/api/files/${file.uuid}`}>
<Download />
Télécharger
</Link>
</Button>
<ContextButtonForFile />
</div>
);
},
},
];
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
searchField: ISearchBarResult
}
export function FilesDataTable<TData, TValue>({
columns,
searchField
}: DataTableProps<TData, TValue>) {
const rerender = useReducer(() => ({}), {})[1];
const [sorting, setSorting] = useState<SortingState>([]);
const [data, setData] = useState<TData[]>([]);
const [rowsCount, setRowsCount] = useState(0);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const queryResult = useQuery({
queryKey: ['files', pagination, searchField],
queryFn: async () => {
const response = await FilesApi.get.files(pagination, searchField.value);
setRowsCount(response.count);
setData(response.data as TData[]);
return response.data;
},
staleTime: 500,
placeholderData: keepPreviousData,
});
const { isPending, isError, error, isFetching, isPlaceholderData } = queryResult;
const table = useReactTable({
data,
columns,
pageCount: Math.ceil(rowsCount / pagination.pageSize),
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualPagination: true,
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
state: {
sorting,
pagination,
},
});
if (isError) {
return (
<Alert variant="destructive">
<TriangleAlert />
<AlertTitle>Erreur</AlertTitle>
<AlertDescription>
{error.message}
<Accordion type="single" collapsible>
<AccordionItem value="item-1">
<AccordionTrigger>Stack trace</AccordionTrigger>
<AccordionContent>
<code className="text-sm font-mono border-l-2 border-l-destructive h-fit">{error.stack}</code>
</AccordionContent>
</AccordionItem>
</Accordion>
</AlertDescription>
</Alert>
);
}
return (
<div className="w-full">
{isPending && isFetching && isPlaceholderData ? <LoadingSpinner/> : <Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder ? null : flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
Auccun résultat
</TableCell>
</TableRow>
)}
</TableBody>
</Table>}
<div className={"flex w-full justify-end items-center"}>
<TablePagination rowsCount={rowsCount} pagination={pagination} setPagination={setPagination}/>
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { PaginationState, Table } from '@tanstack/react-table';
import { Dispatch, SetStateAction } from 'react';
import { Button } from 'apps/frontend/src/components/ui/button';
export interface TablePaginationProps {
rowsCount: number;
pagination: PaginationState;
setPagination: Dispatch<SetStateAction<PaginationState>>
}
export function TablePagination(props: TablePaginationProps) {
const totalPages = Math.ceil(props.rowsCount / props.pagination.pageSize)
const currentPage = props.pagination.pageIndex
const isMonoPage = totalPages === 1
const hasNextPage = (props.pagination.pageIndex >= totalPages)
const hasPreviousPage = !(props.pagination.pageIndex === 0)
if (!props.rowsCount) return (<></>);
return (<div className="flex items-center justify-end gap-4 space-x-2 py-4">
{isMonoPage ? (<h2>Il n'y as qu'une seule page.</h2>) : (<h2>Page <em>{currentPage}</em> sur <em>{totalPages}</em></h2>)}
<div className={"flex gap-2 justify-center items-center"}>
<Button
variant="outline"
size="sm"
onClick={() => {
}}
disabled={!hasPreviousPage}
>
Page précédente
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
}}
disabled={!hasNextPage}
>
Page suivante
</Button>
</div>
</div>)
}

View File

@@ -0,0 +1,42 @@
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { LoadingSpinner } from 'apps/frontend/src/components/loading-spinner';
import { Loader } from 'lucide-react';
export interface SearchBarProps {
stateSetter: Dispatch<SetStateAction<ISearchBarResult>>;
}
export interface ISearchBarResult {
value: string;
}
export function SearchBar(props: SearchBarProps) {
const [inputValue, setInputValue] = useState('');
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
setIsLoading(true);
const timer = setTimeout(() => {
props.stateSetter({value: inputValue});
setIsLoading(false);
}, 750);
// Nettoyage du timer
return () => clearTimeout(timer);
}, [inputValue]);
return (<div className="flex gap-2">
<div className="grid w-full max-w-md items-center gap-1.5">
<Label htmlFor="searchFiled">Chercher un nom de fichier</Label>
<Input
type="search"
id="searchFiled"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Votre recherche..." />
</div>
{isLoading && <Loader className={"w-6 h-6 animate-spin"} />}
</div>)
}

View File

@@ -3,7 +3,7 @@
import * as React from "react" import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar" import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils"
const Avatar = React.forwardRef< const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>, React.ElementRef<typeof AvatarPrimitive.Root>,

View File

@@ -3,7 +3,7 @@
import * as React from "react" import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul" import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils"
const Drawer = ({ const Drawer = ({
shouldScaleBackground = true, shouldScaleBackground = true,

View File

@@ -12,8 +12,8 @@ import {
useFormContext, useFormContext,
} from "react-hook-form" } from "react-hook-form"
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils"
import { Label } from "@/components/ui/label" import { Label } from "./label"
const Form = FormProvider const Form = FormProvider

View File

@@ -0,0 +1,608 @@
'use client';
import { Command as CommandPrimitive, useCommandState } from 'cmdk';
import { X } from 'lucide-react';
import * as React from 'react';
import { forwardRef, useEffect } from 'react';
import { Badge } from './badge';
import { Command, CommandGroup, CommandItem, CommandList } from './command';
import { cn } from '../../lib/utils';
export interface Option {
value: string;
label: string;
disable?: boolean;
/** fixed option that can't be removed. */
fixed?: boolean;
/** Group the options by providing key. */
[key: string]: string | boolean | undefined;
}
interface GroupOption {
[key: string]: Option[];
}
interface MultipleSelectorProps {
value?: Option[];
defaultOptions?: Option[];
/** manually controlled options */
options?: Option[];
placeholder?: string;
/** Loading component. */
loadingIndicator?: React.ReactNode;
/** Empty component. */
emptyIndicator?: React.ReactNode;
/** Debounce time for async search. Only work with `onSearch`. */
delay?: number;
/**
* Only work with `onSearch` prop. Trigger search when `onFocus`.
* For example, when user click on the input, it will trigger the search to get initial options.
**/
triggerSearchOnFocus?: boolean;
/** async search */
onSearch?: (value: string) => Promise<Option[]>;
/**
* sync search. This search will not showing loadingIndicator.
* The rest props are the same as async search.
* i.e.: creatable, groupBy, delay.
**/
onSearchSync?: (value: string) => Option[];
onChange?: (options: Option[]) => void;
/** Limit the maximum number of selected options. */
maxSelected?: number;
/** When the number of selected options exceeds the limit, the onMaxSelected will be called. */
onMaxSelected?: (maxLimit: number) => void;
/** Hide the placeholder when there are options selected. */
hidePlaceholderWhenSelected?: boolean;
disabled?: boolean;
/** Group the options base on provided key. */
groupBy?: string;
className?: string;
badgeClassName?: string;
/**
* First item selected is a default behavior by cmdk. That is why the default is true.
* This is a workaround solution by add a dummy item.
*
* @reference: https://github.com/pacocoursey/cmdk/issues/171
*/
selectFirstItem?: boolean;
/** Allow user to create option when there is no option matched. */
creatable?: boolean;
/** Props of `Command` */
commandProps?: React.ComponentPropsWithoutRef<typeof Command>;
/** Props of `CommandInput` */
inputProps?: Omit<
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,
'value' | 'placeholder' | 'disabled'
>;
/** hide the clear all button. */
hideClearAllButton?: boolean;
}
export interface MultipleSelectorRef {
selectedValue: Option[];
input: HTMLInputElement;
focus: () => void;
reset: () => void;
}
export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
function transToGroupOption(options: Option[], groupBy?: string) {
if (options.length === 0) {
return {};
}
if (!groupBy) {
return {
'': options,
};
}
const groupOption: GroupOption = {};
options.forEach((option) => {
const key = (option[groupBy] as string) || '';
if (!groupOption[key]) {
groupOption[key] = [];
}
groupOption[key].push(option);
});
return groupOption;
}
function removePickedOption(groupOption: GroupOption, picked: Option[]) {
const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption;
for (const [key, value] of Object.entries(cloneOption)) {
cloneOption[key] = value.filter((val) => !picked.find((p) => p.value === val.value));
}
return cloneOption;
}
function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {
for (const [, value] of Object.entries(groupOption)) {
if (value.some((option) => targetOption.find((p) => p.value === option.value))) {
return true;
}
}
return false;
}
/**
* The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly.
* So we create one and copy the `Empty` implementation from `cmdk`.
*
* @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607
**/
const CommandEmpty = forwardRef<
HTMLDivElement,
React.ComponentProps<typeof CommandPrimitive.Empty>
>(({ className, ...props }, forwardedRef) => {
const render = useCommandState((state) => state.filtered.count === 0);
if (!render) return null;
return (
<div
ref={forwardedRef}
className={cn('py-6 text-center text-sm', className)}
cmdk-empty=""
role="presentation"
{...props}
/>
);
});
CommandEmpty.displayName = 'CommandEmpty';
const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorProps>(
(
{
value,
onChange,
placeholder,
defaultOptions: arrayDefaultOptions = [],
options: arrayOptions,
delay,
onSearch,
onSearchSync,
loadingIndicator,
emptyIndicator,
maxSelected = Number.MAX_SAFE_INTEGER,
onMaxSelected,
hidePlaceholderWhenSelected,
disabled,
groupBy,
className,
badgeClassName,
selectFirstItem = true,
creatable = false,
triggerSearchOnFocus = false,
commandProps,
inputProps,
hideClearAllButton = false,
}: MultipleSelectorProps,
ref: React.Ref<MultipleSelectorRef>,
) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = React.useState(false);
const [onScrollbar, setOnScrollbar] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false);
const dropdownRef = React.useRef<HTMLDivElement>(null); // Added this
const [selected, setSelected] = React.useState<Option[]>(value || []);
const [options, setOptions] = React.useState<GroupOption>(
transToGroupOption(arrayDefaultOptions, groupBy),
);
const [inputValue, setInputValue] = React.useState('');
const debouncedSearchTerm = useDebounce(inputValue, delay || 500);
React.useImperativeHandle(
ref,
() => ({
selectedValue: [...selected],
input: inputRef.current as HTMLInputElement,
focus: () => inputRef?.current?.focus(),
reset: () => setSelected([])
}),
[selected],
);
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
setOpen(false);
inputRef.current.blur();
}
};
const handleUnselect = React.useCallback(
(option: Option) => {
const newOptions = selected.filter((s) => s.value !== option.value);
setSelected(newOptions);
onChange?.(newOptions);
},
[onChange, selected],
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const input = inputRef.current;
if (input) {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (input.value === '' && selected.length > 0) {
const lastSelectOption = selected[selected.length - 1];
// If last item is fixed, we should not remove it.
if (!lastSelectOption.fixed) {
handleUnselect(selected[selected.length - 1]);
}
}
}
// This is not a default behavior of the <input /> field
if (e.key === 'Escape') {
input.blur();
}
}
},
[handleUnselect, selected],
);
useEffect(() => {
if (open) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('touchend', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchend', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchend', handleClickOutside);
};
}, [open]);
useEffect(() => {
if (value) {
setSelected(value);
}
}, [value]);
useEffect(() => {
/** If `onSearch` is provided, do not trigger options updated. */
if (!arrayOptions || onSearch) {
return;
}
const newOption = transToGroupOption(arrayOptions || [], groupBy);
if (JSON.stringify(newOption) !== JSON.stringify(options)) {
setOptions(newOption);
}
}, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]);
useEffect(() => {
/** sync search */
const doSearchSync = () => {
const res = onSearchSync?.(debouncedSearchTerm);
setOptions(transToGroupOption(res || [], groupBy));
};
const exec = async () => {
if (!onSearchSync || !open) return;
if (triggerSearchOnFocus) {
doSearchSync();
}
if (debouncedSearchTerm) {
doSearchSync();
}
};
void exec();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
useEffect(() => {
/** async search */
const doSearch = async () => {
setIsLoading(true);
const res = await onSearch?.(debouncedSearchTerm);
setOptions(transToGroupOption(res || [], groupBy));
setIsLoading(false);
};
const exec = async () => {
if (!onSearch || !open) return;
if (triggerSearchOnFocus) {
await doSearch();
}
if (debouncedSearchTerm) {
await doSearch();
}
};
void exec();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
const CreatableItem = () => {
if (!creatable) return undefined;
if (
isOptionsExist(options, [{ value: inputValue, label: inputValue }]) ||
selected.find((s) => s.value === inputValue)
) {
return undefined;
}
const Item = (
<CommandItem
value={inputValue}
className="cursor-pointer"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={(value: string) => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue('');
const newOptions = [...selected, { value, label: value }];
setSelected(newOptions);
onChange?.(newOptions);
}}
>
{`Create "${inputValue}"`}
</CommandItem>
);
// For normal creatable
if (!onSearch && inputValue.length > 0) {
return Item;
}
// For async search creatable. avoid showing creatable item before loading at first.
if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {
return Item;
}
return undefined;
};
const EmptyItem = React.useCallback(() => {
if (!emptyIndicator) return undefined;
// For async search that showing emptyIndicator
if (onSearch && !creatable && Object.keys(options).length === 0) {
return (
<CommandItem value="-" disabled>
{emptyIndicator}
</CommandItem>
);
}
return <CommandEmpty>{emptyIndicator}</CommandEmpty>;
}, [creatable, emptyIndicator, onSearch, options]);
const selectables = React.useMemo<GroupOption>(
() => removePickedOption(options, selected),
[options, selected],
);
/** Avoid Creatable Selector freezing or lagging when paste a long string. */
const commandFilter = React.useCallback(() => {
if (commandProps?.filter) {
return commandProps.filter;
}
if (creatable) {
return (value: string, search: string) => {
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1;
};
}
// Using default filter in `cmdk`. We don't have to provide it.
return undefined;
}, [creatable, commandProps?.filter]);
return (
<Command
ref={dropdownRef}
{...commandProps}
onKeyDown={(e) => {
handleKeyDown(e);
commandProps?.onKeyDown?.(e);
}}
className={cn('h-auto overflow-visible bg-transparent', commandProps?.className)}
shouldFilter={
commandProps?.shouldFilter !== undefined ? commandProps.shouldFilter : !onSearch
} // When onSearch is provided, we don't want to filter the options. You can still override it.
filter={commandFilter()}
>
<div
className={cn(
'min-h-10 rounded-md border border-input text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2',
{
'px-3 py-2': selected.length !== 0,
'cursor-text': !disabled && selected.length !== 0,
},
className,
)}
onClick={() => {
if (disabled) return;
inputRef?.current?.focus();
}}
>
<div className="relative flex flex-wrap gap-1">
{selected.map((option) => {
return (
<Badge
key={option.value}
className={cn(
'data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground',
'data-[fixed]:bg-muted-foreground data-[fixed]:text-muted data-[fixed]:hover:bg-muted-foreground',
badgeClassName,
)}
data-fixed={option.fixed}
data-disabled={disabled || undefined}
>
{option.label}
<button
className={cn(
'ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2',
(disabled || option.fixed) && 'hidden',
)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleUnselect(option);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={() => handleUnselect(option)}
>
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
</button>
</Badge>
);
})}
{/* Avoid having the "Search" Icon */}
<CommandPrimitive.Input
{...inputProps}
ref={inputRef}
value={inputValue}
disabled={disabled}
onValueChange={(value) => {
setInputValue(value);
inputProps?.onValueChange?.(value);
}}
onBlur={(event) => {
if (!onScrollbar) {
setOpen(false);
}
inputProps?.onBlur?.(event);
}}
onFocus={(event) => {
setOpen(true);
triggerSearchOnFocus && onSearch?.(debouncedSearchTerm);
inputProps?.onFocus?.(event);
}}
placeholder={hidePlaceholderWhenSelected && selected.length !== 0 ? '' : placeholder}
className={cn(
'flex-1 bg-transparent outline-none placeholder:text-muted-foreground',
{
'w-full': hidePlaceholderWhenSelected,
'px-3 py-2': selected.length === 0,
'ml-1': selected.length !== 0,
},
inputProps?.className,
)}
/>
<button
type="button"
onClick={() => {
setSelected(selected.filter((s) => s.fixed));
onChange?.(selected.filter((s) => s.fixed));
}}
className={cn(
'absolute right-0 h-6 w-6 p-0',
(hideClearAllButton ||
disabled ||
selected.length < 1 ||
selected.filter((s) => s.fixed).length === selected.length) &&
'hidden',
)}
>
<X />
</button>
</div>
</div>
<div className="relative">
{open && (
<CommandList
className="absolute top-1 z-10 w-full rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in"
onMouseLeave={() => {
setOnScrollbar(false);
}}
onMouseEnter={() => {
setOnScrollbar(true);
}}
onMouseUp={() => {
inputRef?.current?.focus();
}}
>
{isLoading ? (
<>{loadingIndicator}</>
) : (
<>
{EmptyItem()}
{CreatableItem()}
{!selectFirstItem && <CommandItem value="-" className="hidden" />}
{Object.entries(selectables).map(([key, dropdowns]) => (
<CommandGroup key={key} heading={key} className="h-full overflow-auto">
<>
{dropdowns.map((option) => {
return (
<CommandItem
key={option.value}
value={option.value}
disabled={option.disable}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={() => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue('');
const newOptions = [...selected, option];
setSelected(newOptions);
onChange?.(newOptions);
}}
className={cn(
'cursor-pointer',
option.disable && 'cursor-default text-muted-foreground',
)}
>
{option.label}
</CommandItem>
);
})}
</>
</CommandGroup>
))}
</>
)}
</CommandList>
)}
</div>
</Command>
);
},
);
MultipleSelector.displayName = 'MultipleSelector';
export default MultipleSelector;

View File

@@ -4,7 +4,7 @@ import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select" import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react" import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils"
const Select = SelectPrimitive.Root const Select = SelectPrimitive.Root

View File

@@ -0,0 +1,27 @@
import * as React from "react"
/**
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/use-callback-ref/src/useCallbackRef.tsx
*/
/**
* A custom hook that converts a callback to a ref to avoid triggering re-renders when passed as a
* prop or avoid re-executing effects when passed as a dependency
*/
function useCallbackRef<T extends (...args: never[]) => unknown>(
callback: T | undefined
): T {
const callbackRef = React.useRef(callback)
React.useEffect(() => {
callbackRef.current = callback
})
// https://github.com/facebook/react/issues/19240
return React.useMemo(
() => ((...args) => callbackRef.current?.(...args)) as T,
[]
)
}
export { useCallbackRef }

View File

@@ -0,0 +1,69 @@
import * as React from "react"
import { useCallbackRef } from "../hooks/use-callback-ref"
import { useEffect } from 'react';
/**
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/use-controllable-state/src/useControllableState.tsx
*/
type UseControllableStateParams<T> = {
prop?: T | undefined
defaultProp?: T | undefined
onChange?: (state: T) => void
}
type SetStateFn<T> = (prevState?: T) => T
function useControllableState<T>({
prop,
defaultProp,
onChange = () => {},
}: UseControllableStateParams<T>) {
const [uncontrolledProp, setUncontrolledProp] = useUncontrolledState({
defaultProp,
onChange,
})
const isControlled = prop !== undefined
const value = isControlled ? prop : uncontrolledProp
const handleChange = useCallbackRef(onChange)
const setValue: React.Dispatch<React.SetStateAction<T | undefined>> =
React.useCallback(
(nextValue) => {
if (isControlled) {
const setter = nextValue as SetStateFn<T>
const value =
typeof nextValue === "function" ? setter(prop) : nextValue
if (value !== prop) handleChange(value as T)
} else {
setUncontrolledProp(nextValue)
}
},
[isControlled, prop, setUncontrolledProp, handleChange]
)
return [value, setValue] as const
}
function useUncontrolledState<T>({
defaultProp,
onChange,
}: Omit<UseControllableStateParams<T>, "prop">) {
const uncontrolledState = React.useState<T | undefined>(defaultProp)
const [value] = uncontrolledState
const prevValueRef = React.useRef(value)
const handleChange = useCallbackRef(onChange)
// @ts-ignore
useEffect(() => {
if (prevValueRef.current !== value) {
handleChange(value as T)
prevValueRef.current = value
}
}, [value, prevValueRef, handleChange])
return uncontrolledState
}
export { useControllableState }

View File

@@ -4,3 +4,42 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
export function formatBytes(
bytes: number,
opts: {
decimals?: number
sizeType?: "accurate" | "normal"
} = {}
) {
const { decimals = 0, sizeType = "normal" } = opts
const sizes = ["Octets", "Ko", "Mo", "Go", "To"]
const accurateSizes = ["Octets", "Kio", "Mio", "Gio", "Tio"]
if (bytes === 0) return "0 Byte"
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / (1024 ** i)).toFixed(decimals)} ${
sizeType === "accurate" ? accurateSizes[i] ?? "Bytest" : sizes[i] ?? "Bytes"
}`
}
/**
* Stole this from the @radix-ui/primitive
* @see https://github.com/radix-ui/primitives/blob/main/packages/core/primitive/src/primitive.tsx
*/
export function composeEventHandlers<E>(
originalEventHandler?: (event: E) => void,
ourEventHandler?: (event: E) => void,
{ checkForDefaultPrevented = true } = {}
) {
return function handleEvent(event: E) {
originalEventHandler?.(event)
if (
checkForDefaultPrevented === false ||
!(event as unknown as Event).defaultPrevented
) {
return ourEventHandler?.(event)
}
}
}

View File

@@ -0,0 +1,83 @@
import { PaginationState } from "@tanstack/react-table";
import { IFile, IFilesResponse } from "../types/file";
import axios from 'axios';
const API_URL = "http://localhost:3333/";
const PAGE_LIMIT = 10;
function getFullRoute(route: string) {
return API_URL + route;
}
/**
* Fetches a list of files from the server based on pagination and an optional search field.
*
* @param {PaginationState} pagination - The current state of pagination, including page size and page index.
* @param {string} [searchField] - An optional field used to filter the files by a search term.
* @return {Promise<IFilesResponse>} A promise that resolves to an IFilesResponse object containing the list of files.
*/
async function getFiles(
pagination: PaginationState,
searchField?: string,
): Promise<IFilesResponse> {
const offset = pagination.pageSize * pagination.pageIndex;
const res = await fetch(
`${getFullRoute("api/files/find")}?limit=${PAGE_LIMIT}&offset=${offset}${searchField ? `&search=${searchField}` : "&search="}`,
);
return res.json();
}
/**
* Uploads a file to the server with specified metadata.
*
* @param {File} file - The file to be uploaded.
* @param {string} fileName - The name to be assigned to the uploaded file.
* @param {string} uploadedBy - The identifier for the user uploading the file.
* @param {Array<string>} machineIds - An array of machine IDs associated with the file.
* @param {string|null} groupId - The group ID for grouping files, if applicable.
* @param {boolean} [isDocumentation=false] - Flag indicating if the file is documentation.
* @param {boolean} [isRestricted=false] - Flag indicating if the file is restricted.
* @return {Promise<Object>} A promise resolving to the server's response data.
*/
async function uploadFile(
file: File,
fileName: string,
uploadedBy: string,
machineIds: Array<string>,
groupId: string|null = null,
isDocumentation = false,
isRestricted = false
) {
const headers = {
"Content-Type": "application/octet-stream",
file_name: fileName,
uploaded_by: uploadedBy,
//machine_id: machineIds.join(","), // Assuming machine IDs should be a comma-separated string
machine_id: machineIds,
is_documentation: isDocumentation.toString(),
is_restricted: isRestricted.toString(),
};
if (groupId) {
// @ts-ignore
headers["group_id"] = groupId;
}
try {
const response = await axios.post(getFullRoute("api/files/new"), file, { headers });
return response.data;
} catch (error) {
console.error("Error uploading file:", error);
throw error;
}
}
export const FilesApi = {
get: {
files: getFiles,
},
post: {
upload: uploadFile,
}
};

View File

@@ -0,0 +1,33 @@
import { PaginationState } from "@tanstack/react-table";
import { IFile, IFilesResponse } from "../types/file";
import axios from 'axios';
import { IMachine } from 'apps/frontend/src/types/machine';
const API_URL = "http://localhost:3333/";
const PAGE_LIMIT = 10;
function getFullRoute(route: string) {
return API_URL + route;
}
/**
* Fetches a list of files from the server.
*
* @return {Promise<IMachine[]>} A promise that resolves to an IFilesResponse object containing the list of files.
*/
async function getMachines(): Promise<IMachine[]> {
const response = await axios.get<IMachine[]>(getFullRoute("api/machines/find"), {
params: {
limit: -1,
offset: 0,
},
});
return response.data;
}
export const MachinesApi = {
get: {
all: getMachines
}
}

View File

View File

@@ -0,0 +1,20 @@
export type IFile = {
uuid: string;
fileName: string;
checksum: string;
extension: string;
groupId: string | null;
fileSize: number;
fileType: string;
isRestricted: boolean;
isDocumentation: boolean;
uploadedAt: string;
uploadedBy: string;
};
export interface IFilesResponse {
count: number;
limit: number;
currentOffset: number;
data: Array<IFile>;
}

View File

@@ -0,0 +1,5 @@
export type IMachine = {
id: string;
machineName: string;
machineType: string;
}

View File

@@ -5,7 +5,7 @@
}, },
"files": { "files": {
"include": [ "include": [
"./apps/**/*.ts" "./apps/**/src/**/*.ts"
] ]
}, },
"vcs": { "vcs": {

View File

@@ -4,6 +4,7 @@
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"check": "biome check --skip-errors --apply apps", "check": "biome check --skip-errors --apply apps",
"fix": "biome check --skip-errors --write --unsafe apps",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio" "db:studio": "drizzle-kit studio"
@@ -11,11 +12,13 @@
"private": true, "private": true,
"dependencies": { "dependencies": {
"@fontsource/ubuntu": "^5.1.0", "@fontsource/ubuntu": "^5.1.0",
"@nestjs/common": "^10.4.4", "@hookform/resolvers": "^3.9.0",
"@nestjs/common": "^10.4.5",
"@nestjs/config": "^3.2.3", "@nestjs/config": "^3.2.3",
"@nestjs/core": "^10.4.4", "@nestjs/core": "^10.4.5",
"@nestjs/mapped-types": "*", "@nestjs/mapped-types": "*",
"@nestjs/platform-express": "^10.4.4", "@nestjs/platform-express": "^10.4.5",
"@nestjs/swagger": "^7.4.2",
"@nestjs/throttler": "^6.2.1", "@nestjs/throttler": "^6.2.1",
"@radix-ui/react-accordion": "^1.2.1", "@radix-ui/react-accordion": "^1.2.1",
"@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.1.2",
@@ -34,6 +37,7 @@
"@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-radio-group": "^1.2.1", "@radix-ui/react-radio-group": "^1.2.1",
"@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.1", "@radix-ui/react-slider": "^1.2.1",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
@@ -43,6 +47,8 @@
"@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.3",
"@tanstack/react-query": "^5.59.15",
"@tanstack/react-table": "^8.20.5",
"argon2": "^0.41.1", "argon2": "^0.41.1",
"axios": "^1.7.7", "axios": "^1.7.7",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
@@ -50,43 +56,50 @@
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.0", "cmdk": "^1.0.0",
"cors": "^2.8.5",
"drizzle-kit": "^0.24.2", "drizzle-kit": "^0.24.2",
"drizzle-orm": "^0.33.0", "drizzle-orm": "^0.33.0",
"drizzle-zod": "^0.5.1", "drizzle-zod": "^0.5.1",
"embla-carousel-react": "^8.3.0", "embla-carousel-react": "^8.3.0",
"express": "^4.21.0", "express": "^4.21.1",
"file-type": "^19.5.0", "file-type": "^19.6.0",
"helmet": "^7.2.0", "helmet": "^7.2.0",
"input-otp": "^1.2.4", "input-otp": "^1.2.4",
"jose": "^5.9.3", "jose": "^5.9.4",
"lucide-react": "^0.429.0", "lucide-react": "^0.429.0",
"magic-bytes.js": "^1.10.0", "magic-bytes.js": "^1.10.0",
"next": "14.2.3", "next": "14.2.15",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"postgres": "^3.4.4", "postgres": "^3.4.4",
"react": "18.3.1", "react": "18.3.1",
"react-day-picker": "^9.1.3", "react-day-picker": "^9.1.4",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-dropzone": "^14.2.10",
"react-hook-form": "^7.53.1",
"react-resizable-panels": "^2.1.4", "react-resizable-panels": "^2.1.4",
"recharts": "^2.12.7", "recharts": "^2.13.0",
"reflect-metadata": "^0.1.14", "reflect-metadata": "^0.1.14",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sonner": "^1.5.0", "sonner": "^1.5.0",
"tailwind-merge": "^2.5.3", "tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"ts-mockito": "^2.6.1", "ts-mockito": "^2.6.1",
"tslib": "^2.7.0" "tslib": "^2.8.0",
"vaul": "^1.1.0",
"zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.9.3", "@biomejs/biome": "^1.9.3",
"@nestjs/schematics": "^10.1.4", "@nestjs/cli": "^10.4.5",
"@nestjs/testing": "^10.4.4", "@nestjs/schematics": "^10.2.0",
"@nestjs/testing": "^10.4.5",
"@nx-tools/nx-container": "^6.1.0",
"@nx/cypress": "19.6.1", "@nx/cypress": "19.6.1",
"@nx/eslint": "19.6.1", "@nx/eslint": "19.6.1",
"@nx/eslint-plugin": "19.6.1", "@nx/eslint-plugin": "19.6.1",
"@nx/jest": "19.6.1", "@nx/jest": "19.6.1",
"@nx/js": "19.6.1", "@nx/js": "19.6.1",
"@nx/nest": "^19.8.4", "@nx/nest": "^19.8.5",
"@nx/next": "19.6.1", "@nx/next": "19.6.1",
"@nx/node": "19.6.1", "@nx/node": "19.6.1",
"@nx/react": "19.6.1", "@nx/react": "19.6.1",
@@ -95,8 +108,9 @@
"@nx/workspace": "19.6.1", "@nx/workspace": "19.6.1",
"@swc-node/register": "~1.10.9", "@swc-node/register": "~1.10.9",
"@swc/cli": "~0.3.14", "@swc/cli": "~0.3.14",
"@swc/core": "~1.7.26", "@swc/core": "~1.7.36",
"@swc/helpers": "~0.5.13", "@swc/helpers": "~0.5.13",
"@types/cors": "^2.8.17",
"@types/jest": "^29.5.13", "@types/jest": "^29.5.13",
"@types/node": "18.16.9", "@types/node": "18.16.9",
"@types/react": "18.3.1", "@types/react": "18.3.1",
@@ -122,7 +136,7 @@
"tailwindcss": "3.4.3", "tailwindcss": "3.4.3",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",
"ts-node": "10.9.1", "ts-node": "10.9.1",
"typescript": "~5.6.2", "typescript": "~5.6.3",
"webpack-cli": "^5.1.4" "webpack-cli": "^5.1.4"
} }
} }

2368
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff