Compare commits
58 Commits
84d6743863
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
9c9caa409d
|
|||
|
bcf2f28a6b
|
|||
|
b512732a14
|
|||
|
3cd94e5999
|
|||
|
d5370220a4
|
|||
|
bfe49f65ec
|
|||
|
ee127f431c
|
|||
|
13c77bfc32
|
|||
|
2d6815efb6
|
|||
|
30706118a8
|
|||
|
b028fa653a
|
|||
|
1d550e29b1
|
|||
|
d9f0acac58
|
|||
|
4547a22f5c
|
|||
|
1020d6283d
|
|||
|
0330358139
|
|||
|
38634132ba
|
|||
|
7b4792b612
|
|||
|
989ec71e2e
|
|||
|
2f0c2f8b7c
|
|||
|
8b5a4640c1
|
|||
|
92bb6bf367
|
|||
|
4d6962afb5
|
|||
|
09ec8d683f
|
|||
|
4f40ef371c
|
|||
|
ed1defb1da
|
|||
|
3c31223293
|
|||
|
6f9d25a58b
|
|||
|
ff649ebdbf
|
|||
|
6f0f209e00
|
|||
|
18a5999334
|
|||
|
877e13043d
|
|||
|
6bb9510356
|
|||
|
a5f54e165b
|
|||
|
b29b188912
|
|||
|
c7e1a949a2
|
|||
|
16487e5985
|
|||
|
8ee5410c91
|
|||
|
e7830095b3
|
|||
|
055c48dbf9
|
|||
|
1c78912f99
|
|||
|
04cc2daf43
|
|||
|
fc2f437556
|
|||
|
1fc9185afc
|
|||
|
8686f0c27b
|
|||
|
79b2dec9e9
|
|||
|
3e6b2dc2bc
|
|||
|
04a37d19b7
|
|||
|
30c9c28e3d
|
|||
|
2ca13714a4
|
|||
|
356b6869ad
|
|||
|
030b5c814c
|
|||
|
16ed8d3420
|
|||
|
0874ffb835
|
|||
|
3d67b8ad18
|
|||
|
3bc440cbf8
|
|||
|
44dab5ba11
|
|||
|
7876bc2c38
|
@@ -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" });
|
||||||
|
|||||||
@@ -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
16
apps/backend/.swrc
Normal 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
21
apps/backend/Dockerfile
Normal 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"]
|
||||||
@@ -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-"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
27
apps/frontend/Dockerfile
Normal 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"]
|
||||||
2
apps/frontend/next-env.d.ts
vendored
2
apps/frontend/next-env.d.ts
vendored
@@ -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.
|
||||||
|
|||||||
@@ -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-"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export async function GET(request: Request) {
|
|
||||||
return new Response("Hello, from API!");
|
|
||||||
}
|
|
||||||
@@ -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%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}>
|
||||||
|
|||||||
@@ -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</>)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
329
apps/frontend/src/components/file-uploader.tsx
Normal file
329
apps/frontend/src/components/file-uploader.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
154
apps/frontend/src/components/forms/file-upload.tsx
Normal file
154
apps/frontend/src/components/forms/file-upload.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
50
apps/frontend/src/components/forms/machines-selector.tsx
Normal file
50
apps/frontend/src/components/forms/machines-selector.tsx
Normal 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>
|
||||||
|
}
|
||||||
|
/>);
|
||||||
|
};
|
||||||
8
apps/frontend/src/components/loading-spinner.tsx
Normal file
8
apps/frontend/src/components/loading-spinner.tsx
Normal 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>)
|
||||||
|
}
|
||||||
32
apps/frontend/src/components/new-file-modal.tsx
Normal file
32
apps/frontend/src/components/new-file-modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
56
apps/frontend/src/components/sub-pages/index.tsx
Normal file
56
apps/frontend/src/components/sub-pages/index.tsx
Normal 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</>)
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/frontend/src/components/sub-pages/sub-doc-page.tsx
Normal file
14
apps/frontend/src/components/sub-pages/sub-doc-page.tsx
Normal 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>)
|
||||||
|
}
|
||||||
35
apps/frontend/src/components/sub-pages/sub-home-page.tsx
Normal file
35
apps/frontend/src/components/sub-pages/sub-home-page.tsx
Normal 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>)
|
||||||
|
}
|
||||||
244
apps/frontend/src/components/tables/files-table.tsx
Normal file
244
apps/frontend/src/components/tables/files-table.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
apps/frontend/src/components/tables/pagination.tsx
Normal file
46
apps/frontend/src/components/tables/pagination.tsx
Normal 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>)
|
||||||
|
}
|
||||||
42
apps/frontend/src/components/tables/search-bar.tsx
Normal file
42
apps/frontend/src/components/tables/search-bar.tsx
Normal 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>)
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
608
apps/frontend/src/components/ui/multiple-selector.tsx
Normal file
608
apps/frontend/src/components/ui/multiple-selector.tsx
Normal 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;
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
27
apps/frontend/src/hooks/use-callback-ref.ts
Normal file
27
apps/frontend/src/hooks/use-callback-ref.ts
Normal 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 }
|
||||||
69
apps/frontend/src/hooks/use-controllable-state.ts
Normal file
69
apps/frontend/src/hooks/use-controllable-state.ts
Normal 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 }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
apps/frontend/src/requests/files.ts
Normal file
83
apps/frontend/src/requests/files.ts
Normal 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,
|
||||||
|
}
|
||||||
|
};
|
||||||
33
apps/frontend/src/requests/machines.ts
Normal file
33
apps/frontend/src/requests/machines.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
0
apps/frontend/src/temp.tsx
Normal file
0
apps/frontend/src/temp.tsx
Normal file
20
apps/frontend/src/types/file.ts
Normal file
20
apps/frontend/src/types/file.ts
Normal 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>;
|
||||||
|
}
|
||||||
5
apps/frontend/src/types/machine.ts
Normal file
5
apps/frontend/src/types/machine.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export type IMachine = {
|
||||||
|
id: string;
|
||||||
|
machineName: string;
|
||||||
|
machineType: string;
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"include": [
|
"include": [
|
||||||
"./apps/**/*.ts"
|
"./apps/**/src/**/*.ts"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"vcs": {
|
"vcs": {
|
||||||
|
|||||||
46
package.json
46
package.json
@@ -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
2368
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user