Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added src/assets/Works_1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
File renamed without changes
Binary file added src/assets/Works_3.png
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 src/assets/homelogo.png
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 src/assets/instagram.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 7 additions & 5 deletions src/components/about/Footer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { FaInstagram, FaHome } from 'react-icons/fa'
// import { FaInstagram, FaHome } from 'react-icons/fa'
import logo from '../../assets/duksung.png'
import Instagram from '../../assets/instagram.png'
import Home from '../../assets/homelogo.png'

const Footer = () => {
return (
Expand All @@ -15,15 +17,15 @@ const Footer = () => {
href="https://instagram.com/2025_wiscom"
target="_blank"
rel="noopener noreferrer"
className="w-10 h-10 rounded-full bg-[#A0896F] flex items-center justify-center"
className="w-10 h-10 rounded-full bg-[#9D8469] flex items-center justify-center"
>
<FaInstagram className="text-white text-lg" />
<img src={Instagram} alt="μΈμŠ€νƒ€κ·Έλž¨" className="w-[20px] h-[20px]" />
</a>
<a
href="/"
className="w-10 h-10 rounded-full bg-[#A0896F] flex items-center justify-center"
className="w-10 h-10 rounded-full bg-[#9D8469] flex items-center justify-center"
>
<FaHome className="text-white text-lg" />
<img src={Home} alt="ν™ˆ" className="w-[20px] h-[20px]" />
</a>
</div>
<div className="mb-3 leading-5">
Expand Down
94 changes: 88 additions & 6 deletions src/components/works/FrameCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,80 @@
import frameImg from '../../assets/Works.png'
// import frameImg from '../../assets/Works.png'

// type Size = 'sm' | 'md' | 'lg'

// interface Props {
// image: string
// onClick?: () => void
// size?: Size
// focused?: boolean
// }

// // μ•‘μž PNG μ•ˆμͺ½ μ˜μ—­ λΉ„μœ¨
// const INSET = {
// sm: { top: '14%', left: '12%', right: '12%', bottom: '14%' },
// md: { top: '12%', left: '10%', right: '10%', bottom: '12%' },
// lg: { top: '10%', left: '9%', right: '9%', bottom: '10%' },
// }

// export default function FrameCard({
// image,
// onClick,
// size = 'md',
// focused = false,
// }: Props) {
// const wh =
// size === 'lg'
// ? 'h-[280px] w-[250px] max-lg:h-[300px] max-lg:w-[220px]'
// : size === 'sm'
// ? 'h-[240px] w-[175px] max-lg:h-[220px] max-lg:w-[160px]'
// : 'h-[300px] w-[220px] max-lg:h-[260px] max-lg:w-[190px]'

// const pad = INSET[size]
// const src = image?.startsWith('http://') ? 'https://' + image.slice(7) : image

// return (
// <button
// type="button"
// onClick={onClick}
// className={`
// snap-center shrink-0 outline-none transition-transform duration-300 cursor-pointer mt-2
// ${focused ? 'scale-105' : 'scale-95'} //포컀슀 여뢀에 따라 크기 μ‘°μ •
// `}
// >
// <div className={`relative ${wh}`}>
// {/* μ•‘μž 이미지 */}
// <img
// src={frameImg}
// alt="frame"
// className="absolute inset-0 h-full w-full object-contain pointer-events-none select-none z-10"
// draggable={false}
// />

// {/* 사진 - μ•‘μž μ•ˆμͺ½ νŒ¨λ”© 적용 */}
// <div
// className="absolute flex items-center justify-center overflow-hidden rounded-sm"
// style={{
// top: pad.top,
// left: pad.left,
// right: pad.right,
// bottom: pad.bottom,
// }}
// >
// <img
// src={src}
// alt="work"
// className="w-full object-cover"
// loading="lazy"
// />
// </div>
// </div>
// </button>
// )
// }

import frame1 from '../../assets/Works_1.png'
import frame2 from '../../assets/Works_2.png'
import frame3 from '../../assets/Works_3.png'

type Size = 'sm' | 'md' | 'lg'

Expand All @@ -7,20 +83,23 @@ interface Props {
onClick?: () => void
size?: Size
focused?: boolean
index?: number
}

// μ•‘μž PNG μ•ˆμͺ½ μ˜μ—­ λΉ„μœ¨
const INSET = {
sm: { top: '14%', left: '12%', right: '12%', bottom: '14%' },
md: { top: '12%', left: '10%', right: '10%', bottom: '12%' },
lg: { top: '10%', left: '9%', right: '9%', bottom: '10%' },
}

const FRAMES = [frame1, frame2, frame3] as const

export default function FrameCard({
image,
onClick,
size = 'md',
focused = false,
index = 0,
}: Props) {
const wh =
size === 'lg'
Expand All @@ -32,25 +111,28 @@ export default function FrameCard({
const pad = INSET[size]
const src = image?.startsWith('http://') ? 'https://' + image.slice(7) : image

// 1,2,3 반볡: 0,1,2 β†’ (i % 3)둜 선택
const frameSrc = FRAMES[index % 3]

return (
<button
type="button"
onClick={onClick}
className={`
snap-center shrink-0 outline-none transition-transform duration-300 cursor-pointer mt-2
${focused ? 'scale-105' : 'scale-95'} //포컀슀 여뢀에 따라 크기 μ‘°μ •
${focused ? 'scale-105' : 'scale-95'}
`}
>
<div className={`relative ${wh}`}>
{/* μ•‘μž 이미지 */}
{/* μ•‘μž 이미지 (1β†’2β†’3 반볡) */}
<img
src={frameImg}
src={frameSrc}
alt="frame"
className="absolute inset-0 h-full w-full object-contain pointer-events-none select-none z-10"
draggable={false}
/>

{/* 사진 - μ•‘μž μ•ˆμͺ½ νŒ¨λ”© 적용 */}
{/* 사진 */}
<div
className="absolute flex items-center justify-center overflow-hidden rounded-sm"
style={{
Expand Down
137 changes: 120 additions & 17 deletions src/pages/WorksDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { useEffect, useMemo, useState, useCallback } from 'react'
import {
useEffect,
useMemo,
useState,
useCallback,
useLayoutEffect,
useRef,
} from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import DetailSection from '../components/workdetail/DetailSection'
import MetaList from '../components/workdetail/MetaList'
Expand All @@ -9,6 +16,8 @@ import type { CategoryUI } from '../components/works/Tabs'
import {
fetchWorkDetail,
type WorkDetail as WorkDetailType,
fetchWorkList,
type WorkItem,
} from '../apis/works'
import WorkHeader from '../components/workdetail/WorkHeader'
import ImageLightbox from '../components/workdetail/ImageLightbox'
Expand All @@ -32,21 +41,24 @@ export default function WorksDetailPage() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)

// μ •λ ¬μš© λͺ©λ‘
const [, setListLoading] = useState(false)
const [items, setItems] = useState<WorkItem[]>([])

// 이미지 μŠ¬λΌμ΄λ“œ 인덱슀
const [imgIdx, setImgIdx] = useState(0)

const [lightboxOpen, setLightboxOpen] = useState(false)
const openLightbox = useCallback(() => setLightboxOpen(true), [])
const closeLightbox = useCallback(() => setLightboxOpen(false), [])

const backToList = useCallback(() => {
navigate(`/works?tab=${encodeURIComponent(category)}`)
}, [navigate, category])
// μ΅œμƒλ‹¨ 슀크둀용 액컀
const topRef = useRef<HTMLDivElement | null>(null)

useEffect(() => {
setImgIdx(0)
}, [category, workId])

// 상세 데이터 λ‘œλ“œ
useEffect(() => {
if (!workId || Number.isNaN(workId)) {
setError('잘λͺ»λœ μž‘ν’ˆ IDμž…λ‹ˆλ‹€.')
Expand All @@ -68,17 +80,107 @@ export default function WorksDetailPage() {
return () => ac.abort()
}, [category, workId])

// 리슀트 λ‘œλ“œ(μ •λ ¬ κΈ°μ€€ λ§žμΆ”κΈ°)
useEffect(() => {
const ac = new AbortController()
setListLoading(true)
fetchWorkList(category, ac.signal)
.then((list) => setItems(list))
.catch(() => setItems([]))
.finally(() => setListLoading(false))
return () => ac.abort()
}, [category])

// 리슀트 νŽ˜μ΄μ§€μ™€ λ™μΌν•œ μ •λ ¬ κΈ°μ€€
const koCollator = useMemo(
() => new Intl.Collator('ko', { sensitivity: 'base', numeric: true }),
[],
)
const enCollator = useMemo(
() => new Intl.Collator('en', { sensitivity: 'base', numeric: true }),
[],
)

const getSortGroup = useCallback((name: string): number => {
const key = (name ?? '').trim().replace(/^[^A-Za-z0-9\uAC00-\uD7A3]+/, '')
const ch = key.charAt(0)
if (/^[\uAC00-\uD7A3]$/.test(ch)) return 0 // ν•œκΈ€
if (/^[A-Za-z]$/.test(ch)) return 1 // μ˜μ–΄
return 2 // 기타
}, [])

const compareProjectName = useCallback(
(a: WorkItem, b: WorkItem): number => {
const ga = getSortGroup(a.projectName)
const gb = getSortGroup(b.projectName)
if (ga !== gb) return ga - gb
if (ga === 0) return koCollator.compare(a.projectName, b.projectName)
if (ga === 1) return enCollator.compare(a.projectName, b.projectName)
return koCollator.compare(a.projectName, b.projectName)
},
[getSortGroup, koCollator, enCollator],
)

const sorted = useMemo(() => {
if (!items?.length) return []
return [...items].sort(compareProjectName)
}, [items, compareProjectName])

const currentIndex = useMemo(
() => sorted.findIndex((w) => w.id === workId),
[sorted, workId],
)
const prevId = currentIndex > 0 ? sorted[currentIndex - 1]?.id : undefined
const nextId =
currentIndex >= 0 && currentIndex < sorted.length - 1
? sorted[currentIndex + 1]?.id
: undefined

// 슀크둀 볡원 μ–΅μ œ + 상단 κ³ μ •
useLayoutEffect(() => {
if ('scrollRestoration' in history) {
try {
history.scrollRestoration = 'manual'
} catch {
console.log('')
}
}
window.scrollTo(0, 0)
topRef.current?.scrollIntoView({ block: 'start', inline: 'nearest' })
}, [category, workId])

// λ‘œλ”© μ™„λ£Œ ν›„ ν•œ 번 더 상단 κ³ μ •(이미지 λ‘œλ”© λ“± λ ˆμ΄μ•„μ›ƒ 변동 λŒ€λΉ„)
useEffect(() => {
if (!loading) {
requestAnimationFrame(() => {
window.scrollTo({ top: 0, left: 0, behavior: 'auto' })
topRef.current?.scrollIntoView({ block: 'start', inline: 'nearest' })
})
}
}, [loading])

// ν˜„μž¬ 인덱슀λ₯Ό μ„Έμ…˜μ— λ°±μ—…(λΈŒλΌμš°μ € λ’€λ‘œκ°€κΈ° 볡원)
useEffect(() => {
if (currentIndex >= 0 && typeof sessionStorage !== 'undefined') {
sessionStorage.setItem(`worksIdx:${category}`, String(currentIndex))
}
}, [category, currentIndex])

const backToList = useCallback(() => {
navigate(`/works?tab=${encodeURIComponent(category)}`, {
state: { fromIdx: currentIndex },
})
}, [navigate, category, currentIndex])

const goPrev = useCallback(() => {
if (!data?.prev) return
navigate(`/works/${params.category}/${data.prev}`)
window.scrollTo({ top: 0, behavior: 'smooth' })
}, [data?.prev, navigate, params.category])
if (!prevId) return
navigate(`/works/${params.category}/${prevId}`)
}, [navigate, params.category, prevId])

const goNext = useCallback(() => {
if (!data?.next) return
navigate(`/works/${params.category}/${data.next}`)
window.scrollTo({ top: 0, behavior: 'smooth' })
}, [data?.next, navigate, params.category])
if (!nextId) return
navigate(`/works/${params.category}/${nextId}`)
}, [navigate, params.category, nextId])

if (loading) {
return (
Expand Down Expand Up @@ -108,7 +210,8 @@ export default function WorksDetailPage() {
const longBody = data.description || data.midDescription || ''

return (
<div className="w-full pb-3">
<div key={`${category}-${workId}`} className="w-full pb-3">
<div ref={topRef} aria-hidden />
<WorkHeader
instagramUrl={data.instagramUrl}
githubUrl={data.githubUrl}
Expand Down Expand Up @@ -155,13 +258,13 @@ export default function WorksDetailPage() {
}
body={<DetailSection>{longBody}</DetailSection>}
nav={
// IOT, GAME이면 항상 μˆ¨κΉ€
// IOT, GAME은 μˆ¨κΉ€
category === 'IOT' || category === 'GAME' ? undefined : (
<DetailNav
onPrev={goPrev}
onNext={goNext}
prevDisabled={!data.prev}
nextDisabled={!data.next}
prevDisabled={!prevId}
nextDisabled={!nextId}
/>
)
}
Expand Down
Loading