first commit
This commit is contained in:
		
							
								
								
									
										5
									
								
								.env
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								.env
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,5 @@
 | 
			
		||||
//DATABASE_URL="mysql://learnsteam:fbOgZ6Xxn5VXBYihjqygRXyaK6ZUgKL6@jongyeob.com/learnsteam_quiz"
 | 
			
		||||
NEXTAUTH_URL=https://learnsteam-quiz.jongyeob.com
 | 
			
		||||
NEXT_PUBLIC_HOST=https://learnsteam-quiz.jongyeob.com
 | 
			
		||||
NEXT_PUBLIC_API_ENDPOINT=https://learnsteam-quiz-api.jongyeob.com
 | 
			
		||||
NEXTAUTH_SECRET=dd87d8220ed9535d3152910f62ca40fb8ae292ac527e9528425118406aebbce0
 | 
			
		||||
							
								
								
									
										5
									
								
								.env.dev.sample
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								.env.dev.sample
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,5 @@
 | 
			
		||||
//DATABASE_URL="mysql://root:fbOgZ6Xxn5VXBYihjqygRXyaK6ZUgKL6@db/learnsteam_quiz"
 | 
			
		||||
DATABASE_URL="mysql://learnsteam:fbOgZ6Xxn5VXBYihjqygRXyaK6ZUgKL6@127.0.0.1/learnsteam_quiz"
 | 
			
		||||
NEXTAUTH_URL=https://learnsteam-quiz.jongyeob.com
 | 
			
		||||
NEXT_PUBLIC_API_ENDPOINT=https://learnsteam-quiz.jongyeob.com
 | 
			
		||||
NEXTAUTH_SECRET=dd87d8220ed9535d3152910f62ca40fb8ae292ac527e9528425118406aebbce0
 | 
			
		||||
							
								
								
									
										7
									
								
								.env.local.sample
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								.env.local.sample
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
			
		||||
//DATABASE_URL="mysql://learnsteam:fbOgZ6Xxn5VXBYihjqygRXyaK6ZUgKL6@jongyeob.com/learnsteam_quiz"
 | 
			
		||||
//DATABASE_URL="mysql://root:fbOgZ6Xxn5VXBYihjqygRXyaK6ZUgKL6@db/learnsteam_quiz"
 | 
			
		||||
DATABASE_URL="mysql://learnsteam:fbOgZ6Xxn5VXBYihjqygRXyaK6ZUgKL6@127.0.0.1/learnsteam_quiz"
 | 
			
		||||
NEXTAUTH_URL=http://localhost:3000
 | 
			
		||||
NEXT_PUBLIC_HOST=http://localhost:3000
 | 
			
		||||
NEXT_PUBLIC_API_ENDPOINT=http://localhost:3000
 | 
			
		||||
NEXTAUTH_SECRET=dd87d8220ed9535d3152910f62ca40fb8ae292ac527e9528425118406aebbce0
 | 
			
		||||
							
								
								
									
										5
									
								
								.env.prod.sample
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								.env.prod.sample
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,5 @@
 | 
			
		||||
//DATABASE_URL="mysql://learnsteam:fbOgZ6Xxn5VXBYihjqygRXyaK6ZUgKL6@jongyeob.com/learnsteam_quiz"
 | 
			
		||||
NEXTAUTH_URL=https://learnsteam-quiz.jongyeob.com
 | 
			
		||||
NEXT_PUBLIC_HOST=https://learnsteam-quiz.jongyeob.com
 | 
			
		||||
NEXT_PUBLIC_API_ENDPOINT=https://learnsteam-quiz-api.jongyeob.com
 | 
			
		||||
