Compare commits
2 Commits
6a3b604d13
...
837408423e
| Author | SHA1 | Date | |
|---|---|---|---|
| 837408423e | |||
| e8d3e65b69 |
@@ -39,6 +39,8 @@ const extractExcerpt = (body) => {
|
|||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const allowedTags = new Set(['일상', '개발', '공부', '아이디어', '기타']);
|
||||||
|
|
||||||
const normalizeTags = (value) => {
|
const normalizeTags = (value) => {
|
||||||
if (!value) return [];
|
if (!value) return [];
|
||||||
const mapped = value
|
const mapped = value
|
||||||
@@ -54,7 +56,8 @@ const normalizeTags = (value) => {
|
|||||||
return tag;
|
return tag;
|
||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(new Set(mapped));
|
const normalized = mapped.map((tag) => (allowedTags.has(tag) ? tag : '기타'));
|
||||||
|
return Array.from(new Set(normalized));
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeTitle = (slug) =>
|
const normalizeTitle = (slug) =>
|
||||||
@@ -70,7 +73,7 @@ const inferTagFromPath = (path) => {
|
|||||||
if (folder === 'dev') return '개발';
|
if (folder === 'dev') return '개발';
|
||||||
if (folder === 'study') return '공부';
|
if (folder === 'study') return '공부';
|
||||||
if (folder === 'ideas') return '아이디어';
|
if (folder === 'ideas') return '아이디어';
|
||||||
return '';
|
return '기타';
|
||||||
};
|
};
|
||||||
|
|
||||||
const inferDateFromSlug = (slug) => {
|
const inferDateFromSlug = (slug) => {
|
||||||
|
|||||||
@@ -132,6 +132,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
color: #f8f3ee;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-list__excerpt {
|
.blog-list__excerpt {
|
||||||
|
|||||||
@@ -196,6 +196,113 @@
|
|||||||
letter-spacing: 0.14em;
|
letter-spacing: 0.14em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-profile {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-profile__card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 22px;
|
||||||
|
background: var(--surface);
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-profile__identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-profile__avatar {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 16px;
|
||||||
|
object-fit: cover;
|
||||||
|
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-profile__role {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-profile__name {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-profile__bio {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-profile__timeline {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-profile__section-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-profile__timeline ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-profile__timeline li {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-profile__timeline span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-profile__timeline strong {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-profile__tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-profile__tags span {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-profile__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.home-hero {
|
.home-hero {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { navLinks } from '../../routes.jsx';
|
import { navLinks } from '../../routes.jsx';
|
||||||
import { getBlogPosts } from '../../data/blog';
|
import { getBlogPosts } from '../../data/blog';
|
||||||
|
import myPhoto from '../../assets/myPhoto.jpg';
|
||||||
import './Home.css';
|
import './Home.css';
|
||||||
|
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
@@ -82,6 +83,69 @@ const Home = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="home-section">
|
||||||
|
<div className="home-section__header">
|
||||||
|
<h2>Profile</h2>
|
||||||
|
<p>페이지 주인 소개 영역입니다.</p>
|
||||||
|
</div>
|
||||||
|
<div className="home-profile">
|
||||||
|
<div className="home-profile__card">
|
||||||
|
<div className="home-profile__identity">
|
||||||
|
<img
|
||||||
|
className="home-profile__avatar"
|
||||||
|
src={myPhoto}
|
||||||
|
alt="Profile"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="home-profile__role">Server Developer</p>
|
||||||
|
<p className="home-profile__name">박 재 오</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="home-profile__bio">
|
||||||
|
주변 동료와 함께 소통하며 성장하는걸 좋아합니다. <br />
|
||||||
|
성능 최적화, 인프라 자동화를 중요하게 생각합니다. <br />
|
||||||
|
여행과 사진, 새로운 기술 탐구를 좋아합니다.
|
||||||
|
</p>
|
||||||
|
<div className="home-profile__timeline">
|
||||||
|
<p className="home-profile__section-title">연혁</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<span>2023.02 - 현재</span>
|
||||||
|
<strong>Server Developer</strong>
|
||||||
|
<span>내비 TIS 교통 서버/현대오토에버</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>2020.01 - 2023.02</span>
|
||||||
|
<strong>Embedded Device SW Developer</strong>
|
||||||
|
<span>캐시비 단말기 개발/롯데정보통신</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>2019.07 - 2019.12</span>
|
||||||
|
<strong>SSAFY - 삼성 SW Academy</strong>
|
||||||
|
<span>SSAFY</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="home-profile__tags">
|
||||||
|
<span>C++</span>
|
||||||
|
<span>Git</span>
|
||||||
|
<span>AWS</span>
|
||||||
|
<span>Jira</span>
|
||||||
|
<span>MySQL</span>
|
||||||
|
<span>Docker</span>
|
||||||
|
<span>Kubernetes</span>
|
||||||
|
<span>Linux</span>
|
||||||
|
</div>
|
||||||
|
<div className="home-profile__actions">
|
||||||
|
<button className="button ghost">프로필 수정</button>
|
||||||
|
<a className="button primary" href="mailto:bgg8988@gmail.com">
|
||||||
|
연락하기
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -52,6 +52,62 @@
|
|||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.travel-albums {
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-album {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 20px;
|
||||||
|
background: rgba(9, 10, 16, 0.5);
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-album__head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-album__eyebrow {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-album__meta {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-album__cover {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 16px;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-state {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-error {
|
||||||
|
color: #f9b6b1;
|
||||||
|
border: 1px solid rgba(249, 182, 177, 0.4);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(249, 182, 177, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.travel-card {
|
.travel-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
@@ -106,4 +162,9 @@
|
|||||||
.travel-card.is-wide {
|
.travel-card.is-wide {
|
||||||
grid-column: span 1;
|
grid-column: span 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.travel-album__cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,85 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { travelGallery } from '../../data/travel';
|
|
||||||
import './Travel.css';
|
import './Travel.css';
|
||||||
|
|
||||||
|
const normalizePhotos = (items = []) =>
|
||||||
|
items
|
||||||
|
.map((item) => {
|
||||||
|
if (typeof item === 'string') return { src: item, title: '' };
|
||||||
|
if (!item) return null;
|
||||||
|
return {
|
||||||
|
src: item.url || item.path || item.src || '',
|
||||||
|
title: item.title || item.name || '',
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item) => item && item.src);
|
||||||
|
|
||||||
|
const getPhotoLabel = (src) => {
|
||||||
|
if (!src) return '';
|
||||||
|
const parts = src.split('/');
|
||||||
|
return parts[parts.length - 1];
|
||||||
|
};
|
||||||
|
|
||||||
const Travel = () => {
|
const Travel = () => {
|
||||||
|
const [albums, setAlbums] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadAlbums = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const albumRes = await fetch('/api/travel/albums');
|
||||||
|
if (!albumRes.ok) {
|
||||||
|
throw new Error(`앨범 목록 로딩 실패 (${albumRes.status})`);
|
||||||
|
}
|
||||||
|
const albumJson = await albumRes.json();
|
||||||
|
const items = albumJson.items ?? [];
|
||||||
|
|
||||||
|
const hydrated = await Promise.all(
|
||||||
|
items.map(async (item) => {
|
||||||
|
const name = item.album || item.name || '';
|
||||||
|
if (!name) return null;
|
||||||
|
const photoRes = await fetch(
|
||||||
|
`/api/travel/albums/${encodeURIComponent(name)}`
|
||||||
|
);
|
||||||
|
if (!photoRes.ok) {
|
||||||
|
throw new Error(`앨범 로딩 실패: ${name}`);
|
||||||
|
}
|
||||||
|
const photoJson = await photoRes.json();
|
||||||
|
const photos = normalizePhotos(photoJson.items ?? []);
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
count: item.count ?? photos.length,
|
||||||
|
cover: item.cover || photos[0]?.src || '',
|
||||||
|
photos,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setAlbums(hydrated.filter(Boolean));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err?.message ?? String(err));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadAlbums();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="travel">
|
<div className="travel">
|
||||||
<header className="travel-header">
|
<header className="travel-header">
|
||||||
@@ -21,20 +98,51 @@ const Travel = () => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="travel-grid">
|
<section className="travel-albums">
|
||||||
{travelGallery.map((photo, index) => (
|
{loading ? <p className="travel-state">앨범을 불러오는 중...</p> : null}
|
||||||
<article
|
{error ? <p className="travel-error">{error}</p> : null}
|
||||||
key={photo.id}
|
{!loading && !error && albums.length === 0 ? (
|
||||||
className={`travel-card ${index % 3 === 0 ? 'is-wide' : ''}`}
|
<p className="travel-state">표시할 앨범이 없습니다.</p>
|
||||||
>
|
) : null}
|
||||||
<img src={photo.image} alt={photo.title} loading="lazy" />
|
|
||||||
<div className="travel-card__overlay">
|
{albums.map((album) => (
|
||||||
<p className="travel-card__title">{photo.title}</p>
|
<div key={album.name} className="travel-album">
|
||||||
<p className="travel-card__meta">
|
<div className="travel-album__head">
|
||||||
{photo.location} · {photo.month}
|
<div>
|
||||||
|
<p className="travel-album__eyebrow">Album</p>
|
||||||
|
<h2>{album.name}</h2>
|
||||||
|
<p className="travel-album__meta">
|
||||||
|
{album.count} photos
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{album.cover ? (
|
||||||
|
<img
|
||||||
|
className="travel-album__cover"
|
||||||
|
src={album.cover}
|
||||||
|
alt={`${album.name} cover`}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="travel-grid">
|
||||||
|
{album.photos.map((photo, index) => {
|
||||||
|
const label = photo.title || getPhotoLabel(photo.src);
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={`${album.name}-${photo.src}`}
|
||||||
|
className={`travel-card ${
|
||||||
|
index % 6 === 0 ? 'is-wide' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img src={photo.src} alt={label} loading="lazy" />
|
||||||
|
<div className="travel-card__overlay">
|
||||||
|
<p className="travel-card__title">{label}</p>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user