Skip to content

Commit

Permalink
Merge pull request #146 from DDD-Community/feature/save-screenshot
Browse files Browse the repository at this point in the history
[FEATURE] 보드 이미지 저장
  • Loading branch information
junseublim authored Dec 29, 2024
2 parents 1301bbe + b24dd8c commit bfb50aa
Show file tree
Hide file tree
Showing 28 changed files with 457 additions and 114 deletions.
15 changes: 14 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
FROM node:20-alpine3.19 as builder

# Install necessary dependencies for Puppeteer and Chromium
RUN apk add --no-cache \
chromium \
nss \
freetype \
harfbuzz \
ca-certificates \
ttf-freefont

WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

# Set environment variable for Puppeteer to use installed Chromium
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser

EXPOSE 3000
CMD ["npm", "run" , "start"]
CMD ["npm", "run" , "start"]
4 changes: 1 addition & 3 deletions public/icons/PinIcon.svg → public/icons/BigPinIcon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/screenshot_loading.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 17 additions & 6 deletions src/app/board/[boardId]/_components/Empty.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
'use client'

import Icon from 'public/icons/pinned.svg'
import { getBoardStyle } from '@/lib/utils/board'
import { useBoard } from '@/app/board/[boardId]/_contexts/BoardContext'

const Empty = () => {
const { board } = useBoard()
const { titleClassName } = getBoardStyle(board)

const Empty = () => (
<div className="flex flex-1 flex-col items-center justify-center pb-16">
<Icon className="size-24 text-gray-900" />
<span className="font-jooree text-2xl opacity-40">보드를 꾸며주세요!</span>
</div>
)
return (
<div className="flex flex-1 flex-col items-center justify-center pb-16">
<Icon className="size-24" />
<span className={`font-jooree text-2xl opacity-40 ${titleClassName}`}>
보드를 꾸며주세요!
</span>
</div>
)
}

export default Empty
6 changes: 4 additions & 2 deletions src/app/board/[boardId]/_components/Header/DefaultHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import { BoardTutorial } from '@/components/Tutorial'
import { useSession } from 'next-auth/react'
import PinIcon from 'public/icons/pinFilled.svg'
import { twMerge } from 'tailwind-merge'
import { getBoardStyle } from '@/lib/utils/board'
import { useBoard } from '../../_contexts/BoardContext'
import ShareBtn from '../Share'
import { Step1Tooltip } from '../Tooltips'