NEXTAUTH_SECRET=dd87d8220ed9535d3152910f62ca40fb8ae292ac527e9528425118406aebbce0
 | 
			
		||||
							
								
								
									
										6
									
								
								.eslintrc.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								.eslintrc.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
  "extends": ["next/core-web-vitals", "prettier"],
 | 
			
		||||
  "rules": {
 | 
			
		||||
    "@next/next/no-img-element": "off"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										44
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,44 @@
 | 
			
		||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
 | 
			
		||||
 | 
			
		||||
# dependencies
 | 
			
		||||
/node_modules
 | 
			
		||||
/.pnp
 | 
			
		||||
.pnp.js
 | 
			
		||||
.yarn/install-state.gz
 | 
			
		||||
 | 
			
		||||
# testing
 | 
			
		||||
/coverage
 | 
			
		||||
 | 
			
		||||
# next.js
 | 
			
		||||
/.next/
 | 
			
		||||
/out/
 | 
			
		||||
 | 
			
		||||
# production
 | 
			
		||||
/build
 | 
			
		||||
 | 
			
		||||
# misc
 | 
			
		||||
.DS_Store
 | 
			
		||||
*.pem
 | 
			
		||||
 | 
			
		||||
# debug
 | 
			
		||||
npm-debug.log*
 | 
			
		||||
yarn-debug.log*
 | 
			
		||||
yarn-error.log*
 | 
			
		||||
 | 
			
		||||
# local env files
 | 
			
		||||
.env*.local
 | 
			
		||||
 | 
			
		||||
# vercel
 | 
			
		||||
.vercel
 | 
			
		||||
 | 
			
		||||
# typescript
 | 
			
		||||
*.tsbuildinfo
 | 
			
		||||
next-env.d.ts
 | 
			
		||||
 | 
			
		||||
# Elastic Beanstalk Files
 | 
			
		||||
.elasticbeanstalk/*
 | 
			
		||||
!.elasticbeanstalk/*.cfg.yml
 | 
			
		||||
!.elasticbeanstalk/*.global.yml
 | 
			
		||||
 | 
			
		||||
eb.zip
 | 
			
		||||
Dockerrun.aws.json
 | 
			
		||||
							
								
								
									
										19
									
								
								.prettierrc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								.prettierrc
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,19 @@
 | 
			
		||||
{
 | 
			
		||||
  "arrowParens": "always",
 | 
			
		||||
  "bracketSameLine": false,
 | 
			
		||||
  "bracketSpacing": true,
 | 
			
		||||
  "embeddedLanguageFormatting": "auto",
 | 
			
		||||
  "htmlWhitespaceSensitivity": "css",
 | 
			
		||||
  "insertPragma": false,
 | 
			
		||||
  "jsxSingleQuote": false,
 | 
			
		||||
  "printWidth": 120,
 | 
			
		||||
  "proseWrap": "preserve",
 | 
			
		||||
  "quoteProps": "as-needed",
 | 
			
		||||
  "requirePragma": false,
 | 
			
		||||
  "semi": false,
 | 
			
		||||
  "singleQuote": false,
 | 
			
		||||
  "tabWidth": 2,
 | 
			
		||||
  "trailingComma": "es5",
 | 
			
		||||
  "useTabs": false,
 | 
			
		||||
  "vueIndentScriptAndStyle": false
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										94
									
								
								Makefile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								Makefile
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,94 @@
 | 
			
		||||
NAME 			=learnsteam/learnsteam-quiz
 | 
			
		||||
ECR				=384080715421.dkr.ecr.ap-northeast-2.amazonaws.com
 | 
			
		||||
REPOSITORY=$(ECR)/$(NAME)
 | 
			
		||||
VERSION		=v1.0.0
 | 
			
		||||
BUILD 		=1
 | 
			
		||||
TAG 			=$(VERSION)-$(BUILD)
 | 
			
		||||
TIMESTAMP =`date +%s`
 | 
			
		||||
PLATFORM	=linux/amd64
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
default: help
 | 
			
		||||
 | 
			
		||||
env.local: ## env for local
 | 
			
		||||
	@cp .env.local.sample .env
 | 
			
		||||
 | 
			
		||||
env.dev: ## env for development
 | 
			
		||||
	@cp .env.dev.sample .env
 | 
			
		||||
 | 
			
		||||
env.prod:	## env for production
 | 
			
		||||
	@cp .env.prod.sample .env
 | 
			
		||||
 | 
			
		||||
run: env.local	## run local
 | 
			
		||||
	@echo "\033[32mRunning ...\033[0m"
 | 
			
		||||
	@yarn dev
 | 
			
		||||
.PHONY: run
 | 
			
		||||
 | 
			
		||||
build: env.prod	## build for production
 | 
			
		||||
	@echo "\033[32mBuilding ...\033[0m"
 | 
			
		||||
	@yarn build
 | 
			
		||||
 | 
			
		||||
build.prod: env.prod	## build for production with env
 | 
			
		||||
	@echo "\033[32mBuilding ... for production\033[0m"
 | 
			
		||||
	@yarn build
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
docker.build.local: ## build docker for local running
 | 
			
		||||
	@echo "\033[32mDocker for local ...\033[0m"
 | 
			
		||||
	@docker compose -f docker/local/docker-compose.yml build --no-cache
 | 
			
		||||
	@docker compose -f docker/local/docker-compose.yml create
 | 
			
		||||
.PHONY: docker.build.local
 | 
			
		||||
 | 
			
		||||
docker.start.local: ## start docker for local
 | 
			
		||||
	@echo "\033[32mDocker start ...\033[0m"
 | 
			
		||||
	@docker compose -f docker/local/docker-compose.yml up -d
 | 
			
		||||
.PHONY: docker.start.local
 | 
			
		||||
 | 
			
		||||
docker.stop.local:    ## stop docker for local
 | 
			
		||||
	@echo "\033[32mDocker stop ...\033[0m"
 | 
			
		||||
	@docker compose -f docker/local/docker-compose.yml down
 | 
			
		||||
.PHONY: docker.stop.local
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
docker.build.dev: ## build docker for development
 | 
			
		||||
	@echo "\033[32mDocker for development ...\033[0m"
 | 
			
		||||
	@docker compose -f docker/dev/docker-compose.yml build --no-cache
 | 
			
		||||
	@docker compose -f docker/dev/docker-compose.yml create
 | 
			
		||||
.PHONY: docker.build.dev
 | 
			
		||||
 | 
			
		||||
docker.start.dev: ## start docker for development
 | 
			
		||||
	@echo "\033[32mDocker start for development...\033[0m"
 | 
			
		||||
	@docker compose -f docker/dev/docker-compose.yml up -d
 | 
			
		||||
.PHONY: docker.start.dev
 | 
			
		||||
 | 
			
		||||
docker.stop.dev: ## stop docker for development
 | 
			
		||||
	@echo "\033[32mDocker stop for development...\033[0m"
 | 
			
		||||
	@docker compose -f docker/dev/docker-compose.yml down
 | 
			
		||||
.PHONY: docker.stop.dev
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
docker.build.prod: ## build docker for production
 | 
			
		||||
	@echo "\033[32mDocker for production ...\033[0m"
 | 
			
		||||
	@docker compose -f docker/prod/docker-compose.yml build --no-cache
 | 
			
		||||
	@docker compose -f docker/prod/docker-compose.yml create
 | 
			
		||||
.PHONY: docker.build.prod
 | 
			
		||||
 | 
			
		||||
docker.start.prod: ## start docker for production
 | 
			
		||||
	@echo "\033[32mDocker start for production...\033[0m"
 | 
			
		||||
	@docker compose -f docker/prod/docker-compose.yml up -d
 | 
			
		||||
.PHONY: docker.start.prod
 | 
			
		||||
 | 
			
		||||
docker.stop.prod: ## stop docker for production
 | 
			
		||||
	@echo "\033[32mDocker stop for production...\033[0m"
 | 
			
		||||
	@docker compose -f docker/prod/docker-compose.yml down
 | 
			
		||||
.PHONY: docker.stop.prod
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
clean:
 | 
			
		||||
	@echo "\033[32mCleaning...\033[0m"
 | 
			
		||||
	@rm -rf .next
 | 
			
		||||
.PHONY: clean
 | 
			
		||||
 | 
			
		||||
help: ## Show help for each of the Makefile recipes.
 | 
			
		||||
	@grep -E '^[a-zA-Z0-9 -.]+:.*#'  $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
 | 
			
		||||
.PHONY: help
 | 
			
		||||
							
								
								
									
										42
									
								
								docker/dev/Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								docker/dev/Dockerfile
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,42 @@
 | 
			
		||||
FROM node:21-alpine AS base
 | 
			
		||||
 | 
			
		||||
FROM base AS deps
 | 
			
		||||
RUN apk add --no-cache libc6-compat
 | 
			
		||||
WORKDIR /app
 | 
			
		||||
COPY package.json yarn.lock ./
 | 
			
		||||
ENV NEXT_TELEMETRY_DISABLED 0
 | 
			
		||||
# RUN yarn install --production=true
 | 
			
		||||
RUN yarn --frozen-lockfile
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
FROM base AS builder
 | 
			
		||||
WORKDIR /app
 | 
			
		||||
COPY .env.dev.sample .env
 | 
			
		||||
COPY . .
 | 
			
		||||
ENV NEXT_TELEMETRY_DISABLED 0
 | 
			
		||||
RUN yarn upgrade
 | 
			
		||||
RUN npx prisma generate
 | 
			
		||||
RUN yarn build
 | 
			
		||||
RUN npm prune --production
 | 
			
		||||
 | 
			
		||||
FROM alpine AS runner
 | 
			
		||||
RUN apk add --no-cache nodejs
 | 
			
		||||
WORKDIR /app
 | 
			
		||||
ENV NODE_ENV production
 | 
			
		||||
RUN addgroup --system --gid 1001 nodejs
 | 
			
		||||
RUN adduser --system --uid 1001 nextjs
 | 
			
		||||
 | 
			
		||||
COPY --from=builder --chown=nextjs:nodejs /app/next.config.js ./
 | 
			
		||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
 | 
			
		||||
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
 | 
			
		||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
 | 
			
		||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
 | 
			
		||||
# COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
 | 
			
		||||
# COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
 | 
			
		||||
# COPY --from=builder --chown=nextjs:nodejs /app/start.sh .
 | 
			
		||||
 | 
			
		||||
USER nextjs
 | 
			
		||||
EXPOSE 3000
 | 
			
		||||
ENV PORT 3000
 | 
			
		||||
 | 
			
		||||
CMD ["sh", "start.sh"]
 | 
			
		||||
							
								
								
									
										10
									
								
								docker/dev/docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								docker/dev/docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
			
		||||
version: "3"
 | 
			
		||||
 | 
			
		||||
services:
 | 
			
		||||
  app:
 | 
			
		||||
    build:
 | 
			
		||||
      context: ../../
 | 
			
		||||
      dockerfile: docker/dev/Dockerfile
 | 
			
		||||
    image: learnsteam/learnsteam-quiz:dev
 | 
			
		||||
    ports:
 | 
			
		||||
      - "3000:3000"
 | 
			
		||||
							
								
								
									
										43
									
								
								docker/local/Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								docker/local/Dockerfile
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,43 @@
 | 
			
		||||
FROM node:alpine AS base
 | 
			
		||||
 | 
			
		||||
FROM base AS deps
 | 
			
		||||
RUN apk add --no-cache libc6-compat
 | 
			
		||||
WORKDIR /app
 | 
			
		||||
COPY package.json yarn.lock ./
 | 
			
		||||
ENV NEXT_TELEMETRY_DISABLED 0
 | 
			
		||||
# RUN yarn install --production=true
 | 
			
		||||
RUN yarn --frozen-lockfile
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
FROM base AS builder
 | 
			
		||||
WORKDIR /app
 | 
			
		||||
COPY .env.local.sample .env.production
 | 
			
		||||
COPY . .
 | 
			
		||||
ENV NEXT_TELEMETRY_DISABLED 0
 | 
			
		||||
RUN npx prisma generate
 | 
			
		||||
RUN yarn build
 | 
			
		||||
RUN npm prune --production
 | 
			
		||||
COPY ./script/start.sh .
 | 
			
		||||
 | 
			
		||||
# FROM alpine AS runner
 | 
			
		||||
# RUN apk add --no-cache nodejs npm
 | 
			
		||||
# RUN npm install npx -g
 | 
			
		||||
# WORKDIR /app
 | 
			
		||||
 | 
			
		||||
ENV NODE_ENV production
 | 
			
		||||
RUN addgroup --system --gid 1001 nodejs
 | 
			
		||||
RUN adduser --system --uid 1001 nextjs
 | 
			
		||||
 | 
			
		||||
COPY --from=builder --chown=nextjs:nodejs /app/next.config.js ./
 | 
			
		||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
 | 
			
		||||
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
 | 
			
		||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
 | 
			
		||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
 | 
			
		||||
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
 | 
			
		||||
COPY --from=builder --chown=nextjs:nodejs /app/start.sh .
 | 
			
		||||
 | 
			
		||||
USER nextjs
 | 
			
		||||
EXPOSE 3000
 | 
			
		||||
ENV PORT 3000
 | 
			
		||||
 | 
			
		||||
CMD ["sh", "start.sh"]
 | 
			
		||||
							
								
								
									
										30
									
								
								docker/local/docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								docker/local/docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,30 @@
 | 
			
		||||
version: "3"
 | 
			
		||||
 | 
			
		||||
services:
 | 
			
		||||
  db:
 | 
			
		||||
    image: mysql:latest
 | 
			
		||||
    environment:
 | 
			
		||||
      MYSQL_ROOT_PASSWORD: fbOgZ6Xxn5VXBYihjqygRXyaK6ZUgKL6
 | 
			
		||||
      MYSQL_DATABASE: learnsteam_quiz
 | 
			
		||||
    ports:
 | 
			
		||||
      - "33062:3306"
 | 
			
		||||
    volumes:
 | 
			
		||||
      - db-data:/var/lib/mysql
 | 
			
		||||
  app:
 | 
			
		||||
    build:
 | 
			
		||||
      context: ../../
 | 
			
		||||
      dockerfile: docker/local/Dockerfile
 | 
			
		||||
    image: learnsteam/learnsteam-quiz:local
 | 
			
		||||
    ports:
 | 
			
		||||
      - "3000:3000"
 | 
			
		||||
    environment:
 | 
			
		||||
      DB_HOST: db
 | 
			
		||||
      DB_PORT: 3306
 | 
			
		||||
      DB_USER: root
 | 
			
		||||
      DB_PASSWORD: fbOgZ6Xxn5VXBYihjqygRXyaK6ZUgKL6
 | 
			
		||||
      DB_NAME: learnsteam_quiz
 | 
			
		||||
    depends_on:
 | 
			
		||||
      - db
 | 
			
		||||
    restart: always
 | 
			
		||||
volumes:
 | 
			
		||||
  db-data:
 | 
			
		||||
							
								
								
									
										34
									
								
								docker/prod/Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								docker/prod/Dockerfile
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,34 @@
 | 
			
		||||
FROM node:alpine AS base
 | 
			
		||||
 | 
			
		||||
FROM base AS deps
 | 
			
		||||
RUN apk update && apk add --no-cache libc6-compat
 | 
			
		||||
WORKDIR /app
 | 
			
		||||
COPY package.json ./
 | 
			
		||||
RUN npm install
 | 
			
		||||
 | 
			
		||||
FROM base AS builder
 | 
			
		||||
WORKDIR /app
 | 
			
		||||
COPY .env.prod.sample .env.production
 | 
			
		||||
COPY --from=deps /app/node_modules ./node_modules
 | 
			
		||||
COPY . .
 | 
			
		||||
RUN npx prisma generate
 | 
			
		||||
RUN npm run build
 | 
			
		||||
 | 
			
		||||
FROM alpine AS runner
 | 
			
		||||
RUN apk add --no-cache nodejs
 | 
			
		||||
WORKDIR /app
 | 
			
		||||
ENV NODE_ENV production
 | 
			
		||||
RUN addgroup --system --gid 1001 nodejs
 | 
			
		||||
RUN adduser --system --uid 1001 nextjs
 | 
			
		||||
 | 
			
		||||
COPY --from=builder --chown=nextjs:nodejs /app/next.config.js ./
 | 
			
		||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
 | 
			
		||||
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
 | 
			
		||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
 | 
			
		||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
 | 
			
		||||
 | 
			
		||||
USER nextjs
 | 
			
		||||
EXPOSE 3000
 | 
			
		||||
ENV PORT 3000
 | 
			
		||||
 | 
			
		||||
CMD ["node", "server.js"]
 | 
			
		||||
							
								
								
									
										10
									
								
								docker/prod/docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								docker/prod/docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
			
		||||
version: "3"
 | 
			
		||||
 | 
			
		||||
services:
 | 
			
		||||
  superrichquiz-app:
 | 
			
		||||
    build:
 | 
			
		||||
      context: ../../
 | 
			
		||||
      dockerfile: docker/prod/Dockerfile
 | 
			
		||||
    image: learnsteam/learnsteam-quiz
 | 
			
		||||
    ports:
 | 
			
		||||
      - "3100:3000"
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								fonts/PretendardVariable.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								fonts/PretendardVariable.woff2
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										22
									
								
								next.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								next.config.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
			
		||||
/** @type {import('next').NextConfig} */
 | 
			
		||||
const nextConfig = {
 | 
			
		||||
  swcMinify: true,
 | 
			
		||||
  transpilePackages: ["antd"],
 | 
			
		||||
  images: {
 | 
			
		||||
    domains: [],
 | 
			
		||||
  },
 | 
			
		||||
  output: "standalone",
 | 
			
		||||
  eslint: {
 | 
			
		||||
    ignoreDuringBuilds: true,
 | 
			
		||||
  },
 | 
			
		||||
  rewrites: async () => {
 | 
			
		||||
    return [
 | 
			
		||||
      {
 | 
			
		||||
        source: "/healthcheck",
 | 
			
		||||
        destination: "/api/healthcheck",
 | 
			
		||||
      },
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = nextConfig
 | 
			
		||||
							
								
								
									
										4341
									
								
								package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										4341
									
								
								package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										47
									
								
								package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								package.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,47 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "learnsteam-quiz",
 | 
			
		||||
  "version": "0.1.0",
 | 
			
		||||
  "private": true,
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "dev": "next dev",
 | 
			
		||||
    "build": "next build",
 | 
			
		||||
    "start": "next start",
 | 
			
		||||
    "lint": "next lint"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@ant-design/cssinjs": "^1.17.2",
 | 
			
		||||
    "@prisma/client": "5.5.2",
 | 
			
		||||
    "@uiw/react-markdown-preview": "^4.2.2",
 | 
			
		||||
    "@uiw/react-md-editor": "^3.25.2",
 | 
			
		||||
    "antd": "^5.10.3",
 | 
			
		||||
    "dayjs": "^1.11.10",
 | 
			
		||||
    "framer-motion": "^10.16.4",
 | 
			
		||||
    "ky": "^1.1.3",
 | 
			
		||||
    "ky-universal": "^0.12.0",
 | 
			
		||||
    "lucide-react": "^0.291.0",
 | 
			
		||||
    "moment": "^2.29.4",
 | 
			
		||||
    "next": "14.0.1",
 | 
			
		||||
    "numeral": "^2.0.6",
 | 
			
		||||
    "qs": "^6.11.2",
 | 
			
		||||
    "react": "^18",
 | 
			
		||||
    "react-dom": "^18",
 | 
			
		||||
    "react-tag-input-component": "^2.0.2",
 | 
			
		||||
    "swr": "^2.2.4"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@types/node": "^20",
 | 
			
		||||
    "@types/numeral": "^2.0.4",
 | 
			
		||||
    "@types/qs": "^6.9.9",
 | 
			
		||||
    "@types/react": "^18",
 | 
			
		||||
    "@types/react-dom": "^18",
 | 
			
		||||
    "autoprefixer": "^10.0.1",
 | 
			
		||||
    "eslint": "^8",
 | 
			
		||||
    "eslint-config-next": "14.0.1",
 | 
			
		||||
    "eslint-config-prettier": "^9.0.0",
 | 
			
		||||
    "postcss": "^8",
 | 
			
		||||
    "prettier": "^3.0.3",
 | 
			
		||||
    "prisma": "5.5.2",
 | 
			
		||||
    "tailwindcss": "^3.3.0",
 | 
			
		||||
    "typescript": "^5"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										6
									
								
								postcss.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								postcss.config.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
			
		||||
module.exports = {
 | 
			
		||||
  plugins: {
 | 
			
		||||
    tailwindcss: {},
 | 
			
		||||
    autoprefixer: {},
 | 
			
		||||
  },
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										66
									
								
								prisma/schema.prisma
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								prisma/schema.prisma
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,66 @@
 | 
			
		||||
generator client {
 | 
			
		||||
  provider = "prisma-client-js"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
datasource db {
 | 
			
		||||
  provider = "mysql"
 | 
			
		||||
  url      = env("DATABASE_URL")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model Admin {
 | 
			
		||||
  id            String    @id @default(cuid()) @db.VarChar(255)
 | 
			
		||||
  name          String    @db.VarChar(40)
 | 
			
		||||
  email         String    @db.VarChar(255)
 | 
			
		||||
  social_id     String    @db.VarChar(40)
 | 
			
		||||
  login_type    String    @db.VarChar(10)
 | 
			
		||||
  picture       String    @db.VarChar(255)
 | 
			
		||||
  accesstoken   String    @db.VarChar(255)
 | 
			
		||||
  updated_at    DateTime  @default(now())
 | 
			
		||||
  created_at    DateTime  @default(now())
 | 
			
		||||
 | 
			
		||||
  @@map("admin")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model Program {
 | 
			
		||||
  id            String    @id @default(cuid()) @db.VarChar(255)
 | 
			
		||||
  course        String    @db.VarChar(40)
 | 
			
		||||
  subject       String    @db.VarChar(255)
 | 
			
		||||
  content       String    @db.VarChar(512)
 | 
			
		||||
  tag           Json
 | 
			
		||||
  status        String    @db.VarChar(10)
 | 
			
		||||
  publish_at    DateTime  @db.Date
 | 
			
		||||
  updated_at    DateTime  @default(now())
 | 
			
		||||
  created_at    DateTime  @default(now())
 | 
			
		||||
  quizzes       Quiz[]
 | 
			
		||||
 | 
			
		||||
  @@map("program")
 | 
			
		||||
 | 
			
		||||
  @@index([course])
 | 
			
		||||
  @@index([tag])
 | 
			
		||||
  @@index([status])
 | 
			
		||||
  @@index([updated_at])
 | 
			
		||||
  @@index([created_at])
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model Quiz {
 | 
			
		||||
  id            String    @id @default(cuid())
 | 
			
		||||
  program_id    String    @db.VarChar(255)
 | 
			
		||||
  sequence      Int
 | 
			
		||||
  quiz_type     String    @db.VarChar(10)
 | 
			
		||||
  question      String    @db.VarChar(512)
 | 
			
		||||
  choice        Json
 | 
			
		||||
  answer        Json
 | 
			
		||||
  comment       String    @db.VarChar(512)
 | 
			
		||||
  hint          String    @db.VarChar(512)
 | 
			
		||||
  updated_at    DateTime  @default(now())
 | 
			
		||||
  created_at    DateTime  @default(now())
 | 
			
		||||
  program       Program?  @relation(fields: [program_id], references: [id])
 | 
			
		||||
 | 
			
		||||
  @@map("quiz")
 | 
			
		||||
 | 
			
		||||
  @@index([program_id])
 | 
			
		||||
  @@index([sequence])
 | 
			
		||||
  @@index([quiz_type])
 | 
			
		||||
  @@index([updated_at])
 | 
			
		||||
  @@index([created_at])
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								public/favicon.ico
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/favicon.ico
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 25 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								public/images/logo.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/images/logo.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 15 KiB  | 
							
								
								
									
										1
									
								
								public/next.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								public/next.svg
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 1.3 KiB  | 
							
								
								
									
										1
									
								
								public/vercel.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								public/vercel.svg
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 629 B  | 
							
								
								
									
										11
									
								
								src/client/base.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/client/base.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
import ky from "ky-universal"
 | 
			
		||||
 | 
			
		||||
export const fetcher = (input: URL | RequestInfo, init?: RequestInit | undefined) =>
 | 
			
		||||
  ky(`${process.env.NEXT_PUBLIC_API_ENDPOINT}/${input}`, init).then((res) => res.json())
 | 
			
		||||
 | 
			
		||||
export const fetchApi = ky.create({
 | 
			
		||||
  prefixUrl: process.env.NEXT_PUBLIC_API_ENDPOINT,
 | 
			
		||||
  headers: {
 | 
			
		||||
    "Content-Type": "application/json",
 | 
			
		||||
  },
 | 
			
		||||
})
 | 
			
		||||
							
								
								
									
										56
									
								
								src/client/program.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								src/client/program.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,56 @@
 | 
			
		||||
import qs from "qs"
 | 
			
		||||
import useSWR from "swr"
 | 
			
		||||
import { fetchApi } from "./base"
 | 
			
		||||
 | 
			
		||||
export interface IProgram {
 | 
			
		||||
  id: string
 | 
			
		||||
  subject: string
 | 
			
		||||
  content: string
 | 
			
		||||
  tag: string[]
 | 
			
		||||
  status: string
 | 
			
		||||
  updated_at: Date
 | 
			
		||||
  created_at: Date
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface ProgramFormValue extends Omit<IProgram, "id" | "created_at" | "updated_at" | "status"> {}
 | 
			
		||||
 | 
			
		||||
interface ProgramParams {
 | 
			
		||||
  id?: string
 | 
			
		||||
  q?: string
 | 
			
		||||
  tag?: string
 | 
			
		||||
  page?: number
 | 
			
		||||
  limit?: number
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface ProgramResponse {
 | 
			
		||||
  message: string
 | 
			
		||||
  data: any
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface ProgramsResponse {
 | 
			
		||||
  message: string
 | 
			
		||||
  data: IProgram[]
 | 
			
		||||
  total: number
 | 
			
		||||
  page: number
 | 
			
		||||
  totalPage: number
 | 
			
		||||
  pageSize: number
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const usePrograms = (params: ProgramParams = {}) => {
 | 
			
		||||
  const url = `api/program?${qs.stringify(params)}`
 | 
			
		||||
  return useSWR<ProgramsResponse>(url)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const useProgram = (params: ProgramParams = {}) => {
 | 
			
		||||
  const id = params.id ? (params.id as string) : ""
 | 
			
		||||
  const url = `api/program/${id}`
 | 
			
		||||
  return useSWR<ProgramResponse>(url)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const create = (value: ProgramFormValue) => {
 | 
			
		||||
  return fetchApi.post(`api/program`, { body: JSON.stringify(value) })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const update = (id: string, value: ProgramFormValue) => {
 | 
			
		||||
  return fetchApi.put(`api/program/${id}`, { body: JSON.stringify(value) })
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										69
									
								
								src/client/quiz.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								src/client/quiz.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,69 @@
 | 
			
		||||
import qs from "qs"
 | 
			
		||||
import useSWR from "swr"
 | 
			
		||||
import { fetchApi } from "./base"
 | 
			
		||||
 | 
			
		||||
export interface IQuiz {
 | 
			
		||||
  id: string
 | 
			
		||||
  program_id: string
 | 
			
		||||
  sequence: number
 | 
			
		||||
  question: string
 | 
			
		||||
  quiz_type: string
 | 
			
		||||
  choice?: string[]
 | 
			
		||||
  choice1?: string
 | 
			
		||||
  choice2?: string
 | 
			
		||||
  choice3?: string
 | 
			
		||||
  choice4?: string
 | 
			
		||||
  answer?: string[]
 | 
			
		||||
  answer1?: string
 | 
			
		||||
  answer2?: string
 | 
			
		||||
  answer3?: string
 | 
			
		||||
  answer4?: string
 | 
			
		||||
  hint: string
 | 
			
		||||
  comment: string
 | 
			
		||||
  status: string
 | 
			
		||||
  updated_at: Date
 | 
			
		||||
  created_at: Date
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface QuizFormValue extends Omit<IQuiz, "id" | "created_at" | "updated_at" | "status"> {}
 | 
			
		||||
 | 
			
		||||
interface QuizParams {
 | 
			
		||||
  id?: any
 | 
			
		||||
  program_id?: string
 | 
			
		||||
  page?: number
 | 
			
		||||
  limit?: number
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface QuizResponse {
 | 
			
		||||
  message: string
 | 
			
		||||
  data: any
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface QuizzesResponse {
 | 
			
		||||
  message: string
 | 
			
		||||
  data: IQuiz[]
 | 
			
		||||
  total: number
 | 
			
		||||
  page: number
 | 
			
		||||
  totalPage: number
 | 
			
		||||
  pageSize: number
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const useQuizzes = (params: QuizParams = {}) => {
 | 
			
		||||
  const url = `api/quiz?${qs.stringify(params)}`
 | 
			
		||||
  return useSWR<QuizzesResponse>(url)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const useQuiz = (params: QuizParams = {}) => {
 | 
			
		||||
  const id = params.id ? (params.id as string) : ""
 | 
			
		||||
  const url = `api/quiz/${id}`
 | 
			
		||||
  return useSWR<QuizResponse>(url)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const create = (value: QuizFormValue) => {
 | 
			
		||||
  console.log("create", value)
 | 
			
		||||
  return fetchApi.post(`api/quiz`, { body: JSON.stringify(value) })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const update = (id: string, value: QuizFormValue) => {
 | 
			
		||||
  return fetchApi.put(`api/quiz/${id}`, { body: JSON.stringify(value) })
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										63
									
								
								src/client/user.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								src/client/user.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,63 @@
 | 
			
		||||
import qs from "qs"
 | 
			
		||||
import useSWR from "swr"
 | 
			
		||||
import { fetchApi } from "./base"
 | 
			
		||||
 | 
			
		||||
export interface IUser {
 | 
			
		||||
  id: string
 | 
			
		||||
  name: string
 | 
			
		||||
  email: string
 | 
			
		||||
  social_id: string
 | 
			
		||||
  login_type: string
 | 
			
		||||
  picture: string
 | 
			
		||||
  tier: number
 | 
			
		||||
  rct: number
 | 
			
		||||
  rxt: number
 | 
			
		||||
  coin: number
 | 
			
		||||
  exp: number
 | 
			
		||||
  status: string
 | 
			
		||||
  accesstoken: string
 | 
			
		||||
  updated_at: Date
 | 
			
		||||
  created_at: Date
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IUserFormValue extends Omit<IUser, "id" | "created_at" | "updated_at"> {}
 | 
			
		||||
 | 
			
		||||
interface UserParams {
 | 
			
		||||
  accesstoken?: string
 | 
			
		||||
  name?: string
 | 
			
		||||
  email?: string
 | 
			
		||||
  page?: number
 | 
			
		||||
  limit?: number
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface UsersResponse {
 | 
			
		||||
  message: string
 | 
			
		||||
  data: IUser[]
 | 
			
		||||
  page: {
 | 
			
		||||
    pageNumber: number
 | 
			
		||||
    pageSize: number
 | 
			
		||||
    totalPage: number
 | 
			
		||||
    totalNumber: number
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface ItemResponse {
 | 
			
		||||
  message: string
 | 
			
		||||
  data: IUser
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const useUsers = (params: UserParams = {}) => {
 | 
			
		||||
  return useSWR<UsersResponse>(`api/user?${qs.stringify(params)}`)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const useUser = (id: string) => {
 | 
			
		||||
  return useSWR<ItemResponse>(`api/user/${id}`)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const create = (value: IUserFormValue) => {
 | 
			
		||||
  return fetchApi.post(`api/user`, { body: JSON.stringify(value) })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const update = (id: string, value: IUserFormValue) => {
 | 
			
		||||
  return fetchApi.put(`api/user/${id}`, { body: JSON.stringify(value) })
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										46
									
								
								src/components/layout/default-layout.module.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/components/layout/default-layout.module.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,46 @@
 | 
			
		||||
.sidebar {
 | 
			
		||||
  @apply fixed top-0 left-0 bottom-0 p-5 bg-gray-100 w-72 overflow-hidden;
 | 
			
		||||
  box-shadow: inset -7px 0 9px -7px rgb(0 0 0 / 0.1);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.menu-wrapper {
 | 
			
		||||
  position: relative;
 | 
			
		||||
  width: 16px;
 | 
			
		||||
  height: 14px;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.menu-bar {
 | 
			
		||||
  left: 0;
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  width: 16px;
 | 
			
		||||
  height: 2px;
 | 
			
		||||
  background-color: #000;
 | 
			
		||||
  transition: top 0.25s, background-color 0.5s, transform 0.25s;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.menu-top {
 | 
			
		||||
  top: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.menu-top:global(.active) {
 | 
			
		||||
  top: 6px;
 | 
			
		||||
  transform: translate(0) rotate(225deg);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.menu-middle {
 | 
			
		||||
  top: 6px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.menu-middle:global(.active) {
 | 
			
		||||
  transform: translate(16px);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.menu-bottom {
 | 
			
		||||
  top: 12px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.menu-bottom:global(.active) {
 | 
			
		||||
  top: 6px;
 | 
			
		||||
  transform: translate(0) rotate(135deg);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										116
									
								
								src/components/layout/default-layout.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								src/components/layout/default-layout.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,116 @@
 | 
			
		||||
import { motion } from "framer-motion"
 | 
			
		||||
import { ChevronRight, Menu as MenuIcon } from "lucide-react"
 | 
			
		||||
import { NextComponentType, NextPage } from "next"
 | 
			
		||||
import { useRouter } from "next/router"
 | 
			
		||||
import { useCallback, useEffect, useState } from "react"
 | 
			
		||||
import MainMenu from "./main-menu"
 | 
			
		||||
import MenuBtn from "./menu-btn"
 | 
			
		||||
import PageHeader from "./page-header"
 | 
			
		||||
import Sidebar from "./sidebar"
 | 
			
		||||
 | 
			
		||||
export interface IPageHeader {
 | 
			
		||||
  title: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type IDefaultLayoutPage<P = {}> = NextPage<P> & {
 | 
			
		||||
  getLayout(page: NextComponentType, props: unknown): React.ReactNode
 | 
			
		||||
  pageHeader?: IPageHeader
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface IDefaultLayoutProps {
 | 
			
		||||
  Page: IDefaultLayoutPage
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const DefaultLayout = ({ Page, ...props }: IDefaultLayoutProps) => {
 | 
			
		||||
  const [isShowSidebar, setIsShowSidebar] = useState(true)
 | 
			
		||||
  const [isShowPopupMenu, setIsShowPopupMenu] = useState(false)
 | 
			
		||||
  const router = useRouter()
 | 
			
		||||
 | 
			
		||||
  const showSidebar = useCallback(() => {
 | 
			
		||||
    setIsShowSidebar(true)
 | 
			
		||||
  }, [])
 | 
			
		||||
 | 
			
		||||
  const hideSidebar = useCallback(() => {
 | 
			
		||||
    setIsShowSidebar(false)
 | 
			
		||||
  }, [])
 | 
			
		||||
 | 
			
		||||
  const setActive = useCallback((val: boolean) => {
 | 
			
		||||
    if (val) {
 | 
			
		||||
      document.body.style.overflow = "hidden"
 | 
			
		||||
    } else {
 | 
			
		||||
      document.body.style.overflow = ""
 | 
			
		||||
    }
 | 
			
		||||
    setIsShowPopupMenu(val)
 | 
			
		||||
  }, [])
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setActive(false)
 | 
			
		||||
  }, [router.asPath, setActive])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div>
 | 
			
		||||
      <Sidebar isShowSidebar={isShowSidebar} hideSidebar={hideSidebar} />
 | 
			
		||||
 | 
			
		||||
      {/* mobile navigation */}
 | 
			
		||||
      <div className="z-40 flex items-center justify-between px-5 border-b h-14 sm:hidden">
 | 
			
		||||
        <div className="flex items-center">
 | 
			
		||||
          <div className="flex items-center justify-center w-8 h-8 text-white rounded-lg bg-turquoise">P</div>
 | 
			
		||||
          <div className="ml-3 text-lg text-black">Purple Admin UI</div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div>
 | 
			
		||||
          <MenuBtn isActive={isShowPopupMenu} setActive={setActive} />
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <motion.div
 | 
			
		||||
        animate={isShowPopupMenu ? "open" : "closed"}
 | 
			
		||||
        initial={{ display: "none" }}
 | 
			
		||||
        variants={{
 | 
			
		||||
          open: { display: "block", opacity: 1, y: 0 },
 | 
			
		||||
          closed: { opacity: 0, y: "-10px", transitionEnd: { display: "none" } },
 | 
			
		||||
        }}
 | 
			
		||||
        transition={{ duration: 0.15 }}
 | 
			
		||||
        className="fixed bottom-0 left-0 right-0 z-30 w-full p-5 overflow-auto bg-white"
 | 
			
		||||
        style={{ top: "3.5rem" }}
 | 
			
		||||
      >
 | 
			
		||||
        <MainMenu />
 | 
			
		||||
      </motion.div>
 | 
			
		||||
 | 
			
		||||
      <div className={`sm:h-full sm:overflow-auto ${isShowSidebar ? "sm:ml-72" : ""}`}>
 | 
			
		||||
        {Page.pageHeader ? (
 | 
			
		||||
          <PageHeader value={Page.pageHeader} />
 | 
			
		||||
        ) : !isShowSidebar ? (
 | 
			
		||||
          <div className="pt-5 pl-7">
 | 
			
		||||
            <button
 | 
			
		||||
              className="inline-flex items-center justify-center h-12 px-3 transition-all duration-300 rounded hover:bg-gray-200"
 | 
			
		||||
              onClick={showSidebar}
 | 
			
		||||
            >
 | 
			
		||||
              <MenuIcon className="w-5 h-5" />
 | 
			
		||||
              <span className="px-2">메뉴 열기</span>
 | 
			
		||||
              <ChevronRight className="w-3 h-3" />
 | 
			
		||||
            </button>
 | 
			
		||||
          </div>
 | 
			
		||||
        ) : (
 | 
			
		||||
          <></>
 | 
			
		||||
        )}
 | 
			
		||||
        <section className="px-5 pb-5 sm:px-10">
 | 
			
		||||
          <Page {...props} />
 | 
			
		||||
        </section>
 | 
			
		||||
        {!isShowSidebar ? (
 | 
			
		||||
          <div className="fixed bottom-5 left-5">
 | 
			
		||||
            <button
 | 
			
		||||
              className="flex items-center justify-center w-12 h-12 bg-white border rounded opacity-50 enable-transition hover:opacity-100"
 | 
			
		||||
              onClick={showSidebar}
 | 
			
		||||
            >
 | 
			
		||||
              <MenuIcon className="w-5 h-5" />
 | 
			
		||||
              <ChevronRight className="w-3 h-3" />
 | 
			
		||||
            </button>
 | 
			
		||||
          </div>
 | 
			
		||||
        ) : null}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const getDefaultLayout = (Page: IDefaultLayoutPage, props: Record<string, unknown>) => {
 | 
			
		||||
  return <DefaultLayout {...props} Page={Page} />
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										81
									
								
								src/components/layout/main-menu.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								src/components/layout/main-menu.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,81 @@
 | 
			
		||||
import { Divider } from "antd"
 | 
			
		||||
import { BarChart4, FilePieChartIcon, FileQuestion, Settings, UserCircle, Users } from "lucide-react"
 | 
			
		||||
import React from "react"
 | 
			
		||||
import Menu, { IMenu } from "./nav"
 | 
			
		||||
 | 
			
		||||
const dashboard: IMenu[] = [
 | 
			
		||||
  {
 | 
			
		||||
    id: "status",
 | 
			
		||||
    name: "응시현황",
 | 
			
		||||
    icon: <BarChart4 className="w-5 h-5" />,
 | 
			
		||||
    link: {
 | 
			
		||||
      path: "/status",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: "analysis",
 | 
			
		||||
    name: "응시분석",
 | 
			
		||||
    icon: <FilePieChartIcon className="w-5 h-5" />,
 | 
			
		||||
    link: {
 | 
			
		||||
      path: "/analysis",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
const manage: IMenu[] = [
 | 
			
		||||
  {
 | 
			
		||||
    id: "quiz",
 | 
			
		||||
    name: "퀴즈시트",
 | 
			
		||||
    icon: <FileQuestion className="w-5 h-5" />,
 | 
			
		||||
    link: {
 | 
			
		||||
      path: "/program",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: "user",
 | 
			
		||||
    name: "User",
 | 
			
		||||
    icon: <Users className="w-5 h-5" />,
 | 
			
		||||
    link: {
 | 
			
		||||
      path: "/user",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
const setting: IMenu[] = [
 | 
			
		||||
  {
 | 
			
		||||
    id: "account",
 | 
			
		||||
    name: "계정",
 | 
			
		||||
    icon: <UserCircle className="w-5 h-5" />,
 | 
			
		||||
    link: {
 | 
			
		||||
      path: "/account",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: "system",
 | 
			
		||||
    name: "시스템",
 | 
			
		||||
    icon: <Settings className="w-5 h-5" />,
 | 
			
		||||
    link: {
 | 
			
		||||
      path: "/system",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
const MainMenu = () => {
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <h2>대시보드</h2>
 | 
			
		||||
 | 
			
		||||
      <Menu data={dashboard} />
 | 
			
		||||
      <Divider />
 | 
			
		||||
 | 
			
		||||
      <h2>정보관리</h2>
 | 
			
		||||
      <Menu data={manage} />
 | 
			
		||||
      <Divider />
 | 
			
		||||
 | 
			
		||||
      <h2>설정</h2>
 | 
			
		||||
      <Menu data={setting} />
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default React.memo(MainMenu)
 | 
			
		||||
							
								
								
									
										21
									
								
								src/components/layout/menu-btn.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/components/layout/menu-btn.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
			
		||||
import React from "react"
 | 
			
		||||
import style from "./default-layout.module.css"
 | 
			
		||||
 | 
			
		||||
interface IMenuBtnProps {
 | 
			
		||||
  isActive: boolean
 | 
			
		||||
  setActive: (val: boolean) => void
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const MenuBtn = ({ isActive, setActive }: IMenuBtnProps) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <button className="p-3 -mr-2" onClick={() => setActive(!isActive)}>
 | 
			
		||||
      <div className={style["menu-wrapper"]}>
 | 
			
		||||
        <div className={`${style["menu-bar"]} ${style["menu-top"]} ${isActive ? "active" : ""}`} />
 | 
			
		||||
        <div className={`${style["menu-bar"]} ${style["menu-middle"]} ${isActive ? "active" : ""}`} />
 | 
			
		||||
        <div className={`${style["menu-bar"]} ${style["menu-bottom"]} ${isActive ? "active" : ""}`} />
 | 
			
		||||
      </div>
 | 
			
		||||
    </button>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default React.memo(MenuBtn)
 | 
			
		||||
							
								
								
									
										40
									
								
								src/components/layout/nav/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/components/layout/nav/index.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,40 @@
 | 
			
		||||
import { NextRouter } from "next/router"
 | 
			
		||||
import { ParsedUrlQueryInput } from "querystring"
 | 
			
		||||
import React from "react"
 | 
			
		||||
import NavMenu from "./nav-menu"
 | 
			
		||||
import style from "./nav.module.css"
 | 
			
		||||
 | 
			
		||||
interface INavProps {
 | 
			
		||||
  data: IMenu[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IMenu {
 | 
			
		||||
  id?: string /* 식별자 없으면 name으로 대체 */
 | 
			
		||||
  name: string
 | 
			
		||||
  link?: {
 | 
			
		||||
    path: string
 | 
			
		||||
    query?: ParsedUrlQueryInput
 | 
			
		||||
  }
 | 
			
		||||
  icon?: React.ReactNode
 | 
			
		||||
  isActive?: (router: NextRouter, link: IMenu["link"]) => boolean
 | 
			
		||||
  submenu?: IMenu[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const isEqualPath = (router: NextRouter, link: IMenu["link"]) => {
 | 
			
		||||
  return (
 | 
			
		||||
    router.pathname === link?.path &&
 | 
			
		||||
    Object.keys(link.query || {}).every((k) => String(link.query?.[k]) === router.query[k])
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const Nav = ({ data }: INavProps) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <ul className={style.menu}>
 | 
			
		||||
      {data.map((menu) => {
 | 
			
		||||
        return <NavMenu key={menu.id || menu.name} menu={menu} />
 | 
			
		||||
      })}
 | 
			
		||||
    </ul>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default React.memo(Nav)
 | 
			
		||||
							
								
								
									
										31
									
								
								src/components/layout/nav/nav-item.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/components/layout/nav/nav-item.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,31 @@
 | 
			
		||||
import { ChevronRight } from "lucide-react"
 | 
			
		||||
import Link from "next/link"
 | 
			
		||||
import { useRouter } from "next/router"
 | 
			
		||||
import React from "react"
 | 
			
		||||
import { IMenu, isEqualPath } from "."
 | 
			
		||||
 | 
			
		||||
interface INavItemProps {
 | 
			
		||||
  item: IMenu
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const NavItem = ({ item }: INavItemProps) => {
 | 
			
		||||
  const router = useRouter()
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <li>
 | 
			
		||||
      <Link
 | 
			
		||||
        href={{
 | 
			
		||||
          pathname: item.link?.path ?? "/",
 | 
			
		||||
          query: item.link?.query,
 | 
			
		||||
        }}
 | 
			
		||||
        className={(item.isActive || isEqualPath)(router, item.link) ? "active" : ""}
 | 
			
		||||
      >
 | 
			
		||||
        {item.icon}
 | 
			
		||||
        <span className="cursor-pointer grow">{item.name}</span>
 | 
			
		||||
        <ChevronRight className="w-6 h-6 text-white active-check" />
 | 
			
		||||
      </Link>
 | 
			
		||||
    </li>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default React.memo(NavItem)
 | 
			
		||||
							
								
								
									
										47
									
								
								src/components/layout/nav/nav-menu.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								src/components/layout/nav/nav-menu.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,47 @@
 | 
			
		||||
import { ChevronDown, ChevronUp } from "lucide-react"
 | 
			
		||||
import { useRouter } from "next/router"
 | 
			
		||||
import React, { useState } from "react"
 | 
			
		||||
import { IMenu, isEqualPath } from "."
 | 
			
		||||
import NavItem from "./nav-item"
 | 
			
		||||
 | 
			
		||||
interface INavMenuProps {
 | 
			
		||||
  menu: IMenu
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const NavMenu = ({ menu }: INavMenuProps) => {
 | 
			
		||||
  const router = useRouter()
 | 
			
		||||
  const [isShowSubMenu, setIsShowSubMenu] = useState(
 | 
			
		||||
    menu.submenu && menu.submenu.length > 0 && menu.submenu.find((v) => (v.isActive || isEqualPath)(router, v.link))
 | 
			
		||||
      ? true
 | 
			
		||||
      : false
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  if (menu.submenu) {
 | 
			
		||||
    return (
 | 
			
		||||
      <li>
 | 
			
		||||
        <a onClick={() => setIsShowSubMenu(!isShowSubMenu)}>
 | 
			
		||||
          {menu.icon}
 | 
			
		||||
          <span className="cursor-pointer grow">{menu.name}</span>
 | 
			
		||||
          {menu.submenu && menu.submenu.length > 0 ? (
 | 
			
		||||
            isShowSubMenu ? (
 | 
			
		||||
              <ChevronUp className="w-6 h-6 text-gray-500" />
 | 
			
		||||
            ) : (
 | 
			
		||||
              <ChevronDown className="w-6 h-6 text-gray-500" />
 | 
			
		||||
            )
 | 
			
		||||
          ) : (
 | 
			
		||||
            <></>
 | 
			
		||||
          )}
 | 
			
		||||
        </a>
 | 
			
		||||
        <ul className={isShowSubMenu ? "block" : "hidden"}>
 | 
			
		||||
          {menu.submenu.map((sub) => {
 | 
			
		||||
            return <NavItem key={sub.name} item={sub} />
 | 
			
		||||
          })}
 | 
			
		||||
        </ul>
 | 
			
		||||
      </li>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return <NavItem item={menu} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default React.memo(NavMenu)
 | 
			
		||||
							
								
								
									
										33
									
								
								src/components/layout/nav/nav.module.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								src/components/layout/nav/nav.module.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,33 @@
 | 
			
		||||
.menu a {
 | 
			
		||||
  @apply flex items-center gap-2 rounded-lg text-gray-700 hover:text-gray-700 hover:bg-gray-200 transition-all duration-300;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.menu>li {
 | 
			
		||||
  @apply text-lg mb-1;
 | 
			
		||||
}
 | 
			
		||||
.menu>li>a {
 | 
			
		||||
  @apply px-3 py-2;
 | 
			
		||||
}
 | 
			
		||||
.menu a:global(.active) {
 | 
			
		||||
  @apply bg-wetasphalt text-white;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.menu :global(.active-check) {
 | 
			
		||||
  @apply hidden;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.menu a:global(.active) > :global(.active-check) {
 | 
			
		||||
  @apply block;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.menu>li>ul {
 | 
			
		||||
  @apply mt-1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.menu>li>ul>li {
 | 
			
		||||
  @apply mb-1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.menu>li>ul>li>a {
 | 
			
		||||
  @apply pl-9 py-1 pr-3 text-base;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										16
									
								
								src/components/layout/page-header.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/components/layout/page-header.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,16 @@
 | 
			
		||||
import React from "react"
 | 
			
		||||
import { IPageHeader } from "./default-layout"
 | 
			
		||||
 | 
			
		||||
interface IPageHeaderProps {
 | 
			
		||||
  value: IPageHeader
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const PageHeader = ({ value }: IPageHeaderProps) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={`pt-7 px-5 sm:px-10`}>
 | 
			
		||||
      <div className="flex items-center text-3xl text-gray-900">{value.title}</div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default React.memo(PageHeader)
 | 
			
		||||
							
								
								
									
										21
									
								
								src/components/layout/seo-head.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/components/layout/seo-head.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
			
		||||
import Head from "next/head"
 | 
			
		||||
import React from "react"
 | 
			
		||||
 | 
			
		||||
export const DEFAULT_TITLE = "SuperRichQuiz Admin"
 | 
			
		||||
export const DEFAULT_DESCRIPTION = "SuperRichQuiz Admin"
 | 
			
		||||
 | 
			
		||||
interface ISeoHeadProps {
 | 
			
		||||
  title?: string
 | 
			
		||||
  description?: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const SeoHead = ({ title, description }: ISeoHeadProps) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <Head>
 | 
			
		||||
      <title>{title ? `${title} | ${DEFAULT_TITLE}` : DEFAULT_TITLE}</title>
 | 
			
		||||
      <meta name="description" content={description ?? DEFAULT_DESCRIPTION} />
 | 
			
		||||
    </Head>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default React.memo(SeoHead)
 | 
			
		||||
							
								
								
									
										41
									
								
								src/components/layout/sidebar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								src/components/layout/sidebar.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,41 @@
 | 
			
		||||
import { ChevronLeft, MenuIcon } from "lucide-react"
 | 
			
		||||
import Link from "next/link"
 | 
			
		||||
import React from "react"
 | 
			
		||||
import style from "./default-layout.module.css"
 | 
			
		||||
import MainMenu from "./main-menu"
 | 
			
		||||
import Image from "next/image"
 | 
			
		||||
 | 
			
		||||
interface ISidebarProps {
 | 
			
		||||
  isShowSidebar: boolean
 | 
			
		||||
  hideSidebar: () => void
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const Sidebar = ({ isShowSidebar, hideSidebar }: ISidebarProps) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <aside className={`hidden ${style.sidebar} ${isShowSidebar ? "sm:block" : "hidden"}`}>
 | 
			
		||||
      <div className="flex flex-col h-full">
 | 
			
		||||
        <div className="flex h-20">
 | 
			
		||||
          <Link href="/">
 | 
			
		||||
            <Image width={250} height={55} src={"/images/logo.png"} alt="Logo" />
 | 
			
		||||
          </Link>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className="overflow-auto grow">
 | 
			
		||||
          <MainMenu />
 | 
			
		||||
        </div>
 | 
			
		||||
        <div>
 | 
			
		||||
          <div className="flex justify-end">
 | 
			
		||||
            <button
 | 
			
		||||
              className="flex items-center justify-center w-12 h-12 rounded enable-transition hover:bg-gray-200"
 | 
			
		||||
              onClick={hideSidebar}
 | 
			
		||||
            >
 | 
			
		||||
              <ChevronLeft className="w-3 h-3" />
 | 
			
		||||
              <MenuIcon className="w-5 h-5" />
 | 
			
		||||
            </button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </aside>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default React.memo(Sidebar)
 | 
			
		||||
							
								
								
									
										18
									
								
								src/components/login/TitleBGImage/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								src/components/login/TitleBGImage/index.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
			
		||||
import styles from "./styles.module.css"
 | 
			
		||||
 | 
			
		||||
export const TitleBGImage = () => {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={styles["title-bgimage-wraps"]}>
 | 
			
		||||
      <div className={styles["title-top-wrap"]}>
 | 
			
		||||
        <div></div>
 | 
			
		||||
        <div className={styles["title-bgimage-2"]}></div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div className={styles["title-bottom-wrap"]}>
 | 
			
		||||
        <div className={styles["title-bgimage-1"]}></div>
 | 
			
		||||
        <div></div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default TitleBGImage
 | 
			
		||||
							
								
								
									
										39
									
								
								src/components/login/TitleBGImage/styles.module.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/components/login/TitleBGImage/styles.module.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,39 @@
 | 
			
		||||
.title-bgimage-wraps {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  background-color: #051224;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  justify-content: space-between;
 | 
			
		||||
  position: absolute;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.title-top-wrap {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: row;
 | 
			
		||||
  justify-content: space-between;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.title-bottom-wrap {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: row;
 | 
			
		||||
  justify-content: space-between;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.title-bgimage-1 {
 | 
			
		||||
  background-image: url("/title_bgimage_1.png");
 | 
			
		||||
  background-size: contain;
 | 
			
		||||
  background-repeat: no-repeat;
 | 
			
		||||
  width: 747px;
 | 
			
		||||
  height: 394px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.title-bgimage-2 {
 | 
			
		||||
  background-image: url("/title_bgimage_2.png");
 | 
			
		||||
  background-size: contain;
 | 
			
		||||
  background-repeat: no-repeat;
 | 
			
		||||
  width: 709px;
 | 
			
		||||
  height: 325px;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										10
									
								
								src/components/login/TitleLogo/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/components/login/TitleLogo/index.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
			
		||||
import styles from "./styles.module.css"
 | 
			
		||||
 | 
			
		||||
const TitleLogo = () => {
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <div className={styles["title-image"]}></div>
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
export default TitleLogo
 | 
			
		||||
							
								
								
									
										25
									
								
								src/components/login/TitleLogo/styles.module.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/components/login/TitleLogo/styles.module.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,25 @@
 | 
			
		||||
.title-image {
 | 
			
		||||
  background-image: url("/title2.png");
 | 
			
		||||
  background-repeat: no-repeat;
 | 
			
		||||
  background-position: center;
 | 
			
		||||
  background-size: contain;
 | 
			
		||||
  width: 83vw;
 | 
			
		||||
  height: 30vh;
 | 
			
		||||
  top: 16vh;
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  animation-name: title-move;
 | 
			
		||||
  animation-duration: 1s;
 | 
			
		||||
  animation-iteration-count: 1;
 | 
			
		||||
  transition: all ease-in-out;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes title-move {
 | 
			
		||||
  from {
 | 
			
		||||
    opacity: 0;
 | 
			
		||||
    top: 20vh;
 | 
			
		||||
  }
 | 
			
		||||
  to {
 | 
			
		||||
    opacity: 1;
 | 
			
		||||
    top: 16vh;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										24
									
								
								src/components/login/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/components/login/index.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,24 @@
 | 
			
		||||
import dynamic from "next/dynamic"
 | 
			
		||||
import styles from "./styles.module.css"
 | 
			
		||||
import TitleBGImage from "./TitleBGImage"
 | 
			
		||||
import TitleLogo from "./TitleLogo"
 | 
			
		||||
 | 
			
		||||
const Login = () => {
 | 
			
		||||
  // const GoogleOneTapLogin = dynamic(() => import("./GoogleOneTapLogin"), {
 | 
			
		||||
  //   ssr: false,
 | 
			
		||||
  // })
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={styles["container"]}>
 | 
			
		||||
      <TitleBGImage />
 | 
			
		||||
      <div className={styles["title"]}>
 | 
			
		||||
        <div className={styles["title-wrapper"]}>
 | 
			
		||||
          <TitleLogo />
 | 
			
		||||
          {/* <GoogleOneTapLogin /> */}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default Login
 | 
			
		||||
							
								
								
									
										26
									
								
								src/components/login/styles.module.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								src/components/login/styles.module.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
			
		||||
.container {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: row;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
  position: fixed;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.title {
 | 
			
		||||
  align-items: flex-start;
 | 
			
		||||
  background-color: var(--white);
 | 
			
		||||
  border: 1px none;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.title-wrapper {
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  position: absolute;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										77
									
								
								src/components/page/login/gradient-bg.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								src/components/page/login/gradient-bg.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,77 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
 | 
			
		||||
interface IGradientBgProps {
 | 
			
		||||
  className?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const GradientBg = ({ className }: IGradientBgProps) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <svg xmlns="http://www.w3.org/2000/svg" className={className}>
 | 
			
		||||
      <defs>
 | 
			
		||||
        <linearGradient id="a" gradientUnits="objectBoundingBox" x1="0" y1="0" x2="1" y2="1">
 | 
			
		||||
          <stop offset="0" stop-color="red">
 | 
			
		||||
            <animate
 | 
			
		||||
              attributeName="stop-color"
 | 
			
		||||
              values="red;purple;blue;green;yellow;orange;red;"
 | 
			
		||||
              dur="20s"
 | 
			
		||||
              repeatCount="indefinite"
 | 
			
		||||
            ></animate>
 | 
			
		||||
          </stop>
 | 
			
		||||
          <stop offset=".5" stop-color="purple">
 | 
			
		||||
            <animate
 | 
			
		||||
              attributeName="stop-color"
 | 
			
		||||
              values="purple;blue;green;yellow;orange;red;purple;"
 | 
			
		||||
              dur="20s"
 | 
			
		||||
              repeatCount="indefinite"
 | 
			
		||||
            ></animate>
 | 
			
		||||
          </stop>
 | 
			
		||||
          <stop offset="1" stop-color="blue">
 | 
			
		||||
            <animate
 | 
			
		||||
              attributeName="stop-color"
 | 
			
		||||
              values="blue;green;yellow;orange;red;purple;blue;"
 | 
			
		||||
              dur="20s"
 | 
			
		||||
              repeatCount="indefinite"
 | 
			
		||||
            ></animate>
 | 
			
		||||
          </stop>
 | 
			
		||||
          <animateTransform
 | 
			
		||||
            attributeName="gradientTransform"
 | 
			
		||||
            type="rotate"
 | 
			
		||||
            from="0 .5 .5"
 | 
			
		||||
            to="360 .5 .5"
 | 
			
		||||
            dur="20s"
 | 
			
		||||
            repeatCount="indefinite"
 | 
			
		||||
          />
 | 
			
		||||
        </linearGradient>
 | 
			
		||||
        <linearGradient id="b" gradientUnits="objectBoundingBox" x1="0" y1="1" x2="1" y2="1">
 | 
			
		||||
          <stop offset="0" stop-color="red">
 | 
			
		||||
            <animate
 | 
			
		||||
              attributeName="stop-color"
 | 
			
		||||
              values="red;purple;blue;green;yellow;orange;red;"
 | 
			
		||||
              dur="20s"
 | 
			
		||||
              repeatCount="indefinite"
 | 
			
		||||
            ></animate>
 | 
			
		||||
          </stop>
 | 
			
		||||
          <stop offset="1" stop-color="purple" stop-opacity="0">
 | 
			
		||||
            <animate
 | 
			
		||||
              attributeName="stop-color"
 | 
			
		||||
              values="purple;blue;green;yellow;orange;red;purple;"
 | 
			
		||||
              dur="20s"
 | 
			
		||||
              repeatCount="indefinite"
 | 
			
		||||
            ></animate>
 | 
			
		||||
          </stop>
 | 
			
		||||
          <animateTransform
 | 
			
		||||
            attributeName="gradientTransform"
 | 
			
		||||
            type="rotate"
 | 
			
		||||
            values="360 .5 .5;0 .5 .5"
 | 
			
		||||
            dur="10s"
 | 
			
		||||
            repeatCount="indefinite"
 | 
			
		||||
          />
 | 
			
		||||
        </linearGradient>
 | 
			
		||||
      </defs>
 | 
			
		||||
      <rect fill="url(#a)" width="100%" height="100%" />
 | 
			
		||||
      <rect fill="url(#b)" width="100%" height="100%" />
 | 
			
		||||
    </svg>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default React.memo(GradientBg);
 | 
			
		||||
							
								
								
									
										197
									
								
								src/components/program/ListPanel.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										197
									
								
								src/components/program/ListPanel.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,197 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { Alert, Button, Dropdown, MenuProps, Tag } from "antd"
 | 
			
		||||
import { ColumnsType } from "antd/es/table"
 | 
			
		||||
import { MoreVertical, Plus, Users } from "lucide-react"
 | 
			
		||||
import Link from "next/link"
 | 
			
		||||
import { useRouter } from "next/router"
 | 
			
		||||
import React, { useCallback, useEffect, useMemo, useState } from "react"
 | 
			
		||||
 | 
			
		||||
import { IProgram, usePrograms } from "@/client/program"
 | 
			
		||||
import DefaultTable from "@/components/shared/ui/default-table"
 | 
			
		||||
import DefaultTableBtn from "@/components/shared/ui/default-table-btn"
 | 
			
		||||
import { getLocalDate, getUTCDatetime } from "@/helpers/datetime"
 | 
			
		||||
 | 
			
		||||
const ListPanel = () => {
 | 
			
		||||
  const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
 | 
			
		||||
  const router = useRouter()
 | 
			
		||||
 | 
			
		||||
  const { data, error, isLoading } = usePrograms({
 | 
			
		||||
    page: router.query.page ? Number(router.query.page) : 1,
 | 
			
		||||
    tag: router.query.tag ? (typeof router.query.tag === "string" ? router.query.tag : router.query.tag[0]) : "",
 | 
			
		||||
    q: router.query.q ? (typeof router.query.q === "string" ? router.query.q : router.query.q[0]) : "",
 | 
			
		||||
  })
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    console.log("data", data)
 | 
			
		||||
  }, [data])
 | 
			
		||||
 | 
			
		||||
  const handleChangePage = useCallback(
 | 
			
		||||
    (pageNumber: number) => {
 | 
			
		||||
      router.push({
 | 
			
		||||
        pathname: router.pathname,
 | 
			
		||||
        query: { ...router.query, page: pageNumber },
 | 
			
		||||
      })
 | 
			
		||||
    },
 | 
			
		||||
    [router]
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const onSelectChange = useCallback((newSelectedRowKeys: React.Key[]) => {
 | 
			
		||||
    setSelectedRowKeys(newSelectedRowKeys)
 | 
			
		||||
  }, [])
 | 
			
		||||
 | 
			
		||||
  const modifyDropdownItems: MenuProps["items"] = useMemo(
 | 
			
		||||
    () => [
 | 
			
		||||
      {
 | 
			
		||||
        key: "statusUpdate",
 | 
			
		||||
        label: <a onClick={() => console.log(selectedRowKeys)}>상태수정</a>,
 | 
			
		||||
      },
 | 
			
		||||
    ],
 | 
			
		||||
    [selectedRowKeys]
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const rowSelection = {
 | 
			
		||||
    selectedRowKeys,
 | 
			
		||||
    onChange: onSelectChange,
 | 
			
		||||
  }
 | 
			
		||||
  const hasSelected = selectedRowKeys.length > 0
 | 
			
		||||
 | 
			
		||||
  const columns: ColumnsType<IProgram> = [
 | 
			
		||||
    {
 | 
			
		||||
      title: "제목",
 | 
			
		||||
      width: 200,
 | 
			
		||||
      align: "left",
 | 
			
		||||
      dataIndex: "subject",
 | 
			
		||||
      render: (value: string, item: IProgram) => {
 | 
			
		||||
        return (
 | 
			
		||||
          <span>
 | 
			
		||||
            <Link href={`/program/${item.id}`}>{value}</Link>
 | 
			
		||||
          </span>
 | 
			
		||||
        )
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      title: "프로그램",
 | 
			
		||||
      width: 140,
 | 
			
		||||
      align: "left",
 | 
			
		||||
      dataIndex: "course",
 | 
			
		||||
      render: (value: string, item: IProgram) => {
 | 
			
		||||
        return <span>{value}</span>
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      title: "문항",
 | 
			
		||||
      width: 40,
 | 
			
		||||
      align: "center",
 | 
			
		||||
      dataIndex: "cnt",
 | 
			
		||||
      render: (value: number, item: IProgram) => {
 | 
			
		||||
        return <span>{value}</span>
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      title: "태그",
 | 
			
		||||
      dataIndex: "tag",
 | 
			
		||||
      align: "left",
 | 
			
		||||
      width: 120,
 | 
			
		||||
      render: (value: any) => {
 | 
			
		||||
        return (
 | 
			
		||||
          <span>
 | 
			
		||||
            {value.map((t: string, i: number) => (
 | 
			
		||||
              <Tag key={i}>
 | 
			
		||||
                <Link href={`/program?tag=${t}`}>{t}</Link>
 | 
			
		||||
              </Tag>
 | 
			
		||||
            ))}
 | 
			
		||||
          </span>
 | 
			
		||||
        )
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      title: "상태",
 | 
			
		||||
      dataIndex: "status",
 | 
			
		||||
      align: "center",
 | 
			
		||||
      width: 80,
 | 
			
		||||
      render: (value: string) => {
 | 
			
		||||
        return (
 | 
			
		||||
          <span>
 | 
			
		||||
            {value === "on" && <Tag color="success">ON</Tag>}
 | 
			
		||||
            {value === "off" && <Tag color="error">OFF</Tag>}
 | 
			
		||||
          </span>
 | 
			
		||||
        )
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      title: "등록날짜",
 | 
			
		||||
      dataIndex: "publish_at",
 | 
			
		||||
      align: "center",
 | 
			
		||||
      width: 120,
 | 
			
		||||
      render: (value: string) => {
 | 
			
		||||
        return (
 | 
			
		||||
          <div className="text-sm">
 | 
			
		||||
            <span className="block">{getLocalDate(value)}</span>
 | 
			
		||||
          </div>
 | 
			
		||||
        )
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      title: "",
 | 
			
		||||
      dataIndex: "id",
 | 
			
		||||
      align: "center",
 | 
			
		||||
      width: 30,
 | 
			
		||||
      render: (value: string) => {
 | 
			
		||||
        const items: MenuProps["items"] = [
 | 
			
		||||
          {
 | 
			
		||||
            key: "1",
 | 
			
		||||
            label: <Link href={`/program/${value}`}>편집</Link>,
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            key: "2",
 | 
			
		||||
            label: (
 | 
			
		||||
              <Link href={`/program/${value}/preview`} target="_blank">
 | 
			
		||||
                미리보기
 | 
			
		||||
              </Link>
 | 
			
		||||
            ),
 | 
			
		||||
          },
 | 
			
		||||
        ]
 | 
			
		||||
        return (
 | 
			
		||||
          <Dropdown menu={{ items }} placement="topRight">
 | 
			
		||||
            <MoreVertical />
 | 
			
		||||
          </Dropdown>
 | 
			
		||||
        )
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  ]
 | 
			
		||||
 | 
			
		||||
  if (error) {
 | 
			
		||||
    return <Alert message="데이터 로딩 중 오류가 발생했습니다." type="warning" />
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <DefaultTableBtn className="justify-between">
 | 
			
		||||
        <div></div>
 | 
			
		||||
        <div className="flex-item-list">
 | 
			
		||||
          <Button className="btn-with-icon" icon={<Plus />} onClick={() => router.push("/program/create")}>
 | 
			
		||||
            New
 | 
			
		||||
          </Button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </DefaultTableBtn>
 | 
			
		||||
 | 
			
		||||
      <DefaultTable<IProgram>
 | 
			
		||||
        rowSelection={rowSelection}
 | 
			
		||||
        columns={columns}
 | 
			
		||||
        dataSource={data?.data || []}
 | 
			
		||||
        loading={isLoading}
 | 
			
		||||
        pagination={{
 | 
			
		||||
          current: Number(router.query.page || 1),
 | 
			
		||||
          defaultPageSize: 10,
 | 
			
		||||
          total: data?.total || 0,
 | 
			
		||||
          showSizeChanger: false,
 | 
			
		||||
          onChange: handleChangePage,
 | 
			
		||||
        }}
 | 
			
		||||
        className="mt-3"
 | 
			
		||||
        countLabel={data?.total}
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default React.memo(ListPanel)
 | 
			
		||||
							
								
								
									
										30
									
								
								src/components/program/Preview.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/components/program/Preview.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,30 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import "@uiw/react-markdown-preview/markdown.css"
 | 
			
		||||
import "@uiw/react-md-editor/markdown-editor.css"
 | 
			
		||||
import { Card, Space } from "antd"
 | 
			
		||||
import React from "react"
 | 
			
		||||
import ProgramPanel from "./ProgramPanel"
 | 
			
		||||
import QuizPreviewPanel from "./QuizPreviewPanel"
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  program?: any
 | 
			
		||||
  quizzes?: any
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const Preview = (props: Props) => {
 | 
			
		||||
  const { program, quizzes } = props
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Space direction="vertical" size="middle" style={{ display: "flex" }}>
 | 
			
		||||
      <Card>
 | 
			
		||||
        <ProgramPanel program={program} />
 | 
			
		||||
      </Card>
 | 
			
		||||
      <Card>
 | 
			
		||||
        <QuizPreviewPanel quizzes={quizzes} />
 | 
			
		||||
      </Card>
 | 
			
		||||
    </Space>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default React.memo(Preview)
 | 
			
		||||
							
								
								
									
										133
									
								
								src/components/program/ProgramForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								src/components/program/ProgramForm.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,133 @@
 | 
			
		||||
import { create, ProgramFormValue, update } from "@/client/program"
 | 
			
		||||
import "@uiw/react-markdown-preview/markdown.css"
 | 
			
		||||
import "@uiw/react-md-editor/markdown-editor.css"
 | 
			
		||||
import { Button, DatePicker, Form, Input, message, Radio, RadioChangeEvent } from "antd"
 | 
			
		||||
import { useForm } from "antd/lib/form/Form"
 | 
			
		||||
import dynamic from "next/dynamic"
 | 
			
		||||
import Router from "next/router"
 | 
			
		||||
import React, { useEffect, useState } from "react"
 | 
			
		||||
import { TagsInput } from "react-tag-input-component"
 | 
			
		||||
 | 
			
		||||
import DefaultForm from "@/components/shared/form/ui/default-form"
 | 
			
		||||
import FormGroup from "@/components/shared/form/ui/form-group"
 | 
			
		||||
import FormSection from "@/components/shared/form/ui/form-section"
 | 
			
		||||
import moment from "moment"
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  pid?: any
 | 
			
		||||
  data?: any
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const ProgramForm = (props: Props) => {
 | 
			
		||||
  const { pid, data } = props
 | 
			
		||||
  const [form] = useForm()
 | 
			
		||||
  const [messageApi, contextHolder] = message.useMessage()
 | 
			
		||||
 | 
			
		||||
  const [status, setStatus] = useState("")
 | 
			
		||||
 | 
			
		||||
  const [isLoading, setIsLoading] = useState(false)
 | 
			
		||||
  const [formData, setFormData] = useState<any>(null)
 | 
			
		||||
 | 
			
		||||
  const handleFinish = async (value: ProgramFormValue) => {
 | 
			
		||||
    console.log("value", value)
 | 
			
		||||
    try {
 | 
			
		||||
      setIsLoading(true)
 | 
			
		||||
      if (pid) {
 | 
			
		||||
        await update(pid, value)
 | 
			
		||||
        messageApi.success("수정되었습니다")
 | 
			
		||||
        setTimeout(() => Router.back(), 500)
 | 
			
		||||
      } else {
 | 
			
		||||
        await create(value)
 | 
			
		||||
        messageApi.success("생성되었습니다")
 | 
			
		||||
        setTimeout(() => Router.back(), 500)
 | 
			
		||||
      }
 | 
			
		||||
    } catch (e: unknown) {
 | 
			
		||||
      messageApi.error("에러가 발생했습니다")
 | 
			
		||||
    } finally {
 | 
			
		||||
      setTimeout(() => setIsLoading(false), 500)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const Editor = dynamic(() => import("@uiw/react-md-editor"), { ssr: false })
 | 
			
		||||
 | 
			
		||||
  const updateData = (data: any) => {
 | 
			
		||||
    console.log("updateData", data)
 | 
			
		||||
    setFormData(data)
 | 
			
		||||
    setFormData((prevData: any) => ({
 | 
			
		||||
      ...prevData,
 | 
			
		||||
      ["publish_at"]: moment(data?.publish_at),
 | 
			
		||||
    }))
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const changeStatus = (e: RadioChangeEvent) => {
 | 
			
		||||
    console.log("radio checked", e.target.value)
 | 
			
		||||
    setStatus(e.target.value)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    console.log("use effect data", data)
 | 
			
		||||
    if (data) {
 | 
			
		||||
      updateData(data)
 | 
			
		||||
    }
 | 
			
		||||
    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
			
		||||
  }, [data])
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    form.resetFields()
 | 
			
		||||
    console.log("formData", formData)
 | 
			
		||||
  }, [form, formData])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {contextHolder}
 | 
			
		||||
      <span></span>
 | 
			
		||||
      <DefaultForm<ProgramFormValue> form={form} initialValues={formData} onFinish={handleFinish}>
 | 
			
		||||
        <FormSection>
 | 
			
		||||
          <FormGroup title="제목">
 | 
			
		||||
            <Form.Item name="subject" rules={[{ required: true, message: "필수값입니다" }]}>
 | 
			
		||||
              <Input placeholder="제목" value={data?.subject} />
 | 
			
		||||
            </Form.Item>
 | 
			
		||||
          </FormGroup>
 | 
			
		||||
          <FormGroup title="퀴즈단계">
 | 
			
		||||
            <Form.Item name="course" rules={[{ required: true, message: "필수값입니다" }]}>
 | 
			
		||||
              <Input placeholder="퀴즈단계" value={data?.course} />
 | 
			
		||||
            </Form.Item>
 | 
			
		||||
          </FormGroup>
 | 
			
		||||
          <FormGroup title="설명">
 | 
			
		||||
            <Form.Item name="content" rules={[{ required: true, message: "필수값입니다" }]}>
 | 
			
		||||
              <Editor height={160} value={data?.content} />
 | 
			
		||||
            </Form.Item>
 | 
			
		||||
          </FormGroup>
 | 
			
		||||
 | 
			
		||||
          <FormGroup title="태그">
 | 
			
		||||
            <Form.Item name="tag" rules={[{ required: true, message: "필수값입니다" }]}>
 | 
			
		||||
              <TagsInput value={data?.tag} name="fruits" placeHolder="태그" />
 | 
			
		||||
            </Form.Item>
 | 
			
		||||
          </FormGroup>
 | 
			
		||||
 | 
			
		||||
          <FormGroup title="상태">
 | 
			
		||||
            <Form.Item name="status" rules={[{ required: true, message: "필수값입니다" }]}>
 | 
			
		||||
              <Radio.Group onChange={changeStatus} value={data?.status}>
 | 
			
		||||
                <Radio value="on">ON</Radio>
 | 
			
		||||
                <Radio value="off">OFF</Radio>
 | 
			
		||||
              </Radio.Group>
 | 
			
		||||
            </Form.Item>
 | 
			
		||||
          </FormGroup>
 | 
			
		||||
          <FormGroup title="등록날짜">
 | 
			
		||||
            <Form.Item name="publish_at" rules={[{ required: true, message: "필수값입니다" }]}>
 | 
			
		||||
              <DatePicker format={"YYYY-MM-DD"} />
 | 
			
		||||
            </Form.Item>
 | 
			
		||||
          </FormGroup>
 | 
			
		||||
        </FormSection>
 | 
			
		||||
 | 
			
		||||
        <div className="text-center">
 | 
			
		||||
          <Button htmlType="submit" type="primary" loading={isLoading}>
 | 
			
		||||
            Save
 | 
			
		||||
          </Button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </DefaultForm>
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default React.memo(ProgramForm)
 | 
			
		||||
							
								
								
									
										38
									
								
								src/components/program/ProgramPanel.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/components/program/ProgramPanel.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,38 @@
 | 
			
		||||
import { Form } from "antd"
 | 
			
		||||
import React from "react"
 | 
			
		||||
 | 
			
		||||
import FormSection from "../shared/form/ui/form-section"
 | 
			
		||||
import FormGroup from "../shared/form/ui/form-group"
 | 
			
		||||
import dynamic from "next/dynamic"
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  program: any
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const ProgramPanel = (props: Props) => {
 | 
			
		||||
  const { program } = props
 | 
			
		||||
  const Preview = dynamic(() => import("@uiw/react-markdown-preview"), { ssr: false })
 | 
			
		||||
  return (
 | 
			
		||||
    <Form>
 | 
			
		||||
      <FormSection>
 | 
			
		||||
        <FormGroup title="제목">
 | 
			
		||||
          <Form.Item name="subject">
 | 
			
		||||
            <span>{program?.subject}</span>
 | 
			
		||||
          </Form.Item>
 | 
			
		||||
        </FormGroup>
 | 
			
		||||
        <FormGroup title="퀴즈단계">
 | 
			
		||||
          <Form.Item name="course">
 | 
			
		||||
            <span>{program?.course}</span>
 | 
			
		||||
          </Form.Item>
 | 
			
		||||
        </FormGroup>
 | 
			
		||||
        <FormGroup title="설명">
 | 
			
		||||
          <Form.Item name="content">
 | 
			
		||||
            <Preview source={program?.content} />
 | 
			
		||||
          </Form.Item>
 | 
			
		||||
        </FormGroup>
 | 
			
		||||
      </FormSection>
 | 
			
		||||
    </Form>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default React.memo(ProgramPanel)
 | 
			
		||||
							
								
								
									
										106
									
								
								src/components/program/QuizCheckPanel.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								src/components/program/QuizCheckPanel.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,106 @@
 | 
			
		||||
import { Button, Card, Checkbox, Divider, Flex, Form, Input, message, Progress, Radio, Space } from "antd"
 | 
			
		||||
import { useForm } from "antd/lib/form/Form"
 | 
			
		||||
import { useRouter } from "next/router"
 | 
			
		||||
import React, { useCallback, useEffect, useState } from "react"
 | 
			
		||||
 | 
			
		||||
import DefaultSearchForm from "@/components/shared/form/ui/default-search-form"
 | 
			
		||||
import FieldInline from "@/components/shared/form/ui/field-inline"
 | 
			
		||||
import FormSearch from "@/components/shared/form/ui/form-search"
 | 
			
		||||
import { QuizResponse } from "../../client/quiz"
 | 
			
		||||
import FormSection from "../shared/form/ui/form-section"
 | 
			
		||||
import DefaultForm from "../shared/form/ui/default-form"
 | 
			
		||||
import FormGroup from "../shared/form/ui/form-group"
 | 
			
		||||
import dynamic from "next/dynamic"
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  quiz: any
 | 
			
		||||
  count: number
 | 
			
		||||
  step: number
 | 
			
		||||
  action: any
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const QuizCheckPanel = (props: Props) => {
 | 
			
		||||
  const { quiz, count, step, action } = props
 | 
			
		||||
  const [form] = useForm()
 | 
			
		||||
  const [messageApi, contextHolder] = message.useMessage()
 | 
			
		||||
  const [didAnswer, setDidAnswer] = useState(false)
 | 
			
		||||
 | 
			
		||||
  const handleFinish = async (value: any) => {
 | 
			
		||||
    setDidAnswer(true)
 | 
			
		||||
    messageApi.success("응답")
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const selectAnswer = (e: any) => {
 | 
			
		||||
    const value = form.getFieldValue(e)
 | 
			
		||||
    form.setFieldsValue({
 | 
			
		||||
      [e]: !value,
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const nextQuiz = () => {
 | 
			
		||||
    messageApi.success("다음 퀴즈")
 | 
			
		||||
    setDidAnswer(false)
 | 
			
		||||
    form.resetFields()
 | 
			
		||||
    action.nextQuiz()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const finishQuiz = () => {
 | 
			
		||||
    messageApi.success("퀴즈 완료")
 | 
			
		||||
    window.close()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const Preview = dynamic(() => import("@uiw/react-markdown-preview"), { ssr: false })
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {contextHolder}
 | 
			
		||||
      <DefaultForm<any> form={form} onFinish={handleFinish}>
 | 
			
		||||
        <Space direction="vertical" size="middle" style={{ display: "flex", padding: 20 }} className="mb-10">
 | 
			
		||||
          <Preview source={quiz?.question} />
 | 
			
		||||
        </Space>
 | 
			
		||||
 | 
			
		||||
        <Space direction="vertical" size="small" style={{ display: "flex", padding: 10 }}>
 | 
			
		||||
          {quiz.choice.map((c: any, index: number) => (
 | 
			
		||||
            <Card
 | 
			
		||||
              key={index + 1}
 | 
			
		||||
              hoverable
 | 
			
		||||
              bodyStyle={{ padding: 20 }}
 | 
			
		||||
              onClick={() => selectAnswer(`check${index + 1}`)}
 | 
			
		||||
            >
 | 
			
		||||
              <Flex gap="middle" wrap="wrap">
 | 
			
		||||
                <Form.Item name={`check${index + 1}`} valuePropName="checked">
 | 
			
		||||
                  <Checkbox> </Checkbox>
 | 
			
		||||
                </Form.Item>
 | 
			
		||||
                <Flex gap="middle" wrap="wrap" style={{ flex: 1 }}>
 | 
			
		||||
                  <Preview source={quiz?.choice[1]} style={{ width: "100%" }} />
 | 
			
		||||
                </Flex>
 | 
			
		||||
              </Flex>
 | 
			
		||||
            </Card>
 | 
			
		||||
          ))}
 | 
			
		||||
        </Space>
 | 
			
		||||
 | 
			
		||||
        <Flex justify={"center"} align={"center"} className="mt-10">
 | 
			
		||||
          <Space size="small">
 | 
			
		||||
            <Button htmlType="submit" type="primary" disabled={didAnswer}>
 | 
			
		||||
              제출
 | 
			
		||||
            </Button>
 | 
			
		||||
 | 
			
		||||
            {step < count && (
 | 
			
		||||
              <Button htmlType="button" type="primary" disabled={!didAnswer} onClick={() => nextQuiz()}>
 | 
			
		||||
                다음
 | 
			
		||||
              </Button>
 | 
			
		||||
            )}
 | 
			
		||||
 | 
			
		||||
            {step === count && (
 | 
			
		||||
              <Button htmlType="button" type="primary" disabled={!didAnswer} onClick={() => finishQuiz()}>
 | 
			
		||||
                완료
 | 
			
		||||
              </Button>
 | 
			
		||||
            )}
 | 
			
		||||
          </Space>
 | 
			
		||||
        </Flex>
 | 
			
		||||
      </DefaultForm>
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default React.memo(QuizCheckPanel)
 | 
			
		||||
							
								
								
									
										106
									
								
								src/components/program/QuizChoicePanel.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								src/components/program/QuizChoicePanel.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,106 @@
 | 
			
		||||
import { Button, Card, Flex, Form, Input, message, Progress, Radio, Space } from "antd"
 | 
			
		||||
import { useForm } from "antd/lib/form/Form"
 | 
			
		||||
import React, { useCallback, useEffect, useState } from "react"
 | 
			
		||||
 | 
			
		||||
import DefaultSearchForm from "@/components/shared/form/ui/default-search-form"
 | 
			
		||||
import FieldInline from "@/components/shared/form/ui/field-inline"
 | 
			
		||||
import FormSearch from "@/components/shared/form/ui/form-search"
 | 
			
		||||
import { QuizResponse } from "../../client/quiz"
 | 
			
		||||
import FormSection from "../shared/form/ui/form-section"
 | 
			
		||||
import DefaultForm from "../shared/form/ui/default-form"
 | 
			
		||||
import FormGroup from "../shared/form/ui/form-group"
 | 
			
		||||
import dynamic from "next/dynamic"
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  quiz: any
 | 
			
		||||
  count: number
 | 
			
		||||
  step: number
 | 
			
		||||
  action: any
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const QuizChoicePanel = (props: Props) => {
 | 
			
		||||
  const { quiz, count, step, action } = props
 | 
			
		||||
  const [form] = useForm()
 | 
			
		||||
  const [messageApi, contextHolder] = message.useMessage()
 | 
			
		||||
  const [didAnswer, setDidAnswer] = useState(false)
 | 
			
		||||
  const [selectedValue, setSelectedValue] = useState(null)
 | 
			
		||||
 | 
			
		||||
  const select = (value: any) => {
 | 
			
		||||
    setSelectedValue(value) // Update the state, and thus the selected radio button
 | 
			
		||||
    form.setFieldsValue({ answer: value }) // Update the form value if you have a field named 'answer'
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const selectAnswer = (e: any) => {
 | 
			
		||||
    console.log("selectAnswer", e.target.value)
 | 
			
		||||
    // setAnswer(e.target.value)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const handleFinish = async (value: any) => {
 | 
			
		||||
    setDidAnswer(true)
 | 
			
		||||
    messageApi.success("응답")
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const nextQuiz = () => {
 | 
			
		||||
    messageApi.success("다음 퀴즈")
 | 
			
		||||
    form.resetFields()
 | 
			
		||||
    setDidAnswer(false)
 | 
			
		||||
    action.nextQuiz()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const finishQuiz = () => {
 | 
			
		||||
    messageApi.success("퀴즈 완료")
 | 
			
		||||
    form.resetFields()
 | 
			
		||||
    window.close
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const Preview = dynamic(() => import("@uiw/react-markdown-preview"), { ssr: false })
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {contextHolder}
 | 
			
		||||
      <DefaultForm<any> form={form} onFinish={handleFinish}>
 | 
			
		||||
        <Space direction="vertical" size="middle" style={{ display: "flex", padding: 20 }} className="mb-10">
 | 
			
		||||
          <Preview source={quiz?.question} />
 | 
			
		||||
        </Space>
 | 
			
		||||
        <Form.Item name="answer">
 | 
			
		||||
          <Radio.Group onChange={selectAnswer} style={{ display: "block" }}>
 | 
			
		||||
            <Space direction="vertical" size="small" style={{ display: "flex", padding: 10 }}>
 | 
			
		||||
              {quiz.choice.map((c: any, index: number) => (
 | 
			
		||||
                <Card key={index + 1} hoverable bodyStyle={{ padding: 20 }} onClick={() => select(`${index + 1}`)}>
 | 
			
		||||
                  <Flex gap="middle" wrap="wrap">
 | 
			
		||||
                    <Radio value={`${index + 1}`}></Radio>
 | 
			
		||||
                    <Flex gap="middle" wrap="wrap" style={{ flex: 1 }}>
 | 
			
		||||
                      <Preview source={c} style={{ width: "100%" }} />
 | 
			
		||||
                    </Flex>
 | 
			
		||||
                  </Flex>
 | 
			
		||||
                </Card>
 | 
			
		||||
              ))}
 | 
			
		||||
            </Space>
 | 
			
		||||
          </Radio.Group>
 | 
			
		||||
        </Form.Item>
 | 
			
		||||
 | 
			
		||||
        <Flex justify={"center"} align={"center"} className="mt-10">
 | 
			
		||||
          <Space size="small">
 | 
			
		||||
            <Button htmlType="submit" type="primary" disabled={didAnswer}>
 | 
			
		||||
              제출
 | 
			
		||||
            </Button>
 | 
			
		||||
 | 
			
		||||
            {step < count && (
 | 
			
		||||
              <Button htmlType="button" type="primary" disabled={!didAnswer} onClick={() => nextQuiz()}>
 | 
			
		||||
                다음
 | 
			
		||||
              </Button>
 | 
			
		||||
            )}
 | 
			
		||||
 | 
			
		||||
            {step === count && (
 | 
			
		||||
              <Button htmlType="button" type="primary" disabled={!didAnswer} onClick={() => finishQuiz()}>
 | 
			
		||||
                완료
 | 
			
		||||
              </Button>
 | 
			
		||||
            )}
 | 
			
		||||
          </Space>
 | 
			
		||||
        </Flex>
 | 
			
		||||
      </DefaultForm>
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default React.memo(QuizChoicePanel)
 | 
			
		||||
							
								
								
									
										89
									
								
								src/components/program/QuizList.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								src/components/program/QuizList.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,89 @@
 | 
			
		||||
import { Alert, Button, Divider, MenuProps, Popconfirm, Space } from "antd"
 | 
			
		||||
import { ColumnsType } from "antd/es/table"
 | 
			
		||||
import Link from "next/link"
 | 
			
		||||
 | 
			
		||||
import { IQuiz, useQuizzes } from "@/client/quiz"
 | 
			
		||||
import DefaultTable from "@/components/shared/ui/default-table"
 | 
			
		||||
import DefaultTableBtn from "@/components/shared/ui/default-table-btn"
 | 
			
		||||
import dynamic from "next/dynamic"
 | 
			
		||||
import router from "next/dist/client/router"
 | 
			
		||||
import React from "react"
 | 
			
		||||
import { useEffect } from "react"
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  pid: any
 | 
			
		||||
  data: any
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const QuizList = (props: Props) => {
 | 
			
		||||
  const { pid, data } = props
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    console.log("data", data)
 | 
			
		||||
  }, [data])
 | 
			
		||||
 | 
			
		||||
  const openNewQuiz = () => {
 | 
			
		||||
    const sequence = data ? data?.data.length + 1 : 1
 | 
			
		||||
    router.push(`/program/${pid}/quiz/create?sequence=${sequence}`)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const Preview = dynamic(() => import("@uiw/react-markdown-preview"), { ssr: false })
 | 
			
		||||
  const columns: ColumnsType<IQuiz> = [
 | 
			
		||||
    {
 | 
			
		||||
      title: "번호",
 | 
			
		||||
      dataIndex: "sequence",
 | 
			
		||||
      align: "center",
 | 
			
		||||
      width: 20,
 | 
			
		||||
      render: (value: string, item: IQuiz) => {
 | 
			
		||||
        return <span>{value}</span>
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      title: "문제",
 | 
			
		||||
      width: 160,
 | 
			
		||||
      align: "left",
 | 
			
		||||
      dataIndex: "question",
 | 
			
		||||
      render: (value: string, item: IQuiz) => {
 | 
			
		||||
        return (
 | 
			
		||||
          <Link href={`/program/${pid}/quiz/${item.id}`}>
 | 
			
		||||
            <Preview source={value} />
 | 
			
		||||
          </Link>
 | 
			
		||||
        )
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      title: "유형",
 | 
			
		||||
      dataIndex: "quiz_type",
 | 
			
		||||
      align: "center",
 | 
			
		||||
      width: 40,
 | 
			
		||||
      render: (value: string, item: IQuiz) => {
 | 
			
		||||
        return <span>{value}</span>
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  ]
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <DefaultTableBtn className="justify-between">
 | 
			
		||||
        <div></div>
 | 
			
		||||
 | 
			
		||||
        <div className="flex-item-list">
 | 
			
		||||
          <Space>
 | 
			
		||||
            <Link href={`/program/${pid}/preview`} target="_blank">
 | 
			
		||||
              <Button type="primary">Preview</Button>
 | 
			
		||||
            </Link>
 | 
			
		||||
            <Button type="primary" onClick={() => openNewQuiz()}>
 | 
			
		||||
              New
 | 
			
		||||
            </Button>
 | 
			
		||||
          </Space>
 | 
			
		||||
        </div>
 | 
			
		||||
      </DefaultTableBtn>
 | 
			
		||||
 | 
			
		||||
      <Space />
 | 
			
		||||
 | 
			
		||||
      <DefaultTable<IQuiz> columns={columns} dataSource={data?.data || []} pagination={false} className="mt-3" />
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default React.memo(QuizList)
 | 
			
		||||
							
								
								
									
										74
									
								
								src/components/program/QuizPreviewPanel.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								src/components/program/QuizPreviewPanel.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,74 @@
 | 
			
		||||
import { Progress } from "antd"
 | 
			
		||||
import { useForm } from "antd/lib/form/Form"
 | 
			
		||||
import React, { useEffect, useState } from "react"
 | 
			
		||||
 | 
			
		||||
import dynamic from "next/dynamic"
 | 
			
		||||
import QuizChoicePanel from "./QuizChoicePanel"
 | 
			
		||||
import QuizCheckPanel from "./QuizCheckPanel"
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  quizzes: any
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const QuizPreviewPanel = (props: Props) => {
 | 
			
		||||
  const { quizzes } = props
 | 
			
		||||
  const [count, setCount] = useState(5)
 | 
			
		||||
  const [step, setStep] = useState(1)
 | 
			
		||||
  const [percent, setPercent] = useState(0)
 | 
			
		||||
  const [form] = useForm()
 | 
			
		||||
  const [quiz, setQuiz] = useState<any>(null)
 | 
			
		||||
 | 
			
		||||
  const selectAnswer = (e: any) => {
 | 
			
		||||
    console.log("selectAnswer", e.target.value)
 | 
			
		||||
    // setAnswer(e.target.value)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const nextQuiz = () => {
 | 
			
		||||
    if (step >= count) return
 | 
			
		||||
 | 
			
		||||
    setStep(step + 1)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (quizzes) {
 | 
			
		||||
      const count = quizzes?.length ?? 5
 | 
			
		||||
      setCount(count)
 | 
			
		||||
      setPercent((step * 100) / count)
 | 
			
		||||
 | 
			
		||||
      setQuiz(quizzes[0])
 | 
			
		||||
    }
 | 
			
		||||
    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
			
		||||
  }, [quizzes])
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (quiz) {
 | 
			
		||||
      console.log(quiz)
 | 
			
		||||
    }
 | 
			
		||||
  }, [quiz])
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (step > 1) {
 | 
			
		||||
      setPercent((step * 100) / count)
 | 
			
		||||
      setQuiz(quizzes[step - 1])
 | 
			
		||||
    }
 | 
			
		||||
    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
			
		||||
  }, [step])
 | 
			
		||||
 | 
			
		||||
  const Preview = dynamic(() => import("@uiw/react-markdown-preview"), { ssr: false })
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <div className="flex justify-end  mr-5">
 | 
			
		||||
        <Progress className="align-end" percent={percent} steps={count} size={[16, 16]} showInfo={false} />
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      {quiz && quiz.quiz_type === "choice" && (
 | 
			
		||||
        <QuizChoicePanel quiz={quiz} count={count} step={step} action={{ nextQuiz }} />
 | 
			
		||||
      )}
 | 
			
		||||
      {quiz && quiz.quiz_type === "check" && (
 | 
			
		||||
        <QuizCheckPanel quiz={quiz} count={count} step={step} action={{ nextQuiz }} />
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default React.memo(QuizPreviewPanel)
 | 
			
		||||
							
								
								
									
										70
									
								
								src/components/program/SearchPanel.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								src/components/program/SearchPanel.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,70 @@
 | 
			
		||||
import { Button, Form, Input } from "antd"
 | 
			
		||||
import { useForm } from "antd/lib/form/Form"
 | 
			
		||||
import { Search } from "lucide-react"
 | 
			
		||||
import { useRouter } from "next/router"
 | 
			
		||||
import React, { useCallback } from "react"
 | 
			
		||||
 | 
			
		||||
import DefaultSearchForm from "@/components/shared/form/ui/default-search-form"
 | 
			
		||||
import FieldInline from "@/components/shared/form/ui/field-inline"
 | 
			
		||||
import FormSearch from "@/components/shared/form/ui/form-search"
 | 
			
		||||
import { QuizResponse } from "../../client/quiz"
 | 
			
		||||
 | 
			
		||||
const statusOptions = [
 | 
			
		||||
  { label: "전체", value: "ALL" },
 | 
			
		||||
  { label: "판매", value: "SALE" },
 | 
			
		||||
  { label: "추첨", value: "LOT" },
 | 
			
		||||
  // { label: "품절", value: "SOLDOUT" },
 | 
			
		||||
  // { label: "판매중단", value: "NOTSALE" },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
const SearchPanel = () => {
 | 
			
		||||
  const [form] = useForm()
 | 
			
		||||
  const router = useRouter()
 | 
			
		||||
 | 
			
		||||
  const handleFinish = useCallback(
 | 
			
		||||
    (formValue: QuizResponse) => {
 | 
			
		||||
      router.push({
 | 
			
		||||
        pathname: router.pathname,
 | 
			
		||||
        query: { ...router.query, ...formValue },
 | 
			
		||||
      })
 | 
			
		||||
    },
 | 
			
		||||
    [router]
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const reset = () => {
 | 
			
		||||
    form.resetFields()
 | 
			
		||||
    router.push({
 | 
			
		||||
      pathname: router.pathname,
 | 
			
		||||
      query: {},
 | 
			
		||||
    })
 | 
			
		||||
    window.scrollTo(0, 0)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <DefaultSearchForm form={form} onFinish={handleFinish}>
 | 
			
		||||
      <FormSearch>
 | 
			
		||||
        <FieldInline>
 | 
			
		||||
          <Form.Item label="검색" name="q" className="grow">
 | 
			
		||||
            <Input placeholder="검색" />
 | 
			
		||||
          </Form.Item>
 | 
			
		||||
        </FieldInline>
 | 
			
		||||
      </FormSearch>
 | 
			
		||||
      <div className="flex justify-center gap-2">
 | 
			
		||||
        <Button htmlType="submit" className="btn-with-icon" icon={<Search />}>
 | 
			
		||||
          검색
 | 
			
		||||
        </Button>
 | 
			
		||||
        <Button
 | 
			
		||||
          htmlType="button"
 | 
			
		||||
          className="btn-with-icon"
 | 
			
		||||
          onClick={() => {
 | 
			
		||||
            reset()
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          초기화
 | 
			
		||||
        </Button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </DefaultSearchForm>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default React.memo(SearchPanel)
 | 
			
		||||
							
								
								
									
										166
									
								
								src/components/quiz/List.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										166
									
								
								src/components/quiz/List.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,166 @@
 | 
			
		||||
import { Alert, Button, MenuProps, Tag } from "antd"
 | 
			
		||||
import { ColumnsType } from "antd/es/table"
 | 
			
		||||
import { Download } from "lucide-react"
 | 
			
		||||
import Link from "next/link"
 | 
			
		||||
import { useRouter } from "next/router"
 | 
			
		||||
import React, { useCallback, useEffect, useMemo, useState } from "react"
 | 
			
		||||
 | 
			
		||||
import { IQuiz, useQuizzes } from "@/client/quiz"
 | 
			
		||||
import DefaultTable from "@/components/shared/ui/default-table"
 | 
			
		||||
import DefaultTableBtn from "@/components/shared/ui/default-table-btn"
 | 
			
		||||
import { getUTCDatetime } from "@/helpers/datetime"
 | 
			
		||||
 | 
			
		||||
const ListPanel = () => {
 | 
			
		||||
  const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
 | 
			
		||||
  const router = useRouter()
 | 
			
		||||
  const pid = typeof router.query.pid === "string" ? typeof router.query.pid : ""
 | 
			
		||||
 | 
			
		||||
  const { data, error, isLoading } = useQuizzes({
 | 
			
		||||
    page: router.query.page ? Number(router.query.page) : 1,
 | 
			
		||||
    program_id: pid,
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    console.log("data", data)
 | 
			
		||||
  }, [data])
 | 
			
		||||
 | 
			
		||||
  const handleChangePage = useCallback(
 | 
			
		||||
    (pageNumber: number) => {
 | 
			
		||||
      router.push({
 | 
			
		||||
        pathname: router.pathname,
 | 
			
		||||
        query: { ...router.query, page: pageNumber },
 | 
			
		||||
      })
 | 
			
		||||
    },
 | 
			
		||||
    [router]
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const onSelectChange = useCallback((newSelectedRowKeys: React.Key[]) => {
 | 
			
		||||
    setSelectedRowKeys(newSelectedRowKeys)
 | 
			
		||||
  }, [])
 | 
			
		||||
 | 
			
		||||
  const modifyDropdownItems: MenuProps["items"] = useMemo(
 | 
			
		||||
    () => [
 | 
			
		||||
      {
 | 
			
		||||
        key: "statusUpdate",
 | 
			
		||||
        label: <a onClick={() => console.log(selectedRowKeys)}>상태수정</a>,
 | 
			
		||||
      },
 | 
			
		||||
    ],
 | 
			
		||||
    [selectedRowKeys]
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const rowSelection = {
 | 
			
		||||
    selectedRowKeys,
 | 
			
		||||
    onChange: onSelectChange,
 | 
			
		||||
  }
 | 
			
		||||
  const hasSelected = selectedRowKeys.length > 0
 | 
			
		||||
 | 
			
		||||
  const columns: ColumnsType<IQuiz> = [
 | 
			
		||||
    {
 | 
			
		||||
      title: "제목",
 | 
			
		||||
      width: 200,
 | 
			
		||||
      align: "left",
 | 
			
		||||
      dataIndex: "subject",
 | 
			
		||||
      render: (value: string, item: IQuiz) => {
 | 
			
		||||
        return (
 | 
			
		||||
          <span>
 | 
			
		||||
            <span>
 | 
			
		||||
              <Link href={`/program/${item.id}`}>{value}</Link>
 | 
			
		||||
            </span>
 | 
			
		||||
          </span>
 | 
			
		||||
        )
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      title: "태그",
 | 
			
		||||
      dataIndex: "tag",
 | 
			
		||||
      align: "center",
 | 
			
		||||
      width: 80,
 | 
			
		||||
      render: (value: string) => {
 | 
			
		||||
        const tags = value.split(",")
 | 
			
		||||
        return (
 | 
			
		||||
          <span>
 | 
			
		||||
            {tags.map((tag, i) => (
 | 
			
		||||
              <Tag key={tag}>
 | 
			
		||||
                <Link href="/program?tag={tag}">{tag}</Link>
 | 
			
		||||
              </Tag>
 | 
			
		||||
            ))}
 | 
			
		||||
          </span>
 | 
			
		||||
        )
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      title: "상태",
 | 
			
		||||
      dataIndex: "status",
 | 
			
		||||
      align: "center",
 | 
			
		||||
      width: 80,
 | 
			
		||||
      render: (value: string) => {
 | 
			
		||||
        return (
 | 
			
		||||
          <span>
 | 
			
		||||
            <span>{value}</span>
 | 
			
		||||
          </span>
 | 
			
		||||
        )
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      title: "상태",
 | 
			
		||||
      dataIndex: "status",
 | 
			
		||||
      align: "center",
 | 
			
		||||
      width: 80,
 | 
			
		||||
      render: (value: string) => {
 | 
			
		||||
        return (
 | 
			
		||||
          <span>
 | 
			
		||||
            <span>{value}</span>
 | 
			
		||||
          </span>
 | 
			
		||||
        )
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      title: "등록날짜",
 | 
			
		||||
      dataIndex: "created_at",
 | 
			
		||||
      align: "center",
 | 
			
		||||
      width: 120,
 | 
			
		||||
      render: (value: string) => {
 | 
			
		||||
        return (
 | 
			
		||||
          <div className="text-sm">
 | 
			
		||||
            <span className="block">{getUTCDatetime(value)}</span>
 | 
			
		||||
          </div>
 | 
			
		||||
        )
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  ]
 | 
			
		||||
 | 
			
		||||
  if (error) {
 | 
			
		||||
    return <Alert message="데이터 로딩 중 오류가 발생했습니다." type="warning" />
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <DefaultTableBtn className="justify-between">
 | 
			
		||||
        <div></div>
 | 
			
		||||
        <div className="flex-item-list">
 | 
			
		||||
          <Button className="btn-with-icon" icon={<Download />}>
 | 
			
		||||
            XLS
 | 
			
		||||
          </Button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </DefaultTableBtn>
 | 
			
		||||
 | 
			
		||||
      <DefaultTable<IQuiz>
 | 
			
		||||
        rowSelection={rowSelection}
 | 
			
		||||
        columns={columns}
 | 
			
		||||
        dataSource={data?.data || []}
 | 
			
		||||
        loading={isLoading}
 | 
			
		||||
        pagination={{
 | 
			
		||||
          current: Number(router.query.page || 1),
 | 
			
		||||
          defaultPageSize: 10,
 | 
			
		||||
          total: data?.total || 0,
 | 
			
		||||
          showSizeChanger: false,
 | 
			
		||||
          onChange: handleChangePage,
 | 
			
		||||
        }}
 | 
			
		||||
        className="mt-3"
 | 
			
		||||
        countLabel={data?.total}
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default React.memo(ListPanel)
 | 
			
		||||
							
								
								
									
										171
									
								
								src/components/quiz/ListPanel.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										171
									
								
								src/components/quiz/ListPanel.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,171 @@
 | 
			
		||||
import { Alert, Button, MenuProps, Popconfirm } from "antd"
 | 
			
		||||
import { ColumnsType } from "antd/es/table"
 | 
			
		||||
import Link from "next/link"
 | 
			
		||||
import { useRouter } from "next/router"
 | 
			
		||||
import React, { useCallback, useEffect, useMemo, useState } from "react"
 | 
			
		||||
 | 
			
		||||
import { IQuiz, useQuizzes } from "@/client/quiz"
 | 
			
		||||
import DefaultTable from "@/components/shared/ui/default-table"
 | 
			
		||||
import DefaultTableBtn from "@/components/shared/ui/default-table-btn"
 | 
			
		||||
import { getUTCDatetime } from "@/helpers/datetime"
 | 
			
		||||
import dynamic from "next/dynamic"
 | 
			
		||||
 | 
			
		||||
const ListPanel = () => {
 | 
			
		||||
  const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
 | 
			
		||||
  const router = useRouter()
 | 
			
		||||
  const pid = router.query.pid ? (router.query.pid as string) : ""
 | 
			
		||||
  const { data, error, isLoading } = useQuizzes({
 | 
			
		||||
    page: router.query.page ? Number(router.query.page) : 1,
 | 
			
		||||
    program_id: pid,
 | 
			
		||||
  })
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    console.log("data", data)
 | 
			
		||||
  }, [data])
 | 
			
		||||
 | 
			
		||||
  const handleChangePage = useCallback(
 | 
			
		||||
    (pageNumber: number) => {
 | 
			
		||||
      router.push({
 | 
			
		||||
        pathname: router.pathname,
 | 
			
		||||
        query: { ...router.query, page: pageNumber },
 | 
			
		||||
      })
 | 
			
		||||
    },
 | 
			
		||||
    [router]
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const onSelectChange = useCallback((newSelectedRowKeys: React.Key[]) => {
 | 
			
		||||
    setSelectedRowKeys(newSelectedRowKeys)
 | 
			
		||||
  }, [])
 | 
			
		||||
 | 
			
		||||
  const modifyDropdownItems: MenuProps["items"] = useMemo(
 | 
			
		||||
    () => [
 | 
			
		||||
      {
 | 
			
		||||
        key: "statusUpdate",
 | 
			
		||||
        label: <a onClick={() => console.log(selectedRowKeys)}>상태수정</a>,
 | 
			
		||||
      },
 | 
			
		||||
    ],
 | 
			
		||||
    [selectedRowKeys]
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const rowSelection = {
 | 
			
		||||
    selectedRowKeys,
 | 
			
		||||
    onChange: onSelectChange,
 | 
			
		||||
  }
 | 
			
		||||
  const hasSelected = selectedRowKeys.length > 0
 | 
			
		||||
 | 
			
		||||
  const Preview = dynamic(() => import("@uiw/react-markdown-preview"), { ssr: false })
 | 
			
		||||
  const columns: ColumnsType<IQuiz> = [
 | 
			
		||||
    {
 | 
			
		||||
      title: "Category",
 | 
			
		||||
      dataIndex: "category",
 | 
			
		||||
      align: "center",
 | 
			
		||||
      width: 80,
 | 
			
		||||
      render: (value: string, item: IQuiz) => {
 | 
			
		||||
        return (
 | 
			
		||||
          <span>
 | 
			
		||||
            <span>{value}</span>
 | 
			
		||||
          </span>
 | 
			
		||||
        )
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      title: "Subject",
 | 
			
		||||
      dataIndex: "subject",
 | 
			
		||||
      align: "center",
 | 
			
		||||
      width: 80,
 | 
			
		||||
      render: (value: string, item: IQuiz) => {
 | 
			
		||||
        return (
 | 
			
		||||
          <span>
 | 
			
		||||
            <span>{value}</span>
 | 
			
		||||
          </span>
 | 
			
		||||
        )
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      title: "Question",
 | 
			
		||||
      width: 240,
 | 
			
		||||
      align: "left",
 | 
			
		||||
      dataIndex: "question",
 | 
			
		||||
      render: (value: string, item: IQuiz) => {
 | 
			
		||||
        return (
 | 
			
		||||
          <Link href={`/program/${item.program_id}/quiz/${item.id}`}>
 | 
			
		||||
            <Preview source={value} />
 | 
			
		||||
          </Link>
 | 
			
		||||
        )
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      title: "Status",
 | 
			
		||||
      dataIndex: "status",
 | 
			
		||||
      align: "center",
 | 
			
		||||
      width: 100,
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      title: "Created",
 | 
			
		||||
      dataIndex: "created_at",
 | 
			
		||||
      align: "center",
 | 
			
		||||
      width: 120,
 | 
			
		||||
      render: (value: string) => {
 | 
			
		||||
        return (
 | 
			
		||||
          <div className="text-sm">
 | 
			
		||||
            <span className="block">{getUTCDatetime(value)}</span>
 | 
			
		||||
          </div>
 | 
			
		||||
        )
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      key: "action",
 | 
			
		||||
      width: 100,
 | 
			
		||||
      align: "center",
 | 
			
		||||
      render: (_value: unknown, item: IQuiz) => {
 | 
			
		||||
        return (
 | 
			
		||||
          <span className="flex justify-center gap-1">
 | 
			
		||||
            <Link href={`/program/${item.program_id}/quiz/edit/${item.id}`} className="px-2 py-1 text-sm btn">
 | 
			
		||||
              Edit
 | 
			
		||||
            </Link>
 | 
			
		||||
            {
 | 
			
		||||
              <Popconfirm title="Delete?" onConfirm={() => alert("삭제")} okText="Yes" cancelText="No">
 | 
			
		||||
                <a className="px-1 py-1 text-sm btn">Del</a>
 | 
			
		||||
              </Popconfirm>
 | 
			
		||||
            }
 | 
			
		||||
          </span>
 | 
			
		||||
        )
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  ]
 | 
			
		||||
 | 
			
		||||
  if (error) {
 | 
			
		||||
    return <Alert message="데이터 로딩 중 오류가 발생했습니다." type="warning" />
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <DefaultTableBtn className="justify-between">
 | 
			
		||||
        <div></div>
 | 
			
		||||
 | 
			
		||||
        <div className="flex-item-list">
 | 
			
		||||
          <Button type="primary" onClick={() => router.push(`/program/${pid}quiz/new`)}>
 | 
			
		||||
            New
 | 
			
		||||
          </Button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </DefaultTableBtn>
 | 
			
		||||
 | 
			
		||||
      <DefaultTable<IQuiz>
 | 
			
		||||
        rowSelection={rowSelection}
 | 
			
		||||
        columns={columns}
 | 
			
		||||
        dataSource={data?.data || []}
 | 
			
		||||
        loading={isLoading}
 | 
			
		||||
        pagination={{
 | 
			
		||||
          current: Number(router.query.page || 1),
 | 
			
		||||
          defaultPageSize: 10,
 | 
			
		||||
          total: data?.total || 0,
 | 
			
		||||
          showSizeChanger: false,
 | 
			
		||||
          onChange: handleChangePage,
 | 
			
		||||
        }}
 | 
			
		||||
        className="mt-3"
 | 
			
		||||
        countLabel={data?.totalPage}
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default React.memo(ListPanel)
 | 
			
		||||
							
								
								
									
										225
									
								
								src/components/quiz/QuizCheckForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										225
									
								
								src/components/quiz/QuizCheckForm.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,225 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { create, QuizFormValue, update } from "@/client/quiz"
 | 
			
		||||
import "@uiw/react-markdown-preview/markdown.css"
 | 
			
		||||
import "@uiw/react-md-editor/markdown-editor.css"
 | 
			
		||||
import { Button, Checkbox, Divider, Form, Input, message, Radio } from "antd"
 | 
			
		||||
import { useForm } from "antd/lib/form/Form"
 | 
			
		||||
import dynamic from "next/dynamic"
 | 
			
		||||
import Router from "next/router"
 | 
			
		||||
import React, { useEffect, useState } from "react"
 | 
			
		||||
 | 
			
		||||
import DefaultForm from "@/components/shared/form/ui/default-form"
 | 
			
		||||
import FormGroup from "@/components/shared/form/ui/form-group"
 | 
			
		||||
import FormSection from "@/components/shared/form/ui/form-section"
 | 
			
		||||
import router from "next/router"
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  pid?: string
 | 
			
		||||
  qid?: string
 | 
			
		||||
  data?: any
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const QuizCheckForm = (props: Props) => {
 | 
			
		||||
  const { pid, qid, data } = props
 | 
			
		||||
  const program_id = router.query.program_id ? (router.query.program_id as string) : ""
 | 
			
		||||
  const sequence = data?.sequence ? data?.sequence : router.query.sequence ? Number(router.query.sequence as string) : 1
 | 
			
		||||
  const [form] = useForm()
 | 
			
		||||
  const [isLoading, setIsLoading] = useState(false)
 | 
			
		||||
  const [messageApi, contextHolder] = message.useMessage()
 | 
			
		||||
 | 
			
		||||
  const [quizType, setQuizType] = React.useState(data ? data.quiz_choicec : "choice")
 | 
			
		||||
  const [answer, setAnswer] = React.useState("")
 | 
			
		||||
  const [formData, setFormData] = useState<any>(null)
 | 
			
		||||
 | 
			
		||||
  const handleFinish = async (value: any) => {
 | 
			
		||||
    console.log("value", value)
 | 
			
		||||
    let answer: string[] = []
 | 
			
		||||
    if (value.check1) {
 | 
			
		||||
      answer.push("1")
 | 
			
		||||
    }
 | 
			
		||||
    if (value.check2) {
 | 
			
		||||
      answer.push("2")
 | 
			
		||||
    }
 | 
			
		||||
    if (value.check3) {
 | 
			
		||||
      answer.push("3")
 | 
			
		||||
    }
 | 
			
		||||
    if (value.check4) {
 | 
			
		||||
      answer.push("4")
 | 
			
		||||
    }
 | 
			
		||||
    if (answer.length === 0) {
 | 
			
		||||
      messageApi.error("해답을 선택하세요.")
 | 
			
		||||
      return
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let params: QuizFormValue = {
 | 
			
		||||
      quiz_type: "check",
 | 
			
		||||
      program_id: data ? data.program_id : pid,
 | 
			
		||||
      answer: answer,
 | 
			
		||||
      choice: [value.choice1!, value.choice2!, value.choice3!, value.choice4!],
 | 
			
		||||
      sequence: Number(value.sequence),
 | 
			
		||||
      question: value.question,
 | 
			
		||||
      hint: value.hint,
 | 
			
		||||
      comment: "",
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    console.log("params", params)
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      setIsLoading(true)
 | 
			
		||||
      if (qid) {
 | 
			
		||||
        await update(qid, params)
 | 
			
		||||
        messageApi.success("수정되었습니다")
 | 
			
		||||
        setTimeout(() => Router.back(), 500)
 | 
			
		||||
      } else {
 | 
			
		||||
        await create(params)
 | 
			
		||||
        messageApi.success("생성되었습니다")
 | 
			
		||||
        setTimeout(() => Router.back(), 500)
 | 
			
		||||
      }
 | 
			
		||||
    } catch (e: unknown) {
 | 
			
		||||
      messageApi.error("에러가 발생했습니다")
 | 
			
		||||
    } finally {
 | 
			
		||||
      setTimeout(() => setIsLoading(false), 500)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const Editor = dynamic(() => import("@uiw/react-md-editor"), { ssr: false })
 | 
			
		||||
  const selectQuizType = (e: any) => {
 | 
			
		||||
    setQuizType(e.target.value)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const selectAnswer = (e: any) => {
 | 
			
		||||
    console.log("selectAnswer", e.target.value)
 | 
			
		||||
    setAnswer(e.target.value)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const updateData = (data: any) => {
 | 
			
		||||
    console.log("updateData", data)
 | 
			
		||||
    setAnswer(data?.answer[0])
 | 
			
		||||
    setFormData(data)
 | 
			
		||||
    setFormData((prevData: any) => ({
 | 
			
		||||
      ...prevData,
 | 
			
		||||
      ["choice1"]: data?.choice[0] ?? "",
 | 
			
		||||
      ["choice2"]: data?.choice[1] ?? "",
 | 
			
		||||
      ["choice3"]: data?.choice[2] ?? "",
 | 
			
		||||
      ["choice4"]: data?.choice[3] ?? "",
 | 
			
		||||
      ["check1"]: data?.answer?.includes("1"),
 | 
			
		||||
      ["check2"]: data?.answer?.includes("2"),
 | 
			
		||||
      ["check3"]: data?.answer?.includes("3"),
 | 
			
		||||
      ["check4"]: data?.answer?.includes("4"),
 | 
			
		||||
    }))
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    console.log("use effect data", data)
 | 
			
		||||
    if (data) {
 | 
			
		||||
      updateData(data)
 | 
			
		||||
    }
 | 
			
		||||
    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
			
		||||
  }, [data])
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    console.log("formData", formData)
 | 
			
		||||
    if (!data && !formData) {
 | 
			
		||||
      const sequence = router.query.sequence
 | 
			
		||||
      setFormData({ sequence: sequence })
 | 
			
		||||
    }
 | 
			
		||||
    form.resetFields()
 | 
			
		||||
  }, [data, form, formData])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {contextHolder}
 | 
			
		||||
      <DefaultForm<QuizFormValue> form={form} initialValues={formData} onFinish={handleFinish}>
 | 
			
		||||
        <FormSection>
 | 
			
		||||
          <FormGroup title="번호">
 | 
			
		||||
            <Form.Item name="sequence" rules={[{ required: true, message: "필수값입니다" }]}>
 | 
			
		||||
              <Input type="number" value={sequence} />
 | 
			
		||||
            </Form.Item>
 | 
			
		||||
          </FormGroup>
 | 
			
		||||
          <FormGroup title="문제">
 | 
			
		||||
            <Form.Item name="question" rules={[{ required: true, message: "필수값입니다" }]}>
 | 
			
		||||
              <Editor height={100} value={formData?.question} />
 | 
			
		||||
            </Form.Item>
 | 
			
		||||
          </FormGroup>
 | 
			
		||||
        </FormSection>
 | 
			
		||||
 | 
			
		||||
        <FormSection>
 | 
			
		||||
          <div className="mb-0 lg:flex lg:mb-3">
 | 
			
		||||
            <div className="flex-none w-full mt-1 mb-3 lg:w-48 lg:mb-0">
 | 
			
		||||
              <Form.Item name="check1" valuePropName="checked">
 | 
			
		||||
                <Checkbox checked={formData?.answer?.includes("1") ? true : false}>해답 1</Checkbox>
 | 
			
		||||
              </Form.Item>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div className="min-w-0 mb-5 grow lg:-mb-3">
 | 
			
		||||
              <Form.Item name="choice1" rules={[{ required: true, message: "필수값입니다" }]}>
 | 
			
		||||
                <Editor height={120} value={formData?.choice1} />
 | 
			
		||||
              </Form.Item>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <Divider />
 | 
			
		||||
 | 
			
		||||
          <div className="mb-0 lg:flex lg:mb-3">
 | 
			
		||||
            <div className="flex-none w-full mt-1 mb-3 lg:w-48 lg:mb-0">
 | 
			
		||||
              <Form.Item name="check2" valuePropName="checked">
 | 
			
		||||
                <Checkbox checked={formData?.answer?.includes("2") ? true : false}>해답 2</Checkbox>
 | 
			
		||||
              </Form.Item>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div className="min-w-0 mb-5 grow lg:-mb-3">
 | 
			
		||||
              <Form.Item name="choice2" rules={[{ required: true, message: "필수값입니다" }]}>
 | 
			
		||||
                <Editor height={120} value={formData?.choice2} />
 | 
			
		||||
              </Form.Item>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <Divider />
 | 
			
		||||
 | 
			
		||||
          <div className="mb-0 lg:flex lg:mb-3">
 | 
			
		||||
            <div className="flex-none w-full mt-1 mb-3 lg:w-48 lg:mb-0">
 | 
			
		||||
              <Form.Item name="check3" valuePropName="checked">
 | 
			
		||||
                <Checkbox checked={formData?.answer?.includes("3") ? true : false}>해답 3</Checkbox>
 | 
			
		||||
              </Form.Item>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div className="min-w-0 mb-5 grow lg:-mb-3">
 | 
			
		||||
              <Form.Item name="choice3" rules={[{ required: true, message: "필수값입니다" }]}>
 | 
			
		||||
                <Editor height={120} value={formData?.choice3} />
 | 
			
		||||
              </Form.Item>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <Divider />
 | 
			
		||||
 | 
			
		||||
          <div className="mb-0 lg:flex lg:mb-3">
 | 
			
		||||
            <div className="flex-none w-full mt-1 mb-3 lg:w-48 lg:mb-0">
 | 
			
		||||
              <Form.Item name="check4" valuePropName="checked">
 | 
			
		||||
                <Checkbox checked={formData?.answer?.includes("4") ? true : false}>해답 4</Checkbox>
 | 
			
		||||
              </Form.Item>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div className="min-w-0 mb-5 grow lg:-mb-3">
 | 
			
		||||
              <Form.Item name="choice4" rules={[{ required: true, message: "필수값입니다" }]}>
 | 
			
		||||
                <Editor height={120} value={formData?.choice4} />
 | 
			
		||||
              </Form.Item>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </FormSection>
 | 
			
		||||
 | 
			
		||||
        <FormSection>
 | 
			
		||||
          <FormGroup title="힌트">
 | 
			
		||||
            <Form.Item name="hint">
 | 
			
		||||
              <Editor height={100} value={formData?.hint} />
 | 
			
		||||
            </Form.Item>
 | 
			
		||||
          </FormGroup>
 | 
			
		||||
        </FormSection>
 | 
			
		||||
 | 
			
		||||
        <div className="text-center">
 | 
			
		||||
          <Button htmlType="submit" type="primary" loading={isLoading}>
 | 
			
		||||
            Save
 | 
			
		||||
          </Button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </DefaultForm>
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default React.memo(QuizCheckForm)
 | 
			
		||||
							
								
								
									
										202
									
								
								src/components/quiz/QuizChoiceForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										202
									
								
								src/components/quiz/QuizChoiceForm.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,202 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { create, QuizFormValue, update } from "@/client/quiz"
 | 
			
		||||
import "@uiw/react-markdown-preview/markdown.css"
 | 
			
		||||
import "@uiw/react-md-editor/markdown-editor.css"
 | 
			
		||||
import { Button, Checkbox, Divider, Form, Input, message, Radio } from "antd"
 | 
			
		||||
import { useForm } from "antd/lib/form/Form"
 | 
			
		||||
import dynamic from "next/dynamic"
 | 
			
		||||
import Router from "next/router"
 | 
			
		||||
import React, { useEffect, useState } from "react"
 | 
			
		||||
 | 
			
		||||
import DefaultForm from "@/components/shared/form/ui/default-form"
 | 
			
		||||
import FormGroup from "@/components/shared/form/ui/form-group"
 | 
			
		||||
import FormSection from "@/components/shared/form/ui/form-section"
 | 
			
		||||
import router from "next/router"
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  qid?: string
 | 
			
		||||
  pid?: string
 | 
			
		||||
  data?: any
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const QuizChoiceForm = (props: Props) => {
 | 
			
		||||
  const { pid, qid, data } = props
 | 
			
		||||
  const [form] = useForm()
 | 
			
		||||
  const [isLoading, setIsLoading] = useState(false)
 | 
			
		||||
  const [messageApi, contextHolder] = message.useMessage()
 | 
			
		||||
 | 
			
		||||
  const [quizType, setQuizType] = React.useState(data ? data.quiz_choicec : "choice")
 | 
			
		||||
  const [answer, setAnswer] = React.useState("")
 | 
			
		||||
  const [formData, setFormData] = useState<any>(null)
 | 
			
		||||
 | 
			
		||||
  const handleFinish = async (value: QuizFormValue) => {
 | 
			
		||||
    console.log("value", value)
 | 
			
		||||
    console.log("answer", answer)
 | 
			
		||||
    if (answer === "") {
 | 
			
		||||
      messageApi.error("해답을 선택하세요.")
 | 
			
		||||
      return
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let params: QuizFormValue = {
 | 
			
		||||
      quiz_type: "choice",
 | 
			
		||||
      program_id: data ? data.program_id : pid,
 | 
			
		||||
      answer: [answer],
 | 
			
		||||
      choice: [value.choice1!, value.choice2!, value.choice3!, value.choice4!],
 | 
			
		||||
      sequence: Number(value.sequence),
 | 
			
		||||
      question: value.question,
 | 
			
		||||
      hint: value.hint,
 | 
			
		||||
      comment: "",
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    console.log("params", params)
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      setIsLoading(true)
 | 
			
		||||
      if (qid) {
 | 
			
		||||
        await update(qid, params)
 | 
			
		||||
        messageApi.success("수정되었습니다")
 | 
			
		||||
        setTimeout(() => Router.back(), 500)
 | 
			
		||||
      } else {
 | 
			
		||||
        await create(params)
 | 
			
		||||
        messageApi.success("생성되었습니다")
 | 
			
		||||
        setTimeout(() => Router.back(), 500)
 | 
			
		||||
      }
 | 
			
		||||
    } catch (e: unknown) {
 | 
			
		||||
      messageApi.error("에러가 발생했습니다")
 | 
			
		||||
    } finally {
 | 
			
		||||
      setTimeout(() => setIsLoading(false), 500)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const Editor = dynamic(() => import("@uiw/react-md-editor"), { ssr: false })
 | 
			
		||||
  const selectQuizType = (e: any) => {
 | 
			
		||||
    setQuizType(e.target.value)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const selectAnswer = (e: any) => {
 | 
			
		||||
    console.log("selectAnswer", e.target.value)
 | 
			
		||||
    setAnswer(e.target.value)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const updateData = (data: any) => {
 | 
			
		||||
    console.log("updateData", data)
 | 
			
		||||
    setAnswer(data?.answer ? data?.answer[0] : "")
 | 
			
		||||
    setFormData(data)
 | 
			
		||||
    setFormData((prevData: any) => ({
 | 
			
		||||
      ...prevData,
 | 
			
		||||
      ["choice1"]: data?.choice[0] ?? "",
 | 
			
		||||
      ["choice2"]: data?.choice[1] ?? "",
 | 
			
		||||
      ["choice3"]: data?.choice[2] ?? "",
 | 
			
		||||
      ["choice4"]: data?.choice[3] ?? "",
 | 
			
		||||
    }))
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    console.log("use effect data", data)
 | 
			
		||||
    if (data) {
 | 
			
		||||
      updateData(data)
 | 
			
		||||
    }
 | 
			
		||||
    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
			
		||||
  }, [data])
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    console.log("formData", formData)
 | 
			
		||||
    if (!data && !formData) {
 | 
			
		||||
      const sequence = router.query.sequence
 | 
			
		||||
      setFormData({ sequence: sequence })
 | 
			
		||||
    }
 | 
			
		||||
    form.resetFields()
 | 
			
		||||
  }, [data, form, formData])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {contextHolder}
 | 
			
		||||
      <DefaultForm<QuizFormValue> form={form} initialValues={formData} onFinish={handleFinish}>
 | 
			
		||||
        <FormSection>
 | 
			
		||||
          <FormGroup title="번호">
 | 
			
		||||
            <Form.Item name="sequence" rules={[{ required: true, message: "필수값입니다" }]}>
 | 
			
		||||
              <Input type="number" value={formData?.sequence} />
 | 
			
		||||
            </Form.Item>
 | 
			
		||||
          </FormGroup>
 | 
			
		||||
          <FormGroup title="문제">
 | 
			
		||||
            <Form.Item name="question" rules={[{ required: true, message: "필수값입니다" }]}>
 | 
			
		||||
              <Editor height={100} value={formData?.question} />
 | 
			
		||||
            </Form.Item>
 | 
			
		||||
          </FormGroup>
 | 
			
		||||
        </FormSection>
 | 
			
		||||
 | 
			
		||||
        <FormSection>
 | 
			
		||||
          <Radio.Group onChange={selectAnswer} value={answer} style={{ display: "block" }}>
 | 
			
		||||
            <div className="mb-0 lg:flex lg:mb-3">
 | 
			
		||||
              <div className="flex-none w-full mt-1 mb-3 lg:w-48 lg:mb-0">
 | 
			
		||||
                <Radio value="1">해답 1</Radio>
 | 
			
		||||
              </div>
 | 
			
		||||
 | 
			
		||||
              <div className="min-w-0 mb-5 grow lg:-mb-3">
 | 
			
		||||
                <Form.Item name="choice1" rules={[{ required: true, message: "필수값입니다" }]}>
 | 
			
		||||
                  <Editor height={120} value={formData?.choice1} />
 | 
			
		||||
                </Form.Item>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <Divider />
 | 
			
		||||
 | 
			
		||||
            <div className="mb-0 lg:flex lg:mb-3">
 | 
			
		||||
              <div className="flex-none w-full mt-1 mb-3 lg:w-48 lg:mb-0">
 | 
			
		||||
                <Radio value="2">해답 2</Radio>
 | 
			
		||||
              </div>
 | 
			
		||||
              <div className="min-w-0 mb-5 grow lg:-mb-3">
 | 
			
		||||
                <Form.Item name="choice2" rules={[{ required: true, message: "필수값입니다" }]}>
 | 
			
		||||
                  <Editor height={120} value={formData?.choice2} />
 | 
			
		||||
                </Form.Item>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <Divider />
 | 
			
		||||
 | 
			
		||||
            <div className="mb-0 lg:flex lg:mb-3">
 | 
			
		||||
              <div className="flex-none w-full mt-1 mb-3 lg:w-48 lg:mb-0">
 | 
			
		||||
                <Radio value="3">해답 3</Radio>
 | 
			
		||||
              </div>
 | 
			
		||||
              <div className="min-w-0 mb-5 grow lg:-mb-3">
 | 
			
		||||
                <Form.Item name="choice3" rules={[{ required: true, message: "필수값입니다" }]}>
 | 
			
		||||
                  <Editor height={120} value={formData?.choice3} />
 | 
			
		||||
                </Form.Item>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <Divider />
 | 
			
		||||
 | 
			
		||||
            <div className="mb-0 lg:flex lg:mb-3">
 | 
			
		||||
              <div className="flex-none w-full mt-1 mb-3 lg:w-48 lg:mb-0">
 | 
			
		||||
                <Radio value="4">해답 4</Radio>
 | 
			
		||||
              </div>
 | 
			
		||||
              <div className="min-w-0 mb-5 grow lg:-mb-3">
 | 
			
		||||
                <Form.Item name="choice4" rules={[{ required: true, message: "필수값입니다" }]}>
 | 
			
		||||
                  <Editor height={120} value={formData?.choice4} />
 | 
			
		||||
                </Form.Item>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </Radio.Group>
 | 
			
		||||
        </FormSection>
 | 
			
		||||
 | 
			
		||||
        <FormSection>
 | 
			
		||||
          <FormGroup title="힌트">
 | 
			
		||||
            <Form.Item name="hint">
 | 
			
		||||
              <Editor height={100} value={data?.hint} />
 | 
			
		||||
            </Form.Item>
 | 
			
		||||
          </FormGroup>
 | 
			
		||||
        </FormSection>
 | 
			
		||||
 | 
			
		||||
        <div className="text-center">
 | 
			
		||||
          <Button htmlType="submit" type="primary" loading={isLoading}>
 | 
			
		||||
            Save
 | 
			
		||||
          </Button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </DefaultForm>
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default React.memo(QuizChoiceForm)
 | 
			
		||||
							
								
								
									
										37
									
								
								src/components/quiz/QuizForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/components/quiz/QuizForm.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,37 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import "@uiw/react-markdown-preview/markdown.css"
 | 
			
		||||
import "@uiw/react-md-editor/markdown-editor.css"
 | 
			
		||||
import React, { useState } from "react"
 | 
			
		||||
 | 
			
		||||
import QuizChoiceForm from "./QuizChoiceForm"
 | 
			
		||||
import QuizCheckForm from "./QuizCheckForm"
 | 
			
		||||
import QuizTypePanel from "./QuizTypePanel"
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  qid?: any
 | 
			
		||||
  pid?: any
 | 
			
		||||
  data?: any
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const QuizForm = (props: Props) => {
 | 
			
		||||
  const { pid, qid, data } = props
 | 
			
		||||
  const [quizType, setQuizType] = useState(data?.quiz_type ? data.quiz_type : "choice")
 | 
			
		||||
 | 
			
		||||
  const selectQuizType = (quizType: string) => {
 | 
			
		||||
    console.log("selectQuizType", quizType)
 | 
			
		||||
    setQuizType(quizType)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  console.log("quiz_type", data ? data.quiz_type : "choice")
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <QuizTypePanel quizType={quizType} action={{ selectQuizType }} />
 | 
			
		||||
      {quizType === "choice" && <QuizChoiceForm pid={pid} qid={qid} data={data} />}
 | 
			
		||||
      {quizType === "check" && <QuizCheckForm pid={pid} qid={qid} data={data} />}
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default React.memo(QuizForm)
 | 
			
		||||
							
								
								
									
										54
									
								
								src/components/quiz/QuizTypePanel.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								src/components/quiz/QuizTypePanel.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,54 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { create, QuizFormValue, update } from "@/client/quiz"
 | 
			
		||||
import "@uiw/react-markdown-preview/markdown.css"
 | 
			
		||||
import "@uiw/react-md-editor/markdown-editor.css"
 | 
			
		||||
import { Button, Checkbox, Divider, Form, Input, message, Radio } from "antd"
 | 
			
		||||
import { useForm } from "antd/lib/form/Form"
 | 
			
		||||
import dynamic from "next/dynamic"
 | 
			
		||||
import Router from "next/router"
 | 
			
		||||
import React, { useEffect, useState } from "react"
 | 
			
		||||
 | 
			
		||||
import FormGroup from "@/components/shared/form/ui/form-group"
 | 
			
		||||
import FormSection from "@/components/shared/form/ui/form-section"
 | 
			
		||||
import router from "next/router"
 | 
			
		||||
import QuizChoiceForm from "./QuizChoiceForm"
 | 
			
		||||
import QuizCheckForm from "./QuizCheckForm"
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  quizType: string
 | 
			
		||||
  action: any
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const QuizTypePanel = (props: Props) => {
 | 
			
		||||
  const { quizType, action } = props
 | 
			
		||||
  const onChange = (e: any) => {
 | 
			
		||||
    console.log("onChange", e.target.value)
 | 
			
		||||
    action.selectQuizType(e.target.value)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    console.log("quizType", quizType)
 | 
			
		||||
  }, [quizType])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <Form initialValues={{ quiz_type: quizType }}>
 | 
			
		||||
        <FormSection>
 | 
			
		||||
          <FormGroup title="문제유형">
 | 
			
		||||
            <Form.Item name="quiz_type">
 | 
			
		||||
              <Radio.Group size="middle" value={quizType} onChange={onChange}>
 | 
			
		||||
                <Radio.Button value="choice">4지선다</Radio.Button>
 | 
			
		||||
                <Radio.Button value="check">체크</Radio.Button>
 | 
			
		||||
                <Radio.Button value="yes">Yes/No</Radio.Button>
 | 
			
		||||
                <Radio.Button value="input">주관식</Radio.Button>
 | 
			
		||||
              </Radio.Group>
 | 
			
		||||
            </Form.Item>
 | 
			
		||||
          </FormGroup>
 | 
			
		||||
        </FormSection>
 | 
			
		||||
      </Form>
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default React.memo(QuizTypePanel)
 | 
			
		||||
							
								
								
									
										67
									
								
								src/components/quiz/SearchPanel.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								src/components/quiz/SearchPanel.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,67 @@
 | 
			
		||||
import { Button, Form, Input, Select } from "antd"
 | 
			
		||||
import { useForm } from "antd/lib/form/Form"
 | 
			
		||||
import { Search } from "lucide-react"
 | 
			
		||||
import { useRouter } from "next/router"
 | 
			
		||||
import React, { useCallback } from "react"
 | 
			
		||||
 | 
			
		||||
import DefaultSearchForm from "@/components/shared/form/ui/default-search-form"
 | 
			
		||||
import FieldInline from "@/components/shared/form/ui/field-inline"
 | 
			
		||||
import FormSearch from "@/components/shared/form/ui/form-search"
 | 
			
		||||
import { QuizResponse } from "../../client/quiz"
 | 
			
		||||
import DateRangeField from "../shared/form/control/date-range-field"
 | 
			
		||||
 | 
			
		||||
const statusOptions = [
 | 
			
		||||
  { label: "전체", value: "ALL" },
 | 
			
		||||
  { label: "판매", value: "SALE" },
 | 
			
		||||
  { label: "추첨", value: "LOT" },
 | 
			
		||||
  // { label: "품절", value: "SOLDOUT" },
 | 
			
		||||
  // { label: "판매중단", value: "NOTSALE" },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
const SearchPanel = () => {
 | 
			
		||||
  const [form] = useForm()
 | 
			
		||||
  const router = useRouter()
 | 
			
		||||
 | 
			
		||||
  const handleFinish = useCallback(
 | 
			
		||||
    (formValue: QuizResponse) => {
 | 
			
		||||
      router.push({
 | 
			
		||||
        pathname: router.pathname,
 | 
			
		||||
        query: { ...router.query, ...formValue },
 | 
			
		||||
      })
 | 
			
		||||
    },
 | 
			
		||||
    [router]
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <DefaultSearchForm form={form} onFinish={handleFinish}>
 | 
			
		||||
      <FormSearch>
 | 
			
		||||
        <FieldInline>
 | 
			
		||||
          <Form.Item label="기간" name="searchDateType" initialValue="created">
 | 
			
		||||
            <Select dropdownMatchSelectWidth={false}>
 | 
			
		||||
              <Select.Option value="created">등록일자</Select.Option>
 | 
			
		||||
              <Select.Option value="updated">수정일자</Select.Option>
 | 
			
		||||
            </Select>
 | 
			
		||||
          </Form.Item>
 | 
			
		||||
          <DateRangeField />
 | 
			
		||||
        </FieldInline>
 | 
			
		||||
        <div>
 | 
			
		||||
          <FieldInline>
 | 
			
		||||
            <Form.Item label="검색어" name="searchText" className="grow">
 | 
			
		||||
              <Input placeholder="검색어를 입력해주세요" />
 | 
			
		||||
            </Form.Item>
 | 
			
		||||
          </FieldInline>
 | 
			
		||||
        </div>
 | 
			
		||||
      </FormSearch>
 | 
			
		||||
      <div className="flex justify-center gap-2">
 | 
			
		||||
        <Button htmlType="submit" className="btn-with-icon" icon={<Search />}>
 | 
			
		||||
          검색
 | 
			
		||||
        </Button>
 | 
			
		||||
        <Button htmlType="submit" className="btn-with-icon" onClick={() => form.resetFields()}>
 | 
			
		||||
          초기화
 | 
			
		||||
        </Button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </DefaultSearchForm>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default React.memo(SearchPanel)
 | 
			
		||||
							
								
								
									
										70
									
								
								src/components/shared/form/control/date-range-field.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								src/components/shared/form/control/date-range-field.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,70 @@
 | 
			
		||||
import { DatePicker, Form, Radio, RadioChangeEvent } from "antd"
 | 
			
		||||
import dayjs from "dayjs"
 | 
			
		||||
import React from "react"
 | 
			
		||||
 | 
			
		||||
interface IDateRangeFieldProps {
 | 
			
		||||
  value?: (dayjs.Dayjs | null)[]
 | 
			
		||||
  onChange?: (value: (dayjs.Dayjs | null)[]) => void
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const dateRangeOptions = [
 | 
			
		||||
  { label: "오늘", value: "today" },
 | 
			
		||||
  { label: "1주일", value: "1week" },
 | 
			
		||||
  { label: "1개월", value: "1month" },
 | 
			
		||||
  { label: "3개월", value: "3months" },
 | 
			
		||||
  { label: "6개월", value: "6months" },
 | 
			
		||||
  { label: "1년", value: "1year" },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
const DateRangeField = ({ value, onChange }: IDateRangeFieldProps) => {
 | 
			
		||||
  const handleDateRangeChange = (e: RadioChangeEvent) => {
 | 
			
		||||
    if (e.target.value === "today") {
 | 
			
		||||
      onChange?.([dayjs(), dayjs()])
 | 
			
		||||
    } else if (e.target.value === "1week") {
 | 
			
		||||
      onChange?.([dayjs().subtract(1, "week"), dayjs()])
 | 
			
		||||
    } else if (e.target.value === "1month") {
 | 
			
		||||
      onChange?.([dayjs().subtract(1, "month"), dayjs()])
 | 
			
		||||
    } else if (e.target.value === "3months") {
 | 
			
		||||
      onChange?.([dayjs().subtract(3, "months"), dayjs()])
 | 
			
		||||
    } else if (e.target.value === "6months") {
 | 
			
		||||
      onChange?.([dayjs().subtract(6, "months"), dayjs()])
 | 
			
		||||
    } else if (e.target.value === "1year") {
 | 
			
		||||
      onChange?.([dayjs().subtract(1, "year"), dayjs()])
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="flex flex-wrap items-center gap-2">
 | 
			
		||||
      <Form.Item name="startDate">
 | 
			
		||||
        <DatePicker
 | 
			
		||||
          placeholder="시작 날짜"
 | 
			
		||||
          onChange={(v: dayjs.Dayjs | null) => {
 | 
			
		||||
            onChange?.([v, value?.[1] || null])
 | 
			
		||||
          }}
 | 
			
		||||
          value={value?.[0]}
 | 
			
		||||
        />
 | 
			
		||||
      </Form.Item>
 | 
			
		||||
      <span>~</span>
 | 
			
		||||
      <Form.Item name="endDate">
 | 
			
		||||
        <DatePicker
 | 
			
		||||
          placeholder="종료 날짜"
 | 
			
		||||
          onChange={(v: dayjs.Dayjs | null) => {
 | 
			
		||||
            onChange?.([value?.[0] || null, v])
 | 
			
		||||
          }}
 | 
			
		||||
          value={value?.[1]}
 | 
			
		||||
        />
 | 
			
		||||
      </Form.Item>
 | 
			
		||||
      <div className="flex items-center gap-1">
 | 
			
		||||
        <Radio.Group
 | 
			
		||||
          size="small"
 | 
			
		||||
          options={dateRangeOptions}
 | 
			
		||||
          optionType="button"
 | 
			
		||||
          buttonStyle="solid"
 | 
			
		||||
          onChange={handleDateRangeChange}
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default React.memo(DateRangeField)
 | 
			
		||||
							
								
								
									
										28
									
								
								src/components/shared/form/ui/default-form.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/components/shared/form/ui/default-form.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
			
		||||
import { Form, FormProps } from "antd";
 | 
			
		||||
import React, { PropsWithChildren, useCallback } from "react";
 | 
			
		||||
import style from "./form.module.css";
 | 
			
		||||
 | 
			
		||||
interface IDefaultFormProps extends FormProps {}
 | 
			
		||||
 | 
			
		||||
const DefaultForm = <T,>({ children, ...formProps }: PropsWithChildren<IDefaultFormProps>) => {
 | 
			
		||||
  const handleFormFailed = useCallback(
 | 
			
		||||
    ({ errorFields }: any) => {
 | 
			
		||||
      formProps.form?.scrollToField(errorFields[0].name);
 | 
			
		||||
    },
 | 
			
		||||
    [formProps.form]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Form<T>
 | 
			
		||||
      className={style["default-form"]}
 | 
			
		||||
      layout="vertical"
 | 
			
		||||
      requiredMark={false}
 | 
			
		||||
      onFinishFailed={handleFormFailed}
 | 
			
		||||
      {...formProps}
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
    </Form>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default React.memo(DefaultForm) as typeof DefaultForm;
 | 
			
		||||
							
								
								
									
										30
									
								
								src/components/shared/form/ui/default-search-form.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/components/shared/form/ui/default-search-form.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,30 @@
 | 
			
		||||
import { Form, FormProps } from "antd";
 | 
			
		||||
import React, { PropsWithChildren, useCallback } from "react";
 | 
			
		||||
import style from "./form.module.css";
 | 
			
		||||
 | 
			
		||||
interface IDefaultSearchFormProps extends FormProps {}
 | 
			
		||||
 | 
			
		||||
const DefaultSearchForm = <T,>({ children, ...formProps }: PropsWithChildren<IDefaultSearchFormProps>) => {
 | 
			
		||||
  const handleFormFailed = useCallback(
 | 
			
		||||
    ({ errorFields }: any) => {
 | 
			
		||||
      formProps.form?.scrollToField(errorFields[0].name);
 | 
			
		||||
    },
 | 
			
		||||
    [formProps.form]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Form<T>
 | 
			
		||||
      className={style["default-form"]}
 | 
			
		||||
      layout="horizontal"
 | 
			
		||||
      requiredMark={false}
 | 
			
		||||
      onFinishFailed={handleFormFailed}
 | 
			
		||||
      labelAlign="left"
 | 
			
		||||
      labelWrap
 | 
			
		||||
      {...formProps}
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
    </Form>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default React.memo(DefaultSearchForm) as typeof DefaultSearchForm;
 | 
			
		||||
							
								
								
									
										7
									
								
								src/components/shared/form/ui/field-inline.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/components/shared/form/ui/field-inline.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
			
		||||
import React, { PropsWithChildren } from "react";
 | 
			
		||||
 | 
			
		||||
const FieldInline = ({ children }: PropsWithChildren<{}>) => {
 | 
			
		||||
  return <div className="flex flex-wrap items-center gap-2">{children}</div>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default React.memo(FieldInline);
 | 
			
		||||
							
								
								
									
										20
									
								
								src/components/shared/form/ui/form-group.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/components/shared/form/ui/form-group.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
import React, { PropsWithChildren } from "react";
 | 
			
		||||
 | 
			
		||||
interface IFormGroupProps {
 | 
			
		||||
  title?: string;
 | 
			
		||||
  description?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const FormGroup = ({ title, description, children }: PropsWithChildren<IFormGroupProps>) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="mb-0 lg:flex lg:mb-3">
 | 
			
		||||
      <div className="flex-none w-full mt-1 mb-3 lg:w-48 lg:mb-0">
 | 
			
		||||
        <div>{title}</div>
 | 
			
		||||
        <div className="text-gray-400">{description}</div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div className="min-w-0 mb-5 grow lg:-mb-3">{children}</div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default React.memo(FormGroup);
 | 
			
		||||
							
								
								
									
										10
									
								
								src/components/shared/form/ui/form-search.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/components/shared/form/ui/form-search.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
			
		||||
import React, { PropsWithChildren } from "react";
 | 
			
		||||
import style from "./form.module.css";
 | 
			
		||||
 | 
			
		||||
interface IFormSearchProps {}
 | 
			
		||||
 | 
			
		||||
const FormSearch = ({ children }: PropsWithChildren<IFormSearchProps>) => {
 | 
			
		||||
  return <div className={style["search-form"]}>{children}</div>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default React.memo(FormSearch);
 | 
			
		||||
							
								
								
									
										27
									
								
								src/components/shared/form/ui/form-section.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/components/shared/form/ui/form-section.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,27 @@
 | 
			
		||||
import React, { PropsWithChildren } from "react";
 | 
			
		||||
 | 
			
		||||
interface IFormSectionProps {
 | 
			
		||||
  title?: string;
 | 
			
		||||
  description?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const FormSection = ({
 | 
			
		||||
  title,
 | 
			
		||||
  description,
 | 
			
		||||
  children,
 | 
			
		||||
}: PropsWithChildren<IFormSectionProps>) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="w-full border border-gray-200 shadow-sm rounded-lg pt-5 pl-3 pr-3 pb-4 mb-5">
 | 
			
		||||
      {title ? <h3 className="text-xl pl-4 pr-4">{title}</h3> : null}
 | 
			
		||||
      {description ? (
 | 
			
		||||
        <div className="mt-1 pb-5 pl-4 pr-4 mb-6 text-gray-400 border-b border-gray-200">
 | 
			
		||||
          {description}
 | 
			
		||||
        </div>
 | 
			
		||||
      ) : null}
 | 
			
		||||
 | 
			
		||||
      <div className="pl-4 pr-4">{children}</div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default React.memo(FormSection);
 | 
			
		||||
							
								
								
									
										19
									
								
								src/components/shared/form/ui/form.module.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/components/shared/form/ui/form.module.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,19 @@
 | 
			
		||||
.default-form {
 | 
			
		||||
  @apply my-5;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.default-form :global(.ant-form-item) {
 | 
			
		||||
  margin-bottom: 12px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.default-form :global(.ant-divider-horizontal) {
 | 
			
		||||
  margin: 12px 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.search-form {
 | 
			
		||||
  @apply px-5 my-5 bg-gray-100 rounded-lg pt-5 pb-2 block sm:grid gap-x-3 border border-gray-200;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.search-form :global(.ant-form-item-label) {
 | 
			
		||||
  @apply pb-1 sm:pb-0 min-w-[6em];
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										6
									
								
								src/components/shared/spinner.module.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/components/shared/spinner.module.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
			
		||||
.spinner {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  top: 30%;
 | 
			
		||||
  left: 50%;
 | 
			
		||||
  transform: translate(-50%, -50%);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										29
									
								
								src/components/shared/spinner.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src/components/shared/spinner.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,29 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import style from "./spinner.module.css";
 | 
			
		||||
 | 
			
		||||
interface ISpinnerProps {
 | 
			
		||||
  color?: "gray";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const Spinner = ({ color = "gray" }: ISpinnerProps) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={style.spinner}>
 | 
			
		||||
      <div className={`mx-auto lds-spinner lds-spinner-${color}`}>
 | 
			
		||||
        <div />
 | 
			
		||||
        <div />
 | 
			
		||||
        <div />
 | 
			
		||||
        <div />
 | 
			
		||||
        <div />
 | 
			
		||||
        <div />
 | 
			
		||||
        <div />
 | 
			
		||||
        <div />
 | 
			
		||||
        <div />
 | 
			
		||||
        <div />
 | 
			
		||||
        <div />
 | 
			
		||||
        <div />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default React.memo(Spinner);
 | 
			
		||||
							
								
								
									
										13
									
								
								src/components/shared/ui/default-btn-group.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/components/shared/ui/default-btn-group.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
 | 
			
		||||
interface IDefaultBtnGroupProps {
 | 
			
		||||
  children: React.ReactNode;
 | 
			
		||||
  align?: "start" | "end" | "center";
 | 
			
		||||
  className?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const DefaultBtnGroup = ({ className, children, align = "start" }: IDefaultBtnGroupProps) => {
 | 
			
		||||
  return <div className={`flex flex-wrap gap-2 mt-5 justify-${align} ${className || ""}`}>{children}</div>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default React.memo(DefaultBtnGroup);
 | 
			
		||||
							
								
								
									
										15
									
								
								src/components/shared/ui/default-modal.module.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/components/shared/ui/default-modal.module.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,15 @@
 | 
			
		||||
.default-popup :global(.ant-modal-close-x) {
 | 
			
		||||
  display: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.default-popup-close-btn {
 | 
			
		||||
  @apply absolute top-6 right-6 text-gray-400 hover:text-gray-600;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.default-popup-title {
 | 
			
		||||
  @apply text-2xl font-medium px-2;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.default-popup-content {
 | 
			
		||||
  @apply pt-5 pb-2 px-2;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										22
									
								
								src/components/shared/ui/default-modal.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/components/shared/ui/default-modal.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
			
		||||
import { Modal, ModalProps } from "antd";
 | 
			
		||||
import { X } from "lucide-react";
 | 
			
		||||
import React, { PropsWithChildren } from "react";
 | 
			
		||||
import style from "./default-modal.module.css";
 | 
			
		||||
 | 
			
		||||
interface IDefaultModalProps extends ModalProps {
 | 
			
		||||
  handleHide: () => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const DefaultModal = ({ children, handleHide, title, ...modalProps }: PropsWithChildren<IDefaultModalProps>) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <Modal footer={null} closable={false} className={style["default-popup"]} {...modalProps} onCancel={handleHide}>
 | 
			
		||||
      <button className={style["default-popup-close-btn"]} onClick={handleHide}>
 | 
			
		||||
        <X className="w-7 h-7" />
 | 
			
		||||
      </button>
 | 
			
		||||
      <h3 className={style["default-popup-title"]}>{title}</h3>
 | 
			
		||||
      <div className={style["default-popup-content"]}>{children}</div>
 | 
			
		||||
    </Modal>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default React.memo(DefaultModal);
 | 
			
		||||
							
								
								
									
										11
									
								
								src/components/shared/ui/default-table-btn.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/components/shared/ui/default-table-btn.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
import React, { PropsWithChildren } from "react";
 | 
			
		||||
 | 
			
		||||
interface IDefaultTableBtnProps {
 | 
			
		||||
  className?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const DefaultTableBtn = ({ children, className }: PropsWithChildren<IDefaultTableBtnProps>) => {
 | 
			
		||||
  return <div className={`my-5 flex-item-list ${className}`}>{children}</div>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default React.memo(DefaultTableBtn);
 | 
			
		||||
							
								
								
									
										29
									
								
								src/components/shared/ui/default-table.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src/components/shared/ui/default-table.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,29 @@
 | 
			
		||||
import { Table, TableProps } from "antd"
 | 
			
		||||
import React, { PropsWithChildren } from "react"
 | 
			
		||||
import numeral from "numeral"
 | 
			
		||||
 | 
			
		||||
interface IDefaultTableProps<T> extends TableProps<T> {
 | 
			
		||||
  countLabel?: number
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const DefaultTable = <T extends object>({
 | 
			
		||||
  children,
 | 
			
		||||
  countLabel,
 | 
			
		||||
  ...tableProps
 | 
			
		||||
}: PropsWithChildren<IDefaultTableProps<T>>) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <Table<T>
 | 
			
		||||
      size="small"
 | 
			
		||||
      rowKey="id"
 | 
			
		||||
      tableLayout="fixed"
 | 
			
		||||
      scroll={{ x: 800 }}
 | 
			
		||||
      bordered
 | 
			
		||||
      {...(countLabel && { title: () => <p>{numeral(countLabel).format("0,0")}건</p> })}
 | 
			
		||||
      {...tableProps}
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
    </Table>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default React.memo(DefaultTable) as typeof DefaultTable
 | 
			
		||||
							
								
								
									
										27
									
								
								src/helpers/datetime.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/helpers/datetime.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,27 @@
 | 
			
		||||
import dayjs from "dayjs"
 | 
			
		||||
import utc from "dayjs/plugin/utc"
 | 
			
		||||
 | 
			
		||||
export const getLocalDate = (s: string) => {
 | 
			
		||||
  dayjs.extend(utc)
 | 
			
		||||
  return dayjs(s).local().format("YYYY-MM-DD")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const getLocalDatetime = (s: string) => {
 | 
			
		||||
  dayjs.extend(utc)
 | 
			
		||||
  return dayjs(s).local().format("YYYY-MM-DD HH:mm")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const getUTCDatetime = (s: string) => {
 | 
			
		||||
  dayjs.extend(utc)
 | 
			
		||||
  return dayjs(s).utc().format("YYYY-MM-DD HH:mm")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const getUTCDate = (s: string) => {
 | 
			
		||||
  dayjs.extend(utc)
 | 
			
		||||
  return dayjs(s).utc().format("YYYY-MM-DD")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const getUTCTime = (s: string) => {
 | 
			
		||||
  dayjs.extend(utc)
 | 
			
		||||
  return dayjs(s).utc().format("HH:mm")
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										5
									
								
								src/helpers/uuid.helper.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/helpers/uuid.helper.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,5 @@
 | 
			
		||||
import { randomBytes, randomUUID } from "crypto"
 | 
			
		||||
 | 
			
		||||
export const generateUUID = () => {
 | 
			
		||||
  return randomUUID?.() ?? randomBytes(32).toString("hex")
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										10
									
								
								src/lib/prismadb.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/lib/prismadb.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
			
		||||
import { PrismaClient } from "@prisma/client"
 | 
			
		||||
 | 
			
		||||
declare global {
 | 
			
		||||
  var prisma: PrismaClient | undefined
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const client = new PrismaClient()
 | 
			
		||||
if (process.env.NODE_ENV !== "production") globalThis.prisma = client
 | 
			
		||||
 | 
			
		||||
export default client
 | 
			
		||||
							
								
								
									
										41
									
								
								src/pages/_app.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								src/pages/_app.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,41 @@
 | 
			
		||||
import { fetcher } from "@/client/base"
 | 
			
		||||
import { IDefaultLayoutPage } from "@/components/layout/default-layout"
 | 
			
		||||
import SeoHead from "@/components/layout/seo-head"
 | 
			
		||||
import "@/styles/globals.css"
 | 
			
		||||
import { ConfigProvider } from "antd"
 | 
			
		||||
import koKR from "antd/locale/ko_KR"
 | 
			
		||||
import { NextComponentType } from "next"
 | 
			
		||||
import type { AppProps } from "next/app"
 | 
			
		||||
import Head from "next/head"
 | 
			
		||||
import { SWRConfig } from "swr"
 | 
			
		||||
 | 
			
		||||
export default function App({ Component, pageProps: { session, ...pageProps } }: AppProps) {
 | 
			
		||||
  const getLayout =
 | 
			
		||||
    (Component as IDefaultLayoutPage).getLayout ||
 | 
			
		||||
    ((Page: NextComponentType, props: Record<string, unknown>) => <Page {...props} />)
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <SeoHead />
 | 
			
		||||
      <Head>
 | 
			
		||||
        <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
 | 
			
		||||
        <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
 | 
			
		||||
        <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
 | 
			
		||||
      </Head>
 | 
			
		||||
      <ConfigProvider
 | 
			
		||||
        theme={{
 | 
			
		||||
          token: {
 | 
			
		||||
            colorPrimary: "#63489a",
 | 
			
		||||
            colorLink: "#63489a",
 | 
			
		||||
            colorLinkHover: "#7f68a6",
 | 
			
		||||
          },
 | 
			
		||||
        }}
 | 
			
		||||
        locale={koKR}
 | 
			
		||||
      >
 | 
			
		||||
        <SWRConfig value={{ fetcher }}>
 | 
			
		||||
          <main className={`font-sans`}>{getLayout(Component, pageProps)}</main>
 | 
			
		||||
        </SWRConfig>
 | 
			
		||||
      </ConfigProvider>
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										42
									
								
								src/pages/_document.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								src/pages/_document.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,42 @@
 | 
			
		||||
import { createCache, extractStyle, StyleProvider } from "@ant-design/cssinjs";
 | 
			
		||||
import Document, { DocumentContext, Head, Html, Main, NextScript } from "next/document";
 | 
			
		||||
 | 
			
		||||
export default class MyDocument extends Document {
 | 
			
		||||
  static async getInitialProps(ctx: DocumentContext) {
 | 
			
		||||
    const cache = createCache();
 | 
			
		||||
    const originalRenderPage = ctx.renderPage;
 | 
			
		||||
 | 
			
		||||
    ctx.renderPage = () =>
 | 
			
		||||
      originalRenderPage({
 | 
			
		||||
        enhanceApp: (App) => (props) =>
 | 
			
		||||
          (
 | 
			
		||||
            <StyleProvider cache={cache}>
 | 
			
		||||
              <App {...props} />
 | 
			
		||||
            </StyleProvider>
 | 
			
		||||
          ),
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
    const initialProps = await Document.getInitialProps(ctx);
 | 
			
		||||
    return {
 | 
			
		||||
      ...initialProps,
 | 
			
		||||
      styles: (
 | 
			
		||||
        <>
 | 
			
		||||
          {initialProps.styles}
 | 
			
		||||
          <style data-test="extract" dangerouslySetInnerHTML={{ __html: extractStyle(cache) }} />
 | 
			
		||||
        </>
 | 
			
		||||
      ),
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  render() {
 | 
			
		||||
    return (
 | 
			
		||||
      <Html lang="ko">
 | 
			
		||||
        <Head />
 | 
			
		||||
        <body>
 | 
			
		||||
          <Main />
 | 
			
		||||
          <NextScript />
 | 
			
		||||
        </body>
 | 
			
		||||
      </Html>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										9
									
								
								src/pages/api/health.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/pages/api/health.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
			
		||||
import type { NextApiRequest, NextApiResponse } from "next"
 | 
			
		||||
 | 
			
		||||
type Data = {
 | 
			
		||||
  status: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function handler(_req: NextApiRequest, res: NextApiResponse<Data>) {
 | 
			
		||||
  res.status(200).json({ status: "ok" })
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										8
									
								
								src/pages/api/healthcheck/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/pages/api/healthcheck/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
			
		||||
import type { NextApiRequest, NextApiResponse } from "next"
 | 
			
		||||
 | 
			
		||||
export default async function handler(
 | 
			
		||||
  req: NextApiRequest,
 | 
			
		||||
  res: NextApiResponse
 | 
			
		||||
) {
 | 
			
		||||
  return res.status(200).json({ message: "Ok" })
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										60
									
								
								src/pages/api/program/[slug].ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								src/pages/api/program/[slug].ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,60 @@
 | 
			
		||||
import prisma from "@/lib/prismadb"
 | 
			
		||||
import type { NextApiRequest, NextApiResponse } from "next"
 | 
			
		||||
 | 
			
		||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
 | 
			
		||||
  if (req.method === "GET") {
 | 
			
		||||
    return get(req, res)
 | 
			
		||||
  } else if (req.method === "PUT") {
 | 
			
		||||
    return update(req, res)
 | 
			
		||||
  } else {
 | 
			
		||||
    return res.status(405).json({ error: "Method not allowed" })
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function get(req: NextApiRequest, res: NextApiResponse) {
 | 
			
		||||
  const id = req.query.slug as string
 | 
			
		||||
  const result = await prisma.program.findFirst({
 | 
			
		||||
    select: {
 | 
			
		||||
      id: true,
 | 
			
		||||
      course: true,
 | 
			
		||||
      subject: true,
 | 
			
		||||
      content: true,
 | 
			
		||||
      tag: true,
 | 
			
		||||
      status: true,
 | 
			
		||||
      publish_at: true,
 | 
			
		||||
      updated_at: true,
 | 
			
		||||
      created_at: true,
 | 
			
		||||
    },
 | 
			
		||||
    where: {
 | 
			
		||||
      id,
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  if (!result) {
 | 
			
		||||
    return res.status(400).json({ error: "no data" })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  res.status(200).json(result)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function update(req: NextApiRequest, res: NextApiResponse) {
 | 
			
		||||
  const id = req.query.slug as string
 | 
			
		||||
  const { course, subject, content, tag, status, publish_at } = req.body
 | 
			
		||||
  const result = await prisma.program.update({
 | 
			
		||||
    where: { id: id },
 | 
			
		||||
    data: {
 | 
			
		||||
      course,
 | 
			
		||||
      subject,
 | 
			
		||||
      content,
 | 
			
		||||
      tag,
 | 
			
		||||
      status,
 | 
			
		||||
      publish_at,
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  if (!result) {
 | 
			
		||||
    return res.status(400).json({ error: "update fail" })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return res.status(200).json(result)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										95
									
								
								src/pages/api/program/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								src/pages/api/program/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,95 @@
 | 
			
		||||
import { generateUUID } from "@/helpers/uuid.helper"
 | 
			
		||||
import prisma from "@/lib/prismadb"
 | 
			
		||||
import { Prisma } from "@prisma/client"
 | 
			
		||||
import type { NextApiRequest, NextApiResponse } from "next"
 | 
			
		||||
 | 
			
		||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
 | 
			
		||||
  if (req.method === "GET") {
 | 
			
		||||
    return get(req, res)
 | 
			
		||||
  } else if (req.method === "POST") {
 | 
			
		||||
    return create(req, res)
 | 
			
		||||
  } else {
 | 
			
		||||
    return res.status(405).json({ error: "Method not allowed" })
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function get(req: NextApiRequest, res: NextApiResponse) {
 | 
			
		||||
  console.log("req.query", req.query)
 | 
			
		||||
 | 
			
		||||
  const accesstoken = req.query.accesstoken ? (req.query.accesstoken as string) : ""
 | 
			
		||||
  const tag = typeof req.query.tag === "string" ? req.query.tag : undefined
 | 
			
		||||
  const q = typeof req.query.q === "string" ? req.query.q : undefined
 | 
			
		||||
  const page = req.query.page ? Number(req.query.page) : 1
 | 
			
		||||
  const pageSize = req.query.limit ? Number(req.query.limit) : 10
 | 
			
		||||
  const start = (page - 1) * pageSize
 | 
			
		||||
 | 
			
		||||
  let conditions = Prisma.sql``
 | 
			
		||||
 | 
			
		||||
  if (tag) {
 | 
			
		||||
    conditions = Prisma.sql`${conditions} AND p.tag LIKE ${"%" + tag + "%"}`
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (q) {
 | 
			
		||||
    conditions = Prisma.sql`${conditions}
 | 
			
		||||
      AND (p.tag LIKE ${"%" + q + "%"}
 | 
			
		||||
          OR p.subject LIKE ${"%" + q + "%"}
 | 
			
		||||
          OR p.content LIKE ${"%" + q + "%"})`
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const query = Prisma.sql`
 | 
			
		||||
    SELECT p.id, p.course, p.subject, p.content, p.tag, p.status, p.publish_at, IFNULL(q.cnt, 0) AS cnt
 | 
			
		||||
    FROM program p
 | 
			
		||||
    LEFT JOIN (SELECT program_id, COUNT(*) AS cnt FROM quiz GROUP BY program_id) q ON p.id = q.program_id
 | 
			
		||||
    WHERE TRUE ${conditions}
 | 
			
		||||
    ORDER BY p.created_at DESC
 | 
			
		||||
    LIMIT ${pageSize} OFFSET ${start}
 | 
			
		||||
`
 | 
			
		||||
  const result = await prisma.$queryRaw<any[]>(query)
 | 
			
		||||
 | 
			
		||||
  const totalQuery = Prisma.sql`
 | 
			
		||||
    SELECT CAST(COUNT(*) AS UNSIGNED) AS cnt
 | 
			
		||||
    FROM program p
 | 
			
		||||
    WHERE TRUE ${conditions}
 | 
			
		||||
  `
 | 
			
		||||
 | 
			
		||||
  type CountResult = { cnt: bigint }
 | 
			
		||||
  const totalResult: CountResult[] = await prisma.$queryRaw<CountResult[]>(totalQuery)
 | 
			
		||||
  const total: number = Number(totalResult.length > 0 ? totalResult[0].cnt : 0)
 | 
			
		||||
  const totalPage = Math.ceil(Number(total / pageSize))
 | 
			
		||||
 | 
			
		||||
  const data = JSON.parse(
 | 
			
		||||
    JSON.stringify(result, (key, value) => (typeof value === "bigint" ? value.toString() : value))
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  res.status(200).json({
 | 
			
		||||
    message: "OK",
 | 
			
		||||
    data: data,
 | 
			
		||||
    page: page,
 | 
			
		||||
    pageSize: pageSize,
 | 
			
		||||
    totalPage: totalPage,
 | 
			
		||||
    total: total,
 | 
			
		||||
    start: start,
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function create(req: NextApiRequest, res: NextApiResponse) {
 | 
			
		||||
  const { course, subject, content, tag, status, publish_at } = req.body
 | 
			
		||||
  let id = generateUUID()
 | 
			
		||||
  const result = await prisma.program.create({
 | 
			
		||||
    data: {
 | 
			
		||||
      id,
 | 
			
		||||
      course,
 | 
			
		||||
      subject,
 | 
			
		||||
      content,
 | 
			
		||||
      tag,
 | 
			
		||||
      status,
 | 
			
		||||
      publish_at,
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  if (!result) {
 | 
			
		||||
    return res.status(400).json({ error: "create fail" })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return res.status(200).json(result)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										62
									
								
								src/pages/api/quiz/[slug].ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								src/pages/api/quiz/[slug].ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,62 @@
 | 
			
		||||
import prisma from "@/lib/prismadb"
 | 
			
		||||
import type { NextApiRequest, NextApiResponse } from "next"
 | 
			
		||||
 | 
			
		||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
 | 
			
		||||
  if (req.method === "GET") {
 | 
			
		||||
    return get(req, res)
 | 
			
		||||
  } else if (req.method === "PUT") {
 | 
			
		||||
    return update(req, res)
 | 
			
		||||
  } else {
 | 
			
		||||
    return res.status(405).json({ error: "Method not allowed" })
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function get(req: NextApiRequest, res: NextApiResponse) {
 | 
			
		||||
  const id = req.query.slug as string
 | 
			
		||||
  const result = await prisma.quiz.findFirst({
 | 
			
		||||
    select: {
 | 
			
		||||
      id: true,
 | 
			
		||||
      quiz_type: true,
 | 
			
		||||
      sequence: true,
 | 
			
		||||
      program_id: true,
 | 
			
		||||
      question: true,
 | 
			
		||||
      choice: true,
 | 
			
		||||
      answer: true,
 | 
			
		||||
      comment: true,
 | 
			
		||||
      hint: true,
 | 
			
		||||
    },
 | 
			
		||||
    where: {
 | 
			
		||||
      id,
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  if (!result) {
 | 
			
		||||
    return res.status(400).json({ error: "no data" })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  res.status(200).json(result)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function update(req: NextApiRequest, res: NextApiResponse) {
 | 
			
		||||
  const id = req.query.slug as string
 | 
			
		||||
  const { quiz_type, sequence, program_id, question, choice, answer, comment, hint } = req.body
 | 
			
		||||
  const result = await prisma.quiz.update({
 | 
			
		||||
    where: { id },
 | 
			
		||||
    data: {
 | 
			
		||||
      quiz_type,
 | 
			
		||||
      sequence,
 | 
			
		||||
      program_id,
 | 
			
		||||
      question,
 | 
			
		||||
      choice,
 | 
			
		||||
      answer,
 | 
			
		||||
      comment,
 | 
			
		||||
      hint,
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  if (!result) {
 | 
			
		||||
    return res.status(400).json({ error: "update fail" })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return res.status(200).json(result)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										88
									
								
								src/pages/api/quiz/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								src/pages/api/quiz/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,88 @@
 | 
			
		||||
import { generateUUID } from "@/helpers/uuid.helper"
 | 
			
		||||
import prisma from "@/lib/prismadb"
 | 
			
		||||
import type { NextApiRequest, NextApiResponse } from "next"
 | 
			
		||||
 | 
			
		||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
 | 
			
		||||
  if (req.method === "GET") {
 | 
			
		||||
    return get(req, res)
 | 
			
		||||
  } else if (req.method === "POST") {
 | 
			
		||||
    return create(req, res)
 | 
			
		||||
  } else {
 | 
			
		||||
    return res.status(405).json({ error: "Method not allowed" })
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function get(req: NextApiRequest, res: NextApiResponse) {
 | 
			
		||||
  const accesstoken = req.query.accesstoken ? (req.query.accesstoken as string) : ""
 | 
			
		||||
  const program_id = req.query.program_id ? (req.query.program_id as string) : ""
 | 
			
		||||
  const page = req.query.page ? Number(req.query.page) : 1
 | 
			
		||||
  const pageSize = req.query.limit ? Number(req.query.limit) : 10
 | 
			
		||||
  const start = (page - 1) * pageSize
 | 
			
		||||
 | 
			
		||||
  // const user = await prisma.admin.findFirst({
 | 
			
		||||
  //   where: { accesstoken },
 | 
			
		||||
  //   select: {
 | 
			
		||||
  //     id: true,
 | 
			
		||||
  //   },
 | 
			
		||||
  // })
 | 
			
		||||
 | 
			
		||||
  // if (!user) {
 | 
			
		||||
  //   return res.status(400).json({ message: "Invalid user" })
 | 
			
		||||
  // }
 | 
			
		||||
 | 
			
		||||
  const total = await prisma.quiz.count()
 | 
			
		||||
  const totalPage = Math.ceil(total / pageSize)
 | 
			
		||||
  const where = program_id === "" ? {} : { program_id }
 | 
			
		||||
  const result = await prisma.quiz.findMany({
 | 
			
		||||
    select: {
 | 
			
		||||
      id: true,
 | 
			
		||||
      program_id: true,
 | 
			
		||||
      sequence: true,
 | 
			
		||||
      quiz_type: true,
 | 
			
		||||
      question: true,
 | 
			
		||||
      choice: true,
 | 
			
		||||
      hint: true,
 | 
			
		||||
    },
 | 
			
		||||
    where: where,
 | 
			
		||||
    orderBy: {
 | 
			
		||||
      sequence: "asc",
 | 
			
		||||
    },
 | 
			
		||||
    skip: start,
 | 
			
		||||
    take: pageSize,
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  res.status(200).json({
 | 
			
		||||
    data: result,
 | 
			
		||||
    page: page,
 | 
			
		||||
    pageSize: pageSize,
 | 
			
		||||
    totalPage: totalPage,
 | 
			
		||||
    total: total,
 | 
			
		||||
    start: start,
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function create(req: NextApiRequest, res: NextApiResponse) {
 | 
			
		||||
  console.log("req.body", req.body)
 | 
			
		||||
 | 
			
		||||
  const { program_id, sequence, quiz_type, question, choice, answer, comment, hint } = req.body
 | 
			
		||||
  let id = generateUUID()
 | 
			
		||||
  const result = await prisma.quiz.create({
 | 
			
		||||
    data: {
 | 
			
		||||
      id,
 | 
			
		||||
      program_id,
 | 
			
		||||
      quiz_type,
 | 
			
		||||
      sequence,
 | 
			
		||||
      question,
 | 
			
		||||
      choice,
 | 
			
		||||
      answer,
 | 
			
		||||
      comment,
 | 
			
		||||
      hint,
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  if (!result) {
 | 
			
		||||
    return res.status(400).json({ message: "Error" })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return res.status(200).json(result)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										14
									
								
								src/pages/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/pages/index.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,14 @@
 | 
			
		||||
import { getDefaultLayout, IDefaultLayoutPage, IPageHeader } from "@/components/layout/default-layout"
 | 
			
		||||
 | 
			
		||||
const pageHeader: IPageHeader = {
 | 
			
		||||
  title: "",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const Index: IDefaultLayoutPage = () => {
 | 
			
		||||
  return <></>
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
Index.getLayout = getDefaultLayout
 | 
			
		||||
Index.pageHeader = pageHeader
 | 
			
		||||
 | 
			
		||||
export default Index
 | 
			
		||||
							
								
								
									
										7
									
								
								src/pages/login/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/pages/login/index.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
			
		||||
import Login from "@/components/login"
 | 
			
		||||
 | 
			
		||||
const Index = () => {
 | 
			
		||||
  return <Login />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default Index
 | 
			
		||||
							
								
								
									
										32
									
								
								src/pages/program/[pid].tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/pages/program/[pid].tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,32 @@
 | 
			
		||||
import { useProgram } from "@/client/program"
 | 
			
		||||
import { useQuizzes } from "@/client/quiz"
 | 
			
		||||
import { getDefaultLayout, IDefaultLayoutPage, IPageHeader } from "@/components/layout/default-layout"
 | 
			
		||||
import ProgramForm from "@/components/program/ProgramForm"
 | 
			
		||||
import QuizList from "@/components/program/QuizList"
 | 
			
		||||
import { Alert } from "antd"
 | 
			
		||||
import { useRouter } from "next/router"
 | 
			
		||||
 | 
			
		||||
const pageHeader: IPageHeader = {
 | 
			
		||||
  title: "",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const ProgramEditPage: IDefaultLayoutPage = () => {
 | 
			
		||||
  const router = useRouter()
 | 
			
		||||
  const pid = router.query.pid ? (router.query.pid as string) : ""
 | 
			
		||||
 | 
			
		||||
  const { data: program } = useProgram({ id: pid })
 | 
			
		||||
  const { data: quizzes } = useQuizzes({
 | 
			
		||||
    program_id: pid,
 | 
			
		||||
  })
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <ProgramForm pid={pid} data={program} />
 | 
			
		||||
      <QuizList pid={pid} data={quizzes} />
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
ProgramEditPage.getLayout = getDefaultLayout
 | 
			
		||||
ProgramEditPage.pageHeader = pageHeader
 | 
			
		||||
 | 
			
		||||
export default ProgramEditPage
 | 
			
		||||
							
								
								
									
										30
									
								
								src/pages/program/[pid]/preview.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/pages/program/[pid]/preview.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,30 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { useProgram } from "@/client/program"
 | 
			
		||||
import { useQuizzes } from "@/client/quiz"
 | 
			
		||||
import { getDefaultLayout, IDefaultLayoutPage, IPageHeader } from "@/components/layout/default-layout"
 | 
			
		||||
import ListPanel from "@/components/program/ListPanel"
 | 
			
		||||
import Preview from "@/components/program/Preview"
 | 
			
		||||
import SearchPanel from "@/components/program/SearchPanel"
 | 
			
		||||
import { useRouter } from "next/router"
 | 
			
		||||
 | 
			
		||||
const pageHeader: IPageHeader = {
 | 
			
		||||
  title: "퀴즈 Preview",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const Index: IDefaultLayoutPage = () => {
 | 
			
		||||
  const router = useRouter()
 | 
			
		||||
  const pid = router.query.pid as string
 | 
			
		||||
 | 
			
		||||
  const { data: program } = useProgram({ id: pid })
 | 
			
		||||
  const { data } = useQuizzes({
 | 
			
		||||
    program_id: pid,
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  return <Preview program={program} quizzes={data?.data} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
Index.getLayout = getDefaultLayout
 | 
			
		||||
Index.pageHeader = pageHeader
 | 
			
		||||
 | 
			
		||||
export default Index
 | 
			
		||||
							
								
								
									
										35
									
								
								src/pages/program/[pid]/quiz/[qid].tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/pages/program/[pid]/quiz/[qid].tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,35 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { useQuiz } from "@/client/quiz"
 | 
			
		||||
import { getDefaultLayout, IDefaultLayoutPage, IPageHeader } from "@/components/layout/default-layout"
 | 
			
		||||
import QuizForm from "@/components/quiz/QuizForm"
 | 
			
		||||
import { Alert } from "antd"
 | 
			
		||||
import { useRouter } from "next/router"
 | 
			
		||||
 | 
			
		||||
const pageHeader: IPageHeader = {
 | 
			
		||||
  title: "퀴즈",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const QuizPage: IDefaultLayoutPage = () => {
 | 
			
		||||
  const router = useRouter()
 | 
			
		||||
  const pid = router.query.pid as string
 | 
			
		||||
  const qid = router.query.qid as string
 | 
			
		||||
 | 
			
		||||
  const { data, error } = useQuiz({ id: qid })
 | 
			
		||||
 | 
			
		||||
  if (error) {
 | 
			
		||||
    return <Alert message="데이터 로딩 중 오류가 발생했습니다." type="warning" />
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (data) {
 | 
			
		||||
    console.log("data", data)
 | 
			
		||||
    return <QuizForm pid={pid} qid={qid} data={data} />
 | 
			
		||||
  } else {
 | 
			
		||||
    return <></>
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
QuizPage.getLayout = getDefaultLayout
 | 
			
		||||
QuizPage.pageHeader = pageHeader
 | 
			
		||||
 | 
			
		||||
export default QuizPage
 | 
			
		||||
							
								
								
									
										20
									
								
								src/pages/program/[pid]/quiz/create.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/pages/program/[pid]/quiz/create.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { getDefaultLayout, IDefaultLayoutPage, IPageHeader } from "@/components/layout/default-layout"
 | 
			
		||||
import QuizForm from "@/components/quiz/QuizForm"
 | 
			
		||||
import { useRouter } from "next/router"
 | 
			
		||||
 | 
			
		||||
const pageHeader: IPageHeader = {
 | 
			
		||||
  title: "",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const QuizCreatePage: IDefaultLayoutPage = () => {
 | 
			
		||||
  const router = useRouter()
 | 
			
		||||
  const pid = (router.query.pid as string) ?? undefined
 | 
			
		||||
  return <QuizForm pid={pid} data={null} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
QuizCreatePage.getLayout = getDefaultLayout
 | 
			
		||||
QuizCreatePage.pageHeader = pageHeader
 | 
			
		||||
 | 
			
		||||
export default QuizCreatePage
 | 
			
		||||
							
								
								
									
										23
									
								
								src/pages/program/[pid]/quiz/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/pages/program/[pid]/quiz/index.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { getDefaultLayout, IDefaultLayoutPage, IPageHeader } from "@/components/layout/default-layout"
 | 
			
		||||
import List from "@/components/quiz/List"
 | 
			
		||||
import SearchPanel from "@/components/quiz/SearchPanel"
 | 
			
		||||
 | 
			
		||||
const pageHeader: IPageHeader = {
 | 
			
		||||
  title: "퀴즈",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const Index: IDefaultLayoutPage = () => {
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <SearchPanel />
 | 
			
		||||
      <List />
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
Index.getLayout = getDefaultLayout
 | 
			
		||||
Index.pageHeader = pageHeader
 | 
			
		||||
 | 
			
		||||
export default Index
 | 
			
		||||
							
								
								
									
										17
									
								
								src/pages/program/create.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/pages/program/create.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { getDefaultLayout, IDefaultLayoutPage, IPageHeader } from "@/components/layout/default-layout"
 | 
			
		||||
import ProgramForm from "@/components/program/ProgramForm"
 | 
			
		||||
 | 
			
		||||
const pageHeader: IPageHeader = {
 | 
			
		||||
  title: "",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const NewProgramPage: IDefaultLayoutPage = () => {
 | 
			
		||||
  return <ProgramForm data={null} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
NewProgramPage.getLayout = getDefaultLayout
 | 
			
		||||
NewProgramPage.pageHeader = pageHeader
 | 
			
		||||
 | 
			
		||||
export default NewProgramPage
 | 
			
		||||
							
								
								
									
										23
									
								
								src/pages/program/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/pages/program/index.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { getDefaultLayout, IDefaultLayoutPage, IPageHeader } from "@/components/layout/default-layout"
 | 
			
		||||
import ListPanel from "@/components/program/ListPanel"
 | 
			
		||||
import SearchPanel from "@/components/program/SearchPanel"
 | 
			
		||||
 | 
			
		||||
const pageHeader: IPageHeader = {
 | 
			
		||||
  title: "퀴즈",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const Index: IDefaultLayoutPage = () => {
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <SearchPanel />
 | 
			
		||||
      <ListPanel />
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
Index.getLayout = getDefaultLayout
 | 
			
		||||
Index.pageHeader = pageHeader
 | 
			
		||||
 | 
			
		||||
export default Index
 | 
			
		||||
							
								
								
									
										15
									
								
								src/styles/_custom_antd.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/styles/_custom_antd.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,15 @@
 | 
			
		||||
/* button */
 | 
			
		||||
.ant-btn svg,
 | 
			
		||||
.ant-input-affix-wrapper svg {
 | 
			
		||||
  @apply w-4 h-4;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.ant-btn.ant-btn-sm svg,
 | 
			
		||||
.ant-input-affix-wrapper.ant-input-affix-wrapper-sm svg {
 | 
			
		||||
  @apply w-3 h-3;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.ant-btn.ant-btn-lg svg,
 | 
			
		||||
.ant-input-affix-wrapper.ant-input-affix-wrapper-lg svg {
 | 
			
		||||
  @apply w-5 h-5;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										400
									
								
								src/styles/_custom_preflight.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										400
									
								
								src/styles/_custom_preflight.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,400 @@
 | 
			
		||||
/**
 | 
			
		||||
 * https://unpkg.com/tailwindcss@3.2.4/src/css/preflight.css
 | 
			
		||||
 * tailwind와 antd 충돌을 막기 위해 커스터마이징
 | 
			
		||||
 * antd cssinjs priority를 high로 설정시 버튼 커스텀이 어려워져 tailwind base css를 커스텀
 | 
			
		||||
 * 충돌하는 클래스에 :where를 추가하여 priority를 0으로 설정
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
 | 
			
		||||
2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
*,
 | 
			
		||||
::before,
 | 
			
		||||
::after {
 | 
			
		||||
  box-sizing: border-box; /* 1 */
 | 
			
		||||
  border-width: 0; /* 2 */
 | 
			
		||||
  border-style: solid; /* 2 */
 | 
			
		||||
  border-color: theme("borderColor.DEFAULT", currentColor); /* 2 */
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
::before,
 | 
			
		||||
::after {
 | 
			
		||||
  --tw-content: "";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
1. Use a consistent sensible line-height in all browsers.
 | 
			
		||||
2. Prevent adjustments of font size after orientation changes in iOS.
 | 
			
		||||
3. Use a more readable tab size.
 | 
			
		||||
4. Use the user's configured `sans` font-family by default.
 | 
			
		||||
5. Use the user's configured `sans` font-feature-settings by default.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
html {
 | 
			
		||||
  line-height: 1.5; /* 1 */
 | 
			
		||||
  -webkit-text-size-adjust: 100%; /* 2 */
 | 
			
		||||
  -moz-tab-size: 4; /* 3 */
 | 
			
		||||
  tab-size: 4; /* 3 */
 | 
			
		||||
  font-family: theme(
 | 
			
		||||
    "fontFamily.sans",
 | 
			
		||||
    ui-sans-serif,
 | 
			
		||||
    system-ui,
 | 
			
		||||
    -apple-system,
 | 
			
		||||
    BlinkMacSystemFont,
 | 
			
		||||
    "Segoe UI",
 | 
			
		||||
    Roboto,
 | 
			
		||||
    "Helvetica Neue",
 | 
			
		||||
    Arial,
 | 
			
		||||
    "Noto Sans",
 | 
			
		||||
    sans-serif,
 | 
			
		||||
    "Apple Color Emoji",
 | 
			
		||||
    "Segoe UI Emoji",
 | 
			
		||||
    "Segoe UI Symbol",
 | 
			
		||||
    "Noto Color Emoji"
 | 
			
		||||
  ); /* 4 */
 | 
			
		||||
  font-feature-settings: theme("fontFamily.sans[1].fontFeatureSettings", normal); /* 5 */
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
1. Remove the margin in all browsers.
 | 
			
		||||
2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
body {
 | 
			
		||||
  margin: 0; /* 1 */
 | 
			
		||||
  line-height: inherit; /* 2 */
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
1. Add the correct height in Firefox.
 | 
			
		||||
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
 | 
			
		||||
3. Ensure horizontal rules are visible by default.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
hr {
 | 
			
		||||
  height: 0; /* 1 */
 | 
			
		||||
  color: inherit; /* 2 */
 | 
			
		||||
  border-top-width: 1px; /* 3 */
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
Add the correct text decoration in Chrome, Edge, and Safari.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
abbr:where([title]) {
 | 
			
		||||
  text-decoration: underline dotted;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
Remove the default font size and weight for headings.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
h1,
 | 
			
		||||
h2,
 | 
			
		||||
h3,
 | 
			
		||||
h4,
 | 
			
		||||
h5,
 | 
			
		||||
h6 {
 | 
			
		||||
  font-size: inherit;
 | 
			
		||||
  font-weight: inherit;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
Reset links to optimize for opt-in styling instead of opt-out.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
:where(a) {
 | 
			
		||||
  color: inherit;
 | 
			
		||||
  text-decoration: inherit;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
Add the correct font weight in Edge and Safari.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
b,
 | 
			
		||||
strong {
 | 
			
		||||
  font-weight: bolder;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
1. Use the user's configured `mono` font family by default.
 | 
			
		||||
2. Correct the odd `em` font sizing in all browsers.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
code,
 | 
			
		||||
kbd,
 | 
			
		||||
samp,
 | 
			
		||||
pre {
 | 
			
		||||
  font-family: theme(
 | 
			
		||||
    "fontFamily.mono",
 | 
			
		||||
    ui-monospace,
 | 
			
		||||
    SFMono-Regular,
 | 
			
		||||
    Menlo,
 | 
			
		||||
    Monaco,
 | 
			
		||||
    Consolas,
 | 
			
		||||
    "Liberation Mono",
 | 
			
		||||
    "Courier New",
 | 
			
		||||
    monospace
 | 
			
		||||
  ); /* 1 */
 | 
			
		||||
  font-size: 1em; /* 2 */
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
Add the correct font size in all browsers.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
small {
 | 
			
		||||
  font-size: 80%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
Prevent `sub` and `sup` elements from affecting the line height in all browsers.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
sub,
 | 
			
		||||
sup {
 | 
			
		||||
  font-size: 75%;
 | 
			
		||||
  line-height: 0;
 | 
			
		||||
  position: relative;
 | 
			
		||||
  vertical-align: baseline;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
sub {
 | 
			
		||||
  bottom: -0.25em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
sup {
 | 
			
		||||
  top: -0.5em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
 | 
			
		||||
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
 | 
			
		||||
3. Remove gaps between table borders by default.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
table {
 | 
			
		||||
  text-indent: 0; /* 1 */
 | 
			
		||||
  border-color: inherit; /* 2 */
 | 
			
		||||
  border-collapse: collapse; /* 3 */
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
1. Change the font styles in all browsers.
 | 
			
		||||
2. Remove the margin in Firefox and Safari.
 | 
			
		||||
3. Remove default padding in all browsers.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
button,
 | 
			
		||||
input,
 | 
			
		||||
optgroup,
 | 
			
		||||
select,
 | 
			
		||||
textarea {
 | 
			
		||||
  font-family: inherit; /* 1 */
 | 
			
		||||
  font-size: 100%; /* 1 */
 | 
			
		||||
  font-weight: inherit; /* 1 */
 | 
			
		||||
  line-height: inherit; /* 1 */
 | 
			
		||||
  color: inherit; /* 1 */
 | 
			
		||||
  margin: 0; /* 2 */
 | 
			
		||||
  padding: 0; /* 3 */
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
Remove the inheritance of text transform in Edge and Firefox.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
button,
 | 
			
		||||
select {
 | 
			
		||||
  text-transform: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
1. Correct the inability to style clickable types in iOS and Safari.
 | 
			
		||||
2. Remove default button styles.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
:where(button),
 | 
			
		||||
:where([type="button"]),
 | 
			
		||||
:where([type="reset"]),
 | 
			
		||||
:where([type="submit"]) {
 | 
			
		||||
  -webkit-appearance: button; /* 1 */
 | 
			
		||||
  background-color: transparent; /* 2 */
 | 
			
		||||
  background-image: none; /* 2 */
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
Use the modern Firefox focus style for all focusable elements.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
:-moz-focusring {
 | 
			
		||||
  outline: auto;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
:-moz-ui-invalid {
 | 
			
		||||
  box-shadow: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
Add the correct vertical alignment in Chrome and Firefox.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
progress {
 | 
			
		||||
  vertical-align: baseline;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
Correct the cursor style of increment and decrement buttons in Safari.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
::-webkit-inner-spin-button,
 | 
			
		||||
::-webkit-outer-spin-button {
 | 
			
		||||
  height: auto;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
1. Correct the odd appearance in Chrome and Safari.
 | 
			
		||||
2. Correct the outline style in Safari.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
[type="search"] {
 | 
			
		||||
  -webkit-appearance: textfield; /* 1 */
 | 
			
		||||
  outline-offset: -2px; /* 2 */
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
Remove the inner padding in Chrome and Safari on macOS.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
::-webkit-search-decoration {
 | 
			
		||||
  -webkit-appearance: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
1. Correct the inability to style clickable types in iOS and Safari.
 | 
			
		||||
2. Change font properties to `inherit` in Safari.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
::-webkit-file-upload-button {
 | 
			
		||||
  -webkit-appearance: button; /* 1 */
 | 
			
		||||
  font: inherit; /* 2 */
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
Add the correct display in Chrome and Safari.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
summary {
 | 
			
		||||
  display: list-item;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
Removes the default spacing and border for appropriate elements.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
blockquote,
 | 
			
		||||
dl,
 | 
			
		||||
dd,
 | 
			
		||||
h1,
 | 
			
		||||
h2,
 | 
			
		||||
h3,
 | 
			
		||||
h4,
 | 
			
		||||
h5,
 | 
			
		||||
h6,
 | 
			
		||||
hr,
 | 
			
		||||
figure,
 | 
			
		||||
p,
 | 
			
		||||
pre {
 | 
			
		||||
  margin: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fieldset {
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  padding: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
legend {
 | 
			
		||||
  padding: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
ol,
 | 
			
		||||
ul,
 | 
			
		||||
menu {
 | 
			
		||||
  list-style: none;
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  padding: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
Prevent resizing textareas horizontally by default.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
textarea {
 | 
			
		||||
  resize: vertical;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
 | 
			
		||||
2. Set the default placeholder color to the user's configured gray 400 color.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
input::placeholder,
 | 
			
		||||
textarea::placeholder {
 | 
			
		||||
  opacity: 1; /* 1 */
 | 
			
		||||
  color: theme("colors.gray.400", #9ca3af); /* 2 */
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
Set the default cursor for buttons.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
button,
 | 
			
		||||
[role="button"] {
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
Make sure disabled buttons don't get the pointer cursor.
 | 
			
		||||
*/
 | 
			
		||||
:disabled {
 | 
			
		||||
  cursor: default;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
 | 
			
		||||
2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
 | 
			
		||||
   This can trigger a poorly considered lint error in some tools but is included by design.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
img,
 | 
			
		||||
svg,
 | 
			
		||||
video,
 | 
			
		||||
canvas,
 | 
			
		||||
audio,
 | 
			
		||||
iframe,
 | 
			
		||||
embed,
 | 
			
		||||
object {
 | 
			
		||||
  display: block; /* 1 */
 | 
			
		||||
  vertical-align: middle; /* 2 */
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
img,
 | 
			
		||||
video {
 | 
			
		||||
  max-width: 100%;
 | 
			
		||||
  height: auto;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Make elements with the HTML hidden attribute stay hidden by default */
 | 
			
		||||
[hidden] {
 | 
			
		||||
  display: none;
 | 
			
		||||
}
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user