create project
This commit is contained in:
20
api.ts
20
api.ts
@@ -7,7 +7,8 @@ export const API = {
|
|||||||
MEDIA: `${API_URL_ROOT}/users/current/media`,
|
MEDIA: `${API_URL_ROOT}/users/current/media`,
|
||||||
Update: `${API_URL_ROOT}/users/current`,
|
Update: `${API_URL_ROOT}/users/current`,
|
||||||
CHANGE_PASSWORD: `${API_URL_ROOT}/users/current/password`,
|
CHANGE_PASSWORD: `${API_URL_ROOT}/users/current/password`,
|
||||||
APPLICATION: `${API_URL_ROOT}/users/current/application`
|
APPLICATION: `${API_URL_ROOT}/users/current/application`,
|
||||||
|
CURRENT_PROJECT: `${API_URL_ROOT}/users/current/project`
|
||||||
},
|
},
|
||||||
Media:{
|
Media:{
|
||||||
GET_MEDIA: `${API_URL_ROOT}/media`,
|
GET_MEDIA: `${API_URL_ROOT}/media`,
|
||||||
@@ -41,5 +42,20 @@ export const API = {
|
|||||||
CREATE_CV: `${API_URL_ROOT}/historian/application`,
|
CREATE_CV: `${API_URL_ROOT}/historian/application`,
|
||||||
APPLICATION: `${API_URL_ROOT}/historian/application`,
|
APPLICATION: `${API_URL_ROOT}/historian/application`,
|
||||||
DELETE_CV: (Id: number | string) => `${API_URL_ROOT}/historian/application/${Id}`,
|
DELETE_CV: (Id: number | string) => `${API_URL_ROOT}/historian/application/${Id}`,
|
||||||
}
|
},
|
||||||
|
Project: {
|
||||||
|
GET_CURRENT_PROJECT: `${API_URL_ROOT}/users/current/project`,
|
||||||
|
CREATE: `${API_URL_ROOT}/projects`,
|
||||||
|
GET_ALL: `${API_URL_ROOT}/projects`,
|
||||||
|
GET_DETAIL: (id: number | string) => `${API_URL_ROOT}/projects/${id}`,
|
||||||
|
UPDATE: (id: number | string) => `${API_URL_ROOT}/projects/${id}`,
|
||||||
|
DELETE: (id: number | string) => `${API_URL_ROOT}/projects/${id}`,
|
||||||
|
ADD_MEMBER: (id: number | string) => `${API_URL_ROOT}/projects/${id}/members`,
|
||||||
|
UPDATE_MEMBER: (id: number | string, userId: number | string) => `${API_URL_ROOT}/projects/${id}/members/${userId}`,
|
||||||
|
REMOVE_MEMBER: (id: number | string, userId: number | string) => `${API_URL_ROOT}/projects/${id}/members/${userId}`,
|
||||||
|
CHANGE_OWNER: (id: number | string) => `${API_URL_ROOT}/projects/${id}/change-owner`,
|
||||||
|
CREATE_COMMIT: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits`,
|
||||||
|
GET_COMMITS: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits`,
|
||||||
|
RESTORE_COMMIT: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits/restore`,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
services:
|
services:
|
||||||
history-admin:
|
history-user-master:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: history-admin
|
container_name: history-user-master
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3011:3000"
|
- "3014:3000"
|
||||||
networks:
|
networks:
|
||||||
- history-admin-network
|
- history-user-master-network
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
history-admin-network:
|
history-user-master-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import "yet-another-react-lightbox/styles.css";
|
|||||||
import "yet-another-react-lightbox/plugins/captions.css";
|
import "yet-another-react-lightbox/plugins/captions.css";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { URL_MEDIA } from "../../../../../api";
|
import { URL_MEDIA } from "../../../../../api";
|
||||||
|
import { MediaItem } from "@/components/tables/MediaTable";
|
||||||
|
|
||||||
export default function ApplicationDetailPage() {
|
export default function ApplicationDetailPage() {
|
||||||
const application = useSelector(
|
const application = useSelector(
|
||||||
@@ -39,7 +40,7 @@ export default function ApplicationDetailPage() {
|
|||||||
|
|
||||||
const config = statusConfig[application.status] || statusConfig.PENDING;
|
const config = statusConfig[application.status] || statusConfig.PENDING;
|
||||||
|
|
||||||
const isImageFile = (file: any) => {
|
const isImageFile = (file: MediaItem) => {
|
||||||
const isImageMime = file.mime_type?.startsWith("image/");
|
const isImageMime = file.mime_type?.startsWith("image/");
|
||||||
const isImageExt = /\.(jpg|jpeg|png|webp|gif)$/i.test(file.storage_key);
|
const isImageExt = /\.(jpg|jpeg|png|webp|gif)$/i.test(file.storage_key);
|
||||||
return isImageMime || isImageExt;
|
return isImageMime || isImageExt;
|
||||||
@@ -47,13 +48,13 @@ export default function ApplicationDetailPage() {
|
|||||||
|
|
||||||
const imageMediaOnly = (application.media || []).filter(isImageFile);
|
const imageMediaOnly = (application.media || []).filter(isImageFile);
|
||||||
|
|
||||||
const imageSlides = imageMediaOnly.map((item: any) => ({
|
const imageSlides = imageMediaOnly.map((item: MediaItem) => ({
|
||||||
src: `${URL_MEDIA}${item.storage_key}`,
|
src: `${URL_MEDIA}${item.storage_key}`,
|
||||||
title: item.original_name,
|
title: item.original_name,
|
||||||
description: `Dung lượng: ${(item.size / 1024).toFixed(2)} KB`,
|
description: `Dung lượng: ${(item.size / 1024).toFixed(2)} KB`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const handleMediaClick = (item: any) => {
|
const handleMediaClick = (item: MediaItem) => {
|
||||||
const fileUrl = `${URL_MEDIA}${item.storage_key}`;
|
const fileUrl = `${URL_MEDIA}${item.storage_key}`;
|
||||||
if (isImageFile(item)) {
|
if (isImageFile(item)) {
|
||||||
const photoIndex = imageMediaOnly.findIndex(
|
const photoIndex = imageMediaOnly.findIndex(
|
||||||
@@ -167,7 +168,7 @@ export default function ApplicationDetailPage() {
|
|||||||
Tài liệu đính kèm ({application.media.length})
|
Tài liệu đính kèm ({application.media.length})
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-row gap-4 overflow-x-auto pb-4 scrollbar-hide">
|
<div className="flex flex-row gap-4 overflow-x-auto pb-4 scrollbar-hide">
|
||||||
{application.media.map((item: any) => {
|
{application.media.map((item: MediaItem) => {
|
||||||
const isImg = isImageFile(item);
|
const isImg = isImageFile(item);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ApplicationLibrary from "@/components/user-profile/ApplicationList";
|
import ApplicationLibrary from "@/components/user-profile/ApplicationList";
|
||||||
import { MediaDto } from "@/interface/media";
|
import { MediaDto } from "@/interface/media"; // Assuming this file will be created
|
||||||
import { apiGetCurrentUserApplications, apiGetCurrentUserMedia } from "@/service/userService";
|
import { apiGetCurrentUserApplications, apiGetCurrentUserMedia } from "@/service/userService";
|
||||||
import MediaLibrary from "@/components/user-profile/Media";
|
import MediaLibrary from "@/components/user-profile/Media";
|
||||||
|
import { Application } from "@/interface/historian";
|
||||||
|
|
||||||
export default function LibraryPage() {
|
export default function LibraryPage() {
|
||||||
const [mediaData, setMediaData] = useState<MediaDto | null>(null);
|
const [mediaData, setMediaData] = useState<MediaDto | null>(null);
|
||||||
const [applications, setApplications] = useState<any[]>([]);
|
const [applications, setApplications] = useState<Application[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -58,7 +59,7 @@ export default function LibraryPage() {
|
|||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
{(mediaData?.data?.length ?? 0) > 0 && (
|
{(mediaData?.data?.length ?? 0) > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<MediaLibrary data={mediaData ?? {}} />
|
<MediaLibrary data={mediaData!} />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
190
src/app/user/projects/page.tsx
Normal file
190
src/app/user/projects/page.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||||
|
import ComponentCard from "@/components/common/ComponentCard";
|
||||||
|
import { getCurrentProject, apiCreateProject, CreateProjectPayload } from "@/service/projectService";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useModal } from "@/hooks/useModal";
|
||||||
|
import { Modal } from "@/components/ui/modal";
|
||||||
|
import Button from "@/components/ui/button/Button";
|
||||||
|
import Label from "@/components/form/Label";
|
||||||
|
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import Badge from "@/components/ui/badge/Badge";
|
||||||
|
import { Project } from "@/interface/project";
|
||||||
|
|
||||||
|
export default function ProjectsPage() {
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const { isOpen, openModal, closeModal } = useModal();
|
||||||
|
const [formData, setFormData] = useState<CreateProjectPayload>({ title: "", description: "", project_status: "PRIVATE" });
|
||||||
|
|
||||||
|
const fetchProjects = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const res = await getCurrentProject();
|
||||||
|
setProjects(res?.data?.items || res?.data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Lỗi khi tải danh sách dự án:", error);
|
||||||
|
toast.error("Không thể tải danh sách dự án. Vui lòng thử lại!");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProjects();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateProject = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!formData.title.trim()) {
|
||||||
|
toast.warning("Vui lòng nhập tên dự án!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
await apiCreateProject(formData);
|
||||||
|
toast.success("Tạo dự án mới thành công!");
|
||||||
|
closeModal();
|
||||||
|
setFormData({ title: "", description: "", project_status: "PRIVATE" });
|
||||||
|
fetchProjects(); // Tải lại danh sách sau khi tạo
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Lỗi tạo dự án:", error);
|
||||||
|
toast.error("Có lỗi xảy ra khi tạo dự án.");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto pb-10">
|
||||||
|
<PageBreadcrumb pageTitle="Quản lý dự án" />
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<ComponentCard
|
||||||
|
title="Danh sách dự án"
|
||||||
|
headerAction={
|
||||||
|
<Button size="sm" onClick={openModal} className="bg-brand-500 hover:bg-brand-600 text-white">
|
||||||
|
+ Tạo dự án mới
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="relative min-h-[300px]">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/50 dark:bg-gray-900/50 rounded-xl">
|
||||||
|
<div className="w-10 h-10 border-4 border-t-brand-500 rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{projects.length > 0 ? (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-white/[0.05] dark:bg-white/[0.03]">
|
||||||
|
<div className="max-w-full overflow-x-auto">
|
||||||
|
<div className="min-w-[800px]">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="border-b border-gray-100 dark:border-white/[0.05]">
|
||||||
|
<TableRow>
|
||||||
|
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
|
||||||
|
Tên dự án
|
||||||
|
</TableCell>
|
||||||
|
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
|
||||||
|
Mô tả
|
||||||
|
</TableCell>
|
||||||
|
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-center text-theme-xs dark:text-gray-400">
|
||||||
|
Trạng thái
|
||||||
|
</TableCell>
|
||||||
|
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-center text-theme-xs dark:text-gray-400">
|
||||||
|
Thao tác
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody className="divide-y divide-gray-100 dark:divide-white/[0.05]">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<TableRow key={project.id} className="hover:bg-gray-50/50 dark:hover:bg-white/[0.01] transition-colors">
|
||||||
|
<TableCell className="px-5 py-4 text-start text-theme-sm font-semibold text-gray-800 dark:text-white/90">
|
||||||
|
{project.title}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-5 py-4 text-start text-theme-sm text-gray-500 dark:text-gray-400 max-w-[300px]">
|
||||||
|
<p className="truncate">{project.description || "Chưa có mô tả cho dự án này..."}</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-5 py-4 text-center">
|
||||||
|
<Badge size="sm" variant="light" color={project.project_status === "PUBLIC" ? "success" : project.project_status === "PRIVATE" ? "warning" : "light"}>
|
||||||
|
{project.project_status || "N/A"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-5 py-4 text-center">
|
||||||
|
<Link
|
||||||
|
href={`/user/projects/${project.id}`}
|
||||||
|
className="text-brand-500 hover:text-brand-600 font-medium text-theme-sm"
|
||||||
|
>
|
||||||
|
Thao tác
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : !isLoading && (
|
||||||
|
<div className="py-20 text-center">
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">Bạn chưa có dự án nào.</p>
|
||||||
|
<Button size="sm" onClick={openModal} className="mt-4 bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 hover:bg-gray-200">
|
||||||
|
Tạo dự án đầu tiên
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ComponentCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Tạo Dự án */}
|
||||||
|
<Modal isOpen={isOpen} onClose={closeModal} className="max-w-[500px] m-4">
|
||||||
|
<div className="p-6 bg-white rounded-3xl dark:bg-gray-900">
|
||||||
|
<h3 className="mb-5 text-xl font-bold text-gray-800 dark:text-white/90">Tạo dự án mới</h3>
|
||||||
|
<form onSubmit={handleCreateProject} className="flex flex-col gap-5">
|
||||||
|
<div>
|
||||||
|
<Label>Tên dự án <span className="text-red-500">*</span></Label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="title"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Nhập tên dự án..."
|
||||||
|
autoFocus
|
||||||
|
className="h-11 w-full rounded-xl border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Trạng thái</Label>
|
||||||
|
<select name="status" value={formData.project_status} onChange={(e: any) => handleChange(e)} className="h-11 w-full rounded-xl border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800">
|
||||||
|
<option value="PRIVATE">Riêng tư (Private)</option>
|
||||||
|
<option value="PUBLIC">Công khai (Public)</option>
|
||||||
|
<option value="ARCHIVE">Lưu trữ (Archive)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Mô tả dự án</Label>
|
||||||
|
<textarea name="description" value={formData.description} onChange={handleChange} rows={4} className="w-full rounded-xl border border-gray-200 bg-transparent px-4 py-3 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800 custom-scrollbar" placeholder="Mô tả ngắn gọn về dự án..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-3 mt-4">
|
||||||
|
<Button size="sm" variant="outline" type="button" onClick={closeModal}>Hủy</Button>
|
||||||
|
<Button size="sm" type="submit" disabled={isSubmitting} className="bg-brand-500 hover:bg-brand-600 text-white">
|
||||||
|
{isSubmitting ? "Đang tạo..." : "Khởi tạo"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import "yet-another-react-lightbox/plugins/captions.css";
|
|||||||
import { createHistorianCV } from "@/service/historianService";
|
import { createHistorianCV } from "@/service/historianService";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import Swal from "sweetalert2";
|
import Swal from "sweetalert2";
|
||||||
|
import { PresignedUrlResponse } from "@/interface/media";
|
||||||
|
|
||||||
type PendingFile = {
|
type PendingFile = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -26,7 +27,7 @@ type PendingFile = {
|
|||||||
size: number;
|
size: number;
|
||||||
type: "image" | "document";
|
type: "image" | "document";
|
||||||
extension: string;
|
extension: string;
|
||||||
presigned?: any;
|
presigned?: PresignedUrlResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RoleUpgrade() {
|
export default function RoleUpgrade() {
|
||||||
@@ -131,6 +132,9 @@ export default function RoleUpgrade() {
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
const uploadPromises = pendingFiles.map(async (item) => {
|
const uploadPromises = pendingFiles.map(async (item) => {
|
||||||
|
if (!item.presigned) {
|
||||||
|
throw new Error(`Không thể lấy URL tải lên cho tệp: ${item.name}`);
|
||||||
|
}
|
||||||
await uploadFileToS3(item.file, item.presigned);
|
await uploadFileToS3(item.file, item.presigned);
|
||||||
const confirmRes = await confirmUpload(item.presigned.token_id);
|
const confirmRes = await confirmUpload(item.presigned.token_id);
|
||||||
return confirmRes?.data?.id || confirmRes?.id;
|
return confirmRes?.data?.id || confirmRes?.id;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "../ui/table";
|
} from "../ui/table";
|
||||||
import Badge from "../ui/badge/Badge";
|
import Badge from "../ui/badge/Badge";
|
||||||
import { ApplicationDto } from "@/interface/historian";
|
import { Application } from "@/interface/historian";
|
||||||
|
|
||||||
export type AppSortColumn =
|
export type AppSortColumn =
|
||||||
| "created_at"
|
| "created_at"
|
||||||
@@ -19,9 +19,9 @@ export type AppSortColumn =
|
|||||||
| "updated_at";
|
| "updated_at";
|
||||||
|
|
||||||
interface ApplicationTableProps {
|
interface ApplicationTableProps {
|
||||||
data: ApplicationDto[];
|
data: Application[];
|
||||||
onSort: (column: AppSortColumn) => void;
|
onSort: (column: AppSortColumn) => void;
|
||||||
onViewDetail: (app: ApplicationDto) => void;
|
onViewDetail: (app: Application) => void;
|
||||||
sortBy?: AppSortColumn;
|
sortBy?: AppSortColumn;
|
||||||
sortOrder?: "asc" | "desc";
|
sortOrder?: "asc" | "desc";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export default function UserDetailModal({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{(mediaData?.data?.length ?? 0) > 0 ? (
|
{(mediaData?.data?.length ?? 0) > 0 ? (
|
||||||
<MediaCard data={mediaData ?? {}} />
|
<MediaCard data={mediaData!} />
|
||||||
) : (
|
) : (
|
||||||
<div className="p-5 border border-dashed border-gray-200 rounded-2xl text-center text-gray-400 text-sm">
|
<div className="p-5 border border-dashed border-gray-200 rounded-2xl text-center text-gray-400 text-sm">
|
||||||
Người dùng này chưa có dữ liệu media.
|
Người dùng này chưa có dữ liệu media.
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export default function ApplicationList({
|
|||||||
|
|
||||||
const handleViewDetail = (app: any) => {
|
const handleViewDetail = (app: any) => {
|
||||||
dispatch(setSelectedApplication(app));
|
dispatch(setSelectedApplication(app));
|
||||||
router.push(`/account/applications`);
|
router.push(`/user/account/applications`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const StatusIcons: Record<string, React.ReactNode> = {
|
const StatusIcons: Record<string, React.ReactNode> = {
|
||||||
|
|||||||
29
src/interface/common.ts
Normal file
29
src/interface/common.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export interface CommonResponse<T = any> {
|
||||||
|
status: boolean;
|
||||||
|
message: string;
|
||||||
|
data: T;
|
||||||
|
errors?: any; // Or a more specific error type
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
status: boolean;
|
||||||
|
message: string;
|
||||||
|
data: T[];
|
||||||
|
pagination: {
|
||||||
|
current_page: number;
|
||||||
|
page_size: number;
|
||||||
|
total_records: number;
|
||||||
|
total_pages: number;
|
||||||
|
};
|
||||||
|
errors?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CursorPaginatedResponse<T> {
|
||||||
|
status: boolean;
|
||||||
|
message: string;
|
||||||
|
data: {
|
||||||
|
items: T[];
|
||||||
|
next_cursor_id?: string;
|
||||||
|
};
|
||||||
|
errors?: any;
|
||||||
|
}
|
||||||
@@ -1,62 +1,29 @@
|
|||||||
export interface MediaDto {
|
import { MediaItem } from "@/components/tables/MediaTable";
|
||||||
|
|
||||||
|
export interface Reviewer {
|
||||||
id: string;
|
id: string;
|
||||||
storage_key: string;
|
display_name: string;
|
||||||
original_name: string;
|
email: string;
|
||||||
mime_type: string;
|
avatar_url?: string;
|
||||||
size: number;
|
|
||||||
created_at: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApplicationDto {
|
export interface ApplicationUser {
|
||||||
id: string;
|
id?: string;
|
||||||
user_id?: string;
|
display_name: string;
|
||||||
verify_type: string | number;
|
email?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Application {
|
||||||
|
id:string;
|
||||||
content: string;
|
content: string;
|
||||||
is_deleted: boolean;
|
status: "PENDING" | "APPROVED" | "REJECTED" | string | number;
|
||||||
status: string | number;
|
verify_type: string | string[] | number | number[];
|
||||||
reviewed_by: string;
|
user: ApplicationUser;
|
||||||
review_note: string;
|
media: MediaItem[];
|
||||||
reviewed_at: string | null;
|
reviewer?: Reviewer;
|
||||||
|
reviewed_at?: string;
|
||||||
|
review_note?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at?: string;
|
updated_at: string;
|
||||||
media: any[];
|
|
||||||
user: {
|
|
||||||
display_name?: string;
|
|
||||||
avatar_url?: string;
|
|
||||||
full_name?: string;
|
|
||||||
id?: string;
|
|
||||||
email?: string;
|
|
||||||
};
|
|
||||||
reviewer?: {
|
|
||||||
display_name?: string;
|
|
||||||
avatar_url?: string;
|
|
||||||
full_name?: string;
|
|
||||||
id?: string;
|
|
||||||
email?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetApplicationsParams {
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
search?: string;
|
|
||||||
sort?: string;
|
|
||||||
order?: "asc" | "desc";
|
|
||||||
statuses?: string[];
|
|
||||||
verify_types?: string;
|
|
||||||
created_from?: string;
|
|
||||||
created_to?: string;
|
|
||||||
reviewed_by?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApplicationResponse {
|
|
||||||
status: boolean;
|
|
||||||
message: string;
|
|
||||||
data: ApplicationDto[];
|
|
||||||
pagination: {
|
|
||||||
current_page: number;
|
|
||||||
page_size: number;
|
|
||||||
total_records: number;
|
|
||||||
total_pages: number;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,20 @@
|
|||||||
interface item{
|
import { MediaItem } from "@/components/tables/MediaTable";
|
||||||
file_metadata:string;
|
|
||||||
id:string;
|
export interface PresignedUrlResponse {
|
||||||
mime_type:string;
|
token_id: string;
|
||||||
original_name:string;
|
upload_url: string;
|
||||||
size:number;
|
storage_key: string;
|
||||||
updated_at:string;
|
signed_headers: Record<string, string>;
|
||||||
user_id:string;
|
|
||||||
storage_key:string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaDto {
|
export interface MediaDto {
|
||||||
data?: item[];
|
status: boolean;
|
||||||
message?:string;
|
message: string;
|
||||||
status?:boolean;
|
data: MediaItem[];
|
||||||
}
|
pagination: {
|
||||||
|
current_page: number;
|
||||||
export interface payloadPresignedMedia {
|
page_size: number;
|
||||||
filename:string;
|
total_records: number;
|
||||||
content_type:string;
|
total_pages: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
9
src/interface/project.ts
Normal file
9
src/interface/project.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
project_status: "PRIVATE" | "PUBLIC" | "ARCHIVE";
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
// You can add other fields like 'members' if they are part of the response
|
||||||
|
}
|
||||||
@@ -38,6 +38,11 @@ const ALL_NAV_ITEMS: NavItem[] = [
|
|||||||
// subItems: [{ name: "Ecommerce", path: "/", pro: false }],
|
// subItems: [{ name: "Ecommerce", path: "/", pro: false }],
|
||||||
path: "/",
|
path: "/",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: <BoxCubeIcon />,
|
||||||
|
name: "Dự Án",
|
||||||
|
path: "/user/projects",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: <FileIcon />,
|
icon: <FileIcon />,
|
||||||
name: "Thư Viện",
|
name: "Thư Viện",
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import api from "@/config/config";
|
import api from "@/config/config";
|
||||||
import { API } from "../../api";
|
import { API } from "../../api";
|
||||||
import { payloadPresignedMedia } from "@/interface/media";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { PresignedUrlResponse } from "@/interface/media";
|
||||||
|
|
||||||
|
export interface payloadPresignedMedia {
|
||||||
|
fileName?: string;
|
||||||
|
content_type?: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const apiGetCurrentUserMedia = async (
|
export const apiGetCurrentUserMedia = async (
|
||||||
payload: payloadPresignedMedia,
|
payload: payloadPresignedMedia,
|
||||||
@@ -41,16 +47,9 @@ export const getFileType = (mime: string): FileType => {
|
|||||||
return "other";
|
return "other";
|
||||||
};
|
};
|
||||||
|
|
||||||
type PreSignedResponse = {
|
|
||||||
token_id: string;
|
|
||||||
upload_url: string;
|
|
||||||
storage_key: string;
|
|
||||||
signed_headers: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const uploadFileToS3 = async (
|
export const uploadFileToS3 = async (
|
||||||
file: File,
|
file: File,
|
||||||
presigned: PreSignedResponse,
|
presigned: PresignedUrlResponse,
|
||||||
) => {
|
) => {
|
||||||
const res = await axios.put(presigned.upload_url, file, {
|
const res = await axios.put(presigned.upload_url, file, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -70,7 +69,7 @@ export const confirmUpload = async (token_id: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const uploadMedia = async (file: File) => {
|
export const uploadMedia = async (file: File) => {
|
||||||
const { data: presigned } = await api.get<PreSignedResponse>(
|
const { data: presigned } = await api.get<PresignedUrlResponse>(
|
||||||
"/media/presigned",
|
"/media/presigned",
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
@@ -90,7 +89,7 @@ export const uploadMedia = async (file: File) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getPresignedUrl = async (file: File) => {
|
export const getPresignedUrl = async (file: File) => {
|
||||||
const { data: presigned } = await api.get<PreSignedResponse>(
|
const { data: presigned } = await api.get<PresignedUrlResponse>(
|
||||||
"/media/presigned",
|
"/media/presigned",
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
114
src/service/projectService.ts
Normal file
114
src/service/projectService.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import api from "@/config/config";
|
||||||
|
import { API } from "../../api";
|
||||||
|
import { Project } from "@/interface/project";
|
||||||
|
import { CommonResponse, CursorPaginatedResponse } from "@/interface/common";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// TYPES & INTERFACES (Cơ bản theo logic chuẩn)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export interface CreateProjectPayload {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
project_status?: "PRIVATE" | "PUBLIC" | "ARCHIVE";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProjectPayload {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
project_status?: "PRIVATE" | "PUBLIC" | "ARCHIVE";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddMemberPayload {
|
||||||
|
user_id: string;
|
||||||
|
role: "EDITOR" | "VIEWER";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMemberRolePayload {
|
||||||
|
role: "EDITOR" | "VIEWER";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChangeOwnerPayload {
|
||||||
|
new_owner_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCommitPayload {
|
||||||
|
edit_summary: string;
|
||||||
|
snapshot_json: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestoreCommitPayload {
|
||||||
|
commit_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 1. NHÓM: QUẢN LÝ DỰ ÁN (PROJECTS)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export const apiCreateProject = async (payload: CreateProjectPayload): Promise<CommonResponse<Project>> => {
|
||||||
|
const response = await api.post(API.Project.CREATE, payload);
|
||||||
|
return response?.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiGetProjectDetail = async (id: string): Promise<CommonResponse<Project>> => {
|
||||||
|
const response = await api.get(API.Project.GET_DETAIL(id));
|
||||||
|
return response?.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiUpdateProject = async (id: string, payload: UpdateProjectPayload): Promise<CommonResponse<Project>> => {
|
||||||
|
const response = await api.put(API.Project.UPDATE(id), payload);
|
||||||
|
return response?.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiDeleteProject = async (id: string): Promise<CommonResponse> => {
|
||||||
|
const response = await api.delete(API.Project.DELETE(id));
|
||||||
|
return response?.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 2. NHÓM: QUẢN LÝ THÀNH VIÊN (MEMBERS)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export const apiAddProjectMember = async (id: string, payload: AddMemberPayload): Promise<CommonResponse> => {
|
||||||
|
const response = await api.post(API.Project.ADD_MEMBER(id), payload);
|
||||||
|
return response?.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiUpdateProjectMemberRole = async (id: string, userId: string, payload: UpdateMemberRolePayload): Promise<CommonResponse> => {
|
||||||
|
const response = await api.put(API.Project.UPDATE_MEMBER(id, userId), payload);
|
||||||
|
return response?.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiRemoveProjectMember = async (id: string, userId: string): Promise<CommonResponse> => {
|
||||||
|
const response = await api.delete(API.Project.REMOVE_MEMBER(id, userId));
|
||||||
|
return response?.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiChangeProjectOwner = async (id: string, payload: ChangeOwnerPayload): Promise<CommonResponse> => {
|
||||||
|
const response = await api.put(API.Project.CHANGE_OWNER(id), payload);
|
||||||
|
return response?.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 3. NHÓM: LỊCH SỬ BẢN LƯU (COMMITS)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export const apiCreateProjectCommit = async (id: string, payload: CreateCommitPayload): Promise<CommonResponse> => {
|
||||||
|
const response = await api.post(API.Project.CREATE_COMMIT(id), payload);
|
||||||
|
return response?.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiGetProjectCommits = async (id: string): Promise<CommonResponse> => { // Assuming it returns a list of commits
|
||||||
|
const response = await api.get(API.Project.GET_COMMITS(id));
|
||||||
|
return response?.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiRestoreProjectCommit = async (id: string, payload: RestoreCommitPayload): Promise<CommonResponse> => {
|
||||||
|
const response = await api.post(API.Project.RESTORE_COMMIT(id), payload);
|
||||||
|
return response?.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCurrentProject = async (params?: { cursor_id?: string; limit?: number }): Promise<CursorPaginatedResponse<Project>> => {
|
||||||
|
const response = await api.get(API.Project.GET_CURRENT_PROJECT, { params });
|
||||||
|
return response?.data;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user