const DefaultHeader = ({ className }: { className: string }) => {
const DefaultHeader = () => {
const { data: session } = useSession()
const { board } = useBoard()
const { titleClassName } = getBoardStyle(board)

return (
<Header
Expand All @@ -32,7 +34,7 @@ const DefaultHeader = ({ className }: { className: string }) => {
<ShareBtn />
)
}
className={twMerge('bg-transparent', className)}
className={twMerge('bg-transparent', titleClassName)}
shadow={false}
/>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,27 @@ import Header from '@/components/Header'
import { useSelect } from '@/app/board/[boardId]/_contexts/SelectModeContext'
import { useBoard } from '@/app/board/[boardId]/_contexts/BoardContext'
import { twMerge } from 'tailwind-merge'
import { getBoardStyle } from '@/lib/utils/board'

const SelectModeHeader = ({ className }: { className: string }) => {
const SelectModeHeader = () => {
const { selectedIds, MAX_SELECT_COUNT } = useSelect()
const { board } = useBoard()
const maxLength = Math.min(board.items.length, MAX_SELECT_COUNT)
const { selectCountClassName, titleClassName } = getBoardStyle(board)

return (
<Header
title={
<div className="flex flex-col items-center justify-center gap-[3px] text-center text-md font-semiBold leading-6">
<h1>꾸미고 싶은 폴라로이드를 골라주세요</h1>
<h2 className="flex gap-0.5">
<span className="text-gray-400">{selectedIds.length}</span>
<span className={selectCountClassName}>{selectedIds.length}</span>
<span className="text-gray-400">/</span>
<span>{maxLength}</span>
</h2>
</div>
}
className={twMerge('bg-transparent', className)}
className={twMerge('h-20 bg-transparent p-3', titleClassName)}
shadow={false}
/>
)
Expand Down
9 changes: 2 additions & 7 deletions src/app/board/[boardId]/_components/Header/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,14 @@ import React from 'react'
import { useSelect } from '@/app/board/[boardId]/_contexts/SelectModeContext'
import DefaultHeader from '@/app/board/[boardId]/_components/Header/DefaultHeader'
import SelectModeHeader from '@/app/board/[boardId]/_components/Header/SelectModeHeader'
import { useBoard } from '@/app/board/[boardId]/_contexts/BoardContext'
import { BOARDTHEMAS } from '@/lib/constants'

const Header = () => {
const { isSelectMode } = useSelect()
const { board } = useBoard()
const boardTheme = BOARDTHEMAS[board.options.THEMA].theme
const className = boardTheme === 'LIGHT' ? 'text-gray-900' : 'text-gray-0'

if (isSelectMode) {
return <SelectModeHeader className={className} />
return <SelectModeHeader />
}
return <DefaultHeader className={className} />
return <DefaultHeader />
}

export default Header
26 changes: 5 additions & 21 deletions src/app/board/[boardId]/_components/PolaroidList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ import Button from '@/components/Button'
import PolaroidDetailModal from '@/components/Polaroid/PolaroidDetail'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { createPolaroidSearchParams } from '@/lib/utils/query'

const PolaroidList = () => {
const { board, boardId } = useBoard()
const { isSelectMode, selectedIds, toggleSelectedId } = useSelect()
const [isModalOpen, setIsModalOpen] = useState(false)
const [selectedIdx, setSelectedIdx] = useState<number>(0)
const [isLoading, setIsLoading] = useState(false)
const router = useRouter()

const openDetailModal = (idx: number) => {
Expand All @@ -38,25 +38,9 @@ const PolaroidList = () => {
return ''
}

const onSelectComplete = async () => {
setIsLoading(true)
const res = await fetch(`/board/api/screenshot`, {
method: 'POST',
body: JSON.stringify({
polaroids: selectedIds,
boardId,
}),
})

if (!res.ok) {
throw new Error('Failed to take screenshot')
}

const blob = await res.blob()
const imageUrl = URL.createObjectURL(blob)
setIsLoading(false)

router.push(`/board/${boardId}/decorate?imageUrl=${imageUrl}`)
const onSelectComplete = () => {
const polaroidIdsSearchParam = createPolaroidSearchParams(selectedIds)
router.push(`/board/${boardId}/decorate?${polaroidIdsSearchParam}`)
}

return (
Expand All @@ -79,7 +63,7 @@ const PolaroidList = () => {
<Button
size="lg"
className="w-full"
disabled={selectedIds.length === 0 || isLoading}
disabled={selectedIds.length === 0}
onClick={onSelectComplete}
>
선택 완료
Expand Down
153 changes: 153 additions & 0 deletions src/app/board/[boardId]/decorate/_components/DecorateScreenshot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
'use client'

import React, { useEffect, useState } from 'react'
import Sticker from '@/app/board/[boardId]/decorate/_components/Sticker'
import Image from 'next/image'
import SubmitBtn from '@/app/board/[boardId]/decorate/_components/SubmitBtn'
import { ensureArray } from '@/lib/utils/array'
import { getStickerStyles } from '@/app/board/[boardId]/decorate/_utils/getStickerStyles'
import { downloadImage } from '@/lib/utils/image'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { getBoard } from '@/lib'
import OpenStickerModalBtn from '@/app/board/[boardId]/decorate/_components/OpenStickerModalBtn'
import SelectSticker from '@/app/board/[boardId]/decorate/_components/SelectStickerModal'
import ScreenshotLoading from 'public/images/screenshot_loading.gif'
import Button from '@/components/Button'
import { useSticker } from '@/app/board/[boardId]/decorate/_contexts/StickerContext'
import DownloadIcon from 'public/icons/download.svg'

const DecorateScreenshot = () => {
const { boardId } = useParams<{ boardId: string }>()
const searchParams = useSearchParams()
const polaroidIds = searchParams.getAll('polaroidIds')
const [boardName, setBoardName] = useState('')
const [previewUrl, setPreviewUrl] = useState('')
const [isLoadingPreview, setIsLoadingPreview] = useState(true)
const [isLoadingDownload, setIsLoadingDownload] = useState(false)
const [isDownloaded, setIsDownloaded] = useState(false)
const { isDecorating, setIsDecorating } = useSticker()
const router = useRouter()

useEffect(() => {
getBoard(boardId).then((board) => {
setBoardName(board.title)
})
}, [boardId])

useEffect(() => {
const takePreview = async () => {
const res = await fetch(`/board/api/screenshot`, {
method: 'POST',
body: JSON.stringify({
polaroids: polaroidIds,
boardId,
}),
})

if (!res.ok) {
throw new Error('Failed to take screenshot')
}

const blob = await res.blob()
const previewURl = URL.createObjectURL(blob)
setPreviewUrl(previewURl)
setIsLoadingPreview(false)
}

takePreview()
}, [boardId, polaroidIds])

const takeScreenshot = () => {
setIsLoadingDownload(true)
fetch(`/board/api/screenshot`, {
method: 'POST',
body: JSON.stringify({
polaroids: ensureArray(polaroidIds),
boardId,
stickers: getStickerStyles(),
}),
})
.then((res) => res.blob())
.then((blob) => {
const screenshotUrl = URL.createObjectURL(blob)
downloadImage(screenshotUrl, boardName)
setIsLoadingDownload(false)
setIsDownloaded(true)
})
}

const routeToHome = () => {
router.push('/')
}

if (isLoadingPreview) {
return (
<div className="flex h-dvh items-center justify-center bg-gray-0">
<Image src={ScreenshotLoading} alt="loading" className="w-[80%]" />
</div>
)
}

return (
<div className="relative flex h-full touch-none flex-col items-center justify-between gap-5">
<header className="my-5 w-full bg-gray-0 bg-transparent">
<div>
<div className="text-center text-md font-semiBold leading-6">
{isDownloaded ? '앨범에 저장되었습니다!' : '보드 꾸미기'}
</div>
</div>
</header>
{previewUrl && (
<div
id="preview"
className="relative aspect-[9/16] w-auto overflow-hidden shadow-screenshot"
>
<OpenStickerModalBtn>
<SelectSticker />
</OpenStickerModalBtn>
<Sticker />
<Image
src={previewUrl}
alt="screenshot"
width={1080}
height={1920}
className="aspect-[9/16] max-h-full w-auto object-contain"
/>
</div>
)}
<div className="mb-5 w-full">
{isDownloaded && (
<Button
size="lg"
variant="primary"
className="mx-5"
onClick={routeToHome}
>
메인으로 가기
</Button>
)}
{!isDownloaded && isDecorating && (
<Button
size="lg"
variant="secondary"
className="mx-5"
onClick={() => {
setIsDecorating(false)
}}
>
꾸미기 완료
</Button>
)}
{!isDownloaded && !isDecorating && (
<SubmitBtn disabled={isLoadingDownload} onClick={takeScreenshot}>
<span className="flex items-center justify-center gap-2">
내 보드 이미지 저장 <DownloadIcon />
</span>
</SubmitBtn>
)}
</div>
</div>
)
}

export default DecorateScreenshot
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const OpenStickerModalBtn = ({ children }: OpenModalBtnProps) => {
}

return (
<div className="absolute right-4 z-10">
<div className="absolute right-0 z-10">
<DecorateTutorial step={1} tooltip={<Step1Tooltip />} hasNext>
<StickerIcon onClick={openStickerModal} className="cursor-pointer" />
</DecorateTutorial>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Image from 'next/image'
import { useEffect, useState } from 'react'
import { getRecentStickers, postStickers } from '@/lib/api/sticker'
import { useParams } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { useSticker } from '../../_contexts/StickerContext'
import { useStickerModal } from '../../_contexts/ModalContext'
import { getStickerFile } from '../../_utils/getStickerFile'
Expand All @@ -11,6 +12,7 @@ const Contents = () => {
const { closeModal } = useStickerModal()
const [stickerFiles, setStickerFiles] = useState<string[]>([])
const { boardId } = useParams<{ boardId: string }>()
const { status } = useSession()

useEffect(() => {
const fetchStickers = async () => {
Expand All @@ -25,8 +27,10 @@ const Contents = () => {
fetchStickers()
}, [selectedMenu])

const handleClickSticker = async (file: string) => {
await postStickers({ stickerIds: [file], boardId })
const handleClickSticker = (file: string) => {
if (status === 'authenticated') {
postStickers({ stickerIds: [file], boardId })
}
addSticker(file)
closeModal()
}
Expand Down
Loading

0 comments on commit bfb50aa

Please sign in to comment.