일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | |
7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 | 29 | 30 | 31 |
- 앵귤러 모달
- angular modal
- angular button
- mysql if
- Ionic modal
- Router
- Angular Router
- angular route
- TAILWIND
- prisma
- modal
- ajax 사용 예시
- 아이오닉 스크롤 이벤트
- 검색
- ApexChart
- 모달
- flex-1
- 스크롤 이벤트
- 옵저버블
- Oracle LISTAGG 사용 예시
- angular animation
- formgroup
- route
- egov spring ajax 사용 예시
- 스크롤 이벤트 감지
- summary
- scroll
- 호버
- 셀렉트박스 커스텀
- 앵귤러 애니메이션
- Today
- Total
깜놀하는 해므찌로
Ionic Anular nestjs video Upload example / 앵귤러 nestjs 동영상 파일 업로드 예시 feat.AWS S3 본문
Ionic Anular nestjs video Upload example / 앵귤러 nestjs 동영상 파일 업로드 예시 feat.AWS S3
agnusdei1207 2023. 8. 17. 13:11<div class="flex flex-col gap-2">
<!-- Start: Label -->
<div class="relative flex flex-col gap-2 mb-2 w-max" *ngIf="label">
<!-- Display the label text -->
<span class="text-xs">{{ label }}</span>
<!-- Display an asterisk (*) if the field is required or has a minimum length -->
<p
class="text-primary absolute -top-[8px] left-[calc(100%+2px)]"
*ngIf="required || minlength"
>
*
</p>
</div>
<!-- End: Label -->
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2" *ngIf="items.length !== this.maxlength">
<!-- File upload area -->
<div
class="flex flex-col items-center justify-center w-full gap-4 p-4 transition-all border-2 border-gray-200 border-dashed rounded-lg cursor-pointer h-80 hover:border-primary hover:bg-primary-50"
[class.bg-primary-50]="isActive"
[class.border-primary]="isActive"
[class.bg-white]="!isActive"
(click)="selectFile()"
(dragover)="enter($event)"
(dragleave)="leave($event)"
(drop)="drop($event)"
>
<!-- Icon for file upload -->
<app-icon
name="ic:baseline-upload-file"
class="w-8 h-8 text-primary"
></app-icon>
<div class="flex flex-col gap-1 text-center">
<!-- Instructions for file upload -->
<p class="text-sm text-gray-800">
동영상 파일을 드래그 할 수 있습니다.
</p>
<p class="text-sm text-gray-500">또는 클릭해서 선택이 가능합니다.</p>
</div>
</div>
<!-- Display the count of items and maxlength -->
<div class="flex w-full text-sm text-gray-500">
<p>{{ items.length }} / {{ maxlength }}</p>
</div>
</div>
<div
class="flex flex-col gap-2 max-h-[23rem] overflow-auto"
*ngIf="items.length > 0"
>
<!-- Display uploaded files -->
<div
class="flex w-full justify-between p-2.5 gap-2.5 bg-white border border-gray-200 rounded-md"
*ngFor="let item of items"
>
<div class="flex gap-2.5 flex-col items-center w-full">
<div class="flex items-end gap-4">
<!-- Display status icons (success, fail) or a spinner for ongoing upload -->
<app-icon
class="w-5 h-5 text-red-500"
name="ic:round-warning-amber"
*ngIf="item.status === 'fail'"
></app-icon>
<app-icon
class="w-5 h-5 text-green-500"
name="ic:baseline-check"
*ngIf="item.status === 'success'"
></app-icon>
<ion-spinner
class="w-5 h-5"
color="primary"
*ngIf="!item.status"
></ion-spinner>
<!-- Display delete icon for successful uploads -->
<app-icon
name="ic:outline-close"
class="w-5 h-5 text-gray-700 cursor-pointer"
*ngIf="item.status === 'success'"
(click)="handleDelete(item)"
></app-icon>
</div>
<!-- Display file icon or image thumbnail -->
<ng-container *ngIf="isVideoFile(item)">
<!-- Only show video if thumbnail is not available -->
<video
[src]="item.url"
class="w-full rounded-md cursor-pointer aspect-video"
(click)="openVideo(item.url)"
*ngIf="item.url"
></video>
<div
class="flex items-center justify-center p-2 border border-gray-200 rounded-md w-9 h-9"
*ngIf="!item.url && !item.thumbnail"
>
<app-icon
name="mdi:file-image"
class="w-6 h-6 text-gray-700"
></app-icon>
</div>
</ng-container>
<div class="flex flex-col gap-1">
<!-- Display file name and size -->
<p class="text-sm text-gray-700">{{ item.name }}</p>
<p class="text-xs text-gray-400">{{ item.size | fileSize }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
1. 템플릿 예시 (자세한 설명은 주석을 참조하세요.)
import { CommonModule } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Component, Input, OnInit, Optional, Self } from '@angular/core';
import { NgControl } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { File } from '@namdorang/interface';
import { Subject } from 'rxjs';
import { FileSizePipe } from '../../pipes/file-size.pipe';
import { CustomValueAccessor, IconComponent, ToastService } from '@common/lib';
// FileUploadItem 타입 정의
type FileUploadItem = {
name: string;
url?: string;
status?: 'success' | 'fail';
thumbnail?: string; // 동영상 썸네일 이미지 URL
size: number;
};
@Component({
selector: 'app-file-upload',
standalone: true,
imports: [CommonModule, IconComponent, IonicModule, FileSizePipe],
templateUrl: './file-upload.component.html',
styleUrls: ['./file-upload.component.scss'],
})
export class FileUploadComponent
extends CustomValueAccessor<File[]>
implements OnInit
{
@Input() label: string = ''; // 라벨 입력값을 받는 프로퍼티
@Input() override required = false; // required 속성을 오버라이드하는 프로퍼티
@Input() type: // 허용하는 파일 타입을 지정하는 프로퍼티
'mp4' | 'avi' | 'mkv' | 'mov' | 'wmv' | 'flv' | '3gp' | 'mpeg' = 'mp4';
@Input() maxlength: number = 1; // 최대 업로드 개수를 지정하는 프로퍼티
@Input() minlength: number = 0; // 최소 업로드 개수를 지정하는 프로퍼티
@Input() uploadedFiles$ = new Subject<File[] | null>(); // 업로드된 파일 목록을 방출하는 서브젝트
accept = ''; // input 요소의 accept 속성 값
fileServer = `${process.env['NX_FILE_SERVER_URL']}`; // 파일 서버 URL
token = `${process.env['NX_FILE_SERVER_TOKEN']}`; // 파일 서버 토큰
isLoading = false; // 업로드 중 여부를 나타내는 플래그
isActive = false; // 드래그 앤 드롭 영역에 마우스가 진입한 상태를 나타내는 플래그
items: FileUploadItem[] = []; // 업로드된 파일 목록을 담는 배열
constructor(
@Self() @Optional() public ngControl: NgControl, // 자체 제어 기능을 가진 폼 컨트롤
private httpClient: HttpClient, // HTTP 요청을 수행하는 HttpClient
private toastService: ToastService // 토스트 메시지를 표시하는 서비스
) {
super();
if (this.ngControl) this.ngControl.valueAccessor = this;
}
ngOnInit() {
// 업로드된 파일 목록을 구독합니다.
this.uploadedFiles$.subscribe({
next: (files) => {
this.items = [];
if (!files) return;
// 파일 목록을 FileUploadItem 형태로 변환하여 items 배열에 추가합니다.
this.items = files.map((file) => {
return {
name: file.name,
url: file.url,
status: 'success',
size: file.size,
};
});
},
});
}
// 드래그 앤 드롭 영역에 마우스가 진입했을 때 호출되는 메서드
enter(ev: any) {
this.isActive = true;
// 이벤트의 기본 동작을 취소합니다. 드래그 앤 드롭 이벤트에서는 파일을 브라우저 창으로 드래그했을 때 파일이 열리는 동작을 막습니다.
ev.preventDefault();
// 이벤트의 전파를 중지합니다. 이는 부모 요소로 이벤트가 전파되지 않도록 막는 역할을 합니다.
// 드래그 앤 드롭 영역에서 발생한 이벤트가 부모 요소로 전파되지 않도록 방지합니다.
ev.stopPropagation();
}
// 드래그 앤 드롭 영역에서 마우스가 떠났을 때 호출되는 메서드
leave(ev: any) {
ev.preventDefault();
ev.stopPropagation();
this.isActive = false;
}
// 파일을 드롭했을 때 호출되는 메서드
drop(ev: any) {
ev.preventDefault();
ev.stopPropagation();
this.isActive = false;
const files: FileList = ev.dataTransfer.files;
if (files) {
for (const file of Array.from(files)) {
const formData = new FormData();
formData.append('file', file);
// 최대 업로드 개수를 검증합니다.
const validateMaxlength = this.validateMaxlength();
if (!validateMaxlength) {
this.toastService.show(
`파일은 최대 ${this.maxlength}개까지 업로드 가능합니다.`,
'danger'
);
return;
}
// 파일 타입을 검증합니다.
const validateType = this.validateType(file.type);
if (!validateType) {
this.toastService.show('동영상 파일만 업로드 가능합니다.', 'danger');
return;
}
// 파일 중복을 검증합니다.
const validate = this.duplicationValidate(file.name);
if (validate) {
this.toastService.show('이미 업로드된 파일입니다.', 'danger');
return;
}
// 파일을 items 배열에 추가하고 업로드를 처리합니다.
this.items.push({
name: file.name,
size: file.size,
});
this.handleUpload(formData, file);
}
}
}
// 최대 업로드 개수를 검증하는 메서드
validateMaxlength(): boolean {
const temp = this.items.filter((item) => item.status === 'success');
if (temp.length >= this.maxlength) {
return false;
}
return true;
}
// 파일 타입을 검증하는 메서드
validateType(type: string): boolean {
if (
this.type === 'mp4' ||
this.type === 'avi' ||
this.type === 'mkv' ||
this.type === 'mov' ||
this.type === 'wmv' ||
this.type === 'flv' ||
this.type === '3gp' ||
this.type === 'mpeg'
) {
if (!type.includes('video')) {
return false;
}
} else {
}
return true;
}
// 파일 중복을 검증하는 메서드
duplicationValidate(name: string): boolean {
if (this.items.findIndex((item) => item.name === name) !== -1) {
return true;
}
return false;
}
// 값이 변경될 때 호출되는 메서드
handleChange() {
if (this.items && this.items.length > 0) {
this.items.map((item) => {
if (item.url) {
if (!this.value) {
this.value = [];
}
this.value.push({
name: item.name,
url: item.url,
size: item.size,
});
}
});
} else {
this.value = [];
}
}
// 파일 삭제를 처리하는 메서드
handleDelete(item: FileUploadItem) {
const index = this.items.findIndex((i) => i.name === item.name);
this.items.splice(index, 1);
this.handleChange();
}
// 파일 업로드를 처리하는 메서드
handleUpload(formData: FormData, file: any) {
console.log(formData);
const index = this.items.findIndex((item) => item.name === file.name);
this.httpClient
.post(`${this.fileServer}/upload?token=${this.token}`, formData)
.subscribe({
next: (res: any) => {
if (res.ok) {
this.items[index].url = this.fileServer + res.path;
this.items[index].status = 'success';
// 동영상 썸네일을 가져오는 작업 추가
this.generateVideoThumbnail(this.items[index].url!)
.then((thumbnailUrl) => {
this.items[index].thumbnail = thumbnailUrl;
this.handleChange();
})
.catch(() => {
this.handleChange();
});
} else {
this.items[index].status = 'fail';
}
},
error: (err) => {
this.items[index].status = 'fail';
},
});
}
// 이미지를 새 창에서 열기 위한 메서드
openImage(url: string) {
const a = document.createElement('a');
a.href = url;
a.target = '_blank';
a.click();
}
// 파일 선택 창을 열기 위한 메서드
selectFile() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'mp4';
input.multiple = true;
input.onchange = () => {
const files = input.files;
if (files) {
for (const file of Array.from(files)) {
const formData = new FormData();
formData.append('file', file);
// 최대 업로드 개수를 검증합니다.
const validateMaxlength = this.validateMaxlength();
if (!validateMaxlength) {
this.toastService.show(
`파일은 최대 ${this.maxlength}개까지 업로드 가능합니다.`,
'danger'
);
return;
}
// 파일 타입을 검증합니다.
const validateType = this.validateType(file.type);
if (!validateType) {
this.toastService.show(
'이미지 파일만 업로드 가능합니다.',
'danger'
);
return;
}
// 파일 중복을 검증합니다.
const validate = this.duplicationValidate(file.name);
if (validate) {
this.toastService.show('이미 업로드된 파일입니다.', 'danger');
return;
}
// 파일을 items 배열에 추가하고 업로드를 처리합니다.
this.items.push({
name: file.name,
size: file.size,
});
this.handleUpload(formData, file);
}
}
};
input.click();
}
// 동영상 썸네일을 생성하는 메서드
generateVideoThumbnail(videoUrl: string): Promise<string> {
return new Promise((resolve, reject) => {
const video = document.createElement('video');
video.src = videoUrl;
video.setAttribute('crossorigin', 'anonymous');
video.addEventListener('loadeddata', () => {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas
.getContext('2d')
?.drawImage(video, 0, 0, canvas.width, canvas.height);
const thumbnailUrl = canvas.toDataURL(); // Base64 형식의 이미지 데이터 URL
resolve(thumbnailUrl);
});
video.addEventListener('error', () => {
reject();
});
});
}
// Check if the file is a video file
isVideoFile(item: any): boolean {
const videoExtensions = [
'mp4',
'avi',
'mkv',
'mov',
'wmv',
'flv',
'3gp',
'mpeg',
];
const fileExtension = item.name.split('.').pop().toLowerCase();
return videoExtensions.includes(fileExtension);
}
// Open the video in a new tab or window
openVideo(url: string): void {
window.open(url, '_blank');
}
}
2. 파일 업로드 컴포넌트 예시
import { forwardRef, Type } from '@angular/core';
import {
AbstractControl,
ControlValueAccessor,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
} from '@angular/forms';
export function createProviders<T>(type: Type<CustomValueAccessor<T>>) {
return [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => type),
multi: true,
},
];
}
export class CustomValueAccessor<T> implements ControlValueAccessor, Validator {
disabled = false;
required: boolean = false;
touched: boolean = false;
onChange: any = (value: T) => {};
onTouched: any = () => {};
onValidationchange: any = () => {};
private _value!: T;
set value(newValue: T) {
if (this._value === newValue) {
return;
}
this._value = newValue;
this.onChange(newValue);
this.onTouched();
this.touched = true;
}
get value(): T {
return this._value;
}
writeValue(value: T): void {
this.value = value;
}
registerOnChange(fn: (value: T) => {}): void {
this.onChange = fn;
}
registerOnTouched(fn: () => {}): void {
this.onTouched = fn;
}
setDisabledState?(isDisabled: boolean): void {
this.disabled = isDisabled;
}
validate(control: AbstractControl<any, any>): ValidationErrors | null {
return this.required && this.touched
? !this._value
? { required: true }
: null
: null;
}
registerOnValidatorChange?(fn: () => void): void {
this.onValidationchange = fn;
}
}
3. CustomValueAccessor : 앵귤러 제공 ControlValueAccessor 를 약간 수정한 버전입니다.
https://agnusdei1207.tistory.com/414
ControlValueAccessor Angular 사용 예시 / CustomValueAccessor
import { AbstractControl, ControlValueAccessor, ValidationErrors, Validator } from "@angular/forms"; export class CustomValueAccessor implements ControlValueAccessor, Validator { disabled = false; required: boolean = false; touched: boolean = false; onChan
agnusdei1207.tistory.com
4. IconComponent : 아이콘을 바로 활용할 수 있도록 커스텀된 컴포넌트
https://agnusdei1207.tistory.com/583
5. toastService : ionic 기반 토스트 메세지 활용을 커스텀 서비스
https://agnusdei1207.tistory.com/460
6. proess.env : webPack 활용
https://agnusdei1207.tistory.com/564
이로써 프론트엔드는 구현이 끝났습니다.
이제 프론트엔드에서 업로드한 동영상 파일을 백엔드 nestjs 에서 받도록 구현해보겠습니다.
7. 이름은 대략 video-upload 로 지어줍니다.
8. NX resource generate CLI 를 활용해서 생성합니다.
npx nx generate resource --project 프로젝트명 경로/파일명
import {
Body,
Controller,
Logger,
Post,
Query,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { FileInterceptor } from '@nestjs/platform-express';
import { Express } from 'express';
import { diskStorage } from 'multer';
import { join } from 'path';
import dayjs from 'dayjs';
import { VideoUploadService } from './video-upload.service';
import { File } from '@namdorang/interface';
@ApiTags('파일 업로드') // 파일 업로드 API에 대한 태그 정의
@Controller({ version: '1' }) // 버전 1의 컨트롤러로 설정
export class VideoUploadController {
constructor(private readonly videoUploadService: VideoUploadService) {} // VideoUploadService 의존성 주입
@Post() // POST 요청 핸들러
@UseInterceptors(
FileInterceptor('file', {
// 'file'이라는 필드의 파일을 인터셉트함
dest: join(__dirname, 'temp'), // 파일 저장 디렉토리 설정
storage: diskStorage({
destination: join(__dirname, 'temp'), // 파일 저장 경로 설정
filename: (req, file, cb) => {
// 파일 이름 설정 함수
cb(null, `${dayjs().unix()}_${file.originalname}`); // 현재 시간과 파일 원본 이름을 조합하여 파일 이름 생성
},
}),
})
)
@Post()
@ApiOperation({
summary: '비디오 업로드',
description: '비디오 파일을 업로드합니다.',
})
upload(@UploadedFile() video: File[]): void {
Logger.log('video-upload.controller.ts file');
console.log(video); // 업로드된 비디오 파일 정보
}
// 업로드된 파일을 처리하는 핸들러
uploadFile(@UploadedFile() file: Express.Multer.File) {
return this.videoUploadService.upload(file); // VideoUploadService를 사용하여 파일 업로드 수행
}
}
https://docs.nestjs.com/techniques/file-upload
Documentation | NestJS - A progressive Node.js framework
Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Rea
docs.nestjs.com
9. nestjs 공식 문서 참조
import { BadGatewayException, Injectable, Logger } from '@nestjs/common';
import * as fs from 'fs';
import { join } from 'path';
import * as AWS from 'aws-sdk';
import { Express } from 'express';
@Injectable()
export class VideoUploadService {
private logger: Logger = new Logger(VideoUploadService.name);
async upload(file: Express.Multer.File) {
const check = fs.readFileSync(join(__dirname, 'temp', file.filename)); // 파일을 동기적으로 읽어옴
if (!check) {
throw new BadGatewayException();
// 파일이 존재하지 않으면 BadGatewayException 예외를 throw
}
const S3 = new AWS.S3({
// AWS S3 클라이언트 생성
endpoint: 'https://kr.object.ncloudstorage.com',
region: 'kr-standard',
credentials: {
accessKeyId: process.env.NAVER_CLOUD_KEY,
secretAccessKey: process.env.NAVER_CLOUD_SECRET,
},
});
const result = await S3.putObject({
// S3 버킷에 객체 업로드
Bucket: 'aresa-assets',
Key: file.filename, // 파일 이름
ACL: 'public-read', // 공개 읽기 권한 설정
Body: fs.createReadStream(join(__dirname, 'temp', file.filename)), // 파일의 스트림을 업로드
}).promise();
fs.rmSync(join(__dirname, 'temp', file.filename)); // 업로드 완료 후 임시 파일 삭제
return `${process.env.NAVER_CLOUD_BUCKET_ENDPOINT}/${file.filename}`; // 업로드된 파일의 공개 URL 반환
}
}
10. 서비스
11. 필요한 의존성 모듈 설치
https://www.npmjs.com/package/aws-sdk
aws-sdk
AWS SDK for JavaScript. Latest version: 2.1400.0, last published: 9 hours ago. Start using aws-sdk in your project by running `npm i aws-sdk`. There are 20203 other projects in the npm registry using aws-sdk.
www.npmjs.com
https://www.npmjs.com/package/fs
fs
This package name is not currently in use, but was formerly occupied by another package. To avoid malicious use, npm is hanging on to the package name, but loosely, and we'll probably give it to you if you want it.. Latest version: 0.0.1-security, last pub
www.npmjs.com
https://www.npmjs.com/package/multer
multer
Middleware for handling `multipart/form-data`.. Latest version: 1.4.5-lts.1, last published: a year ago. Start using multer in your project by running `npm i multer`. There are 3802 other projects in the npm registry using multer.
www.npmjs.com
'IT' 카테고리의 다른 글
Apexchart toggle custom 아펙스차트 토글 커스텀 예시 (0) | 2023.08.19 |
---|---|
Prisma foreign key 배열 조회하여 등록하기 예시 / prisma connect (0) | 2023.08.18 |
nestjs search 검색 controller service 기본 구조 예시 feat.Prisma (0) | 2023.08.16 |
Angular 컴포넌트 cli 생성 시 앞에 이름 / prefix 수정하기 (0) | 2023.08.15 |
CSS tag 예시 / tailwind 태그 줄바꿈 (0) | 2023.08.14 |