diff --git a/src/components/Icons.jsx b/src/components/Icons.jsx
index 0a8bc49..5a65614 100644
--- a/src/components/Icons.jsx
+++ b/src/components/Icons.jsx
@@ -102,6 +102,16 @@ export const IconBlogMarketing = () =>
>
);
+export const IconPortfolio = () =>
+ svg(
+ <>
+
+
+
+
+ >
+ );
+
export const IconBuilding = () =>
svg(
<>
diff --git a/src/pages/portfolio/IntroTab.jsx b/src/pages/portfolio/IntroTab.jsx
new file mode 100644
index 0000000..08d30f3
--- /dev/null
+++ b/src/pages/portfolio/IntroTab.jsx
@@ -0,0 +1,94 @@
+import { useState } from 'react';
+
+const emptyIntro = { title: '', content: '', is_main: 0 };
+
+export default function IntroTab({ introductions, editing, api, onRefresh }) {
+ const [form, setForm] = useState(null);
+ const [copiedId, setCopiedId] = useState(null);
+
+ const save = async () => {
+ if (form.id) {
+ await api.editIntro(form.id, { title: form.title, content: form.content });
+ } else {
+ await api.addIntro(form);
+ }
+ setForm(null);
+ onRefresh();
+ };
+
+ const remove = async (id) => {
+ await api.removeIntro(id);
+ onRefresh();
+ };
+
+ const setMain = async (id) => {
+ await api.setMainIntro(id);
+ onRefresh();
+ };
+
+ const copyToClipboard = async (intro) => {
+ try {
+ await navigator.clipboard.writeText(intro.content);
+ setCopiedId(intro.id);
+ setTimeout(() => setCopiedId(null), 1500);
+ } catch {
+ /* 무시 */
+ }
+ };
+
+ return (
+
+ {editing && (
+
+
+
+ )}
+
+ {/* 작성/수정 폼 */}
+ {form && (
+
+
+
+
+
+
+
+
+ )}
+
+ {/* 자기소개 목록 */}
+
+ {introductions.length === 0 &&
자기소개 글이 없습니다.
}
+ {introductions.map(intro => (
+
+
+
+ {intro.is_main ? MAIN : null}
+ {intro.title || '제목 없음'}
+
+
+ {intro.updated_at ? new Date(intro.updated_at).toLocaleDateString('ko-KR') : ''}
+
+
+
{intro.content}
+
+
+ {editing && (
+ <>
+
+ {!intro.is_main && }
+
+ >
+ )}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/pages/portfolio/PasswordModal.jsx b/src/pages/portfolio/PasswordModal.jsx
new file mode 100644
index 0000000..c4fe593
--- /dev/null
+++ b/src/pages/portfolio/PasswordModal.jsx
@@ -0,0 +1,43 @@
+import { useState } from 'react';
+
+export default function PasswordModal({ open, onAuth, onClose, error }) {
+ const [pw, setPw] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ if (!open) return null;
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!pw.trim()) return;
+ setLoading(true);
+ await onAuth(pw);
+ setLoading(false);
+ setPw('');
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
편집 모드
+
편집하려면 비밀번호를 입력하세요.
+
+
+
+ );
+}
diff --git a/src/pages/portfolio/Portfolio.css b/src/pages/portfolio/Portfolio.css
new file mode 100644
index 0000000..6c3e4d2
--- /dev/null
+++ b/src/pages/portfolio/Portfolio.css
@@ -0,0 +1,873 @@
+/* ═══════════════════════════════════════════════════════════════════════
+ Portfolio Page — Cyberpunk Resume
+ ═══════════════════════════════════════════════════════════════════════ */
+
+.pf-page {
+ display: grid;
+ gap: 20px;
+}
+
+.pf-loading,
+.pf-error {
+ margin: 0;
+ font-size: 13px;
+ color: var(--text-muted);
+}
+
+.pf-error {
+ color: #f9b6b1;
+ border: 1px solid rgba(249, 182, 177, 0.4);
+ border-radius: 12px;
+ padding: 12px;
+ background: rgba(249, 182, 177, 0.08);
+}
+
+.pf-empty {
+ margin: 0;
+ color: var(--text-muted);
+ font-size: 13px;
+ text-align: center;
+ padding: 32px 16px;
+ opacity: 0.6;
+}
+
+/* ── Toolbar ─────────────────────────────────────────────────────────── */
+
+.pf-toolbar {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+/* ── Password Modal ──────────────────────────────────────────────────── */
+
+.pf-modal-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+ z-index: 500;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.pf-modal {
+ background: var(--bg-secondary);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-lg);
+ padding: 28px 24px;
+ width: min(400px, 90vw);
+ display: grid;
+ gap: 16px;
+}
+
+.pf-modal__title {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--text-bright);
+}
+
+.pf-modal__desc {
+ margin: 0;
+ font-size: 13px;
+ color: var(--text-muted);
+}
+
+.pf-modal__input {
+ width: 100%;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 12px 14px;
+ background: rgba(0, 0, 0, 0.25);
+ color: var(--text-bright);
+ outline: none;
+ font-family: inherit;
+ font-size: 15px;
+ box-sizing: border-box;
+}
+
+.pf-modal__input:focus {
+ border-color: rgba(6, 182, 212, 0.5);
+ box-shadow: 0 0 0 2px rgba(6, 182, 212, 0.1);
+}
+
+.pf-modal__error {
+ margin: 8px 0 0;
+ font-size: 13px;
+ color: #f9b6b1;
+}
+
+.pf-modal__actions {
+ display: flex;
+ gap: 10px;
+ justify-content: flex-end;
+ margin-top: 8px;
+}
+
+/* ── Edit Form (공통) ────────────────────────────────────────────────── */
+
+.pf-edit-form {
+ background: var(--surface-card);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-lg);
+ padding: 20px;
+ display: grid;
+ gap: 14px;
+ animation: fadeIn 0.2s ease both;
+}
+
+.pf-edit-form label {
+ display: grid;
+ gap: 6px;
+ font-size: 12px;
+ color: var(--text-muted);
+}
+
+.pf-edit-form input,
+.pf-edit-form textarea,
+.pf-edit-form select {
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 10px 12px;
+ background: rgba(0, 0, 0, 0.25);
+ color: var(--text-bright);
+ outline: none;
+ font-family: inherit;
+ font-size: 14px;
+ resize: vertical;
+}
+
+.pf-edit-form input:focus,
+.pf-edit-form textarea:focus,
+.pf-edit-form select:focus {
+ border-color: rgba(6, 182, 212, 0.5);
+ box-shadow: 0 0 0 2px rgba(6, 182, 212, 0.1);
+}
+
+.pf-edit-form__row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+}
+
+.pf-edit-form__actions {
+ display: flex;
+ gap: 10px;
+ justify-content: flex-end;
+}
+
+.pf-edit-btn {
+ margin-top: 8px;
+}
+
+/* ── Profile Card ────────────────────────────────────────────────────── */
+
+.pf-profile-tab {
+ display: grid;
+ gap: 20px;
+}
+
+.pf-profile-card {
+ background: var(--surface-card);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-lg);
+ padding: 24px;
+ display: grid;
+ gap: 16px;
+ box-shadow: var(--shadow-card);
+}
+
+.pf-profile-card__header {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.pf-profile-card__photo {
+ width: 72px;
+ height: 72px;
+ border-radius: 50%;
+ object-fit: cover;
+ border: 2px solid rgba(6, 182, 212, 0.3);
+}
+
+.pf-profile-card__name {
+ margin: 0;
+ font-size: 22px;
+ font-weight: 700;
+ color: var(--text-bright);
+}
+
+.pf-profile-card__name-en {
+ margin: 2px 0 0;
+ font-size: 13px;
+ color: var(--text-muted);
+}
+
+.pf-profile-card__role {
+ margin: 4px 0 0;
+ font-size: 14px;
+ color: #06b6d4;
+ font-weight: 600;
+}
+
+.pf-profile-card__bio {
+ margin: 0;
+ font-size: 14px;
+ color: var(--text);
+ line-height: 1.7;
+ white-space: pre-line;
+}
+
+.pf-profile-card__links {
+ display: flex;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+
+.pf-profile-card__links a {
+ font-size: 13px;
+ color: #06b6d4;
+ text-decoration: none;
+}
+
+.pf-profile-card__links a:hover {
+ text-decoration: underline;
+}
+
+/* ── Section ─────────────────────────────────────────────────────────── */
+
+.pf-section {
+ display: grid;
+ gap: 12px;
+}
+
+.pf-section__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.pf-section__header h3 {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--text-bright);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+/* ── Career Group ────────────────────────────────────────────────────── */
+
+.pf-career-group {
+ display: grid;
+ gap: 8px;
+}
+
+.pf-career-group__title {
+ margin: 0;
+ font-size: 12px;
+ font-weight: 600;
+ color: #06b6d4;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.pf-career-item {
+ background: var(--surface-card);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ padding: 14px;
+ display: grid;
+ gap: 4px;
+}
+
+.pf-career-item__period {
+ font-size: 11px;
+ color: var(--text-muted);
+ letter-spacing: 0.04em;
+}
+
+.pf-career-item__role {
+ font-size: 14px;
+ color: var(--text-bright);
+}
+
+.pf-career-item__org {
+ font-size: 13px;
+ color: var(--text-dim);
+}
+
+.pf-career-item__desc {
+ margin: 4px 0 0;
+ font-size: 12px;
+ color: var(--text-dim);
+ line-height: 1.6;
+}
+
+.pf-career-item__actions {
+ display: flex;
+ gap: 6px;
+ margin-top: 4px;
+}
+
+/* ── Skill Group ─────────────────────────────────────────────────────── */
+
+.pf-skill-group {
+ display: grid;
+ gap: 8px;
+}
+
+.pf-skill-group__title {
+ margin: 0;
+ font-size: 12px;
+ font-weight: 600;
+ color: #06b6d4;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.pf-skill-group__tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.pf-skill-tag {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ padding: 5px 12px;
+ border-radius: 999px;
+ background: rgba(6, 182, 212, 0.1);
+ border: 1px solid rgba(6, 182, 212, 0.25);
+ color: #06b6d4;
+ font-weight: 500;
+}
+
+.pf-skill-tag[data-level="5"] {
+ background: rgba(6, 182, 212, 0.2);
+ border-color: rgba(6, 182, 212, 0.5);
+}
+
+.pf-skill-tag[data-level="4"] {
+ background: rgba(6, 182, 212, 0.15);
+ border-color: rgba(6, 182, 212, 0.35);
+}
+
+.pf-skill-tag__actions {
+ display: inline-flex;
+ gap: 2px;
+}
+
+.pf-skill-tag__actions button {
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ padding: 0 2px;
+ font-size: 12px;
+ line-height: 1;
+}
+
+.pf-skill-tag__actions button:hover {
+ color: #06b6d4;
+}
+
+/* ── Filter Bar ──────────────────────────────────────────────────────── */
+
+.pf-filter-bar {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ align-items: center;
+}
+
+.pf-filter-btn {
+ font-size: 12px;
+ padding: 6px 14px;
+ border-radius: 999px;
+ border: 1px solid var(--line);
+ background: rgba(255, 255, 255, 0.04);
+ color: var(--text-muted);
+ cursor: pointer;
+ font-family: inherit;
+ transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
+ -webkit-tap-highlight-color: transparent;
+}
+
+.pf-filter-btn:hover {
+ background: rgba(6, 182, 212, 0.1);
+ border-color: rgba(6, 182, 212, 0.3);
+ color: #06b6d4;
+}
+
+.pf-filter-btn.is-active {
+ background: rgba(6, 182, 212, 0.18);
+ border-color: rgba(6, 182, 212, 0.5);
+ color: #06b6d4;
+ font-weight: 600;
+}
+
+/* ── Project Tab ─────────────────────────────────────────────────────── */
+
+.pf-project-tab {
+ display: grid;
+ gap: 16px;
+}
+
+.pf-project-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 14px;
+}
+
+.pf-project-card {
+ background: var(--surface-card);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ padding: 16px;
+ display: grid;
+ gap: 8px;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
+ box-shadow: var(--shadow-card);
+}
+
+.pf-project-card:hover {
+ border-color: rgba(6, 182, 212, 0.25);
+ box-shadow: var(--shadow-md);
+}
+
+.pf-project-card__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.pf-project-card__cat {
+ font-size: 10px;
+ font-weight: 600;
+ padding: 2px 8px;
+ border-radius: 999px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.pf-project-card__cat--company {
+ background: rgba(96, 165, 250, 0.15);
+ color: #60a5fa;
+ border: 1px solid rgba(96, 165, 250, 0.3);
+}
+
+.pf-project-card__cat--personal {
+ background: rgba(52, 211, 153, 0.15);
+ color: #34d399;
+ border: 1px solid rgba(52, 211, 153, 0.3);
+}
+
+.pf-project-card__cat--academy {
+ background: rgba(251, 146, 60, 0.15);
+ color: #fb923c;
+ border: 1px solid rgba(251, 146, 60, 0.3);
+}
+
+.pf-project-card__period {
+ font-size: 11px;
+ color: var(--text-muted);
+}
+
+.pf-project-card__title {
+ margin: 0;
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--text-bright);
+}
+
+.pf-project-card__role {
+ margin: 0;
+ font-size: 12px;
+ color: #06b6d4;
+ font-weight: 500;
+}
+
+.pf-project-card__desc {
+ margin: 0;
+ font-size: 12px;
+ color: var(--text-dim);
+ line-height: 1.6;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.pf-project-card__tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.pf-tech-tag {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 11px;
+ padding: 3px 10px;
+ border-radius: 999px;
+ background: rgba(6, 182, 212, 0.08);
+ border: 1px solid rgba(6, 182, 212, 0.2);
+ color: #06b6d4;
+}
+
+.pf-tech-tag button {
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ padding: 0;
+ font-size: 12px;
+ line-height: 1;
+}
+
+.pf-tech-tag button:hover {
+ color: #f9b6b1;
+}
+
+.pf-project-card__link {
+ font-size: 12px;
+ color: #06b6d4;
+ text-decoration: none;
+}
+
+.pf-project-card__link:hover {
+ text-decoration: underline;
+}
+
+.pf-project-card__actions {
+ display: flex;
+ gap: 6px;
+ margin-top: 4px;
+}
+
+/* ── Tech Input ──────────────────────────────────────────────────────── */
+
+.pf-tech-input {
+ display: grid;
+ gap: 8px;
+}
+
+.pf-tech-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+/* ── Intro Tab ───────────────────────────────────────────────────────── */
+
+.pf-intro-tab {
+ display: grid;
+ gap: 16px;
+}
+
+.pf-intro-tab__toolbar {
+ display: flex;
+ gap: 10px;
+}
+
+.pf-intro-list {
+ display: grid;
+ gap: 12px;
+}
+
+.pf-intro-card {
+ background: var(--surface-card);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ padding: 16px;
+ display: grid;
+ gap: 10px;
+ transition: border-color 0.2s ease;
+}
+
+.pf-intro-card.is-main {
+ border-color: rgba(6, 182, 212, 0.4);
+ background: rgba(6, 182, 212, 0.03);
+}
+
+.pf-intro-card__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.pf-intro-card__title {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-bright);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.pf-intro-card__badge {
+ font-size: 10px;
+ font-weight: 700;
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: rgba(6, 182, 212, 0.2);
+ border: 1px solid rgba(6, 182, 212, 0.4);
+ color: #06b6d4;
+ letter-spacing: 0.08em;
+}
+
+.pf-intro-card__date {
+ font-size: 11px;
+ color: var(--text-muted);
+}
+
+.pf-intro-card__preview {
+ margin: 0;
+ font-size: 13px;
+ color: var(--text-dim);
+ line-height: 1.7;
+ display: -webkit-box;
+ -webkit-line-clamp: 4;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ white-space: pre-line;
+}
+
+.pf-intro-card__actions {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+/* ── Resume View ─────────────────────────────────────────────────────── */
+
+.pf-resume-overlay {
+ display: grid;
+ gap: 16px;
+}
+
+.pf-resume-actions {
+ display: flex;
+ gap: 10px;
+}
+
+.pf-resume {
+ background: #fff;
+ color: #1a1a2e;
+ border-radius: var(--radius-lg);
+ padding: 40px;
+ max-width: 800px;
+ margin: 0 auto;
+ font-family: 'Pretendard', -apple-system, system-ui, sans-serif;
+ line-height: 1.6;
+}
+
+.pf-resume__header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 20px;
+ padding-bottom: 20px;
+ border-bottom: 2px solid #1a1a2e;
+ margin-bottom: 24px;
+}
+
+.pf-resume__name {
+ margin: 0;
+ font-size: 28px;
+ font-weight: 800;
+ color: #1a1a2e;
+}
+
+.pf-resume__role {
+ margin: 4px 0 0;
+ font-size: 14px;
+ color: #555;
+}
+
+.pf-resume__contact {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 4px;
+ font-size: 12px;
+ color: #555;
+}
+
+.pf-resume__section {
+ margin-bottom: 24px;
+}
+
+.pf-resume__section h2 {
+ margin: 0 0 12px;
+ font-size: 14px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: #1a1a2e;
+ border-bottom: 1px solid #ddd;
+ padding-bottom: 6px;
+}
+
+.pf-resume__section p {
+ margin: 0;
+ font-size: 13px;
+ color: #333;
+ line-height: 1.7;
+}
+
+.pf-resume__item {
+ margin-bottom: 14px;
+}
+
+.pf-resume__item-header {
+ display: flex;
+ align-items: baseline;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.pf-resume__item-header strong {
+ font-size: 14px;
+ color: #1a1a2e;
+}
+
+.pf-resume__item-header span {
+ font-size: 12px;
+ color: #555;
+}
+
+.pf-resume__period {
+ font-size: 11px !important;
+ color: #888 !important;
+}
+
+.pf-resume__item p {
+ margin-top: 4px;
+}
+
+.pf-resume__tech {
+ font-size: 11px !important;
+ color: #06b6d4 !important;
+ margin-top: 4px !important;
+}
+
+.pf-resume__skills {
+ font-size: 13px !important;
+ color: #333 !important;
+}
+
+/* ── Print ────────────────────────────────────────────────────────────── */
+
+@media print {
+ body * {
+ visibility: hidden;
+ }
+
+ .pf-resume-overlay,
+ .pf-resume-overlay * {
+ visibility: visible;
+ }
+
+ .pf-resume-overlay {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ }
+
+ .no-print {
+ display: none !important;
+ }
+
+ .pf-resume {
+ padding: 0;
+ border-radius: 0;
+ box-shadow: none;
+ max-width: none;
+ }
+
+ .pf-resume__header {
+ border-bottom: 2px solid #000;
+ }
+
+ .pf-resume__section h2 {
+ border-bottom: 1px solid #000;
+ }
+}
+
+/* ── Responsive ──────────────────────────────────────────────────────── */
+
+@media (max-width: 768px) {
+ .pf-toolbar {
+ display: none;
+ }
+
+ .pf-profile-card {
+ padding: 16px;
+ }
+
+ .pf-profile-card__photo {
+ width: 56px;
+ height: 56px;
+ }
+
+ .pf-profile-card__name {
+ font-size: 18px;
+ }
+
+ .pf-project-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .pf-edit-form__row {
+ grid-template-columns: 1fr;
+ }
+
+ .pf-edit-form input,
+ .pf-edit-form textarea,
+ .pf-edit-form select {
+ font-size: 16px;
+ padding: 12px 14px;
+ }
+
+ .pf-edit-form label {
+ font-size: 14px;
+ }
+
+ .pf-modal__input {
+ font-size: 16px;
+ }
+
+ .pf-resume {
+ padding: 20px;
+ }
+
+ .pf-resume__header {
+ flex-direction: column;
+ }
+
+ .pf-resume__contact {
+ align-items: flex-start;
+ }
+
+ .pf-filter-btn {
+ font-size: 13px;
+ padding: 8px 16px;
+ min-height: 36px;
+ }
+
+ .pf-intro-card__actions .button {
+ min-height: 44px;
+ }
+}
diff --git a/src/pages/portfolio/Portfolio.jsx b/src/pages/portfolio/Portfolio.jsx
new file mode 100644
index 0000000..d25c4fd
--- /dev/null
+++ b/src/pages/portfolio/Portfolio.jsx
@@ -0,0 +1,121 @@
+import { useCallback, useEffect, useState } from 'react';
+import { useIsMobile } from '../../hooks/useIsMobile';
+import SwipeableView from '../../components/SwipeableView';
+import usePortfolioApi from './usePortfolioApi';
+import PasswordModal from './PasswordModal';
+import ProfileTab from './ProfileTab';
+import ProjectTab from './ProjectTab';
+import IntroTab from './IntroTab';
+import ResumeView from './ResumeView';
+import './Portfolio.css';
+
+export default function Portfolio() {
+ const isMobile = useIsMobile();
+ const api = usePortfolioApi();
+
+ const [data, setData] = useState(null);
+ const [intros, setIntros] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+ const [editing, setEditing] = useState(false);
+ const [showPwModal, setShowPwModal] = useState(false);
+ const [showResume, setShowResume] = useState(false);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setError('');
+ try {
+ const d = await api.fetchPublic();
+ setData(d);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => { load(); }, [load]);
+
+ const handleEditToggle = () => {
+ if (editing) {
+ setEditing(false);
+ return;
+ }
+ if (api.token) {
+ setEditing(true);
+ } else {
+ setShowPwModal(true);
+ }
+ };
+
+ const handleAuth = async (pw) => {
+ const ok = await api.login(pw);
+ if (ok) {
+ setShowPwModal(false);
+ setEditing(true);
+ try {
+ const list = await api.fetchIntros();
+ setIntros(list);
+ } catch { /* 무시 */ }
+ }
+ };
+
+ const refresh = useCallback(async () => {
+ try {
+ const d = await api.fetchPublic();
+ setData(d);
+ if (api.token) {
+ const list = await api.fetchIntros();
+ setIntros(list);
+ }
+ } catch { /* 무시 */ }
+ }, [api.token]);
+
+ if (loading && !data) return ;
+ if (error && !data) return ;
+ if (!data) return null;
+
+ if (showResume) {
+ return setShowResume(false)} />;
+ }
+
+ const tabs = [
+ {
+ key: 'profile',
+ label: '프로필',
+ content: ,
+ },
+ {
+ key: 'projects',
+ label: '프로젝트',
+ content: ,
+ },
+ {
+ key: 'intro',
+ label: '자기소개',
+ content: ,
+ },
+ ];
+
+ return (
+
+
+
+
+
+
+
+
+
setShowPwModal(false)}
+ error={api.authError}
+ />
+
+ );
+}
diff --git a/src/pages/portfolio/ProfileTab.jsx b/src/pages/portfolio/ProfileTab.jsx
new file mode 100644
index 0000000..d201fdb
--- /dev/null
+++ b/src/pages/portfolio/ProfileTab.jsx
@@ -0,0 +1,202 @@
+import { useState } from 'react';
+
+const CAREER_CATEGORIES = { company: '회사', education: '교육', etc: '기타' };
+const SKILL_CATEGORIES = { language: '언어', framework: '프레임워크', infra: '인프라', tool: '도구' };
+
+const emptyCareer = { category: 'company', organization: '', role: '', description: '', start_date: '', end_date: '', sort_order: 0 };
+const emptySkill = { category: 'language', name: '', level: 3, sort_order: 0 };
+
+export default function ProfileTab({ data, editing, api, onRefresh }) {
+ const { profile, careers, skills } = data;
+ const [editingProfile, setEditingProfile] = useState(null);
+ const [careerForm, setCareerForm] = useState(null);
+ const [skillForm, setSkillForm] = useState(null);
+
+ // ── Profile 편집 ──
+ const startEditProfile = () => setEditingProfile({ ...profile });
+ const saveProfileEdit = async () => {
+ await api.saveProfile(editingProfile);
+ setEditingProfile(null);
+ onRefresh();
+ };
+
+ // ── Career CRUD ──
+ const saveCareer = async () => {
+ if (careerForm.id) {
+ await api.editCareer(careerForm.id, careerForm);
+ } else {
+ await api.addCareer(careerForm);
+ }
+ setCareerForm(null);
+ onRefresh();
+ };
+ const deleteCareer = async (id) => {
+ await api.removeCareer(id);
+ onRefresh();
+ };
+
+ // ── Skill CRUD ──
+ const saveSkill = async () => {
+ if (skillForm.id) {
+ await api.editSkill(skillForm.id, skillForm);
+ } else {
+ await api.addSkill(skillForm);
+ }
+ setSkillForm(null);
+ onRefresh();
+ };
+ const deleteSkill = async (id) => {
+ await api.removeSkill(id);
+ onRefresh();
+ };
+
+ const grouped = (items, catMap) => {
+ const groups = {};
+ for (const key of Object.keys(catMap)) groups[key] = [];
+ for (const item of items) {
+ const cat = item.category || Object.keys(catMap)[0];
+ if (!groups[cat]) groups[cat] = [];
+ groups[cat].push(item);
+ }
+ return groups;
+ };
+
+ return (
+
+ {/* ── 프로필 카드 ── */}
+
+ {editingProfile ? (
+
+ ) : (
+ <>
+
+ {profile.photo_url &&

}
+
+
{profile.name || '이름 미설정'}
+ {profile.name_en &&
{profile.name_en}
}
+
{profile.role || profile.role_en}
+
+
+ {profile.bio &&
{profile.bio}
}
+
+ {editing &&
}
+ >
+ )}
+
+
+ {/* ── 경력 타임라인 ── */}
+
+
+
경력
+ {editing && }
+
+ {careerForm && (
+
+ )}
+ {Object.entries(grouped(careers, CAREER_CATEGORIES)).map(([cat, items]) =>
+ items.length > 0 && (
+
+
{CAREER_CATEGORIES[cat]}
+ {items.map((c) => (
+
+
{c.start_date} — {c.end_date || '현재'}
+
{c.role}
+
{c.organization}
+ {c.description &&
{c.description}
}
+ {editing && (
+
+
+
+
+ )}
+
+ ))}
+
+ )
+ )}
+
+
+ {/* ── 기술 스택 ── */}
+
+
+
기술 스택
+ {editing && }
+
+ {skillForm && (
+
+
+
+
+
+
+
+
+
+ )}
+ {Object.entries(grouped(skills, SKILL_CATEGORIES)).map(([cat, items]) =>
+ items.length > 0 && (
+
+
{SKILL_CATEGORIES[cat]}
+
+ {items.map((s) => (
+
+ {s.name}
+ {editing && (
+
+
+
+
+ )}
+
+ ))}
+
+
+ )
+ )}
+
+
+ );
+}
diff --git a/src/pages/portfolio/ProjectTab.jsx b/src/pages/portfolio/ProjectTab.jsx
new file mode 100644
index 0000000..4765a05
--- /dev/null
+++ b/src/pages/portfolio/ProjectTab.jsx
@@ -0,0 +1,133 @@
+import { useState } from 'react';
+
+const CATEGORIES = [
+ { key: 'all', label: '전체' },
+ { key: 'company', label: '회사' },
+ { key: 'personal', label: '개인' },
+ { key: 'academy', label: '아카데미' },
+];
+
+const emptyProject = {
+ category: 'personal', title: '', description: '', tech_stack: [],
+ role: '', start_date: '', end_date: '', url: '', image_url: '', sort_order: 0,
+};
+
+export default function ProjectTab({ projects, editing, api, onRefresh }) {
+ const [filter, setFilter] = useState('all');
+ const [form, setForm] = useState(null);
+ const [techInput, setTechInput] = useState('');
+
+ const filtered = filter === 'all' ? projects : projects.filter(p => p.category === filter);
+
+ const addTech = () => {
+ const tag = techInput.trim();
+ if (tag && !form.tech_stack.includes(tag)) {
+ setForm(f => ({ ...f, tech_stack: [...f.tech_stack, tag] }));
+ }
+ setTechInput('');
+ };
+
+ const removeTech = (tag) => {
+ setForm(f => ({ ...f, tech_stack: f.tech_stack.filter(t => t !== tag) }));
+ };
+
+ const save = async () => {
+ if (form.id) {
+ await api.editProject(form.id, form);
+ } else {
+ await api.addProject(form);
+ }
+ setForm(null);
+ setTechInput('');
+ onRefresh();
+ };
+
+ const remove = async (id) => {
+ await api.removeProject(id);
+ onRefresh();
+ };
+
+ return (
+
+ {/* 카테고리 필터 */}
+
+ {CATEGORIES.map(c => (
+
+ ))}
+ {editing && }
+
+
+ {/* 추가/수정 폼 */}
+ {form && (
+
+ )}
+
+ {/* 프로젝트 카드 그리드 */}
+
+ {filtered.length === 0 &&
프로젝트가 없습니다.
}
+ {filtered.map(p => (
+
+
+ {CATEGORIES.find(c => c.key === p.category)?.label}
+ {p.start_date} — {p.end_date || '현재'}
+
+
{p.title}
+ {p.role &&
{p.role}
}
+ {p.description &&
{p.description}
}
+ {p.tech_stack?.length > 0 && (
+
+ {p.tech_stack.map(t => {t})}
+
+ )}
+ {p.url &&
링크 →}
+ {editing && (
+
+
+
+
+ )}
+
+ ))}
+
+
+ );
+}
diff --git a/src/pages/portfolio/ResumeView.jsx b/src/pages/portfolio/ResumeView.jsx
new file mode 100644
index 0000000..cdfdbfe
--- /dev/null
+++ b/src/pages/portfolio/ResumeView.jsx
@@ -0,0 +1,82 @@
+export default function ResumeView({ data, onClose }) {
+ const { profile, careers, projects, skills, main_introduction } = data;
+
+ const handlePrint = () => {
+ window.print();
+ };
+
+ return (
+
+
+
+
+
+
+ {/* 헤더 */}
+
+
+ {/* About */}
+ {(main_introduction?.content || profile.bio) && (
+
+ About
+ {main_introduction?.content || profile.bio}
+
+ )}
+
+ {/* Experience */}
+ {careers.length > 0 && (
+
+ Experience
+ {careers.map(c => (
+
+
+ {c.role}
+ {c.organization}
+ {c.start_date} — {c.end_date || '현재'}
+
+ {c.description &&
{c.description}
}
+
+ ))}
+
+ )}
+
+ {/* Projects */}
+ {projects.length > 0 && (
+
+ Projects
+ {projects.map(p => (
+
+
+ {p.title}
+ {p.start_date} — {p.end_date || '현재'}
+
+ {p.description &&
{p.description}
}
+ {p.tech_stack?.length > 0 && (
+
{p.tech_stack.join(' · ')}
+ )}
+
+ ))}
+
+ )}
+
+ {/* Skills */}
+ {skills.length > 0 && (
+
+ Skills
+ {skills.map(s => s.name).join(' · ')}
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/portfolio/usePortfolioApi.js b/src/pages/portfolio/usePortfolioApi.js
new file mode 100644
index 0000000..c18117d
--- /dev/null
+++ b/src/pages/portfolio/usePortfolioApi.js
@@ -0,0 +1,100 @@
+import { useState, useCallback } from 'react';
+
+const BASE = '/api/profile';
+
+async function apiFetch(path, options = {}) {
+ const res = await fetch(`${BASE}${path}`, {
+ headers: { 'Content-Type': 'application/json', ...options.headers },
+ ...options,
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ detail: res.statusText }));
+ throw new Error(err.detail || res.statusText);
+ }
+ return res.json();
+}
+
+export default function usePortfolioApi() {
+ const [token, setToken] = useState(null);
+ const [authError, setAuthError] = useState('');
+
+ const authHeaders = token ? { Authorization: `Bearer ${token}` } : {};
+
+ const login = useCallback(async (password) => {
+ setAuthError('');
+ try {
+ const data = await apiFetch('/auth', {
+ method: 'POST',
+ body: JSON.stringify({ password }),
+ });
+ setToken(data.token);
+ return true;
+ } catch (err) {
+ setAuthError(err.message);
+ return false;
+ }
+ }, []);
+
+ const logout = useCallback(() => setToken(null), []);
+
+ // ── Public ──
+ const fetchPublic = useCallback(() => apiFetch('/public'), []);
+
+ // ── Profile ──
+ const fetchProfile = useCallback(() =>
+ apiFetch('/profile', { headers: authHeaders }), [token]);
+ const saveProfile = useCallback((data) =>
+ apiFetch('/profile', { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
+
+ // ── Careers ──
+ const fetchCareers = useCallback(() =>
+ apiFetch('/careers', { headers: authHeaders }), [token]);
+ const addCareer = useCallback((data) =>
+ apiFetch('/careers', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]);
+ const editCareer = useCallback((id, data) =>
+ apiFetch(`/careers/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
+ const removeCareer = useCallback((id) =>
+ apiFetch(`/careers/${id}`, { method: 'DELETE', headers: authHeaders }), [token]);
+
+ // ── Projects ──
+ const fetchProjects = useCallback(() =>
+ apiFetch('/projects', { headers: authHeaders }), [token]);
+ const addProject = useCallback((data) =>
+ apiFetch('/projects', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]);
+ const editProject = useCallback((id, data) =>
+ apiFetch(`/projects/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
+ const removeProject = useCallback((id) =>
+ apiFetch(`/projects/${id}`, { method: 'DELETE', headers: authHeaders }), [token]);
+
+ // ── Skills ──
+ const fetchSkills = useCallback(() =>
+ apiFetch('/skills', { headers: authHeaders }), [token]);
+ const addSkill = useCallback((data) =>
+ apiFetch('/skills', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]);
+ const editSkill = useCallback((id, data) =>
+ apiFetch(`/skills/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
+ const removeSkill = useCallback((id) =>
+ apiFetch(`/skills/${id}`, { method: 'DELETE', headers: authHeaders }), [token]);
+
+ // ── Introductions ──
+ const fetchIntros = useCallback(() =>
+ apiFetch('/introductions', { headers: authHeaders }), [token]);
+ const addIntro = useCallback((data) =>
+ apiFetch('/introductions', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]);
+ const editIntro = useCallback((id, data) =>
+ apiFetch(`/introductions/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
+ const removeIntro = useCallback((id) =>
+ apiFetch(`/introductions/${id}`, { method: 'DELETE', headers: authHeaders }), [token]);
+ const setMainIntro = useCallback((id) =>
+ apiFetch(`/introductions/${id}/main`, { method: 'PATCH', headers: authHeaders }), [token]);
+
+ return {
+ token, authError, login, logout,
+ fetchPublic,
+ fetchProfile, saveProfile,
+ fetchCareers, addCareer, editCareer, removeCareer,
+ fetchProjects, addProject, editProject, removeProject,
+ fetchSkills, addSkill, editSkill, removeSkill,
+ fetchIntros, addIntro, editIntro, removeIntro, setMainIntro,
+ };
+}
diff --git a/src/routes.jsx b/src/routes.jsx
index 189c34d..ccbe2df 100644
--- a/src/routes.jsx
+++ b/src/routes.jsx
@@ -10,6 +10,7 @@ import {
IconLab,
IconTodo,
IconBlogMarketing,
+ IconPortfolio,
} from './components/Icons';
const Home = lazy(() => import('./pages/home/Home'));
@@ -25,6 +26,7 @@ const DayCalc = lazy(() => import('./pages/effect-lab/DayCalc'));
const Todo = lazy(() => import('./pages/todo/Todo'));
const MusicStudio = lazy(() => import('./pages/music/MusicStudio'));
const BlogMarketing = lazy(() => import('./pages/blog-marketing/BlogMarketing'));
+const Portfolio = lazy(() => import('./pages/portfolio/Portfolio'));
export const navLinks = [
{
@@ -117,6 +119,15 @@ export const navLinks = [
icon: ,
accent: '#f472b6',
},
+ {
+ id: 'portfolio',
+ label: 'Portfolio',
+ path: '/portfolio',
+ subtitle: 'RESUME',
+ description: '개인 포트폴리오 — 프로필, 이력, 프로젝트 쇼케이스',
+ icon: ,
+ accent: '#06b6d4',
+ },
{
id: 'agent-office',
label: 'Agent Office',
@@ -181,6 +192,10 @@ export const appRoutes = [
path: 'todo',
element: ,
},
+ {
+ path: 'portfolio',
+ element: ,
+ },
{
path: 'agent-office',
lazy: () => import('./pages/agent-office/AgentOffice'),