first commit
This commit is contained in:
commit
d8a5c1c0e4
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
Loading…
Reference in New Issue
Block a user