Compare commits

...

31 Commits

Author SHA1 Message Date
f198255bb1 v1.0.5: 추천 링크 모바일 이동 오류 수정
Made-with: Cursor
2026-04-27 18:15:15 +09:00
a4cb6e4618 v1.0.4: 테마 경고 수정과 기본 다크모드 적용
Made-with: Cursor
2026-04-27 18:09:10 +09:00
319f30d6c5 테마: 작성자 이미지 경로 보정 2026-04-27 17:10:03 +09:00
bedd6cc603 v1.0.0: 사이드바 네비게이션 라벨 설정과 최신 수정 반영
Made-with: Cursor
2026-04-17 19:01:13 +09:00
b148269f53 v1.0.0: Zenless Column Flow 이름과 버전 규칙 반영
Made-with: Cursor
2026-04-17 16:38:39 +09:00
12a1c450ae v0.2.11: 커스텀 공유 모달과 레이아웃 수정 반영
Made-with: Cursor
2026-04-17 16:14:34 +09:00
8bbb2c73f1 v0.2.11: v1.0.0 변경 롤백
Made-with: Cursor
2026-04-17 15:12:15 +09:00
bdb84ddd09 v1.0.0: 구조 클래스 중복 스타일 최종 정리
Made-with: Cursor
2026-04-17 14:47:54 +09:00
1e095798e3 v1.0.0: 헤더 최종 점검과 사이드바 스타일 정리
Made-with: Cursor
2026-04-17 14:31:30 +09:00
f7920f96d6 헤더: topbar 그리드 복구 및 태블릿 2열 정렬 (v0.2.11)
Made-with: Cursor
2026-04-17 14:03:24 +09:00
93ce051343 헤더: 검색 트리거 최소 폭 및 브랜드 말줄임 (v0.2.10)
Made-with: Cursor
2026-04-17 13:57:17 +09:00
a08d053742 블로그: 목록 카드 요약 영역 세로 여백 정리 (v0.2.9)
Made-with: Cursor
2026-04-17 13:49:22 +09:00
070425dd22 테마: v0.2.8 및 상단 사용자 메뉴·검색·히어로·사이드바 누적 보정
Made-with: Cursor
2026-04-17 13:39:59 +09:00
24f065e67e 사이드바: Secondary navigation 아코디언 그룹 추가 (v0.2.7)
Made-with: Cursor
2026-04-17 10:29:07 +09:00
2a72bb29f9 홈: Categories 좌측 액센트를 border-left로 변경 (v0.2.6)
Made-with: Cursor
2026-04-17 10:24:16 +09:00
64944d97c3 홈: Categories 카드 액센트·그리드·설명 처리 보정 (v0.2.5)
Made-with: Cursor
2026-04-17 10:09:04 +09:00
9bc700265d 홈: Latest 아래 Categories 섹션 추가 (v0.2.4)
Made-with: Cursor
2026-04-17 10:04:27 +09:00
3ba0c7231b theme: 태그·작성자 아카이브에서 홈 피드 분리
Made-with: Cursor
2026-04-17 09:56:29 +09:00
21099b53c9 theme: 태그 페이지 헤더 렌더링 보정
Made-with: Cursor
2026-04-16 19:03:28 +09:00
f35f0b155e theme: 작성자 디렉터리 제거 및 로컬 메일 주소 출력
Made-with: Cursor
2026-04-16 18:46:13 +09:00
c6f73c66ee v0.1.44 dev: 로컬 메일 서버 및 댓글 UI 안정화
Made-with: Cursor
2026-04-16 18:19:52 +09:00
d2aeeff2fe v0.1.43 theme: 관련글 우선순위 및 레이아웃 UI 보정
Made-with: Cursor
2026-04-16 17:40:06 +09:00
4ca64abfa7 v0.1.41 theme: 홈/사이드바/추천 모달 동작 정리
Made-with: Cursor
2026-04-16 15:39:45 +09:00
b380820bb6 theme: 홈 피드·사이드바·유저 메뉴 동작 보정
Made-with: Cursor
2026-04-16 13:59:49 +09:00
f839a1fba7 v0.1.40 홈 피드 및 문서 버전 정리
Made-with: Cursor
2026-04-16 12:00:28 +09:00
3f01b8b808 theme: 태그 카드 보더 및 호버 색상 정렬
태그 카드의 좌측 보더와 호버 배경이 각 태그의 accent_color를 항상 기준으로 표현되도록 수정했다.
원본 Thred 태그 목록과 동일한 시각 동작을 맞추기 위해 관련 작업 이력을 문서에 반영했다.

Made-with: Cursor
2026-04-16 10:47:33 +09:00
fd5c654118 v0.1.31 로컬 실시간 확인 스크립트 추가 2026-04-14 16:27:23 +09:00
8c591b3b93 v0.1.30 본문 타이포그래피와 태그 배지 보정 2026-04-14 12:43:35 +09:00
4c460e62fc v0.1.29 테일윈드 초기화 복원 2026-04-14 12:02:10 +09:00
a845f162ca v0.1.28 포스트 상세 헤더 정리 2026-04-14 11:42:10 +09:00
669f20f14a v0.1.27 포스트 카드 경계선 보정 2026-04-14 11:33:04 +09:00
40 changed files with 3129 additions and 550 deletions

View File

@@ -1,6 +1,6 @@
# Ghost Theme: Thred-Inspired
# Ghost Theme: Zenless Column Flow
This repository contains a Ghost theme scaffold inspired by the `Thred` reference layout.
This repository contains the `Zenless Column Flow` Ghost theme for editorial three-column blogs.
## Included
@@ -22,11 +22,11 @@ This repository contains a Ghost theme scaffold inspired by the `Thred` referenc
1. Run `npm run dev:ghost:start`
2. Open `http://localhost:2368/ghost` and complete the local Ghost setup
3. Activate the `ghost-theme-thred-clone` theme in `Settings -> Design`
3. Activate the `zenless-column-flow` theme in `Settings -> Design`
4. After theme changes, run `npm run dev:ghost:restart`
5. To add local sample categories and posts, run `npm run dev:seed` and import the generated JSON file from Ghost Admin
The local Docker setup syncs only theme-related files into `.docker/theme/ghost-theme-thred-clone`, so work files such as `docs/` or `local-ghost/` do not slow down Ghost startup.
The local Docker setup syncs only theme-related files into `.docker/theme/zenless-column-flow`, so work files such as `docs/` or `local-ghost/` do not slow down Ghost startup.
## Important note

View File

