first commit

This commit is contained in:
JongYeob Sheen 2023-11-10 18:34:35 +09:00
commit d8a5c1c0e4
106 changed files with 13606 additions and 0 deletions

5
.env Normal file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,6 @@
{
"extends": ["next/core-web-vitals", "prettier"],
"rules": {
"@next/next/no-img-element": "off"
}
}

44
.gitignore vendored Normal file
View 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
View 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
View 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

0
README.md Normal file
View File

42
docker/dev/Dockerfile Normal file
View 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"]

View 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
View 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"]

View 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
View 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"]

View File

@ -0,0 +1,10 @@
version: "3"
services:
superrichquiz-app:
build:
context: ../../
dockerfile: docker/prod/Dockerfile
image: learnsteam/learnsteam-quiz
ports:
- "3100:3000"

Binary file not shown.

22
next.config.js Normal file
View 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

File diff suppressed because it is too large Load Diff

47
package.json Normal file
View 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
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

66
prisma/schema.prisma Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
public/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

1
public/next.svg Normal file
View 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
View 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
View 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
View 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
View 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
View 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) })
}

View 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);
}

View 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} />
}

View 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)

View 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)

View 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)

View 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)

View 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)

View 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;
}

View 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)

View 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)

View 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)

View 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

View 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;
}

View File

@ -0,0 +1,10 @@
import styles from "./styles.module.css"
const TitleLogo = () => {
return (
<>
<div className={styles["title-image"]}></div>
</>
)
}
export default TitleLogo

View 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;
}
}

View 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

View 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;
}

View 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);

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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;

View 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;

View 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);

View 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);

View 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);

View 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);

View 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];
}

View File

@ -0,0 +1,6 @@
.spinner {
position: absolute;
top: 30%;
left: 50%;
transform: translate(-50%, -50%);
}

View 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);

View 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);

View 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;
}

View 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);

View 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);

View 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
View 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")
}

View 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
View 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
View 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
View 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
View 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" })
}

View 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" })
}

View 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)
}

View 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)
}

View 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)
}

View 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
View 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

View File

@ -0,0 +1,7 @@
import Login from "@/components/login"
const Index = () => {
return <Login />
}
export default Index

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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;
}

View 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