@@ -22,6 +22,7 @@
--content-header: 720px;
--topbar-height: 62px;
--shell-width: calc(var(--sidebar-left) + var(--content-column) + var(--sidebar-right));
--topbar-search-min: 260px;
--font-sans: "Pretendard", "Apple SD Gothic Neo", "Noto Sans KR", "Segoe UI", sans-serif;
}
@@ -100,6 +101,7 @@ menu {
min-height: calc(100vh - var(--topbar-height));
width: min(100%, var(--shell-width));
margin: 0 auto;
align-items: start;
transition: grid-template-columns 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}
@@ -108,16 +110,58 @@ menu {
background: var(--bg);
}
@media (min-width: 1024px) {
.sidebar.sidebar--left,
.sidebar.sidebar--right {
position: sticky;
top: var(--topbar-height);
align-self: start;
height: calc(100vh - var(--topbar-height)) !important;
max-height: calc(100vh - var(--topbar-height));
overflow: hidden;
}
}
.sidebar,
.site-main {
min-width: 0;
}
body.tag-hash-ld .sidebar--left,
body.tag-hash-ld .sidebar--right {
display: none;
}
body.tag-hash-ld .topbar {
display: none;
}
body.tag-hash-ld .site-shell {
display: block;
width: 100%;
max-width: 720px;
min-height: 0;
}
body.tag-hash-ld .site-main {
width: 100%;
max-width: 100%;
}
body.tag-hash-ld .post-header {
display: none;
}
.sidebar {
border-right: 1px solid var(--border);
background: var(--bg);
}
.sidebar--left {
overflow: hidden;
transition: opacity 0.24s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.24s cubic-bezier(0.4, 0, 0.2, 1);
}
.sidebar--right {
border-right: 0;
border-left: 1px solid var(--border);
@@ -131,13 +175,24 @@ menu {
overflow-y: auto;
}
.sidebar--left .sidebar__inner {
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.24s cubic-bezier(0.4, 0, 0.2, 1);
}
body.left-sidebar-collapsed .site-shell {
width: calc(var(--content-column) + var(--sidebar-right));
grid-template-columns: minmax(0, var(--content-column)) var(--sidebar-right);
grid-template-columns: 0 minmax(0, var(--content-column)) var(--sidebar-right);
}
body.left-sidebar-collapsed .sidebar--left {
display: none;
opacity: 0;
pointer-events: none;
border-right-color: transparent;
}
body.left-sidebar-collapsed .sidebar--left .sidebar__inner {
opacity: 0;
transform: translateX(-14px);
}
.sidebar__inner--right {
@@ -168,6 +223,20 @@ body.left-sidebar-collapsed .sidebar--left {
.brand--topbar {
margin-bottom: 0;
min-width: 0;
max-width: 100%;
}
.brand--topbar .brand__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.brand--topbar img {
max-width: 100%;
height: auto;
object-fit: contain;
}
.menu-groups {
@@ -247,7 +316,6 @@ body.left-sidebar-collapsed .sidebar--left {
gap: 3px;
margin-left: 14px;
margin-top: 3px;
border-left: 1px solid var(--border);
padding-left: 0;
}
@@ -265,12 +333,22 @@ body.left-sidebar-collapsed .sidebar--left {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0 6px 16px;
padding: 6px 10px 6px 0;
border-radius: 18px;
transition: background-color 0.22s ease, color 0.22s ease, padding 0.22s ease;
font-size: 13px;
}
.menu-group .nav a::before {
content: "";
width: 4px;
height: 16px;
border-radius: 3px;
background: color-mix(in srgb, var(--border) 88%, #ffffff 12%);
flex: 0 0 auto;
transition: width 0.22s ease, height 0.22s ease, border-radius 0.22s ease, background-color 0.22s ease, transform 0.22s ease;
}
.menu-group .nav a:hover,
.menu-sub-link:hover {
background: var(--surface-muted);
@@ -279,6 +357,33 @@ body.left-sidebar-collapsed .sidebar--left {
padding-right: 12px;
}
.menu-group .nav li:not(.nav-current) > a:hover::before {
width: 8px;
height: 8px;
border-radius: 999px;
background: #cfc7be;
transform: translateX(1px);
}
.menu-group .nav .nav-current > a,
.menu-group .nav a.nav-current {
color: var(--text);
}
.menu-group .nav .nav-current > a::before,
.menu-group .nav a.nav-current::before {
background: var(--accent);
}
.menu-group .nav .nav-current > a:hover::before,
.menu-group .nav a.nav-current:hover::before {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--accent);
transform: translateX(1px);
}
.link-list {
margin: 0;
padding: 0;
@@ -555,11 +660,7 @@ body.left-sidebar-collapsed .sidebar--left {
}
.topbar {
position: sticky;
top: 0;
z-index: 20;
height: var(--topbar-height);
width: 100%;
background: color-mix(in srgb, var(--bg) 94%, transparent);
backdrop-filter: blur(10px);
}
@@ -581,7 +682,6 @@ body.left-sidebar-collapsed .sidebar--left {
}
.topbar__brand {
padding: 0 16px;
height: 100%;
display: flex;
align-items: center;
@@ -596,22 +696,23 @@ body.left-sidebar-collapsed .sidebar--left {
height: 28px;
border: 0;
background: transparent;
color: var(--text-dim);
color: var(--text);
cursor: pointer;
border-radius: 8px;
padding: 0;
}
.topbar__sidebar-toggle:hover {
background: var(--surface-muted);
}
.topbar__sidebar-toggle-icon {
width: 24px;
height: 24px;
display: block;
}
.topbar__sidebar-toggle-icon--open-hover,
.topbar__sidebar-toggle-icon--close-hover {
display: none;
}
.topbar__sidebar-toggle-icon--close {
display: none;
}
@@ -624,6 +725,14 @@ body.left-sidebar-collapsed .topbar__sidebar-toggle-icon--close {
display: none;
}
body.left-sidebar-collapsed .topbar__sidebar-toggle:hover .topbar__sidebar-toggle-icon--open {
display: none;
}
body.left-sidebar-collapsed .topbar__sidebar-toggle:hover .topbar__sidebar-toggle-icon--open-hover {
display: block;
}
body:not(.left-sidebar-collapsed) .topbar__sidebar-toggle-icon--open {
display: none;
}
@@ -632,12 +741,21 @@ body:not(.left-sidebar-collapsed) .topbar__sidebar-toggle-icon--close {
display: block;
}
body:not(.left-sidebar-collapsed) .topbar__sidebar-toggle:hover .topbar__sidebar-toggle-icon--close {
display: none;
}
body:not(.left-sidebar-collapsed) .topbar__sidebar-toggle:hover .topbar__sidebar-toggle-icon--close-hover {
display: block;
}
.topbar__search {
display: flex;
justify-content: center;
padding: 0 18px;
height: 100%;
align-items: center;
box-sizing: border-box;
}
.search-trigger,
@@ -646,11 +764,11 @@ body:not(.left-sidebar-collapsed) .topbar__sidebar-toggle-icon--close {
align-items: center;
gap: 10px;
width: min(100%, 320px);
max-width: 100%;
padding: 10px 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
color: var(--text-soft);
box-sizing: border-box;
}
.search-trigger {
@@ -658,6 +776,15 @@ body:not(.left-sidebar-collapsed) .topbar__sidebar-toggle-icon--close {
box-shadow: none;
}
.search-trigger__label {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: start;
}
.search-trigger__icon {
width: 16px;
height: 16px;
@@ -666,6 +793,7 @@ body:not(.left-sidebar-collapsed) .topbar__sidebar-toggle-icon--close {
.search-shortcut {
margin-left: auto;
flex-shrink: 0;
padding: 1px 7px;
border: 1px solid var(--border);
border-radius: 4px;
@@ -677,10 +805,15 @@ body:not(.left-sidebar-collapsed) .topbar__sidebar-toggle-icon--close {
justify-content: flex-end;
align-items: center;
gap: 10px;
padding: 0 16px;
height: 100%;
}
@media (min-width: 1024px) {
.topbar__search {
min-width: min(var(--topbar-search-min), 100%);
}
}
.button,
.gh-subscribe-form button {
display: inline-flex;
@@ -716,6 +849,10 @@ body:not(.left-sidebar-collapsed) .topbar__sidebar-toggle-icon--close {
padding-inline: 14px;
}
[data-featured-track]::-webkit-scrollbar {
display: none;
}
.icon-button {
display: inline-flex;
justify-content: center;
@@ -767,11 +904,6 @@ body:not(.left-sidebar-collapsed) .topbar__sidebar-toggle-icon--close {
background: transparent;
}
.content-area {
padding: 0 20px 44px;
min-width: 0;
}
.hero {
padding: 28px 0 18px;
text-align: center;
@@ -790,6 +922,46 @@ body:not(.left-sidebar-collapsed) .topbar__sidebar-toggle-icon--close {
padding: 20px 18px 10px;
}
.home-hero {
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.home-hero__cover {
opacity: 0;
transition: opacity 0.45s ease;
}
.home-hero__skeleton {
background: linear-gradient(
90deg,
color-mix(in srgb, var(--surface-muted) 88%, var(--surface)),
color-mix(in srgb, var(--surface-muted) 60%, #fff) 45%,
color-mix(in srgb, var(--surface-muted) 88%, var(--surface))
);
background-size: 220% 100%;
animation: home-hero-skeleton 1.2s linear infinite;
transition: opacity 0.35s ease;
}
.home-hero.is-loaded .home-hero__cover {
opacity: 1;
}
.home-hero.is-loaded .home-hero__skeleton {
opacity: 0;
pointer-events: none;
}
@keyframes home-hero-skeleton {
0% {
background-position: 200% 0;
}
100% {
background-position: -20% 0;
}
}
.hero__title {
max-width: 760px;
margin: 0;
@@ -1064,6 +1236,7 @@ body:not(.left-sidebar-collapsed) .topbar__sidebar-toggle-icon--close {
color: var(--text-soft);
font-size: 13px;
line-height: 1.45;
line-clamp: 2;
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 2;
@@ -1364,8 +1537,8 @@ body:not(.left-sidebar-collapsed) .topbar__sidebar-toggle-icon--close {
.post-article {
max-width: var(--content-header);
margin: 0 auto;
padding: 18px 0 10px;
margin: 0 auto 3rem;
word-break: break-word;
}
.post-header {
@@ -1440,7 +1613,7 @@ body:not(.left-sidebar-collapsed) .topbar__sidebar-toggle-icon--close {
.kg-content .kg-width-wide {
position: relative;
width: min(100vw - 48px, 820px);
width: min(100vw - 48px, 680px);
max-width: none;
margin-left: 50%;
transform: translateX(-50%);
@@ -1564,7 +1737,6 @@ body:not(.left-sidebar-collapsed) .topbar__sidebar-toggle-icon--close {
justify-content: space-between;
align-items: center;
gap: 12px;
padding-top: 20px;
}
.pagination__link {
@@ -1577,6 +1749,39 @@ body:not(.left-sidebar-collapsed) .topbar__sidebar-toggle-icon--close {
color: var(--text-soft);
}
.pagination--load-more {
justify-content: center;
}
.pagination__load-more {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 96px;
min-height: 34px;
padding: 8px 16px;
border: 1px solid #2f2f2f;
border-radius: 8px;
background: linear-gradient(180deg, #4a4a4a 0%, #242424 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2);
color: #fff;
font-size: 12px;
font-weight: 700;
letter-spacing: -0.02em;
cursor: pointer;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.pagination__load-more:hover {
transform: translateY(-1px);
opacity: 0.9;
}
.pagination__load-more:disabled {
opacity: 0.7;
cursor: wait;
}
.search-modal[hidden] {
display: none;
}
@@ -1596,9 +1801,20 @@ body:not(.left-sidebar-collapsed) .topbar__sidebar-toggle-icon--close {
.search-modal__panel {
position: relative;
width: min(100%, 720px);
margin: 56px auto;
padding: 0 18px;
width: min(95vw, 620px);
margin: 72px auto 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 18px;
box-shadow: var(--shadow);
overflow: hidden;
}
.search-modal__input {
width: 100%;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.search-modal__input input {
@@ -1607,40 +1823,274 @@ body:not(.left-sidebar-collapsed) .topbar__sidebar-toggle-icon--close {
background: transparent;
color: inherit;
outline: none;
font-size: 1.05rem;
line-height: 1.2;
}
.search-modal__input input::-webkit-search-cancel-button,
.search-modal__input input::-webkit-search-decoration {
-webkit-appearance: none;
appearance: none;
display: none;
}
.search-modal__body {
margin-top: 14px;
padding: 18px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 18px;
box-shadow: var(--shadow);
max-height: 70vh;
padding: 10px 0;
max-height: min(70vh, 640px);
overflow: auto;
}
.search-modal__hint,
.search-empty {
color: var(--text-soft);
padding: 14px 20px;
}
.search-result {
display: block;
padding: 10px 4px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: flex-start;
gap: 10px;
width: 100%;
padding: 10px 20px;
transition: background-color 0.15s ease;
}
.search-result:last-child {
border-bottom: 0;
.search-result:hover {
background: var(--surface-muted);
}
.search-result-group + .search-result-group {
border-top: 1px solid var(--border);
}
.search-result-group__title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-soft);
padding: 12px 20px 6px;
}
.search-result-group__items {
display: flex;
flex-direction: column;
padding-bottom: 4px;
}
.search-author__avatar {
width: 28px;
height: 28px;
border-radius: 999px;
object-fit: cover;
flex: 0 0 auto;
}
.search-author__avatar--fallback {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
background: var(--surface-muted);
color: var(--text-soft);
}
.search-result__prefix {
color: var(--text-soft);
font-weight: 700;
margin-top: 1px;
}
.search-result__title {
display: block;
line-height: 1.35;
color: var(--text);
}
.search-result__excerpt {
width: 100%;
margin-top: 4px;
font-size: 12px;
line-height: 1.35;
color: var(--text-soft);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.search-result--post {
flex-direction: column;
gap: 0;
}
body.share-modal-open {
overflow: hidden;
}
.share-modal[hidden] {
display: none;
}
.share-modal {
position: fixed;
inset: 0;
z-index: 90;
padding: 14px;
}
.share-modal__backdrop {
position: absolute;
inset: 0;
background: rgba(10, 10, 10, 0.42);
backdrop-filter: blur(4px);
}
.share-modal__dialog {
position: relative;
width: min(calc(100% - 2rem), 480px);
margin: 56px auto 0;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow);
padding: 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
transform: translateY(24px) scale(0.95);
opacity: 0;
transition: transform 0.2s ease, opacity 0.2s ease;
}
.share-modal.is-open .share-modal__dialog {
transform: translateY(0) scale(1);
opacity: 1;
}
.share-modal__close {
position: absolute;
top: 16px;
right: 16px;
width: 24px;
height: 24px;
border: 0;
border-radius: 6px;
background: transparent;
opacity: 0.35;
cursor: pointer;
transition: opacity 0.15s ease;
font-size: 18px;
line-height: 1;
color: inherit;
}
.share-modal__close:hover {
opacity: 0.7;
}
.share-modal__eyebrow {
display: block;
align-self: flex-start;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-soft);
font-size: 11px;
font-weight: 700;
}
.share-modal__preview {
width: 100%;
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.share-modal__image-wrap {
width: 100%;
aspect-ratio: 2 / 1;
background: var(--surface-muted);
}
.share-modal__image {
width: 100%;
height: 100%;
object-fit: cover;
}
.share-modal__meta {
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 6px;
}
.share-modal__title {
margin: 0;
font-size: 15px;
line-height: 1.35;
}
.share-modal__description {
margin: 0;
font-size: 13px;
line-height: 1.45;
color: var(--text-soft);
line-clamp: 2;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.share-modal__actions {
width: 100%;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 8px;
}
.share-modal__action {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 40px;
padding: 9px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface-muted);
color: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s ease;
}
.share-modal__action:hover {
opacity: 0.75;
}
.share-modal__action--x {
min-width: 44px;
padding-inline: 10px;
}
.share-modal__action--copy {
justify-content: flex-start;
gap: 8px;
}
.share-modal__action--copy.is-success {
background: #111111;
border-color: #111111;
color: #ffffff;
}
@media (max-width: 1023px) {
.topbar {
grid-template-columns: 1fr;
padding: 0 14px;
border-left: 0;
border-right: 0;
.topbar__inner {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
width: 100%;
max-width: none;
}
.topbar__brand,
@@ -1651,7 +2101,9 @@ body:not(.left-sidebar-collapsed) .topbar__sidebar-toggle-icon--close {
}
.topbar__brand {
grid-column: 1;
grid-row: 1;
min-width: 0;
}
.topbar__search {
@@ -1659,10 +2111,14 @@ body:not(.left-sidebar-collapsed) .topbar__sidebar-toggle-icon--close {
}
.topbar__actions {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
position: static;
grid-column: 2;
grid-row: 1;
align-self: center;
justify-self: end;
right: auto;
top: auto;
transform: none;
gap: 8px;
}
@@ -1764,11 +2220,6 @@ body:not(.left-sidebar-collapsed) .topbar__sidebar-toggle-icon--close {
}
@media (max-width: 640px) {
.topbar {
padding-inline: 16px;
height: 60px;
}
.button--accent {
padding-inline: 12px;
min-height: 38px;

File diff suppressed because one or more lines are too long

View File

@@ -11,6 +11,8 @@
var savedTheme = localStorage.getItem(storageKey);
if (savedTheme) {
setTheme(savedTheme);
} else {
setTheme("dark");
}
document.querySelectorAll("[data-theme-toggle]").forEach(function (button) {
@@ -153,6 +155,30 @@
syncLeftSidebarState();
function initializeHomeHero() {
document.querySelectorAll("[data-home-hero]").forEach(function (heroSection) {
var heroImage = heroSection.querySelector("[data-home-hero-image]");
if (!heroImage) {
return;
}
function markHeroLoaded() {
heroSection.classList.add("is-loaded");
}
if (heroImage.complete && heroImage.naturalWidth > 0) {
markHeroLoaded();
return;
}
heroImage.addEventListener("load", markHeroLoaded, { once: true });
heroImage.addEventListener("error", markHeroLoaded, { once: true });
});
}
initializeHomeHero();
var tabRoot = document.querySelector("[data-tabs]");
if (tabRoot) {
var triggers = tabRoot.querySelectorAll("[data-tab-trigger]");
@@ -176,43 +202,136 @@
var searchModal = document.querySelector("[data-search-modal]");
var searchInput = document.querySelector("[data-search-input]");
var searchResults = document.querySelector("[data-search-results]");
var searchResetButtons = document.querySelectorAll("[data-search-reset]");
function escapeSearchHtml(value) {
return String(value || "")
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function getSearchItems() {
var sourceItems = document.querySelectorAll("[data-search-source] [data-search-item]");
var items = [];
document.querySelectorAll(".post-card h2 a, .recommended-list a, .category-chip, .author-list__item").forEach(function (link) {
sourceItems.forEach(function (itemNode) {
var title = (itemNode.dataset.searchTitle || "").trim();
var url = (itemNode.dataset.searchUrl || "").trim();
if (!title || !url) {
return;
}
items.push({
title: (link.textContent || "").trim(),
url: link.getAttribute("href") || "#"
type: itemNode.dataset.searchType || "post",
title: title,
url: url,
excerpt: (itemNode.dataset.searchExcerpt || "").trim(),
image: (itemNode.dataset.searchImage || "").trim()
});
});
return items.filter(function (item, index, array) {
return item.title && array.findIndex(function (candidate) {
return candidate.title === item.title && candidate.url === item.url;
return array.findIndex(function (candidate) {
return candidate.type === item.type && candidate.title === item.title && candidate.url === item.url;
}) === index;
});
}
function renderSearchSection(heading, items, renderer) {
if (!items.length) {
return "";
}
return [
'<section class="search-result-group">',
'<h3 class="search-result-group__title">' + heading + "</h3>",
'<div class="search-result-group__items">',
items.map(renderer).join(""),
"</div>",
"</section>"
].join("");
}
function renderSearchResults(keyword) {
var normalized = keyword.trim().toLowerCase();
if (!normalized) {
searchResults.innerHTML = '<p class="search-modal__hint">Start typing to filter visible posts, tags, and authors in the current page.</p>';
searchResults.innerHTML = '<p class="search-modal__hint">검색어를 입력하면 Authors, Tags, Posts를 함께 보여줍니다.</p>';
return;
}
var items = getSearchItems().filter(function (item) {
return item.title.toLowerCase().indexOf(normalized) !== -1;
var matchedItems = getSearchItems().filter(function (item) {
var title = item.title.toLowerCase();
var excerpt = item.excerpt.toLowerCase();
return title.indexOf(normalized) !== -1 || excerpt.indexOf(normalized) !== -1;
});
if (!items.length) {
searchResults.innerHTML = '<p class="search-empty">No matching items in the current view.</p>';
if (!matchedItems.length) {
searchResults.innerHTML = '<p class="search-empty">일치하는 항목이 없습니다.</p>';
return;
}
searchResults.innerHTML = items.map(function (item) {
return '<a class="search-result" href="' + item.url + '">' + item.title + "</a>";
}).join("");
var authorItems = matchedItems.filter(function (item) {
return item.type === "author";
});
var tagItems = matchedItems.filter(function (item) {
return item.type === "tag";
});
var postItems = matchedItems.filter(function (item) {
return item.type === "post";
});
var resultMarkup = "";
resultMarkup += renderSearchSection("Authors", authorItems, function (item) {
var imageMarkup = item.image
? '<img class="search-author__avatar" src="' + escapeSearchHtml(item.image) + '" alt="' + escapeSearchHtml(item.title) + '">'
: '<span class="search-author__avatar search-author__avatar--fallback">@</span>';
return [
'<a class="search-result search-result--author" href="' + escapeSearchHtml(item.url) + '">',
imageMarkup,
'<span class="search-result__title">' + escapeSearchHtml(item.title) + "</span>",
"</a>"
].join("");
});
resultMarkup += renderSearchSection("Tags", tagItems, function (item) {
return [
'<a class="search-result search-result--tag" href="' + escapeSearchHtml(item.url) + '">',
'<span class="search-result__prefix">#</span>',
'<span class="search-result__title">' + escapeSearchHtml(item.title) + "</span>",
"</a>"
].join("");
});
resultMarkup += renderSearchSection("Posts", postItems, function (item) {
var excerptMarkup = item.excerpt
? '<p class="search-result__excerpt">' + escapeSearchHtml(item.excerpt) + "</p>"
: "";
return [
'<a class="search-result search-result--post" href="' + escapeSearchHtml(item.url) + '">',
'<h4 class="search-result__title">' + escapeSearchHtml(item.title) + "</h4>",
excerptMarkup,
"</a>"
].join("");
});
searchResults.innerHTML = resultMarkup;
}
function clearSearchInput() {
if (!searchInput) {
return;
}
searchInput.value = "";
renderSearchResults("");
searchInput.focus();
}
function toggleSearch(open) {
@@ -242,6 +361,13 @@
});
});
searchResetButtons.forEach(function (button) {
button.addEventListener("click", function (event) {
event.preventDefault();
clearSearchInput();
});
});
if (searchInput) {
searchInput.addEventListener("input", function (event) {
renderSearchResults(event.target.value);
@@ -258,4 +384,696 @@
toggleSearch(false);
}
});
var userMenuToggle = document.querySelector("[data-user-menu-toggle]");
var userMenu = document.querySelector("[data-user-menu]");
var userThemeToggle = document.querySelector("[data-user-theme-toggle]");
var userMenuStateToggle = document.querySelector("[data-user-menu-state-toggle]");
var userThemeTrack = document.querySelector("[data-user-theme-track]");
var userThemeThumb = document.querySelector("[data-user-theme-thumb]");
var userMenuTrack = document.querySelector("[data-user-menu-track]");
var userMenuThumb = document.querySelector("[data-user-menu-thumb]");
var memberNameDisplays = document.querySelectorAll("[data-member-name-display]");
var memberAvatarImages = document.querySelectorAll("[data-member-avatar-image]");
var memberAvatarBackgrounds = document.querySelectorAll("[data-member-avatar-background]");
var memberAvatarInitials = document.querySelectorAll("[data-member-avatar-initial]");
var memberPortalLinks = document.querySelectorAll("a[href^='#/portal/']");
var memberRefreshInterval = null;
function getMemberDisplayName(member) {
if (!member) {
return "Anonymous";
}
var name = (member.name || "").trim();
return name || "Member";
}
function getMemberInitial(member, seed) {
var fallbackSeed = (seed || "").trim();
var source = "";
if (member) {
source = ((member.name || "").trim() || (member.email || "").trim());
}
source = source || fallbackSeed || "M";
return source.charAt(0).toUpperCase();
}
function applyMemberUi(member) {
if (!memberNameDisplays.length && !memberAvatarImages.length && !memberAvatarBackgrounds.length && !memberAvatarInitials.length) {
return;
}
var displayName = getMemberDisplayName(member);
var avatarImage = (member && member.avatar_image ? member.avatar_image : "").trim();
var hasAvatarImage = !!avatarImage;
memberNameDisplays.forEach(function (element) {
element.textContent = displayName;
});
memberAvatarImages.forEach(function (element) {
if (hasAvatarImage) {
element.src = avatarImage;
element.alt = displayName;
element.classList.remove("hidden");
if (!element.dataset.errorBound) {
element.addEventListener("error", function () {
element.classList.add("hidden");
});
element.dataset.errorBound = "true";
}
} else {
element.classList.add("hidden");
}
});
memberAvatarBackgrounds.forEach(function (element) {
element.style.backgroundColor = "transparent";
});
memberAvatarInitials.forEach(function (element) {
var seed = element.dataset.memberAvatarSeed || "";
element.textContent = getMemberInitial(member, seed);
});
}
function initializeMemberAvatarFromSeed() {
memberAvatarInitials.forEach(function (element) {
var seed = element.dataset.memberAvatarSeed || "";
element.textContent = getMemberInitial(null, seed);
});
memberAvatarBackgrounds.forEach(function (element) {
element.style.backgroundColor = "transparent";
});
}
function getMemberFromPayload(payload) {
if (!payload) {
return null;
}
if (payload.member) {
return payload.member;
}
if (Array.isArray(payload.members) && payload.members.length) {
return payload.members[0];
}
return null;
}
function refreshMemberUi() {
if (!memberNameDisplays.length && !memberAvatarImages.length && !memberAvatarBackgrounds.length && !memberAvatarInitials.length) {
return Promise.resolve();
}
return window
.fetch("/members/api/member/?_=" + Date.now(), {
method: "GET",
credentials: "include",
cache: "no-store",
headers: {
Accept: "application/json"
}
})
.then(function (response) {
if (!response.ok) {
throw new Error("Failed to fetch member");
}
return response.json();
})
.then(function (payload) {
var member = getMemberFromPayload(payload);
if (member) {
applyMemberUi(member);
}
})
.catch(function () {});
}
function scheduleMemberUiRefreshLoop() {
var attempts = 0;
var maxAttempts = 20;
if (memberRefreshInterval) {
window.clearInterval(memberRefreshInterval);
memberRefreshInterval = null;
}
refreshMemberUi();
memberRefreshInterval = window.setInterval(function () {
attempts += 1;
refreshMemberUi();
if (attempts >= maxAttempts) {
window.clearInterval(memberRefreshInterval);
memberRefreshInterval = null;
}
}, 1000);
}
function syncUserMenuToggles() {
var isDark = body.classList.contains("theme-dark");
var menuOpen = tabletMedia.matches ? body.classList.contains("left-sidebar-open") : !body.classList.contains("left-sidebar-collapsed");
if (userThemeTrack) {
userThemeTrack.classList.toggle("bg-accent", isDark);
userThemeTrack.classList.toggle("bg-brd", !isDark);
userThemeTrack.style.backgroundColor = isDark ? "var(--accent)" : "var(--border)";
}
if (userThemeThumb) {
userThemeThumb.classList.toggle("translate-x-3.5", isDark);
userThemeThumb.classList.toggle("translate-x-0", !isDark);
}
if (userMenuTrack) {
userMenuTrack.classList.toggle("bg-accent", menuOpen);
userMenuTrack.classList.toggle("bg-brd", !menuOpen);
userMenuTrack.style.backgroundColor = menuOpen ? "var(--accent)" : "var(--border)";
}
if (userMenuThumb) {
userMenuThumb.classList.toggle("translate-x-3.5", menuOpen);
userMenuThumb.classList.toggle("translate-x-0", !menuOpen);
}
}
function setUserMenu(open) {
if (!userMenu) {
return;
}
userMenu.classList.toggle("translate-y-0", open);
userMenu.classList.toggle("opacity-100", open);
userMenu.classList.toggle("visible", open);
userMenu.classList.toggle("scale-100", open);
userMenu.classList.toggle("pointer-events-auto", open);
userMenu.classList.toggle("-translate-y-4", !open);
userMenu.classList.toggle("opacity-0", !open);
userMenu.classList.toggle("invisible", !open);
userMenu.classList.toggle("scale-95", !open);
userMenu.classList.toggle("pointer-events-none", !open);
}
if (userMenuToggle && userMenu) {
userMenuToggle.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
setUserMenu(userMenu.classList.contains("invisible"));
syncUserMenuToggles();
refreshMemberUi();
});
document.addEventListener("click", function (event) {
if (!event.target.closest("[data-user-menu]") && !event.target.closest("[data-user-menu-toggle]")) {
setUserMenu(false);
}
});
}
if (userThemeToggle) {
userThemeToggle.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
setTheme(body.classList.contains("theme-dark") ? "light" : "dark");
syncUserMenuToggles();
});
}
if (userMenuStateToggle) {
userMenuStateToggle.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
var isOverlay = tabletMedia.matches;
var isOpen = isOverlay ? body.classList.contains("left-sidebar-open") : !body.classList.contains("left-sidebar-collapsed");
if (isOpen) {
closeLeftSidebar();
} else {
openLeftSidebar();
}
syncUserMenuToggles();
});
}
initializeMemberAvatarFromSeed();
syncUserMenuToggles();
refreshMemberUi();
memberPortalLinks.forEach(function (link) {
link.addEventListener("click", function () {
scheduleMemberUiRefreshLoop();
});
});
document.addEventListener("visibilitychange", function () {
if (document.visibilityState === "visible") {
refreshMemberUi();
}
});
window.addEventListener("focus", function () {
refreshMemberUi();
});
var recommendationsPortalTrigger = document.querySelector("[data-portal='recommendations']");
var recommendationsPortalTitle = recommendationsPortalTrigger ? recommendationsPortalTrigger.getAttribute("data-portal-title") : "";
var recommendationsPortalDescription = recommendationsPortalTrigger ? recommendationsPortalTrigger.getAttribute("data-portal-description") : "";
function getPortalDocuments() {
var docs = [document];
document.querySelectorAll("iframe").forEach(function (frame) {
try {
if (frame.contentDocument) {
docs.push(frame.contentDocument);
}
} catch (error) {}
});
return docs;
}
function applyRecommendationsPortalCopy() {
if (!recommendationsPortalTitle && !recommendationsPortalDescription) {
return false;
}
var hasPatched = false;
getPortalDocuments().forEach(function (docRoot) {
var portalTitle = docRoot.querySelector(".gh-portal-main-title");
var portalDescription = docRoot.querySelector(".gh-portal-recommendations-description");
if (portalTitle && recommendationsPortalTitle) {
portalTitle.textContent = recommendationsPortalTitle;
hasPatched = true;
}
if (portalDescription && recommendationsPortalDescription) {
portalDescription.textContent = recommendationsPortalDescription;
hasPatched = true;
}
});
return hasPatched;
}
if (recommendationsPortalTrigger && (recommendationsPortalTitle || recommendationsPortalDescription)) {
recommendationsPortalTrigger.addEventListener("click", function () {
applyRecommendationsPortalCopy();
var attempts = 0;
var maxAttempts = 20;
var retryTimer = window.setInterval(function () {
attempts += 1;
var done = applyRecommendationsPortalCopy();
if (done || attempts >= maxAttempts) {
window.clearInterval(retryTimer);
}
}, 100);
});
}
function copyTextToClipboard(text) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text);
}
return new Promise(function (resolve, reject) {
var textArea = document.createElement("textarea");
textArea.value = text;
textArea.setAttribute("readonly", "readonly");
textArea.style.position = "fixed";
textArea.style.opacity = "0";
textArea.style.pointerEvents = "none";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
var copied = document.execCommand("copy");
document.body.removeChild(textArea);
if (!copied) {
reject(new Error("Copy command was rejected"));
return;
}
resolve();
} catch (error) {
document.body.removeChild(textArea);
reject(error);
}
});
}
function setShareButtonFeedback(button, label) {
if (!button) {
return;
}
var originalLabel = button.dataset.shareOriginalLabel || button.getAttribute("aria-label") || "Share this post";
button.dataset.shareOriginalLabel = originalLabel;
button.setAttribute("aria-label", label);
if (button.dataset.shareFeedbackTimer) {
window.clearTimeout(Number(button.dataset.shareFeedbackTimer));
}
button.dataset.shareFeedbackTimer = String(window.setTimeout(function () {
button.setAttribute("aria-label", originalLabel);
delete button.dataset.shareFeedbackTimer;
}, 1600));
}
var shareModal = document.querySelector("[data-share-modal]");
var shareModalDialog = shareModal ? shareModal.querySelector(".share-modal__dialog") : null;
var shareModalTitle = shareModal ? shareModal.querySelector("[data-share-modal-title]") : null;
var shareModalDescription = shareModal ? shareModal.querySelector("[data-share-modal-description]") : null;
var shareModalImage = shareModal ? shareModal.querySelector("[data-share-modal-image]") : null;
var shareModalCopyButton = shareModal ? shareModal.querySelector("[data-share-copy-button]") : null;
var shareModalCopyLabel = shareModal ? shareModal.querySelector("[data-share-copy-label]") : null;
var activeShareMetadata = null;
function buildShareHrefMap(metadata) {
var encodedUrl = encodeURIComponent(metadata.url || "");
var encodedTitle = encodeURIComponent(metadata.title || "");
return {
x: "https://twitter.com/intent/tweet?text=" + encodedTitle + "&url=" + encodedUrl
};
}
function renderShareModal(metadata) {
if (!shareModal) {
return;
}
activeShareMetadata = metadata;
var hrefMap = buildShareHrefMap(metadata);
if (shareModalTitle) {
shareModalTitle.textContent = metadata.title || document.title || "Post";
}
if (shareModalDescription) {
shareModalDescription.textContent = metadata.description || "이 글을 공유해보세요.";
}
if (shareModalImage) {
if (metadata.image) {
shareModalImage.src = metadata.image;
shareModalImage.alt = metadata.title || "Post image";
shareModalImage.classList.remove("hidden");
} else {
shareModalImage.removeAttribute("src");
shareModalImage.classList.add("hidden");
}
}
Object.keys(hrefMap).forEach(function (key) {
var link = shareModal.querySelector("[data-share-link='" + key + "']");
if (!link) {
return;
}
link.href = hrefMap[key];
});
if (shareModalCopyLabel) {
shareModalCopyLabel.textContent = "주소 복사";
}
}
function openShareModal(metadata) {
if (!shareModal || !metadata || !metadata.url) {
return;
}
renderShareModal(metadata);
shareModal.hidden = false;
body.classList.add("share-modal-open");
window.requestAnimationFrame(function () {
shareModal.classList.add("is-open");
});
}
function closeShareModal() {
if (!shareModal || shareModal.hidden) {
return;
}
shareModal.classList.remove("is-open");
body.classList.remove("share-modal-open");
window.setTimeout(function () {
if (!shareModal.classList.contains("is-open")) {
shareModal.hidden = true;
}
}, 180);
}
function getShareMetadata(button) {
return {
url: (button.dataset.shareUrl || window.location.href || "").trim(),
title: (button.dataset.shareTitle || document.title || "").trim(),
description: (button.dataset.shareDescription || "").trim(),
image: (button.dataset.shareImage || "").trim()
};
}
function initializeShareButtons() {
document.querySelectorAll("[data-post-share-toggle]").forEach(function (button) {
if (button.dataset.shareBound === "true") {
return;
}
button.dataset.shareBound = "true";
button.addEventListener("click", function (event) {
event.preventDefault();
openShareModal(getShareMetadata(button));
});
});
}
if (shareModal) {
shareModal.querySelectorAll("[data-share-modal-close]").forEach(function (button) {
button.addEventListener("click", closeShareModal);
});
shareModal.addEventListener("click", function (event) {
if (shareModalDialog && !shareModalDialog.contains(event.target)) {
closeShareModal();
}
});
document.addEventListener("keydown", function (event) {
if (event.key === "Escape" && !shareModal.hidden) {
closeShareModal();
}
});
if (shareModalCopyButton) {
shareModalCopyButton.addEventListener("click", function () {
if (!activeShareMetadata || !activeShareMetadata.url) {
return;
}
copyTextToClipboard(activeShareMetadata.url)
.then(function () {
setShareButtonFeedback(shareModalCopyButton, "복사 완료");
shareModalCopyButton.classList.add("is-success");
window.setTimeout(function () {
shareModalCopyButton.classList.remove("is-success");
}, 1400);
if (shareModalCopyLabel) {
shareModalCopyLabel.textContent = "복사 완료";
window.setTimeout(function () {
if (shareModalCopyLabel) {
shareModalCopyLabel.textContent = "주소 복사";
}
}, 1400);
}
})
.catch(function () {});
});
}
}
initializeShareButtons();
function updateLoadMoreState(pagination, nextUrl, loading) {
var trigger = pagination.querySelector("[data-load-more-trigger]");
if (!trigger) {
return;
}
if (!nextUrl) {
pagination.remove();
return;
}
pagination.dataset.nextUrl = nextUrl;
trigger.disabled = !!loading;
trigger.textContent = loading ? "Loading..." : "Load More";
pagination.classList.toggle("is-loading", !!loading);
}
function initializeLoadMore(root) {
var pagination = root.querySelector("[data-load-more-pagination]");
var list = root.querySelector("[data-load-more-list]");
if (!pagination || !list || pagination.dataset.bound === "true") {
return;
}
var trigger = pagination.querySelector("[data-load-more-trigger]");
if (!trigger) {
return;
}
pagination.dataset.bound = "true";
trigger.addEventListener("click", function () {
var nextUrl = pagination.dataset.nextUrl;
if (!nextUrl || pagination.classList.contains("is-loading")) {
return;
}
updateLoadMoreState(pagination, nextUrl, true);
fetch(nextUrl, {
headers: {
"X-Requested-With": "XMLHttpRequest"
}
})
.then(function (response) {
if (!response.ok) {
throw new Error("Failed to load more posts");
}
return response.text();
})
.then(function (html) {
var parser = new DOMParser();
var documentFragment = parser.parseFromString(html, "text/html");
var nextRoot = documentFragment.querySelector("[data-load-more-root]");
var nextList = nextRoot ? nextRoot.querySelector("[data-load-more-list]") : null;
var nextPagination = nextRoot ? nextRoot.querySelector("[data-load-more-pagination]") : null;
if (!nextList) {
throw new Error("Post list not found");
}
Array.prototype.forEach.call(nextList.children, function (item) {
list.appendChild(item.cloneNode(true));
});
initializeShareButtons();
updateLoadMoreState(pagination, nextPagination ? nextPagination.dataset.nextUrl : "", false);
})
.catch(function () {
updateLoadMoreState(pagination, pagination.dataset.nextUrl, false);
});
});
}
document.querySelectorAll("[data-load-more-root]").forEach(function (rootNode) {
initializeLoadMore(rootNode);
});
function initializeCategoryPriority() {
document.querySelectorAll("[data-category-priority-list]").forEach(function (list) {
var items = Array.prototype.slice.call(list.querySelectorAll("[data-category-priority-item]"));
var priorityOrder = (list.dataset.categoryPriorityOrder || "")
.split(",")
.map(function (slug) {
return slug.trim();
})
.filter(Boolean);
var limit = Number(list.dataset.categoryPriorityLimit || items.length);
if (!items.length) {
return;
}
items.sort(function (leftItem, rightItem) {
var leftSlug = leftItem.dataset.categorySlug || "";
var rightSlug = rightItem.dataset.categorySlug || "";
var leftIndex = priorityOrder.indexOf(leftSlug);
var rightIndex = priorityOrder.indexOf(rightSlug);
var leftPinned = leftIndex !== -1;
var rightPinned = rightIndex !== -1;
if (leftPinned && rightPinned) {
return leftIndex - rightIndex;
}
if (leftPinned) {
return -1;
}
if (rightPinned) {
return 1;
}
return 0;
});
items.forEach(function (item, index) {
list.appendChild(item);
item.hidden = index >= limit;
});
});
}
initializeCategoryPriority();
document.querySelectorAll("[data-featured-slider]").forEach(function (sliderRoot) {
var track = sliderRoot.querySelector("[data-featured-track]");
var prev = sliderRoot.querySelector("[data-featured-prev]");
var next = sliderRoot.querySelector("[data-featured-next]");
if (!track || !prev || !next) {
return;
}
function getStep() {
var firstCard = track.querySelector(".featured-slider__item");
if (!firstCard) {
return 320;
}
var gap = 20;
return firstCard.getBoundingClientRect().width + gap;
}
function syncButtons() {
var maxLeft = Math.max(0, track.scrollWidth - track.clientWidth - 2);
prev.disabled = track.scrollLeft <= 1;
next.disabled = track.scrollLeft >= maxLeft;
}
prev.addEventListener("click", function () {
track.scrollBy({ left: -getStep(), behavior: "smooth" });
});
next.addEventListener("click", function () {
track.scrollBy({ left: getStep(), behavior: "smooth" });
});
track.addEventListener("scroll", syncButtons, { passive: true });
window.addEventListener("resize", syncButtons);
syncButtons();
});
})();

View File

@@ -1,6 +1,250 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
font-family: "Pretendard", system-ui, sans-serif;
}
body {
color: var(--text);
background-color: var(--bg);
}
}
@layer components {
.prose {
color: var(--tw-prose-body);
/* max-width: 65ch; */
font-size: 1rem;
line-height: 1.75;
}
.prose-theme {
--tw-prose-body: var(--text);
--tw-prose-headings: var(--text);
--tw-prose-lead: var(--text-soft);
--tw-prose-links: var(--text);
--tw-prose-bold: var(--text);
--tw-prose-counters: var(--text-soft);
--tw-prose-bullets: var(--border-strong);
--tw-prose-hr: var(--border);
--tw-prose-quotes: var(--text);
--tw-prose-quote-borders: var(--border);
--tw-prose-captions: var(--text-soft);
--tw-prose-kbd: var(--text);
--tw-prose-kbd-shadows: color-mix(in srgb, var(--text) 10%, transparent);
--tw-prose-code: var(--text);
--tw-prose-pre-code: #f8f5f0;
--tw-prose-pre-bg: #26221f;
--tw-prose-th-borders: var(--border-strong);
--tw-prose-td-borders: var(--border);
}
.prose :where(p):not(:where(.not-prose, .not-prose *)) {
margin-top: 1.25em;
margin-bottom: 1.25em;
}
.prose :where(a):not(:where(.not-prose, .not-prose *)) {
color: var(--tw-prose-links);
font-weight: 500;
text-decoration: underline;
text-underline-offset: 0.12em;
}
.prose :where(strong):not(:where(.not-prose, .not-prose *)) {
color: var(--tw-prose-bold);
font-weight: 600;
}
.prose :where(ol):not(:where(.not-prose, .not-prose *)) {
margin-top: 1.25em;
margin-bottom: 1.25em;
padding-inline-start: 1.625em;
list-style-type: decimal;
}
.prose :where(ul):not(:where(.not-prose, .not-prose *)) {
margin-top: 1.25em;
margin-bottom: 1.25em;
padding-inline-start: 1.625em;
list-style-type: disc;
}
.prose :where(ol > li):not(:where(.not-prose, .not-prose *))::marker {
color: var(--tw-prose-counters);
font-weight: 400;
}
.prose :where(ul > li):not(:where(.not-prose, .not-prose *))::marker {
color: var(--tw-prose-bullets);
}
.prose :where(li):not(:where(.not-prose, .not-prose *)) {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
.prose :where(ol > li, ul > li):not(:where(.not-prose, .not-prose *)) {
padding-inline-start: 0.375em;
}
.prose :where(hr):not(:where(.not-prose, .not-prose *)) {
border-color: var(--tw-prose-hr);
border-top-width: 1px;
margin-top: 3em;
margin-bottom: 3em;
}
.prose :where(blockquote):not(:where(.not-prose, .not-prose *)) {
color: var(--tw-prose-quotes);
border-inline-start-width: 0.25rem;
border-inline-start-color: var(--tw-prose-quote-borders);
margin-top: 1.6em;
margin-bottom: 1.6em;
padding-inline-start: 1em;
font-style: italic;
font-weight: 500;
}
.prose :where(h1):not(:where(.not-prose, .not-prose *)) {
color: var(--tw-prose-headings);
margin-top: 0;
margin-bottom: 0.888889em;
font-size: 2.25em;
font-weight: 800;
line-height: 1.11111;
}
.prose :where(h2):not(:where(.not-prose, .not-prose *)) {
color: var(--tw-prose-headings);
margin-top: 2em;
margin-bottom: 1em;
font-size: 1.5em;
font-weight: 700;
line-height: 1.33333;
}
.prose :where(h3):not(:where(.not-prose, .not-prose *)) {
color: var(--tw-prose-headings);
margin-top: 1.6em;
margin-bottom: 0.6em;
font-size: 1.25em;
font-weight: 600;
line-height: 1.6;
}
.prose :where(h4):not(:where(.not-prose, .not-prose *)) {
color: var(--tw-prose-headings);
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 600;
line-height: 1.5;
}
.prose :where(img, picture, video):not(:where(.not-prose, .not-prose *)) {
margin-top: 2em;
margin-bottom: 2em;
}
.prose :where(picture):not(:where(.not-prose, .not-prose *)) {
display: block;
}
.prose :where(picture > img):not(:where(.not-prose, .not-prose *)) {
margin-top: 0;
margin-bottom: 0;
}
.prose :where(figure):not(:where(.not-prose, .not-prose *)) {
margin-top: 2em;
margin-bottom: 2em;
}
.prose :where(figure > *):not(:where(.not-prose, .not-prose *)) {
margin-top: 0;
margin-bottom: 0;
}
.prose :where(figcaption):not(:where(.not-prose, .not-prose *)) {
color: var(--tw-prose-captions);
margin-top: 0.857143em;
font-size: 0.875em;
line-height: 1.42857;
}
.prose :where(code):not(:where(.not-prose, .not-prose *)) {
color: var(--tw-prose-code);
font-size: 0.875em;
font-weight: 600;
}
.prose :where(code):not(:where(.not-prose, .not-prose *))::before,
.prose :where(code):not(:where(.not-prose, .not-prose *))::after {
content: "`";
}
.prose :where(pre):not(:where(.not-prose, .not-prose *)) {
color: var(--tw-prose-pre-code);
background-color: var(--tw-prose-pre-bg);
padding: 0.857143em 1.14286em;
border-radius: 0.375rem;
margin-top: 1.71429em;
margin-bottom: 1.71429em;
font-size: 0.875em;
font-weight: 400;
line-height: 1.71429;
overflow-x: auto;
}
.prose :where(pre code):not(:where(.not-prose, .not-prose *)) {
font-weight: inherit;
color: inherit;
font-size: inherit;
background-color: transparent;
padding: 0;
}
.prose :where(pre code):not(:where(.not-prose, .not-prose *))::before,
.prose :where(pre code):not(:where(.not-prose, .not-prose *))::after {
content: none;
}
.prose :where(table):not(:where(.not-prose, .not-prose *)) {
table-layout: auto;
width: 100%;
margin-top: 2em;
margin-bottom: 2em;
font-size: 0.875em;
line-height: 1.71429;
}
.prose :where(thead):not(:where(.not-prose, .not-prose *)) {
border-bottom-width: 1px;
border-bottom-color: var(--tw-prose-th-borders);
}
.prose :where(tbody tr):not(:where(.not-prose, .not-prose *)) {
border-bottom-width: 1px;
border-bottom-color: var(--tw-prose-td-borders);
}
.prose :where(th, td):not(:where(.not-prose, .not-prose *)) {
text-align: start;
padding: 0.571429em;
}
.prose :where(.prose > :first-child):not(:where(.not-prose, .not-prose *)) {
margin-top: 0;
}
.prose :where(.prose > :last-child):not(:where(.not-prose, .not-prose *)) {
margin-bottom: 0;
}
}
@layer utilities {
[x-cloak] {
display: none !important;
@@ -30,6 +274,18 @@
background: var(--color-accent);
}
.bg-accent\/10 {
background-color: color-mix(in srgb, var(--color-accent) 10%, transparent);
}
.hover\:bg-accent\/5:hover {
background-color: color-mix(in srgb, var(--color-accent) 5%, transparent);
}
.text-accent {
color: var(--color-accent);
}
.gap-0\.75 {
gap: 0.1875rem;
}
@@ -60,9 +316,22 @@
min-width: 4rem;
}
.max-w-content {
max-width: var(--content-header);
}
.divide-brd > :not([hidden]) ~ :not([hidden]) {
border-color: var(--border);
}
.line-clamp-1 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 홈 Categories 행: 태그 --color-accent 왼쪽 세로 막대(border, ::before 미사용) */
.home-categories__row {
border-left: 3px solid var(--color-accent);
}
}

View File

@@ -1,21 +1,20 @@
{{!< default}}
<main class="content-area">
<section class="stack-section">
<header class="section-header section-header--author">
<div class="author-header">
{{#if profile_image}}
<img class="avatar avatar--large" src="{{img_url profile_image size="s"}}" alt="{{name}}">
{{else}}
<div class="avatar avatar--large avatar--fallback">A</div>
{{/if}}
<div>
<h1 class="section-title">{{name}}</h1>
{{#if bio}}<p class="section-description">{{bio}}</p>{{/if}}
<div class="meta-pill">{{plural count.posts empty="No posts" singular="% post" plural="% posts"}}</div>
</div>
<section class="px-5 sm:px-6 pt-4 sm:pt-5">
<div class="max-w-content mx-auto flex items-center flex-col-reverse sm:flex-row gap-3 justify-between border-b border-brd pb-4 sm:pb-5">
<div class="flex-1 flex flex-col gap-1">
<h1 class="text-lg sm:text-xl font-medium leading-tight">{{name}}</h1>
{{#if bio}}<p class="text-sm text-typ-tone max-w-lg text-balance">{{bio}}</p>{{/if}}
</div>
</header>
{{> "lists/post-feed"}}
<div class="meta-pill">{{plural count.posts empty="No posts" singular="% post" plural="% posts"}}</div>
{{#if profile_image}}
<img class="h-16 w-16 shrink-0 self-start rounded-theme object-cover sm:h-20 sm:w-20" src="{{img_url profile_image size="s" absolute="true"}}" alt="{{name}}">
{{else}}
<div class="flex h-16 w-16 shrink-0 self-start items-center justify-center rounded-theme bg-bgr-tone text-sm font-semibold text-typ-tone sm:h-20 sm:w-20">{{name}}</div>
{{/if}}
</div>
</section>
{{> "lists/post-feed-archive"}}
</main>

View File

@@ -1,36 +0,0 @@
{{!< default}}
<main class="content-area">
<section class="stack-section">
<header class="section-header text-center">
<h1 class="section-title">Authors</h1>
<p class="section-description">Browse by author</p>
</header>
{{#get "authors" limit="100" include="count.posts"}}
<div class="author-directory grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{{#foreach authors}}
<a class="author-directory__card group flex min-h-[128px] flex-col rounded-[14px] border border-[var(--border)] bg-[var(--surface)] p-4 text-left transition-colors hover:bg-[var(--surface-muted)]" href="{{url}}">
<span class="flex items-start gap-3">
{{#if profile_image}}
<img class="h-11 w-11 rounded-full object-cover" src="{{img_url profile_image size="xs"}}" alt="{{name}}">
{{else}}
<span class="flex h-11 w-11 items-center justify-center rounded-full bg-[var(--surface-muted)] text-sm font-semibold">A</span>
{{/if}}
<span class="min-w-0 flex-1">
<strong class="block text-[15px] font-semibold tracking-[-0.02em]">{{name}}</strong>
{{#if bio}}
<span class="mt-1 line-clamp-3 block text-[13px] leading-5 text-[var(--text-soft)]">{{bio}}</span>
{{else}}
<span class="mt-1 block text-[13px] leading-5 text-[var(--text-soft)]">Posts by {{name}}.</span>
{{/if}}
</span>
<img class="h-4 w-4 shrink-0 opacity-70 transition-opacity group-hover:opacity-100" src="{{asset "icons/arrow_outward.svg"}}" alt="">
</span>
<span class="mt-auto pt-3 text-[13px] font-medium text-[var(--text-soft)]">{{plural count.posts empty="0 posts" singular="% post" plural="% posts"}}</span>
</a>
{{/foreach}}
</div>
{{/get}}
</section>
</main>

View File

@@ -11,18 +11,57 @@
{{ghost_head}}
</head>
<body class="{{body_class}}">
<body class="{{body_class}} bg-bgr text-typ antialiased"{{#if @site.accent_color}} style="--accent: {{@site.accent_color}}; --accent-strong: color-mix(in srgb, {{@site.accent_color}} 84%, #000);"{{/if}}>
{{> "site/topbar"}}
<div class="left-sidebar-backdrop" data-left-sidebar-backdrop hidden></div>
<div class="site-shell-wrap">
<div class="site-shell">
<div class="site-shell-wrap border-t border-brd bg-bgr">
<div class="site-shell mx-auto">
{{> "site/sidebar-left"}}
<div class="site-main">
<div class="site-main min-w-0 bg-bgr">
{{{body}}}
</div>
{{> "site/sidebar-right"}}
</div>
</div>
<div class="share-modal" data-share-modal hidden>
<div class="share-modal__backdrop" data-share-modal-close></div>
<div class="share-modal__dialog" role="dialog" aria-modal="true" aria-labelledby="share-modal-title">
<button class="share-modal__close" type="button" data-share-modal-close aria-label="Close share modal">×</button>
<span class="share-modal__eyebrow">Share this post</span>
<div class="share-modal__preview">
<figure class="share-modal__image-wrap">
<img class="share-modal__image hidden" data-share-modal-image alt="">
</figure>
<div class="share-modal__meta">
<h2 class="share-modal__title" id="share-modal-title" data-share-modal-title></h2>
<p class="share-modal__description" data-share-modal-description></p>
</div>
</div>
<div class="share-modal__actions">
<a class="share-modal__action share-modal__action--x" data-share-link="x" target="_blank" rel="noopener noreferrer" aria-label="Share on X">
<i class="icon icon-brand-x size-4 stroke-2" role="presentation">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-brand-x" width="16" height="16" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M4 4l11.733 16h4.267l-11.733 -16z"></path>
<path d="M4 20l6.768 -6.768m2.46 -2.46l6.772 -6.772"></path>
</svg>
</i>
<span class="sr-only">Share on X</span>
</a>
<button class="share-modal__action share-modal__action--copy" type="button" data-share-copy-button aria-label="주소 복사">
<i class="icon icon-link size-4 stroke-2" role="presentation">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-link" width="16" height="16" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M9 15l6 -6"></path>
<path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464"></path>
<path d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463"></path>
</svg>
</i>
<span data-share-copy-label>주소 복사</span>
</button>
</div>
</div>
</div>
{{ghost_foot}}
<script defer src="{{asset "built/alpine.js"}}"></script>

View File

@@ -8,6 +8,26 @@ services:
environment:
url: http://localhost:2368
NODE_ENV: development
database__client: sqlite3
database__connection__filename: /var/lib/ghost/content/data/ghost-local.db
mail__transport: SMTP
mail__from: "Ghost Local <noreply@localhost>"
mail__options__host: mailpit
mail__options__port: 1025
mail__options__secure: "false"
mail__options__ignoreTLS: "true"
mail__options__auth__user: ""
mail__options__auth__pass: ""
volumes:
- ./.docker/ghost/content:/var/lib/ghost/content
- ./.docker/theme/ghost-theme-thred-clone:/var/lib/ghost/content/themes/ghost-theme-thred-clone
- ./.docker/theme/zenless-column-flow:/var/lib/ghost/content/themes/zenless-column-flow
depends_on:
- mailpit
mailpit:
image: axllent/mailpit:latest
container_name: ghost-theme-mailpit
restart: unless-stopped
ports:
- "1025:1025"
- "8025:8025"

View File

@@ -1,7 +1,7 @@
# 배포 가이드
## 현재 버전
- `v0.1.26`
- `v1.0.5`
## Git 기본 설정
- 저장소 작성자 정보는 아래 값으로 통일한다.
@@ -45,6 +45,17 @@ npm install
npm run dev
```
## 로컬 실시간 확인
```bash
npm run dev:ghost:start
npm run dev:watch
```
- `npm run dev:ghost:start`는 로컬 Ghost 컨테이너를 시작한다.
- `npm run dev:watch`는 초기 빌드와 sync를 한 번 수행한 뒤, Tailwind watch와 파일 변경 감지 기반 theme sync를 계속 유지한다.
- 템플릿이나 자산을 저장한 뒤 브라우저 새로고침만으로 바로 반영 상태를 확인할 수 있다.
- 로컬 메일 확인은 Mailpit UI(`http://localhost:8025`)에서 확인한다.
## 로컬 스타일 빌드
```bash
npm run build:alpine
@@ -55,9 +66,18 @@ npm run build:tailwind
- Alpine 결과물은 `assets/built/alpine.js`에 생성된다.
- Tailwind 결과물은 `assets/built/tailwind.css`에 생성되고, Ghost 테마에서 `screen.css` 다음에 로드된다.
## `/tags/`, `/authors/` 연결 방법
- 가장 쉬운 방법은 Ghost Admin에서 페이지를 만들고 슬러그를 각각 `tags`, `authors`로 지정한 뒤 템플릿을 연결하는 방식이다.
- 페이지 방식 대신 커스텀 라우트를 쓰려면 [routes.yaml.example](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/routes.yaml.example)를 기준으로 Ghost의 `routes.yaml``/tags/`, `/authors/`를 연결한다.
## 업로드용 zip 생성
```bash
npm run zip:version
```
- 생성 파일명 형식은 `ZCF-v<package.json version>.zip`이다.
- 현재 `package.json` 버전을 기준으로 `thred-inspired-theme-v0.x.x.zip` 파일을 생성한다.
## `/tags/` 연결 방법
- 가장 쉬운 방법은 Ghost Admin에서 페이지를 만들고 슬러그를 `tags`로 지정한 뒤 템플릿을 연결하는 방식이다.
- 페이지 방식 대신 커스텀 라우트를 쓰려면 [routes.yaml.example](../routes.yaml.example)를 기준으로 Ghost의 `routes.yaml``/tags/`를 연결한다.
## 로컬 빌드 검증
```bash

View File

@@ -1,5 +1,89 @@
# 의사결정 이력
## 2026-04-27 v1.0.5
Recommendations 목록 링크는 `{{#foreach recommendations}}` 컨텍스트 안에서 `{{url}}`처럼 헬퍼 이름과 충돌할 수 있는 식별자를 직접 쓰면 예상과 다르게 현재 사이트 URL로 해석될 수 있다. 모바일에서 우측 사이드바 추천 링크가 홈으로 이동하던 문제를 안정적으로 막기 위해, recommendations 필드 접근을 `{{this.url}}`, `{{this.title}}`, `{{this.favicon}}`처럼 명시 경로로 통일했다.
## 2026-04-27 v1.0.4
이번 변경은 Ghost 테마 경고를 제거하고(helpers 호환성), 우측 사이드바 작성자 아바타를 고정 크기(48x48)로 안정화하며, 초기 테마 기본값을 다크 모드로 통일하는 데 목적이 있다. 특히 `img_url``size="s"` 옵션은 `/size/w300/` 경로를 만들기 때문에 원본 이미지 경로를 요구하는 현재 운영 정책과 맞지 않아 제거했고, 작성자 아바타에는 `h-full` 대신 명시적 `w-12 h-12`를 적용해 텍스트 높이에 따라 이미지가 늘어나는 문제를 방지했다.
## 2026-04-27 v1.0.1
작성자 프로필 이미지는 홈과 포스트 상세처럼 현재 URL 깊이가 다른 화면에서 동일하게 보여야 한다. Ghost의 `img_url`이 루트 기준 경로를 만들 수 있지만, 테마가 프록시나 캐시 계층을 거치는 운영 환경에서는 절대 URL이 더 명확하므로 작성자 이미지 렌더링 지점을 `absolute="true"`로 통일했다. 우측 사이드바 작성자 카드는 시각적으로 외부 이동 아이콘을 이미 갖고 있으므로 실제 작성자 URL도 함께 연결했다.
## 2026-04-17 v1.0.0
실사용 전환 단계에서 기존 패키지명(`ghost-theme-thred-clone`)은 레퍼런스 성격이 강해 운영 식별성과 배포 파일 관리에 불리했다. 그래서 테마 공식 명칭을 `Zenless Column Flow`로 확정하고 slug를 `zenless-column-flow`로 통일했다. Ghost 호환성을 위해 `package.json``version`은 순수 semver(`1.0.0`)를 유지하고, 사람이 보는 릴리스/압축 파일 표기는 `ZCF-v1.x.x` 규칙으로 분리했다.
## 2026-04-17 v0.2.7
Ghost 관리자의 Secondary navigation은 기본 테마 헬퍼만으로는 출력 위치가 없어 활용되지 않았다. Primary와 동일한 `ul.nav` 스타일을 재사용하려면 `Home pages` 블록과 같은 아코디언 래퍼 안에 `{{navigation type="secondary"}}`를 두고, 항목이 없을 때는 빈 UI가 나오지 않도록 `{{#if @site.secondary_navigation}}`으로 감싸는 방식이 가장 단순하다.
## 2026-04-17 v0.2.6
Thred 참고 마크업은 `before:` 가상 요소로 세로 막대를 그리지만, Tailwind 조합만 쓰면 `content`가 비어 있어 `::before` 박스가 생성되지 않아 배경색이 보이지 않는 경우가 있다. 동일한 시각은 `border-left: solid var(--color-accent)`로 더 단순하고 태그 디렉터리 카드와도 맞추기 쉬워서, 홈 Categories 행은 `.home-categories__row` 컴포넌트 규칙으로 왼쪽 테두리만 사용하도록 바꿨다.
## 2026-04-17 v0.2.5
홈 Categories 행에서 `flex`+`basis-full` 제목 배치 때문에 좌·우 열 폭이 태그마다 달라 보였고, 설명이 없을 때 게시물 수로 채우면 원본 Thred와 다른 정보가 들어갔다. 그래서 데스크톱 기준 2:3 그리드로 좌측(제목·선택 설명·View all)과 우측(글 5개) 폭을 고정했고, 설명 미입력 시에는 문구를 생략했다. 좌측 세로 바는 Ghost 태그 `accent_color``--color-accent`에 넣어 `before:bg-accent`로 표시하며, 미설정 태그는 동일 두께의 중립색 막대로 통일했다.
## 2026-04-17 v0.2.4
참고 사이트(Thred) 홈은 Latest 아래에 태그별로 짧은 설명과 상위 글 제목만 나열하는 Categories 블록이 있다. Ghost에서는 태그·글을 서버에서 한 번에 조합하기 어렵고, 좌측 사이드바와 동일한 노출 순서를 유지해야 하므로, `get tags`로 후보를 렌더한 뒤 기존 `initializeCategoryPriority()`로 slug 우선순위와 최대 10개를 맞추고, 각 태그마다 중첩 `get posts`(filter `tag:slug`, 5개)로 목록만 채우는 partial을 분리해 홈 `post-feed` 하단에 포함하는 방식으로 정리했다.
## 2026-04-16 v0.2.3
태그·작성자 아카이브에 홈과 동일한 `post-feed`(Featured + Latest)를 그대로 넣으면 참고 사이트와 달리 중복 UI가 생긴다. 홈 전용 피드와 아카이브 목록을 분리하기 위해 `post-feed-archive` partial을 두고, 아카이브에서는 글 목록과 페이지네이션만 노출하도록 정리했다.
## 2026-04-16 v0.2.1
개인용 블로그 운영에서는 작성자 디렉터리(`/authors/`)와 작성자 목록 페이지가 탐색/운영 측면에서 가치가 낮고, 좌측 사이드바에도 불필요한 메뉴가 늘어나는 문제가 있었다. 그래서 `/authors/` 커스텀 라우트와 작성자 디렉터리 템플릿을 제거하고, 필요 시에는 기본 제공되는 `author` 아카이브(개별 작성자 페이지)만 유지하는 방향으로 단순화했다.
## 2026-04-16 v0.1.43
포스트 상세의 `Read next`가 최신 글만 노출되면 홈 목록과 중복되어 탐색 가치가 낮아진다는 요구에 맞춰, 우선순위를 `primary_tag` 관련 글로 전환했다. 태그 기반 결과가 없을 때만 현재 글 제외 최신 글을 fallback으로 노출하도록 구성해, 관련성 우선과 빈 상태 회피를 함께 만족시키는 방식으로 정리했다.
## 2026-04-16 v0.1.42
랜딩 용도 페이지는 별도 템플릿 분기보다 콘텐츠 태그(`#LD`)로 제어하는 쪽이 운영이 단순하다는 요구에 맞춰, `tag-hash-ld` 조건에서 레이아웃을 단일 컬럼으로 강제했다. 이 모드에서는 좌우 사이드바를 숨기고 `.site-shell` 최대 폭을 720px로 제한하며, `post-header`를 숨겨 본문 중심 구성으로 고정했다.
## 2026-04-16 v0.1.42
사이드바 카테고리는 단순 `limit`만 걸어두면 Ghost 기본 순서나 게시물 수 기준만으로 잘려서, 운영자가 원하는 태그를 항상 노출하기 어려웠다. 수동 우선순위와 자동 정렬을 함께 만족시키기 위해, 템플릿에서는 태그를 게시물 수 기준으로 넉넉히 가져오고 프런트에서 지정한 slug 목록만 앞쪽으로 재배치한 뒤 최종 노출 개수를 제한하는 방식으로 정리했다. 이렇게 하면 우선 노출 태그는 코드에서 명시적으로 관리할 수 있고, 나머지 슬롯은 여전히 게시물 수 많은 순서로 자연스럽게 채워진다.
## 2026-04-16 v0.1.41
Ghost Portal Recommendations 모달의 제목/설명은 렌더링 시점이 일정하지 않고, 경우에 따라 iframe 문서 내부에서 생성되어 기존 문서 단일 선택자 접근으로는 치환이 실패했다. 또한 이전 구현은 전역 `MutationObserver`를 상시 감시로 두고 있어 페이지 체감 지연 가능성이 있었다. 그래서 클릭 시점에 한정해 메인 문서와 접근 가능한 iframe 문서를 함께 탐색하고, 짧은 재시도 구간에서만 텍스트를 치환하는 방식으로 변경했다. 이 방식은 치환 성공률을 높이면서도 상시 관찰 비용을 제거해 안정성을 높인다.
## 2026-04-16 v0.1.40
홈 메인에서 4개 탭을 유지하면 원본 화면과 다르게 상단 컨트롤이 과해지고, 실제 요구사항인 Featured 강조 흐름이 약해진다. 그래서 탭형 전환 UI를 홈 진입 구조에서 제거하고, Featured가 있을 때만 수평 슬라이드로 노출한 뒤 Latest 리스트를 바로 이어 붙이는 구성으로 정리했다. 이 방식은 원본의 정보 우선순위(Featured -> Latest)를 그대로 따르면서 Ghost `featured:true` 데이터도 자연스럽게 재사용할 수 있다.
## 2026-04-16 v0.1.40
최근 수정이 누적되면서 `update/spec/map/deploy/history` 문서의 버전 표기가 서로 어긋났고, 커밋 메시지도 기존 저장소 패턴과 달라져 추적성이 떨어졌다. 그래서 이번에는 기능 자체를 더 넓게 바꾸기보다, 이미 진행한 홈 히어로/탭 피드/태그 카드 보정 작업을 기준으로 문서 버전 체계를 `v0.1.40`으로 맞추고 기록 형식을 기존 흐름에 맞춰 정리했다. 이렇게 해야 다음 작업자가 문서와 커밋 히스토리만 보고도 현재 상태를 빠르게 파악할 수 있다.
## 2026-04-14 v0.1.38
업로드용 zip을 매번 수동 명령으로 만들면 작업 마무리 단계에서 반복 비용이 계속 생긴다. 그래서 현재 `package.json` 버전을 그대로 파일명에 반영하는 `npm run zip:version` 스크립트를 추가해, 사용자가 별도 옵션 없이도 바로 업로드용 압축 파일을 만들 수 있게 정리했다. 이 방식이면 버전 표기와 파일명이 자연스럽게 맞춰지고, 이후 반복 작업도 훨씬 단순해진다.
## 2026-04-14 v0.1.37
참고 사이트의 목록 UX는 페이지 이동보다 하단 버튼으로 다음 목록을 이어 붙이는 방식이었고, 현재 Ghost 기본 `{{pagination}}` 링크 UI는 그 경험과 다르게 느껴졌다. 그래서 서버 쪽 데이터 구조를 바꾸지 않고, Ghost가 렌더링한 다음 페이지 HTML을 그대로 가져와 목록 부분만 파싱해 append 하는 방식을 선택했다. 이 방식은 기존 템플릿 구조를 크게 해치지 않으면서도, 다음 페이지가 없으면 버튼이 자동으로 사라지는 동작까지 비교적 안전하게 구현할 수 있다.
## 2026-04-14 v0.1.36
`tag.hbs`는 개별 태그 아카이브용인데, 사용자가 기대한 화면은 `/tags/` 태그 목록 인덱스였다. 확인 결과 테마 안의 링크와 템플릿은 이미 준비돼 있었지만, 로컬 Ghost가 실제로 읽는 `.docker/ghost/content/settings/routes.yaml`에는 `/tags/`, `/authors/` 라우트가 빠져 있었다. 그래서 테마 템플릿을 더 손대기보다, 로컬 Ghost 설정 라우트를 예시 파일과 맞추는 쪽이 문제 원인과 해결책이 가장 명확하다고 판단했다.
## 2026-04-14 v0.1.35
Ghost의 `tag.hbs`는 이미 태그 컨텍스트와 페이지네이션 컨텍스트를 함께 제공하는데, 여기에 다시 `{{#tag}}` 블록을 감싸면 내부 partial에서 `{{pagination}}`이 현재 페이징 스코프를 잃을 수 있다. 그래서 태그 템플릿은 블록 헬퍼 없이 현재 컨텍스트를 바로 쓰도록 정리했다. 작성자 템플릿의 아바타는 데이터가 없어서가 아니라 크기 유틸리티가 빠져 실질적으로 보이지 않던 상태였기 때문에, 이미지와 fallback 모두 명시적인 크기를 부여해 화면에 안정적으로 표시되도록 수정했다.
## 2026-04-14 v0.1.34
우측 사이드바는 레이아웃 복구 과정에서 CSS 구조 중심 마크업으로 너무 많이 되돌아가면서, 사용자가 앞서 적용해둔 Tailwind 기반 정리 흐름이 사실상 사라졌다. 그래서 이번에는 우측 사이드바도 좌측/헤더와 같은 기준으로, `sidebar--right`와 기존 블록 클래스는 유지하되 실제 배치와 간격은 Tailwind 유틸리티로 다시 표현하는 방식으로 재정리했다. 이렇게 하면 기존 폭 계산과 스타일 훅은 보존하면서도, 사용자가 직접 이어서 수정하기 쉬운 구조를 다시 확보할 수 있다.
## 2026-04-14 v0.1.33
좌측 사이드바 닫힘 애니메이션이 사라진 직접적인 원인은 데스크톱 접힘 상태에서 `.sidebar--left``display: none`으로 처리한 데 있었다. 이 방식은 레이아웃 정리는 쉽지만 전환 프레임이 모두 사라지기 때문에, 이번에는 그리드 첫 열을 `0`으로 줄이면서 사이드바와 내부 콘텐츠에 `opacity``transform` 전환을 주는 방식으로 바꿨다. 이렇게 하면 기존 셸 폭 계산은 유지하면서도 사용자가 기대하는 열리고 닫히는 움직임을 다시 복원할 수 있다.
## 2026-04-14 v0.1.32
최근 레이아웃 붕괴는 Tailwind 마크업을 유지하려는 수정과, 기존 `screen.css``theme.js`가 기대하는 구조 클래스가 어긋난 것이 함께 겹치면서 발생했다. 그래서 이번에는 원래 셸 구조를 단순 복원하는 대신, `site-shell`, `topbar__inner`, `sidebar--left`, `sidebar--right`처럼 폭 계산과 토글 동작에 직접 연결된 훅은 유지하고 그 위에만 Tailwind 유틸리티를 다시 얹는 하이브리드 방식으로 정리했다. 이렇게 하면 사용자가 선호하는 Tailwind 중심 수정 흐름을 유지하면서도, 1296px 헤더 정렬과 287px 사이드바 계산 같은 핵심 레이아웃 기준을 다시 안정적으로 보존할 수 있다.
## 2026-04-14 v0.1.31
zip 업로드 없이 로컬 Ghost에서 바로 확인하려면 자동 sync가 필요했지만, `fs.watch` 기반 재귀 watcher는 현재 환경에서 `EMFILE` 오류로 안정적으로 유지되지 않았다. 그래서 별도 의존성을 추가하지 않고, 제외 디렉터리를 뺀 파일 목록의 수정 시간을 주기적으로 비교하는 polling 방식으로 `dev:watch`를 구현했다. 이 방식은 다소 단순하지만 현재 저장소 크기에서는 충분히 가볍고, 사용자가 템플릿을 저장한 뒤 바로 브라우저 새로고침으로 확인할 수 있다는 점이 더 중요하다고 판단했다.
## 2026-04-14 v0.1.30
`prose`는 Tailwind 기본 유틸리티가 아니라 `@tailwindcss/typography` 플러그인 영역인데, 현재 저장소에는 해당 플러그인이 들어있지 않았다. 이번 단계에서는 사용자가 이미 수정한 Tailwind 마크업을 최대한 유지하는 것이 우선이었기 때문에, 의존성을 새로 늘리기보다 `assets/styles/tailwind.css``@layer components`에 원본 소스 기준 타이포그래피 규칙을 직접 옮겨 적용했다. 동시에 `bg-accent/10`처럼 현재 빌드에서 누락되던 유틸리티도 같은 방식으로 보강해 카드 태그 배경과 리스트 구분선이 다시 보이도록 정리했다.
## 2026-04-14 v0.1.29
브라우저 기본 `h1`, `button` 스타일이 계속 남는 문제는 개별 요소 보정보다 Tailwind 전역 초기화 자체를 다시 켜는 편이 더 맞다고 판단했다. 초기 단계에서 `preflight`를 꺼두었지만, 현재처럼 Tailwind 마크업 비중이 커진 상태에서는 기본 리셋이 없는 쪽이 오히려 예측 불가능한 차이를 만들기 때문에 `preflight`를 복원했다.
## 2026-04-14 v0.1.28
포스트 상세 화면은 기존 `post-header`, `post-meta` CSS 구조를 유지한 채 부분 수정하는 것보다, 홈 카드와 같은 기준으로 Tailwind 마크업 쪽으로 맞추는 편이 원본 화면에 더 가깝다고 판단했다. 또한 원본 상세 화면에는 제목 위 별도 태그 라벨이 없기 때문에, 메타 영역 태그만 남기고 상단 중복 태그는 제거했다.
## 2026-04-14 v0.1.27
포스트 카드의 큰 구조를 Tailwind 기준으로 되돌린 뒤에도, 실제 사용 화면에서는 구분선과 콘텐츠 폭 제약처럼 아주 기본적인 레이아웃 안정화가 먼저 필요했다. 그래서 이번 단계에서는 새 해석을 추가하지 않고, 사용자가 준 구조를 유지한 채 `border-b`, `min-w-0`, `items-start`처럼 레이아웃 안정성에 직접 연결되는 속성만 최소 보정했다.
## 2026-04-14 v0.1.26
포스트 카드 영역은 원본 구조를 닮게 보이도록 CSS로 따로 해석하는 방식보다, 사용자가 준 Tailwind 마크업 흐름을 직접 반영하는 편이 맞다고 다시 정리했다. 그래서 카드 관련 커스텀 구조를 최소화하고, 부족한 유틸리티만 Tailwind 입력 파일에 보강하는 방향으로 수정했다.

View File

@@ -1,38 +1,41 @@
# 파일-화면 매핑 가이드
## 현재 버전
- `v0.1.26`
- `v1.0.5`
## 공통 레이아웃
- [default.hbs](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/default.hbs): 전체 3열 셸과 공통 자산 로드
- [partials/site/sidebar-left.hbs](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/partials/site/sidebar-left.hbs): 좌측 탐색/직접 링크형 Tags·Authors 메뉴/카테고리 아코디언/푸터
- [page-tags.hbs](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/page-tags.hbs): `slug=tags` 페이지용 태그 디렉터리
- [page-authors.hbs](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/page-authors.hbs): `slug=authors` 페이지용 작성자 디렉터리
- [tags-index.hbs](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/tags-index.hbs): `/tags/` 커스텀 라우트용 태그 디렉터리
- [authors-index.hbs](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/authors-index.hbs): `/authors/` 커스텀 라우트용 작성자 디렉터리
- [partials/site/topbar.hbs](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/partials/site/topbar.hbs): 상단 검색/CTA/다크모드
- [partials/site/sidebar-right.hbs](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/partials/site/sidebar-right.hbs): 구독/추천/작성자/푸터
- [default.hbs](../default.hbs): 전체 3열 셸, 1296px 공통 폭 계산, 공통 자산 로드, 전역 공유 모달(`share-modal`) 마크업 포함
- [partials/site/sidebar-left.hbs](../partials/site/sidebar-left.hbs): 좌측 탐색(Primary `{{navigation}}`, Secondary `{{navigation type="secondary"}}`·`@site.secondary_navigation` 있을 때만)/Tags/카테고리 아코디언/푸터
- [page-tags.hbs](../page-tags.hbs): `slug=tags` 페이지용 태그 디렉터리
- [tags-index.hbs](../tags-index.hbs): `/tags/` 커스텀 라우트용 태그 디렉터리
- [partials/site/topbar.hbs](../partials/site/topbar.hbs): 상단 3열 헤더, 중앙 검색(최소 폭·라벨 말줄임), 좌측 사이드바 토글
- [partials/site/sidebar-right.hbs](../partials/site/sidebar-right.hbs): 우측 287px 사이드바, 포스트 상세 작성자 카드, Ghost Recommendations 기반 추천 링크/구독/푸터 구성
## 홈 및 목록
- [home.hbs](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/home.hbs): 메인 홈
- [index.hbs](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/index.hbs): 기본 목록 진입
- [partials/home/hero.hbs](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/partials/home/hero.hbs): 홈 히어로 영역
- [partials/home/tabbed-feed.hbs](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/partials/home/tabbed-feed.hbs): Latest/Featured/Updated/Categories 탭과 카테고리 개요 행
- [partials/lists/post-items.hbs](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/partials/lists/post-items.hbs): Tailwind 기반 리스트형 포스트 카드, 댓글 아이콘, 우측 공유 액션, 메타 정보
- [home.hbs](../home.hbs): 메인 홈
- [index.hbs](../index.hbs): 기본 목록 진입
- [partials/home/hero.hbs](../partials/home/hero.hbs): 홈 히어로 영역
- [partials/lists/post-feed.hbs](../partials/lists/post-feed.hbs): Featured 슬라이드, Latest 리스트, Categories(태그별 상위 5글) 묶음
- [partials/lists/home-categories.hbs](../partials/lists/home-categories.hbs): 홈 Latest 하단 태그별 최신 글 5개(텍스트 링크·우선순위 목록과 동일 정렬)
- [partials/lists/post-feed-archive.hbs](../partials/lists/post-feed-archive.hbs): 태그·작성자 아카이브용 글 목록 + Load More(페이지네이션)
- [partials/lists/post-items.hbs](../partials/lists/post-items.hbs): Tailwind 기반 리스트형 포스트 카드(요약·메타·`post-card-content` 세로 정렬), 댓글 아이콘, 우측 공유 액션(`data-share-*` 메타데이터 전달), 태그 accent 배지
## 상세 및 아카이브
- [post.hbs](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/post.hbs): 포스트 상세
- [page.hbs](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/page.hbs): 일반 페이지
- [tag.hbs](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/tag.hbs): 태그 아카이브
- [author.hbs](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/author.hbs): 작성자 아카이브
- [partials/post/post-navigation.hbs](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/partials/post/post-navigation.hbs): 이전/다음 글 이동
- [post.hbs](../post.hbs): 포스트 상세
- [page.hbs](../page.hbs): 일반 페이지
- [tag.hbs](../tag.hbs): 태그 아카이브
- [author.hbs](../author.hbs): 작성자 아카이브
- [partials/post/post-navigation.hbs](../partials/post/post-navigation.hbs): 이전/다음 글 이동
## 자산
- [assets/built/screen.css](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/assets/built/screen.css): 전체 스타일
- [assets/built/tailwind.css](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/assets/built/tailwind.css): Tailwind 빌드 결과물
- [assets/built/alpine.js](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/assets/built/alpine.js): Alpine.js 로컬 배포 파일
- [assets/built/theme.js](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/assets/built/theme.js): 인터랙션 스크립트
- [assets/styles/tailwind.css](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/assets/styles/tailwind.css): Tailwind 입력 파일
- [tailwind.config.js](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/tailwind.config.js): Tailwind 스캔 경로 및 테마 설정
- [routes.yaml.example](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/routes.yaml.example): Ghost 커스텀 라우트 예시
- [package.json](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/package.json): Ghost 테마 메타데이터
- [assets/built/screen.css](../assets/built/screen.css): 전체 스타일
- [assets/built/tailwind.css](../assets/built/tailwind.css): Tailwind 빌드 결과물
- [assets/built/alpine.js](../assets/built/alpine.js): Alpine.js 로컬 배포 파일
- [assets/built/theme.js](../assets/built/theme.js): 인터랙션 스크립트
- [partials/pagination.hbs](../partials/pagination.hbs): 다음 페이지가 있을 때만 표시되는 `Load More` 버튼
- [assets/styles/tailwind.css](../assets/styles/tailwind.css): Tailwind 입력 파일, `prose` 타이포그래피 규칙, accent/구분선 보조 유틸리티
- [tailwind.config.js](../tailwind.config.js): Tailwind 스캔 경로, 테마 설정, preflight 초기화 설정
- [scripts/dev-watch.js](../scripts/dev-watch.js): 로컬 파일 변경 감지 후 `dev:sync`와 Tailwind watch를 함께 실행하는 개발용 watcher
- [routes.yaml.example](../routes.yaml.example): Ghost 커스텀 라우트 예시
- [package.json](../package.json): Ghost 테마 메타데이터
- [package.json](../package.json): Ghost 테마 메타데이터, `zip:version` 압축 스크립트

View File

@@ -1,9 +1,10 @@
# 기술 명세
## 현재 버전
- `v0.1.26`
- `v1.0.5`
## 테마 개요
- 테마명: `Zenless Column Flow` (slug: `zenless-column-flow`)
- Ghost `v5` 대응 커스텀 테마
- 참고 사이트 `https://thred.brightthemes.com/` 기반의 3열 에디토리얼 레이아웃
- 좌측 탐색, 중앙 콘텐츠, 우측 구독/추천/작성자 패널 구조
@@ -13,17 +14,56 @@
- `home`, `index`, `tag`, `author`, `post`, `page` 템플릿
- 검색 오버레이, 탭 전환, 다크모드 토글용 프런트 스크립트
- Ghost `navigation`, `get`, `subscribe_form`, `comments`, `pagination` 헬퍼 사용
- `@site.accent_color`가 설정된 경우 `default.hbs`의 body 인라인 변수로 전역 `--accent`/`--accent-strong`를 덮어써 테마 포인트 색상을 동기화함
- `topbar` 브랜드는 모바일 화면에서만 노출하며, `@site.logo`를 우선 렌더링하고 로고 미설정 시 `@site.title` 텍스트를 fallback으로 사용함
- 데스크톱 `topbar__inner``var(--sidebar-left) minmax(0, var(--content-column)) var(--sidebar-right)` 3열 그리드이며, `1024px` 이상에서만 검색 셀(`.topbar__search`)에 `min-width: min(--topbar-search-min, 100%)`를 적용해 중앙 검색 최소 폭을 완만히 보장함. 검색 트리거는 라벨(`.search-trigger__label`) 말줄임·단축키 `flex-shrink: 0` 유지. 브랜드 텍스트는 말줄임, 로고 이미지는 `max-width: 100%`로 축소
- `1023px` 이하에서는 `topbar__inner``minmax(0, 1fr) auto` 2열로 바꾸고 검색 열을 숨긴 뒤, 브랜드·액션을 각각 1·2열에 배치함(헤더가 3열 정의만 남는 문제 방지)
- `home` Hero는 `@site.cover_image`가 있을 때 배경 이미지로 적용하며 오버레이와 텍스트 대비를 함께 조정함
- 좌측 사이드바 `menu-groups`: Primary는 `@site.navigation` 항목이 있을 때만 아코디언과 `{{navigation}}`를 렌더하고, Secondary도 `@site.secondary_navigation`이 있을 때만 아코디언과 `{{navigation type="secondary"}}`를 동일 마크업(`ul.nav`)으로 노출함. 각 라벨은 `@custom.primary_nav_label`, `@custom.secondary_nav_label`(미입력 시 `Primary`/`Secondary` fallback)로 어드민에서 변경 가능함
- 상단 사용자 메뉴는 멤버 로그인 시에만 드롭다운 상단에 이름/아바타 행을 노출하며, 서버 렌더링 값으로 초기 표시한 뒤 `/members/api/member/` 재조회로 실시간 동기화하고 아바타 미등록 시 fallback 문자(이름/이메일 첫 글자)를 사용함. 비로그인 시 해당 행은 렌더하지 않음
- Tailwind CSS 빌드 결과물(`assets/built/tailwind.css`)을 기존 `screen.css`와 함께 로드
- Tailwind 기본 초기화(`preflight`)를 활성화해 브라우저 기본 마진과 폼 스타일을 리셋
- Alpine.js 로컬 자산(`assets/built/alpine.js`)을 전역 로드
- `npm run dev:watch`는 초기 `dev:prepare` 실행 후 Tailwind `--watch`와 파일 변경 감지 기반 `dev:sync`를 함께 실행함
- 로컬 Docker 구성은 Mailpit SMTP(`mailpit:1025`)를 사용하도록 설정되어 멤버/댓글 알림 메일을 로컬 수신함
- `npm run zip:version`은 현재 `package.json` 버전명을 기준으로 업로드용 zip을 생성함
- 좌측 카테고리 영역은 Alpine.js로 제어되며 `1024px` 이상에서 기본 열림, 미만에서 기본 닫힘
- 좌측 카테고리 목록은 `data-category-priority-order`에 지정한 태그 slug를 우선순위로 먼저 배치하고, 나머지는 `count.posts desc` 기본 순서를 유지한 뒤 제한 개수만 노출함
- 좌측 네비게이션 마커와 카테고리 마커는 동일한 세로 바 → 원형 hover 패턴 사용
- 상단 사용자 버튼/메뉴 아바타는 로그인 멤버일 때 댓글 UI와 유사한 형태(이니셜 배경 + 이미지 오버레이)로 표시하며, 이니셜과 배경색은 멤버 이름/이메일 기반으로 동기화함. 비로그인 상태는 `icon-user-circle` 아이콘을 사용함
- 로그인 상태 사용자 버튼/메뉴 아바타의 배경색은 투명으로 유지하며, 비로그인 상태 아이콘은 32px 버튼 영역에 맞는 크기로 렌더링함
- 상단 사용자 메뉴의 멤버 동기화는 `/members/api/member/` 재조회(`cache: no-store`)로 수행하며 `member`/`members[0]` 응답 형태를 모두 처리함
- 검색 모달은 `partials/site/topbar.hbs``data-search-source`(posts/tags/authors) 데이터를 사용하고, 결과를 `Authors`, `Tags`, `Posts` 섹션으로 분리해 렌더링함
- 검색 모달 입력 왼쪽 `X` 버튼은 입력값 초기화 용도이며, 모달 닫기는 배경 클릭/ESC로 처리함
- 검색 모달 입력의 브라우저 기본 우측 cancel 버튼은 숨김 처리하고, `search-result__excerpt`는 한 줄 말줄임으로 고정함
- 전역 `ol`, `ul`, `menu` 기본 패딩과 리스트 스타일 리셋 적용
- `author.hbs`는 페이지 컨텍스트의 작성자 데이터를 직접 사용
- `page-tags.hbs`, `page-authors.hbs`는 각각 `slug=tags`, `slug=authors` 페이지에 연결 가능
- 작성자 프로필 이미지는 우측 사이드바, 좌측 작성자 목록, 작성자 아카이브, 검색 데이터 소스에서 `img_url ... absolute="true"` 기준으로 렌더링해 포스트 상세처럼 경로가 깊은 URL에서도 동일한 이미지 주소를 사용함
- `page-tags.hbs``slug=tags` 페이지에 연결 가능
- 태그/작성자 디렉터리 목록은 현재 `limit="100"` 기준
- `tags-index.hbs`, `authors-index.hbs` `routes.yaml` 커스텀 라우트로 `/tags/`, `/authors/`에 연결 가능
- 홈 메인 피드는 히어로, 탭형 목록, 카테고리 개요를 원본 비주얼 밀도에 가깝게 재구성
- `tags-index.hbs`는 Ghost `routes.yaml` 커스텀 라우트로 `/tags/`에 연결
- 로컬 개발 환경의 실제 라우트 설정은 `.docker/ghost/content/settings/routes.yaml`을 기준으로 사용함
- 홈 메인 피드는 히어로, Featured 수평 슬라이드, Latest 리스트 구성을 사용함
- 홈 Hero는 `@site.cover_image`가 있을 때만 커버 이미지 영역으로 노출하며, 텍스트/구독 폼은 렌더링하지 않음
- 홈 Hero 커버는 이미지 로딩 완료 전까지 스켈레톤(shimmer) 애니메이션을 표시하고, 로드 후 실제 이미지로 페이드 전환함
- 홈 Latest 블록 아래에는 `home-categories` partial로 태그별 섹션을 두며, 좌측 사이드바와 동일한 `data-category-priority-order`·`data-category-priority-limit`(10)로 정렬·개수 제한 후 태그당 최신 글 최대 5개를 번호 링크로만 표시함. 세로 액센트는 태그 `accent_color``--color-accent`에 넣고 `assets/styles/tailwind.css``.home-categories__row`에서 `border-left: 3px solid var(--color-accent)`로 표시한다(미설정 시 중립색). 설명이 없으면 빈 칸으로 두며, 좌·우 열은 `minmax(0,2fr)`/`minmax(0,3fr)` 그리드로 고정한다
- 태그·작성자 아카이브(`tag.hbs`, `author.hbs`)는 홈과 동일한 `post-feed`를 쓰지 않고, `post-feed-archive`로 글 목록과 페이지네이션만 노출함
- 우측 사이드바 `Recommended` 섹션은 Ghost `recommendations` 데이터를 우선 사용하며, 항목별 외부 링크와 favicon 표시를 지원함
- 포스트 상세 우측 `Read next``primary_tag` 기준 관련 글을 우선 노출하고, 관련 글이 없으면 최신 글(현재 글 제외)로 대체함
- Recommendations Portal 모달의 제목/설명은 트리거 버튼의 `data-portal-title`, `data-portal-description` 값으로 오픈 시점에 동기화함
- 리스트형 `post-card`는 Tailwind 유틸리티 중심 마크업으로 구성되며, 썸네일은 `aspect-square sm:aspect-video` 비율을 사용
- `post-items` 카드 본문(`post-card-content`)은 요약 문단이 실제 텍스트 높이만 차지하며(`flex-1`·고정 `min-h` 없음), 요약이 있을 때만 메타 줄에 `mt-auto`를 주어 이미지와 높이를 맞출 때 생기던 요약 블록 내부 빈 여백을 줄임. 요약이 없으면 요약 노드를 렌더하지 않음
- 각 카드 항목은 `border-b border-brd` 구분선을 유지하고, 콘텐츠 래퍼는 `min-w-0` 기준으로 줄바꿈 폭을 제어
- 포스트 상세 헤더는 제목, 메타, 공유 버튼, 대표 이미지를 Tailwind 유틸리티 중심 마크업으로 구성하고 상단 단일 태그 라벨은 표시하지 않음
- 포스트 상세/목록 공유 버튼(`data-post-share-toggle`)은 각 글의 `data-share-title/description/image/url` 메타데이터를 공통 공유 모달(`data-share-modal`)에 전달해 프리뷰 카드와 공유 액션(X, 주소 복사)을 렌더링함. 모달 닫기(배경/버튼/ESC)와 body scroll lock을 지원하며, 주소 복사 성공 시 버튼은 잠깐 검정 배경/흰 텍스트 상태로 바뀜
- 포스트 본문은 `prose prose-theme` 클래스를 사용하며, Typography 플러그인 대신 Tailwind 입력 파일에서 원본 기준 타이포그래피 규칙을 직접 제공함
- 포스트 댓글 영역은 `comments` 활성 여부를 우선 기준으로 노출하며, 활성 시 로그인 멤버는 댓글 0개 상태에서도 `{{comments}}` 입력 UI를 사용함
- 본문 `ul`, `ol`은 전역 리스트 리셋과 별개로 `prose` 범위 안에서 실제 마커와 들여쓰기를 다시 적용함
- 태그 배지는 `--color-accent` 기반 배경 혼합색(`bg-accent/10`, `hover:bg-accent/5`)을 사용함
- 홈 Latest, 기본 index, 태그 아카이브, 작성자 아카이브의 목록 영역은 `Load More` 버튼 기반 확장형 페이지네이션을 사용함
- `body``tag-hash-ld`가 있는 페이지는 좌/우 사이드바를 숨기고, `.site-shell`을 단일 블록(최대 폭 720px)으로 전환함
- `tag-hash-ld` 페이지에서는 `topbar`도 숨겨 랜딩 본문만 노출함
- `tag-hash-ld` 페이지에서는 `.post-header`를 숨기고 페이지 본문만 노출함
## 주요 스타일 방향
- 밝은 크림톤 배경 + 오렌지 포인트

View File

@@ -1,5 +1,275 @@
# 업데이트 로그
## v1.0.5 - 2026-04-27
- `package.json` 버전을 `1.0.5`로 증가.
- `partials/recommendations.hbs`: Recommendations 목록 링크/타이틀/파비콘 바인딩을 `{{this.*}}` 명시 방식으로 수정해 모바일에서 홈으로 이동하던 잘못된 링크 해석을 보정.
- `docs/spec.md`, `docs/map.md`, `docs/deploy.md`, `docs/history.md`, `docs/convention.md` 현재 버전을 `v1.0.5`로 동기화.
## v1.0.4 - 2026-04-27
- `package.json` 버전을 `1.0.4`로 증가.
- `assets/built/theme.js`: 로컬 저장 테마가 없을 때 기본 다크 모드를 적용하도록 초기 분기 추가.
- `partials/recommendations.hbs`: Ghost 테마 경고 대응을 위해 `{{#each}}``{{#foreach}}`로 변경.
- `partials/site/topbar.hbs`: 검색 데이터 소스 `{{#get "posts"}}` 조회 제한을 `150`에서 `100`으로 조정.
- `partials/site/sidebar-right.hbs`: 포스트 상세 작성자 아바타 `img_url`에서 `size="s"`를 제거해 원본 경로 이미지가 렌더링되도록 수정.
- `partials/site/sidebar-right.hbs`: 작성자 아바타를 `w-12 h-12` 고정(`48x48`) 및 `shrink-0`로 조정해 텍스트 높이에 따른 이미지 확장을 방지.
- `docs/spec.md`, `docs/map.md`, `docs/deploy.md`, `docs/history.md`, `docs/convention.md` 현재 버전을 `v1.0.4`로 동기화.
## v1.0.1 - 2026-04-27
- `assets/built/theme.js`: 로컬 저장된 테마 값이 없을 때 기본 테마를 다크 모드로 적용하도록 초기 분기 추가.
- `partials/site/sidebar-right.hbs`: 포스트 상세 작성자 아바타를 `w-12 h-12` 고정 크기(`48x48`)와 `shrink-0`로 조정해 본문 높이에 따라 이미지 높이가 늘어나지 않도록 수정.
- `partials/recommendations.hbs`: Ghost 테마 경고 대응을 위해 `{{#each}}``{{#foreach}}`로 변경.
- `partials/site/topbar.hbs`: 검색 데이터 소스 `{{#get "posts"}}` 조회 제한을 `150`에서 `100`으로 조정.
- `partials/site/sidebar-right.hbs`: 포스트 상세 작성자 아바타 `img_url`에서 `size="s"`를 제거해 원본 경로 이미지가 렌더링되도록 수정.
- `package.json` 버전을 `1.0.1`로 증가.
- `partials/site/sidebar-right.hbs`: 포스트 상세 우측 작성자 프로필 이미지 URL을 절대 경로로 렌더링하도록 수정하고 작성자 링크를 연결.
- `author.hbs`, `partials/site/sidebar-left.hbs`, `partials/site/topbar.hbs`: 작성자 프로필 이미지 URL을 절대 경로 기준으로 통일.
- `partials/site/topbar.hbs`: 상단 브랜드 링크를 모바일 화면에서만 노출하도록 수정.
- `docs/map.md`, `docs/deploy.md`: 기존 로컬 절대 경로 링크를 상대 경로로 정리.
- `docs/spec.md`, `docs/map.md`, `docs/deploy.md`, `docs/history.md`, `docs/convention.md` 현재 버전을 `v1.0.1`로 동기화.
## v1.0.0 - 2026-04-17
- `package.json`: 테마 패키지명을 `zenless-column-flow`로 변경하고 버전을 `1.0.0`으로 상향.
- `package.json`: 설명을 `Zenless Column Flow` 기준으로 갱신하고 zip 파일명 규칙을 `ZCF-v$npm_package_version.zip`으로 변경.
- `scripts/sync-theme.sh`, `docker-compose.yml`: 로컬 Ghost 테마 동기화 경로를 `zenless-column-flow`로 변경.
- `scripts/build-sample-content.js`, `seed/README.md`, `README.md`: 샘플 콘텐츠 파일명/문서 표기를 `zcf-sample-content` 및 새 테마명으로 갱신.
- `docs/spec.md`, `docs/map.md`, `docs/deploy.md`, `docs/history.md`: 현재 버전과 테마명/버전 규칙을 `v1.0.0`, `ZCF-v1.x.x` 기준으로 동기화.
- `partials/site/sidebar-left.hbs`: Primary navigation 항목이 없으면 `Home pages` 그룹 자체가 렌더되지 않도록 `{{#if @site.navigation}}` 분기 추가.
- `package.json`, `partials/site/sidebar-left.hbs`: 사이드바 네비게이션 라벨을 `@custom.primary_nav_label`, `@custom.secondary_nav_label`로 어드민에서 입력 가능하도록 확장하고, 미입력 시 `Primary`/`Secondary` fallback을 사용.
## v0.2.11 - 2026-04-17
- `package.json` 버전을 `0.2.11`로 증가.
- `assets/built/screen.css`: `topbar__inner` 그리드를 `minmax(0, var(--content-column))` 중앙 열로 복구해 데스크톱 3열 배치를 안정화. `minmax(260px, min(720px, 1fr))`·검색 트리거 강제 `min-width` 제거. 브랜드 `overflow: hidden` 제거 및 로고 `max-width: 100%` 보정. `1023px` 이하에서는 `topbar__inner``minmax(0,1fr) auto` 2열로 두고 검색 숨김 시 액션을 그리드 2열에 배치(절대 위치 제거). `1024px` 이상에서만 `.topbar__search``min-width: min(--topbar-search-min, 100%)` 적용.
- `post.hbs`, `partials/lists/post-items.hbs`, `assets/built/theme.js`: 공유 버튼(`data-post-share-toggle`)에 실제 URL/제목 데이터를 연결하고, 클릭 시 Web Share API 우선 호출 후 미지원 환경에서는 링크 복사 fallback이 동작하도록 보정.
- `default.hbs`, `post.hbs`, `partials/lists/post-items.hbs`, `assets/built/theme.js`, `assets/built/screen.css`: 포스트 공유를 커스텀 공유 모달(`share-modal`) 방식으로 전환. 공유 버튼 클릭 시 공통 모달에서 X와 주소 복사만 제공하고, 카드·포스트 데이터(`data-share-title/description/image/url`)를 모달 프리뷰와 공유 URL에 동기화.
- `default.hbs`, `assets/built/theme.js`, `assets/built/screen.css`: 공유 모달 액션 아이콘을 SVG로 통일하고, 주소 복사 성공 시 버튼을 잠깐 검정 배경/흰 텍스트(`is-success`)로 표시해 피드백을 강화.
- `docs/spec.md`, `docs/map.md`, `docs/deploy.md` 현재 버전을 `v0.2.11`로 동기화.
## v0.2.10 - 2026-04-17
- `package.json` 버전을 `0.2.10`로 증가.
- `assets/built/screen.css`: `topbar__inner` 가운데 열을 `minmax(--topbar-search-min, min(--content-column, 1fr))`로 두고 `--topbar-search-min`(260px)을 도입해 좁은 뷰에서 검색 트리거가 과도하게 압축되지 않도록 함. 헤더 브랜드 영역 말줄임·검색 라벨 `.search-trigger__label`·단축키 `flex-shrink: 0` 등 보조 스타일 추가.
- `partials/site/topbar.hbs`: 검색 래퍼(`topbar-search`)·트리거 `min-w-0`, 라벨/단축키 `shrink-0`·`search-trigger__label` 마크업으로 검색창·단축키 배치 안정화. 브랜드 링크에 `min-w-0 max-w-full` 적용.
- `docs/spec.md`, `docs/map.md`, `docs/deploy.md` 현재 버전을 `v0.2.10`로 동기화.
- `npm run build:tailwind``assets/built/tailwind.css` 재생성.
## v0.2.9 - 2026-04-17
- `package.json` 버전을 `0.2.9`로 증가.
- `partials/lists/post-items.hbs`: 목록 카드 요약 `<p>``flex-1`·`min-h` 제거, `justify-between` 제거, 요약이 있을 때만 메타 블록에 `mt-auto`를 적용해 좁은 뷰에서 요약 영역이 빈 높이를 먹지 않도록 조정. 요약 없을 때는 `<p>` 미렌더.
- `docs/spec.md`, `docs/map.md`, `docs/deploy.md` 현재 버전을 `v0.2.9`로 동기화.
## v0.2.8 - 2026-04-17
- `package.json` 버전을 `0.2.8`로 증가.
- `partials/site/topbar.hbs`: 비로그인 시 사용자 드롭다운 상단 프로필 행(아바타·이름)을 렌더하지 않도록 `{{#if @member}}`로 분리.
- `docs/spec.md`, `docs/map.md`, `docs/deploy.md` 현재 버전을 `v0.2.8`로 동기화.
## v0.2.7 - 2026-04-17
- `package.json` 버전을 `0.2.7`로 증가.
- `partials/site/sidebar-left.hbs`: `@site.secondary_navigation`이 있을 때 `Home pages` 아래에 `More links` 아코디언 그룹을 추가하고 `{{navigation type="secondary"}}`로 Secondary 링크를 노출.
- `partials/home/hero.hbs`: 로그인 멤버(`@member`)일 때 홈 Hero 구독 폼을 숨기도록 조건 처리.
- `partials/site/topbar.hbs`: 멤버 아바타 미등록 시 사용자 아이콘(`icon-user-circle`)을 노출하고, Gravatar 외부 링크를 제거.
- `partials/site/topbar.hbs`: 이름 비어있을 때 기본 이름(`Member`)을 표시하도록 보정.
- `assets/built/theme.js`: 사용자 메뉴 오픈/포털 진입/포커스 복귀 시 `/members/api/member/``no-store`로 재조회하고 `member`/`members[0]` 응답을 모두 처리해 닉네임 변경이 새로고침 없이 반영되도록 보정.
- `partials/site/topbar.hbs`: 검색 모달 전용 데이터 소스(`data-search-source`)를 추가해 포스트/태그/작성자 전체 목록을 검색 대상으로 통합.
- `assets/built/theme.js`: 검색 로직을 섹션형 결과(`Authors`, `Tags`, `Posts`) 렌더링으로 교체하고 포스트 제목/요약 검색을 지원.
- `assets/built/screen.css`: 검색 모달 레이아웃을 단일 패널 구조로 정리하고 섹션/아이템 스타일을 추가.
- `partials/site/topbar.hbs`, `assets/built/theme.js`: 검색창 좌측 `X` 버튼을 모달 닫기 대신 입력 초기화 동작으로 변경.
- `assets/built/screen.css`: 검색 input의 기본 우측 cancel(`::-webkit-search-cancel-button`)을 숨김 처리.
- `assets/built/screen.css`: `search-result__excerpt`를 한 줄 말줄임(`text-overflow: ellipsis`)으로 변경.
- `partials/site/topbar.hbs`: 로그인 여부와 무관하게 사용자 버튼/메뉴 아바타를 `icon-user-circle` 아이콘으로 고정하고 `@member.avatar_image` 렌더링 분기를 제거.
- `default.hbs`: `@site.accent_color`가 있으면 `--accent`, `--accent-strong` CSS 변수를 런타임으로 덮어쓰도록 적용.
- `partials/site/topbar.hbs`: `@site.logo`가 있을 때 헤더 브랜드에 로고 이미지를 우선 표시하고, 없으면 사이트 제목 텍스트를 표시.
- `partials/home/hero.hbs`: `@site.cover_image`가 있을 때 홈 Hero 배경으로 커버 이미지를 적용하고 텍스트 대비를 보정.
- `partials/site/topbar.hbs`, `assets/built/theme.js`: 로그인 멤버 아바타를 댓글 UI와 유사한 형태(이니셜 배경 + 이미지 오버레이)로 표시하고, 이름/이메일 기반 초기문자·배경색을 동기화.
- `partials/site/topbar.hbs`, `assets/built/theme.js`: 멤버 아바타 초기문자를 템플릿 시드(`@member.name` 또는 `@member.email`)로 먼저 렌더해 `M` 고정 표시가 남지 않도록 보정.
- `partials/home/hero.hbs`: 홈 Hero를 커버 전용 영역으로 정리해 `@site.cover_image`가 있을 때만 렌더링하고, 하드코딩 텍스트/구독 폼을 제거.
- `partials/home/hero.hbs`, `assets/built/screen.css`, `assets/built/theme.js`: 홈 Hero 커버 이미지 로딩 중 그라데이션 대신 스켈레톤(shimmer) 애니메이션을 표시하고, 이미지 로드 완료 시 페이드 전환되도록 처리.
- `partials/site/topbar.hbs`, `assets/built/theme.js`: 로그인 상태 사용자 버튼/팝업 아바타 뒤 배경색을 제거(transparent)하고, 비로그인 사용자 아이콘 크기를 버튼/팝업 영역에 맞게 조정.
- `partials/site/topbar.hbs`: 비로그인 사용자 아이콘의 내부 `svg`를 부모 크기(`size-*`)에 맞춰 렌더링하도록 보정해 24px 고정으로 작게 보이던 문제를 수정.
- `partials/site/topbar.hbs`: 비로그인 사용자 아이콘 크기 규칙을 `w/h-full + svg 62%` 고정 비율로 통일해 팝업 아바타 영역(32/40px)에서 위치·크기 불균형이 생기지 않도록 조정.
- `docs/spec.md`, `docs/map.md`, `docs/deploy.md` 현재 버전을 `v0.2.7`로 동기화.
- `docs/history.md`에 Secondary 네비 사이드바 노출(`v0.2.7`) 기록.
## v0.2.6 - 2026-04-17
- `package.json` 버전을 `0.2.6`으로 증가.
- 홈 Categories 좌측 색 막대: `::before`+`before:bg-accent` 제거, `tailwind.css``.home-categories__row``border-left: 3px solid var(--color-accent)` 적용(가상 요소 `content` 누락으로 색이 안 보이던 문제 해소).
- `docs/spec.md`, `docs/map.md`, `docs/deploy.md` 현재 버전을 `v0.2.6`으로 동기화.
- `docs/history.md`에 Categories 액센트 표현 변경(`v0.2.6`) 기록.
## v0.2.5 - 2026-04-17
- `package.json` 버전을 `0.2.5`로 증가.
- `partials/lists/home-categories.hbs` 수정: 좌측 액센트 바는 태그 `accent_color`(없으면 중립 회색)로 `--color-accent` 고정, 설명 없을 때는 문구 없음, 2열 그리드(2fr/3fr)로 좌우 간격 통일, 태그별 글은 `get posts` `limit="5"` 유지.
- `docs/spec.md`, `docs/deploy.md` 현재 버전을 `v0.2.5`로 동기화.
- `docs/history.md`에 홈 Categories 보정(`v0.2.5`) 의사결정 이력 추가.
## v0.2.4 - 2026-04-17
- `package.json` 버전을 `0.2.4`로 증가.
- `partials/lists/home-categories.hbs` 추가: 홈 Latest 아래 Categories 섹션(태그당 최신 글 5개 텍스트 링크, 최대 10태그, `data-category-priority-order` 정렬).
- `partials/lists/post-feed.hbs`에서 Latest 이후 `home-categories` partial 포함.
- `npm run build:tailwind``assets/built/tailwind.css` 재생성.
- `docs/spec.md`, `docs/map.md`, `docs/deploy.md` 현재 버전을 `v0.2.4`로 동기화.
- `docs/history.md`에 홈 Categories 섹션 추가(`v0.2.4`) 의사결정 이력 추가.
## v0.2.3 - 2026-04-16
- `package.json` 버전을 `0.2.3`으로 증가.
- `partials/lists/post-feed-archive.hbs` 추가: 태그/작성자 아카이브용 글 목록 + 페이지네이션만(홈용 Featured·Latest 제외).
- `tag.hbs`, `author.hbs`에서 홈 전용 `post-feed` 대신 `post-feed-archive`를 사용하도록 변경.
- `docs/spec.md`, `docs/map.md`, `docs/deploy.md` 현재 버전을 `v0.2.3`으로 동기화.
- `docs/history.md`에 아카이브 피드 분리(`v0.2.3`) 의사결정 이력 추가.
## v0.2.2 - 2026-04-16
- `package.json` 버전을 `0.2.2`로 증가.
- `tag.hbs` 태그 제목/설명(`name`, `description`)이 항상 렌더링되도록 `{{#tag}}` 컨텍스트를 명시 적용.
## v0.2.1 - 2026-04-16
- `package.json` 버전을 `0.2.1`로 증가.
- `routes.yaml.example`에서 `/authors/` 커스텀 라우트를 제거.
- 작성자 디렉터리 템플릿(`authors-index.hbs`, `page-authors.hbs`)을 제거해 개인용 블로그 구성에 맞게 정리.
- `npm run dev`, `npm run dev:ghost:start` 실행 시 로컬 메일 확인용 Mailpit 주소(`http://localhost:8025`, `localhost:1025`)를 함께 출력하도록 개선.
- `docs/spec.md`, `docs/map.md`, `docs/deploy.md` 현재 버전을 `v0.2.1`로 동기화하고 작성자 디렉터리 관련 내용을 제거.
- `docs/history.md`에 작성자 디렉터리 제거 의사결정 이력 추가.
## v0.1.43 - 2026-04-16
- `package.json` 버전을 `0.1.43`으로 증가.
- `docs/spec.md`, `docs/map.md`, `docs/deploy.md` 현재 버전을 `v0.1.43`으로 동기화.
- `docs/history.md``v0.1.43` 의사결정 이력(`Read next` 태그 우선화) 추가.
- `partials/site/sidebar-right.hbs` `Read next` 목록을 현재 글 `primary_tag` 기준 관련 글 우선 노출로 변경하고, 결과가 없으면 최신 글(현재 글 제외)로 fallback 처리.
- `post.hbs` 댓글 영역 분기를 `comments` 활성 여부 우선으로 재조정하고, 로그인 멤버에서 댓글 비활성화 시 전용 안내 문구를 노출하도록 수정.
- `docker-compose.yml`에 Mailpit 컨테이너를 추가하고 Ghost 메일 전송 설정(`mail__*`)을 로컬 SMTP로 연결.
- 로컬 멤버 테스트 계정 10개(`localmember01@example.com`~`localmember10@example.com`)를 Ghost DB에 생성.
## v0.1.42 - 2026-04-16
- 좌측 카테고리 목록에 slug 기반 우선순위 정렬 기능 추가.
- 우선순위에 없는 태그는 게시물 수 내림차순 기본 순서를 유지하도록 정리.
- 카테고리 노출 제한을 우선순위 재정렬 이후 `11개`로 적용하도록 수정.
- `partials/site/topbar.hbs` 유저 메뉴 토글 스위치에 기준 마크업과 동일한 트랙/썸 전환 클래스를 보강해 활성 상태 대비를 개선.
- `post.hbs` 상단 메타 구분자(`/`)를 마지막 항목 제외 패턴으로 보정하고, 댓글 수를 항상 `0`으로 표시하도록 수정.
- 랜딩 샘플 템플릿(`default-landing`, `custom-landing`, `page-landing-page`) 및 관련 문서 항목 제거.
- `assets/built/screen.css` `tag-hash-ld` 기반 레이아웃 전환 규칙 제거.
- `assets/built/theme.js` 유저 메뉴 토글 트랙 배경을 활성 시 `--accent`, 비활성 시 `--border`로 명시 지정.
- `partials/site/topbar.hbs`, `assets/built/screen.css` 좌측 사이드바 토글 버튼을 이미지 2개 방식에서 상태/hover별 아이콘 4개 전환 방식으로 교체.
- `post.hbs` 댓글 비활성 안내 블록 텍스트/링크 색상을 `text-typ`, `text-typ-tone`, `text-accent` 기준으로 정리해 다크모드 대비를 보정.
- `assets/built/screen.css` `tag-hash-ld` 페이지에서 좌우 사이드바를 숨기고 `.site-shell`을 단일 블록/최대폭 720px으로 전환하도록 수정.
- `assets/built/screen.css` `tag-hash-ld` 페이지에서 `topbar`를 숨기도록 수정.
- `assets/built/screen.css` `tag-hash-ld` 페이지에서 `.post-header`를 숨기도록 수정.
- `page.hbs` 페이지 본문 래퍼의 상하 마진(`mt-6`, `mb-8`)을 제거.
- `partials/site/topbar.hbs`, `assets/built/screen.css` 좌측 사이드바 토글 아이콘 색상을 `--text`로 통일하고 hover 배경 변경을 제거.
- `post.hbs` 댓글 비활성 안내 영역의 `Sign up now` 버튼/`Sign in` 링크 색상을 브랜드 계열(`bg-orange-600`, `text-orange-600`)로 보정해 라이트 모드 가독성을 복구.
- `post.hbs` 댓글 영역 분기 조건을 `comments`에서 `@member`로 변경해 로그인 멤버는 댓글 0개 상태에서도 입력 UI가 노출되도록 수정.
## v0.1.41 - 2026-04-16
- `package.json` 버전을 `0.1.41`로 증가.
- `docs/spec.md`, `docs/map.md`, `docs/deploy.md` 현재 버전을 `v0.1.41`로 동기화.
- `docs/history.md``v0.1.41` 의사결정 이력(Portal 문구 치환 안정화) 추가.
- `assets/built/theme.js` Portal 문구 치환 로직을 메인 문서+iframe 탐색 기반 재시도 방식으로 보정하고, 상시 `MutationObserver`를 제거해 지연 가능성을 완화.
## v0.1.40 - 2026-04-16
- 문서 버전 표기를 `v0.1.40`으로 정합성 수정.
- `docs/history.md` 의사결정 이력 최신 버전 항목 추가.
- 홈 히어로/탭 피드/태그 카드 원본 기준 보정 작업 반영 및 형식 정리.
- `partials/site/sidebar-left.hbs` Authors 섹션을 원본 구조 기준 아코디언/hover 인터랙션 형태로 수정.
- `partials/site/sidebar-left.hbs` Authors 헤더 양식을 Categories와 동일한 `sidebar-card` 패턴으로 통일하고, 썸네일/등급 텍스트/hover 점 전환 동작을 보정.
- `partials/site/sidebar-left.hbs` Authors 보조 텍스트를 `role` 우선 표시로 수정하고, 하단 네비게이션 닫힘 태그 오류(`</footer>``</nav>`)와 좌측 사이드바 하단 고정 레이아웃을 보정.
- `partials/site/sidebar-left.hbs` Authors hover 점 아이콘이 배경에 묻히던 문제를 대비 색/위치/전환값으로 보정해 항상 보이도록 수정.
- `partials/site/sidebar-left.hbs` Authors 보조 텍스트에서 `location` 노출을 제거하고 `role`만 표시하도록 수정.
- `partials/site/sidebar-left.hbs` Authors hover 점 아이콘 색상을 Tailwind 유틸리티 대신 `--text` 기반 `color-mix` 인라인 값으로 고정해 테마 색상과 충돌 없이 표시되도록 수정.
- `partials/site/sidebar-left.hbs` Authors hover 배경색을 `#f5f5f5`로 조정하고 hover 점 아이콘 대비를 더 진하게 보정.
- `partials/site/sidebar-left.hbs` Authors hover 점 아이콘 색상을 브랜드 액센트(`--accent`) 고정으로 변경.
- `partials/site/topbar.hbs` 우측 모바일 검색 버튼을 계정 버튼으로 교체하고, 로그인 시 멤버 아바타/비로그인 시 기본 유저 아이콘 노출로 수정.
- `partials/site/topbar.hbs` 유저 아이콘 클릭 시 열리는 사용자 메뉴 모달(계정/회원 액션, 다크모드 토글, 메뉴 열림 토글) 추가.
- `partials/site/topbar.hbs` 유저 메뉴가 아이콘 내부 SVG 클릭 시 즉시 닫히던 조건을 `closest()` 기반으로 수정하고, 데스크톱에서도 보이도록 유저 버튼 클래스를 분리.
- `partials/site/topbar.hbs`, `assets/built/theme.js` 유저 메뉴 모달 토글을 Alpine 의존에서 공통 스크립트 기반으로 전환해 클릭/외부 클릭 열고닫기와 스위치 상태 동기화를 안정화.
- `partials/site/topbar.hbs`, `assets/built/theme.js` 유저 메뉴 스위치 클릭 시 모달이 닫히지 않도록 이벤트 전파를 차단하고, 다크모드를 공통 `setTheme()` 직접 호출 방식으로 보정.
- `assets/built/screen.css`, `assets/built/theme.js` 데스크톱에서 사이드바는 화면 고정/중앙 본문만 스크롤되도록 레이아웃을 조정하고, 사이드바 영역 휠 입력을 중앙 본문 스크롤로 전달하도록 수정.
- `home.hbs`, `partials/lists/post-feed.hbs` 홈 피드 구조를 탭형에서 Featured 슬라이드 + Latest 리스트 구성으로 전환.
- `assets/built/theme.js` Featured 슬라이드 좌/우 버튼 스크롤 제어 및 비활성 상태 동기화 로직 추가.
- `docs/spec.md`, `docs/map.md`, `docs/history.md` 홈 피드 구조 변경 기준으로 문서 정합성 갱신.
- `partials/lists/post-feed.hbs` Featured 카드에서 썸네일 미존재 시 텍스트를 검은색으로 보정하고, 썸네일 카드와 동일한 라디우스/테두리 형태로 통일.
- `assets/built/screen.css` Featured 가로 슬라이드 스크롤바 비노출 처리 추가.
- `partials/site/sidebar-right.hbs` 우측 사이드바를 세로 플렉스 구조로 보정해 하단 footer가 화면 하단에 고정되도록 수정.
- `assets/built/screen.css` 좌측 Home pages 하위 링크의 배지/hover 동작을 원본처럼 사각 배지→원형 전환 및 현재 페이지 accent 표시로 보정.
- `assets/built/screen.css`, `assets/built/theme.js` 데스크톱 스크롤을 본문 전용 내부 스크롤에서 문서 스크롤로 전환해 스크롤바가 브라우저 우측에 표시되도록 정리.
- `assets/built/screen.css` 현재 활성화된 좌측 하위 메뉴 hover 시 배지가 회색으로 바뀌지 않고 accent 색상을 유지하도록 우선순위 보정.
- `assets/built/screen.css` 그리드 컨테이너 `align-items`를 보정해 사이드바 sticky 고정을 안정화하고, 스크롤 시 사이드바 동반 이동/푸터 이탈 현상을 완화.
- `assets/built/screen.css` `h-full` 클래스 우선순위로 깨지던 사이드바 뷰포트 고정을 `sidebar.sidebar--*` 선택자와 높이 보정으로 수정해 footer/nav가 화면 하단에 유지되도록 보정.
- `assets/built/screen.css` 하위 메뉴 hover 비활성 선택자에서 `a:not(.nav-current)` 조건을 제거해 활성 항목 hover 시 accent 배지가 회색으로 덮이지 않도록 수정.
- `partials/lists/post-items.hbs` 본문 요약 유무와 길이에 관계없이 카드 콘텐츠 높이가 균일하도록 콘텐츠 영역 높이/요약 최소 높이를 보정.
- `partials/site/sidebar-right.hbs` Recommended 섹션을 featured 글 목록에서 Ghost `recommendations` 데이터 기반 외부 링크 목록으로 전환하고, 항목별 favicon/타이틀 렌더링을 추가.
- `docs/spec.md`, `docs/map.md` 추천 섹션 데이터 소스 변경 기준으로 문서 정합성 갱신.
- `partials/site/sidebar-right.hbs` Recommended 목록 렌더링을 `{{get}}` 호출 대신 `data-recommendations` 기반 Portal 자동 주입 방식으로 전환.
- `partials/site/sidebar-right.hbs`, `partials/recommendations.hbs` Recommended를 공식 `{{recommendations}}` 헬퍼 기반 렌더링으로 전환하고 커스텀 recommendations partial을 추가.
- `partials/lists/post-items.hbs` 메타 구분자(`/`)를 직접 자식 선택자 기반으로 보정해 마지막 항목 뒤에는 표시되지 않도록 수정.
- `partials/recommendations.hbs` 추천 favicon 크기/라디우스를 원본 기준(`18px`, `8px`)으로 보정.
- `partials/site/sidebar-right.hbs`, `assets/built/theme.js` Portal Recommendations 모달이 열릴 때 제목/설명을 버튼 데이터 속성(`발견하기`) 기준으로 치환하도록 보정.
- `assets/built/theme.js` Portal 문구 치환 로직을 메인 문서+iframe 탐색 기반 재시도 방식으로 보정하고, 상시 `MutationObserver`를 제거해 지연 가능성을 완화.
## v0.1.39 - 2026-04-16
- `tags-index.hbs` 태그 카드 목록 마크업을 원본(Thred) 구조 기준으로 변환 수정.
- `tags-index.hbs` 태그 카드 좌측 보더/호버 배경이 각 태그 `accent_color`를 항상 사용하도록 수정.
- `tags-index.hbs` 태그 카드 호버 시 보더 색 변경을 제거하고 내부 배경만 `accent_color` 25%로 보이도록 수정.
- `tags-index.hbs` 태그 카드 좌측 보더 색을 인라인 `border-left-color`로 고정해 클래스 우선순위 영향 없이 항상 액센트 색이 표시되도록 수정.
- `tags-index.hbs`에서 `accent_color` 미지정 태그는 컬러 fallback을 제거해 좌측 포인트 컬러가 표시되지 않도록 수정.
- `partials/home/hero.hbs` 기존 히어로 섹션을 제거하고 원본 구조 기반 수동 구독 폼으로 교체, 미지원 클래스/Alpine 이벤트를 Ghost 동작 기준으로 정리.
- `partials/home/tabbed-feed.hbs` 탭/모바일 피드 선택 UI를 원본 구조 기준 Alpine 마크업으로 재구성하고, 피드/카테고리 데이터 바인딩은 기존 Ghost 헬퍼 흐름으로 유지.
## v0.1.38 - 2026-04-14
- `npm run zip:version` 압축 스크립트 추가.
- 현재 버전 기준 업로드용 zip 생성 흐름 정리.
## v0.1.37 - 2026-04-14
- 목록 페이지네이션을 링크 이동형에서 `Load More` 확장형으로 변경.
- 다음 페이지 HTML에서 목록만 파싱해 이어 붙이는 클라이언트 로직 추가.
- 다음 페이지가 없을 때 하단 버튼이 노출되지 않도록 수정.
## v0.1.36 - 2026-04-14
- 로컬 Ghost `routes.yaml``/tags/`, `/authors/` 커스텀 라우트 추가.
- 좌측 네비게이션 `Tags`, `Authors` 링크가 각각 `tags-index.hbs`, `authors-index.hbs`를 타도록 로컬 환경 정리.
- `tag.hbs`는 개별 태그 아카이브, `/tags/`는 태그 목록 인덱스 역할로 구분 정리.
## v0.1.35 - 2026-04-14
- `author.hbs` 작성자 아바타 크기와 fallback 표시 수정.
- `tag.hbs`의 불필요한 `{{#tag}}` 블록 제거.
- 태그 아카이브 페이지 `pagination` 컨텍스트 오류 수정.
## v0.1.34 - 2026-04-14
- 우측 사이드바 마크업을 훅 클래스 유지형 Tailwind 구조로 다시 정리.
- 작성자 소개, 사이트 소개, 추천 목록, 구독 폼, 푸터 영역에 Tailwind 보조 클래스 재적용.
## v0.1.33 - 2026-04-14
- 좌측 사이드바 데스크톱 닫힘 상태를 `display:none` 대신 폭/투명도 전환으로 수정.
- 좌측 사이드바 열기·닫기 애니메이션 복구.
## v0.1.32 - 2026-04-14
- 공통 셸 레이아웃 훅 클래스를 유지한 채 Tailwind 보조 클래스 재적용.
- 헤더 `1296px` 셸 정렬과 중앙 검색바 배치 복구 방향 정리.
- 좌우 사이드바 폭 계산과 토글 동작을 깨지 않는 하이브리드 마크업으로 수정.
## v0.1.31 - 2026-04-14
- `npm run dev:watch` 스크립트 추가.
- 로컬 파일 변경 감지 후 자동 `dev:sync` 반영 흐름 추가.
- Tailwind `--watch`와 테마 sync를 함께 사용하는 로컬 확인 루프 정리.
## v0.1.30 - 2026-04-14
- `prose` 본문 타이포그래피 스타일 추가.
- `ul`, `ol`, `blockquote`, `code`, `table` 본문 표현 복구.
- `bg-accent/10`, `hover:bg-accent/5`, `divide-brd` 유틸리티 추가.
- 리스트형 `post-card` 하드코딩 클래스 정리.
- 리스트 메타 구분 슬래시 중복 출력 수정.
## v0.1.29 - 2026-04-14
- Tailwind `preflight` 초기화 다시 활성화.
- `assets/styles/tailwind.css`에 base 레이어 추가.
- `html`, `body` 기본 폰트/색상 초기값 정리.
## v0.1.28 - 2026-04-14
- 포스트 상세 헤더를 Tailwind 기준 마크업으로 재정리.
- 포스트 상단 중복 태그 표시 제거.
- 포스트 메타 행, 댓글 아이콘, 우측 공유 아이콘 위치 수정.
- 대표 이미지 비율과 상단 간격을 원본 기준으로 보정.
## v0.1.27 - 2026-04-14
- 리스트형 `post-card` 항목 구분선 복구.
- 카드 콘텐츠 영역 `min-w-0` 적용으로 제목/본문 줄바꿈 폭 보정.
- 카드 정렬을 `items-start` 기준으로 재정리.
## v0.1.26 - 2026-04-14
- 리스트형 `post-card`를 Tailwind 기준 마크업으로 재구성.
- 썸네일 비율을 `aspect-square sm:aspect-video` 기준으로 수정.

View File

@@ -1,6 +1,6 @@
{{!< default}}
<main class="content-area">
<main class="">
{{> "home/hero"}}
{{> "home/tabbed-feed"}}
{{> "lists/post-feed" posts=posts}}
</main>

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "ghost-theme-thred-clone",
"version": "0.1.26",
"version": "0.1.29",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ghost-theme-thred-clone",
"version": "0.1.26",
"version": "0.1.29",
"devDependencies": {
"alpinejs": "^3.14.9",
"tailwindcss": "^3.4.17"

View File

@@ -1,8 +1,8 @@
{
"name": "ghost-theme-thred-clone",
"version": "0.1.26",
"name": "zenless-column-flow",
"version": "1.0.5",
"private": true,
"description": "A Ghost theme inspired by the Thred reference layout.",
"description": "Zenless Column Flow Ghost theme for editorial three-column blogs.",
"keywords": [
"ghost-theme",
"ghost",
@@ -71,6 +71,14 @@
"show_admin_link": {
"type": "boolean",
"default": false
},
"primary_nav_label": {
"type": "text",
"default": "Primary"
},
"secondary_nav_label": {
"type": "text",
"default": "Secondary"
}
}
},
@@ -79,13 +87,15 @@
"build:tailwind": "tailwindcss -c ./tailwind.config.js -i ./assets/styles/tailwind.css -o ./assets/built/tailwind.css --minify",
"dev": "npm run dev:ghost:start",
"dev:prepare": "npm run build:alpine && npm run build:tailwind && npm run dev:sync",
"dev:watch": "node ./scripts/dev-watch.js",
"dev:sync": "sh ./scripts/sync-theme.sh",
"dev:seed": "node ./scripts/build-sample-content.js",
"dev:seed:zip": "npm run dev:seed && cd seed && zip -q -r thred-inspired-sample-content.ghost.zip thred-inspired-sample-content.ghost.json",
"dev:ghost:start": "npm run dev:prepare && docker compose up -d && printf '\\nGhost local: http://localhost:2368\\nGhost admin: http://localhost:2368/ghost\\n\\n'",
"dev:ghost:restart": "npm run dev:prepare && docker compose restart ghost && printf '\\nGhost local: http://localhost:2368\\nGhost admin: http://localhost:2368/ghost\\n\\n'",
"dev:seed:zip": "npm run dev:seed && cd seed && zip -q -r zcf-sample-content.ghost.zip zcf-sample-content.ghost.json",
"dev:ghost:start": "npm run dev:prepare && docker compose up -d && printf '\\nGhost local: http://localhost:2368\\nGhost admin: http://localhost:2368/ghost\\nMailpit UI: http://localhost:8025\\nMailpit SMTP: localhost:1025\\n\\n'",
"dev:ghost:restart": "npm run dev:prepare && docker compose restart ghost && printf '\\nGhost local: http://localhost:2368\\nGhost admin: http://localhost:2368/ghost\\nMailpit UI: http://localhost:8025\\nMailpit SMTP: localhost:1025\\n\\n'",
"dev:ghost:stop": "docker compose down",
"zip": "zip -r theme.zip . -x '*.git*' -x 'node_modules/*' -x 'theme.zip'"
"zip": "zip -r theme.zip . -x '*.git*' -x 'node_modules/*' -x 'theme.zip'",
"zip:version": "rm -f ZCF-v$npm_package_version.zip && git ls-files | zip -q ZCF-v$npm_package_version.zip -@"
},
"devDependencies": {
"alpinejs": "^3.14.9",

View File

@@ -1,42 +0,0 @@
{{!< default}}
{{#post}}
<main class="content-area">
<section class="stack-section">
<header class="section-header text-center">
<h1 class="section-title">{{title}}</h1>
{{#if custom_excerpt}}
<p class="section-description">{{custom_excerpt}}</p>
{{else}}
<p class="section-description">Browse by author</p>
{{/if}}
</header>
{{#get "authors" limit="100" include="count.posts"}}
<div class="author-directory grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{{#foreach authors}}
<a class="author-directory__card group flex min-h-[128px] flex-col rounded-[14px] border border-[var(--border)] bg-[var(--surface)] p-4 text-left transition-colors hover:bg-[var(--surface-muted)]" href="{{url}}">
<span class="flex items-start gap-3">
{{#if profile_image}}
<img class="h-11 w-11 rounded-full object-cover" src="{{img_url profile_image size="xs"}}" alt="{{name}}">
{{else}}
<span class="flex h-11 w-11 items-center justify-center rounded-full bg-[var(--surface-muted)] text-sm font-semibold">A</span>
{{/if}}
<span class="min-w-0 flex-1">
<strong class="block text-[15px] font-semibold tracking-[-0.02em]">{{name}}</strong>
{{#if bio}}
<span class="mt-1 line-clamp-3 block text-[13px] leading-5 text-[var(--text-soft)]">{{bio}}</span>
{{else}}
<span class="mt-1 block text-[13px] leading-5 text-[var(--text-soft)]">Posts by {{name}}.</span>
{{/if}}
</span>
<img class="h-4 w-4 shrink-0 opacity-70 transition-opacity group-hover:opacity-100" src="{{asset "icons/arrow_outward.svg"}}" alt="">
</span>
<span class="mt-auto pt-3 text-[13px] font-medium text-[var(--text-soft)]">{{plural count.posts empty="0 posts" singular="% post" plural="% posts"}}</span>
</a>
{{/foreach}}
</div>
{{/get}}
</section>
</main>
{{/post}}

View File

@@ -1,7 +1,7 @@
{{!< default}}
{{#post}}
<main class="content-area content-area--post">
<main class="px-4 sm:px-[max(2vmin,20px)] mt-6 mb-8">
<article class="post-template page-template {{post_class}}">
{{#match @page.show_title_and_feature_image}}
<header class="post-header">

View File

@@ -1,15 +1,9 @@
<section class="hero home-hero" data-home-hero>
<div class="home-hero__inner">
<div class="home-hero__content">
<h1 class="hero__title">Ideas <em>published</em> for meaningful conversation, <em>discussed</em> and shaped by the community</h1>
{{#if @site.description}}
<p class="hero__description">{{@site.description}}</p>
{{/if}}
{{subscribe_form
placeholder="Your email"
button_class="button button--light button--subscribe"
form_class="subscribe-form subscribe-form--hero"
}}
</div>
</div>
</section>
{{#if @site.cover_image}}
<section
class="home-hero min-h-[216px] relative overflow-hidden"
data-home-hero=""
>
<div class="home-hero__skeleton absolute inset-0" data-home-hero-skeleton aria-hidden="true"></div>
<img class="home-hero__cover absolute inset-0 w-full h-full object-cover" src="{{@site.cover_image}}" alt="{{@site.title}}" loading="eager" fetchpriority="high" data-home-hero-image>
</section>
{{/if}}

View File

@@ -1,57 +1,97 @@
<section class="stack-section home-feed" data-tabs data-home-content>
<div class="tab-row-wrap home-feed__toolbar">
<div class="tab-row home-feed__tabs" role="tablist" aria-label="Homepage sections">
<button class="tab-row__button is-active" type="button" data-tab-trigger="latest">Latest</button>
<button class="tab-row__button" type="button" data-tab-trigger="featured">Featured</button>
<button class="tab-row__button" type="button" data-tab-trigger="updated">Updated</button>
<button class="tab-row__button" type="button" data-tab-trigger="categories">Categories</button>
<section class="px-5 sm:px-6 py-4" data-home-content x-data="{ activeFeed: '#latest', postFeedTypeOpen: false }">
<div class="max-w-content mx-auto">
<div class="flex gap-2 items-center justify-between border-b border-brd pb-2">
<ul class="hidden md:flex gap-1 text-typ-tone font-medium" data-feed-select="">
<li data-feed-select-item="latest">
<a href="#latest" class="block text-sm px-3 py-1.5 rounded-theme hover:bg-bgr-tone hover:text-typ relative" :class="activeFeed === '#latest' ? 'text-typ after:absolute after:-bottom-2 after:left-0 after:w-full after:h-0.5 after:bg-typ' : ''" @click.prevent="activeFeed = '#latest'">Latest</a>
</li>
<li data-feed-select-item="featured">
<a href="#featured" class="block text-sm px-3 py-1.5 rounded-theme hover:bg-bgr-tone hover:text-typ relative" :class="activeFeed === '#featured' ? 'text-typ after:absolute after:-bottom-2 after:left-0 after:w-full after:h-0.5 after:bg-typ' : ''" @click.prevent="activeFeed = '#featured'">Featured</a>
</li>
<li data-feed-select-item="updated">
<a href="#updated" class="block text-sm px-3 py-1.5 rounded-theme hover:bg-bgr-tone hover:text-typ relative" :class="activeFeed === '#updated' ? 'text-typ after:absolute after:-bottom-2 after:left-0 after:w-full after:h-0.5 after:bg-typ' : ''" @click.prevent="activeFeed = '#updated'">Updated</a>
</li>
<li data-feed-select-item="categories">
<a href="#categories" class="block text-sm px-3 py-1.5 rounded-theme hover:bg-bgr-tone hover:text-typ relative" :class="activeFeed === '#categories' ? 'text-typ after:absolute after:-bottom-2 after:left-0 after:w-full after:h-0.5 after:bg-typ' : ''" @click.prevent="activeFeed = '#categories'">Categories</a>
</li>
</ul>
<div class="flex flex-col gap-1 relative text-sm font-medium md:hidden">
<button class="px-3 py-1.5 pr-2 rounded-theme bg-bgr hover:bg-bgr-tone relative leading-none cursor-pointer flex gap-0.75 items-center justify-center border border-brd" @click="postFeedTypeOpen = !postFeedTypeOpen" data-feed-type-toggle="" aria-label="Select feed type">
<span class="pointer-events-none" x-show="activeFeed === '#latest'">Latest</span>
<span class="pointer-events-none" x-show="activeFeed === '#featured'">Featured</span>
<span class="pointer-events-none" x-show="activeFeed === '#updated'">Updated</span>
<span class="pointer-events-none" x-show="activeFeed === '#categories'">Categories</span>
<span class="opacity-75 pointer-events-none">
<i class="icon icon-chevron-down stroke-[2.5] size-4" role="presentation">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</i>
</span>
</button>
<menu class="flex flex-col gap-0.5 absolute top-9 left-0 border border-brd bg-bgr rounded-theme p-1.5 z-10 transition-[transform,opacity,visibility] shadow" :class="postFeedTypeOpen ? 'translate-y-0 opacity-100 visible' : '-translate-y-2 opacity-0 invisible'" @click.outside="postFeedTypeOpen = false">
<li class="w-full"><a href="#latest" class="w-full px-2 py-1 flex items-center gap-1.5 hover:bg-bgr-tone rounded-theme cursor-pointer" @click.prevent="activeFeed = '#latest'; postFeedTypeOpen = false">Latest</a></li>
<li class="w-full"><a href="#featured" class="w-full px-2 py-1 flex items-center gap-1.5 hover:bg-bgr-tone rounded-theme cursor-pointer" @click.prevent="activeFeed = '#featured'; postFeedTypeOpen = false">Featured</a></li>
<li class="w-full"><a href="#updated" class="w-full px-2 py-1 flex items-center gap-1.5 hover:bg-bgr-tone rounded-theme cursor-pointer" @click.prevent="activeFeed = '#updated'; postFeedTypeOpen = false">Updated</a></li>
<li class="w-full"><a href="#categories" class="w-full px-2 py-1 flex items-center gap-1.5 hover:bg-bgr-tone rounded-theme cursor-pointer" @click.prevent="activeFeed = '#categories'; postFeedTypeOpen = false">Categories</a></li>
</menu>
</div>
</div>
<button class="view-toggle home-feed__view-toggle" type="button" aria-label="Change view">
<span class="view-toggle__grid"></span>
<span class="view-toggle__chevron">⌄</span>
</button>
</div>
<div class="tab-panel is-active" data-tab-panel="latest">
{{> "lists/post-feed"}}
</div>
<div class="flex flex-col gap-8 mb-8" x-show="activeFeed === '#latest'" data-feed="latest">
{{> "lists/post-feed"}}
</div>
<div class="tab-panel" data-tab-panel="featured">
{{#get "posts" filter="featured:true" limit="8" include="authors,tags"}}
{{> "lists/post-items" posts=posts}}
{{/get}}
</div>
<div class="tab-panel" data-tab-panel="updated">
{{#get "posts" order="updated_at desc" limit="8" include="authors,tags"}}
{{> "lists/post-items" posts=posts}}
{{/get}}
</div>
<div class="tab-panel" data-tab-panel="categories">
<div class="category-overview">
{{#get "tags" limit="8" include="count.posts" order="count.posts desc"}}
{{#foreach tags}}
<section class="category-overview__row"{{#if accent_color}} style="--tag-accent: {{accent_color}};"{{/if}}>
<div class="category-overview__intro">
<h2>{{name}}</h2>
{{#if description}}
<p>{{description}}</p>
{{else}}
<p>{{plural count.posts empty="No posts yet" singular="% post in this category" plural="% posts in this category"}}</p>
{{/if}}
<a class="category-overview__link" href="{{url}}">View all ↗</a>
</div>
<ol class="category-overview__posts">
{{#get "posts" filter=(concat "tag:" slug) limit="5"}}
{{#foreach posts}}
<li><a href="{{url}}" data-number="{{@number}}. ">{{title}}</a></li>
{{/foreach}}
{{/get}}
</ol>
</section>
{{/foreach}}
<div class="tab-panel" x-show="activeFeed === '#featured'" data-feed="featured">
{{#get "posts" filter="featured:true" limit="8" include="authors,tags"}}
{{> "lists/post-items" posts=posts}}
{{/get}}
</div>
<div class="tab-panel **:data-post-card-date-updated:block **:data-post-card-date:hidden" x-show="activeFeed === '#updated'" data-feed="updated">
{{#get "posts" order="updated_at desc" limit="8" include="authors,tags"}}
{{> "lists/post-items" posts=posts}}
{{/get}}
</div>
<div class="tab-panel" x-show="activeFeed === '#categories'" data-feed="categories">
<ul class="flex flex-col">
{{#get "tags" limit="8" include="count.posts" order="count.posts desc"}}
{{#foreach tags}}
<li class="p-4 pl-5 sm:pl-6 sm:p-5 flex flex-wrap flex-col gap-2 md:flex-row border-b border-b-brd relative before:absolute before:left-0 before:top-0 before:w-0.75 before:h-full before:bg-accent"{{#if accent_color}} style="--color-accent:{{accent_color}}"{{/if}}>
<h2 class="font-medium basis-full leading-tight">{{name}}</h2>
<div class="flex-2 flex flex-col gap-1.5 border-b border-brd pb-3 mb-2 md:pb-0 md:border-b-0 md:mr-4 md:mb-0 items-start justify-between">
{{#if description}}
<p class="text-sm text-typ-tone line-clamp-4 text-ellipsis leading-snug">{{description}}</p>
{{else}}
<p class="text-sm text-typ-tone line-clamp-4 text-ellipsis leading-snug">{{plural count.posts empty="No posts yet" singular="% post in this category" plural="% posts in this category"}}</p>
{{/if}}
<a href="{{url}}" class="text-sm text-typ-tone font-semibold leading-snug flex items-center gap-1 hover:opacity-75 mt-0.5">
<span>View all</span>
<i class="icon icon-arrow-up-right size-3.5 stroke-3" role="presentation">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M17 7l-10 10"></path>
<path d="M8 7l9 0l0 9"></path>
</svg>
</i>
</a>
</div>
<ol class="flex-3 flex flex-col gap-1.5 text-sm text-typ-tone font-medium">
{{#get "posts" filter=(concat "tag:" slug) limit="5"}}
{{#foreach posts}}
<li>
<a href="{{url}}" data-number="{{@number}}. " class="line-clamp-1 text-ellipsis before:content-[attr(data-number)] before:font-bold leading-snug hover:opacity-75" title="{{title}}">{{title}}</a>
</li>
{{/foreach}}
{{/get}}
</ol>
</li>
{{/foreach}}
{{/get}}
</ul>
</div>
</div>
</section>

View File

@@ -0,0 +1,61 @@
{{!-- 홈 Latest 하단: 태그별 최신 글 최대 5개(텍스트 링크만). 좌측 사이드바와 동일한 data-category-priority-order + 최대 10개 --}}
<section class="px-4 sm:px-5 lg:px-6 py-4 mb-8 home-categories" data-home-categories>
<div class="max-w-content mx-auto">
<div class="flex gap-2 items-end justify-between border-b border-brd pb-3">
<h2 class="text-sm uppercase font-medium text-typ-tone">Categories</h2>
</div>
<div data-feed="categories">
{{#get "tags" limit="100" include="count.posts" order="count.posts desc"}}
<ul
class="flex flex-col"
data-category-priority-list
data-category-priority-order="tech,business,health,science,design,travel,gaming,music,diy,photography,books"
data-category-priority-limit="10"
>
{{#foreach tags}}
{{#if count.posts}}
<li
class="home-categories__row grid grid-cols-1 md:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] gap-x-6 gap-y-3 md:gap-y-2 md:items-start border-b border-b-brd p-4 sm:p-5 pl-4 sm:pl-5"
data-category-priority-item
data-category-slug="{{slug}}"
style="--color-accent: {{#if accent_color}}{{accent_color}}{{else}}#94a3b8{{/if}};"
>
<div class="home-categories__meta flex min-h-0 min-w-0 flex-col gap-1.5 border-b border-brd pb-3 md:border-b-0 md:pb-0">
<h2 class="font-medium leading-tight text-typ">{{name}}</h2>
{{#if description}}
<p class="text-sm text-typ-tone line-clamp-4 text-ellipsis leading-snug">{{description}}</p>
{{/if}}
<a href="{{url}}" class="home-categories__view-all mt-0.5 flex items-center gap-1 text-sm font-semibold leading-snug text-typ-tone hover:opacity-75">
<span>View all</span>
<i class="icon icon-arrow-up-right size-3.5 shrink-0 stroke-2" role="presentation" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M17 7l-10 10"></path>
<path d="M8 7l9 0l0 9"></path>
</svg>
</i>
</a>
</div>
<ol class="home-categories__posts flex min-h-0 min-w-0 list-none flex-col gap-1.5 text-sm font-medium text-typ-tone">
{{#get "posts" filter=(concat "tag:" slug) limit="5" include="authors,tags" order="published_at desc"}}
{{#foreach posts}}
<li>
<a
href="{{url}}"
data-number="{{@number}}. "
title="{{title}}"
class="line-clamp-1 text-ellipsis before:content-[attr(data-number)] before:font-bold leading-snug hover:opacity-75"
aria-label="{{title}}"
>{{title}}</a>
</li>
{{/foreach}}
{{/get}}
</ol>
</li>
{{/if}}
{{/foreach}}
</ul>
{{/get}}
</div>
</div>
</section>

View File

@@ -0,0 +1,9 @@
{{!-- 태그/작성자 등 아카이브: Featured·Latest 헤더 없이 글 목록 + 페이지네이션만 --}}
<section class="px-5 sm:px-6 pb-4 sm:pb-5" data-archive-post-feed>
<div class="max-w-content mx-auto flex flex-col gap-8">
<div class="flex flex-col gap-8 mb-8 mt-1" data-load-more-root>
{{> "lists/post-items" posts=posts}}
{{pagination}}
</div>
</div>
</section>

View File

@@ -1,7 +1,65 @@
{{#if show_filter}}
<div class="section-filter">
<span>Latest posts</span>
<section class="px-4 sm:px-5 lg:px-6 py-4" data-home-featured>
<div class="max-w-content mx-auto">
{{#get "posts" filter="featured:true" limit="12" include="authors,tags"}}
{{#if posts}}
<section data-featured-slider>
<div class="flex gap-2 items-end justify-between border-b border-brd pb-2">
<h2 class="text-sm uppercase font-medium text-typ-tone">Featured</h2>
<div class="flex justify-between gap-2">
<button class="cursor-pointer hover:opacity-75 disabled:opacity-40" data-featured-prev type="button">
<i class="icon icon-chevron-left size-4 stroke-2" role="presentation">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M15 6l-6 6l6 6"></path>
</svg>
</i>
<span class="sr-only">Previous</span>
</button>
<button class="cursor-pointer hover:opacity-75 disabled:opacity-40" data-featured-next type="button">
<span class="sr-only">Next</span>
<i class="icon icon-chevron-right size-4 stroke-2" role="presentation">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M9 6l6 6l-6 6"></path>
</svg>
</i>
</button>
</div>
</div>
<div class="overflow-hidden mt-4">
<div class="featured-slider flex gap-4 sm:gap-5 overflow-x-auto snap-x snap-mandatory scroll-smooth" data-featured-track style="scrollbar-width: none; -ms-overflow-style: none;">
{{#foreach posts}}
<a href="{{url}}" class="featured-slider__item relative block group shrink-0 snap-start basis-[78%] sm:basis-[58%] lg:basis-[38%]">
{{#if feature_image}}
<figure class="relative block rounded-theme overflow-hidden contrast-125 brightness-75 group-hover:contrast-110 group-hover:brightness-90 transition-all" role="none">
<img class="w-full aspect-video object-cover rounded-[inherit]" src="{{img_url feature_image size="m"}}" alt="{{title}}">
</figure>
<h2 class="text-white absolute bottom-0 left-0 w-full px-3 mb-2.5 text-sm font-medium leading-tight line-clamp-2 text-ellipsis">{{title}}</h2>
{{else}}
<figure class="relative rounded-theme overflow-hidden bg-bgr-tone aspect-video border border-brd p-3 flex items-end" role="none">
<h2 class="text-typ w-full text-sm font-medium leading-tight line-clamp-2 text-ellipsis">{{title}}</h2>
</figure>
{{/if}}
</a>
{{/foreach}}
</div>
</div>
</section>
{{/if}}
{{/get}}
</div>
{{/if}}
{{> "lists/post-items" posts=posts}}
{{pagination}}
</section>
<section class="px-4 sm:px-5 lg:px-6 py-4 " data-home-latest>
<div class="max-w-content mx-auto">
<div class="flex gap-2 items-end justify-between border-b border-brd pb-2">
<h2 class="text-sm uppercase font-medium text-typ-tone">Latest</h2>
</div>
<div class="flex flex-col gap-8 mb-8 mt-1" data-load-more-root>
{{> "lists/post-items" posts=posts}}
{{pagination}}
</div>
</div>
</section>
{{> "lists/home-categories"}}

View File

@@ -1,37 +1,37 @@
<div class="post-list home-post-list">
<div class="post-list home-post-list flex flex-col divide-y divide-brd" data-load-more-list>
{{#foreach posts}}
<article class="post {{post_class}} relative flex flex-row gap-3 text-typ py-4 group overflow-hidden" data-post-card{{#if featured}} data-featured{{/if}}>
<a href="{{url}}" data-post-card-media class="flex-1 relative group aspect-square sm:aspect-video min-w-16">
<article class="post {{post_class}} relative flex flex-row items-start gap-3 text-typ py-4 group overflow-hidden" data-post-card{{#if featured}} data-featured{{/if}}>
<a href="{{url}}" data-post-card-media class="flex-1 relative group aspect-square sm:aspect-video min-w-16 ">
{{#if feature_image}}
<figure class="block rounded-theme overflow-hidden" role="none">
<img class="w-full aspect-square sm:aspect-video object-cover rounded-[inherit] hover:opacity-90 transition-all blur-0" src="{{img_url feature_image size="s"}}" alt="{{title}}">
<figure class="block rounded-lg overflow-hidden" role="none">
<img class="w-full aspect-square sm:aspect-video object-cover hover:opacity-90 transition-all blur-0" src="{{img_url feature_image size="s"}}" alt="{{title}}">
</figure>
{{else}}
<span class="block w-full aspect-square sm:aspect-video rounded-theme bg-bgr-tone text-typ-tone text-xs text-center p-4">{{title}}</span>
<span class="block w-full aspect-square sm:aspect-video rounded-lg bg-bgr-tone text-typ-tone text-xs text-center p-4">{{title}}</span>
{{/if}}
</a>
<div class="relative flex-[3] md:flex-[4] flex flex-col gap-1.5 justify-between" data-post-card-content>
<div class="post-card-content relative min-w-0 flex-3 md:flex-4 flex flex-col gap-0.5 self-stretch min-h-0" data-post-card-content>
<h2 class="text-sm font-medium leading-tight flex flex-wrap gap-y-0.5 gap-x-1.5 items-end max-w-[90%]" data-post-card-title>
<a href="{{url}}" class="hover:opacity-75 flex items-start gap-1.5">
{{#if featured}}
<span data-post-featured class="inline-flex text-brand -mr-0.5 shrink-0">
<img class="size-4 -mt-0.5" src="{{asset "icons/bolt.svg"}}" alt="">
<span data-post-featured="" class="inline-flex text-brand -mr-0.5 [&amp;_svg]:-mt-0.5">
<i class="icon icon-bolt size-4 stroke-1 fill-current" role="presentation">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-bolt"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11"></path></svg>
</i>
</span>
{{/if}}
<span>{{title}}</span>
</a>
</h2>
{{#if excerpt}}
<p class="text-[0.8rem] text-typ-tone leading-tight line-clamp-2 text-ellipsis flex-1" data-post-card-excerpt>{{excerpt words="24"}}</p>
<p class="post-card-content__excerpt text-[0.8rem] text-typ-tone leading-tight line-clamp-2 text-ellipsis" data-post-card-excerpt>{{excerpt words="24"}}</p>
{{/if}}
<div class="flex flex-wrap gap-2 sm:gap-1.5 items-center text-xs text-typ-tone" data-post-card-info>
<div class="post-card-content__meta flex flex-wrap gap-2 sm:gap-1.5 items-center text-xs text-typ-tone [&>*:not(:last-child)]:after:content-['/'] [&>*:not(:last-child)]:after:ml-2 sm:[&>*:not(:last-child)]:after:ml-1.5 [&>*:not(:last-child)]:after:text-brd{{#if excerpt}} mt-auto{{/if}}" data-post-card-info>
<time data-post-card-date datetime="{{date format="YYYY-MM-DD"}}">{{date format="MMM D"}}</time>
{{#primary_author}}
<span class="opacity-70">/</span>
<a data-post-card-author href="{{url}}" class="hover:opacity-75">{{name}}</a>
{{/primary_author}}
{{#primary_tag}}
<span class="opacity-70">/</span>
<ul class="flex flex-wrap items-center font-medium" data-post-card-tags>
<li{{#if accent_color}} style="--color-accent: {{accent_color}};"{{/if}}>
<a href="{{url}}" class="bg-accent/10 px-1.5 py-px rounded-sm text-typ hover:bg-accent/5 hover:text-accent">{{name}}</a>
@@ -39,17 +39,19 @@
</ul>
{{/primary_tag}}
{{#unless access}}
<span class="opacity-70">/</span>
<span class="font-medium">Members</span>
{{/unless}}
<span class="opacity-70">/</span>
<a data-post-card-comments class="flex gap-0.5 items-center hover:opacity-75" href="{{url}}#ghost-comments">
<img class="size-3.5 -mt-px pointer-events-none" src="{{asset "icons/chat_bubble.svg"}}" alt="">
{{#if comments}}{{comment_count empty="0" singular="" plural="" autowrap="false"}}{{else}}0{{/if}}
<a data-post-card-comments class="flex gap-1 items-center hover:opacity-75" href="{{url}}#ghost-comments">
<i class="icon icon-comments size-3.5 stroke-2 -mt-px pointer-events-none" role="presentation">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-message-circle"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M3 20l1.3 -3.9c-2.324 -3.437 -1.426 -7.872 2.1 -10.374c3.526 -2.501 8.59 -2.296 11.845 .48c3.255 2.777 3.695 7.266 1.029 10.501c-2.666 3.235 -7.615 4.215 -11.574 2.293l-4.7 1"></path></svg>
</i>
<span class="pointer-events-none">{{#if comments}}{{comment_count empty="0" singular="" plural="" autowrap="false"}}{{else}}0{{/if}}</span>
</a>
</div>
<button data-post-share-toggle class="absolute top-0 md:top-auto md:bottom-0 right-0 flex gap-1 items-center hover:opacity-75 cursor-pointer md:opacity-0 md:invisible group-hover:opacity-100 group-hover:visible transition-[opacity,visibility]" type="button" aria-label="Share this post">
<img class="size-4 pointer-events-none" src="{{asset "icons/arrow_outward.svg"}}" alt="">
<button data-post-share-toggle data-share-url="{{url absolute="true"}}" data-share-title="{{title}}" data-share-description="{{#if excerpt}}{{excerpt words='24'}}{{else}}{{@site.description}}{{/if}}" data-share-image="{{#if feature_image}}{{img_url feature_image size='m'}}{{/if}}" class="absolute top-0 md:top-auto md:bottom-0 right-0 flex gap-1 items-center hover:opacity-75 cursor-pointer md:opacity-0 md:invisible group-hover:opacity-100 group-hover:visible transition-[opacity,visibility]" type="button" aria-label="Share this post">
<i class="icon icon-share size-4 stroke-2 pointer-events-none" role="presentation">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-share-3"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M13 4v4c-6.575 1.028 -9.02 6.788 -10 12c-.037 .206 5.384 -5.962 10 -6v4l8 -7l-8 -7"></path></svg>
</i>
</button>
</div>
</article>

View File

@@ -1,9 +1,16 @@
<nav class="pagination" role="navigation">
{{#if prev}}
<a class="pagination__link" href="{{page_url prev}}">Newer</a>
{{/if}}
<span class="pagination__status">Page {{page}} of {{pages}}</span>
{{#if next}}
<a class="pagination__link" href="{{page_url next}}">Older</a>
{{/if}}
</nav>
{{#if next}}
<div class="pagination pagination--load-more" data-load-more-pagination data-next-url="{{page_url next}}">
<button class="pagination__load-more" type="button" data-load-more-trigger>Load More</button>
</div>
{{/if}}
<noscript>
<nav class="pagination" role="navigation">
{{#if prev}}
<a class="pagination__link" href="{{page_url prev}}">Newer</a>
{{/if}}
<span class="pagination__status">Page {{page}} of {{pages}}</span>
{{#if next}}
<a class="pagination__link" href="{{page_url next}}">Older</a>
{{/if}}
</nav>
</noscript>

View File

@@ -1,14 +1,32 @@
<nav class="post-navigation">
<nav class="px-5 sm:px-6 mb-6">
<div class="max-w-content mx-auto">
<div class="grid md:grid-cols-2 gap-4 text-sm leading-tight font-medium">
{{#prev_post}}
<a class="post-navigation__link" href="{{url}}">
<small>Previous post</small>
<strong>{{title}}</strong>
<a class="flex flex-col gap-1 items-start hover:opacity-75" href="{{url}}">
<small class="uppercase font-medium text-[0.7rem] text-typ-tone opacity-75 flex items-center gap-0.75">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chevron-left" width="14" height="14" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M15 6l-6 6l6 6"></path></svg>
Previous post
</small>
<strong class="text-left flex flex-col gap-1 ml-4">{{title}}</strong>
</a>
{{/prev_post}}
{{#next_post}}
<a class="post-navigation__link post-navigation__link--next" href="{{url}}">
<small>Next post</small>
<strong>{{title}}</strong>
<a class="flex flex-col gap-1 items-end hover:opacity-75" href="{{url}}">
<small class="uppercase font-medium text-[0.7rem] text-typ-tone opacity-75 flex items-center gap-0.75">
Next post
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chevron-right" width="14" height="14" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M9 6l6 6l-6 6"></path></svg>
</small>
<strong class="text-left flex flex-col gap-1 ml-4">{{title}}</strong>
</a>
{{/next_post}}
</div>
</div>
</nav>

View File

@@ -0,0 +1,14 @@
{{#if recommendations}}
<ul class="flex flex-col gap-1 rounded-theme overflow-hidden" data-recommendations="">
{{#foreach recommendations}}
<li>
<a class="py-1 text-[0.8rem] leading-tight flex items-center gap-2 hover:opacity-75" href="{{this.url}}" data-recommendation="{{this.id}}" target="_blank" rel="noopener">
{{#if this.favicon}}
<img class="w-[18px] h-[18px] rounded-[8px] aspect-square object-cover" src="{{this.favicon}}" alt="" loading="lazy" onerror="this.style.display='none'">
{{/if}}
<h3 class="font-medium line-clamp-1 text-ellipsis overflow-hidden" title="{{this.title}}">{{this.title}}</h3>
</a>
</li>
{{/foreach}}
</ul>
{{/if}}

View File

@@ -1,11 +1,11 @@
<aside class="sidebar sidebar--left">
<div class="sidebar__inner">
<nav class="menu-groups border-b border-brd pl-4 pr-3 sm:pl-5 xl:pl-0 py-3" data-nav="menu" data-primary-nav x-data="{ homePagesOpen: true, membersOpen: false }">
<aside class="sidebar sidebar--left h-full flex flex-col">
<nav class="menu-groups border-b border-brd pl-4 pr-3 sm:pl-5 xl:pl-0 py-3" data-nav="menu" data-primary-nav x-data="{ homePagesOpen: true, secondaryNavOpen: false, membersOpen: false }">
<ul class="menu-groups__list flex flex-col gap-0.75 text-typ text-sm">
{{#if @site.navigation}}
<li class="menu-group menu-group--nav nav-toggle is-mainitem flex items-center flex-wrap w-full relative group" :class="{ 'is-open': homePagesOpen }" data-label="Home pages" data-slug="home-pages" data-length="10" aria-haspopup="true">
<a class="menu-group__link menu-group__link--toggle flex gap-2 items-center flex-1 py-1.5 rounded-theme transition-[padding]" href="#" role="button" @click.prevent="homePagesOpen = !homePagesOpen" :aria-expanded="homePagesOpen.toString()" aria-haspopup="true">
<span class="menu-link__marker w-1 h-4 rounded-sm rounded-l-none transition-all"></span>
<span class="menu-link__label">Home pages</span>
<span class="menu-link__label">{{#if @custom.primary_nav_label}}{{@custom.primary_nav_label}}{{else}}Primary{{/if}}</span>
</a>
<button class="menu-group__toggle relative cursor-pointer p-2 rounded-r-theme" type="button" title="Menu toggle" aria-label="Toggle submenu" aria-controls="home-pages" :aria-expanded="homePagesOpen.toString()" @click="homePagesOpen = !homePagesOpen" aria-haspopup="true">
<img x-show="!homePagesOpen" x-cloak class="menu-group__chevron-icon menu-group__chevron-icon--down" src="{{asset "icons/keyboard_arrow_down.svg"}}" alt="">
@@ -17,6 +17,25 @@
</div>
</div>
</li>
{{/if}}
{{#if @site.secondary_navigation}}
<li class="menu-group menu-group--nav nav-toggle is-mainitem flex items-center flex-wrap w-full relative group menu-group--secondary-nav" :class="{ 'is-open': secondaryNavOpen }" data-label="More links" data-slug="secondary-nav" aria-haspopup="true">
<a class="menu-group__link menu-group__link--toggle flex gap-2 items-center flex-1 py-1.5 rounded-theme transition-[padding]" href="#" role="button" @click.prevent="secondaryNavOpen = !secondaryNavOpen" :aria-expanded="secondaryNavOpen.toString()" aria-haspopup="true">
<span class="menu-link__marker w-1 h-4 rounded-sm rounded-l-none transition-all"></span>
<span class="menu-link__label">{{#if @custom.secondary_nav_label}}{{@custom.secondary_nav_label}}{{else}}Secondary{{/if}}</span>
</a>
<button class="menu-group__toggle relative cursor-pointer p-2 rounded-r-theme" type="button" title="Menu toggle" aria-label="Toggle submenu" aria-controls="secondary-nav" :aria-expanded="secondaryNavOpen.toString()" @click="secondaryNavOpen = !secondaryNavOpen" aria-haspopup="true">
<img x-show="!secondaryNavOpen" x-cloak class="menu-group__chevron-icon menu-group__chevron-icon--down" src="{{asset "icons/keyboard_arrow_down.svg"}}" alt="">
<img x-show="secondaryNavOpen" x-cloak class="menu-group__chevron-icon menu-group__chevron-icon--up" src="{{asset "icons/keyboard_arrow_up.svg"}}" alt="">
</button>
<div class="menu-group__panel basis-full overflow-hidden transition-[height,opacity] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]" x-ref="secondaryNav" :style="secondaryNavOpen ? 'height:' + $refs.secondaryNav.scrollHeight + 'px; opacity:1; visibility:visible;' : 'height:0px; opacity:0; visibility:hidden;'">
<div class="menu-group__content menu-group__content--nav" id="secondary-nav">
{{navigation type="secondary"}}
</div>
</div>
</li>
{{/if}}
<li class="menu-group menu-group--nav menu-group--link flex items-center flex-wrap w-full relative group" data-label="Tags" data-slug="tags" data-length="4">
<a class="menu-group__link flex gap-2 items-center flex-1 py-1.5 rounded-theme transition-[padding]" href="/tags/">
@@ -25,14 +44,14 @@
</a>
</li>
<li class="menu-group menu-group--nav menu-group--link flex items-center flex-wrap w-full relative group" data-label="Authors" data-slug="authors" data-length="7">
{{!-- <li class="menu-group menu-group--nav menu-group--link flex items-center flex-wrap w-full relative group" data-label="Authors" data-slug="authors" data-length="7">
<a class="menu-group__link flex gap-2 items-center flex-1 py-1.5 rounded-theme transition-[padding]" href="/authors/">
<span class="menu-link__marker w-1 h-4 rounded-sm rounded-l-none transition-all"></span>
<span class="menu-link__label">Authors</span>
</a>
</li>
</a>
</li> --}}
<li class="menu-group menu-group--nav nav-toggle is-mainitem flex items-center flex-wrap w-full relative group" :class="{ 'is-open': membersOpen }" data-label="Members" data-slug="members" data-length="7" aria-haspopup="true">
{{!-- <li class="menu-group menu-group--nav nav-toggle is-mainitem flex items-center flex-wrap w-full relative group" :class="{ 'is-open': membersOpen }" data-label="Members" data-slug="members" data-length="7" aria-haspopup="true">
<a class="menu-group__link menu-group__link--toggle flex gap-2 items-center flex-1 py-1.5 rounded-theme transition-[padding]" href="#" role="button" @click.prevent="membersOpen = !membersOpen" :aria-expanded="membersOpen.toString()" aria-haspopup="true">
<span class="menu-link__marker w-1 h-4 rounded-sm rounded-l-none transition-all"></span>
<span class="menu-link__label">Members</span>
@@ -50,7 +69,7 @@
</ul>
</div>
</div>
</li>
</li> --}}
</ul>
</nav>
@@ -63,10 +82,12 @@
</span>
</button>
<div class="sidebar-card__content sidebar-card__content--categories" x-cloak x-show="categoriesOpen">
<ul class="category-grid grid sm:grid-cols-2 gap-x-2 gap-0.5 mt-1 text-typ-tone font-medium text-[0.8rem] -mb-1.5">
{{#get "tags" limit="10" include="count.posts"}}
<ul class="category-grid grid sm:grid-cols-2 gap-x-2 gap-0.5 mt-1 text-typ-tone font-medium text-[0.8rem] -mb-1.5" data-category-priority-list
data-category-priority-order="project, dev, tech, note, study, tools, review, lab, play, life"
data-category-priority-limit="10">
{{#get "tags" limit="100" include="count.posts" order="count.posts desc"}}
{{#foreach tags}}
<li>
<li data-category-priority-item data-category-slug="{{slug}}">
<a class="category-chip group flex items-center gap-2 leading-tight pl-0 pr-3 py-2 rounded-theme hover:bg-bgr-tone hover:px-3 hover:text-typ transition-[padding]" href="{{url}}" aria-label="{{name}}"{{#if accent_color}} style="--color-accent: {{accent_color}};"{{/if}}>
<span class="category-chip__dot w-1 h-4 bg-accent rounded-sm rounded-l-none group-hover:size-2 group-hover:rounded-full transition-all"></span>
<span class="category-chip__label flex-1 line-clamp-1 text-ellipsis">{{name}}</span>
@@ -79,35 +100,48 @@
</div>
</section>
<section class="sidebar-card">
<div class="sidebar-card__header">
<h2>Authors</h2>
</div>
<div class="author-list">
{{!-- 작가 목록 섹션 (주석으로 유지) --}}
{{!-- <section class="sidebar-card sidebar-card--authors border-b border-brd px-5 sm:px-6 py-4 sm:py-5 xl:pl-0 pr-3 sm:pr-3 flex flex-col gap-1.5" x-data="{ authorsOpen: window.innerWidth >= 1024 }" x-init="window.addEventListener('resize', () => { authorsOpen = window.innerWidth >= 1024 })" data-sidebar-authors>
<button class="sidebar-card__trigger flex items-center gap-1 justify-between cursor-pointer pr-2 hover:opacity-75" type="button" @click="authorsOpen = !authorsOpen" :aria-expanded="authorsOpen.toString()">
<span class="sidebar-card__eyebrow uppercase font-medium text-xs">Authors</span>
<span class="sidebar-card__chevron" aria-hidden="true">
<img x-show="!authorsOpen" x-cloak class="sidebar-card__chevron-icon sidebar-card__chevron-icon--down" src="{{asset "icons/keyboard_arrow_down.svg"}}" alt="">
<img x-show="authorsOpen" x-cloak class="sidebar-card__chevron-icon sidebar-card__chevron-icon--up" src="{{asset "icons/keyboard_arrow_up.svg"}}" alt="">
</span>
</button>
<ul class="sidebar-card__content sidebar-card__content--authors flex flex-col gap-0.5 mt-1 text-typ-tone font-medium text-[0.8rem] -mb-1.5" x-cloak x-show="authorsOpen">
{{#get "authors" limit="4" include="count.posts"}}
{{#foreach authors}}
<a class="author-list__item" href="{{url}}">
{{#if profile_image}}
<img class="avatar" src="{{img_url profile_image size="xxs"}}" alt="{{name}}">
{{else}}
<span class="avatar avatar--fallback">A</span>
{{/if}}
<span class="author-list__body">
<strong>{{name}}</strong>
<small>{{plural count.posts empty="No posts" singular="% post" plural="% posts"}}</small>
</span>
</a>
{{#foreach authors}}
<li>
<a href="{{url}}" class="group relative flex items-center gap-1.5 leading-tight pl-0 pr-3 py-1 rounded-theme hover:text-typ hover:bg-[#f5f5f5] transition-[padding] min-h-8 md:min-h-10 hover:pl-7" aria-label="{{name}}">
{{#if profile_image}}
<figure class="size-6 md:size-7 rounded-full overflow-hidden transition-all group-hover:size-0">
<img class="w-full h-full object-cover rounded-full" src="{{img_url profile_image size="s" absolute="true"}}" alt="">
</figure>
{{else}}
<span class="size-6 md:size-7 rounded-full bg-bgr-tone flex items-center justify-center text-[11px] transition-all group-hover:size-0">A</span>
{{/if}}
<span class="absolute left-3.5 top-1/2 z-10 block size-2 rounded-full opacity-0 scale-75 -translate-y-1/2 -translate-x-1 transition-all duration-200 group-hover:opacity-100 group-hover:scale-100 group-hover:translate-x-0" style="background-color: var(--accent);"></span>
<span class="flex-1 min-w-0 flex flex-col gap-0.5 group-hover:gap-0 overflow-hidden">
<span class="line-clamp-1 text-ellipsis leading-none">{{name}}</span>
<span class="text-xs text-typ-tone leading-none line-clamp-1 text-ellipsis opacity-75 group-hover:invisible group-hover:w-0 group-hover:h-0 group-hover:opacity-0 transition-all group-hover:translate-y-full">{{#if role}}{{role}}{{else}}Author{{/if}}</span>
</span>
</a>
</li>
{{/foreach}}
{{/get}}
</div>
</section>
</ul>
</section> --}}
<footer class="sidebar-footer">
<a href="{{@custom.footer_primary_url}}">{{@custom.footer_primary_link}}</a>
<a href="{{@custom.footer_secondary_url}}">{{@custom.footer_secondary_link}}</a>
<a href="{{@custom.footer_tertiary_url}}">{{@custom.footer_tertiary_link}}</a>
<a href="{{@custom.footer_quaternary_url}}">{{@custom.footer_quaternary_link}}</a>
<button class="icon-button icon-button--plain" type="button" data-theme-toggle aria-label="Toggle theme">◐</button>
</footer>
</div>
<span class="flex-1"></span>
<nav class="pl-4 pr-3 sm:pl-5 xl:pl-1 py-2 flex items-center justify-between gap-2">
<ul class="flex flex-wrap items-center gap-3 text-typ-tone text-xs mb-2">
<li><a href="{{@custom.footer_primary_url}}">{{@custom.footer_primary_link}}</a></li>
<li><a href="{{@custom.footer_secondary_url}}">{{@custom.footer_secondary_link}}</a></li>
<li><a href="{{@custom.footer_tertiary_url}}">{{@custom.footer_tertiary_link}}</a></li>
<li><a href="{{@custom.footer_quaternary_url}}">{{@custom.footer_quaternary_link}}</a></li>
</ul>
<button class="flex items-center self-start gap-1 p-1 size-6 rounded-theme hover:bg-bgr-tone cursor-pointer text-xs font-medium" data-theme-toggle aria-label="Toggle theme">◐</button>
</nav>
</aside>

View File

@@ -1,94 +1,135 @@
<aside class="sidebar sidebar--right">
<div class="sidebar__inner sidebar__inner--right">
<aside class="sidebar sidebar--right h-full flex flex-col">
<section class="px-4 sm:px-5 py-4 sm:py-5 xl:pr-1 flex flex-col gap-3 border-b border-brd ">
<div class="flex items-center gap-2.5">
{{#if @site.icon}}
<img class="w-12 min-h-12 rounded-md h-full relative" src="{{@site.icon}}" alt="{{@site.title}}">
{{else}}
<div class="w-12 min-h-12 rounded-md h-full relative site-icon--fallback">T</div>
{{/if}}
<div class="flex flex-col gap-0.5">
<h2 class="text-sm md:text-base font-extrabold leading-tight">{{@site.title}}</h2>
{{#if @site.description}}
<p class="text-sm text-typ-tone leading-tight">{{@site.description}}</p>
{{/if}}
</div>
</div>
</section>
{{#is "post"}}
{{#post}}
{{#primary_author}}
<section class="author-feature author-feature--top">
{{#if profile_image}}
<img class="avatar avatar--large" src="{{img_url profile_image size="s"}}" alt="{{name}}">
<section class="px-4 sm:px-5 py-4 sm:py-5 xl:pr-1 flex flex-col gap-2.5 border-b border-brd">
<a class="flex items-start gap-2 hover:opacity-80" href="{{url}}">{{#if profile_image}}
<img class="w-12 h-12 min-h-12 shrink-0 aspect-square rounded-full object-cover" src="{{img_url profile_image absolute="true"}}" alt="{{name}}">
{{else}}
<div class="avatar avatar--large avatar--fallback">A</div>
<div class="w-12 h-12 min-h-12 shrink-0 aspect-square rounded-full avatar--fallback">A</div>
{{/if}}
<div class="author-feature__body">
<h3>{{name}}</h3>
{{#if bio}}<p>{{bio}}</p>{{/if}}
<div class="flex-1 flex flex-col gap-1">
<h3 class="text-sm font-medium leading-tight flex items-center justify-between gap-1">{{name}}</h3>
{{#if bio}}<p class="text-xs text-typ-tone leading-tight line-clamp-3 text-overflow-ellipsis">{{bio}}</p>{{/if}}
</div>
<span class="author-feature__action">↗</span>
</a>
</section>
{{/primary_author}}
{{/post}}
{{/is}}
<section class="site-intro">
{{#if @site.icon}}
<img class="site-icon" src="{{@site.icon}}" alt="{{@site.title}}">
{{else}}
<div class="site-icon site-icon--fallback">T</div>
{{/if}}
<div class="about-block">
<h2>{{@site.title}}</h2>
{{#if @site.description}}
<p>{{@site.description}}</p>
{{/if}}
</div>
</section>
{{subscribe_form
placeholder="Your email"
button_class="button button--subscribe"
form_class="subscribe-form"
}}
<section class="sidebar-card sidebar-card--tight">
<div class="sidebar-card__header">
<h2>Follow</h2>
<span class="sidebar-card__action"></span>
</div>
<div class="social-row">
{{#if @site.facebook}}<a href="{{social_url type="facebook"}}" aria-label="Facebook">f</a>{{/if}}
{{#if @site.twitter}}<a href="{{social_url type="twitter"}}" aria-label="X">x</a>{{/if}}
<a href="{{@site.url}}/rss/" aria-label="RSS">rss</a>
</div>
<section class="px-4 sm:px-5 py-4 sm:py-5 xl:pr-1 flex flex-wrap items-center justify-between gap-1.5 border-b border-brd">
<h2 class="uppercase font-medium text-xs text-typ-tone">Follow</h2>
<nav class="relative flex flex-wrap items-center gap-1 text-typ text-sm z-10">
{{!-- {{#if @site.facebook}}<a href="{{social_url type="facebook"}}" class="hover:opacity-75 p-0.5" aria-label="Facebook">f</a>{{/if}} --}}
{{#if @site.twitter}}
<a href="https://x.com/ghost" class="hover:opacity-75 p-0.5" aria-label="Twitter">
<i class="icon icon-brand-x size-4 stroke-2" role="presentation">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-brand-x" width="16" height="16" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M4 4l11.733 16h4.267l-11.733 -16z"></path>
<path d="M4 20l6.768 -6.768m2.46 -2.46l6.772 -6.772"></path>
</svg>
</i><span class="hidden">Twitter</span></a>
{{/if}}
<a href="{{@site.url}}/rss/" class="hover:w-5 h-5 opacity-75 p-0.5" aria-label="RSS">
<i class="icon icon-rss size-4 stroke-2" role="presentation">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-rss" width="16" height="16" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<circle cx="5" cy="19" r="1"></circle>
<path d="M4 4a16 16 0 0 1 16 16"></path>
<path d="M4 11a9 9 0 0 1 9 9"></path>
</svg>
</i>
</a>
</nav>
</section>
<section class="sidebar-card sidebar-card--tight">
<div class="sidebar-card__header">
<h2>Recommended</h2>
<span class="sidebar-card__action">↗</span>
<section class="px-4 sm:px-5 py-4 sm:py-5 xl:pr-1 flex flex-col gap-1.5 border-b border-brd">
<div class="flex items-center justify-between gap-2 mb-2">
<h2 class="uppercase font-bold text-xs text-typ-tone">발견하기</h2>
<button data-portal="recommendations" data-portal-title="발견하기" data-portal-description="회원님이 좋아하실 만한 다른 사이트들이에요." class="text-sm font-medium flex items-center gap-1 rounded-btn group bg-bgr text-typ hover:text-typ-tone cursor-pointer gh-portal-close">
<span class="sr-only">모두 보기</span>
<i class="icon icon-arrow-up-right size-4 stroke-2" role="presentation">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M17 7l-10 10"></path>
<path d="M8 7l9 0l0 9"></path>
</svg>
</i>
</button>
</div>
<ul class="recommended-list">
{{#get "posts" filter="featured:true" limit="3" include="tags"}}
{{#foreach posts}}
<li><a href="{{url}}">{{title}}</a></li>
{{/foreach}}
{{else}}
<li><a href="{{@site.url}}">{{@site.title}}</a></li>
{{/get}}
</ul>
</section>
<section class="sidebar-card sidebar-card--about">
<p>{{@site.title}} is a thoughtfully designed Ghost theme inspired by editorial communities, with flexible settings that let you shape it to your publication.</p>
<a class="button button--accent" href="{{@site.url}}">About {{@site.title}}</a>
{{#match @site.recommendations_enabled}}
{{recommendations limit="4"}}
{{else}}
<ul class="flex flex-col gap-1 rounded-theme overflow-hidden">
<li>
<a href="{{@site.url}}" class="py-1 text-[0.8rem] leading-tight flex items-center gap-2 hover:opacity-75">
<h3 class="font-medium line-clamp-1 text-ellipsis overflow-hidden">{{@site.title}}</h3>
</a>
</li>
</ul>
{{/match}}
</section>
{{#is "post"}}
{{#post}}
<section class="sidebar-card sidebar-card--tight">
<div class="sidebar-card__header">
<h2>Read next</h2>
<section class="px-4 sm:px-5 py-4 sm:py-5 xl:pr-1 flex flex-col gap-1.5 border-b border-brd">
<div class="flex items-center justify-between gap-2 mb-1">
<h2 class="uppercase font-bold text-xs text-typ-tone">Read next</h2>
</div>
<ul class="recommended-list">
{{#get "posts" filter=(concat "id:-" id) limit="4"}}
{{#foreach posts}}
<li><a href="{{url}}">{{title}}</a></li>
{{/foreach}}
{{/get}}
<ul class="flex flex-col gap-0.5 rounded-theme overflow-hidden">
{{#if primary_tag}}
{{#get "posts" filter=(concat "id:-" id "+tag:" primary_tag.slug) limit="4"}}
{{#foreach posts}}
<li class="style-none"><a href="{{url}}" class="py-1 text-[0.8rem] leading-tight flex items-center gap-2 hover:opacity-75"><h3 class="font-medium line-clamp-1 text-ellipsis overflow-hidden">{{title}}</h3></a></li>
{{/foreach}}
{{else}}
{{#get "posts" filter=(concat "id:-" id) limit="4"}}
{{#foreach posts}}
<li class="style-none"><a href="{{url}}" class="py-1 text-[0.8rem] leading-tight flex items-center gap-2 hover:opacity-75"><h3 class="font-medium line-clamp-1 text-ellipsis overflow-hidden">{{title}}</h3></a></li>
{{/foreach}}
{{/get}}
{{/get}}
{{else}}
{{#get "posts" filter=(concat "id:-" id) limit="4"}}
{{#foreach posts}}
<li class="style-none"><a href="{{url}}" class="py-1 text-[0.8rem] leading-tight flex items-center gap-2 hover:opacity-75"><h3 class="font-medium line-clamp-1 text-ellipsis overflow-hidden">{{title}}</h3></a></li>
{{/foreach}}
{{/get}}
{{/if}}
</ul>
</section>
{{/post}}
{{/is}}
<footer class="copyright">©{{date format="YYYY"}} {{@site.title}}. Published with Ghost.</footer>
</div>
<span class="flex-1"></span>
<footer class="px-5 sm:px-6 xl:pr-1">
<div class="py-3 flex flex-col items-center justify-center text-[10px] font-bold text-gray-800">
©{{date format="YYYY"}} {{@site.title}}. All rights reserved.
</div>
</footer>
</aside>

View File

@@ -1,28 +1,139 @@
<header class="topbar">
<div class="topbar__inner">
<div class="topbar__brand flex items-center gap-3">
<button class="topbar__sidebar-toggle" type="button" data-left-sidebar-toggle aria-expanded="true" aria-label="Toggle left sidebar">
<img class="topbar__sidebar-toggle-icon topbar__sidebar-toggle-icon--open" src="{{asset "icons/left_panel_open.svg"}}" alt="">
<img class="topbar__sidebar-toggle-icon topbar__sidebar-toggle-icon--close" src="{{asset "icons/left_panel_close.svg"}}" alt="">
<header class="topbar px-3 xl:px-0 sticky top-0 z-20 w-full border-b border-brd bg-bgr/95 supports-[backdrop-filter]:bg-bgr/80">
<div class="w-full max-w-[1294px] h-full flex justify-between items-center mx-auto gap-4">
<div class="topbar__brand flex h-full items-center gap-3">
<button class="topbar__sidebar-toggle inline-flex items-center justify-center rounded-theme group" type="button" data-left-sidebar-toggle aria-expanded="true" aria-label="Toggle left sidebar">
<span class="topbar__sidebar-toggle-icon topbar__sidebar-toggle-icon--open">
<i class="icon icon-layout-sidebar size-6 stroke-2" role="presentation">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-layout-sidebar"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z"></path><path d="M9 4l0 16"></path></svg>
</i>
</span>
<span class="topbar__sidebar-toggle-icon topbar__sidebar-toggle-icon--open-hover">
<i class="icon icon-layout-sidebar-expand size-6 stroke-2" role="presentation">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-layout-sidebar-left-expand"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z"></path><path d="M9 4v16"></path><path d="M14 10l2 2l-2 2"></path></svg>
</i>
</span>
<span class="topbar__sidebar-toggle-icon topbar__sidebar-toggle-icon--close">
<i class="icon icon-layout-sidebar-active size-6 stroke-0 fill-current" role="presentation">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-layout-sidebar"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M6 21a3 3 0 0 1 -3 -3v-12a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3zm12 -16h-8v14h8a1 1 0 0 0 1 -1v-12a1 1 0 0 0 -1 -1"></path></svg>
</i>
</span>
<span class="topbar__sidebar-toggle-icon topbar__sidebar-toggle-icon--close-hover">
<i class="icon icon-layout-sidebar-collapse size-6 stroke-0 fill-current" role="presentation">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-layout-sidebar-left-collapse"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M18 3a3 3 0 0 1 2.995 2.824l.005 .176v12a3 3 0 0 1 -2.824 2.995l-.176 .005h-12a3 3 0 0 1 -2.995 -2.824l-.005 -.176v-12a3 3 0 0 1 2.824 -2.995l.176 -.005h12zm0 2h-9v14h9a1 1 0 0 0 .993 -.883l.007 -.117v-12a1 1 0 0 0 -.883 -.993l-.117 -.007zm-2.293 4.293a1 1 0 0 1 .083 1.32l-.083 .094l-1.292 1.293l1.292 1.293a1 1 0 0 1 .083 1.32l-.083 .094a1 1 0 0 1 -1.32 .083l-.094 -.083l-2 -2a1 1 0 0 1 -.083 -1.32l.083 -.094l2 -2a1 1 0 0 1 1.414 0z"></path></svg>
</i>
</span>
</button>
<a class="brand brand--topbar" href="{{@site.url}}">
<span class="brand__name">{{@site.title}}</span>
<a class="brand brand--topbar inline-flex min-w-0 max-w-full items-center gap-2.5 lg:hidden" href="{{@site.url}}">
{{#if @site.logo}}
<img class="max-h-8 w-auto object-contain" src="{{@site.logo}}" alt="{{@site.title}}">
{{else}}
<span class="brand__name">{{@site.title}}</span>
{{/if}}
</a>
</div>
<div class="topbar__search">
<button class="search-trigger" type="button" data-search-open aria-label="Open search">
<img class="search-trigger__icon" src="{{asset "icons/search.svg"}}" alt="">
<span>Search</span>
<span class="search-shortcut">/</span>
<div class="topbar-search topbar__search flex h-full min-w-0 items-center justify-center flex-1">
<button class="search-trigger flex w-full min-w-0 max-w-xs items-center gap-2.5 rounded-xl border border-brd bg-bgr px-3.5 py-2 text-sm text-typ-tone transition-colors hover:bg-bgr-tone hover:text-typ" type="button" data-search-open aria-label="Open search">
<i class="icon-search flex justify-center items-center shrink-0 size-5 md:size-4 stroke-2 md:stroke-[2.25] fill-bgr-tone [&amp;&gt;svg]:fill-inherit" role="presentation">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-search" width="16" height="16" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<circle cx="10" cy="10" r="7"></circle>
<line x1="21" y1="21" x2="15" y2="15"></line>
</svg>
</i>
<span class="search-trigger__label">Search</span>
<span class="search-shortcut shrink-0">/</span>
</button>
</div>
<div class="topbar__actions flex items-center justify-end gap-2">
<button class="icon-button icon-button--search-mobile" type="button" data-search-open aria-label="Open search">
<img class="search-trigger__icon" src="{{asset "icons/search.svg"}}" alt="">
<div class="topbar__actions relative flex h-full items-center justify-end gap-2">
<button class="w-8 h-8 cursor-pointer icon-button--user-menu inline-flex items-center justify-center rounded-theme overflow-hidden {{#unless @member}}bg-bgr hover:bg-bgr-tone{{/unless}}" type="button" aria-label="Open user menu" data-user-menu-toggle>
{{#if @member}}
<figure class="relative w-8 h-8 pointer-events-none" data-member-avatar-figure>
<div class="flex items-center justify-center rounded-full w-8 h-8" data-member-avatar-background>
<p class="font-sans text-base font-semibold text-typ" data-member-avatar-initial data-member-avatar-seed="{{#if @member.name}}{{@member.name}}{{else}}{{#if @member.email}}{{@member.email}}{{else}}member{{/if}}{{/if}}">{{#if @member.name}}{{@member.name}}{{else}}{{#if @member.email}}{{@member.email}}{{else}}M{{/if}}{{/if}}</p>
</div>
<img alt="Avatar" class="absolute left-0 top-0 rounded-full w-8 h-8 object-cover {{#unless @member.avatar_image}}hidden{{/unless}}" data-member-avatar-image src="{{@member.avatar_image}}">
</figure>
{{else}}
<i class="icon icon-user pointer-events-none flex items-center justify-center w-full h-full [&_svg]:w-[62%] [&_svg]:h-[62%]" role="presentation" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"></path>
<path d="M12 10m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M6.168 18.849a4 4 0 0 1 3.832 -2.849h4a4 4 0 0 1 3.834 2.855"></path>
</svg>
</i>
{{/if}}
</button>
<div class="z-50 p-3 pb-2 flex flex-col text-sm bg-bgr border border-brd rounded-theme absolute top-12 right-0 -translate-y-4 opacity-0 invisible pointer-events-none transition-[transform,opacity,visibility,scale] min-w-[200px] max-w-xs overflow-hidden font-medium scale-95 shadow-md" style="background-color: var(--bg);" data-user-menu>
{{#if @member}}
<div class="flex items-center gap-2 border-b border-brd pb-3 mb-2">
<div class="size-8 md:size-10 rounded-full overflow-hidden flex items-center justify-center">
<figure class="relative w-8 h-8 md:w-10 md:h-10 pointer-events-none" data-member-avatar-figure>
<div class="flex items-center justify-center rounded-full w-8 h-8 md:w-10 md:h-10" data-member-avatar-background>
<p class="font-sans text-base font-semibold text-typ" data-member-avatar-initial data-member-avatar-seed="{{#if @member.name}}{{@member.name}}{{else}}{{#if @member.email}}{{@member.email}}{{else}}member{{/if}}{{/if}}">{{#if @member.name}}{{@member.name}}{{else}}{{#if @member.email}}{{@member.email}}{{else}}M{{/if}}{{/if}}</p>
</div>
<img alt="Avatar" class="absolute left-0 top-0 rounded-full w-8 h-8 md:w-10 md:h-10 object-cover {{#unless @member.avatar_image}}hidden{{/unless}}" data-member-avatar-image src="{{@member.avatar_image}}">
</figure>
</div>
<div class="flex gap-0.5 flex-col">
<div class="line-clamp-1 text-ellipsis leading-[1.15] max-w-xs" data-member-name-display>{{#if @member.name}}{{@member.name}}{{else}}Member{{/if}}</div>
</div>
</div>
{{/if}}
{{#if @member}}
<a href="#/portal/account" class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-theme hover:bg-bgr-tone">
<i class="icon icon-login size-5 stroke-2" role="presentation">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M9 6l6 0"></path>
<path d="M12 3l0 6"></path>
<path d="M5 12a7 7 0 0 0 14 0a7 7 0 0 0 -14 0"></path>
</svg>
</i>
<span>Account</span>
</a>
{{else}}
<a href="#/portal/signup" class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-theme hover:bg-bgr-tone">
<i class="icon icon-signup size-5 stroke-2" role="presentation">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0"></path>
<path d="M15 9l-6 6"></path>
<path d="M15 15v-6h-6"></path>
</svg>
</i>
<span>회원 가입</span>
</a>
<a href="#/portal/signin" class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-theme hover:bg-bgr-tone">
<i class="icon icon-login size-5 stroke-2" role="presentation">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2"></path>
<path d="M21 12h-13l3 -3"></path>
<path d="M11 15l-3 -3"></path>
</svg>
</i>
<span>로그인</span>
</a>
{{/if}}
<div class="flex flex-col gap-0.5 items-center justify-between border-t border-brd mt-2 pt-2">
<button class="group flex items-center justify-between gap-1.5 py-1 pl-0.5 rounded-theme w-full cursor-pointer text-typ-tone hover:text-typ" type="button" data-user-theme-toggle>
<span class="uppercase text-xs">Dark mode</span>
<span class="inline-flex h-[18px] w-8 shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-brd transition-colors duration-200 group-focus:outline-none group-focus:ring-0" data-user-theme-track>
<span aria-hidden="true" class="pointer-events-none inline-block size-3.5 translate-x-0 transform rounded-full bg-white shadow ring-0 transition duration-200" data-user-theme-thumb></span>
</span>
</button>
<button class="group flex items-center justify-between gap-1.5 py-1 pl-0.5 rounded-theme w-full cursor-pointer text-typ-tone hover:text-typ" type="button" data-user-menu-state-toggle>
<span class="uppercase text-xs">Menu open</span>
<span class="inline-flex h-[18px] w-8 shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-brd transition-colors duration-200 group-focus:outline-none group-focus:ring-0" data-user-menu-track>
<span aria-hidden="true" class="pointer-events-none inline-block size-3.5 translate-x-0 transform rounded-full bg-white shadow ring-0 transition duration-200" data-user-menu-thumb></span>
</span>
</button>
</div>
</div>
{{#if @custom.show_admin_link}}
<a class="button button--accent" href="/ghost/">Admin</a>
<a class="icon-button icon-button--profile" href="/ghost/" aria-label="Ghost admin"><span></span></a>
<a class="button button--accent rounded-theme" href="/ghost/">Admin</a>
<a class="icon-button icon-button--profile rounded-full border border-brd bg-bgr hover:bg-bgr-tone" href="/ghost/" aria-label="Ghost admin"><span></span></a>
{{/if}}
</div>
</div>
@@ -32,11 +143,29 @@
<div class="search-modal__backdrop" data-search-close></div>
<div class="search-modal__panel">
<div class="search-modal__input">
<button type="button" class="icon-button icon-button--plain" data-search-close aria-label="Close search">×</button>
<input type="search" placeholder="Search posts, tags and authors" data-search-input>
<button type="button" class="icon-button icon-button--plain" data-search-reset aria-label="Clear search">×</button>
<input type="search" placeholder="게시물, 태그, 작성자 검색" data-search-input>
</div>
<div class="search-modal__body" data-search-results>
<p class="search-modal__hint">Start typing to filter visible posts, tags, and authors in the current page.</p>
<p class="search-modal__hint">현재 페이지에 표시되는 게시물, 태그 및 작성자를 필터링하려면 입력을 시작하세요.</p>
</div>
</div>
</div>
<div hidden data-search-source>
{{#get "authors" limit="100"}}
{{#foreach authors}}
<span data-search-item data-search-type="author" data-search-title="{{name}}" data-search-url="{{url}}" data-search-image="{{#if profile_image}}{{img_url profile_image size='s' absolute='true'}}{{/if}}"></span>
{{/foreach}}
{{/get}}
{{#get "tags" limit="100"}}
{{#foreach tags}}
<span data-search-item data-search-type="tag" data-search-title="{{name}}" data-search-url="{{url}}"></span>
{{/foreach}}
{{/get}}
{{#get "posts" limit="100"}}
{{#foreach posts}}
<span data-search-item data-search-type="post" data-search-title="{{title}}" data-search-url="{{url}}" data-search-excerpt="{{excerpt words='24'}}"></span>
{{/foreach}}
{{/get}}
</div>

127
post.hbs
View File

@@ -1,42 +1,59 @@
{{!< default}}
{{#post}}
<main class="content-area content-area--post">
<article class="post-article {{post_class}}">
<header class="post-header">
<div class="post-header__inner">
{{#if primary_tag}}
<a class="post-tag" href="{{primary_tag.url}}"{{#if primary_tag.accent_color}} style="--tag-accent: {{primary_tag.accent_color}};"{{/if}}>{{primary_tag.name}}</a>
{{/if}}
<h1 class="post-title">{{title}}</h1>
<div class="post-meta">
<time class="meta-item" datetime="{{date format="YYYY-MM-DD"}}">{{date format="MMM D, YYYY"}}</time>
{{#primary_author}}
<span class="meta-divider">/</span>
<span class="meta-item">{{name}}</span>
{{/primary_author}}
{{#if primary_tag}}
<span class="meta-divider">/</span>
<a class="meta-pill" href="{{primary_tag.url}}"{{#if primary_tag.accent_color}} style="--tag-accent: {{primary_tag.accent_color}};"{{/if}}>{{primary_tag.name}}</a>
{{/if}}
<span class="meta-divider">/</span>
<span class="meta-item">{{reading_time}}</span>
</div>
</div>
{{#if feature_image}}
<figure class="post-feature-image">
<img
srcset="{{img_url feature_image size="s"}} 300w, {{img_url feature_image size="m"}} 600w, {{img_url feature_image size="l"}} 1000w"
sizes="(max-width: 900px) 100vw, 760px"
src="{{img_url feature_image size="l"}}"
alt="{{#if feature_image_alt}}{{feature_image_alt}}{{else}}{{title}}{{/if}}"
>
</figure>
{{/if}}
</header>
<main class="flex-2 max-w-content">
<section class="px-4 sm:px-[max(2vmin,20px)] mt-6 mb-8">
<div class="max-w-content mx-auto flex flex-col gap-2.5">
<h1 class="font-semibold text-xl sm:text-2xl leading-[1.125]">{{title}}</h1>
<div class="post-body">
<section class="post-content kg-content">
<div class="relative border-b border-brd pb-4">
<div class="flex flex-wrap gap-2 sm:gap-1.5 items-center text-xs text-typ-tone [&>*:not(:last-child)]:after:content-['/'] [&>*:not(:last-child)]:after:ml-2 sm:[&>*:not(:last-child)]:after:ml-1.5 [&>*:not(:last-child)]:after:text-brd" data-post-card-info>
<time data-post-card-date datetime="{{date format="YYYY-MM-DD"}}">{{date format="MMM D, YYYY"}}</time>
{{#primary_author}}
<a data-post-card-author href="{{url}}" class="hover:opacity-75">{{name}}</a>
{{/primary_author}}
{{#if primary_tag}}
<ul class="flex flex-wrap items-center font-medium" data-post-card-tags>
<li{{#if primary_tag.accent_color}} style="--color-accent: {{primary_tag.accent_color}};"{{/if}}>
<a href="{{primary_tag.url}}" class="bg-accent/10 px-1.5 py-px rounded-sm text-typ hover:bg-accent/5 hover:text-accent">{{primary_tag.name}}</a>
</li>
</ul>
{{/if}}
{{!-- {{#if comments}} --}}
<a data-post-card-comments class="flex gap-1 items-center hover:opacity-75" href="#comments">
<i class="icon icon-comments size-3.5 stroke-2 -mt-px pointer-events-none" role="presentation">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-message-circle"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M3 20l1.3 -3.9c-2.324 -3.437 -1.426 -7.872 2.1 -10.374c3.526 -2.501 8.59 -2.296 11.845 .48c3.255 2.777 3.695 7.266 1.029 10.501c-2.666 3.235 -7.615 4.215 -11.574 2.293l-4.7 1"></path></svg>
</i>
<span class="pointer-events-none">0</span>
</a>
{{!-- {{/if}} --}}
</div>
<button data-post-share-toggle data-share-url="{{url absolute="true"}}" data-share-title="{{title}}" data-share-description="{{#if custom_excerpt}}{{custom_excerpt}}{{else}}{{excerpt words="24"}}{{/if}}" data-share-image="{{#if feature_image}}{{img_url feature_image size='m'}}{{/if}}" class="absolute bottom-4 right-0 flex gap-1 items-center hover:opacity-75 cursor-pointer" type="button" aria-label="Share this post">
<i class="icon icon-share size-4 stroke-2 pointer-events-none" role="presentation">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-share-3"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M13 4v4c-6.575 1.028 -9.02 6.788 -10 12c-.037 .206 5.384 -5.962 10 -6v4l8 -7l-8 -7"></path></svg>
</i>
{{!-- <img class="size-4 pointer-events-none" src="{{asset "icons/arrow_outward.svg"}}" alt=""> --}}
</button>
</div>
{{#if feature_image}}
<figure class="w-full h-full relative max-w-content mt-2.5">
<img
class="w-full object-cover rounded-theme aspect-[16/9] bg-bgr-tone"
srcset="{{img_url feature_image size="s"}} 300w, {{img_url feature_image size="m"}} 600w, {{img_url feature_image size="l"}} 1000w"
sizes="(max-width: 767px) 90vw, 720px"
src="{{img_url feature_image size="l"}}"
alt="{{#if feature_image_alt}}{{feature_image_alt}}{{else}}{{title}}{{/if}}"
>
</figure>
{{/if}}
</div>
</section>
<article class="post-article pt-4 px-5 sm:px-[max(2vmin,20px)] mb-12 {{post_class}}">
{{!-- <div class="post-body px-4 sm:px-[max(2vmin,20px)]"> --}}
<section class="post-content kg-content ghost-content prose prose-theme">
{{content}}
</section>
@@ -48,37 +65,29 @@
</section>
{{/unless}}
<footer class="post-footer">
{{#primary_author}}
<div class="author-inline-card">
{{#if profile_image}}
<img class="avatar" src="{{img_url profile_image size="xs"}}" alt="{{name}}">
{{else}}
<div class="avatar avatar--fallback">A</div>
{{/if}}
<div>
<h3>{{name}}</h3>
{{#if bio}}<p>{{bio}}</p>{{/if}}
</div>
</div>
{{/primary_author}}
</footer>
</div>
{{!-- </div> --}}
</article>
{{> "post/post-navigation"}}
<section class="discussion-panel">
<section class="px-5 sm:px-6 mb-6 border-y py-5 border-brd scroll-mt-14 bg-bgr-tone">
{{#if comments}}
{{comments title="Comments" count=true}}
{{else if @member}}
<div class="flex flex-col items-center py-6 sm:px-8 sm:py-10">
<h3 class="mb-[8px] text-center font-sans text-2xl tracking-tight text-typ font-bold">댓글 기능 비활성화</h3>
<p class="w-full px-0 text-center font-sans text-lg leading-normal text-typ-tone sm:max-w-screen-sm sm:px-8">현재 사이트 설정에서 댓글 기능이 비활성화되어 있습니다.</p>
</div>
{{else}}
<div class="comments-empty">
<h3>Join the discussion</h3>
<p>Become a member of {{@site.title}} to start commenting.</p>
<a class="button" href="#/portal/signup">Sign up now</a>
<p>Already a member? <a href="#/portal/signin">Sign in</a></p>
<div class="flex flex-col items-center py-6 sm:px-8 sm:py-10">
<h3 class="mb-[8px] text-center font-sans text-2xl tracking-tight text-typ font-bold">댓글 참여하기</h3>
<p class="mb-[28px] w-full px-0 text-center font-sans text-lg leading-normal text-typ-tone sm:max-w-screen-sm sm:px-8">멤버가 되어 댓글을 남겨보세요</p>
<a class="text-md mb-[12px] inline-block rounded px-5 py-[14px] font-sans font-medium leading-none text-white transition-all hover:opacity-90 bg-orange-600" href="#/portal/signup">회원가입하기</a>
<p class="text-md text-center font-sans text-typ-tone"><span class="mr-1 inline-block text-[15px]">이미 가입하셨나요?</span>
<a href="#/portal/signin" class="rounded-md text-sm text-orange-600 font-semibold transition-all hover:opacity-90">로그인하기</a></p>
</div>
{{/if}}
</section>
{{> "post/post-navigation"}}
</main>
{{/post}}

View File

@@ -1,8 +1,6 @@
routes:
/tags/:
template: tags-index
/authors/:
template: authors-index
collections:
/:

View File

@@ -2,7 +2,7 @@ const fs = require('fs');
const path = require('path');
const outputDir = path.join(__dirname, '..', 'seed');
const outputFile = path.join(outputDir, 'thred-inspired-sample-content.ghost.json');
const outputFile = path.join(outputDir, 'zcf-sample-content.ghost.json');
const tags = [
{id: 'tag-books', name: 'Books', slug: 'books'},

211
scripts/dev-watch.js Normal file
View File

@@ -0,0 +1,211 @@
const fs = require('fs')
const path = require('path')
const { spawn } = require('child_process')
const rootDir = path.resolve(__dirname, '..')
const ignoredDirectories = new Set([
'.git',
'.cursor',
'.vscode',
'.docker',
'docs',
'local-ghost',
'node_modules',
'seed',
'theme-export'
])
const ignoredFiles = new Set([
'.DS_Store'
])
const syncExtensions = new Set([
'.css',
'.hbs',
'.js',
'.json',
'.svg',
'.yaml',
'.yml'
])
const watchedRoots = [rootDir]
let syncTimer = null
let syncProcess = null
let scanTimer = null
let previousSnapshot = new Map()
/**
* @param {string} relativePath
*/
function shouldIgnorePath(relativePath) {
if (!relativePath || relativePath === '.') {
return false
}
const segments = relativePath.split(path.sep)
return segments.some((segment) => ignoredDirectories.has(segment) || ignoredFiles.has(segment))
}
/**
* @param {string} relativePath
*/
function shouldSyncFile(relativePath) {
if (shouldIgnorePath(relativePath)) {
return false
}
if (relativePath === path.join('assets', 'styles', 'tailwind.css')) {
return false
}
const extension = path.extname(relativePath)
return syncExtensions.has(extension)
}
/**
* @param {string} command
* @param {string[]} args
* @param {(code: number | null) => void} [onExit]
*/
function runCommand(command, args, onExit) {
const child = spawn(command, args, {
cwd: rootDir,
stdio: 'inherit',
shell: false
})
child.on('exit', (code) => {
if (onExit) {
onExit(code)
}
})
return child
}
function queueSync() {
if (syncTimer) {
clearTimeout(syncTimer)
}
syncTimer = setTimeout(() => {
if (syncProcess) {
queueSync()
return
}
console.log('[dev:watch] theme sync')
syncProcess = runCommand('npm', ['run', 'dev:sync'], (code) => {
syncProcess = null
if (code !== 0) {
console.error(`[dev:watch] sync failed with code ${code}`)
}
})
}, 180)
}
/**
* @param {string} directoryPath
* @param {Map<string, number>} snapshot
*/
function collectSnapshot(directoryPath, snapshot) {
if (!fs.existsSync(directoryPath)) {
return
}
for (const entry of fs.readdirSync(directoryPath, { withFileTypes: true })) {
const entryPath = path.join(directoryPath, entry.name)
const relativePath = path.relative(rootDir, entryPath)
if (shouldIgnorePath(relativePath)) {
continue
}
if (entry.isDirectory()) {
collectSnapshot(entryPath, snapshot)
continue
}
if (shouldSyncFile(relativePath)) {
snapshot.set(relativePath, fs.statSync(entryPath).mtimeMs)
}
}
}
function buildSnapshot() {
const snapshot = new Map()
for (const watchedRoot of watchedRoots) {
collectSnapshot(watchedRoot, snapshot)
}
return snapshot
}
function scanForChanges() {
const nextSnapshot = buildSnapshot()
for (const [relativePath, modifiedTime] of nextSnapshot.entries()) {
if (previousSnapshot.get(relativePath) !== modifiedTime) {
console.log(`[dev:watch] change detected: ${relativePath}`)
queueSync()
previousSnapshot = nextSnapshot
return
}
}
for (const relativePath of previousSnapshot.keys()) {
if (!nextSnapshot.has(relativePath)) {
console.log(`[dev:watch] change detected: ${relativePath}`)
queueSync()
previousSnapshot = nextSnapshot
return
}
}
previousSnapshot = nextSnapshot
}
function startPolling() {
previousSnapshot = buildSnapshot()
scanTimer = setInterval(scanForChanges, 800)
}
function shutdown() {
if (syncTimer) {
clearTimeout(syncTimer)
}
if (tailwindProcess && !tailwindProcess.killed) {
tailwindProcess.kill('SIGINT')
}
if (syncProcess && !syncProcess.killed) {
syncProcess.kill('SIGINT')
}
if (scanTimer) {
clearInterval(scanTimer)
}
process.exit(0)
}
console.log('[dev:watch] initial prepare')
runCommand('npm', ['run', 'dev:prepare'], (prepareCode) => {
if (prepareCode !== 0) {
process.exit(prepareCode || 1)
}
console.log('[dev:watch] tailwind watch start')
tailwindProcess = runCommand('npm', ['run', 'build:tailwind', '--', '--watch'])
console.log('[dev:watch] filesystem watch start')
startPolling()
})
let tailwindProcess = null
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)

View File

@@ -3,7 +3,7 @@
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
TARGET_DIR="$ROOT_DIR/.docker/theme/ghost-theme-thred-clone"
TARGET_DIR="$ROOT_DIR/.docker/theme/zenless-column-flow"
mkdir -p "$TARGET_DIR"

View File

@@ -2,7 +2,7 @@
Run `npm run dev:seed` to regenerate the local Ghost import file.
Then import `seed/thred-inspired-sample-content.ghost.json` from Ghost Admin:
Then import `seed/zcf-sample-content.ghost.json` from Ghost Admin:
1. Open `http://localhost:2368/ghost`
2. Go to `Settings -> Advanced -> Import/Export`

24
tag.hbs
View File

@@ -1,15 +1,17 @@
{{!< default}}
{{#tag}}
<main class="content-area">
<section class="stack-section">
<header class="section-header">
<h1 class="section-title">{{name}}</h1>
{{#if description}}
<p class="section-description">{{description}}</p>
{{/if}}
</header>
{{> "lists/post-feed"}}
<main class="flex-2 max-w-content">
<section class="px-5 sm:px-6 pt-4 sm:pt-5">
<div class="max-w-content mx-auto flex flex-col items-center sm:flex-row gap-4 justify-between border-b border-brd pb-4 sm:pb-5">
{{#tag}}
<div class="flex-3 flex flex-col gap-1">
<h1 class="text-lg sm:text-xl font-medium leading-tight">{{name}}</h1>
{{#if description}}
<p class="text-sm text-typ-tone max-w-lg text-balance">{{description}}</p>
{{/if}}
</div>
{{/tag}}
</div>
</section>
{{> "lists/post-feed-archive"}}
</main>
{{/tag}}

View File

@@ -1,30 +1,40 @@
{{!< default}}
<main class="content-area">
<section class="stack-section">
<header class="section-header text-center">
<h1 class="section-title">Tags</h1>
<p class="section-description">Browse by topic</p>
</header>
<section class="px-5 sm:px-6" data-page-hero="">
<div class="max-w-site py-6 md:py-8 mx-auto flex flex-col gap-6 text-center">
<div class="flex-1 max-w-xl flex flex-col gap-2 justify-center mx-auto items-center">
<h1 class="text-xl md:text-2xl lg:text-3xl font-semibold text-balance leading-[1.125]">Tags</h1>
<p class="text-base text-balance text-typ-tone max-w-md leading-snug [&amp;_a]:underline [&amp;_a]:decoration-2 [&amp;_a]:underline-offset-2 [&amp;_a:hover]:text-brand">Browse by topic</p>
</div>
</div>
</section>
{{#get "tags" limit="100" include="count.posts" order="count.posts desc"}}
<div class="tag-directory grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{{#foreach tags}}
<a class="tag-directory__card group relative flex min-h-[128px] flex-col rounded-[14px] border border-[var(--border)] bg-[var(--surface)] p-4 text-left transition-colors hover:bg-[var(--surface-muted)]" href="{{url}}"{{#if accent_color}} style="--tag-accent: {{accent_color}};"{{/if}}>
<span class="tag-directory__accent absolute inset-y-0 left-0 w-[3px] rounded-l-[14px] bg-[var(--tag-accent,var(--accent))]"></span>
<span class="flex items-start justify-between gap-3">
<strong class="text-[15px] font-semibold tracking-[-0.02em]">{{name}}</strong>
<img class="h-4 w-4 shrink-0 opacity-70 transition-opacity group-hover:opacity-100" src="{{asset "icons/arrow_outward.svg"}}" alt="">
</span>
{{#if description}}
<p class="mt-2 line-clamp-3 text-[13px] leading-5 text-[var(--text-soft)]">{{description}}</p>
{{else}}
<p class="mt-2 line-clamp-3 text-[13px] leading-5 text-[var(--text-soft)]">Posts filed under {{name}}.</p>
{{/if}}
<span class="mt-auto pt-3 text-[13px] font-medium text-[var(--text-soft)]">{{plural count.posts empty="0 posts" singular="% post" plural="% posts"}}</span>
</a>
{{/foreach}}
</div>
{{/get}}
<section class="px-5 sm:px-6 mb-8" data-tags="">
{{#get "tags" limit="100" include="count.posts" order="count.posts desc"}}
<ul class="tag-directory max-w-site mx-auto gap-4 sm:gap-5 grid sm:grid-cols-2 lg:grid-cols-3">
{{#foreach tags}}
<li class="tag-directory__item h-full" data-tag-card="{{slug}}">
<a class="tag-directory__card relative h-full flex flex-col gap-2 p-4 border border-brd rounded-r-lg border-l-[3px] border-l-transparent rounded-l-none hover:bg-[color-mix(in_srgb,var(--color-accent)_10%,transparent)]" href="{{url}}"{{#if accent_color}} style="--color-accent: {{accent_color}}; border-left-color: var(--color-accent);"{{/if}}>
<h2 class="text-sm font-medium leading-tight flex items-center justify-between gap-1">{{name}}</h2>
<i class="icon icon-arrow-up-right size-4 stroke-2 absolute top-4 right-4" role="presentation">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M17 7l-10 10"></path>
<path d="M8 7l9 0l0 9"></path>
</svg>
</i>
{{#if description}}
<p class="flex-1 text-[0.8rem] text-typ-tone leading-tight line-clamp-3 text-overflow-ellipsis">{{description}}</p>
{{else}}
<p class="flex-1 text-[0.8rem] text-typ-tone leading-tight line-clamp-3 text-overflow-ellipsis">Posts filed under {{name}}.</p>
{{/if}}
<span class="text-[0.8rem] text-typ-tone leading-tight font-medium">{{plural count.posts empty="0 posts" singular="% post" plural="% posts"}}</span>
</a>
</li>
{{/foreach}}
</ul>
{{/get}}
</section>
</section>
</main>

View File

@@ -19,8 +19,5 @@ module.exports = {
"bgr-tone": "var(--surface-muted)"
}
}
},
corePlugins: {
preflight: false
}
}